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"] -}