mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-09-13 21:59:04 +00:00
♻️ refactor: migrate to bun and refactor
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { loadConfig } from "./config";
|
||||
|
||||
describe("loadConfig", () => {
|
||||
test("returns defaults when env vars are unset", () => {
|
||||
const config = loadConfig({});
|
||||
expect(config).toEqual({
|
||||
host: "0.0.0.0",
|
||||
port: 22,
|
||||
maxLoop: 5,
|
||||
loginDelay: 1500,
|
||||
maxConnections: 10,
|
||||
brightnessThreshold: 40,
|
||||
logCredentials: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("overrides defaults from env vars", () => {
|
||||
const config = loadConfig({
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "2222",
|
||||
MAX_LOOP: "3",
|
||||
LOGIN_DELAY: "500",
|
||||
MAX_CONNECTIONS: "20",
|
||||
BRIGHTNESS_THRESHOLD: "60",
|
||||
LOG_CREDENTIALS: "true",
|
||||
});
|
||||
expect(config).toEqual({
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
maxLoop: 3,
|
||||
loginDelay: 500,
|
||||
maxConnections: 20,
|
||||
brightnessThreshold: 60,
|
||||
logCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("logCredentials is false for any value other than the string 'true'", () => {
|
||||
expect(loadConfig({ LOG_CREDENTIALS: "1" }).logCredentials).toBe(false);
|
||||
expect(loadConfig({ LOG_CREDENTIALS: "false" }).logCredentials).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import fs from "fs";
|
||||
|
||||
export interface Config {
|
||||
host: string;
|
||||
port: number;
|
||||
maxLoop: number;
|
||||
loginDelay: number;
|
||||
maxConnections: number;
|
||||
brightnessThreshold: number;
|
||||
logCredentials: boolean;
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
return {
|
||||
host: env.HOST ?? "0.0.0.0",
|
||||
port: env.PORT ? parseInt(env.PORT, 10) : 22,
|
||||
maxLoop: env.MAX_LOOP ? parseInt(env.MAX_LOOP, 10) : 5,
|
||||
loginDelay: env.LOGIN_DELAY ? parseInt(env.LOGIN_DELAY, 10) : 1500,
|
||||
maxConnections: env.MAX_CONNECTIONS
|
||||
? parseInt(env.MAX_CONNECTIONS, 10)
|
||||
: 10,
|
||||
brightnessThreshold: env.BRIGHTNESS_THRESHOLD
|
||||
? parseInt(env.BRIGHTNESS_THRESHOLD, 10)
|
||||
: 40,
|
||||
logCredentials: env.LOG_CREDENTIALS === "true",
|
||||
};
|
||||
}
|
||||
|
||||
export function loadOptionalTextFile(filePath: string): string | undefined {
|
||||
if (!fs.existsSync(filePath)) return undefined;
|
||||
return fs.readFileSync(filePath).toString();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { frameToAscii } from "./frames";
|
||||
|
||||
describe("frameToAscii", () => {
|
||||
test("returns empty string for an empty buffer", () => {
|
||||
expect(frameToAscii(Buffer.from([]), 40)).toBe("");
|
||||
});
|
||||
|
||||
test("maps a zero-brightness pixel to the first (blankest) character", () => {
|
||||
expect(frameToAscii(Buffer.from([0]), 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]), 40)).toBe(" ");
|
||||
});
|
||||
|
||||
test("maps a mid-range pixel to a mid-range character", () => {
|
||||
// brightness = floor((128/255)*100) = 50
|
||||
expect(frameToAscii(Buffer.from([128]), 40)).toBe("n");
|
||||
});
|
||||
|
||||
test("maps a max-brightness pixel to the last (densest) character", () => {
|
||||
// brightness = floor((255/255)*100) = 100, must clamp to last index
|
||||
expect(frameToAscii(Buffer.from([255]), 40)).toBe("$");
|
||||
});
|
||||
|
||||
test("maps multiple pixels in sequence, preserving order", () => {
|
||||
expect(frameToAscii(Buffer.from([0, 128, 255]), 40)).toBe(" n$");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "fs";
|
||||
import sharp from "sharp";
|
||||
|
||||
export interface FramesContainer {
|
||||
frames: Buffer[];
|
||||
fps: number;
|
||||
}
|
||||
|
||||
const PIXEL_CHARS =
|
||||
" .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
|
||||
|
||||
export function loadFrames(filename: string): FramesContainer {
|
||||
return JSON.parse(fs.readFileSync(filename).toString());
|
||||
}
|
||||
|
||||
export async function resizeFrame(
|
||||
frame: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
keepAspectRatio = false
|
||||
): Promise<Buffer> {
|
||||
return sharp(frame)
|
||||
.resize(width, height, {
|
||||
fit: keepAspectRatio ? "contain" : "fill",
|
||||
})
|
||||
.grayscale()
|
||||
.extend({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||||
})
|
||||
.raw()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export function frameToAscii(
|
||||
pixels: Buffer,
|
||||
brightnessThreshold: number
|
||||
): string {
|
||||
const totalPixelColors = PIXEL_CHARS.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) * totalPixelColors),
|
||||
totalPixelColors - 1
|
||||
);
|
||||
}
|
||||
|
||||
result += PIXEL_CHARS[index];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import sshpk from "sshpk";
|
||||
|
||||
export function ensureHostKeys(configDir: string): void {
|
||||
const keyPath = path.join(configDir, "id_rsa");
|
||||
if (fs.existsSync(keyPath)) return;
|
||||
|
||||
console.log("Generating host keys...");
|
||||
const key = crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: "pkcs1",
|
||||
format: "pem",
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: "pkcs8",
|
||||
format: "pem",
|
||||
},
|
||||
});
|
||||
|
||||
const keyPem = sshpk.parsePrivateKey(key.privateKey, "pem");
|
||||
const keyParsed = sshpk.parsePrivateKey(keyPem.toString("pem"));
|
||||
|
||||
fs.writeFileSync(keyPath, keyParsed.toString("openssh"));
|
||||
}
|
||||
+31
-316
@@ -1,334 +1,49 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import ssh2 from "ssh2";
|
||||
import sharp from "sharp";
|
||||
import crypto from "crypto";
|
||||
import sshpk from "sshpk";
|
||||
import dotenv from "dotenv";
|
||||
import videoProcessor, { FramesContainer } from "./videoProcessor";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
interface ClientCount {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
const pixelColors =
|
||||
" .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
|
||||
|
||||
function generateHostKeys() {
|
||||
let key = crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: "pkcs1",
|
||||
format: "pem",
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: "pkcs8",
|
||||
format: "pem",
|
||||
},
|
||||
});
|
||||
|
||||
const keyPem = sshpk.parsePrivateKey(key.privateKey, "pem");
|
||||
const keyParsed = sshpk.parsePrivateKey(keyPem.toString("pem"));
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(process.cwd(), "config", "id_rsa"),
|
||||
keyParsed.toString("openssh")
|
||||
);
|
||||
}
|
||||
|
||||
async function resizeFrame(
|
||||
frame: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
keep_aspect_ratio = false
|
||||
) {
|
||||
const resized_frame = await sharp(frame)
|
||||
.resize(width, height, {
|
||||
fit: keep_aspect_ratio ? "contain" : "fill",
|
||||
})
|
||||
.grayscale()
|
||||
.extend({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||||
})
|
||||
.raw()
|
||||
.toBuffer();
|
||||
return resized_frame;
|
||||
}
|
||||
|
||||
function loadFrames(filename: string) {
|
||||
const frames: FramesContainer = JSON.parse(
|
||||
fs.readFileSync(filename).toString()
|
||||
);
|
||||
return frames;
|
||||
}
|
||||
|
||||
async function printFrameASCII(
|
||||
stream: ssh2.WriteStream,
|
||||
frame: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
brightness_threshold: number,
|
||||
keep_aspect_ratio = false
|
||||
) {
|
||||
const resized_frame = await resizeFrame(
|
||||
frame,
|
||||
width,
|
||||
height,
|
||||
keep_aspect_ratio
|
||||
);
|
||||
|
||||
let frame_string = "";
|
||||
for (let i = 0; i < resized_frame.length; i++) {
|
||||
const pixel = resized_frame[i];
|
||||
|
||||
const brightness = Math.floor((pixel / 255) * 100);
|
||||
let color;
|
||||
let totalPixelColors = pixelColors.length;
|
||||
let choosenPixelColors;
|
||||
if (brightness > 100) {
|
||||
choosenPixelColors = totalPixelColors - 1;
|
||||
} else if (brightness < brightness_threshold) {
|
||||
choosenPixelColors = 0;
|
||||
} else {
|
||||
choosenPixelColors = Math.floor(
|
||||
(brightness / 100) * totalPixelColors
|
||||
);
|
||||
}
|
||||
color = pixelColors[choosenPixelColors];
|
||||
|
||||
frame_string += color;
|
||||
}
|
||||
|
||||
stream.write("ok\x1Bc[0G");
|
||||
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
stream.write(frame_string);
|
||||
}
|
||||
import { loadConfig, loadOptionalTextFile } from "./config";
|
||||
import { ensureHostKeys } from "./hostKeys";
|
||||
import { loadFrames } from "./frames";
|
||||
import { createServer } from "./server";
|
||||
import videoProcessor from "./videoProcessor";
|
||||
|
||||
async function main() {
|
||||
const HOST = process.env.HOST ? process.env.HOST : "0.0.0.0";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 22;
|
||||
const MAX_LOOP = process.env.MAX_LOOP ? parseInt(process.env.MAX_LOOP) : 5;
|
||||
const LOGIN_DELAY = process.env.LOGIN_DELAY
|
||||
? parseInt(process.env.LOGIN_DELAY)
|
||||
: 1500;
|
||||
const MAX_CONNECTIONS = process.env.MAX_CONNECTIONS
|
||||
? parseInt(process.env.MAX_CONNECTIONS)
|
||||
: 10;
|
||||
const BRIGHTNESS_THRESHOLD = process.env.BRIGHTNESS_THRESHOLD ? parseInt(process.env.BRIGHTNESS_THRESHOLD) : 40;
|
||||
const LOG_CREDENTIALS = process.env.LOG_CREDENTIALS ? process.env.LOG_CREDENTIALS === "true" : false;
|
||||
const config = loadConfig();
|
||||
const configDir = path.join(process.cwd(), "config");
|
||||
|
||||
if (!fs.existsSync(path.join(process.cwd(), "config"))) {
|
||||
fs.mkdirSync(path.join(process.cwd(), "config"));
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir);
|
||||
}
|
||||
|
||||
let bannerText: string | undefined;
|
||||
if (fs.existsSync(path.join(process.cwd(), "config", "banner.txt"))) {
|
||||
bannerText = fs
|
||||
.readFileSync(path.join(process.cwd(), "config", "banner.txt"))
|
||||
.toString();
|
||||
}
|
||||
const bannerText = loadOptionalTextFile(path.join(configDir, "banner.txt"));
|
||||
const fakeLoginText = loadOptionalTextFile(
|
||||
path.join(configDir, "fakelogin.txt")
|
||||
);
|
||||
const goodbyeText = loadOptionalTextFile(
|
||||
path.join(configDir, "goodbye.txt")
|
||||
);
|
||||
|
||||
let fakeLoginText: string | undefined;
|
||||
if (fs.existsSync(path.join(process.cwd(), "config", "fakelogin.txt"))) {
|
||||
fakeLoginText = fs
|
||||
.readFileSync(path.join(process.cwd(), "config", "fakelogin.txt"))
|
||||
.toString();
|
||||
}
|
||||
ensureHostKeys(configDir);
|
||||
const hostKey = fs.readFileSync(path.join(configDir, "id_rsa"));
|
||||
|
||||
let goodbyeText: string | undefined;
|
||||
if (fs.existsSync(path.join(process.cwd(), "config", "goodbye.txt"))) {
|
||||
goodbyeText = fs
|
||||
.readFileSync(path.join(process.cwd(), "config", "goodbye.txt"))
|
||||
.toString();
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(process.cwd(), "config", "id_rsa"))) {
|
||||
console.log("Generating host keys...");
|
||||
generateHostKeys();
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(process.cwd(), "config", "frames.json"))) {
|
||||
const framesPath = path.join(configDir, "frames.json");
|
||||
if (!fs.existsSync(framesPath)) {
|
||||
console.error("frames.json not found!, generating frames.json...");
|
||||
await videoProcessor.process("video.mp4", "config/frames.json");
|
||||
await videoProcessor.process("video.mp4", framesPath);
|
||||
}
|
||||
|
||||
const videoData = loadFrames("config/frames.json");
|
||||
const videoData = loadFrames(framesPath);
|
||||
console.log("Loaded frames");
|
||||
|
||||
const server = new ssh2.Server({
|
||||
hostKeys: [
|
||||
fs.readFileSync(path.join(process.cwd(), "config", "id_rsa")),
|
||||
],
|
||||
banner: bannerText,
|
||||
const server = createServer({
|
||||
config,
|
||||
hostKey,
|
||||
bannerText,
|
||||
fakeLoginText,
|
||||
goodbyeText,
|
||||
videoData,
|
||||
});
|
||||
|
||||
let clientCount: ClientCount = {};
|
||||
|
||||
server.on("connection", (client, info) => {
|
||||
if (clientCount[info.ip] >= MAX_CONNECTIONS) {
|
||||
client.on("error", (err) => {});
|
||||
client.end();
|
||||
return;
|
||||
}
|
||||
|
||||
clientCount[info.ip] = clientCount[info.ip]
|
||||
? clientCount[info.ip] + 1
|
||||
: 1;
|
||||
|
||||
console.log("New connection from", info.ip);
|
||||
client.on("handshake", () => {
|
||||
console.log("Handshake from", info.ip);
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
console.log("Client closed connection from", info.ip);
|
||||
if (typeof clientCount[info.ip] !== "undefined")
|
||||
clientCount[info.ip] = clientCount[info.ip] - 1;
|
||||
else delete clientCount[info.ip];
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
|
||||
let interval: NodeJS.Timer | undefined;
|
||||
|
||||
client.on("error", (err) => {
|
||||
if (err.message === "read ECONNRESET") {
|
||||
console.log(
|
||||
"Terminal closed (ECONNRESET) for session",
|
||||
info.ip
|
||||
);
|
||||
if (typeof clientCount[info.ip] !== "undefined")
|
||||
clientCount[info.ip] = clientCount[info.ip] - 1;
|
||||
else delete clientCount[info.ip];
|
||||
|
||||
if (interval) clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
console.log("Client error: ", err);
|
||||
});
|
||||
|
||||
client.on("authentication", (ctx) => {
|
||||
if (ctx.method === "password" && LOG_CREDENTIALS)
|
||||
console.log(
|
||||
"Authentication from",
|
||||
info.ip,
|
||||
ctx.method,
|
||||
ctx.username,
|
||||
ctx.password
|
||||
);
|
||||
if (!ctx.username) return ctx.reject(["password"]);
|
||||
if (ctx.method != "password") return ctx.reject(["password"]);
|
||||
if (!ctx.password) return ctx.reject(["password"]);
|
||||
|
||||
ctx.accept();
|
||||
});
|
||||
|
||||
client.on("session", (accept, reject) => {
|
||||
const session = accept();
|
||||
|
||||
let height = 100;
|
||||
let width = 100;
|
||||
|
||||
session.once("pty", (accept, reject, data) => {
|
||||
console.log("Opening pty for session", info.ip);
|
||||
height = data.rows;
|
||||
width = data.cols;
|
||||
accept();
|
||||
});
|
||||
|
||||
session.on("window-change", (accept, reject, data) => {
|
||||
// console.log("Terminal resized for session", info.ip);
|
||||
height = data.rows;
|
||||
width = data.cols;
|
||||
});
|
||||
|
||||
const playVideo = (stream: any, keep_aspect_ratio: boolean) => {
|
||||
stream.setEncoding("utf8");
|
||||
|
||||
console.log("Terminal size: " + width + "x" + height);
|
||||
|
||||
if (typeof fakeLoginText !== "undefined") {
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
stream.write(fakeLoginText);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
let current_frame = 0;
|
||||
let loop_count = 0;
|
||||
|
||||
interval = setInterval(async () => {
|
||||
if (stream.destroyed) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = Buffer.from(
|
||||
videoData.frames[current_frame]
|
||||
);
|
||||
await printFrameASCII(
|
||||
stream,
|
||||
frame,
|
||||
width,
|
||||
height,
|
||||
BRIGHTNESS_THRESHOLD,
|
||||
keep_aspect_ratio
|
||||
);
|
||||
current_frame++;
|
||||
if (current_frame >= videoData.frames.length) {
|
||||
current_frame = 0;
|
||||
loop_count++;
|
||||
if (loop_count >= MAX_LOOP) {
|
||||
clearInterval(interval);
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
if (typeof goodbyeText !== "undefined") {
|
||||
stream.write(goodbyeText);
|
||||
}
|
||||
setTimeout(() => {
|
||||
console.log(
|
||||
"Terminal closed for session",
|
||||
info.ip
|
||||
);
|
||||
if (
|
||||
typeof clientCount[info.ip] !==
|
||||
"undefined"
|
||||
)
|
||||
clientCount[info.ip] =
|
||||
clientCount[info.ip] - 1;
|
||||
else delete clientCount[info.ip];
|
||||
stream.end();
|
||||
client.end();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}, 1000 / videoData.fps);
|
||||
}, LOGIN_DELAY);
|
||||
};
|
||||
|
||||
session.once("exec", (accept, reject, data) => {
|
||||
console.log(
|
||||
"Client",
|
||||
info.ip,
|
||||
"is trying to execute command",
|
||||
'"' + data.command + '"'
|
||||
);
|
||||
const stream = accept();
|
||||
playVideo(stream, false);
|
||||
});
|
||||
|
||||
session.once("shell", (accept, reject) => {
|
||||
console.log("Opening shell for session", info.ip);
|
||||
const stream = accept();
|
||||
playVideo(stream, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST);
|
||||
server.listen(config.port, config.host);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import ssh2 from "ssh2";
|
||||
import { Config } from "./config";
|
||||
import { FramesContainer, resizeFrame, frameToAscii } from "./frames";
|
||||
|
||||
export class ConnectionTracker {
|
||||
private counts: Record<string, number> = {};
|
||||
|
||||
increment(ip: string): number {
|
||||
this.counts[ip] = (this.counts[ip] ?? 0) + 1;
|
||||
return this.counts[ip];
|
||||
}
|
||||
|
||||
decrement(ip: string): void {
|
||||
if (typeof this.counts[ip] === "undefined") return;
|
||||
this.counts[ip] -= 1;
|
||||
if (this.counts[ip] <= 0) delete this.counts[ip];
|
||||
}
|
||||
|
||||
count(ip: string): number {
|
||||
return this.counts[ip] ?? 0;
|
||||
}
|
||||
|
||||
hasReachedLimit(ip: string, max: number): boolean {
|
||||
return this.count(ip) >= max;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerDeps {
|
||||
config: Config;
|
||||
hostKey: Buffer;
|
||||
bannerText?: string;
|
||||
fakeLoginText?: string;
|
||||
goodbyeText?: string;
|
||||
videoData: FramesContainer;
|
||||
}
|
||||
|
||||
export function createServer(deps: ServerDeps): ssh2.Server {
|
||||
const {
|
||||
config,
|
||||
hostKey,
|
||||
bannerText,
|
||||
fakeLoginText,
|
||||
goodbyeText,
|
||||
videoData,
|
||||
} = deps;
|
||||
const tracker = new ConnectionTracker();
|
||||
|
||||
const server = new ssh2.Server({
|
||||
hostKeys: [hostKey],
|
||||
banner: bannerText,
|
||||
});
|
||||
|
||||
server.on("connection", (client, info) => {
|
||||
if (tracker.hasReachedLimit(info.ip, config.maxConnections)) {
|
||||
client.on("error", () => {});
|
||||
client.end();
|
||||
return;
|
||||
}
|
||||
|
||||
tracker.increment(info.ip);
|
||||
console.log("New connection from", info.ip);
|
||||
|
||||
client.on("handshake", () => {
|
||||
console.log("Handshake from", info.ip);
|
||||
});
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const endSession = () => {
|
||||
tracker.decrement(info.ip);
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
|
||||
client.on("close", () => {
|
||||
console.log("Client closed connection from", info.ip);
|
||||
endSession();
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
if (err.message === "read ECONNRESET") {
|
||||
console.log(
|
||||
"Terminal closed (ECONNRESET) for session",
|
||||
info.ip
|
||||
);
|
||||
endSession();
|
||||
return;
|
||||
}
|
||||
console.log("Client error: ", err);
|
||||
});
|
||||
|
||||
client.on("authentication", (ctx) => {
|
||||
if (ctx.method === "password" && config.logCredentials)
|
||||
console.log(
|
||||
"Authentication from",
|
||||
info.ip,
|
||||
ctx.method,
|
||||
ctx.username,
|
||||
ctx.password
|
||||
);
|
||||
if (!ctx.username) return ctx.reject(["password"]);
|
||||
if (ctx.method !== "password") return ctx.reject(["password"]);
|
||||
if (!ctx.password) return ctx.reject(["password"]);
|
||||
|
||||
ctx.accept();
|
||||
});
|
||||
|
||||
client.on("session", (accept, _reject) => {
|
||||
const session = accept();
|
||||
|
||||
let height = 100;
|
||||
let width = 100;
|
||||
|
||||
session.once("pty", (accept, _reject, data) => {
|
||||
console.log("Opening pty for session", info.ip);
|
||||
height = data.rows;
|
||||
width = data.cols;
|
||||
accept();
|
||||
});
|
||||
|
||||
session.on("window-change", (_accept, _reject, data) => {
|
||||
height = data.rows;
|
||||
width = data.cols;
|
||||
});
|
||||
|
||||
const playVideo = (
|
||||
stream: ssh2.ServerChannel,
|
||||
keepAspectRatio: boolean
|
||||
) => {
|
||||
stream.setEncoding("utf8");
|
||||
console.log("Terminal size: " + width + "x" + height);
|
||||
|
||||
if (typeof fakeLoginText !== "undefined") {
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
stream.write(fakeLoginText);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
let currentFrame = 0;
|
||||
let loopCount = 0;
|
||||
|
||||
interval = setInterval(async () => {
|
||||
if (stream.destroyed) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = Buffer.from(
|
||||
videoData.frames[currentFrame]
|
||||
);
|
||||
const resized = await resizeFrame(
|
||||
frame,
|
||||
width,
|
||||
height,
|
||||
keepAspectRatio
|
||||
);
|
||||
const ascii = frameToAscii(
|
||||
resized,
|
||||
config.brightnessThreshold
|
||||
);
|
||||
|
||||
stream.write("ok\x1Bc[0G");
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
stream.write(ascii);
|
||||
|
||||
currentFrame++;
|
||||
if (currentFrame >= videoData.frames.length) {
|
||||
currentFrame = 0;
|
||||
loopCount++;
|
||||
if (loopCount >= config.maxLoop) {
|
||||
clearInterval(interval);
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
if (typeof goodbyeText !== "undefined") {
|
||||
stream.write(goodbyeText);
|
||||
}
|
||||
setTimeout(() => {
|
||||
console.log(
|
||||
"Terminal closed for session",
|
||||
info.ip
|
||||
);
|
||||
tracker.decrement(info.ip);
|
||||
stream.end();
|
||||
client.end();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}, 1000 / videoData.fps);
|
||||
}, config.loginDelay);
|
||||
};
|
||||
|
||||
session.once("exec", (accept, _reject, data) => {
|
||||
console.log(
|
||||
"Client",
|
||||
info.ip,
|
||||
"is trying to execute command",
|
||||
'"' + data.command + '"'
|
||||
);
|
||||
const stream = accept();
|
||||
playVideo(stream, false);
|
||||
});
|
||||
|
||||
session.once("shell", (accept, _reject) => {
|
||||
console.log("Opening shell for session", info.ip);
|
||||
const stream = accept();
|
||||
playVideo(stream, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
+9
-10
@@ -1,20 +1,19 @@
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import fs from "fs";
|
||||
|
||||
export interface FramesContainer {
|
||||
frames: Buffer[];
|
||||
fps: number;
|
||||
}
|
||||
import { FramesContainer } from "./frames";
|
||||
|
||||
export async function process(path: string, output: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(path).ffprobe((err, data) => {
|
||||
if (err) {
|
||||
throw new Error("An error occurred: " + err.message);
|
||||
reject(new Error("An error occurred: " + err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.streams[0].r_frame_rate)
|
||||
throw new Error("Unable to get video fps");
|
||||
if (!data.streams[0].r_frame_rate) {
|
||||
reject(new Error("Unable to get video fps"));
|
||||
return;
|
||||
}
|
||||
|
||||
const videoData: FramesContainer = {
|
||||
frames: [],
|
||||
@@ -29,7 +28,7 @@ export async function process(path: string, output: string): Promise<void> {
|
||||
});
|
||||
|
||||
const ffstream = ffvideo.pipe();
|
||||
ffstream.on("data", (chunk) => {
|
||||
ffstream.on("data", (chunk: Buffer) => {
|
||||
videoData.frames.push(chunk);
|
||||
});
|
||||
|
||||
@@ -43,5 +42,5 @@ export async function process(path: string, output: string): Promise<void> {
|
||||
}
|
||||
|
||||
export default {
|
||||
process
|
||||
process,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user