Files
TrollSSH/src/server.ts
T

360 lines
13 KiB
TypeScript
Raw Normal View History

2026-07-13 15:12:04 +07:00
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;
2026-07-13 15:12:04 +07:00
export class ConnectionTracker {
private counts: Record<string, number> = {};
private total = 0;
2026-07-13 15:12:04 +07:00
increment(ip: string): number {
this.counts[ip] = (this.counts[ip] ?? 0) + 1;
this.total += 1;
2026-07-13 15:12:04 +07:00
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);
2026-07-13 15:12:04 +07:00
if (this.counts[ip] <= 0) delete this.counts[ip];
}
count(ip: string): number {
return this.counts[ip] ?? 0;
}
totalCount(): number {
return this.total;
}
2026-07-13 15:12:04 +07:00
hasReachedLimit(ip: string, max: number): boolean {
return this.count(ip) >= max;
}
hasReachedTotalLimit(max: number): boolean {
return this.total >= max;
}
2026-07-13 15:12:04 +07:00
}
export interface ServerDeps {
config: Config;
hostKeys: Buffer[];
2026-07-13 15:12:04 +07:00
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);
2026-07-13 15:12:04 +07:00
}
export function createServer(deps: ServerDeps): ssh2.Server {
const {
config,
hostKeys,
2026-07-13 15:12:04 +07:00
bannerText,
fakeLoginText,
goodbyeText,
videoSets,
2026-07-13 15:12:04 +07:00
} = 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,
}),
}));
2026-07-13 15:12:04 +07:00
const server = new ssh2.Server({
hostKeys,
2026-07-13 15:12:04 +07:00
banner: bannerText,
});
server.on("connection", (client, info) => {
if (
tracker.hasReachedTotalLimit(config.maxTotalConnections) ||
tracker.hasReachedLimit(info.ip, config.maxConnections)
) {
2026-07-13 15:12:04 +07:00
client.on("error", () => {});
client.end();
logger.warn("Connection rejected (limit reached) from", info.ip);
2026-07-13 15:12:04 +07:00
return;
}
const activeForIp = tracker.increment(info.ip);
let currentSetIndex = Math.floor(Math.random() * sets.length);
let { data: videoData, renderer } = sets[currentSetIndex];
2026-07-13 15:12:04 +07:00
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;
2026-07-13 15:12:04 +07:00
};
logger.info(
`New connection from ${info.ip} ` +
`(ip=${activeForIp}, total=${tracker.totalCount()}) ` +
`-> playing "${videoData.name ?? "?"}"`
);
let interval: ReturnType<typeof setInterval> | undefined;
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
let authAttempts = 0;
let ended = false;
// Force-drop clients that connect but never complete a handshake
if (config.handshakeTimeout > 0) {
handshakeTimer = setTimeout(() => {
logger.warn("Handshake timeout for", info.ip);
client.end();
}, config.handshakeTimeout);
}
const endSession = () => {
if (ended) return;
ended = true;
tracker.decrement(info.ip);
if (interval) clearInterval(interval);
if (handshakeTimer) clearTimeout(handshakeTimer);
};
client.on("handshake", () => {
logger.debug("Handshake from", info.ip);
if (handshakeTimer) {
clearTimeout(handshakeTimer);
handshakeTimer = undefined;
}
});
2026-07-13 15:12:04 +07:00
client.on("close", () => {
logger.info("Client closed connection from", info.ip);
2026-07-13 15:12:04 +07:00
endSession();
});
client.on("error", (err) => {
if (err.message === "read ECONNRESET") {
logger.debug(
2026-07-13 15:12:04 +07:00
"Terminal closed (ECONNRESET) for session",
info.ip
);
} else {
logger.warn(
`Client error from ${info.ip}:`,
sanitize(err.message)
);
2026-07-13 15:12:04 +07:00
}
endSession();
2026-07-13 15:12:04 +07:00
});
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)}"`
2026-07-13 15:12:04 +07:00
);
authAttempts += 1;
if (authAttempts > config.maxAuthAttempts) {
client.end();
return;
}
2026-07-13 15:12:04 +07:00
if (ctx.method !== "password") return ctx.reject(["password"]);
if (!ctx.username) return ctx.reject(["password"]);
2026-07-13 15:12:04 +07:00
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);
2026-07-13 15:12:04 +07:00
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);
2026-07-13 15:12:04 +07:00
accept();
});
session.on("window-change", (_accept, _reject, data) => {
height = clampDimension(data.rows, config.maxDimension);
width = clampDimension(data.cols, config.maxDimension);
2026-07-13 15:12:04 +07:00
});
const playVideo = (
stream: ssh2.ServerChannel,
keepAspectRatio: boolean
) => {
stream.setEncoding("utf8");
logger.debug(`Terminal size ${width}x${height} for ${info.ip}`);
2026-07-13 15:12:04 +07:00
if (typeof fakeLoginText !== "undefined") {
stream.write("\x1b[2J\x1b[0f");
stream.write(fakeLoginText);
}
let currentFrame = 0;
let loopCount = 0;
let rendering = false;
2026-07-13 15:12:04 +07:00
const startRenderLoop = () => {
2026-07-13 15:12:04 +07:00
interval = setInterval(async () => {
if (ended || stream.destroyed) {
if (interval) clearInterval(interval);
2026-07-13 15:12:04 +07:00
return;
}
if (rendering) return;
if (
(stream.writableLength ?? 0) >
MAX_WRITE_BACKLOG_BYTES
) {
return;
}
rendering = true;
2026-07-13 15:12:04 +07:00
try {
const ascii = await renderer.render(
currentFrame,
width,
height,
keepAspectRatio
);
2026-07-13 15:12:04 +07:00
if (ended || stream.destroyed) return;
stream.write("\x1b[2J\x1b[0f");
stream.write(ascii);
currentFrame++;
if (currentFrame < videoData.frames.length) {
return;
}
2026-07-13 15:12:04 +07:00
currentFrame = 0;
loopCount++;
if (loopCount >= config.maxLoop) {
if (interval) clearInterval(interval);
2026-07-13 15:12:04 +07:00
stream.write("\x1b[2J\x1b[0f");
if (typeof goodbyeText !== "undefined") {
stream.write(goodbyeText);
}
setTimeout(() => {
logger.info(
"Playback finished, closing session",
2026-07-13 15:12:04 +07:00
info.ip
);
stream.end();
client.end();
}, 1000);
return;
2026-07-13 15:12:04 +07:00
}
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;
2026-07-13 15:12:04 +07:00
}
}, 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();
2026-07-13 15:12:04 +07:00
}, config.loginDelay);
};
session.once("exec", (accept, _reject, data) => {
logger.info(
`Client ${info.ip} attempted exec: ` +
`"${sanitize(data.command, 512)}"`
2026-07-13 15:12:04 +07:00
);
const stream = accept();
playVideo(stream, false);
});
session.once("shell", (accept, _reject) => {
logger.debug("Opening shell for session", info.ip);
2026-07-13 15:12:04 +07:00
const stream = accept();
playVideo(stream, false);
});
});
});
return server;
}