From 1fb948753fd5747dae186a6c008f368e7a41f8f9 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 11:08:42 -0800 Subject: [PATCH 01/20] chore: add .worktrees/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 03a264b2..787d0981 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ dist/ .migration/ .regression/ +.worktrees/ config.json.backup release-artifacts/ From 382852f0ffbd262814e49a4771a1952059031561 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 11:15:53 -0800 Subject: [PATCH 02/20] feat(telnet): add types, constants, and ProtocolService interface Add foundational telnet types to support telnet protocol alongside SSH: - TELNET_DEFAULTS constants (port, timeout, term, auth patterns) - TelnetAuthConfig and TelnetConfig interfaces in config types - ProtocolType, ProtocolConnection, TelnetConnectionConfig interfaces - ProtocolService interface (connect, shell, resize, disconnect) - telnet?: ProtocolService on Services interface - telnet?: TelnetConfig on Config interface - Type-level tests verifying constants and interface shapes --- app/constants/core.ts | 11 ++++ app/services/interfaces.ts | 49 +++++++++++++++ app/types/config.ts | 23 +++++++ .../services/telnet/telnet-types.vitest.ts | 61 +++++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 tests/unit/services/telnet/telnet-types.vitest.ts diff --git a/app/constants/core.ts b/app/constants/core.ts index 18843f7c..0a3dc8b6 100644 --- a/app/constants/core.ts +++ b/app/constants/core.ts @@ -109,6 +109,17 @@ export const HEADERS = { STRICT_TRANSPORT_SECURITY: 'Strict-Transport-Security', } as const +export const TELNET_DEFAULTS = { + PORT: 23, + TIMEOUT_MS: 30_000, + TERM: 'vt100', + EXPECT_TIMEOUT_MS: 10_000, + IO_PATH: '/telnet/socket.io', + LOGIN_PROMPT: 'login:\\s*$', + PASSWORD_PROMPT: '[Pp]assword:\\s*$', + FAILURE_PATTERN: 'Login incorrect|Access denied|Login failed', +} as const + export const STREAM_LIMITS = { MAX_EXEC_OUTPUT_BYTES: 10 * 1024 * 1024, // 10MB OUTPUT_RATE_LIMIT_BYTES_PER_SEC: 0, // 0 = unlimited diff --git a/app/services/interfaces.ts b/app/services/interfaces.ts index 3f527e17..fc435018 100644 --- a/app/services/interfaces.ts +++ b/app/services/interfaces.ts @@ -324,6 +324,54 @@ export interface Logger { */ export type { FileService } from './sftp/file-service.js' +/** + * Protocol type discriminator + */ +export type ProtocolType = 'ssh' | 'telnet' + +/** + * Protocol-agnostic connection state + */ +export interface ProtocolConnection { + id: ConnectionId + sessionId: SessionId + protocol: ProtocolType + status: 'connecting' | 'connected' | 'disconnected' | 'error' + createdAt: number + lastActivity: number + host: string + port: number + username?: string +} + +/** + * Telnet-specific connection configuration + */ +export interface TelnetConnectionConfig { + sessionId: SessionId + host: string + port: number + username?: string + password?: string + timeout: number + term: string + loginPrompt?: RegExp + passwordPrompt?: RegExp + failurePattern?: RegExp + expectTimeout?: number +} + +/** + * Protocol service interface (common subset for SSH and Telnet) + */ +export interface ProtocolService { + connect(config: TelnetConnectionConfig): Promise> + shell(connectionId: ConnectionId, options: ShellOptions): Promise> + resize(connectionId: ConnectionId, rows: number, cols: number): Result + disconnect(connectionId: ConnectionId): Promise> + getConnectionStatus(connectionId: ConnectionId): Result +} + /** * Collection of all services */ @@ -334,6 +382,7 @@ export interface Services { session: SessionService sftp?: FileService hostKey?: HostKeyService + telnet?: ProtocolService } /** diff --git a/app/types/config.ts b/app/types/config.ts index 5413be63..8949f923 100644 --- a/app/types/config.ts +++ b/app/types/config.ts @@ -237,6 +237,28 @@ export interface LoggingConfig { readonly syslog?: LoggingSyslogConfig } +/** + * Telnet authentication pattern configuration + */ +export interface TelnetAuthConfig { + loginPrompt: string + passwordPrompt: string + failurePattern: string + expectTimeout: number +} + +/** + * Telnet protocol configuration + */ +export interface TelnetConfig { + enabled: boolean + defaultPort: number + timeout: number + term: string + auth: TelnetAuthConfig + allowedSubnets: string[] +} + /** * Main configuration interface */ @@ -254,6 +276,7 @@ export interface Config { options: OptionsConfig session: SessionConfig sso: SsoConfig + telnet?: TelnetConfig terminal?: TerminalConfig logging?: LoggingConfig allowedSubnets?: string[] diff --git a/tests/unit/services/telnet/telnet-types.vitest.ts b/tests/unit/services/telnet/telnet-types.vitest.ts new file mode 100644 index 00000000..6ad6bc61 --- /dev/null +++ b/tests/unit/services/telnet/telnet-types.vitest.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { TELNET_DEFAULTS } from '../../../../app/constants/core.js' +import type { ProtocolService, ProtocolConnection, TelnetConnectionConfig } from '../../../../app/services/interfaces.js' + +describe('telnet types and constants', () => { + it('should have telnet defaults', () => { + expect(TELNET_DEFAULTS.PORT).toBe(23) + expect(TELNET_DEFAULTS.TIMEOUT_MS).toBe(30_000) + expect(TELNET_DEFAULTS.TERM).toBe('vt100') + expect(TELNET_DEFAULTS.IO_PATH).toBe('/telnet/socket.io') + }) + + it('should have telnet auth defaults', () => { + expect(TELNET_DEFAULTS.LOGIN_PROMPT).toBe('login:\\s*$') + expect(TELNET_DEFAULTS.PASSWORD_PROMPT).toBe('[Pp]assword:\\s*$') + expect(TELNET_DEFAULTS.FAILURE_PATTERN).toBe('Login incorrect|Access denied|Login failed') + expect(TELNET_DEFAULTS.EXPECT_TIMEOUT_MS).toBe(10_000) + }) + + // Type-level assertions: these compile only if the types are correctly defined + it('should allow ProtocolConnection to be typed correctly', () => { + const connection: ProtocolConnection = { + id: 'conn-1' as ProtocolConnection['id'], + sessionId: 'sess-1' as ProtocolConnection['sessionId'], + protocol: 'telnet', + status: 'connected', + createdAt: Date.now(), + lastActivity: Date.now(), + host: 'example.com', + port: 23, + } + expect(connection.protocol).toBe('telnet') + expect(connection.status).toBe('connected') + }) + + it('should allow TelnetConnectionConfig to be typed correctly', () => { + const config: TelnetConnectionConfig = { + sessionId: 'sess-1' as TelnetConnectionConfig['sessionId'], + host: 'example.com', + port: 23, + timeout: 30_000, + term: 'vt100', + } + expect(config.host).toBe('example.com') + expect(config.port).toBe(23) + }) + + // Compile-time check that ProtocolService has the expected shape + it('should define ProtocolService interface shape', () => { + // This test validates at compile time that the interface exists with the right methods. + // At runtime we just verify the type import resolved (non-null module). + const methods: (keyof ProtocolService)[] = [ + 'connect', + 'shell', + 'resize', + 'disconnect', + 'getConnectionStatus', + ] + expect(methods).toHaveLength(5) + }) +}) From b3640647850f4c94adcba7327fc94d82dedacd60 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 11:20:45 -0800 Subject: [PATCH 03/20] feat(telnet): add default config and environment variable mapping Add telnet configuration defaults to DEFAULT_CONFIG_BASE using TELNET_DEFAULTS constants, with deep clone support via cloneTelnetConfig(). Wire up WEBSSH2_TELNET_* environment variable mappings in env-mapper.ts and add TelnetSchema to the Zod validation schema. Includes unit tests for default values, auth patterns, and deep cloning behavior. --- app/config/default-config.ts | 43 +++++++++++++++++++++-- app/config/env-mapper.ts | 9 +++++ app/schemas/config-schema.ts | 25 +++++++++++++ tests/unit/config/telnet-config.vitest.ts | 32 +++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/unit/config/telnet-config.vitest.ts diff --git a/app/config/default-config.ts b/app/config/default-config.ts index f13dfd2e..6169efd9 100644 --- a/app/config/default-config.ts +++ b/app/config/default-config.ts @@ -12,9 +12,10 @@ import type { LoggingStdoutConfig, LoggingSyslogConfig, LoggingSyslogTlsConfig, - SftpConfig + SftpConfig, + TelnetConfig } from '../types/config.js' -import { DEFAULT_AUTH_METHODS, DEFAULTS, STREAM_LIMITS } from '../constants/index.js' +import { DEFAULT_AUTH_METHODS, DEFAULTS, STREAM_LIMITS, TELNET_DEFAULTS } from '../constants/index.js' import { SFTP_DEFAULTS } from '../constants/sftp.js' import { createAuthMethod } from '../types/branded.js' @@ -143,6 +144,19 @@ export const DEFAULT_CONFIG_BASE: Omit & { session: Omit = { WEBSSH2_SSH_SFTP_ALLOWED_PATHS: { path: 'ssh.sftp.allowedPaths', type: 'array' }, WEBSSH2_SSH_SFTP_BLOCKED_EXTENSIONS: { path: 'ssh.sftp.blockedExtensions', type: 'array' }, WEBSSH2_SSH_SFTP_TIMEOUT: { path: 'ssh.sftp.timeout', type: 'number' }, + // Telnet configuration + WEBSSH2_TELNET_ENABLED: { path: 'telnet.enabled', type: 'boolean' }, + WEBSSH2_TELNET_DEFAULT_PORT: { path: 'telnet.defaultPort', type: 'number' }, + WEBSSH2_TELNET_TIMEOUT: { path: 'telnet.timeout', type: 'number' }, + WEBSSH2_TELNET_TERM: { path: 'telnet.term', type: 'string' }, + WEBSSH2_TELNET_AUTH_LOGIN_PROMPT: { path: 'telnet.auth.loginPrompt', type: 'string' }, + WEBSSH2_TELNET_AUTH_PASSWORD_PROMPT: { path: 'telnet.auth.passwordPrompt', type: 'string' }, + WEBSSH2_TELNET_AUTH_FAILURE_PATTERN: { path: 'telnet.auth.failurePattern', type: 'string' }, + WEBSSH2_TELNET_AUTH_EXPECT_TIMEOUT: { path: 'telnet.auth.expectTimeout', type: 'number' }, } /** diff --git a/app/schemas/config-schema.ts b/app/schemas/config-schema.ts index c21105b6..0f158375 100644 --- a/app/schemas/config-schema.ts +++ b/app/schemas/config-schema.ts @@ -284,6 +284,30 @@ const LoggingSchema = z }) .optional() +/** + * Telnet authentication configuration schema + */ +const TelnetAuthSchema = z.object({ + loginPrompt: z.string(), + passwordPrompt: z.string(), + failurePattern: z.string(), + expectTimeout: z.number().int().positive() +}) + +/** + * Telnet configuration schema (optional) + */ +const TelnetSchema = z + .object({ + enabled: z.boolean(), + defaultPort: z.number().int().min(1).max(65535), + timeout: z.number().int().positive(), + term: z.string(), + auth: TelnetAuthSchema, + allowedSubnets: z.array(z.string()) + }) + .optional() + /** * Main configuration schema */ @@ -296,6 +320,7 @@ export const ConfigSchema = z.object({ options: OptionsSchema, session: SessionSchema, sso: SsoSchema, + telnet: TelnetSchema, terminal: TerminalSchema, logging: LoggingSchema, allowedSubnets: z.array(z.string()).optional(), diff --git a/tests/unit/config/telnet-config.vitest.ts b/tests/unit/config/telnet-config.vitest.ts new file mode 100644 index 00000000..dcbaffcb --- /dev/null +++ b/tests/unit/config/telnet-config.vitest.ts @@ -0,0 +1,32 @@ +// tests/unit/config/telnet-config.vitest.ts +// Tests for telnet default configuration + +import { describe, it, expect } from 'vitest' +import { createCompleteDefaultConfig } from '../../../app/config/default-config.js' + +describe('telnet default config', () => { + it('should include telnet config with defaults', () => { + const config = createCompleteDefaultConfig('test-secret') + expect(config.telnet).toBeDefined() + expect(config.telnet?.enabled).toBe(false) + expect(config.telnet?.defaultPort).toBe(23) + expect(config.telnet?.term).toBe('vt100') + expect(config.telnet?.timeout).toBe(30_000) + }) + + it('should have auth config with regex patterns', () => { + const config = createCompleteDefaultConfig('test-secret') + expect(config.telnet?.auth.loginPrompt).toBe('login:\\s*$') + expect(config.telnet?.auth.passwordPrompt).toBe('[Pp]assword:\\s*$') + expect(config.telnet?.auth.failurePattern).toBe('Login incorrect|Access denied|Login failed') + expect(config.telnet?.auth.expectTimeout).toBe(10_000) + }) + + it('should deep clone telnet config', () => { + const config1 = createCompleteDefaultConfig('test-secret') + const config2 = createCompleteDefaultConfig('test-secret') + expect(config1.telnet).not.toBe(config2.telnet) + expect(config1.telnet?.auth).not.toBe(config2.telnet?.auth) + expect(config1.telnet?.allowedSubnets).not.toBe(config2.telnet?.allowedSubnets) + }) +}) From aeaac3157c38674dc98d06016122532b18778461 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 11:26:02 -0800 Subject: [PATCH 04/20] feat(telnet): add IAC negotiation layer for telnet protocol Implement TelnetNegotiator class that handles telnet IAC option negotiation, stripping protocol sequences from terminal data and generating correct responses for DO/WILL/DONT/WONT commands, NAWS window size, and terminal-type subnegotiations. --- app/services/telnet/telnet-negotiation.ts | 315 +++++++++++++++++ .../telnet/telnet-negotiation.vitest.ts | 332 ++++++++++++++++++ 2 files changed, 647 insertions(+) create mode 100644 app/services/telnet/telnet-negotiation.ts create mode 100644 tests/unit/services/telnet/telnet-negotiation.vitest.ts diff --git a/app/services/telnet/telnet-negotiation.ts b/app/services/telnet/telnet-negotiation.ts new file mode 100644 index 00000000..daab3e58 --- /dev/null +++ b/app/services/telnet/telnet-negotiation.ts @@ -0,0 +1,315 @@ +/** + * Telnet IAC (Interpret As Command) option negotiation handler. + * + * Processes raw telnet protocol data, strips IAC command sequences, + * generates appropriate negotiation responses, and returns clean + * terminal data for display. + * + * @see RFC 854 - Telnet Protocol Specification + * @see RFC 855 - Telnet Option Specifications + * @see RFC 1091 - Telnet Terminal-Type Option + * @see RFC 1073 - Telnet Window Size Option (NAWS) + */ + +// ── Telnet protocol constants ────────────────────────────────────────── + +/** Interpret As Command - marks the start of a telnet command sequence */ +export const IAC = 0xFF + +/** Refuse to perform option / confirm option is disabled */ +export const DONT = 0xFE + +/** Request the other side to perform option */ +export const DO = 0xFD + +/** Refuse to perform option / confirm option is disabled (local) */ +export const WONT = 0xFC + +/** Agree to perform option / confirm option is enabled (local) */ +export const WILL = 0xFB + +/** Subnegotiation Begin */ +export const SB = 0xFA + +/** Subnegotiation End */ +export const SE = 0xF0 + +// ── Option codes ─────────────────────────────────────────────────────── + +/** Echo option */ +export const ECHO = 1 + +/** Suppress Go Ahead */ +export const SGA = 3 + +/** Terminal Type option */ +export const TERMINAL_TYPE = 24 + +/** Negotiate About Window Size */ +export const NAWS = 31 + +// ── Subnegotiation qualifiers ────────────────────────────────────────── + +/** IS qualifier for subnegotiation responses */ +export const IS = 0 + +/** SEND qualifier for subnegotiation requests */ +export const SEND = 1 + +// ── Supported options set ────────────────────────────────────────────── + +const SUPPORTED_OPTIONS: ReadonlySet = new Set([ + ECHO, + SGA, + TERMINAL_TYPE, + NAWS, +]) + +// ── Parser state enum ────────────────────────────────────────────────── + +const enum ParserState { + Data = 0, + GotIAC = 1, + GotCommand = 2, + InSubnegotiation = 3, + SubnegotiationGotIAC = 4, +} + +// ── Types ────────────────────────────────────────────────────────────── + +interface ProcessResult { + /** Terminal data with IAC sequences removed */ + cleanData: Buffer + /** IAC responses to send back to server */ + responses: Buffer[] +} + +// ── TelnetNegotiator ─────────────────────────────────────────────────── + +export class TelnetNegotiator { + private readonly terminalType: string + private cols: number + private rows: number + + private state: ParserState = ParserState.Data + private currentCommand = 0 + private subnegBuffer: number[] = [] + + constructor(terminalType: string = 'vt100') { + this.terminalType = terminalType + this.cols = 80 + this.rows = 24 + } + + /** + * Update the stored window dimensions (used by NAWS). + */ + setWindowSize(cols: number, rows: number): void { + this.cols = cols + this.rows = rows + } + + /** + * Process incoming data from the telnet server. + * Strips IAC sequences, generates responses, returns clean terminal data. + */ + processInbound(data: Buffer): ProcessResult { + const cleanBytes: number[] = [] + const responses: Buffer[] = [] + + for (const byte of data) { + this.processOneByte(byte, cleanBytes, responses) + } + + return { + cleanData: Buffer.from(cleanBytes), + responses, + } + } + + /** + * Encode NAWS (window size) subnegotiation. + * Returns: IAC SB NAWS IAC SE + * Note: If any byte in width/height equals 0xFF, it must be doubled (IAC escape) + */ + encodeNaws(cols: number, rows: number): Buffer { + const widthHigh = (cols >> 8) & 0xFF + const widthLow = cols & 0xFF + const heightHigh = (rows >> 8) & 0xFF + const heightLow = rows & 0xFF + + const bytes: number[] = [IAC, SB, NAWS] + appendEscaped(bytes, widthHigh) + appendEscaped(bytes, widthLow) + appendEscaped(bytes, heightHigh) + appendEscaped(bytes, heightLow) + bytes.push(IAC, SE) + + return Buffer.from(bytes) + } + + /** + * Encode TERMINAL-TYPE IS subnegotiation response. + * Returns: IAC SB TERMINAL-TYPE IS IAC SE + */ + encodeTerminalType(): Buffer { + const termBytes = Buffer.from(this.terminalType, 'ascii') + return Buffer.concat([ + Buffer.from([IAC, SB, TERMINAL_TYPE, IS]), + termBytes, + Buffer.from([IAC, SE]), + ]) + } + + // ── Private helpers ──────────────────────────────────────────────── + + private processOneByte( + byte: number, + cleanBytes: number[], + responses: Buffer[], + ): void { + switch (this.state) { + case ParserState.Data: { + this.handleDataByte(byte, cleanBytes) + break + } + case ParserState.GotIAC: { + this.handleAfterIAC(byte, cleanBytes) + break + } + case ParserState.GotCommand: { + this.handleOptionByte(byte, responses) + break + } + case ParserState.InSubnegotiation: { + this.handleSubnegByte(byte) + break + } + case ParserState.SubnegotiationGotIAC: { + this.handleSubnegIACByte(byte, responses) + break + } + } + } + + private handleDataByte(byte: number, cleanBytes: number[]): void { + if (byte === IAC) { + this.state = ParserState.GotIAC + } else { + cleanBytes.push(byte) + } + } + + private handleAfterIAC( + byte: number, + cleanBytes: number[], + ): void { + if (byte === IAC) { + // IAC IAC → literal 0xFF + cleanBytes.push(0xFF) + this.state = ParserState.Data + return + } + + if (byte === SB) { + this.subnegBuffer = [] + this.state = ParserState.InSubnegotiation + return + } + + if (byte === DO || byte === DONT || byte === WILL || byte === WONT) { + this.currentCommand = byte + this.state = ParserState.GotCommand + return + } + + // Unknown command byte after IAC - ignore and return to data state + this.state = ParserState.Data + } + + private handleOptionByte(byte: number, responses: Buffer[]): void { + const command = this.currentCommand + this.state = ParserState.Data + + if (command === DO) { + this.handleDo(byte, responses) + } else if (command === DONT) { + responses.push(Buffer.from([IAC, WONT, byte])) + } else if (command === WILL) { + this.handleWill(byte, responses) + } else if (command === WONT) { + responses.push(Buffer.from([IAC, DONT, byte])) + } + } + + private handleDo(option: number, responses: Buffer[]): void { + if (SUPPORTED_OPTIONS.has(option)) { + responses.push(Buffer.from([IAC, WILL, option])) + + // NAWS: immediately send window size after WILL + if (option === NAWS) { + responses.push(this.encodeNaws(this.cols, this.rows)) + } + } else { + responses.push(Buffer.from([IAC, WONT, option])) + } + } + + private handleWill(option: number, responses: Buffer[]): void { + if (SUPPORTED_OPTIONS.has(option)) { + responses.push(Buffer.from([IAC, DO, option])) + } else { + responses.push(Buffer.from([IAC, DONT, option])) + } + } + + private handleSubnegByte(byte: number): void { + if (byte === IAC) { + this.state = ParserState.SubnegotiationGotIAC + } else { + this.subnegBuffer.push(byte) + } + } + + private handleSubnegIACByte(byte: number, responses: Buffer[]): void { + if (byte === SE) { + this.processSubnegotiation(responses) + this.state = ParserState.Data + } else if (byte === IAC) { + // Escaped 0xFF inside subnegotiation + this.subnegBuffer.push(0xFF) + this.state = ParserState.InSubnegotiation + } else { + // Unexpected byte after IAC in subneg - treat as end + this.state = ParserState.Data + } + } + + private processSubnegotiation(responses: Buffer[]): void { + if (this.subnegBuffer.length < 2) { + return + } + + const option = this.subnegBuffer[0] + const qualifier = this.subnegBuffer[1] + + if (option === TERMINAL_TYPE && qualifier === SEND) { + responses.push(this.encodeTerminalType()) + } + + this.subnegBuffer = [] + } +} + +// ── Module-level helpers ───────────────────────────────────────────── + +/** + * Append a byte to the array, doubling it if it equals 0xFF (IAC escape). + */ +function appendEscaped(bytes: number[], value: number): void { + if (value === IAC) { + bytes.push(IAC, IAC) + } else { + bytes.push(value) + } +} diff --git a/tests/unit/services/telnet/telnet-negotiation.vitest.ts b/tests/unit/services/telnet/telnet-negotiation.vitest.ts new file mode 100644 index 00000000..18631d64 --- /dev/null +++ b/tests/unit/services/telnet/telnet-negotiation.vitest.ts @@ -0,0 +1,332 @@ +import { describe, it, expect } from 'vitest' +import { TelnetNegotiator, IAC, DO, DONT, WILL, WONT, SB, SE, ECHO, SGA, NAWS, TERMINAL_TYPE, SEND, IS } from '../../../../app/services/telnet/telnet-negotiation.js' + +describe('TelnetNegotiator', () => { + describe('processInbound - strip IAC sequences from mixed data', () => { + it('should pass through clean data unchanged when no IAC sequences present', () => { + const negotiator = new TelnetNegotiator() + const input = Buffer.from('Hello, world!') + const result = negotiator.processInbound(input) + + expect(result.cleanData.toString()).toBe('Hello, world!') + expect(result.responses).toHaveLength(0) + }) + + it('should strip IAC sequences from mixed data and return only clean data', () => { + const negotiator = new TelnetNegotiator() + // "Hi" + IAC DO ECHO + "there" + const input = Buffer.from([ + 0x48, 0x69, // "Hi" + IAC, DO, ECHO, // IAC DO ECHO + 0x74, 0x68, 0x65, 0x72, 0x65, // "there" + ]) + const result = negotiator.processInbound(input) + + expect(result.cleanData.toString()).toBe('Hithere') + expect(result.responses.length).toBeGreaterThan(0) + }) + }) + + describe('processInbound - DO negotiations', () => { + it('should respond WILL ECHO when server sends DO ECHO', () => { + const negotiator = new TelnetNegotiator() + const input = Buffer.from([IAC, DO, ECHO]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + expect(result.responses).toHaveLength(1) + expect(result.responses[0]).toEqual(Buffer.from([IAC, WILL, ECHO])) + }) + + it('should respond WILL SGA when server sends DO SGA', () => { + const negotiator = new TelnetNegotiator() + const input = Buffer.from([IAC, DO, SGA]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + expect(result.responses).toHaveLength(1) + expect(result.responses[0]).toEqual(Buffer.from([IAC, WILL, SGA])) + }) + + it('should respond WONT for unsupported DO option', () => { + const negotiator = new TelnetNegotiator() + const unsupportedOption = 50 + const input = Buffer.from([IAC, DO, unsupportedOption]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + expect(result.responses).toHaveLength(1) + expect(result.responses[0]).toEqual(Buffer.from([IAC, WONT, unsupportedOption])) + }) + + it('should respond WILL TERMINAL_TYPE when server sends DO TERMINAL_TYPE', () => { + const negotiator = new TelnetNegotiator() + const input = Buffer.from([IAC, DO, TERMINAL_TYPE]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + expect(result.responses).toHaveLength(1) + expect(result.responses[0]).toEqual(Buffer.from([IAC, WILL, TERMINAL_TYPE])) + }) + }) + + describe('processInbound - WILL negotiations', () => { + it('should respond DO for supported WILL option', () => { + const negotiator = new TelnetNegotiator() + const input = Buffer.from([IAC, WILL, ECHO]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + expect(result.responses).toHaveLength(1) + expect(result.responses[0]).toEqual(Buffer.from([IAC, DO, ECHO])) + }) + + it('should respond DONT for unsupported WILL option', () => { + const negotiator = new TelnetNegotiator() + const unsupportedOption = 50 + const input = Buffer.from([IAC, WILL, unsupportedOption]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + expect(result.responses).toHaveLength(1) + expect(result.responses[0]).toEqual(Buffer.from([IAC, DONT, unsupportedOption])) + }) + }) + + describe('processInbound - DO NAWS triggers WILL NAWS + NAWS size report', () => { + it('should respond with WILL NAWS and immediately send NAWS size', () => { + const negotiator = new TelnetNegotiator() + const input = Buffer.from([IAC, DO, NAWS]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + // Should have two responses: WILL NAWS + NAWS subnegotiation + expect(result.responses).toHaveLength(2) + expect(result.responses[0]).toEqual(Buffer.from([IAC, WILL, NAWS])) + // Default size is 80x24 + expect(result.responses[1]).toEqual( + Buffer.from([IAC, SB, NAWS, 0, 80, 0, 24, IAC, SE]) + ) + }) + }) + + describe('processInbound - terminal type subnegotiation', () => { + it('should respond to terminal type SEND subnegotiation', () => { + const negotiator = new TelnetNegotiator('xterm-256color') + // IAC SB TERMINAL_TYPE SEND IAC SE + const input = Buffer.from([IAC, SB, TERMINAL_TYPE, SEND, IAC, SE]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + expect(result.responses).toHaveLength(1) + + // Expected: IAC SB TERMINAL_TYPE IS IAC SE + const termBytes = Buffer.from('xterm-256color', 'ascii') + const expected = Buffer.concat([ + Buffer.from([IAC, SB, TERMINAL_TYPE, IS]), + termBytes, + Buffer.from([IAC, SE]), + ]) + expect(result.responses[0]).toEqual(expected) + }) + + it('should use default terminal type vt100 when not specified', () => { + const negotiator = new TelnetNegotiator() + const input = Buffer.from([IAC, SB, TERMINAL_TYPE, SEND, IAC, SE]) + const result = negotiator.processInbound(input) + + expect(result.responses).toHaveLength(1) + const termBytes = Buffer.from('vt100', 'ascii') + const expected = Buffer.concat([ + Buffer.from([IAC, SB, TERMINAL_TYPE, IS]), + termBytes, + Buffer.from([IAC, SE]), + ]) + expect(result.responses[0]).toEqual(expected) + }) + }) + + describe('processInbound - IAC IAC escape', () => { + it('should decode IAC IAC (0xFF 0xFF) as single 0xFF in clean data', () => { + const negotiator = new TelnetNegotiator() + // "A" + IAC IAC + "B" + const input = Buffer.from([0x41, IAC, IAC, 0x42]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toEqual(Buffer.from([0x41, 0xFF, 0x42])) + expect(result.responses).toHaveLength(0) + }) + }) + + describe('processInbound - partial IAC at buffer boundary', () => { + it('should buffer incomplete IAC sequence and complete on next call', () => { + const negotiator = new TelnetNegotiator() + + // First chunk: data + IAC with no following byte + const chunk1 = Buffer.from([0x41, 0x42, IAC]) + const result1 = negotiator.processInbound(chunk1) + expect(result1.cleanData).toEqual(Buffer.from([0x41, 0x42])) + expect(result1.responses).toHaveLength(0) + + // Second chunk: the rest of the IAC DO ECHO + const chunk2 = Buffer.from([DO, ECHO, 0x43]) + const result2 = negotiator.processInbound(chunk2) + expect(result2.cleanData).toEqual(Buffer.from([0x43])) + expect(result2.responses).toHaveLength(1) + expect(result2.responses[0]).toEqual(Buffer.from([IAC, WILL, ECHO])) + }) + + it('should buffer partial IAC DO at boundary (IAC DO but no option byte)', () => { + const negotiator = new TelnetNegotiator() + + // First chunk: IAC DO (missing option byte) + const chunk1 = Buffer.from([IAC, DO]) + const result1 = negotiator.processInbound(chunk1) + expect(result1.cleanData).toHaveLength(0) + expect(result1.responses).toHaveLength(0) + + // Second chunk: the option byte + data + const chunk2 = Buffer.from([ECHO, 0x44]) + const result2 = negotiator.processInbound(chunk2) + expect(result2.cleanData).toEqual(Buffer.from([0x44])) + expect(result2.responses).toHaveLength(1) + expect(result2.responses[0]).toEqual(Buffer.from([IAC, WILL, ECHO])) + }) + + it('should buffer partial subnegotiation at boundary', () => { + const negotiator = new TelnetNegotiator() + + // First chunk: IAC SB TERMINAL_TYPE SEND (missing IAC SE) + const chunk1 = Buffer.from([IAC, SB, TERMINAL_TYPE, SEND]) + const result1 = negotiator.processInbound(chunk1) + expect(result1.cleanData).toHaveLength(0) + expect(result1.responses).toHaveLength(0) + + // Second chunk: IAC SE to close subnegotiation + const chunk2 = Buffer.from([IAC, SE]) + const result2 = negotiator.processInbound(chunk2) + expect(result2.cleanData).toHaveLength(0) + expect(result2.responses).toHaveLength(1) + }) + }) + + describe('encodeNaws', () => { + it('should encode NAWS correctly for cols=80, rows=24', () => { + const negotiator = new TelnetNegotiator() + const result = negotiator.encodeNaws(80, 24) + + expect(result).toEqual( + Buffer.from([IAC, SB, NAWS, 0, 80, 0, 24, IAC, SE]) + ) + }) + + it('should encode NAWS with high byte for large dimensions', () => { + const negotiator = new TelnetNegotiator() + // cols=256 → high=1, low=0 + const result = negotiator.encodeNaws(256, 50) + + expect(result).toEqual( + Buffer.from([IAC, SB, NAWS, 1, 0, 0, 50, IAC, SE]) + ) + }) + + it('should escape byte value 0xFF in NAWS encoding', () => { + const negotiator = new TelnetNegotiator() + // cols=255 (0xFF) must be escaped as 0xFF 0xFF + const result = negotiator.encodeNaws(255, 24) + + // Width: high=0, low=0xFF → escaped as 0xFF 0xFF + expect(result).toEqual( + Buffer.from([IAC, SB, NAWS, 0, IAC, IAC, 0, 24, IAC, SE]) + ) + }) + + it('should escape 0xFF in high byte of dimensions', () => { + const negotiator = new TelnetNegotiator() + // cols=65535 (0xFF, 0xFF) → both bytes must be escaped + const result = negotiator.encodeNaws(65535, 24) + + expect(result).toEqual( + Buffer.from([IAC, SB, NAWS, IAC, IAC, IAC, IAC, 0, 24, IAC, SE]) + ) + }) + + it('should escape 0xFF in row bytes', () => { + const negotiator = new TelnetNegotiator() + // rows=255 (0x00, 0xFF) + const result = negotiator.encodeNaws(80, 255) + + expect(result).toEqual( + Buffer.from([IAC, SB, NAWS, 0, 80, 0, IAC, IAC, IAC, SE]) + ) + }) + }) + + describe('encodeTerminalType', () => { + it('should encode terminal type subnegotiation response', () => { + const negotiator = new TelnetNegotiator('vt100') + const result = negotiator.encodeTerminalType() + + const termBytes = Buffer.from('vt100', 'ascii') + const expected = Buffer.concat([ + Buffer.from([IAC, SB, TERMINAL_TYPE, IS]), + termBytes, + Buffer.from([IAC, SE]), + ]) + expect(result).toEqual(expected) + }) + + it('should encode custom terminal type', () => { + const negotiator = new TelnetNegotiator('xterm-256color') + const result = negotiator.encodeTerminalType() + + const termBytes = Buffer.from('xterm-256color', 'ascii') + const expected = Buffer.concat([ + Buffer.from([IAC, SB, TERMINAL_TYPE, IS]), + termBytes, + Buffer.from([IAC, SE]), + ]) + expect(result).toEqual(expected) + }) + }) + + describe('processInbound - multiple IAC sequences in one buffer', () => { + it('should handle multiple DO commands in one buffer', () => { + const negotiator = new TelnetNegotiator() + const input = Buffer.from([ + IAC, DO, ECHO, + IAC, DO, SGA, + IAC, DO, TERMINAL_TYPE, + ]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + expect(result.responses).toHaveLength(3) + expect(result.responses[0]).toEqual(Buffer.from([IAC, WILL, ECHO])) + expect(result.responses[1]).toEqual(Buffer.from([IAC, WILL, SGA])) + expect(result.responses[2]).toEqual(Buffer.from([IAC, WILL, TERMINAL_TYPE])) + }) + }) + + describe('processInbound - DONT and WONT commands', () => { + it('should respond WONT to DONT', () => { + const negotiator = new TelnetNegotiator() + const input = Buffer.from([IAC, DONT, ECHO]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + expect(result.responses).toHaveLength(1) + expect(result.responses[0]).toEqual(Buffer.from([IAC, WONT, ECHO])) + }) + + it('should respond DONT to WONT', () => { + const negotiator = new TelnetNegotiator() + const input = Buffer.from([IAC, WONT, ECHO]) + const result = negotiator.processInbound(input) + + expect(result.cleanData).toHaveLength(0) + expect(result.responses).toHaveLength(1) + expect(result.responses[0]).toEqual(Buffer.from([IAC, DONT, ECHO])) + }) + }) +}) From 06af9a2bed59557749cae65ca3570267390eea6e Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 11:34:47 -0800 Subject: [PATCH 05/20] feat(telnet): add expect-style authenticator for telnet login prompts Implement TelnetAuthenticator state machine that automates login/password entry by matching incoming data against configurable prompt patterns. Falls through to pass-through mode when no auth is configured or on timeout. --- app/services/telnet/telnet-auth.ts | 211 ++++++++++ .../services/telnet/telnet-auth.vitest.ts | 387 ++++++++++++++++++ 2 files changed, 598 insertions(+) create mode 100644 app/services/telnet/telnet-auth.ts create mode 100644 tests/unit/services/telnet/telnet-auth.vitest.ts diff --git a/app/services/telnet/telnet-auth.ts b/app/services/telnet/telnet-auth.ts new file mode 100644 index 00000000..ee76c7d2 --- /dev/null +++ b/app/services/telnet/telnet-auth.ts @@ -0,0 +1,211 @@ +/** + * Expect-style authentication for telnet login prompts. + * + * A state machine that optionally automates login/password entry + * by matching incoming data against configurable prompt patterns. + * When no auth is configured (or on timeout), falls through to + * pass-through mode where all data goes directly to the client. + */ + +// ── Types ──────────────────────────────────────────────────────────── + +export type TelnetAuthState = + | 'waiting-login' + | 'waiting-password' + | 'waiting-result' + | 'authenticated' + | 'pass-through' + | 'failed' + +export interface TelnetAuthResult { + ok: boolean + state: TelnetAuthState + bufferedData: Buffer +} + +export interface TelnetAuthOptions { + username?: string + password?: string + loginPrompt?: RegExp + passwordPrompt?: RegExp + failurePattern?: RegExp + expectTimeout: number +} + +interface ProcessDataResult { + writeToSocket: Buffer | null + forwardToClient: Buffer | null +} + +// ── TelnetAuthenticator ────────────────────────────────────────────── + +export class TelnetAuthenticator { + private readonly options: TelnetAuthOptions + private currentState: TelnetAuthState + private buffer: Buffer = Buffer.alloc(0) + private timeoutHandle: ReturnType | null = null + + constructor(options: TelnetAuthOptions) { + this.options = options + this.currentState = resolveInitialState(options) + } + + /** + * Get current authentication state. + */ + get state(): TelnetAuthState { + return this.currentState + } + + /** + * Process incoming data from the telnet connection. + * + * Returns what to write to the socket (e.g. username/password) and + * what data to forward to the client terminal. + * + * In pass-through state, all data goes directly to forwardToClient. + */ + processData(data: Buffer): ProcessDataResult { + switch (this.currentState) { + case 'waiting-login': + return this.handleWaitingLogin(data) + case 'waiting-password': + return this.handleWaitingPassword(data) + case 'waiting-result': + return this.handleWaitingResult(data) + case 'authenticated': + case 'pass-through': + return { writeToSocket: null, forwardToClient: data } + case 'failed': + return { writeToSocket: null, forwardToClient: data } + } + } + + /** + * Start the auth timeout. Call after connection is established. + * On timeout, transitions to pass-through and flushes buffered data. + */ + startTimeout(onTimeout: (bufferedData: Buffer) => void): void { + this.cancelTimeout() + this.timeoutHandle = setTimeout(() => { + if (isWaitingState(this.currentState)) { + const buffered = this.buffer + this.buffer = Buffer.alloc(0) + this.currentState = 'pass-through' + onTimeout(buffered) + } + }, this.options.expectTimeout) + } + + /** + * Cancel the timeout (call on successful auth or explicit cancel). + */ + cancelTimeout(): void { + if (this.timeoutHandle !== null) { + clearTimeout(this.timeoutHandle) + this.timeoutHandle = null + } + } + + /** + * Clean up resources (timers). + */ + destroy(): void { + this.cancelTimeout() + } + + // ── Private state handlers ───────────────────────────────────────── + + private handleWaitingLogin(data: Buffer): ProcessDataResult { + this.buffer = Buffer.concat([this.buffer, data]) + const loginPrompt = this.options.loginPrompt + + if (loginPrompt === undefined) { + return noAction() + } + + const text = this.buffer.toString('utf-8') + if (loginPrompt.test(text)) { + this.buffer = Buffer.alloc(0) + const nextState = hasPassword(this.options) + ? 'waiting-password' + : 'waiting-result' + this.currentState = nextState + return { + writeToSocket: Buffer.from(`${this.options.username ?? ''}\r\n`), + forwardToClient: null, + } + } + + return noAction() + } + + private handleWaitingPassword(data: Buffer): ProcessDataResult { + this.buffer = Buffer.concat([this.buffer, data]) + const passwordPrompt = this.options.passwordPrompt + + if (passwordPrompt === undefined) { + return noAction() + } + + const text = this.buffer.toString('utf-8') + if (passwordPrompt.test(text)) { + this.buffer = Buffer.alloc(0) + this.currentState = 'waiting-result' + return { + writeToSocket: Buffer.from(`${this.options.password ?? ''}\r\n`), + forwardToClient: null, + } + } + + return noAction() + } + + private handleWaitingResult(data: Buffer): ProcessDataResult { + this.buffer = Buffer.concat([this.buffer, data]) + const text = this.buffer.toString('utf-8') + + // Check for explicit failure + if (this.options.failurePattern?.test(text) === true) { + this.currentState = 'failed' + this.buffer = Buffer.alloc(0) + this.cancelTimeout() + return { writeToSocket: null, forwardToClient: data } + } + + // Heuristic: if we received data without a failure match, consider + // authentication successful. We look for any content arriving (the + // server sent something back that is not a failure message). + if (text.length > 0) { + this.currentState = 'authenticated' + this.buffer = Buffer.alloc(0) + this.cancelTimeout() + return { writeToSocket: null, forwardToClient: data } + } + + return noAction() + } +} + +// ── Module-level helpers ───────────────────────────────────────────── + +function resolveInitialState(options: TelnetAuthOptions): TelnetAuthState { + if (options.loginPrompt === undefined || options.username === undefined) { + return 'pass-through' + } + return 'waiting-login' +} + +function hasPassword(options: TelnetAuthOptions): boolean { + return options.password !== undefined +} + +function isWaitingState(state: TelnetAuthState): boolean { + return state === 'waiting-login' + || state === 'waiting-password' + || state === 'waiting-result' +} + +function noAction(): ProcessDataResult { + return { writeToSocket: null, forwardToClient: null } +} diff --git a/tests/unit/services/telnet/telnet-auth.vitest.ts b/tests/unit/services/telnet/telnet-auth.vitest.ts new file mode 100644 index 00000000..9d8bf068 --- /dev/null +++ b/tests/unit/services/telnet/telnet-auth.vitest.ts @@ -0,0 +1,387 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { TEST_USERNAME, TEST_PASSWORD } from '../../../test-constants.js' +import { + TelnetAuthenticator, + type TelnetAuthOptions, + type TelnetAuthState, +} from '../../../../app/services/telnet/telnet-auth.js' + +// ── Helpers ────────────────────────────────────────────────────────── + +function baseOptions(): TelnetAuthOptions { + return { + username: TEST_USERNAME, + password: TEST_PASSWORD, + loginPrompt: /login:\s*$/, + passwordPrompt: /[Pp]assword:\s*$/, + failurePattern: /Login incorrect|Access denied|Login failed/, + expectTimeout: 10_000, + } +} + +function withoutLogin(): TelnetAuthOptions { + return { expectTimeout: 10_000 } +} + +function withoutUsername(): TelnetAuthOptions { + return { + loginPrompt: /login:\s*$/, + passwordPrompt: /[Pp]assword:\s*$/, + failurePattern: /Login incorrect|Access denied|Login failed/, + expectTimeout: 10_000, + } +} + +function withoutPassword(): TelnetAuthOptions { + return { + username: TEST_USERNAME, + loginPrompt: /login:\s*$/, + passwordPrompt: /[Pp]assword:\s*$/, + failurePattern: /Login incorrect|Access denied|Login failed/, + expectTimeout: 10_000, + } +} + +function withTimeout(ms: number): TelnetAuthOptions { + return { + username: TEST_USERNAME, + password: TEST_PASSWORD, + loginPrompt: /login:\s*$/, + passwordPrompt: /[Pp]assword:\s*$/, + failurePattern: /Login incorrect|Access denied|Login failed/, + expectTimeout: ms, + } +} + +// ── Tests ──────────────────────────────────────────────────────────── + +describe('TelnetAuthenticator', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + // Test 1: Immediate pass-through when no patterns configured + describe('immediate pass-through', () => { + it('should enter pass-through when no loginPrompt configured', () => { + const auth = new TelnetAuthenticator( + withoutLogin(), + ) + + expect(auth.state).toBe('pass-through' satisfies TelnetAuthState) + + const data = Buffer.from('Welcome to the system') + const result = auth.processData(data) + + expect(result.writeToSocket).toBeNull() + expect(result.forwardToClient).toEqual(data) + + auth.destroy() + }) + + // Test 2: Immediate pass-through when no username configured + it('should enter pass-through when no username configured', () => { + const auth = new TelnetAuthenticator( + withoutUsername(), + ) + + expect(auth.state).toBe('pass-through' satisfies TelnetAuthState) + + const data = Buffer.from('login: ') + const result = auth.processData(data) + + expect(result.writeToSocket).toBeNull() + expect(result.forwardToClient).toEqual(data) + + auth.destroy() + }) + }) + + // Test 3: Detect login prompt and return username to write + describe('login prompt detection', () => { + it('should detect login prompt and write username to socket', () => { + const auth = new TelnetAuthenticator(baseOptions()) + + const data = Buffer.from('Welcome\r\nlogin: ') + const result = auth.processData(data) + + expect(result.writeToSocket).toEqual( + Buffer.from(`${TEST_USERNAME}\r\n`), + ) + // Data during auth is buffered, not forwarded + expect(result.forwardToClient).toBeNull() + + auth.destroy() + }) + }) + + // Test 4: Detect password prompt and return password to write + describe('password prompt detection', () => { + it('should detect password prompt and write password to socket', () => { + const auth = new TelnetAuthenticator(baseOptions()) + + // First, send login prompt to transition to waiting-password + auth.processData(Buffer.from('login: ')) + + // Now send password prompt + const result = auth.processData(Buffer.from('Password: ')) + + expect(result.writeToSocket).toEqual( + Buffer.from(`${TEST_PASSWORD}\r\n`), + ) + expect(result.forwardToClient).toBeNull() + + auth.destroy() + }) + }) + + // Test 5: Detect auth failure pattern -> failed state + describe('auth failure detection', () => { + it('should detect failure pattern and transition to failed', () => { + const auth = new TelnetAuthenticator(baseOptions()) + + // Go through login + auth.processData(Buffer.from('login: ')) + // Go through password + auth.processData(Buffer.from('Password: ')) + + // Server responds with failure + const failData = Buffer.from('Login incorrect\r\n') + const result = auth.processData(failData) + + expect(auth.state).toBe('failed' satisfies TelnetAuthState) + // Forward failure message to client so they can see it + expect(result.forwardToClient).toEqual(failData) + expect(result.writeToSocket).toBeNull() + + auth.destroy() + }) + }) + + // Test 6: Successful auth flow: login -> password -> authenticated + describe('successful auth flow', () => { + it('should transition through login -> password -> authenticated', () => { + const auth = new TelnetAuthenticator(baseOptions()) + expect(auth.state).toBe('waiting-login' satisfies TelnetAuthState) + + // Login prompt + const loginResult = auth.processData(Buffer.from('login: ')) + expect(auth.state).toBe('waiting-password' satisfies TelnetAuthState) + expect(loginResult.writeToSocket).toEqual( + Buffer.from(`${TEST_USERNAME}\r\n`), + ) + + // Password prompt + const passResult = auth.processData(Buffer.from('Password: ')) + expect(auth.state).toBe('waiting-result' satisfies TelnetAuthState) + expect(passResult.writeToSocket).toEqual( + Buffer.from(`${TEST_PASSWORD}\r\n`), + ) + + // Server sends shell prompt (success indicator) + const shellData = Buffer.from('user@host:~$ ') + const shellResult = auth.processData(shellData) + expect(auth.state).toBe('authenticated' satisfies TelnetAuthState) + expect(shellResult.forwardToClient).toEqual(shellData) + expect(shellResult.writeToSocket).toBeNull() + + auth.destroy() + }) + + it('should skip password and go to waiting-result when no password', () => { + const auth = new TelnetAuthenticator( + withoutPassword(), + ) + expect(auth.state).toBe('waiting-login' satisfies TelnetAuthState) + + const loginResult = auth.processData(Buffer.from('login: ')) + expect(auth.state).toBe('waiting-result' satisfies TelnetAuthState) + expect(loginResult.writeToSocket).toEqual( + Buffer.from(`${TEST_USERNAME}\r\n`), + ) + + auth.destroy() + }) + }) + + // Test 7: Timeout fallback + describe('timeout behavior', () => { + it('should transition to pass-through on timeout and flush buffered data', () => { + vi.useFakeTimers() + + const auth = new TelnetAuthenticator( + withTimeout(5000), + ) + expect(auth.state).toBe('waiting-login' satisfies TelnetAuthState) + + const onTimeout = vi.fn() + auth.startTimeout(onTimeout) + + // Send some data that does NOT match login prompt + auth.processData(Buffer.from('Welcome to the system\r\n')) + auth.processData(Buffer.from('Authorized users only\r\n')) + + // Advance timer past the timeout + vi.advanceTimersByTime(5000) + + expect(auth.state).toBe('pass-through' satisfies TelnetAuthState) + expect(onTimeout).toHaveBeenCalledOnce() + // Callback receives all buffered data + const buffered = onTimeout.mock.calls[0]?.[0] as Buffer + expect(buffered.toString()).toContain('Welcome to the system') + expect(buffered.toString()).toContain('Authorized users only') + + vi.useRealTimers() + auth.destroy() + }) + }) + + // Test 8: Data buffered during auth is NOT forwarded to client + describe('data buffering during auth', () => { + it('should not forward data to client while in waiting states', () => { + const auth = new TelnetAuthenticator(baseOptions()) + + // Data before login prompt + const result1 = auth.processData(Buffer.from('Banner message\r\n')) + expect(result1.forwardToClient).toBeNull() + + // Login prompt data + const result2 = auth.processData(Buffer.from('login: ')) + expect(result2.forwardToClient).toBeNull() + + // Data before password prompt + const result3 = auth.processData(Buffer.from('some noise\r\n')) + expect(result3.forwardToClient).toBeNull() + + auth.destroy() + }) + }) + + // Test 9: After auth completes, data forwarded to client + describe('post-auth data forwarding', () => { + it('should forward all data to client after authentication', () => { + const auth = new TelnetAuthenticator(baseOptions()) + + // Complete auth + auth.processData(Buffer.from('login: ')) + auth.processData(Buffer.from('Password: ')) + auth.processData(Buffer.from('user@host:~$ ')) + expect(auth.state).toBe('authenticated' satisfies TelnetAuthState) + + // Subsequent data should be forwarded + const data = Buffer.from('ls -la\r\nfile1.txt\r\n') + const result = auth.processData(data) + + expect(result.forwardToClient).toEqual(data) + expect(result.writeToSocket).toBeNull() + + auth.destroy() + }) + }) + + // Test 10: Pass-through mode forwards all data immediately + describe('pass-through forwarding', () => { + it('should forward all data immediately in pass-through mode', () => { + const auth = new TelnetAuthenticator( + withoutLogin(), + ) + expect(auth.state).toBe('pass-through' satisfies TelnetAuthState) + + const data1 = Buffer.from('First chunk') + const result1 = auth.processData(data1) + expect(result1.forwardToClient).toEqual(data1) + expect(result1.writeToSocket).toBeNull() + + const data2 = Buffer.from('Second chunk') + const result2 = auth.processData(data2) + expect(result2.forwardToClient).toEqual(data2) + expect(result2.writeToSocket).toBeNull() + + auth.destroy() + }) + }) + + // Test 11: Destroy cancels timeout + describe('destroy', () => { + it('should cancel timeout on destroy', () => { + vi.useFakeTimers() + + const auth = new TelnetAuthenticator( + withTimeout(5000), + ) + const onTimeout = vi.fn() + auth.startTimeout(onTimeout) + + // Destroy before timeout fires + auth.destroy() + + // Advance past the timeout + vi.advanceTimersByTime(10_000) + + // Callback should NOT have been called + expect(onTimeout).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + }) + + // Additional edge cases + describe('edge cases', () => { + it('should handle login prompt split across multiple data chunks', () => { + const auth = new TelnetAuthenticator(baseOptions()) + + // First chunk: partial prompt + const result1 = auth.processData(Buffer.from('logi')) + expect(result1.writeToSocket).toBeNull() + expect(auth.state).toBe('waiting-login' satisfies TelnetAuthState) + + // Second chunk: completes the prompt + const result2 = auth.processData(Buffer.from('n: ')) + expect(result2.writeToSocket).toEqual( + Buffer.from(`${TEST_USERNAME}\r\n`), + ) + + auth.destroy() + }) + + it('should not write to socket in failed state', () => { + const auth = new TelnetAuthenticator(baseOptions()) + + // Complete login + password + auth.processData(Buffer.from('login: ')) + auth.processData(Buffer.from('Password: ')) + auth.processData(Buffer.from('Login incorrect\r\n')) + expect(auth.state).toBe('failed' satisfies TelnetAuthState) + + // More data arrives - should forward but not write + const data = Buffer.from('login: ') + const result = auth.processData(data) + expect(result.writeToSocket).toBeNull() + expect(result.forwardToClient).toEqual(data) + + auth.destroy() + }) + + it('should cancel timeout when auth succeeds', () => { + vi.useFakeTimers() + + const auth = new TelnetAuthenticator( + withTimeout(5000), + ) + const onTimeout = vi.fn() + auth.startTimeout(onTimeout) + + // Complete auth flow + auth.processData(Buffer.from('login: ')) + auth.processData(Buffer.from('Password: ')) + auth.processData(Buffer.from('user@host:~$ ')) + + // Advance past timeout + vi.advanceTimersByTime(10_000) + + expect(onTimeout).not.toHaveBeenCalled() + + vi.useRealTimers() + auth.destroy() + }) + }) +}) From 80779224a493bf3a0517aaff4e77d7b0014792e7 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 11:37:40 -0800 Subject: [PATCH 06/20] feat(telnet): add TelnetConnectionPool for managing telnet connections Simple connection pool mirroring the SSH ConnectionPool pattern. Stores TelnetConnection objects (ProtocolConnection + Socket) by ConnectionId with a secondary session index for efficient lookup by SessionId. Includes add, get, getBySession, remove, clear, and size operations. --- app/services/telnet/telnet-connection-pool.ts | 116 +++++++++++++++ .../telnet/telnet-connection-pool.vitest.ts | 140 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 app/services/telnet/telnet-connection-pool.ts create mode 100644 tests/unit/services/telnet/telnet-connection-pool.vitest.ts diff --git a/app/services/telnet/telnet-connection-pool.ts b/app/services/telnet/telnet-connection-pool.ts new file mode 100644 index 00000000..25c715eb --- /dev/null +++ b/app/services/telnet/telnet-connection-pool.ts @@ -0,0 +1,116 @@ +/** + * Connection pool for managing telnet connections + */ + +import type { Socket } from 'node:net' +import type { ConnectionId, SessionId } from '../../types/branded.js' +import type { ProtocolConnection } from '../interfaces.js' +import debug from 'debug' + +const logger = debug('webssh2:services:telnet:pool') + +/** + * Telnet connection extending the protocol-agnostic connection with a raw socket + */ +export interface TelnetConnection extends ProtocolConnection { + socket: Socket +} + +/** + * Pool for managing telnet connections by session and connection ID + */ +export class TelnetConnectionPool { + private readonly connections = new Map() + private readonly sessionConnections = new Map>() + + /** + * Add a connection to the pool + * @param connection - Telnet connection to add + */ + add(connection: TelnetConnection): void { + this.connections.set(connection.id, connection) + + const sessionSet = this.sessionConnections.get(connection.sessionId) ?? new Set() + sessionSet.add(connection.id) + this.sessionConnections.set(connection.sessionId, sessionSet) + + logger('Added connection %s for session %s (pool size: %d)', connection.id, connection.sessionId, this.connections.size) + } + + /** + * Get a connection by ID + * @param connectionId - Connection ID + * @returns Telnet connection or undefined if not found + */ + get(connectionId: ConnectionId): TelnetConnection | undefined { + return this.connections.get(connectionId) + } + + /** + * Get all connections for a session + * @param sessionId - Session ID + * @returns Array of telnet connections for the session + */ + getBySession(sessionId: SessionId): TelnetConnection[] { + const connectionIds = this.sessionConnections.get(sessionId) + if (connectionIds === undefined) { + return [] + } + + const connections: TelnetConnection[] = [] + for (const id of connectionIds) { + const conn = this.connections.get(id) + if (conn !== undefined) { + connections.push(conn) + } + } + return connections + } + + /** + * Remove a connection from the pool + * @param connectionId - Connection ID to remove + * @returns true if the connection was found and removed, false otherwise + */ + remove(connectionId: ConnectionId): boolean { + const connection = this.connections.get(connectionId) + if (connection === undefined) { + return false + } + + const sessionSet = this.sessionConnections.get(connection.sessionId) + if (sessionSet !== undefined) { + sessionSet.delete(connectionId) + if (sessionSet.size === 0) { + this.sessionConnections.delete(connection.sessionId) + } + } + + this.connections.delete(connectionId) + logger('Removed connection %s (pool size: %d)', connectionId, this.connections.size) + return true + } + + /** + * Clear all connections + */ + clear(): void { + for (const connection of this.connections.values()) { + try { + connection.socket.destroy() + } catch (error) { + logger('Error destroying socket for connection %s: %O', connection.id, error) + } + } + this.connections.clear() + this.sessionConnections.clear() + logger('Cleared all connections') + } + + /** + * Get the number of connections in the pool + */ + get size(): number { + return this.connections.size + } +} diff --git a/tests/unit/services/telnet/telnet-connection-pool.vitest.ts b/tests/unit/services/telnet/telnet-connection-pool.vitest.ts new file mode 100644 index 00000000..6554926f --- /dev/null +++ b/tests/unit/services/telnet/telnet-connection-pool.vitest.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import type { Socket } from 'node:net' +import type { ConnectionId, SessionId } from '../../../../app/types/branded.js' +import { createConnectionId, createSessionId } from '../../../../app/types/branded.js' +import { TelnetConnectionPool } from '../../../../app/services/telnet/telnet-connection-pool.js' +import type { TelnetConnection } from '../../../../app/services/telnet/telnet-connection-pool.js' + +/** + * Create a mock TelnetConnection for testing pool behavior. + * The socket is a mock object since we are testing pool operations, not socket behavior. + */ +const createMockConnection = ( + id: string, + sessionId: string, + host = 'localhost', + port = 23 +): TelnetConnection => ({ + id: createConnectionId(id), + sessionId: createSessionId(sessionId), + protocol: 'telnet' as const, + status: 'connected', + createdAt: Date.now(), + lastActivity: Date.now(), + host, + port, + socket: {} as Socket, +}) + +describe('TelnetConnectionPool', () => { + let pool: TelnetConnectionPool + + beforeEach(() => { + pool = new TelnetConnectionPool() + }) + + it('should add and retrieve connections', () => { + const conn = createMockConnection('conn-1', 'session-1') + pool.add(conn) + + const retrieved = pool.get(createConnectionId('conn-1')) + expect(retrieved).toBeDefined() + expect(retrieved?.id).toBe(conn.id) + expect(retrieved?.sessionId).toBe(conn.sessionId) + expect(retrieved?.host).toBe('localhost') + expect(retrieved?.port).toBe(23) + }) + + it('should return undefined for missing connection', () => { + const result = pool.get(createConnectionId('nonexistent')) + expect(result).toBeUndefined() + }) + + it('should get connections by session', () => { + const sessionId = 'session-1' + const conn1 = createMockConnection('conn-1', sessionId) + const conn2 = createMockConnection('conn-2', sessionId) + const conn3 = createMockConnection('conn-3', 'session-2') + + pool.add(conn1) + pool.add(conn2) + pool.add(conn3) + + const sessionConnections = pool.getBySession(createSessionId(sessionId)) + expect(sessionConnections).toHaveLength(2) + + const ids = sessionConnections.map((c) => c.id) + expect(ids).toContain(conn1.id) + expect(ids).toContain(conn2.id) + }) + + it('should return empty array for unknown session', () => { + const result = pool.getBySession(createSessionId('unknown-session')) + expect(result).toEqual([]) + }) + + it('should remove connections', () => { + const conn = createMockConnection('conn-1', 'session-1') + pool.add(conn) + + const removed = pool.remove(createConnectionId('conn-1')) + expect(removed).toBe(true) + expect(pool.get(createConnectionId('conn-1'))).toBeUndefined() + expect(pool.size).toBe(0) + }) + + it('should return false when removing nonexistent connection', () => { + const removed = pool.remove(createConnectionId('nonexistent')) + expect(removed).toBe(false) + }) + + it('should clear all connections', () => { + pool.add(createMockConnection('conn-1', 'session-1')) + pool.add(createMockConnection('conn-2', 'session-1')) + pool.add(createMockConnection('conn-3', 'session-2')) + + expect(pool.size).toBe(3) + + pool.clear() + + expect(pool.size).toBe(0) + expect(pool.get(createConnectionId('conn-1'))).toBeUndefined() + expect(pool.getBySession(createSessionId('session-1'))).toEqual([]) + expect(pool.getBySession(createSessionId('session-2'))).toEqual([]) + }) + + it('should track size', () => { + expect(pool.size).toBe(0) + + pool.add(createMockConnection('conn-1', 'session-1')) + expect(pool.size).toBe(1) + + pool.add(createMockConnection('conn-2', 'session-1')) + expect(pool.size).toBe(2) + + pool.add(createMockConnection('conn-3', 'session-2')) + expect(pool.size).toBe(3) + + pool.remove(createConnectionId('conn-1')) + expect(pool.size).toBe(2) + + pool.remove(createConnectionId('conn-2')) + expect(pool.size).toBe(1) + }) + + it('should clean up session index when last connection for session is removed', () => { + const conn1 = createMockConnection('conn-1', 'session-1') + const conn2 = createMockConnection('conn-2', 'session-1') + + pool.add(conn1) + pool.add(conn2) + + expect(pool.getBySession(createSessionId('session-1'))).toHaveLength(2) + + pool.remove(createConnectionId('conn-1')) + expect(pool.getBySession(createSessionId('session-1'))).toHaveLength(1) + + pool.remove(createConnectionId('conn-2')) + expect(pool.getBySession(createSessionId('session-1'))).toEqual([]) + }) +}) From d95672d09bd8bd10834d69c12a81380d0ed5745f Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 26 Feb 2026 11:58:08 -0800 Subject: [PATCH 07/20] feat(telnet): add TelnetServiceImpl assembling negotiation, auth, and pool Implements the full ProtocolService for telnet, wiring together TelnetNegotiator (IAC handling), TelnetAuthenticator (expect-style login), and TelnetConnectionPool into connect/shell/resize/disconnect. Key design decisions: - ShellDuplex (Duplex subclass) for bidirectional shell stream - Socket paused in connect(), resumed in shell() to prevent data loss - Connection metadata map stores original config for deferred auth setup - createSocketDataHandler extracted as module-level pure function 16 tests covering connect, shell, resize, disconnect, status, and end-to-end authentication integration. --- app/services/telnet/telnet-connection-pool.ts | 4 + app/services/telnet/telnet-service.ts | 374 +++++++++++++ .../services/telnet/telnet-service.vitest.ts | 518 ++++++++++++++++++ 3 files changed, 896 insertions(+) create mode 100644 app/services/telnet/telnet-service.ts create mode 100644 tests/unit/services/telnet/telnet-service.vitest.ts diff --git a/app/services/telnet/telnet-connection-pool.ts b/app/services/telnet/telnet-connection-pool.ts index 25c715eb..24446513 100644 --- a/app/services/telnet/telnet-connection-pool.ts +++ b/app/services/telnet/telnet-connection-pool.ts @@ -5,6 +5,8 @@ import type { Socket } from 'node:net' import type { ConnectionId, SessionId } from '../../types/branded.js' import type { ProtocolConnection } from '../interfaces.js' +import type { TelnetNegotiator } from './telnet-negotiation.js' +import type { TelnetAuthenticator } from './telnet-auth.js' import debug from 'debug' const logger = debug('webssh2:services:telnet:pool') @@ -14,6 +16,8 @@ const logger = debug('webssh2:services:telnet:pool') */ export interface TelnetConnection extends ProtocolConnection { socket: Socket + negotiator?: TelnetNegotiator + authenticator?: TelnetAuthenticator } /** diff --git a/app/services/telnet/telnet-service.ts b/app/services/telnet/telnet-service.ts new file mode 100644 index 00000000..179fc35d --- /dev/null +++ b/app/services/telnet/telnet-service.ts @@ -0,0 +1,374 @@ +/** + * TelnetServiceImpl - Full ProtocolService implementation for telnet. + * + * Assembles TelnetNegotiator (IAC handling), TelnetAuthenticator + * (expect-style login), and TelnetConnectionPool into a unified + * service that can connect, open a shell, resize, and disconnect. + * + * Key difference from SSH: + * SSH: connect() authenticates -> shell() opens a channel + * Telnet: connect() establishes TCP -> shell() returns a stream + * that handles IAC negotiation + optional auth inline + */ + +import type { + ProtocolService, + ProtocolConnection, + TelnetConnectionConfig, + ShellOptions, + ServiceDependencies, +} from '../interfaces.js' +import { type ConnectionId, createConnectionId } from '../../types/branded.js' +import { type Result, ok, err } from '../../state/types.js' +import { Duplex } from 'node:stream' +import { TelnetConnectionPool, type TelnetConnection } from './telnet-connection-pool.js' +import { TelnetNegotiator } from './telnet-negotiation.js' +import { TelnetAuthenticator, type TelnetAuthOptions } from './telnet-auth.js' +import { randomUUID } from 'node:crypto' +import { Socket as NetSocket } from 'node:net' +import debug from 'debug' + +const logger = debug('webssh2:services:telnet') + +/** + * Per-connection metadata not stored on the pool interface. + * Tracks auth config needed to build the authenticator in shell(). + */ +interface ConnectionMeta { + config: TelnetConnectionConfig +} + +// ── ShellDuplex ──────────────────────────────────────────────────────── + +/** + * A proper Duplex stream for the telnet shell. + * + * Writable side (client -> server): forwards data to the net.Socket. + * Readable side (server -> client): receives IAC-stripped data via pushData(). + */ +class ShellDuplex extends Duplex { + private readonly socket: NetSocket + + constructor(socket: NetSocket) { + super() + this.socket = socket + } + + /** + * Push data to the readable side (called by the telnet data handler). + */ + pushData(chunk: Buffer): void { + this.push(chunk) + } + + override _write( + chunk: Buffer, + _encoding: string, + callback: (error?: Error | null) => void, + ): void { + this.socket.write(chunk, callback) + } + + override _read(_size: number): void { + // Data is pushed via pushData() from the socket data handler. + // No pull-based reading needed. + } + + override _destroy( + _error: Error | null, + callback: (error?: Error | null) => void, + ): void { + callback(null) + } +} + +// ── TelnetServiceImpl ────────────────────────────────────────────────── + +export class TelnetServiceImpl implements ProtocolService { + private readonly pool = new TelnetConnectionPool() + private readonly meta = new Map() + + constructor(private readonly deps: ServiceDependencies) {} + + /** + * Establish a TCP connection to the telnet server. + * + * Unlike SSH, authentication is NOT performed here. It happens + * inside the shell stream via the optional TelnetAuthenticator. + */ + async connect(config: TelnetConnectionConfig): Promise> { + const connectionId = createConnectionId(randomUUID()) + + return new Promise((resolve) => { + const socket = new NetSocket() + let settled = false + + const settle = (result: Result): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + resolve(result) + } + + const timer = setTimeout(() => { + socket.destroy() + settle(err(new Error('Connection timed out'))) + }, config.timeout) + + socket.on('error', (error: Error) => { + socket.destroy() + settle(err(error)) + }) + + socket.connect({ host: config.host, port: config.port }, () => { + logger('TCP connection established to %s:%d', config.host, config.port) + + // Pause the socket so data is buffered until shell() attaches listeners + socket.pause() + + const connection: TelnetConnection = { + id: connectionId, + sessionId: config.sessionId, + protocol: 'telnet', + status: 'connected', + createdAt: Date.now(), + lastActivity: Date.now(), + host: config.host, + port: config.port, + socket, + } + + if (config.username !== undefined) { + connection.username = config.username + } + + this.pool.add(connection) + this.meta.set(connectionId, { config }) + settle(ok(connection)) + }) + }) + } + + /** + * Open a shell stream for an existing telnet connection. + * + * Returns a Duplex stream where: + * write() (client -> server): forwards data to net.Socket + * data event (server -> client): IAC-stripped, auth-processed data + */ + async shell( + connectionId: ConnectionId, + options: ShellOptions, + ): Promise> { + const connection = this.pool.get(connectionId) + if (connection === undefined) { + return Promise.resolve(err(new Error('Connection not found'))) + } + + const negotiator = new TelnetNegotiator(options.term ?? 'xterm-256color') + negotiator.setWindowSize(options.cols ?? 80, options.rows ?? 24) + connection.negotiator = negotiator + + const socket = connection.socket + const shellStream = new ShellDuplex(socket) + + // Build optional authenticator from stored config + const authenticator = this.buildAuthenticator(connectionId) + if (authenticator !== null) { + connection.authenticator = authenticator + } + + // ── Readable side: server -> client ────────────────────────── + const handleSocketData = createSocketDataHandler( + connection, + negotiator, + authenticator, + shellStream, + socket, + ) + + socket.on('data', handleSocketData) + + // Start auth timeout if authenticator is present + if (authenticator !== null) { + authenticator.startTimeout((bufferedData) => { + if (bufferedData.length > 0) { + shellStream.pushData(bufferedData) + } + }) + } + + // Clean up on stream close + const cleanup = (): void => { + socket.removeListener('data', handleSocketData) + if (authenticator !== null) { + authenticator.destroy() + } + } + + shellStream.on('close', cleanup) + shellStream.on('error', cleanup) + socket.on('close', () => { + cleanup() + if (!shellStream.destroyed) { + shellStream.destroy() + } + }) + socket.on('error', () => { + cleanup() + if (!shellStream.destroyed) { + shellStream.destroy() + } + }) + + // Send initial NAWS if dimensions provided + if (options.cols !== undefined && options.rows !== undefined) { + socket.write(negotiator.encodeNaws(options.cols, options.rows)) + } + + // Resume the socket now that we have data listeners attached + socket.resume() + + return Promise.resolve(ok(shellStream)) + } + + /** + * Resize the terminal window (send NAWS subnegotiation). + */ + resize(connectionId: ConnectionId, rows: number, cols: number): Result { + const connection = this.pool.get(connectionId) + if (connection === undefined) { + return err(new Error('Connection not found')) + } + + const negotiator = connection.negotiator + if (negotiator === undefined) { + return err(new Error('Negotiator not initialized (shell not opened)')) + } + + negotiator.setWindowSize(cols, rows) + connection.socket.write(negotiator.encodeNaws(cols, rows)) + return ok(undefined) + } + + /** + * Disconnect a telnet connection. + */ + disconnect(connectionId: ConnectionId): Promise> { + const connection = this.pool.get(connectionId) + if (connection === undefined) { + return Promise.resolve(ok(undefined)) + } + + logger('Disconnecting telnet connection %s', connectionId) + + if (connection.authenticator !== undefined) { + connection.authenticator.destroy() + } + + connection.socket.destroy() + this.pool.remove(connectionId) + this.meta.delete(connectionId) + + return Promise.resolve(ok(undefined)) + } + + /** + * Get the status of a connection. + */ + getConnectionStatus(connectionId: ConnectionId): Result { + const connection = this.pool.get(connectionId) + return ok(connection ?? null) + } + + // ── Private helpers ──────────────────────────────────────────────── + + /** + * Build a TelnetAuthenticator from stored config if auth options + * (loginPrompt + username) were provided during connect(). + */ + private buildAuthenticator(connectionId: ConnectionId): TelnetAuthenticator | null { + const meta = this.meta.get(connectionId) + if (meta === undefined) { + return null + } + + const { config } = meta + + if (config.loginPrompt === undefined || config.username === undefined) { + return null + } + + const authOptions: TelnetAuthOptions = { + username: config.username, + loginPrompt: config.loginPrompt, + expectTimeout: config.expectTimeout ?? 10000, + } + + if (config.password !== undefined) { + authOptions.password = config.password + } + if (config.passwordPrompt !== undefined) { + authOptions.passwordPrompt = config.passwordPrompt + } + if (config.failurePattern !== undefined) { + authOptions.failurePattern = config.failurePattern + } + + return new TelnetAuthenticator(authOptions) + } +} + +// ── Module-level helpers ───────────────────────────────────────────── + +/** + * Check if authenticator has completed (authenticated or pass-through). + */ +function isAuthComplete(auth: TelnetAuthenticator): boolean { + return auth.state === 'authenticated' + || auth.state === 'pass-through' + || auth.state === 'failed' +} + +/** + * Create a socket data handler that processes inbound telnet data. + * + * Runs data through the IAC negotiator, sends negotiation responses + * back to the socket, and optionally processes through the authenticator + * before pushing clean data to the shell stream. + */ +function createSocketDataHandler( + connection: TelnetConnection, + negotiator: TelnetNegotiator, + authenticator: TelnetAuthenticator | null, + shellStream: ShellDuplex, + socket: NetSocket, +): (data: Buffer) => void { + return (data: Buffer): void => { + connection.lastActivity = Date.now() + const { cleanData, responses } = negotiator.processInbound(data) + + for (const response of responses) { + socket.write(response) + } + + if (cleanData.length === 0) { + return + } + + if (authenticator !== null && !isAuthComplete(authenticator)) { + const authResult = authenticator.processData(cleanData) + if (authResult.writeToSocket !== null) { + socket.write(authResult.writeToSocket) + } + if (authResult.forwardToClient !== null && authResult.forwardToClient.length > 0) { + shellStream.pushData(authResult.forwardToClient) + } + return + } + + shellStream.pushData(cleanData) + } +} diff --git a/tests/unit/services/telnet/telnet-service.vitest.ts b/tests/unit/services/telnet/telnet-service.vitest.ts new file mode 100644 index 00000000..69e35030 --- /dev/null +++ b/tests/unit/services/telnet/telnet-service.vitest.ts @@ -0,0 +1,518 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as net from 'node:net' +import { createSessionId, createConnectionId } from '../../../../app/types/branded.js' +import { TelnetServiceImpl } from '../../../../app/services/telnet/telnet-service.js' +import type { TelnetConnectionConfig, ServiceDependencies } from '../../../../app/services/interfaces.js' +import { IAC, DO, NAWS, SB, SE, ECHO } from '../../../../app/services/telnet/telnet-negotiation.js' +import { TEST_USERNAME, TEST_PASSWORD } from '../../../test-constants.js' +import { createMockDependencies } from '../../../test-utils.js' + +// ── Helpers ────────────────────────────────────────────────────────────── + +/** + * Create a test telnet config with sensible defaults + */ +const createTestConfig = ( + port: number, + overrides?: Partial +): TelnetConnectionConfig => ({ + sessionId: createSessionId('test-session-1'), + host: '127.0.0.1', + port, + timeout: 5000, + term: 'xterm-256color', + ...overrides, +}) + +/** + * Wait for a condition with timeout + */ +const waitFor = ( + predicate: () => boolean, + timeoutMs = 2000, + intervalMs = 10 +): Promise => + new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs + const check = (): void => { + if (predicate()) { + resolve() + } else if (Date.now() > deadline) { + reject(new Error('waitFor timed out')) + } else { + setTimeout(check, intervalMs) + } + } + check() + }) + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe('TelnetServiceImpl', () => { + let server: net.Server + let serverPort: number + let service: TelnetServiceImpl + let deps: ServiceDependencies + + beforeEach(async () => { + deps = createMockDependencies() + service = new TelnetServiceImpl(deps) + + // Create a simple echo server for testing + server = net.createServer((socket) => { + socket.on('data', (data) => { + // Filter out IAC sequences before echoing + const bytes: number[] = [] + let i = 0 + while (i < data.length) { + if (data[i] === IAC && i + 1 < data.length) { + // Skip IAC sequences + if (data[i + 1] === SB) { + // Skip subnegotiation: IAC SB ... IAC SE + i += 2 + while (i < data.length) { + if (data[i] === IAC && i + 1 < data.length && data[i + 1] === SE) { + i += 2 + break + } + i++ + } + continue + } + // Skip 3-byte commands: IAC WILL/WONT/DO/DONT