From 1421b854d4dde4ff8eef27c12430af7631275045 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Mon, 13 Jul 2026 17:47:00 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20rewrite=20in=20Go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 2 +- .env.example | 38 ++- .github/workflows/ci.yml | 72 +++--- .gitignore | 6 +- .prettierignore | 4 - .prettierrc | 7 - Dockerfile | 25 +- bun.lock | 348 --------------------------- docker-compose.yml | 4 +- eslint.config.mjs | 21 -- go.mod | 11 + go.sum | 10 + package.json | 42 ---- src/app_test.go | 218 +++++++++++++++++ src/config.go | 142 +++++++++++ src/config.test.ts | 86 ------- src/config.ts | 84 ------- src/frameLoader.worker.ts | 51 ---- src/frameformat.go | 91 +++++++ src/frames.go | 144 +++++++++++ src/frames.test.ts | 96 -------- src/frames.ts | 167 ------------- src/hostKeys.ts | 39 --- src/hostkeys.go | 61 +++++ src/index.ts | 172 ------------- src/logger.go | 85 +++++++ src/logger.ts | 58 ----- src/main.go | 205 ++++++++++++++++ src/server.go | 492 ++++++++++++++++++++++++++++++++++++++ src/server.test.ts | 71 ------ src/server.ts | 359 --------------------------- src/videoProcessor.ts | 135 ----------- src/videoprocessor.go | 179 ++++++++++++++ tsconfig.json | 14 -- 34 files changed, 1714 insertions(+), 1825 deletions(-) delete mode 100644 .prettierignore delete mode 100644 .prettierrc delete mode 100644 bun.lock delete mode 100644 eslint.config.mjs create mode 100644 go.mod create mode 100644 go.sum delete mode 100644 package.json create mode 100644 src/app_test.go create mode 100644 src/config.go delete mode 100644 src/config.test.ts delete mode 100644 src/config.ts delete mode 100644 src/frameLoader.worker.ts create mode 100644 src/frameformat.go create mode 100644 src/frames.go delete mode 100644 src/frames.test.ts delete mode 100644 src/frames.ts delete mode 100644 src/hostKeys.ts create mode 100644 src/hostkeys.go delete mode 100644 src/index.ts create mode 100644 src/logger.go delete mode 100644 src/logger.ts create mode 100644 src/main.go create mode 100644 src/server.go delete mode 100644 src/server.test.ts delete mode 100644 src/server.ts delete mode 100644 src/videoProcessor.ts create mode 100644 src/videoprocessor.go delete mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore index 1d58731..9801e47 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,4 +13,4 @@ docs data frames .vscode -video.mp4 \ No newline at end of file +TrollSSH diff --git a/.env.example b/.env.example index ebab0a0..5ffd818 100644 --- a/.env.example +++ b/.env.example @@ -1,35 +1,47 @@ HOST=0.0.0.0 PORT=22 -# Source video (auto-discovers video.* if unset). Only used to build frames.json. -VIDEO_PATH=video.mp4 +# Generation settings +# Stored frame resolution in pixels. Higher = sharper but bigger .tsf files. +# regenerate your frames after changing it. +FRAME_RESOLUTION=512 +# Playback settings. +# Playthroughs before the session is closed (0, unlimited). MAX_LOOP=5 -# Weather to loop the video or play random frames after completion. Options: loop | random +# Whether to keep looping the same frame set or pick a random one after each +# playthrough. Options: loop | random PLAYBACK_MODE=random -# Let viewers switch frame sets with arrow keys +# Let viewers switch frame sets with arrow keys. ALLOW_USER_CONTROL=true -# Min ms between arrow-key switches; ignores faster repeats. -SWITCH_DEBOUNCE_MS=120 +# Minimum ms between arrow-key switches +SWITCH_DEBOUNCE_MS=500 +# Pause in ms after the fake login before playback starts. LOGIN_DELAY=1500 -# ASCII rendering (live, no frame rebuild). -# CHARSET: detailed|standard|simple|blocks or custom ramp. -BRIGHTNESS_THRESHOLD=40 +# ASCII rendering (applied live) +# CHARSET: detailed | standard | simple | blocks, or a custom ramp string. CHARSET=blocks +# Pixels darker than this (0-100) render as the darkest ramp character. +BRIGHTNESS_THRESHOLD=40 +# Invert the brightness ramp (for light terminal backgrounds). INVERT=false # Max rendered width/height in characters. MAX_DIMENSION=1080 -# Stored frame resolution (px). -# Higher = sharper but bigger frames.json. Rebuild required. -FRAME_RESOLUTION=512 +# Connection limits. New connections over a limit are dropped immediately. +# Max simultaneous connections from a single client IP. MAX_CONNECTIONS=10 +# Max simultaneous connections across all clients combined. MAX_TOTAL_CONNECTIONS=1000 -MAX_AUTH_ATTEMPTS=6 +# Login attempts allowed per connection before it is disconnected. +# (Empty password counts as an attempt.) +MAX_AUTH_ATTEMPTS=3 +# SSH handshake deadline in ms (0 to disable). HANDSHAKE_TIMEOUT=10000 +# Log attempted usernames/passwords. LOG_CREDENTIALS=true # Minimum log level to emit: debug | info | warn | error LOG_LEVEL=info diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da7584e..2761c52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,33 +12,30 @@ permissions: jobs: test: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - bun-version: ["1.2"] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-go@v5 with: - bun-version: ${{ matrix.bun-version }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Typecheck - run: bun run typecheck - - - name: Lint - run: bun run lint + go-version-file: go.mod - name: Format check - run: bun run format + run: test -z "$(gofmt -l src)" + + - name: Vet + run: go vet ./src/ + + - name: Staticcheck + uses: dominikh/staticcheck-action@v1 + with: + install-go: false + + - name: Build + run: go build -o trollssh ./src - name: Unit tests - run: bun test + run: go test -race ./src/ docker: needs: test @@ -48,16 +45,6 @@ jobs: - uses: docker/setup-buildx-action@v3 - - name: Build image - uses: docker/build-push-action@v6 - with: - context: . - push: false - load: true - tags: trollssh:ci - cache-from: type=gha - cache-to: type=gha,mode=max - - name: Determine push conditions id: should_push run: | @@ -67,15 +54,15 @@ jobs: echo "push=false" >> "$GITHUB_OUTPUT" fi - - name: Determine image tag - if: steps.should_push.outputs.push == 'true' - id: tag - run: | - if [[ "${{ github.ref }}" == refs/tags/v* ]]; then - echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" - else - echo "tag=latest" >> "$GITHUB_OUTPUT" - fi + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/yuzuzensai/trollssh + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern=v{{version}} + type=sha,prefix=sha-,format=short - name: Log in to GHCR if: steps.should_push.outputs.push == 'true' @@ -85,12 +72,13 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Push image - if: steps.should_push.outputs.push == 'true' + - name: Build (and push) image uses: docker/build-push-action@v6 with: context: . - push: true - tags: ghcr.io/yuzuzensai/trollssh:${{ steps.tag.outputs.tag }} + push: ${{ steps.should_push.outputs.push == 'true' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + provenance: false diff --git a/.gitignore b/.gitignore index f31587f..1fbc8dd 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,8 @@ dist .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz -.pnp.* \ No newline at end of file +.pnp.* +# Go build output +/TrollSSH +TrollSSH +*.exe diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index f7b809d..0000000 --- a/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -dist/ -config/ -node_modules/ -bun.lock diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 689207b..0000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "tabWidth": 4, - "useTabs": false, - "semi": true, - "singleQuote": false, - "trailingComma": "es5" -} diff --git a/Dockerfile b/Dockerfile index 4796440..4d57865 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,15 @@ -FROM oven/bun:1.2-slim -WORKDIR /home/bun/app - -RUN apt-get update -y && \ - apt-get install -y ffmpeg && \ - rm -rf /var/lib/apt/lists/* - -COPY package.json bun.lock ./ -RUN bun install --frozen-lockfile --production - -COPY tsconfig.json ./ +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download COPY src ./src +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /trollssh ./src -CMD ["bun", "src/index.ts"] +FROM alpine:3.22 +WORKDIR /home/app + +RUN apk add --no-cache ffmpeg + +COPY --from=build /trollssh /usr/local/bin/trollssh + +CMD ["trollssh"] diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 93a8f86..0000000 --- a/bun.lock +++ /dev/null @@ -1,348 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "trollssh", - "dependencies": { - "fluent-ffmpeg": "^2.1.3", - "sharp": "^0.35.3", - "ssh2": "^1.17.0", - "sshpk": "^1.18.0", - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@types/bun": "^1.3.14", - "@types/fluent-ffmpeg": "^2.1.28", - "@types/node": "^26.1.1", - "@types/sharp": "^0.31.1", - "@types/ssh2": "^1.15.5", - "@types/sshpk": "^1.17.5", - "eslint": "^10.7.0", - "eslint-config-prettier": "^10.1.8", - "prettier": "^3.9.5", - "typescript": "6.0.3", - "typescript-eslint": "^8.63.0", - }, - }, - }, - "packages": { - "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], - - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - - "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - - "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], - - "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - - "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - - "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], - - "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], - - "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], - - "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], - - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="], - - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="], - - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="], - - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="], - - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="], - - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="], - - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="], - - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], - - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], - - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="], - - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], - - "@types/asn1": ["@types/asn1@0.2.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA=="], - - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - - "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], - - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - - "@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.28", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - - "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], - - "@types/sharp": ["@types/sharp@0.31.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag=="], - - "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], - - "@types/sshpk": ["@types/sshpk@1.17.5", "", { "dependencies": { "@types/asn1": "*", "@types/node": "*" } }, "sha512-khJbOFAOnyLaqOoGIlDCF2ZghIb0jM1K1N0rRoKL0ObqUMwfkicu5zlJ5Wsp+Xn2EUduVwONXwsgbGk+COaCug=="], - - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.63.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/type-utils": "8.63.0", "@typescript-eslint/utils": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ=="], - - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.63.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig=="], - - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], - - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], - - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], - - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], - - "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], - - "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - - "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - - "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], - - "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], - - "async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="], - - "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], - - "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], - - "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], - - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - - "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], - - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="], - - "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], - - "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], - - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - - "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - - "fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="], - - "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="], - - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - - "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nan": ["nan@2.28.0", "", {}, "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="], - - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - - "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], - - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - - "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], - - "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], - - "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], - - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - - "typescript-eslint": ["typescript-eslint@8.63.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.63.0", "@typescript-eslint/parser": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ=="], - - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - - "which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], - - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "@types/asn1/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - - "@types/fluent-ffmpeg/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - - "@types/sharp/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - - "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - - "@types/sshpk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - - "bun-types/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "@types/asn1/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - - "@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - - "@types/sharp/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - - "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - - "@types/sshpk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - - "bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - } -} diff --git a/docker-compose.yml b/docker-compose.yml index f8e7988..764ad69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,6 @@ services: - .env volumes: # Host keys + banner/fakelogin/goodbye text assets (persisted). - - ./data:/home/bun/app/data + - ./data:/home/app/data # Pre-generated frame sets - - ./frames:/home/bun/app/frames + - ./frames:/home/app/frames diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 78c031a..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-check -import eslint from "@eslint/js"; -import tseslint from "typescript-eslint"; -import eslintConfigPrettier from "eslint-config-prettier"; - -export default tseslint.config( - eslint.configs.recommended, - tseslint.configs.recommended, - eslintConfigPrettier, - { - rules: { - "@typescript-eslint/no-unused-vars": [ - "error", - { argsIgnorePattern: "^_" }, - ], - }, - }, - { - ignores: ["dist/**", "config/**", "node_modules/**"], - } -); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5ffdf0e --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/YuzuZensai/TrollSSH + +go 1.25.0 + +require ( + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.54.0 + golang.org/x/image v0.44.0 +) + +require golang.org/x/sys v0.47.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f530f2a --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= diff --git a/package.json b/package.json deleted file mode 100644 index c6f8171..0000000 --- a/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "trollssh", - "version": "1.0.0", - "main": "src/index.ts", - "repository": "https://github.com/YuzuZensai/TrollSSH.git", - "author": "Yuzu ", - "license": "GPL-3.0", - "engines": { - "bun": ">=1.2.0" - }, - "scripts": { - "dev": "bun --watch src/index.ts", - "start": "bun src/index.ts", - "typecheck": "tsc --noEmit", - "lint": "eslint .", - "format": "prettier --check .", - "format:fix": "prettier --write .", - "test": "bun test", - "docker-build": "docker build . -t ghcr.io/yuzuzensai/trollssh:latest", - "docker-push": "docker push ghcr.io/yuzuzensai/trollssh:latest" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@types/bun": "^1.3.14", - "@types/fluent-ffmpeg": "^2.1.28", - "@types/node": "^26.1.1", - "@types/sharp": "^0.31.1", - "@types/ssh2": "^1.15.5", - "@types/sshpk": "^1.17.5", - "eslint": "^10.7.0", - "eslint-config-prettier": "^10.1.8", - "prettier": "^3.9.5", - "typescript": "6.0.3", - "typescript-eslint": "^8.63.0" - }, - "dependencies": { - "fluent-ffmpeg": "^2.1.3", - "sharp": "^0.35.3", - "ssh2": "^1.17.0", - "sshpk": "^1.18.0" - } -} diff --git a/src/app_test.go b/src/app_test.go new file mode 100644 index 0000000..9ef6bcc --- /dev/null +++ b/src/app_test.go @@ -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) + } +} diff --git a/src/config.go b/src/config.go new file mode 100644 index 0000000..cb5b5e4 --- /dev/null +++ b/src/config.go @@ -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 +} diff --git a/src/config.test.ts b/src/config.test.ts deleted file mode 100644 index f9e9ee5..0000000 --- a/src/config.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index e570b6c..0000000 --- a/src/config.ts +++ /dev/null @@ -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(); -} diff --git a/src/frameLoader.worker.ts b/src/frameLoader.worker.ts deleted file mode 100644 index a3014a5..0000000 --- a/src/frameLoader.worker.ts +++ /dev/null @@ -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 -); diff --git a/src/frameformat.go b/src/frameformat.go new file mode 100644 index 0000000..e93b012 --- /dev/null +++ b/src/frameformat.go @@ -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 +} diff --git a/src/frames.go b/src/frames.go new file mode 100644 index 0000000..af43738 --- /dev/null +++ b/src/frames.go @@ -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 +} diff --git a/src/frames.test.ts b/src/frames.test.ts deleted file mode 100644 index 83a790b..0000000 --- a/src/frames.test.ts +++ /dev/null @@ -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(" .#@"); - }); -}); diff --git a/src/frames.ts b/src/frames.ts deleted file mode 100644 index 2fc5c86..0000000 --- a/src/frames.ts +++ /dev/null @@ -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 = { - 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 { - 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 { - 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(); - - constructor( - private readonly frames: Buffer[], - private readonly options: AsciiOptions, - private readonly maxEntries = 4096 - ) {} - - async render( - index: number, - width: number, - height: number, - keepAspectRatio: boolean - ): Promise { - 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; -} diff --git a/src/hostKeys.ts b/src/hostKeys.ts deleted file mode 100644 index b79667a..0000000 --- a/src/hostKeys.ts +++ /dev/null @@ -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); - }); -} diff --git a/src/hostkeys.go b/src/hostkeys.go new file mode 100644 index 0000000..90083cf --- /dev/null +++ b/src/hostkeys.go @@ -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 +} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 604bf2a..0000000 --- a/src/index.ts +++ /dev/null @@ -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 { - const videoPath = resolveVideoPath(videoArg ?? config.videoPath); - if (!videoPath) { - fail( - `No source video found. Pass --video , 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 { - 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 ` - ); - } - - 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)); -}); diff --git a/src/logger.go b/src/logger.go new file mode 100644 index 0000000..99358d9 --- /dev/null +++ b/src/logger.go @@ -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) } diff --git a/src/logger.ts b/src/logger.ts deleted file mode 100644 index 27b2cc6..0000000 --- a/src/logger.ts +++ /dev/null @@ -1,58 +0,0 @@ -export type LogLevel = "debug" | "info" | "warn" | "error"; - -const LEVEL_ORDER: Record = { - 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, -}; diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..0a7a782 --- /dev/null +++ b/src/main.go @@ -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 .") + } + 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 ", + 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) + } +} diff --git a/src/server.go b/src/server.go new file mode 100644 index 0000000..2c4fdde --- /dev/null +++ b/src/server.go @@ -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, + )) + } + } + } +} diff --git a/src/server.test.ts b/src/server.test.ts deleted file mode 100644 index 0015a4f..0000000 --- a/src/server.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/server.ts b/src/server.ts deleted file mode 100644 index 5d2b5b0..0000000 --- a/src/server.ts +++ /dev/null @@ -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 = {}; - 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 | undefined; - let handshakeTimer: ReturnType | 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; -} diff --git a/src/videoProcessor.ts b/src/videoProcessor.ts deleted file mode 100644 index fa776f1..0000000 --- a/src/videoProcessor.ts +++ /dev/null @@ -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 { - 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, -}; diff --git a/src/videoprocessor.go b/src/videoprocessor.go new file mode 100644 index 0000000..324d8d1 --- /dev/null +++ b/src/videoprocessor.go @@ -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 +} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 5843d3b..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "node16", - "moduleResolution": "node16", - "noEmit": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts", "node_modules", "dist"] -}