mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-09-14 02:08:59 +00:00
✨ feat: rewrite in Go
This commit is contained in:
+218
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTSFRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f.tsf")
|
||||
original := &FramesContainer{
|
||||
Frames: [][]byte{{0, 1, 255}, {10, 20, 30}},
|
||||
FPS: 29.97,
|
||||
}
|
||||
if err := writeTSF(path, original); err != nil {
|
||||
t.Fatalf("writeTSF: %v", err)
|
||||
}
|
||||
fc, err := loadTSF(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadTSF: %v", err)
|
||||
}
|
||||
if fc.FPS != 29.97 {
|
||||
t.Errorf("fps = %v", fc.FPS)
|
||||
}
|
||||
if len(fc.Frames) != 2 {
|
||||
t.Fatalf("frames = %d", len(fc.Frames))
|
||||
}
|
||||
if string(fc.Frames[0]) != string([]byte{0, 1, 255}) {
|
||||
t.Errorf("frame0 = %v", fc.Frames[0])
|
||||
}
|
||||
if string(fc.Frames[1]) != string([]byte{10, 20, 30}) {
|
||||
t.Errorf("frame1 = %v", fc.Frames[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTSFInvalid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.tsf")
|
||||
|
||||
os.WriteFile(path, []byte("not a tsf file"), 0o644)
|
||||
if _, err := loadTSF(path); err == nil {
|
||||
t.Error("expected error for garbage input")
|
||||
}
|
||||
|
||||
// Valid container but no frames.
|
||||
writeTSF(path, &FramesContainer{FPS: 30})
|
||||
if _, err := loadTSF(path); err == nil {
|
||||
t.Error("expected error for empty frames")
|
||||
}
|
||||
|
||||
// Valid container but fps <= 0.
|
||||
writeTSF(path, &FramesContainer{Frames: [][]byte{{1}}, FPS: 0})
|
||||
if _, err := loadTSF(path); err == nil {
|
||||
t.Error("expected error for fps<=0")
|
||||
}
|
||||
|
||||
// Truncated payload.
|
||||
writeTSF(path, &FramesContainer{Frames: [][]byte{{1, 2, 3, 4}}, FPS: 30})
|
||||
raw, _ := os.ReadFile(path)
|
||||
os.WriteFile(path, raw[:len(raw)-2], 0o644)
|
||||
if _, err := loadTSF(path); err == nil {
|
||||
t.Error("expected error for truncated file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaults(t *testing.T) {
|
||||
t.Setenv("HOST", "")
|
||||
t.Setenv("PORT", "")
|
||||
t.Setenv("PLAYBACK_MODE", "")
|
||||
t.Setenv("LOGIN_DELAY", "")
|
||||
cfg := loadConfig()
|
||||
if cfg.Host != "0.0.0.0" {
|
||||
t.Errorf("host = %q", cfg.Host)
|
||||
}
|
||||
if cfg.Port != 22 {
|
||||
t.Errorf("port = %d", cfg.Port)
|
||||
}
|
||||
if cfg.PlaybackMode != PlaybackLoop {
|
||||
t.Errorf("playbackMode = %q", cfg.PlaybackMode)
|
||||
}
|
||||
if cfg.Charset != "detailed" {
|
||||
t.Errorf("charset = %q", cfg.Charset)
|
||||
}
|
||||
if cfg.LoginDelay != 1500*time.Millisecond {
|
||||
t.Errorf("loginDelay = %v", cfg.LoginDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigClamping(t *testing.T) {
|
||||
t.Setenv("PORT", "999999")
|
||||
t.Setenv("BRIGHTNESS_THRESHOLD", "-5")
|
||||
cfg := loadConfig()
|
||||
if cfg.Port != 65535 {
|
||||
t.Errorf("port clamp = %d", cfg.Port)
|
||||
}
|
||||
if cfg.BrightnessThreshold != 0 {
|
||||
t.Errorf("brightness clamp = %d", cfg.BrightnessThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigInvalidFallsBack(t *testing.T) {
|
||||
t.Setenv("PORT", "not-a-number")
|
||||
t.Setenv("INVERT", "yes-please")
|
||||
t.Setenv("PLAYBACK_MODE", "shuffle")
|
||||
cfg := loadConfig()
|
||||
if cfg.Port != 22 {
|
||||
t.Errorf("port = %d, want default 22", cfg.Port)
|
||||
}
|
||||
if cfg.Invert {
|
||||
t.Error("invert should fall back to false")
|
||||
}
|
||||
if cfg.PlaybackMode != PlaybackLoop {
|
||||
t.Errorf("playbackMode = %q, want default loop", cfg.PlaybackMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvDurationMs(t *testing.T) {
|
||||
t.Setenv("D", "250")
|
||||
if got := envDurationMs("D", time.Second); got != 250*time.Millisecond {
|
||||
t.Errorf("250 = %v, want 250ms", got)
|
||||
}
|
||||
t.Setenv("D", "-10")
|
||||
if got := envDurationMs("D", time.Second); got != 0 {
|
||||
t.Errorf("negative = %v, want 0", got)
|
||||
}
|
||||
t.Setenv("D", "banana")
|
||||
if got := envDurationMs("D", time.Second); got != time.Second {
|
||||
t.Errorf("invalid = %v, want fallback 1s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackModeRandom(t *testing.T) {
|
||||
t.Setenv("PLAYBACK_MODE", "RaNdOm")
|
||||
if loadConfig().PlaybackMode != PlaybackRandom {
|
||||
t.Error("expected random")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCharset(t *testing.T) {
|
||||
if got := resolveCharset("blocks"); got != " ░▒▓█" {
|
||||
t.Errorf("blocks preset = %q", got)
|
||||
}
|
||||
if got := resolveCharset("XYZ"); got != "XYZ" {
|
||||
t.Errorf("custom ramp = %q", got)
|
||||
}
|
||||
if got := resolveCharset(""); !strings.HasPrefix(got, " .") {
|
||||
t.Errorf("default = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameToAscii(t *testing.T) {
|
||||
// Below threshold -> first ramp char; full brightness -> last.
|
||||
opts := asciiOptions{brightnessThreshold: 40, charset: "standard"}
|
||||
ramp := []rune(resolveCharset("standard"))
|
||||
out := []rune(frameToAscii([]byte{0, 255}, opts))
|
||||
if out[0] != ramp[0] {
|
||||
t.Errorf("dark px = %q, want %q", out[0], ramp[0])
|
||||
}
|
||||
if out[1] != ramp[len(ramp)-1] {
|
||||
t.Errorf("bright px = %q, want %q", out[1], ramp[len(ramp)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameToAsciiInvert(t *testing.T) {
|
||||
opts := asciiOptions{brightnessThreshold: 40, charset: "standard", invert: true}
|
||||
ramp := []rune(resolveCharset("standard"))
|
||||
out := []rune(frameToAscii([]byte{255}, opts))
|
||||
if out[0] != ramp[0] {
|
||||
t.Errorf("inverted bright = %q, want %q", out[0], ramp[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionTracker(t *testing.T) {
|
||||
tr := newConnectionTracker()
|
||||
tr.increment("1.2.3.4")
|
||||
tr.increment("1.2.3.4")
|
||||
if !tr.hasReachedLimits("1.2.3.4", 2, 100) {
|
||||
t.Error("expected per-ip limit reached")
|
||||
}
|
||||
tr.decrement("1.2.3.4")
|
||||
tr.decrement("1.2.3.4")
|
||||
if tr.totalCount() != 0 {
|
||||
t.Errorf("total = %d", tr.totalCount())
|
||||
}
|
||||
if tr.hasReachedLimits("1.2.3.4", 2, 100) {
|
||||
t.Error("should be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampDimension(t *testing.T) {
|
||||
if clampDimension(0, 100) != 1 {
|
||||
t.Error("floor")
|
||||
}
|
||||
if clampDimension(500, 100) != 100 {
|
||||
t.Error("ceil")
|
||||
}
|
||||
if clampDimension(50, 100) != 50 {
|
||||
t.Error("passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDimsPtyReq(t *testing.T) {
|
||||
// "xterm" + cols=100 rows=40 + widthpx + heightpx
|
||||
payload := []byte{
|
||||
0, 0, 0, 5, 'x', 't', 'e', 'r', 'm',
|
||||
0, 0, 0, 100,
|
||||
0, 0, 0, 40,
|
||||
0, 0, 0, 0,
|
||||
0, 0, 0, 0,
|
||||
}
|
||||
cols, rows, ok := parseDims(payload)
|
||||
if !ok || cols != 100 || rows != 40 {
|
||||
t.Errorf("parseDims = %d,%d,%v", cols, rows, ok)
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PlaybackMode string
|
||||
|
||||
const (
|
||||
PlaybackLoop PlaybackMode = "loop"
|
||||
PlaybackRandom PlaybackMode = "random"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
MaxLoop int
|
||||
PlaybackMode PlaybackMode
|
||||
AllowUserControl bool
|
||||
SwitchDebounce time.Duration
|
||||
LoginDelay time.Duration
|
||||
MaxConnections int
|
||||
MaxTotalConnections int
|
||||
MaxAuthAttempts int
|
||||
HandshakeTimeout time.Duration
|
||||
MaxDimension int
|
||||
FrameResolution int
|
||||
BrightnessThreshold int
|
||||
Charset string
|
||||
Invert bool
|
||||
LogCredentials bool
|
||||
}
|
||||
|
||||
func warnInvalid(name, value string, fallback any) {
|
||||
logWarn(fmt.Sprintf("Invalid %s=%q, using default %v", name, sanitize(value), fallback))
|
||||
}
|
||||
|
||||
func envString(name, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envInt(name string, fallback, min, max int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
warnInvalid(name, raw, fallback)
|
||||
return fallback
|
||||
}
|
||||
if parsed < min {
|
||||
return min
|
||||
}
|
||||
if parsed > max {
|
||||
return max
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envBool(name string, fallback bool) bool {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseBool(strings.ToLower(raw))
|
||||
if err != nil {
|
||||
warnInvalid(name, raw, fallback)
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envDurationMs(name string, fallback time.Duration) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
ms, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
warnInvalid(name, raw, fallback)
|
||||
return fallback
|
||||
}
|
||||
if ms < 0 {
|
||||
ms = 0
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
|
||||
func envPlaybackMode(name string, fallback PlaybackMode) PlaybackMode {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
switch PlaybackMode(strings.ToLower(raw)) {
|
||||
case PlaybackLoop:
|
||||
return PlaybackLoop
|
||||
case PlaybackRandom:
|
||||
return PlaybackRandom
|
||||
}
|
||||
warnInvalid(name, raw, fallback)
|
||||
return fallback
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
const maxInt = int(^uint(0) >> 1)
|
||||
return Config{
|
||||
Host: envString("HOST", "0.0.0.0"),
|
||||
Port: envInt("PORT", 22, 1, 65535),
|
||||
MaxLoop: envInt("MAX_LOOP", 5, 0, maxInt),
|
||||
PlaybackMode: envPlaybackMode("PLAYBACK_MODE", PlaybackLoop),
|
||||
AllowUserControl: envBool("ALLOW_USER_CONTROL", true),
|
||||
SwitchDebounce: envDurationMs("SWITCH_DEBOUNCE_MS", 120*time.Millisecond),
|
||||
LoginDelay: envDurationMs("LOGIN_DELAY", 1500*time.Millisecond),
|
||||
MaxConnections: envInt("MAX_CONNECTIONS", 10, 1, maxInt),
|
||||
MaxTotalConnections: envInt("MAX_TOTAL_CONNECTIONS", 1000, 1, maxInt),
|
||||
MaxAuthAttempts: envInt("MAX_AUTH_ATTEMPTS", 6, 1, maxInt),
|
||||
HandshakeTimeout: envDurationMs("HANDSHAKE_TIMEOUT", 10*time.Second),
|
||||
MaxDimension: envInt("MAX_DIMENSION", 512, 1, 4096),
|
||||
FrameResolution: envInt("FRAME_RESOLUTION", 360, 16, 1080),
|
||||
BrightnessThreshold: envInt("BRIGHTNESS_THRESHOLD", 40, 0, 100),
|
||||
Charset: envString("CHARSET", "detailed"),
|
||||
Invert: envBool("INVERT", false),
|
||||
LogCredentials: envBool("LOG_CREDENTIALS", false),
|
||||
}
|
||||
}
|
||||
|
||||
func loadOptionalTextFile(filePath string) (string, bool) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(data), true
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { loadConfig } from "./config";
|
||||
|
||||
describe("loadConfig", () => {
|
||||
test("returns defaults when env vars are unset", () => {
|
||||
expect(loadConfig({})).toEqual({
|
||||
host: "0.0.0.0",
|
||||
port: 22,
|
||||
maxLoop: 5,
|
||||
playbackMode: "loop",
|
||||
allowUserControl: true,
|
||||
switchDebounceMs: 120,
|
||||
loginDelay: 1500,
|
||||
maxConnections: 10,
|
||||
maxTotalConnections: 1000,
|
||||
maxAuthAttempts: 6,
|
||||
handshakeTimeout: 10000,
|
||||
maxDimension: 512,
|
||||
frameResolution: 360,
|
||||
brightnessThreshold: 40,
|
||||
charset: "detailed",
|
||||
invert: false,
|
||||
logCredentials: false,
|
||||
videoPath: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("overrides defaults from env vars", () => {
|
||||
expect(
|
||||
loadConfig({
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "2222",
|
||||
MAX_LOOP: "3",
|
||||
PLAYBACK_MODE: "random",
|
||||
ALLOW_USER_CONTROL: "false",
|
||||
SWITCH_DEBOUNCE_MS: "200",
|
||||
LOGIN_DELAY: "500",
|
||||
MAX_CONNECTIONS: "20",
|
||||
MAX_TOTAL_CONNECTIONS: "50",
|
||||
MAX_AUTH_ATTEMPTS: "3",
|
||||
HANDSHAKE_TIMEOUT: "5000",
|
||||
MAX_DIMENSION: "200",
|
||||
FRAME_RESOLUTION: "480",
|
||||
BRIGHTNESS_THRESHOLD: "60",
|
||||
CHARSET: "blocks",
|
||||
INVERT: "true",
|
||||
LOG_CREDENTIALS: "true",
|
||||
VIDEO_PATH: "/videos/clip.mkv",
|
||||
})
|
||||
).toEqual({
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
maxLoop: 3,
|
||||
playbackMode: "random",
|
||||
allowUserControl: false,
|
||||
switchDebounceMs: 200,
|
||||
loginDelay: 500,
|
||||
maxConnections: 20,
|
||||
maxTotalConnections: 50,
|
||||
maxAuthAttempts: 3,
|
||||
handshakeTimeout: 5000,
|
||||
maxDimension: 200,
|
||||
frameResolution: 480,
|
||||
brightnessThreshold: 60,
|
||||
charset: "blocks",
|
||||
invert: true,
|
||||
logCredentials: true,
|
||||
videoPath: "/videos/clip.mkv",
|
||||
});
|
||||
});
|
||||
|
||||
test("boolean env vars are true only for the exact string 'true'", () => {
|
||||
expect(loadConfig({ LOG_CREDENTIALS: "1" }).logCredentials).toBe(false);
|
||||
expect(loadConfig({ INVERT: "yes" }).invert).toBe(false);
|
||||
expect(loadConfig({ INVERT: "true" }).invert).toBe(true);
|
||||
});
|
||||
|
||||
test("falls back to defaults for non-numeric and out-of-range values", () => {
|
||||
expect(loadConfig({ PORT: "not-a-number" }).port).toBe(22);
|
||||
expect(loadConfig({ PORT: "99999" }).port).toBe(65535);
|
||||
expect(loadConfig({ MAX_DIMENSION: "0" }).maxDimension).toBe(1);
|
||||
expect(
|
||||
loadConfig({ BRIGHTNESS_THRESHOLD: "999" }).brightnessThreshold
|
||||
).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import fs from "fs";
|
||||
|
||||
export interface Config {
|
||||
host: string;
|
||||
port: number;
|
||||
maxLoop: number;
|
||||
playbackMode: "loop" | "random";
|
||||
allowUserControl: boolean;
|
||||
switchDebounceMs: number;
|
||||
loginDelay: number;
|
||||
maxConnections: number;
|
||||
maxTotalConnections: number;
|
||||
maxAuthAttempts: number;
|
||||
handshakeTimeout: number;
|
||||
maxDimension: number;
|
||||
frameResolution: number;
|
||||
brightnessThreshold: number;
|
||||
charset: string;
|
||||
invert: boolean;
|
||||
logCredentials: boolean;
|
||||
videoPath?: string;
|
||||
}
|
||||
|
||||
function parseIntEnv(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
{ min, max }: { min?: number; max?: number } = {}
|
||||
): number {
|
||||
if (value === undefined || value.trim() === "") return fallback;
|
||||
const parsed = parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
let result = parsed;
|
||||
if (typeof min === "number") result = Math.max(min, result);
|
||||
if (typeof max === "number") result = Math.min(max, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseBoolEnv(value: string | undefined): boolean {
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
const videoPath = env.VIDEO_PATH?.trim();
|
||||
|
||||
return {
|
||||
host: env.HOST ?? "0.0.0.0",
|
||||
port: parseIntEnv(env.PORT, 22, { min: 1, max: 65535 }),
|
||||
maxLoop: parseIntEnv(env.MAX_LOOP, 5, { min: 1 }),
|
||||
playbackMode:
|
||||
env.PLAYBACK_MODE?.trim().toLowerCase() === "random"
|
||||
? "random"
|
||||
: "loop",
|
||||
allowUserControl: env.ALLOW_USER_CONTROL !== "false",
|
||||
switchDebounceMs: parseIntEnv(env.SWITCH_DEBOUNCE_MS, 120, { min: 0 }),
|
||||
loginDelay: parseIntEnv(env.LOGIN_DELAY, 1500, { min: 0 }),
|
||||
maxConnections: parseIntEnv(env.MAX_CONNECTIONS, 10, { min: 1 }),
|
||||
maxTotalConnections: parseIntEnv(env.MAX_TOTAL_CONNECTIONS, 1000, {
|
||||
min: 1,
|
||||
}),
|
||||
maxAuthAttempts: parseIntEnv(env.MAX_AUTH_ATTEMPTS, 6, { min: 1 }),
|
||||
handshakeTimeout: parseIntEnv(env.HANDSHAKE_TIMEOUT, 10000, { min: 0 }),
|
||||
maxDimension: parseIntEnv(env.MAX_DIMENSION, 512, {
|
||||
min: 1,
|
||||
max: 4096,
|
||||
}),
|
||||
frameResolution: parseIntEnv(env.FRAME_RESOLUTION, 360, {
|
||||
min: 16,
|
||||
max: 1080,
|
||||
}),
|
||||
brightnessThreshold: parseIntEnv(env.BRIGHTNESS_THRESHOLD, 40, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
}),
|
||||
charset: env.CHARSET?.trim() || "detailed",
|
||||
invert: parseBoolEnv(env.INVERT),
|
||||
logCredentials: parseBoolEnv(env.LOG_CREDENTIALS),
|
||||
videoPath: videoPath ? videoPath : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadOptionalTextFile(filePath: string): string | undefined {
|
||||
if (!fs.existsSync(filePath)) return undefined;
|
||||
return fs.readFileSync(filePath).toString();
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { parentPort, workerData } from "worker_threads";
|
||||
import fs from "fs";
|
||||
|
||||
function loadPacked(filename: string): {
|
||||
fps: number;
|
||||
lengths: Uint32Array;
|
||||
packed: Uint8Array;
|
||||
} {
|
||||
const parsed = JSON.parse(fs.readFileSync(filename, "utf8"));
|
||||
|
||||
if (
|
||||
!parsed ||
|
||||
!Array.isArray(parsed.frames) ||
|
||||
parsed.frames.length === 0 ||
|
||||
typeof parsed.fps !== "number" ||
|
||||
!Number.isFinite(parsed.fps) ||
|
||||
parsed.fps <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid frames file "${filename}": expected non-empty frames[] and a positive fps`
|
||||
);
|
||||
}
|
||||
|
||||
const raw = parsed.frames as unknown[];
|
||||
const count = raw.length;
|
||||
const lengths = new Uint32Array(count);
|
||||
const bufs: Buffer[] = new Array(count);
|
||||
let total = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const b = Buffer.from(raw[i] as Uint8Array);
|
||||
bufs[i] = b;
|
||||
lengths[i] = b.length;
|
||||
total += b.length;
|
||||
}
|
||||
|
||||
const packed = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
packed.set(bufs[i], off);
|
||||
off += lengths[i];
|
||||
}
|
||||
|
||||
return { fps: parsed.fps, lengths, packed };
|
||||
}
|
||||
|
||||
const { filename } = workerData as { filename: string };
|
||||
const { fps, lengths, packed } = loadPacked(filename);
|
||||
parentPort!.postMessage(
|
||||
{ fps, lengths: lengths.buffer, packed: packed.buffer },
|
||||
[lengths.buffer, packed.buffer] as never
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
)
|
||||
|
||||
// .tsf container, little-endian: "TSFR" | version uint16 | fps float64 |
|
||||
// count uint32 | count × (length uint32, JPEG bytes).
|
||||
const (
|
||||
tsfMagic = "TSFR"
|
||||
tsfVersion = 1
|
||||
)
|
||||
|
||||
func writeTSF(output string, data *FramesContainer) error {
|
||||
f, err := os.Create(output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := bufio.NewWriterSize(f, 1<<20)
|
||||
if _, err := w.WriteString(tsfMagic); err != nil {
|
||||
return err
|
||||
}
|
||||
var hdr [14]byte
|
||||
binary.LittleEndian.PutUint16(hdr[0:], tsfVersion)
|
||||
binary.LittleEndian.PutUint64(hdr[2:], math.Float64bits(data.FPS))
|
||||
binary.LittleEndian.PutUint32(hdr[10:], uint32(len(data.Frames)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var lenBuf [4]byte
|
||||
for _, frame := range data.Frames {
|
||||
binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(frame)))
|
||||
if _, err := w.Write(lenBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(frame); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func loadTSF(filename string) (*FramesContainer, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invalid := func() error {
|
||||
return fmt.Errorf("invalid frames file %q: corrupt .tsf container", filename)
|
||||
}
|
||||
|
||||
if len(raw) < 18 || string(raw[:4]) != tsfMagic {
|
||||
return nil, invalid()
|
||||
}
|
||||
version := binary.LittleEndian.Uint16(raw[4:])
|
||||
if version != tsfVersion {
|
||||
return nil, fmt.Errorf("unsupported .tsf version %d in %q", version, filename)
|
||||
}
|
||||
fps := math.Float64frombits(binary.LittleEndian.Uint64(raw[6:]))
|
||||
count := binary.LittleEndian.Uint32(raw[14:])
|
||||
|
||||
frames := make([][]byte, 0, count)
|
||||
off := 18
|
||||
for range count {
|
||||
if off+4 > len(raw) {
|
||||
return nil, invalid()
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint32(raw[off:]))
|
||||
off += 4
|
||||
if off+n > len(raw) {
|
||||
return nil, invalid()
|
||||
}
|
||||
frames = append(frames, raw[off:off+n])
|
||||
off += n
|
||||
}
|
||||
|
||||
if len(frames) == 0 || fps <= 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid frames file %q: expected non-empty frames and a positive fps",
|
||||
filename,
|
||||
)
|
||||
}
|
||||
return &FramesContainer{Frames: frames, FPS: fps}, nil
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type FramesContainer struct {
|
||||
Frames [][]byte
|
||||
FPS float64
|
||||
Name string
|
||||
}
|
||||
|
||||
var charsetPresets = map[string]string{
|
||||
"detailed": " .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
|
||||
"standard": " .:-=+*#%@",
|
||||
"simple": " .:oO#@",
|
||||
"blocks": " ░▒▓█",
|
||||
}
|
||||
|
||||
func resolveCharset(charset string) string {
|
||||
if charset == "" {
|
||||
return charsetPresets["detailed"]
|
||||
}
|
||||
if preset, ok := charsetPresets[strings.ToLower(charset)]; ok {
|
||||
return preset
|
||||
}
|
||||
return charset
|
||||
}
|
||||
|
||||
func resizeFrame(frame []byte, width, height int, keepAspectRatio bool) ([]byte, error) {
|
||||
src, err := jpeg.Decode(bytes.NewReader(frame))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst := image.NewGray(image.Rect(0, 0, width, height))
|
||||
if keepAspectRatio {
|
||||
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.Gray{0}), image.Point{}, draw.Src)
|
||||
sb := src.Bounds()
|
||||
sw, sh := sb.Dx(), sb.Dy()
|
||||
scale := min(float64(width)/float64(sw), float64(height)/float64(sh))
|
||||
tw := max(1, int(float64(sw)*scale))
|
||||
th := max(1, int(float64(sh)*scale))
|
||||
x0 := (width - tw) / 2
|
||||
y0 := (height - th) / 2
|
||||
draw.ApproxBiLinear.Scale(dst, image.Rect(x0, y0, x0+tw, y0+th), src, sb, draw.Src, nil)
|
||||
} else {
|
||||
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
|
||||
}
|
||||
return dst.Pix, nil
|
||||
}
|
||||
|
||||
type asciiOptions struct {
|
||||
brightnessThreshold int
|
||||
charset string
|
||||
invert bool
|
||||
}
|
||||
|
||||
func frameToAscii(pixels []byte, options asciiOptions) string {
|
||||
ramp := []rune(resolveCharset(options.charset))
|
||||
total := len(ramp)
|
||||
var b strings.Builder
|
||||
for _, p := range pixels {
|
||||
brightness := int(p) * 100 / 255
|
||||
var index int
|
||||
if brightness < options.brightnessThreshold {
|
||||
index = 0
|
||||
} else {
|
||||
index = brightness * total / 100
|
||||
if index > total-1 {
|
||||
index = total - 1
|
||||
}
|
||||
}
|
||||
if options.invert {
|
||||
index = total - 1 - index
|
||||
}
|
||||
b.WriteRune(ramp[index])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type FrameRenderer struct {
|
||||
frames [][]byte
|
||||
options asciiOptions
|
||||
maxEntries int
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]*list.Element
|
||||
order *list.List
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
key string
|
||||
ascii string
|
||||
}
|
||||
|
||||
func newFrameRenderer(frames [][]byte, options asciiOptions) *FrameRenderer {
|
||||
return &FrameRenderer{
|
||||
frames: frames,
|
||||
options: options,
|
||||
maxEntries: 4096,
|
||||
cache: make(map[string]*list.Element),
|
||||
order: list.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FrameRenderer) render(index, width, height int, keepAspectRatio bool) (string, error) {
|
||||
key := fmt.Sprintf("%d:%dx%d:%t", index, width, height, keepAspectRatio)
|
||||
|
||||
r.mu.Lock()
|
||||
if el, ok := r.cache[key]; ok {
|
||||
r.order.MoveToBack(el)
|
||||
ascii := el.Value.(*cacheEntry).ascii
|
||||
r.mu.Unlock()
|
||||
return ascii, nil
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
pixels, err := resizeFrame(r.frames[index], width, height, keepAspectRatio)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ascii := frameToAscii(pixels, r.options)
|
||||
|
||||
r.mu.Lock()
|
||||
if _, ok := r.cache[key]; !ok {
|
||||
r.cache[key] = r.order.PushBack(&cacheEntry{key, ascii})
|
||||
if r.order.Len() > r.maxEntries {
|
||||
oldest := r.order.Front()
|
||||
r.order.Remove(oldest)
|
||||
delete(r.cache, oldest.Value.(*cacheEntry).key)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return ascii, nil
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { frameToAscii, resolveCharset, CHARSET_PRESETS } from "./frames";
|
||||
|
||||
describe("frameToAscii", () => {
|
||||
test("returns empty string for an empty buffer", () => {
|
||||
expect(frameToAscii(Buffer.from([]), { brightnessThreshold: 40 })).toBe(
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("maps a zero-brightness pixel to the first (blankest) character", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([0]), { brightnessThreshold: 40 })
|
||||
).toBe(" ");
|
||||
});
|
||||
|
||||
test("maps a below-threshold pixel to the first character", () => {
|
||||
// brightness = floor((50/255)*100) = 19, below threshold 40
|
||||
expect(
|
||||
frameToAscii(Buffer.from([50]), { brightnessThreshold: 40 })
|
||||
).toBe(" ");
|
||||
});
|
||||
|
||||
test("maps a mid-range pixel to a mid-range character", () => {
|
||||
// brightness = floor((128/255)*100) = 50
|
||||
expect(
|
||||
frameToAscii(Buffer.from([128]), { brightnessThreshold: 40 })
|
||||
).toBe("n");
|
||||
});
|
||||
|
||||
test("maps a max-brightness pixel to the last (densest) character", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), { brightnessThreshold: 40 })
|
||||
).toBe("$");
|
||||
});
|
||||
|
||||
test("maps multiple pixels in sequence, preserving order", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([0, 128, 255]), {
|
||||
brightnessThreshold: 40,
|
||||
})
|
||||
).toBe(" n$");
|
||||
});
|
||||
|
||||
test("uses a named charset preset", () => {
|
||||
// standard ramp " .:-=+*#%@": max brightness -> last char "@"
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), {
|
||||
brightnessThreshold: 40,
|
||||
charset: "standard",
|
||||
})
|
||||
).toBe("@");
|
||||
});
|
||||
|
||||
test("accepts a custom literal ramp", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([0, 255]), {
|
||||
brightnessThreshold: 0,
|
||||
charset: "AB",
|
||||
})
|
||||
).toBe("AB");
|
||||
});
|
||||
|
||||
test("invert reverses the ramp", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), {
|
||||
brightnessThreshold: 40,
|
||||
charset: "AB",
|
||||
invert: true,
|
||||
})
|
||||
).toBe("A");
|
||||
});
|
||||
|
||||
test("supports multi-byte (Unicode) ramps", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), {
|
||||
brightnessThreshold: 40,
|
||||
charset: "blocks",
|
||||
})
|
||||
).toBe("█");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCharset", () => {
|
||||
test("returns the detailed preset by default", () => {
|
||||
expect(resolveCharset()).toBe(CHARSET_PRESETS.detailed);
|
||||
});
|
||||
|
||||
test("resolves preset names case-insensitively", () => {
|
||||
expect(resolveCharset("BLOCKS")).toBe(CHARSET_PRESETS.blocks);
|
||||
});
|
||||
|
||||
test("passes through a custom ramp", () => {
|
||||
expect(resolveCharset(" .#@")).toBe(" .#@");
|
||||
});
|
||||
});
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Worker } from "worker_threads";
|
||||
import sharp from "sharp";
|
||||
|
||||
const WORKER_PATH = path.join(__dirname, "frameLoader.worker.ts");
|
||||
|
||||
export interface FramesContainer {
|
||||
frames: Buffer[];
|
||||
fps: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const CHARSET_PRESETS: Record<string, string> = {
|
||||
detailed:
|
||||
" .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
|
||||
standard: " .:-=+*#%@",
|
||||
simple: " .:oO#@",
|
||||
blocks: " ░▒▓█",
|
||||
};
|
||||
|
||||
const DEFAULT_CHARSET = CHARSET_PRESETS.detailed;
|
||||
|
||||
export interface AsciiOptions {
|
||||
brightnessThreshold?: number;
|
||||
charset?: string;
|
||||
invert?: boolean;
|
||||
}
|
||||
|
||||
export function resolveCharset(charset?: string): string {
|
||||
if (!charset) return DEFAULT_CHARSET;
|
||||
return CHARSET_PRESETS[charset.toLowerCase()] ?? charset;
|
||||
}
|
||||
|
||||
export function loadFrames(filename: string): FramesContainer {
|
||||
const parsed = JSON.parse(fs.readFileSync(filename).toString());
|
||||
|
||||
if (
|
||||
!parsed ||
|
||||
!Array.isArray(parsed.frames) ||
|
||||
parsed.frames.length === 0 ||
|
||||
typeof parsed.fps !== "number" ||
|
||||
!Number.isFinite(parsed.fps) ||
|
||||
parsed.fps <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid frames file "${filename}": expected non-empty frames[] and a positive fps`
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as FramesContainer;
|
||||
}
|
||||
|
||||
export function loadFramesAsync(filename: string): Promise<FramesContainer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(WORKER_PATH, { workerData: { filename } });
|
||||
worker.once(
|
||||
"message",
|
||||
(msg: {
|
||||
fps: number;
|
||||
lengths: ArrayBuffer;
|
||||
packed: ArrayBuffer;
|
||||
}) => {
|
||||
const lengths = new Uint32Array(msg.lengths);
|
||||
const frames: Buffer[] = new Array(lengths.length);
|
||||
let off = 0;
|
||||
for (let i = 0; i < lengths.length; i++) {
|
||||
frames[i] = Buffer.from(msg.packed, off, lengths[i]);
|
||||
off += lengths[i];
|
||||
}
|
||||
resolve({ frames, fps: msg.fps });
|
||||
worker.terminate();
|
||||
}
|
||||
);
|
||||
worker.once("error", (err) => {
|
||||
worker.terminate();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resizeFrame(
|
||||
frame: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
keepAspectRatio = false
|
||||
): Promise<Buffer> {
|
||||
return sharp(frame)
|
||||
.resize(width, height, {
|
||||
fit: keepAspectRatio ? "contain" : "fill",
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||||
})
|
||||
.grayscale()
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export class FrameRenderer {
|
||||
private cache = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly frames: Buffer[],
|
||||
private readonly options: AsciiOptions,
|
||||
private readonly maxEntries = 4096
|
||||
) {}
|
||||
|
||||
async render(
|
||||
index: number,
|
||||
width: number,
|
||||
height: number,
|
||||
keepAspectRatio: boolean
|
||||
): Promise<string> {
|
||||
const key = `${index}:${width}x${height}:${keepAspectRatio ? 1 : 0}`;
|
||||
const cached = this.cache.get(key);
|
||||
if (cached !== undefined) {
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const source = Buffer.isBuffer(this.frames[index])
|
||||
? this.frames[index]
|
||||
: Buffer.from(this.frames[index] as unknown as Uint8Array);
|
||||
const resized = await resizeFrame(
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
keepAspectRatio
|
||||
);
|
||||
const ascii = frameToAscii(resized, this.options);
|
||||
|
||||
this.cache.set(key, ascii);
|
||||
if (this.cache.size > this.maxEntries) {
|
||||
const oldest = this.cache.keys().next().value;
|
||||
if (oldest !== undefined) this.cache.delete(oldest);
|
||||
}
|
||||
return ascii;
|
||||
}
|
||||
}
|
||||
|
||||
export function frameToAscii(
|
||||
pixels: Buffer,
|
||||
options: AsciiOptions = {}
|
||||
): string {
|
||||
const { brightnessThreshold = 40, charset, invert = false } = options;
|
||||
|
||||
const ramp = [...resolveCharset(charset)];
|
||||
const total = ramp.length;
|
||||
let result = "";
|
||||
|
||||
for (let i = 0; i < pixels.length; i++) {
|
||||
const brightness = Math.floor((pixels[i] / 255) * 100);
|
||||
|
||||
let index: number;
|
||||
if (brightness < brightnessThreshold) {
|
||||
index = 0;
|
||||
} else {
|
||||
index = Math.min(Math.floor((brightness / 100) * total), total - 1);
|
||||
}
|
||||
|
||||
if (invert) index = total - 1 - index;
|
||||
result += ramp[index];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import sshpk from "sshpk";
|
||||
|
||||
function generateAndSave(keyPath: string, type: "rsa" | "ed25519"): void {
|
||||
console.log(`Generating ${type} host key...`);
|
||||
|
||||
const { privateKey } =
|
||||
type === "rsa"
|
||||
? crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
})
|
||||
: crypto.generateKeyPairSync("ed25519", {
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
});
|
||||
|
||||
const parsed = sshpk.parsePrivateKey(privateKey, "pem");
|
||||
fs.writeFileSync(keyPath, parsed.toString("openssh"), { mode: 0o600 });
|
||||
fs.chmodSync(keyPath, 0o600);
|
||||
}
|
||||
|
||||
export function ensureHostKeys(configDir: string): Buffer[] {
|
||||
const keys: Array<{ file: string; type: "rsa" | "ed25519" }> = [
|
||||
{ file: "id_rsa", type: "rsa" },
|
||||
{ file: "id_ed25519", type: "ed25519" },
|
||||
];
|
||||
|
||||
return keys.map(({ file, type }) => {
|
||||
const keyPath = path.join(configDir, file);
|
||||
if (!fs.existsSync(keyPath)) {
|
||||
generateAndSave(keyPath, type);
|
||||
}
|
||||
return fs.readFileSync(keyPath);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func generateAndSave(keyPath, keyType string) error {
|
||||
fmt.Printf("Generating %s host key...\n", keyType)
|
||||
|
||||
var key any
|
||||
var err error
|
||||
if keyType == "rsa" {
|
||||
key, err = rsa.GenerateKey(rand.Reader, 4096)
|
||||
} else {
|
||||
_, key, err = ed25519.GenerateKey(rand.Reader)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block, err := ssh.MarshalPrivateKey(key, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(keyPath, pem.EncodeToMemory(block), 0o600)
|
||||
}
|
||||
|
||||
func ensureHostKeys(configDir string) ([]ssh.Signer, error) {
|
||||
keys := []struct{ file, keyType string }{
|
||||
{"id_rsa", "rsa"},
|
||||
{"id_ed25519", "ed25519"},
|
||||
}
|
||||
|
||||
signers := make([]ssh.Signer, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
keyPath := filepath.Join(configDir, k.file)
|
||||
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||
if err := generateAndSave(keyPath, k.keyType); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate %s host key: %w", k.keyType, err)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse host key %q: %w", keyPath, err)
|
||||
}
|
||||
signers = append(signers, signer)
|
||||
}
|
||||
return signers, nil
|
||||
}
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { loadConfig, loadOptionalTextFile, Config } from "./config";
|
||||
import { ensureHostKeys } from "./hostKeys";
|
||||
import { loadFramesAsync, FramesContainer } from "./frames";
|
||||
import { createServer } from "./server";
|
||||
import videoProcessor from "./videoProcessor";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const FRAMES_DIR = path.join(process.cwd(), "frames");
|
||||
|
||||
function fail(message: string): never {
|
||||
logger.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface Args {
|
||||
generate: boolean;
|
||||
video?: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = { generate: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--generate" || arg === "-g") args.generate = true;
|
||||
else if (arg === "--video" || arg === "-v") args.video = argv[++i];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function resolveVideoPath(explicitPath?: string): string | undefined {
|
||||
if (explicitPath) {
|
||||
const explicit = path.resolve(explicitPath);
|
||||
return fs.existsSync(explicit) ? explicit : undefined;
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const match = fs
|
||||
.readdirSync(cwd)
|
||||
.filter((name) => path.parse(name).name.toLowerCase() === "video")
|
||||
.sort()
|
||||
.find((name) => fs.statSync(path.join(cwd, name)).isFile());
|
||||
|
||||
return match ? path.join(cwd, match) : undefined;
|
||||
}
|
||||
|
||||
async function generateFrames(
|
||||
config: Config,
|
||||
videoArg?: string
|
||||
): Promise<void> {
|
||||
const videoPath = resolveVideoPath(videoArg ?? config.videoPath);
|
||||
if (!videoPath) {
|
||||
fail(
|
||||
`No source video found. Pass --video <path>, set VIDEO_PATH, or ` +
|
||||
`drop a "video.*" file in "${process.cwd()}".`
|
||||
);
|
||||
}
|
||||
|
||||
fs.mkdirSync(FRAMES_DIR, { recursive: true });
|
||||
const output = path.join(FRAMES_DIR, `${path.parse(videoPath).name}.json`);
|
||||
|
||||
logger.info(`Generating frames from "${videoPath}" -> ${output}`);
|
||||
try {
|
||||
await videoProcessor.process(videoPath, output, {
|
||||
maxDimension: config.frameResolution,
|
||||
});
|
||||
} catch (err) {
|
||||
fail(
|
||||
`Failed to generate frames from "${videoPath}": ` +
|
||||
(err instanceof Error ? err.message : String(err))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllFrames(): Promise<FramesContainer[]> {
|
||||
const files = fs.existsSync(FRAMES_DIR)
|
||||
? fs
|
||||
.readdirSync(FRAMES_DIR)
|
||||
.filter((name) => name.toLowerCase().endsWith(".json"))
|
||||
.sort()
|
||||
: [];
|
||||
|
||||
if (files.length === 0) {
|
||||
fail(
|
||||
`No frame sets found in "${FRAMES_DIR}". ` +
|
||||
`Generate one first with: bun src/index.ts --generate --video <path>`
|
||||
);
|
||||
}
|
||||
|
||||
const cpus = os.availableParallelism?.() ?? os.cpus().length;
|
||||
const concurrency = Math.min(files.length, Math.max(1, Math.min(cpus, 4)));
|
||||
|
||||
const results: FramesContainer[] = new Array(files.length);
|
||||
let nextIndex = 0;
|
||||
const worker = async () => {
|
||||
for (let i = nextIndex++; i < files.length; i = nextIndex++) {
|
||||
const file = files[i];
|
||||
const filePath = path.join(FRAMES_DIR, file);
|
||||
const sizeMb = (fs.statSync(filePath).size / 1024 / 1024).toFixed(
|
||||
1
|
||||
);
|
||||
logger.info(`Loading ${file} (${sizeMb} MB)...`);
|
||||
const data = await loadFramesAsync(filePath);
|
||||
data.name = file;
|
||||
logger.info(
|
||||
` ${file}: ${data.frames.length} frames @ ${data.fps}fps`
|
||||
);
|
||||
results[i] = data;
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.generate) {
|
||||
await generateFrames(config, args.video);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const bannerText = loadOptionalTextFile(path.join(DATA_DIR, "banner.txt"));
|
||||
const fakeLoginText = loadOptionalTextFile(
|
||||
path.join(DATA_DIR, "fakelogin.txt")
|
||||
);
|
||||
const goodbyeText = loadOptionalTextFile(
|
||||
path.join(DATA_DIR, "goodbye.txt")
|
||||
);
|
||||
|
||||
const hostKeys = ensureHostKeys(DATA_DIR);
|
||||
const videoSets = await loadAllFrames();
|
||||
logger.info(`Loaded ${videoSets.length} frame set(s)`);
|
||||
|
||||
const server = createServer({
|
||||
config,
|
||||
hostKeys,
|
||||
bannerText,
|
||||
fakeLoginText,
|
||||
goodbyeText,
|
||||
videoSets,
|
||||
});
|
||||
|
||||
server.on("error", (err: Error) => {
|
||||
logger.error("Server error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
logger.info(`TrollSSH listening on ${config.host}:${config.port}`);
|
||||
});
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
logger.info(`Received ${signal}, shutting down...`);
|
||||
server.close(() => process.exit(0));
|
||||
// Fail-safe: force exit if connections don't drain promptly.
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
};
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type logLevel int
|
||||
|
||||
const (
|
||||
levelDebug logLevel = 10
|
||||
levelInfo logLevel = 20
|
||||
levelWarn logLevel = 30
|
||||
levelError logLevel = 40
|
||||
)
|
||||
|
||||
var logThreshold = resolveThreshold()
|
||||
|
||||
func resolveThreshold() logLevel {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("LOG_LEVEL"))) {
|
||||
case "debug":
|
||||
return levelDebug
|
||||
case "warn":
|
||||
return levelWarn
|
||||
case "error":
|
||||
return levelError
|
||||
default:
|
||||
return levelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func sanitize(value any) string {
|
||||
return sanitizeN(value, 200)
|
||||
}
|
||||
|
||||
func sanitizeN(value any, maxLength int) string {
|
||||
var str string
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
str = ""
|
||||
case string:
|
||||
str = v
|
||||
default:
|
||||
str = fmt.Sprint(v)
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range str {
|
||||
if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
|
||||
b.WriteRune('�')
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := []rune(b.String())
|
||||
if len(out) > maxLength {
|
||||
return string(out[:maxLength]) + "…"
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func emit(level logLevel, name string, stream *os.File, args []any) {
|
||||
if level < logThreshold {
|
||||
return
|
||||
}
|
||||
parts := make([]string, len(args))
|
||||
for i, a := range args {
|
||||
if s, ok := a.(string); ok {
|
||||
parts[i] = s
|
||||
} else if b, err := json.Marshal(a); err == nil {
|
||||
parts[i] = string(b)
|
||||
} else {
|
||||
parts[i] = fmt.Sprint(a)
|
||||
}
|
||||
}
|
||||
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
fmt.Fprintf(stream, "[%s] %-5s %s\n", ts, strings.ToUpper(name), strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func logDebug(args ...any) { emit(levelDebug, "debug", os.Stdout, args) }
|
||||
func logInfo(args ...any) { emit(levelInfo, "info", os.Stdout, args) }
|
||||
func logWarn(args ...any) { emit(levelWarn, "warn", os.Stderr, args) }
|
||||
func logError(args ...any) { emit(levelError, "error", os.Stderr, args) }
|
||||
@@ -1,58 +0,0 @@
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
function resolveThreshold(): number {
|
||||
const raw = (globalThis.process.env.LOG_LEVEL ?? "info")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return LEVEL_ORDER[raw as LogLevel] ?? LEVEL_ORDER.info;
|
||||
}
|
||||
|
||||
let threshold = resolveThreshold();
|
||||
|
||||
export function sanitize(value: unknown, maxLength = 200): string {
|
||||
let str =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value === undefined
|
||||
? ""
|
||||
: String(value);
|
||||
// eslint-disable-next-line no-control-regex
|
||||
str = str.replace(/[\x00-\x1f\x7f-\x9f]/g, "�");
|
||||
if (str.length > maxLength) str = str.slice(0, maxLength) + "…";
|
||||
return str;
|
||||
}
|
||||
|
||||
function emit(
|
||||
level: LogLevel,
|
||||
stream: NodeJS.WriteStream,
|
||||
args: unknown[]
|
||||
): void {
|
||||
if (LEVEL_ORDER[level] < threshold) return;
|
||||
const ts = new Date().toISOString();
|
||||
const line =
|
||||
`[${ts}] ${level.toUpperCase().padEnd(5)} ` +
|
||||
args
|
||||
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
||||
.join(" ");
|
||||
stream.write(line + "\n");
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (...args: unknown[]) =>
|
||||
emit("debug", globalThis.process.stdout, args),
|
||||
info: (...args: unknown[]) => emit("info", globalThis.process.stdout, args),
|
||||
warn: (...args: unknown[]) => emit("warn", globalThis.process.stderr, args),
|
||||
error: (...args: unknown[]) =>
|
||||
emit("error", globalThis.process.stderr, args),
|
||||
refresh: () => {
|
||||
threshold = resolveThreshold();
|
||||
},
|
||||
sanitize,
|
||||
};
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type cliArgs struct {
|
||||
generate bool
|
||||
video string
|
||||
}
|
||||
|
||||
func parseArgs(argv []string) cliArgs {
|
||||
var args cliArgs
|
||||
for i := 0; i < len(argv); i++ {
|
||||
switch argv[i] {
|
||||
case "--generate", "-g":
|
||||
args.generate = true
|
||||
case "--video", "-v":
|
||||
if i+1 < len(argv) {
|
||||
i++
|
||||
args.video = argv[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func fail(message string) {
|
||||
logError(message)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func resolveVideoPath(explicitPath string) string {
|
||||
abs, err := filepath.Abs(explicitPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
|
||||
return abs
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func generateFrames(config Config, framesDir, videoArg string) {
|
||||
if videoArg == "" {
|
||||
fail("No source video given. Pass --video <path>.")
|
||||
}
|
||||
videoPath := resolveVideoPath(videoArg)
|
||||
if videoPath == "" {
|
||||
fail(fmt.Sprintf("Source video %q does not exist or is not a file.", videoArg))
|
||||
}
|
||||
|
||||
os.MkdirAll(framesDir, 0o755)
|
||||
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
|
||||
output := filepath.Join(framesDir, base+".tsf")
|
||||
|
||||
logInfo(fmt.Sprintf("Generating frames from %q -> %s", videoPath, output))
|
||||
if err := processVideo(videoPath, output, config.FrameResolution); err != nil {
|
||||
fail(fmt.Sprintf("Failed to generate frames from %q: %s", videoPath, err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func loadAllFrames(framesDir string) []*FramesContainer {
|
||||
entries, err := os.ReadDir(framesDir)
|
||||
var files []string
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(strings.ToLower(e.Name()), ".tsf") {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
fail(fmt.Sprintf(
|
||||
"No frame sets found in %q. Generate one first with: trollssh --generate --video <path>",
|
||||
framesDir,
|
||||
))
|
||||
}
|
||||
|
||||
concurrency := min(len(files), max(1, min(runtime.NumCPU(), 4)))
|
||||
|
||||
results := make([]*FramesContainer, len(files))
|
||||
errs := make([]error, len(files))
|
||||
var next int
|
||||
var nextMu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
worker := func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
nextMu.Lock()
|
||||
i := next
|
||||
next++
|
||||
nextMu.Unlock()
|
||||
if i >= len(files) {
|
||||
return
|
||||
}
|
||||
file := files[i]
|
||||
filePath := filepath.Join(framesDir, file)
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
logInfo(fmt.Sprintf("Loading %s (%.1f MB)...", file, float64(info.Size())/1024/1024))
|
||||
data, err := loadTSF(filePath)
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
data.Name = file
|
||||
logInfo(fmt.Sprintf(" %s: %d frames @ %gfps", file, len(data.Frames), data.FPS))
|
||||
results[i] = data
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(concurrency)
|
||||
for range concurrency {
|
||||
go worker()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func main() {
|
||||
godotenv.Load()
|
||||
logThreshold = resolveThreshold()
|
||||
|
||||
config := loadConfig()
|
||||
args := parseArgs(os.Args[1:])
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
dataDir := filepath.Join(cwd, "data")
|
||||
framesDir := filepath.Join(cwd, "frames")
|
||||
|
||||
if args.generate {
|
||||
generateFrames(config, framesDir, args.video)
|
||||
return
|
||||
}
|
||||
|
||||
os.MkdirAll(dataDir, 0o755)
|
||||
|
||||
var bannerText, fakeLoginText, goodbyeText *string
|
||||
if text, ok := loadOptionalTextFile(filepath.Join(dataDir, "banner.txt")); ok {
|
||||
bannerText = &text
|
||||
}
|
||||
if text, ok := loadOptionalTextFile(filepath.Join(dataDir, "fakelogin.txt")); ok {
|
||||
fakeLoginText = &text
|
||||
}
|
||||
if text, ok := loadOptionalTextFile(filepath.Join(dataDir, "goodbye.txt")); ok {
|
||||
goodbyeText = &text
|
||||
}
|
||||
|
||||
hostKeys, err := ensureHostKeys(dataDir)
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
videoSets := loadAllFrames(framesDir)
|
||||
logInfo(fmt.Sprintf("Loaded %d frame set(s)", len(videoSets)))
|
||||
|
||||
server := createServer(ServerDeps{
|
||||
Config: config,
|
||||
HostKeys: hostKeys,
|
||||
BannerText: bannerText,
|
||||
FakeLoginText: fakeLoginText,
|
||||
GoodbyeText: goodbyeText,
|
||||
VideoSets: videoSets,
|
||||
})
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
logInfo(fmt.Sprintf("Received %s, shutting down...", sig))
|
||||
server.Close()
|
||||
time.AfterFunc(5*time.Second, func() { os.Exit(0) })
|
||||
}()
|
||||
|
||||
if err := server.Listen(config.Host, config.Port); err != nil {
|
||||
logError("Server error:", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const clearScreen = "\x1b[2J\x1b[0f"
|
||||
|
||||
type ConnectionTracker struct {
|
||||
mu sync.Mutex
|
||||
counts map[string]int
|
||||
total int
|
||||
}
|
||||
|
||||
func newConnectionTracker() *ConnectionTracker {
|
||||
return &ConnectionTracker{counts: make(map[string]int)}
|
||||
}
|
||||
|
||||
func (t *ConnectionTracker) increment(ip string) int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.counts[ip]++
|
||||
t.total++
|
||||
return t.counts[ip]
|
||||
}
|
||||
|
||||
func (t *ConnectionTracker) decrement(ip string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if _, ok := t.counts[ip]; !ok {
|
||||
return
|
||||
}
|
||||
t.counts[ip]--
|
||||
if t.total > 0 {
|
||||
t.total--
|
||||
}
|
||||
if t.counts[ip] <= 0 {
|
||||
delete(t.counts, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ConnectionTracker) totalCount() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.total
|
||||
}
|
||||
|
||||
func (t *ConnectionTracker) hasReachedLimits(ip string, maxPerIP, maxTotal int) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.total >= maxTotal || t.counts[ip] >= maxPerIP
|
||||
}
|
||||
|
||||
type frameSet struct {
|
||||
data *FramesContainer
|
||||
renderer *FrameRenderer
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
config Config
|
||||
sshConfig *ssh.ServerConfig
|
||||
sets []frameSet
|
||||
tracker *ConnectionTracker
|
||||
fakeLogin *string
|
||||
goodbye *string
|
||||
listener net.Listener
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type ServerDeps struct {
|
||||
Config Config
|
||||
HostKeys []ssh.Signer
|
||||
BannerText *string
|
||||
FakeLoginText *string
|
||||
GoodbyeText *string
|
||||
VideoSets []*FramesContainer
|
||||
}
|
||||
|
||||
func clampDimension(value, max int) int {
|
||||
if value < 1 {
|
||||
return 1
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func createServer(deps ServerDeps) *Server {
|
||||
config := deps.Config
|
||||
|
||||
sets := make([]frameSet, len(deps.VideoSets))
|
||||
for i, data := range deps.VideoSets {
|
||||
sets[i] = frameSet{
|
||||
data: data,
|
||||
renderer: newFrameRenderer(data.Frames, asciiOptions{
|
||||
brightnessThreshold: config.BrightnessThreshold,
|
||||
charset: config.Charset,
|
||||
invert: config.Invert,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
sshConfig := &ssh.ServerConfig{
|
||||
MaxAuthTries: config.MaxAuthAttempts,
|
||||
PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
ip := hostOnly(conn.RemoteAddr().String())
|
||||
if config.LogCredentials {
|
||||
logInfo(fmt.Sprintf(
|
||||
`Auth attempt from %s method=password user="%s" pass="%s"`,
|
||||
ip, sanitizeN(conn.User(), 128), sanitizeN(string(password), 128),
|
||||
))
|
||||
}
|
||||
if conn.User() == "" || len(password) == 0 {
|
||||
return nil, errors.New("password rejected")
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
sshConfig.KeyExchanges = []string{
|
||||
"mlkem768x25519-sha256",
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
"diffie-hellman-group14-sha256",
|
||||
}
|
||||
if deps.BannerText != nil {
|
||||
banner := *deps.BannerText
|
||||
sshConfig.BannerCallback = func(_ ssh.ConnMetadata) string { return banner }
|
||||
}
|
||||
for _, key := range deps.HostKeys {
|
||||
sshConfig.AddHostKey(key)
|
||||
}
|
||||
|
||||
return &Server{
|
||||
config: config,
|
||||
sshConfig: sshConfig,
|
||||
sets: sets,
|
||||
tracker: newConnectionTracker(),
|
||||
fakeLogin: deps.FakeLoginText,
|
||||
goodbye: deps.GoodbyeText,
|
||||
}
|
||||
}
|
||||
|
||||
func hostOnly(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) Listen(host string, port int) error {
|
||||
listener, err := net.Listen("tcp", net.JoinHostPort(host, fmt.Sprint(port)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.listener = listener
|
||||
logInfo(fmt.Sprintf("TrollSSH listening on %s:%d", host, port))
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
s.closeOnce.Do(func() {
|
||||
if s.listener != nil {
|
||||
s.listener.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
ip := hostOnly(conn.RemoteAddr().String())
|
||||
|
||||
if s.tracker.hasReachedLimits(ip, s.config.MaxConnections, s.config.MaxTotalConnections) {
|
||||
conn.Close()
|
||||
logWarn("Connection rejected (limit reached) from", ip)
|
||||
return
|
||||
}
|
||||
|
||||
activeForIP := s.tracker.increment(ip)
|
||||
defer s.tracker.decrement(ip)
|
||||
|
||||
if s.config.HandshakeTimeout > 0 {
|
||||
conn.SetDeadline(time.Now().Add(s.config.HandshakeTimeout))
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshConfig)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "i/o timeout") {
|
||||
logWarn("Handshake timeout for", ip)
|
||||
} else {
|
||||
logWarn(fmt.Sprintf("Client error from %s:", ip), sanitize(err.Error()))
|
||||
}
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
conn.SetDeadline(time.Time{})
|
||||
logDebug("Handshake from", ip)
|
||||
defer sshConn.Close()
|
||||
|
||||
setIndex := rand.Intn(len(s.sets))
|
||||
logInfo(fmt.Sprintf(
|
||||
"New connection from %s (ip=%d, total=%d) -> playing %q",
|
||||
ip, activeForIP, s.tracker.totalCount(), s.sets[setIndex].data.Name,
|
||||
))
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
for newChannel := range chans {
|
||||
if newChannel.ChannelType() != "session" {
|
||||
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
|
||||
continue
|
||||
}
|
||||
channel, requests, err := newChannel.Accept()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go s.handleSession(sshConn, channel, requests, ip, setIndex)
|
||||
}
|
||||
logInfo("Client closed connection from", ip)
|
||||
}
|
||||
|
||||
type termSize struct {
|
||||
mu sync.Mutex
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func (t *termSize) set(w, h, maxDim int) {
|
||||
t.mu.Lock()
|
||||
t.width = clampDimension(w, maxDim)
|
||||
t.height = clampDimension(h, maxDim)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *termSize) get() (int, int) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.width, t.height
|
||||
}
|
||||
|
||||
func parseDims(payload []byte) (cols, rows int, ok bool) {
|
||||
if len(payload) < 8 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// pty-req prefixes cols/rows with a TERM string; window-change does not.
|
||||
offset := 0
|
||||
strLen := binary.BigEndian.Uint32(payload)
|
||||
if int(strLen)+12 <= len(payload) {
|
||||
offset = 4 + int(strLen)
|
||||
}
|
||||
if len(payload) < offset+8 {
|
||||
return 0, 0, false
|
||||
}
|
||||
cols = int(binary.BigEndian.Uint32(payload[offset:]))
|
||||
rows = int(binary.BigEndian.Uint32(payload[offset+4:]))
|
||||
return cols, rows, true
|
||||
}
|
||||
|
||||
func (s *Server) handleSession(
|
||||
sshConn *ssh.ServerConn,
|
||||
channel ssh.Channel,
|
||||
requests <-chan *ssh.Request,
|
||||
ip string,
|
||||
initialSetIndex int,
|
||||
) {
|
||||
size := &termSize{}
|
||||
size.set(80, 24, s.config.MaxDimension)
|
||||
|
||||
started := false
|
||||
for req := range requests {
|
||||
switch req.Type {
|
||||
case "pty-req":
|
||||
logDebug("Opening pty for session", ip)
|
||||
if cols, rows, ok := parseDims(req.Payload); ok {
|
||||
size.set(cols, rows, s.config.MaxDimension)
|
||||
}
|
||||
req.Reply(true, nil)
|
||||
case "window-change":
|
||||
if len(req.Payload) >= 8 {
|
||||
cols := int(binary.BigEndian.Uint32(req.Payload))
|
||||
rows := int(binary.BigEndian.Uint32(req.Payload[4:]))
|
||||
size.set(cols, rows, s.config.MaxDimension)
|
||||
}
|
||||
if req.WantReply {
|
||||
req.Reply(true, nil)
|
||||
}
|
||||
case "exec":
|
||||
command := ""
|
||||
if len(req.Payload) >= 4 {
|
||||
n := binary.BigEndian.Uint32(req.Payload)
|
||||
if int(n)+4 <= len(req.Payload) {
|
||||
command = string(req.Payload[4 : 4+n])
|
||||
}
|
||||
}
|
||||
logInfo(fmt.Sprintf("Client %s attempted exec: %q", ip, sanitizeN(command, 512)))
|
||||
req.Reply(true, nil)
|
||||
if !started {
|
||||
started = true
|
||||
go s.playVideo(sshConn, channel, size, ip, initialSetIndex, false)
|
||||
}
|
||||
case "shell":
|
||||
logDebug("Opening shell for session", ip)
|
||||
req.Reply(true, nil)
|
||||
if !started {
|
||||
started = true
|
||||
go s.playVideo(sshConn, channel, size, ip, initialSetIndex, false)
|
||||
}
|
||||
default:
|
||||
if req.WantReply {
|
||||
req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pickNextSetIndex(exclude int) int {
|
||||
if len(s.sets) <= 1 {
|
||||
return exclude
|
||||
}
|
||||
next := exclude
|
||||
for next == exclude {
|
||||
next = rand.Intn(len(s.sets))
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *Server) playVideo(
|
||||
sshConn *ssh.ServerConn,
|
||||
channel ssh.Channel,
|
||||
size *termSize,
|
||||
ip string,
|
||||
setIndex int,
|
||||
keepAspectRatio bool,
|
||||
) {
|
||||
config := s.config
|
||||
current := s.sets[setIndex]
|
||||
|
||||
w, h := size.get()
|
||||
logDebug(fmt.Sprintf("Terminal size %dx%d for %s", w, h, ip))
|
||||
|
||||
if s.fakeLogin != nil {
|
||||
channel.Write([]byte(clearScreen))
|
||||
channel.Write([]byte(*s.fakeLogin))
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var doneOnce sync.Once
|
||||
closeSession := func() {
|
||||
doneOnce.Do(func() { close(done) })
|
||||
}
|
||||
|
||||
switchCh := make(chan int, 8)
|
||||
go func() {
|
||||
buf := make([]byte, 256)
|
||||
var lastSwitch time.Time
|
||||
for {
|
||||
n, err := channel.Read(buf)
|
||||
if err != nil {
|
||||
closeSession()
|
||||
return
|
||||
}
|
||||
if !config.AllowUserControl {
|
||||
continue
|
||||
}
|
||||
str := string(buf[:n])
|
||||
delta := 0
|
||||
if strings.Contains(str, "\x1b[C") || strings.Contains(str, "\x1b[A") {
|
||||
delta = 1
|
||||
} else if strings.Contains(str, "\x1b[D") || strings.Contains(str, "\x1b[B") {
|
||||
delta = -1
|
||||
}
|
||||
if delta == 0 {
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
if now.Sub(lastSwitch) < config.SwitchDebounce {
|
||||
continue
|
||||
}
|
||||
lastSwitch = now
|
||||
select {
|
||||
case switchCh <- delta:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(config.LoginDelay):
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
|
||||
frameInterval := func() time.Duration {
|
||||
return time.Duration(float64(time.Second) / current.data.FPS)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(frameInterval())
|
||||
defer ticker.Stop()
|
||||
|
||||
currentFrame := 0
|
||||
loopCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
|
||||
case delta := <-switchCh:
|
||||
if len(s.sets) <= 1 {
|
||||
continue
|
||||
}
|
||||
setIndex = (setIndex + delta + len(s.sets)) % len(s.sets)
|
||||
current = s.sets[setIndex]
|
||||
currentFrame = 0
|
||||
logDebug(fmt.Sprintf("%s switched to %q", ip, current.data.Name))
|
||||
ticker.Reset(frameInterval())
|
||||
|
||||
case <-ticker.C:
|
||||
w, h := size.get()
|
||||
ascii, err := current.renderer.render(currentFrame, w, h, keepAspectRatio)
|
||||
if err != nil {
|
||||
logError("Render error for", ip, sanitize(err.Error()))
|
||||
sshConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := channel.Write([]byte(clearScreen + ascii)); err != nil {
|
||||
closeSession()
|
||||
return
|
||||
}
|
||||
|
||||
currentFrame++
|
||||
if currentFrame < len(current.data.Frames) {
|
||||
continue
|
||||
}
|
||||
|
||||
currentFrame = 0
|
||||
loopCount++
|
||||
if config.MaxLoop > 0 && loopCount >= config.MaxLoop {
|
||||
channel.Write([]byte(clearScreen))
|
||||
if s.goodbye != nil {
|
||||
channel.Write([]byte(*s.goodbye))
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
logInfo("Playback finished, closing session", ip)
|
||||
channel.Close()
|
||||
sshConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if config.PlaybackMode == PlaybackRandom {
|
||||
setIndex = s.pickNextSetIndex(setIndex)
|
||||
current = s.sets[setIndex]
|
||||
logInfo(fmt.Sprintf(
|
||||
"Playthrough done for %s, switching to %q", ip, current.data.Name,
|
||||
))
|
||||
ticker.Reset(frameInterval())
|
||||
} else if config.MaxLoop > 0 {
|
||||
logInfo(fmt.Sprintf(
|
||||
"Playthrough done for %s, looping %q (%d/%d)",
|
||||
ip, current.data.Name, loopCount, config.MaxLoop,
|
||||
))
|
||||
} else {
|
||||
logInfo(fmt.Sprintf(
|
||||
"Playthrough done for %s, looping %q (%d)",
|
||||
ip, current.data.Name, loopCount,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { ConnectionTracker } from "./server";
|
||||
|
||||
describe("ConnectionTracker", () => {
|
||||
test("count is 0 for an IP that has never connected", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
expect(tracker.count("1.2.3.4")).toBe(0);
|
||||
});
|
||||
|
||||
test("increment raises the count for that IP", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.2.3.4");
|
||||
tracker.increment("1.2.3.4");
|
||||
expect(tracker.count("1.2.3.4")).toBe(2);
|
||||
});
|
||||
|
||||
test("decrement lowers the count for that IP", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.2.3.4");
|
||||
tracker.increment("1.2.3.4");
|
||||
tracker.decrement("1.2.3.4");
|
||||
expect(tracker.count("1.2.3.4")).toBe(1);
|
||||
});
|
||||
|
||||
test("decrementing an IP that was never incremented is a no-op", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.decrement("1.2.3.4");
|
||||
expect(tracker.count("1.2.3.4")).toBe(0);
|
||||
});
|
||||
|
||||
test("hasReachedLimit is true once the count reaches max", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.2.3.4");
|
||||
tracker.increment("1.2.3.4");
|
||||
expect(tracker.hasReachedLimit("1.2.3.4", 2)).toBe(true);
|
||||
expect(tracker.hasReachedLimit("1.2.3.4", 3)).toBe(false);
|
||||
});
|
||||
|
||||
test("tracks separate counts per IP independently", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.1.1.1");
|
||||
tracker.increment("2.2.2.2");
|
||||
tracker.increment("2.2.2.2");
|
||||
expect(tracker.count("1.1.1.1")).toBe(1);
|
||||
expect(tracker.count("2.2.2.2")).toBe(2);
|
||||
});
|
||||
|
||||
test("totalCount aggregates connections across all IPs", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.1.1.1");
|
||||
tracker.increment("2.2.2.2");
|
||||
expect(tracker.totalCount()).toBe(2);
|
||||
tracker.decrement("1.1.1.1");
|
||||
expect(tracker.totalCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("hasReachedTotalLimit reflects the global total", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.1.1.1");
|
||||
tracker.increment("2.2.2.2");
|
||||
expect(tracker.hasReachedTotalLimit(2)).toBe(true);
|
||||
expect(tracker.hasReachedTotalLimit(3)).toBe(false);
|
||||
});
|
||||
|
||||
test("decrementing an unknown IP does not affect the total", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.1.1.1");
|
||||
tracker.decrement("9.9.9.9");
|
||||
expect(tracker.totalCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
-359
@@ -1,359 +0,0 @@
|
||||
import ssh2 from "ssh2";
|
||||
import { Config } from "./config";
|
||||
import { FramesContainer, FrameRenderer } from "./frames";
|
||||
import { logger, sanitize } from "./logger";
|
||||
|
||||
const MAX_WRITE_BACKLOG_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export class ConnectionTracker {
|
||||
private counts: Record<string, number> = {};
|
||||
private total = 0;
|
||||
|
||||
increment(ip: string): number {
|
||||
this.counts[ip] = (this.counts[ip] ?? 0) + 1;
|
||||
this.total += 1;
|
||||
return this.counts[ip];
|
||||
}
|
||||
|
||||
decrement(ip: string): void {
|
||||
if (typeof this.counts[ip] === "undefined") return;
|
||||
this.counts[ip] -= 1;
|
||||
this.total = Math.max(0, this.total - 1);
|
||||
if (this.counts[ip] <= 0) delete this.counts[ip];
|
||||
}
|
||||
|
||||
count(ip: string): number {
|
||||
return this.counts[ip] ?? 0;
|
||||
}
|
||||
|
||||
totalCount(): number {
|
||||
return this.total;
|
||||
}
|
||||
|
||||
hasReachedLimit(ip: string, max: number): boolean {
|
||||
return this.count(ip) >= max;
|
||||
}
|
||||
|
||||
hasReachedTotalLimit(max: number): boolean {
|
||||
return this.total >= max;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerDeps {
|
||||
config: Config;
|
||||
hostKeys: Buffer[];
|
||||
bannerText?: string;
|
||||
fakeLoginText?: string;
|
||||
goodbyeText?: string;
|
||||
videoSets: FramesContainer[];
|
||||
}
|
||||
|
||||
function clampDimension(value: number, max: number): number {
|
||||
if (!Number.isFinite(value) || value < 1) return 1;
|
||||
return Math.min(Math.floor(value), max);
|
||||
}
|
||||
|
||||
export function createServer(deps: ServerDeps): ssh2.Server {
|
||||
const {
|
||||
config,
|
||||
hostKeys,
|
||||
bannerText,
|
||||
fakeLoginText,
|
||||
goodbyeText,
|
||||
videoSets,
|
||||
} = deps;
|
||||
const tracker = new ConnectionTracker();
|
||||
|
||||
const sets = videoSets.map((data) => ({
|
||||
data,
|
||||
renderer: new FrameRenderer(data.frames, {
|
||||
brightnessThreshold: config.brightnessThreshold,
|
||||
charset: config.charset,
|
||||
invert: config.invert,
|
||||
}),
|
||||
}));
|
||||
|
||||
const server = new ssh2.Server({
|
||||
hostKeys,
|
||||
banner: bannerText,
|
||||
});
|
||||
|
||||
server.on("connection", (client, info) => {
|
||||
if (
|
||||
tracker.hasReachedTotalLimit(config.maxTotalConnections) ||
|
||||
tracker.hasReachedLimit(info.ip, config.maxConnections)
|
||||
) {
|
||||
client.on("error", () => {});
|
||||
client.end();
|
||||
logger.warn("Connection rejected (limit reached) from", info.ip);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeForIp = tracker.increment(info.ip);
|
||||
let currentSetIndex = Math.floor(Math.random() * sets.length);
|
||||
let { data: videoData, renderer } = sets[currentSetIndex];
|
||||
|
||||
const pickNextSetIndex = (exclude: number): number => {
|
||||
if (sets.length <= 1) return exclude;
|
||||
let next = exclude;
|
||||
while (next === exclude) {
|
||||
next = Math.floor(Math.random() * sets.length);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
logger.info(
|
||||
`New connection from ${info.ip} ` +
|
||||
`(ip=${activeForIp}, total=${tracker.totalCount()}) ` +
|
||||
`-> playing "${videoData.name ?? "?"}"`
|
||||
);
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let authAttempts = 0;
|
||||
let ended = false;
|
||||
|
||||
// Force-drop clients that connect but never complete a handshake
|
||||
if (config.handshakeTimeout > 0) {
|
||||
handshakeTimer = setTimeout(() => {
|
||||
logger.warn("Handshake timeout for", info.ip);
|
||||
client.end();
|
||||
}, config.handshakeTimeout);
|
||||
}
|
||||
|
||||
const endSession = () => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
tracker.decrement(info.ip);
|
||||
if (interval) clearInterval(interval);
|
||||
if (handshakeTimer) clearTimeout(handshakeTimer);
|
||||
};
|
||||
|
||||
client.on("handshake", () => {
|
||||
logger.debug("Handshake from", info.ip);
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer);
|
||||
handshakeTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
logger.info("Client closed connection from", info.ip);
|
||||
endSession();
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
if (err.message === "read ECONNRESET") {
|
||||
logger.debug(
|
||||
"Terminal closed (ECONNRESET) for session",
|
||||
info.ip
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Client error from ${info.ip}:`,
|
||||
sanitize(err.message)
|
||||
);
|
||||
}
|
||||
endSession();
|
||||
});
|
||||
|
||||
client.on("authentication", (ctx) => {
|
||||
if (ctx.method === "password" && config.logCredentials)
|
||||
logger.info(
|
||||
`Auth attempt from ${info.ip} method=${ctx.method} ` +
|
||||
`user="${sanitize(ctx.username, 128)}" ` +
|
||||
`pass="${sanitize(ctx.password, 128)}"`
|
||||
);
|
||||
|
||||
authAttempts += 1;
|
||||
if (authAttempts > config.maxAuthAttempts) {
|
||||
client.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.method !== "password") return ctx.reject(["password"]);
|
||||
if (!ctx.username) return ctx.reject(["password"]);
|
||||
if (!ctx.password) return ctx.reject(["password"]);
|
||||
|
||||
ctx.accept();
|
||||
});
|
||||
|
||||
client.on("session", (accept, _reject) => {
|
||||
const session = accept();
|
||||
|
||||
let height = clampDimension(24, config.maxDimension);
|
||||
let width = clampDimension(80, config.maxDimension);
|
||||
|
||||
session.once("pty", (accept, _reject, data) => {
|
||||
logger.debug("Opening pty for session", info.ip);
|
||||
height = clampDimension(data.rows, config.maxDimension);
|
||||
width = clampDimension(data.cols, config.maxDimension);
|
||||
accept();
|
||||
});
|
||||
|
||||
session.on("window-change", (_accept, _reject, data) => {
|
||||
height = clampDimension(data.rows, config.maxDimension);
|
||||
width = clampDimension(data.cols, config.maxDimension);
|
||||
});
|
||||
|
||||
const playVideo = (
|
||||
stream: ssh2.ServerChannel,
|
||||
keepAspectRatio: boolean
|
||||
) => {
|
||||
stream.setEncoding("utf8");
|
||||
logger.debug(`Terminal size ${width}x${height} for ${info.ip}`);
|
||||
|
||||
if (typeof fakeLoginText !== "undefined") {
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
stream.write(fakeLoginText);
|
||||
}
|
||||
|
||||
let currentFrame = 0;
|
||||
let loopCount = 0;
|
||||
let rendering = false;
|
||||
|
||||
const startRenderLoop = () => {
|
||||
interval = setInterval(async () => {
|
||||
if (ended || stream.destroyed) {
|
||||
if (interval) clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
if (rendering) return;
|
||||
if (
|
||||
(stream.writableLength ?? 0) >
|
||||
MAX_WRITE_BACKLOG_BYTES
|
||||
) {
|
||||
return;
|
||||
}
|
||||
rendering = true;
|
||||
|
||||
try {
|
||||
const ascii = await renderer.render(
|
||||
currentFrame,
|
||||
width,
|
||||
height,
|
||||
keepAspectRatio
|
||||
);
|
||||
|
||||
if (ended || stream.destroyed) return;
|
||||
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
stream.write(ascii);
|
||||
|
||||
currentFrame++;
|
||||
if (currentFrame < videoData.frames.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentFrame = 0;
|
||||
loopCount++;
|
||||
if (loopCount >= config.maxLoop) {
|
||||
if (interval) clearInterval(interval);
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
if (typeof goodbyeText !== "undefined") {
|
||||
stream.write(goodbyeText);
|
||||
}
|
||||
setTimeout(() => {
|
||||
logger.info(
|
||||
"Playback finished, closing session",
|
||||
info.ip
|
||||
);
|
||||
stream.end();
|
||||
client.end();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.playbackMode === "random") {
|
||||
currentSetIndex =
|
||||
pickNextSetIndex(currentSetIndex);
|
||||
({ data: videoData, renderer } =
|
||||
sets[currentSetIndex]);
|
||||
logger.info(
|
||||
`Playthrough done for ${info.ip}, ` +
|
||||
`switching to "${videoData.name ?? "?"}"`
|
||||
);
|
||||
if (interval) clearInterval(interval);
|
||||
rendering = false;
|
||||
startRenderLoop();
|
||||
} else {
|
||||
logger.info(
|
||||
`Playthrough done for ${info.ip}, ` +
|
||||
`looping "${videoData.name ?? "?"}" ` +
|
||||
`(${loopCount}/${config.maxLoop})`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
"Render error for",
|
||||
info.ip,
|
||||
sanitize(
|
||||
err instanceof Error ? err.message : err
|
||||
)
|
||||
);
|
||||
if (interval) clearInterval(interval);
|
||||
client.end();
|
||||
} finally {
|
||||
rendering = false;
|
||||
}
|
||||
}, 1000 / videoData.fps);
|
||||
};
|
||||
|
||||
let lastSwitch = 0;
|
||||
const switchSet = (delta: number) => {
|
||||
if (sets.length <= 1) return;
|
||||
currentSetIndex =
|
||||
(currentSetIndex + delta + sets.length) % sets.length;
|
||||
({ data: videoData, renderer } = sets[currentSetIndex]);
|
||||
currentFrame = 0;
|
||||
logger.debug(
|
||||
`${info.ip} switched to "${videoData.name ?? "?"}"`
|
||||
);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
rendering = false;
|
||||
startRenderLoop();
|
||||
}
|
||||
};
|
||||
|
||||
if (config.allowUserControl) {
|
||||
stream.on("data", (chunk: Buffer | string) => {
|
||||
const s = chunk.toString();
|
||||
let delta = 0;
|
||||
if (s.includes("\x1b[C") || s.includes("\x1b[A"))
|
||||
delta = 1;
|
||||
else if (s.includes("\x1b[D") || s.includes("\x1b[B"))
|
||||
delta = -1;
|
||||
if (delta === 0) return;
|
||||
const now = Date.now();
|
||||
if (now - lastSwitch < config.switchDebounceMs) return;
|
||||
lastSwitch = now;
|
||||
switchSet(delta);
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (ended || stream.destroyed) return;
|
||||
startRenderLoop();
|
||||
}, config.loginDelay);
|
||||
};
|
||||
|
||||
session.once("exec", (accept, _reject, data) => {
|
||||
logger.info(
|
||||
`Client ${info.ip} attempted exec: ` +
|
||||
`"${sanitize(data.command, 512)}"`
|
||||
);
|
||||
const stream = accept();
|
||||
playVideo(stream, false);
|
||||
});
|
||||
|
||||
session.once("shell", (accept, _reject) => {
|
||||
logger.debug("Opening shell for session", info.ip);
|
||||
const stream = accept();
|
||||
playVideo(stream, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import fs from "fs";
|
||||
import { FramesContainer } from "./frames";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const SOI = Buffer.from([0xff, 0xd8]);
|
||||
const EOI = Buffer.from([0xff, 0xd9]);
|
||||
|
||||
export interface ProcessOptions {
|
||||
maxDimension?: number;
|
||||
}
|
||||
|
||||
class JpegFrameSplitter {
|
||||
private buffer = Buffer.alloc(0);
|
||||
|
||||
push(chunk: Buffer): Buffer[] {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
const frames: Buffer[] = [];
|
||||
|
||||
for (;;) {
|
||||
const start = this.buffer.indexOf(SOI);
|
||||
if (start === -1) break;
|
||||
const end = this.buffer.indexOf(EOI, start + SOI.length);
|
||||
if (end === -1) break;
|
||||
|
||||
const frameEnd = end + EOI.length;
|
||||
frames.push(this.buffer.subarray(start, frameEnd));
|
||||
this.buffer = this.buffer.subarray(frameEnd);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
}
|
||||
|
||||
export async function process(
|
||||
path: string,
|
||||
output: string,
|
||||
options: ProcessOptions = {}
|
||||
): Promise<void> {
|
||||
const maxDimension = options.maxDimension ?? 320;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(path).ffprobe((err, data) => {
|
||||
if (err) {
|
||||
reject(new Error("ffprobe failed: " + err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = data.streams?.[0];
|
||||
const rate = stream?.r_frame_rate;
|
||||
const fps = rate ? parseFloat(rate) : NaN;
|
||||
if (!Number.isFinite(fps) || fps <= 0) {
|
||||
reject(new Error("Unable to determine a valid video fps"));
|
||||
return;
|
||||
}
|
||||
|
||||
const nbFrames = stream?.nb_frames
|
||||
? parseInt(String(stream.nb_frames), 10)
|
||||
: NaN;
|
||||
const duration = Number(data.format?.duration ?? stream?.duration);
|
||||
const totalFrames = Number.isFinite(nbFrames)
|
||||
? nbFrames
|
||||
: Number.isFinite(duration)
|
||||
? Math.round(duration * fps)
|
||||
: undefined;
|
||||
|
||||
const videoData: FramesContainer = { frames: [], fps };
|
||||
const splitter = new JpegFrameSplitter();
|
||||
|
||||
let settled = false;
|
||||
const fail = (message: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error(message));
|
||||
};
|
||||
|
||||
const reportProgress = (count: number) => {
|
||||
if (totalFrames && totalFrames > 0) {
|
||||
const pct = Math.min(
|
||||
100,
|
||||
Math.round((count / totalFrames) * 100)
|
||||
);
|
||||
globalThis.process.stdout.write(
|
||||
`\rGenerating frames: ${count}/${totalFrames} (${pct}%)`
|
||||
);
|
||||
} else {
|
||||
globalThis.process.stdout.write(
|
||||
`\rGenerating frames: ${count}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ffvideo = ffmpeg(path)
|
||||
.outputOptions("-c:v", "mjpeg")
|
||||
.outputOptions("-q:v", "3")
|
||||
.outputOptions(
|
||||
"-vf",
|
||||
`format=gray,scale=w=${maxDimension}:h=${maxDimension}:` +
|
||||
`force_original_aspect_ratio=decrease`
|
||||
)
|
||||
.outputOptions("-f", "image2pipe")
|
||||
.on("error", (err) => fail("ffmpeg failed: " + err.message));
|
||||
|
||||
const ffstream = ffvideo.pipe();
|
||||
ffstream.on("error", (err: Error) =>
|
||||
fail("ffmpeg stream error: " + err.message)
|
||||
);
|
||||
ffstream.on("data", (chunk: Buffer) => {
|
||||
for (const frame of splitter.push(chunk)) {
|
||||
videoData.frames.push(frame);
|
||||
}
|
||||
reportProgress(videoData.frames.length);
|
||||
});
|
||||
|
||||
ffstream.on("end", () => {
|
||||
if (settled) return;
|
||||
if (videoData.frames.length === 0) {
|
||||
fail("No frames were decoded from the video");
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
globalThis.process.stdout.write("\n");
|
||||
fs.writeFileSync(output, JSON.stringify(videoData));
|
||||
logger.info(
|
||||
`Saved ${videoData.frames.length} frames to ${output}`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
process,
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
jpegSOI = []byte{0xff, 0xd8}
|
||||
jpegEOI = []byte{0xff, 0xd9}
|
||||
)
|
||||
|
||||
type jpegFrameSplitter struct {
|
||||
buffer []byte
|
||||
}
|
||||
|
||||
func (s *jpegFrameSplitter) push(chunk []byte) [][]byte {
|
||||
s.buffer = append(s.buffer, chunk...)
|
||||
var frames [][]byte
|
||||
for {
|
||||
start := bytes.Index(s.buffer, jpegSOI)
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := bytes.Index(s.buffer[start+len(jpegSOI):], jpegEOI)
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
frameEnd := start + len(jpegSOI) + end + len(jpegEOI)
|
||||
frame := make([]byte, frameEnd-start)
|
||||
copy(frame, s.buffer[start:frameEnd])
|
||||
frames = append(frames, frame)
|
||||
s.buffer = s.buffer[frameEnd:]
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
type ffprobeOutput struct {
|
||||
Streams []struct {
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
NbFrames string `json:"nb_frames"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
func parseFrameRate(rate string) float64 {
|
||||
if rate == "" {
|
||||
return math.NaN()
|
||||
}
|
||||
parts := strings.SplitN(rate, "/", 2)
|
||||
num, err := strconv.ParseFloat(parts[0], 64)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
den, err := strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil || den == 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
return num / den
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
func processVideo(path, output string, maxDimension int) error {
|
||||
probeCmd := exec.Command(
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_streams", "-show_format",
|
||||
"-of", "json", path,
|
||||
)
|
||||
probeOut, err := probeCmd.Output()
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
|
||||
msg = strings.TrimSpace(string(exitErr.Stderr))
|
||||
}
|
||||
return fmt.Errorf("ffprobe failed: %s", msg)
|
||||
}
|
||||
|
||||
var probe ffprobeOutput
|
||||
if err := json.Unmarshal(probeOut, &probe); err != nil {
|
||||
return fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
if len(probe.Streams) == 0 {
|
||||
return fmt.Errorf("unable to determine a valid video fps")
|
||||
}
|
||||
stream := probe.Streams[0]
|
||||
|
||||
fps := parseFrameRate(stream.RFrameRate)
|
||||
if math.IsNaN(fps) || fps <= 0 {
|
||||
return fmt.Errorf("unable to determine a valid video fps")
|
||||
}
|
||||
|
||||
totalFrames := 0
|
||||
if n, err := strconv.Atoi(stream.NbFrames); err == nil {
|
||||
totalFrames = n
|
||||
} else {
|
||||
durStr := probe.Format.Duration
|
||||
if durStr == "" {
|
||||
durStr = stream.Duration
|
||||
}
|
||||
if d, err := strconv.ParseFloat(durStr, 64); err == nil {
|
||||
totalFrames = int(math.Round(d * fps))
|
||||
}
|
||||
}
|
||||
|
||||
vf := fmt.Sprintf(
|
||||
"format=gray,scale=w=%d:h=%d:force_original_aspect_ratio=decrease",
|
||||
maxDimension, maxDimension,
|
||||
)
|
||||
cmd := exec.Command(
|
||||
"ffmpeg", "-i", path,
|
||||
"-c:v", "mjpeg",
|
||||
"-q:v", "3",
|
||||
"-vf", vf,
|
||||
"-f", "image2pipe",
|
||||
"pipe:1",
|
||||
)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg failed: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("ffmpeg failed: %w", err)
|
||||
}
|
||||
|
||||
videoData := FramesContainer{FPS: fps}
|
||||
splitter := &jpegFrameSplitter{}
|
||||
reportProgress := func(count int) {
|
||||
if totalFrames > 0 {
|
||||
pct := min(100, int(math.Round(float64(count)/float64(totalFrames)*100)))
|
||||
fmt.Printf("\rGenerating frames: %d/%d (%d%%)", count, totalFrames, pct)
|
||||
} else {
|
||||
fmt.Printf("\rGenerating frames: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
buf := make([]byte, 256*1024)
|
||||
for {
|
||||
n, err := stdout.Read(buf)
|
||||
if n > 0 {
|
||||
videoData.Frames = append(videoData.Frames, splitter.push(buf[:n])...)
|
||||
reportProgress(len(videoData.Frames))
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
cmd.Wait()
|
||||
return fmt.Errorf("ffmpeg stream error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return fmt.Errorf("ffmpeg failed: %s", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
if len(videoData.Frames) == 0 {
|
||||
return fmt.Errorf("no frames were decoded from the video")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if err := writeTSF(output, &videoData); err != nil {
|
||||
return err
|
||||
}
|
||||
logInfo(fmt.Sprintf("Saved %d frames to %s", len(videoData.Frames), output))
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user