diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f3854235d..2fa8ea5f8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -141,7 +141,7 @@ jobs: type=raw,value=${{ steps.release_meta.outputs.semver_full }},enable=${{ steps.release_meta.outputs.has_release == 'true' }} type=raw,value=${{ steps.release_meta.outputs.semver_minor }},enable=${{ steps.release_meta.outputs.has_release == 'true' }} type=raw,value=${{ steps.release_meta.outputs.semver_major }},enable=${{ steps.release_meta.outputs.has_release == 'true' }} - type=ref,event=branch,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }} + type=ref,event=branch,enable=${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref != 'refs/heads/main' }} type=sha - name: Build and push docker image diff --git a/.gitignore b/.gitignore index 03a264b24..787d09816 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ dist/ .migration/ .regression/ +.worktrees/ config.json.backup release-artifacts/ diff --git a/DOCS/api/ROUTES.md b/DOCS/api/ROUTES.md index 789ab2c4b..6e5df59e3 100644 --- a/DOCS/api/ROUTES.md +++ b/DOCS/api/ROUTES.md @@ -260,6 +260,95 @@ fetch('/ssh', { 4. **Use SSO integration** when possible for enterprise deployments 5. **Configure CORS properly** via `http.origins` configuration +## Telnet Routes + +> **Telnet support is disabled by default.** Set `WEBSSH2_TELNET_ENABLED=true` or `telnet.enabled: true` in `config.json` to enable these routes. See [Configuration](../configuration/CONFIG-JSON.md) for all telnet options. + +> **Security Warning:** Telnet transmits all data, including credentials, in plain text. Only use on trusted, isolated networks. Prefer SSH whenever possible. + +### 1. `/telnet` - Interactive Login + +- **URL:** `http(s)://your-webssh2-server/telnet` +- **Method:** GET +- **Features:** + - Interactive login form (telnet-specific UI) + - Security warning banner displayed to users + - Expect-style authentication with configurable prompt patterns + - SFTP and host key features hidden (SSH-only) + +### 2. `/telnet/host/:host` - Host-Locked Mode + +- **URL:** `http(s)://your-webssh2-server/telnet/host/:host` +- **Method:** GET +- **Features:** + - Direct connection to a specific telnet host + - Optional `port` parameter (e.g., `?port=2323`, default: 23) + - Host is locked and cannot be changed by the user + +### 3. `/telnet` - HTTP POST Auth + +- **URL:** `http(s)://your-webssh2-server/telnet` +- **Method:** POST +- **Content-Type:** `application/json` +- **Features:** + - SSO form submission for telnet connections + - Same integration patterns as SSH POST auth + +#### Request Body + +```json +{ + "username": "string", + "password": "string", + "host": "string", + "port": 23 +} +``` + +### 4. `/telnet/host/:host` - POST with Host Locked + +- **URL:** `http(s)://your-webssh2-server/telnet/host/:host` +- **Method:** POST +- **Features:** + - SSO form submission with host pre-set + - Host parameter locked from URL path + +### 5. `/telnet/config` - Telnet Configuration Endpoint + +- **URL:** `http(s)://your-webssh2-server/telnet/config` +- **Method:** GET +- **Purpose:** Exposes telnet server configuration to clients +- **Response:** JSON with `protocol`, `defaultPort`, and `term` + +```json +{ + "protocol": "telnet", + "defaultPort": 23, + "term": "vt100" +} +``` + +### Telnet Query Parameters + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `port` | integer | 23 | Telnet port to connect to | +| `header` | string | - | Header text override | +| `headerBackground` | string | green | Header background color | + +### Telnet Examples + +```text +http://localhost:2222/telnet/host/192.168.1.1?port=2323 +``` + +```bash +# Docker with telnet enabled +docker run --rm -p 2222:2222 \ + -e WEBSSH2_TELNET_ENABLED=true \ + ghcr.io/billchurch/webssh2:latest +``` + ## Related Documentation - [Authentication Overview](../features/AUTHENTICATION.md) diff --git a/DOCS/configuration/CONFIG-JSON.md b/DOCS/configuration/CONFIG-JSON.md index 55d9497c7..e98e81714 100644 --- a/DOCS/configuration/CONFIG-JSON.md +++ b/DOCS/configuration/CONFIG-JSON.md @@ -588,3 +588,126 @@ See [ENVIRONMENT-VARIABLES.md](./ENVIRONMENT-VARIABLES.md) for details. - `WEBSSH2_SSH_SFTP_TIMEOUT` See [ENVIRONMENT-VARIABLES.md](./ENVIRONMENT-VARIABLES.md) for details on environment variable format and examples. + +### Telnet Configuration + +> **Security Warning:** Telnet transmits all data, including credentials, in **plain text**. It should only be used on trusted networks or for connecting to legacy devices that do not support SSH. Never expose telnet endpoints to the public internet without additional network-level protections (e.g., VPN, firewall rules). + +WebSSH2 includes optional telnet support for connecting to legacy devices and systems that do not support SSH. Telnet is **disabled by default** and must be explicitly enabled. + +#### Configuration Options + +- `telnet.enabled` (boolean, default: `false`): Enable telnet support. When `false`, all `/telnet` routes return 404. + +- `telnet.defaultPort` (number, default: `23`): Default telnet port used when no port is specified in the connection request. + +- `telnet.timeout` (number, default: `30000`): Connection timeout in milliseconds. If the connection cannot be established within this time, it is aborted. + +- `telnet.term` (string, default: `"vt100"`): Terminal type sent during TERMINAL-TYPE negotiation. Common values include `vt100`, `vt220`, `xterm`, and `ansi`. + +- `telnet.auth.loginPrompt` (string, regex, default: `"login:\\s*$"`): Regular expression pattern used to detect the login prompt. The authenticator watches incoming data for this pattern to know when to send the username. + +- `telnet.auth.passwordPrompt` (string, regex, default: `"[Pp]assword:\\s*$"`): Regular expression pattern used to detect the password prompt. The authenticator watches incoming data for this pattern to know when to send the password. + +- `telnet.auth.failurePattern` (string, regex, default: `"Login incorrect|Access denied|Login failed"`): Regular expression pattern used to detect authentication failure. When matched, the connection reports an authentication error. + +- `telnet.auth.expectTimeout` (number, default: `10000`): Maximum time in milliseconds to wait for prompt pattern matches during authentication. If no prompt is detected within this time, the authenticator falls back to pass-through mode, forwarding raw data to the terminal. + +- `telnet.allowedSubnets` (string[], default: `[]`): Restrict which hosts can be connected to via telnet. Uses the same CIDR notation format as `ssh.allowedSubnets`. When empty, all hosts are allowed. + +#### Default Telnet Configuration + +```json +{ + "telnet": { + "enabled": false, + "defaultPort": 23, + "timeout": 30000, + "term": "vt100", + "auth": { + "loginPrompt": "login:\\s*$", + "passwordPrompt": "[Pp]assword:\\s*$", + "failurePattern": "Login incorrect|Access denied|Login failed", + "expectTimeout": 10000 + }, + "allowedSubnets": [] + } +} +``` + +> **Note:** Telnet is disabled by default. Set `enabled` to `true` to activate telnet support. + +#### Use Cases + +**Enable telnet for legacy network devices:** + +```json +{ + "telnet": { + "enabled": true, + "defaultPort": 23, + "term": "vt100" + } +} +``` + +This enables telnet with default authentication patterns, suitable for most Linux/Unix systems and network equipment. + +**Custom prompts for non-standard devices:** + +```json +{ + "telnet": { + "enabled": true, + "auth": { + "loginPrompt": "Username:\\s*$", + "passwordPrompt": "Password:\\s*$", + "failurePattern": "Authentication failed|Bad password|Access denied", + "expectTimeout": 15000 + } + } +} +``` + +Some devices use non-standard prompt text. Adjust the regex patterns to match your equipment. + +**Restrict telnet to specific subnets:** + +```json +{ + "telnet": { + "enabled": true, + "allowedSubnets": ["10.0.0.0/8", "192.168.1.0/24"], + "timeout": 15000 + } +} +``` + +Only allow telnet connections to hosts within the specified private network ranges. + +**Disable telnet (default):** + +```json +{ + "telnet": { + "enabled": false + } +} +``` + +Telnet is disabled by default. This configuration is only needed if you want to explicitly disable it after previously enabling it. + +#### Environment Variables + +These options can also be configured via environment variables: + +- `WEBSSH2_TELNET_ENABLED` +- `WEBSSH2_TELNET_DEFAULT_PORT` +- `WEBSSH2_TELNET_TIMEOUT` +- `WEBSSH2_TELNET_TERM` +- `WEBSSH2_TELNET_AUTH_LOGIN_PROMPT` +- `WEBSSH2_TELNET_AUTH_PASSWORD_PROMPT` +- `WEBSSH2_TELNET_AUTH_FAILURE_PATTERN` +- `WEBSSH2_TELNET_AUTH_EXPECT_TIMEOUT` + +See [ENVIRONMENT-VARIABLES.md](./ENVIRONMENT-VARIABLES.md) for details. diff --git a/DOCS/configuration/ENVIRONMENT-VARIABLES.md b/DOCS/configuration/ENVIRONMENT-VARIABLES.md index 0fe5a7f31..92f2a1f24 100644 --- a/DOCS/configuration/ENVIRONMENT-VARIABLES.md +++ b/DOCS/configuration/ENVIRONMENT-VARIABLES.md @@ -219,6 +219,66 @@ WEBSSH2_SSH_SFTP_TRANSFER_RATE_LIMIT_BYTES_PER_SEC=0 WEBSSH2_SSH_SFTP_ENABLED=false ``` +### Telnet Configuration + +> **Security Warning:** Telnet transmits all data, including credentials, in **plain text**. Only use telnet on trusted networks or for legacy devices that do not support SSH. + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `WEBSSH2_TELNET_ENABLED` | boolean | `false` | Enable or disable telnet support. When disabled, `/telnet` routes return 404 | +| `WEBSSH2_TELNET_DEFAULT_PORT` | number | `23` | Default telnet port | +| `WEBSSH2_TELNET_TIMEOUT` | number | `30000` | Connection timeout in milliseconds | +| `WEBSSH2_TELNET_TERM` | string | `vt100` | Terminal type for TERMINAL-TYPE negotiation | +| `WEBSSH2_TELNET_AUTH_LOGIN_PROMPT` | string | `login:\s*$` | Regex pattern to detect login prompt | +| `WEBSSH2_TELNET_AUTH_PASSWORD_PROMPT` | string | `[Pp]assword:\s*$` | Regex pattern to detect password prompt | +| `WEBSSH2_TELNET_AUTH_FAILURE_PATTERN` | string | `Login incorrect\|Access denied\|Login failed` | Regex pattern to detect authentication failure | +| `WEBSSH2_TELNET_AUTH_EXPECT_TIMEOUT` | number | `10000` | Max time (ms) to wait for prompt matches before falling back to pass-through mode | +| `WEBSSH2_TELNET_ALLOWED_SUBNETS` | array | `[]` | Comma-separated CIDR ranges restricting which hosts can be connected to via telnet (e.g., `10.0.0.0/8,192.168.0.0/16`) | + +#### Telnet Configuration Examples + +**Enable telnet with defaults:** + +```bash +# Enable telnet support (disabled by default) +WEBSSH2_TELNET_ENABLED=true +``` + +**Custom prompts for non-standard devices:** + +```bash +# Enable telnet +WEBSSH2_TELNET_ENABLED=true + +# Custom prompt patterns for network equipment +WEBSSH2_TELNET_AUTH_LOGIN_PROMPT="Username:\\s*$" +WEBSSH2_TELNET_AUTH_PASSWORD_PROMPT="Password:\\s*$" +WEBSSH2_TELNET_AUTH_FAILURE_PATTERN="Authentication failed|Bad password|Access denied" + +# Longer timeout for slow devices +WEBSSH2_TELNET_AUTH_EXPECT_TIMEOUT=15000 +``` + +**Docker with telnet enabled:** + +```bash +docker run -d \ + -p 2222:2222 \ + -e WEBSSH2_TELNET_ENABLED=true \ + -e WEBSSH2_TELNET_DEFAULT_PORT=23 \ + -e WEBSSH2_TELNET_TERM=vt100 \ + webssh2:latest +``` + +**Disable telnet (default):** + +```bash +# Telnet is disabled by default, but you can explicitly disable it +WEBSSH2_TELNET_ENABLED=false +``` + +See [CONFIG-JSON.md](./CONFIG-JSON.md) for `config.json` examples and additional details. + #### Authentication Allow List `WEBSSH2_AUTH_ALLOWED` lets administrators enforce which SSH authentication methods can be used. Supported tokens are: diff --git a/README.md b/README.md index 7b88e8ad4..8f8fd2aa4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Orthrus Mascot](DOCS/images/orthrus2.png) -WebSSH2 is an HTML5 web-based terminal emulator and SSH client. It uses SSH2 as a client on a host to proxy a Websocket / Socket.io connection to an SSH2 server. +WebSSH2 is an HTML5 web-based terminal emulator and SSH client with optional telnet support. It uses SSH2 as a client on a host to proxy a Websocket / Socket.io connection to an SSH2 server. ![WebSSH2 Screenshot](DOCS/images/Screenshot_sm.png) @@ -30,7 +30,7 @@ npm install --production npm start ``` -Access WebSSH2 at: `http://localhost:2222/ssh` +Access WebSSH2 at: `http://localhost:2222/ssh` (or `http://localhost:2222/telnet` if telnet is enabled) ### Official Containers @@ -129,6 +129,7 @@ Need the Docker Hub mirror instead? Use `docker.io/billchurch/webssh2:latest`. - [Exec Channel](./DOCS/features/EXEC-CHANNEL.md) - Non-interactive command execution - [Environment Forwarding](./DOCS/features/ENVIRONMENT-FORWARDING.md) - Pass environment variables - [Host Key Verification](#host-key-verification) - MITM protection and key management +- [Telnet Support](#telnet-support) - Optional telnet for legacy devices ### Development @@ -158,6 +159,7 @@ Need the Docker Hub mirror instead? Use `docker.io/billchurch/webssh2:latest`. - 🌍 **Environment Variables** - Pass custom environment to SSH sessions - 🛡️ **Subnet Restrictions** - IPv4/IPv6 CIDR subnet validation for access control - 📁 **SFTP Support** - File transfer capabilities (v2.6.0+) +- 📡 **Telnet Support** - Optional telnet protocol for legacy devices (disabled by default) ## Host Key Verification @@ -338,6 +340,42 @@ If you receive frequent mismatches for hosts you did not change, investigate for **Client verification times out:** When using `prompt` mode, the client has 30 seconds to respond to a `hostkey:verify` event. If the client does not respond in time, the connection is refused. Ensure the client application handles the `hostkey:verify` Socket.IO event. +## Telnet Support + +WebSSH2 includes optional telnet protocol support for connecting to legacy devices such as network switches, routers, and older systems that don't support SSH. Telnet is **disabled by default** and must be explicitly enabled. + +> **Security Warning:** Telnet transmits all data in plain text, including credentials. Only use on trusted, isolated networks. + +### Enabling Telnet + +```bash +# Via environment variable +docker run --rm -p 2222:2222 \ + -e WEBSSH2_TELNET_ENABLED=true \ + ghcr.io/billchurch/webssh2:latest +``` + +Or in `config.json`: + +```json +{ + "telnet": { + "enabled": true + } +} +``` + +Once enabled, access telnet at `http://localhost:2222/telnet` or connect to a specific host at `http://localhost:2222/telnet/host/192.168.1.1`. + +### Telnet Features + +- **Expect-style authentication** - Configurable regex patterns for login/password prompt detection +- **IAC negotiation** - Full RFC 854/857/858/1073/1091 support (ECHO, SGA, NAWS, TERMINAL_TYPE) +- **Subnet restrictions** - Limit which hosts can be reached via telnet +- **Security warnings** - Visual banner in the client UI reminding users of telnet's insecurity + +For full configuration options, see [Environment Variables](./DOCS/configuration/ENVIRONMENT-VARIABLES.md) and [Config JSON](./DOCS/configuration/CONFIG-JSON.md). + ## Release Workflow Overview - **Development**: Run `npm install` (or `npm ci`) and continue using scripts such as `npm run dev` and `npm run build`. The TypeScript sources remain the source of truth. diff --git a/app/app.ts b/app/app.ts index b43bc5e8f..3d61defdd 100644 --- a/app/app.ts +++ b/app/app.ts @@ -4,9 +4,10 @@ import type { Server as IOServer } from 'socket.io' import { getConfig } from './config.js' import initSocket from './socket-v2.js' import { createRoutesV2 as createRoutes } from './routes/routes-v2.js' +import { createTelnetRoutes } from './routes/telnet-routes.js' import { applyMiddleware } from './middleware.js' import { createServer, startServer } from './server.js' -import { configureSocketIO } from './io.js' +import { configureSocketIO, configureTelnetNamespace } from './io.js' import { handleError, ConfigError } from './errors.js' import { createNamespacedDebug, applyLoggingConfiguration } from './logger.js' import { MESSAGES } from './constants/index.js' @@ -33,6 +34,13 @@ export function createAppAsync(appConfig: Config): { const sshRoutes = createRoutes(appConfig) app.use('/ssh/assets', express.static(clientPath)) app.use('/ssh', sshRoutes) + + if (appConfig.telnet?.enabled === true) { + const telnetRoutes = createTelnetRoutes(appConfig) + app.use('/telnet/assets', express.static(clientPath)) + app.use('/telnet', telnetRoutes) + } + return { app, sessionMiddleware } } catch (err) { const message = extractErrorMessage(err) @@ -65,9 +73,16 @@ export async function initializeServerAsync(): Promise<{ } const io = configureSocketIO(server, sessionMiddleware, cfgForIO) - // Pass services to socket initialization - initSocket(io as Parameters[0], appConfig, services) - + // Pass services to socket initialization (SSH) + initSocket(io as Parameters[0], appConfig, services, 'ssh') + + // Configure telnet namespace if enabled + const telnetIo = configureTelnetNamespace(server, sessionMiddleware, appConfig) + if (telnetIo !== null) { + initSocket(telnetIo as Parameters[0], appConfig, services, 'telnet') + debug('Telnet Socket.IO namespace initialized') + } + startServer(server, appConfig) debug('Server initialized asynchronously') return { server, io, app, config: appConfig, services } diff --git a/app/config/default-config.ts b/app/config/default-config.ts index f13dfd2e2..2fdc90b2c 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' }, + WEBSSH2_TELNET_ALLOWED_SUBNETS: { path: 'telnet.allowedSubnets', type: 'array' }, } /** diff --git a/app/connectionHandler.ts b/app/connectionHandler.ts index 46e8cfab4..142585783 100644 --- a/app/connectionHandler.ts +++ b/app/connectionHandler.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { promises as fs } from 'node:fs' import { createNamespacedDebug } from './logger.js' -import { HTTP, MESSAGES, DEFAULTS } from './constants/index.js' +import { HTTP, MESSAGES, DEFAULTS, TELNET_DEFAULTS } from './constants/index.js' import { transformHtml } from './utils/html-transformer.js' import type { AuthSession } from './auth/auth-utils.js' @@ -19,11 +19,11 @@ function hasSessionCredentials(session: Sess): boolean { ) } -async function sendClient(config: unknown, res: Response): Promise { +async function sendClient(config: unknown, res: Response, basePath?: string): Promise { try { const data = await readClientTemplate() debug('Transforming HTML with config') - const modifiedHtml = transformHtml(data, config) + const modifiedHtml = transformHtml(data, config, basePath) res.send(modifiedHtml) } catch { res.status(HTTP.INTERNAL_SERVER_ERROR).send(MESSAGES.CLIENT_FILE_ERROR) @@ -75,6 +75,8 @@ interface ConnectionOptions { lockedHost?: string /** Port that cannot be changed (when connectionMode is 'host-locked') */ lockedPort?: number + /** Protocol type: 'ssh' (default) or 'telnet' */ + protocol?: 'ssh' | 'telnet' } export default async function handleConnection( @@ -83,14 +85,20 @@ export default async function handleConnection( opts?: ConnectionOptions ): Promise { debug('Handling connection req.path:', (req as Request).path) + const isTelnet = opts?.protocol === 'telnet' + const socketPath = isTelnet ? TELNET_DEFAULTS.IO_PATH : DEFAULTS.IO_PATH const tempConfig: Record = { socket: { url: `${req.protocol}://${req.get('host')}`, - path: DEFAULTS.IO_PATH, + path: socketPath, }, autoConnect: (req as Request).path.startsWith('/host/'), } + if (isTelnet) { + tempConfig['protocol'] = 'telnet' + } + // Add connection mode info for client-side LoginModal behavior if (opts?.connectionMode !== undefined) { tempConfig['connectionMode'] = opts.connectionMode @@ -128,5 +136,6 @@ export default async function handleConnection( }) } - await sendClient(tempConfig, res) + const basePath = isTelnet ? '/telnet/assets/' : undefined + await sendClient(tempConfig, res, basePath) } diff --git a/app/constants/core.ts b/app/constants/core.ts index 18843f7c2..0a3dc8b6b 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/errors.ts b/app/errors.ts index 386b70eb2..2f9a32be1 100644 --- a/app/errors.ts +++ b/app/errors.ts @@ -5,7 +5,7 @@ import { logError, createNamespacedDebug } from './logger.js' import { HTTP, MESSAGES } from './constants/index.js' import { WebSSH2Error } from './errors/webssh2-error.js' -export { WebSSH2Error } +export { WebSSH2Error } from './errors/webssh2-error.js' export { ConfigError } from './errors/config-error.js' export { SSHConnectionError } from './errors/ssh-connection-error.js' diff --git a/app/io.ts b/app/io.ts index bab19ee8d..e30450c91 100644 --- a/app/io.ts +++ b/app/io.ts @@ -2,7 +2,8 @@ import type { Server as HttpServer } from 'node:http' import type { RequestHandler } from 'express' import { Server as SocketIOServer } from 'socket.io' import { createNamespacedDebug } from './logger.js' -import { DEFAULTS } from './constants/index.js' +import { DEFAULTS, TELNET_DEFAULTS } from './constants/index.js' +import type { Config } from './types/config.js' const debug = createNamespacedDebug('app') @@ -27,3 +28,38 @@ export function configureSocketIO( debug('IO configured') return io } + +/** + * Configure a separate Socket.IO server for the telnet namespace. + * Returns null when telnet is not enabled. + */ +export function configureTelnetNamespace( + server: HttpServer, + sessionMiddleware: RequestHandler, + config: Config +): SocketIOServer | null { + if (config.telnet?.enabled !== true) { + return null + } + + const baseOptions = { + serveClient: false, + path: TELNET_DEFAULTS.IO_PATH, + pingTimeout: DEFAULTS.IO_PING_TIMEOUT_MS, + pingInterval: DEFAULTS.IO_PING_INTERVAL_MS, + } + + const options = config.getCorsConfig === undefined + ? baseOptions + : { ...baseOptions, cors: config.getCorsConfig() } + + const telnetIo = new SocketIOServer(server, options) + + telnetIo.use((socket, next) => { + // @ts-expect-error socket.request.res is optional; express-session expects a Response-like object + sessionMiddleware(socket.request, (socket.request.res ?? {}) as Parameters[1], next) + }) + + debug('Telnet IO configured on path %s', TELNET_DEFAULTS.IO_PATH) + return telnetIo +} diff --git a/app/middleware/index.ts b/app/middleware/index.ts index 50477c9c8..b0a894108 100644 --- a/app/middleware/index.ts +++ b/app/middleware/index.ts @@ -12,13 +12,11 @@ import type { Config } from '../types/config.js' // Re-export individual middleware creators export { createAuthMiddleware } from './auth.middleware.js' -export { - createSessionMiddleware, - createBodyParserMiddleware, - createCookieMiddleware, - createSSOAuthMiddleware, - createCSRFMiddleware -} +export { createSessionMiddleware } from './session.middleware.js' +export { createBodyParserMiddleware } from './body-parser.middleware.js' +export { createCookieMiddleware } from './cookie.middleware.js' +export { createSSOAuthMiddleware } from './sso.middleware.js' +export { createCSRFMiddleware } from './csrf.middleware.js' /** * Apply all middleware to the Express application diff --git a/app/routes/telnet-routes.ts b/app/routes/telnet-routes.ts new file mode 100644 index 000000000..0d25f0504 --- /dev/null +++ b/app/routes/telnet-routes.ts @@ -0,0 +1,131 @@ +// app/routes/telnet-routes.ts +// Telnet routes mirroring the SSH route pattern + +import express, { type Router, type Request, type Response } from 'express' +import handleConnection from '../connectionHandler.js' +import { createNamespacedDebug } from '../logger.js' +import { HTTP, TELNET_DEFAULTS } from '../constants/index.js' +import type { Config } from '../types/config.js' +import { processAuthParameters, type AuthSession } from '../auth/auth-utils.js' +import { asyncRouteHandler, createErrorHandler } from './adapters/express-adapter.js' + +const debug = createNamespacedDebug('routes:telnet') + +type ExpressRequest = Request & { + session: AuthSession & Record + sessionID: string +} + +/** + * Create telnet routes with protocol-specific configuration. + * Mirrors the SSH route structure but simplified for telnet connections. + */ +export function createTelnetRoutes(config: Config): Router { + const router = express.Router() + + // Add error handler + router.use(createErrorHandler()) + + /** + * Root route - full connection mode, protocol: telnet + */ + router.get('/', asyncRouteHandler(async (req: Request, res: Response) => { + const expressReq = req as unknown as ExpressRequest + debug('GET / - Telnet root route accessed') + + processAuthParameters(expressReq.query, expressReq.session) + await handleConnection( + expressReq as unknown as Request & { session?: AuthSession; sessionID?: string }, + res, + { connectionMode: 'full', protocol: 'telnet' } + ) + })) + + /** + * Expose telnet server configuration to clients + */ + router.get('/config', (_req: Request, res: Response) => { + debug('GET /config - Telnet config endpoint') + const telnetConfig = config.telnet + res.setHeader('Cache-Control', 'no-store') + res.status(HTTP.OK).json({ + protocol: 'telnet', + defaultPort: telnetConfig?.defaultPort ?? TELNET_DEFAULTS.PORT, + term: telnetConfig?.term ?? TELNET_DEFAULTS.TERM, + }) + }) + + /** + * Host route with parameter - host-locked mode + */ + router.get('/host/:host', asyncRouteHandler(async (req: Request, res: Response) => { + const expressReq = req as unknown as ExpressRequest + const hostParam = expressReq.params['host'] + const host = typeof hostParam === 'string' ? hostParam : undefined + debug(`GET /host/${host ?? 'unknown'} - Telnet host-locked route`) + + processAuthParameters(expressReq.query, expressReq.session) + + const portParam = expressReq.query['port'] + const parsedPort = typeof portParam === 'string' && portParam !== '' + ? Number(portParam) + : NaN + const lockedPort = Number.isFinite(parsedPort) ? parsedPort : TELNET_DEFAULTS.PORT + + await handleConnection( + expressReq as unknown as Request & { session?: AuthSession; sessionID?: string }, + res, + { + protocol: 'telnet', + connectionMode: 'host-locked', + ...(host === undefined ? {} : { lockedHost: host, host }), + lockedPort, + } + ) + })) + + /** + * POST root - SSO form submission for telnet + */ + router.post('/', asyncRouteHandler(async (req: Request, res: Response) => { + const expressReq = req as unknown as ExpressRequest + debug('POST / - Telnet SSO form submission') + + const body = expressReq.body as Record | undefined + const host = typeof body?.['host'] === 'string' ? body['host'] : undefined + + processAuthParameters(expressReq.query, expressReq.session) + await handleConnection( + expressReq as unknown as Request & { session?: AuthSession; sessionID?: string }, + res, + { + protocol: 'telnet', + connectionMode: 'full', + ...(host === undefined ? {} : { host }), + } + ) + })) + + /** + * POST /host/:host - SSO form submission with host locked + */ + router.post('/host/:host', asyncRouteHandler(async (req: Request, res: Response) => { + const expressReq = req as unknown as ExpressRequest + const hostParam = expressReq.params['host'] + const host = typeof hostParam === 'string' ? hostParam : undefined + debug(`POST /host/${host ?? 'unknown'} - Telnet SSO form with host`) + + processAuthParameters(expressReq.query, expressReq.session) + await handleConnection( + expressReq as unknown as Request & { session?: AuthSession; sessionID?: string }, + res, + { + protocol: 'telnet', + connectionMode: 'host-locked', + ...(host === undefined ? {} : { lockedHost: host, host }), + } + ) + })) + + return router +} diff --git a/app/schemas/config-schema.ts b/app/schemas/config-schema.ts index c21105b6f..0f1583759 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/app/services/container.ts b/app/services/container.ts index 7d6f5951c..dd02e4478 100644 --- a/app/services/container.ts +++ b/app/services/container.ts @@ -4,7 +4,7 @@ import debug from 'debug' import type { Config } from '../types/config.js' -import type { Logger, AuthService, SSHService, TerminalService, SessionService, Services } from './interfaces.js' +import type { Logger, AuthService, SSHService, TerminalService, SessionService, Services, ProtocolService } from './interfaces.js' import type { SessionStore } from '../state/store.js' const logger = debug('webssh2:services:container') @@ -256,5 +256,6 @@ export const TOKENS = { SSHService: createToken('SSHService'), TerminalService: createToken('TerminalService'), SessionService: createToken('SessionService'), - Services: createToken('Services') + Services: createToken('Services'), + TelnetService: createToken('TelnetService') } as const diff --git a/app/services/factory.ts b/app/services/factory.ts index bf8516978..99423b263 100644 --- a/app/services/factory.ts +++ b/app/services/factory.ts @@ -10,7 +10,8 @@ import type { AuthResult, SSHConnection, Terminal, - Session + Session, + ProtocolService } from './interfaces.js' import { AuthServiceImpl } from './auth/auth-service.js' import { SSHServiceImpl } from './ssh/ssh-service.js' @@ -29,6 +30,7 @@ import type { StructuredLogger, StructuredLoggerOptions } from '../logging/struc import { DEFAULT_SFTP_CONFIG } from '../config/default-config.js' import { HostKeyService } from './host-key/host-key-service.js' import { resolveHostKeyMode } from '../config/config-processor.js' +import { TelnetServiceImpl } from './telnet/telnet-service.js' const factoryLogger = debug('webssh2:services:factory') @@ -119,6 +121,12 @@ export function createServices( ? createShellFileService(sftpConfig, sftpDeps) : createSftpService(sftpConfig, sftpDeps) + // Create telnet service if enabled + let telnet: ProtocolService | undefined + if (deps.config.telnet?.enabled === true) { + telnet = new TelnetServiceImpl(deps) + } + const services: Services = { auth, ssh, @@ -127,6 +135,10 @@ export function createServices( sftp, } + if (telnet !== undefined) { + services.telnet = telnet + } + if (hostKey !== undefined) { services.hostKey = hostKey } diff --git a/app/services/interfaces.ts b/app/services/interfaces.ts index 3f527e179..fc435018c 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/services/setup.ts b/app/services/setup.ts index 6baf5d344..1fd52531a 100644 --- a/app/services/setup.ts +++ b/app/services/setup.ts @@ -11,6 +11,7 @@ import { createServiceStructuredLogger, type ExtendedServiceDependencies } from './factory.js' +import { TelnetServiceImpl } from './telnet/telnet-service.js' import debug from 'debug' const logger = debug('webssh2:services:setup') @@ -63,6 +64,16 @@ export function setupContainer(config: Config): Container { return createServices(deps).session }) + // Register telnet service factory (creates when telnet is enabled) + container.register(TOKENS.TelnetService, () => { + logger('Creating telnet service') + const deps = createDependencies(container) + if (deps.config.telnet?.enabled !== true) { + throw new Error('Telnet service requested but telnet is not enabled') + } + return new TelnetServiceImpl(deps) + }) + // Register all services together container.register(TOKENS.Services, () => { logger('Creating all services') diff --git a/app/services/telnet/telnet-auth.ts b/app/services/telnet/telnet-auth.ts new file mode 100644 index 000000000..9669f6ca3 --- /dev/null +++ b/app/services/telnet/telnet-auth.ts @@ -0,0 +1,278 @@ +/** + * 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. + */ + +// ── Constants ──────────────────────────────────────────────────────── + +/** Maximum buffer size before transitioning to pass-through (64KB) */ +const MAX_AUTH_BUFFER_BYTES = 65_536 + +/** Delay (ms) in waiting-result state before declaring success */ +const RESULT_SETTLE_DELAY_MS = 500 + +// ── Types ──────────────────────────────────────────────────────────── + +export type TelnetAuthState = + | 'waiting-login' + | 'waiting-password' + | 'waiting-result' + | 'authenticated' + | 'pass-through' + | 'failed' + +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 + private resultSettleHandle: ReturnType | null = null + private onAuthSettled: ((bufferedData: Buffer) => void) | 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() + this.cancelResultSettle() + } + + // ── Private state handlers ───────────────────────────────────────── + + private handleWaitingLogin(data: Buffer): ProcessDataResult { + this.buffer = Buffer.concat([this.buffer, data]) + + if (this.buffer.length > MAX_AUTH_BUFFER_BYTES) { + return this.transitionToPassThrough() + } + + 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]) + + if (this.buffer.length > MAX_AUTH_BUFFER_BYTES) { + return this.transitionToPassThrough() + } + + 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' + this.resultSettleHandle = null + 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]) + + if (this.buffer.length > MAX_AUTH_BUFFER_BYTES) { + return this.transitionToPassThrough() + } + + 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() + this.cancelResultSettle() + return { writeToSocket: null, forwardToClient: data } + } + + // Start a settle timer on first data: accumulate for a brief period + // to give failure messages time to arrive before declaring success. + this.resultSettleHandle ??= setTimeout(() => { + this.settleAuthResult() + }, RESULT_SETTLE_DELAY_MS) + + return noAction() + } + + /** + * Called after the settle delay to finalize the waiting-result state. + * At this point the buffer has not matched a failure pattern, so we + * declare success and flush buffered data to the client. + */ + private settleAuthResult(): void { + if (this.currentState !== 'waiting-result') { + return + } + this.currentState = 'authenticated' + const buffered = this.buffer + this.buffer = Buffer.alloc(0) + this.cancelTimeout() + this.resultSettleHandle = null + + // Notify listeners that auth completed (so buffered data gets flushed) + if (this.onAuthSettled !== null) { + this.onAuthSettled(buffered) + } + } + + /** + * Register a callback for when auth settles via the result timer. + * This allows the shell layer to flush buffered data to the client. + */ + setOnAuthSettled(callback: (bufferedData: Buffer) => void): void { + this.onAuthSettled = callback + } + + /** + * Transition to pass-through mode due to buffer overflow. + * Flushes accumulated buffer to the client. + */ + private transitionToPassThrough(): ProcessDataResult { + const buffered = this.buffer + this.buffer = Buffer.alloc(0) + this.currentState = 'pass-through' + this.cancelTimeout() + this.cancelResultSettle() + return { writeToSocket: null, forwardToClient: buffered } + } + + private cancelResultSettle(): void { + if (this.resultSettleHandle !== null) { + clearTimeout(this.resultSettleHandle) + this.resultSettleHandle = null + } + } +} + +// ── 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/app/services/telnet/telnet-connection-pool.ts b/app/services/telnet/telnet-connection-pool.ts new file mode 100644 index 000000000..77ab60acc --- /dev/null +++ b/app/services/telnet/telnet-connection-pool.ts @@ -0,0 +1,121 @@ +/** + * 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 type { TelnetNegotiator } from './telnet-negotiation.js' +import type { TelnetAuthenticator } from './telnet-auth.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 + negotiator?: TelnetNegotiator + authenticator?: TelnetAuthenticator +} + +/** + * 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.authenticator?.destroy() + 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/app/services/telnet/telnet-negotiation.ts b/app/services/telnet/telnet-negotiation.ts new file mode 100644 index 000000000..a1d24f389 --- /dev/null +++ b/app/services/telnet/telnet-negotiation.ts @@ -0,0 +1,390 @@ +/** + * 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) + */ + +import debug from 'debug' + +const iacLogger = debug('webssh2:telnet:iac') + +// ── 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, +]) + +// ── Option negotiation states ──────────────────────────────────────── + +type OptionState = 'inactive' | 'offered' | 'active' + +const OPTION_NAMES: ReadonlyMap = new Map([ + [ECHO, 'ECHO'], + [SGA, 'SGA'], + [TERMINAL_TYPE, 'TERMINAL-TYPE'], + [NAWS, '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[] = [] + private readonly optionStates = new Map() + + 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 + } + + /** + * Build proactive WILL offers for TERMINAL-TYPE and NAWS. + * Call once at shell open to announce capabilities before the server asks. + * Returns empty array if offers were already sent. + */ + buildProactiveOffers(): Buffer[] { + const offers: Buffer[] = [] + + for (const option of [TERMINAL_TYPE, NAWS]) { + if (this.getOptionState(option) === 'inactive') { + const name = OPTION_NAMES.get(option) ?? String(option) + iacLogger('→ [proactive] WILL %s', name) + offers.push(Buffer.from([IAC, WILL, option])) + this.setOptionState(option, 'offered') + } + } + + return offers + } + + /** + * 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 getOptionState(option: number): OptionState { + return this.optionStates.get(option) ?? 'inactive' + } + + private setOptionState(option: number, state: OptionState): void { + const previous = this.getOptionState(option) + const name = OPTION_NAMES.get(option) ?? String(option) + iacLogger('%s: %s → %s', name, previous, state) + this.optionStates.set(option, state) + } + + 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) { + const name = OPTION_NAMES.get(byte) ?? String(byte) + iacLogger('← DONT %s', name) + iacLogger('→ WONT %s', name) + responses.push(Buffer.from([IAC, WONT, byte])) + } else if (command === WILL) { + this.handleWill(byte, responses) + } else if (command === WONT) { + const name = OPTION_NAMES.get(byte) ?? String(byte) + iacLogger('← WONT %s', name) + iacLogger('→ DONT %s', name) + responses.push(Buffer.from([IAC, DONT, byte])) + } + } + + private handleDo(option: number, responses: Buffer[]): void { + const name = OPTION_NAMES.get(option) ?? String(option) + iacLogger('← DO %s', name) + + if (!SUPPORTED_OPTIONS.has(option)) { + iacLogger('→ WONT %s (unsupported)', name) + responses.push(Buffer.from([IAC, WONT, option])) + return + } + + const currentState = this.getOptionState(option) + + // Only send WILL if we haven't already offered + if (currentState === 'inactive') { + iacLogger('→ WILL %s', name) + responses.push(Buffer.from([IAC, WILL, option])) + } + + this.setOptionState(option, 'active') + + // NAWS: send window size when option becomes active + if (option === NAWS) { + iacLogger('→ SB NAWS %dx%d', this.cols, this.rows) + responses.push(this.encodeNaws(this.cols, this.rows)) + } + } + + private handleWill(option: number, responses: Buffer[]): void { + const name = OPTION_NAMES.get(option) ?? String(option) + iacLogger('← WILL %s', name) + + if (SUPPORTED_OPTIONS.has(option)) { + iacLogger('→ DO %s', name) + responses.push(Buffer.from([IAC, DO, option])) + } else { + iacLogger('→ DONT %s (unsupported)', name) + 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] as number // guarded by length >= 2 + const qualifier = this.subnegBuffer[1] as number // guarded by length >= 2 + const name = OPTION_NAMES.get(option) ?? String(option) + + if (option === TERMINAL_TYPE && qualifier === SEND) { + iacLogger('← SB %s SEND', name) + iacLogger('→ SB %s IS %s', name, this.terminalType) + 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/app/services/telnet/telnet-service.ts b/app/services/telnet/telnet-service.ts new file mode 100644 index 000000000..afb6bea07 --- /dev/null +++ b/app/services/telnet/telnet-service.ts @@ -0,0 +1,425 @@ +/** + * 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 { validateConnectionWithDns } from '../../ssh/hostname-resolver.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 + private needsDrain = false + + constructor(socket: NetSocket) { + super() + this.socket = socket + } + + /** + * Push data to the readable side (called by the telnet data handler). + * Respects backpressure: if push() returns false, pauses the socket + * until _read() is called. + */ + pushData(chunk: Buffer): void { + const canPush = this.push(chunk) + if (!canPush && !this.needsDrain) { + this.needsDrain = true + this.socket.pause() + } + } + + override _write( + chunk: Buffer, + _encoding: string, + callback: (error?: Error | null) => void, + ): void { + this.socket.write(chunk, callback) + } + + override _read(_size: number): void { + // Resume the socket when the consumer is ready for more data + if (this.needsDrain) { + this.needsDrain = false + this.socket.resume() + } + } + + 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> { + // Enforce subnet restrictions before establishing the TCP connection + const subnetResult = await this.validateSubnets(config.host) + if (!subnetResult.ok) { + return err(subnetResult.error) + } + + 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 and settle callback if authenticator is present + if (authenticator !== null) { + authenticator.startTimeout((bufferedData) => { + if (bufferedData.length > 0) { + shellStream.pushData(bufferedData) + } + }) + authenticator.setOnAuthSettled((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 proactive option offers (WILL TERMINAL-TYPE, WILL NAWS) + const offers = negotiator.buildProactiveOffers() + for (const offer of offers) { + socket.write(offer) + } + + // 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 ──────────────────────────────────────────────── + + /** + * Validate the target host against configured subnet restrictions. + * Returns ok if no restrictions or host is within allowed subnets. + */ + private async validateSubnets(host: string): Promise> { + const allowedSubnets = this.deps.config.telnet?.allowedSubnets + if (allowedSubnets === undefined || allowedSubnets.length === 0) { + return ok(undefined) + } + + logger('Validating telnet connection to %s against subnet restrictions', host) + const validationResult = await validateConnectionWithDns(host, allowedSubnets) + + if (validationResult.ok) { + if (validationResult.value) { + logger('Host %s passed telnet subnet validation', host) + return ok(undefined) + } + + const errorMessage = `Telnet connection to host ${host} is not permitted by subnet policy` + logger('Host %s is not in allowed subnets: %s', host, allowedSubnets.join(', ')) + return err(new Error(errorMessage)) + } + + logger('Telnet host validation failed: %s', validationResult.error.message) + return err(new Error(`Host validation failed: ${validationResult.error.message}`)) + } + + /** + * 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/app/socket-v2.ts b/app/socket-v2.ts index 03cccc6dc..ec93fc620 100644 --- a/app/socket-v2.ts +++ b/app/socket-v2.ts @@ -5,7 +5,7 @@ import type { Server as IOServer } from 'socket.io' import { createNamespacedDebug } from './logger.js' import { ServiceSocketAdapter } from './socket/adapters/service-socket-adapter.js' import type { Config } from './types/config.js' -import type { Services } from './services/interfaces.js' +import type { Services, ProtocolType } from './services/interfaces.js' import type { ClientToServerEvents, ServerToClientEvents, @@ -21,15 +21,16 @@ const debug = createNamespacedDebug('socket:v2') export default function init( io: IOServer, config: Config, - services: Services + services: Services, + protocol: ProtocolType = 'ssh' ): void { - debug('V2 socket init() called - registering connection handler') + debug('V2 socket init() called - registering connection handler (protocol=%s)', protocol) io.on('connection', (socket) => { - debug(`V2 connection handler triggered for socket ${socket.id}`) + debug(`V2 connection handler triggered for socket ${socket.id} (protocol=${protocol})`) debug('Using service-based socket adapter') // ServiceSocketAdapter sets up all handlers in its constructor - const serviceAdapter = new ServiceSocketAdapter(socket, config, services) + const serviceAdapter = new ServiceSocketAdapter(socket, config, services, protocol) // Keep reference to prevent GC (adapter manages its own lifecycle via socket events) void serviceAdapter //NOSONAR }) diff --git a/app/socket/adapters/service-socket-adapter.ts b/app/socket/adapters/service-socket-adapter.ts index 942b26868..33fefd848 100644 --- a/app/socket/adapters/service-socket-adapter.ts +++ b/app/socket/adapters/service-socket-adapter.ts @@ -3,7 +3,7 @@ */ import type { Socket } from 'socket.io' -import type { Services } from '../../services/interfaces.js' +import type { Services, ProtocolType } from '../../services/interfaces.js' import type { Config } from '../../types/config.js' import { UnifiedAuthPipeline } from '../../auth/auth-pipeline.js' import type { @@ -56,7 +56,8 @@ export class ServiceSocketAdapter { constructor( private readonly socket: Socket, private readonly config: Config, - private readonly services: Services + private readonly services: Services, + private readonly protocol: ProtocolType = 'ssh' ) { this.authPipeline = new UnifiedAuthPipeline(socket.request, config) @@ -73,6 +74,7 @@ export class ServiceSocketAdapter { services, authPipeline: this.authPipeline, state, + protocol, debug, logger: createAppStructuredLogger({ namespace: 'webssh2:socket', config }) } @@ -85,7 +87,12 @@ export class ServiceSocketAdapter { this.setupEventHandlers() this.logSessionInit() - this.emitHostKeyVerificationConfig() + + // Host key verification is SSH-only + if (this.protocol === 'ssh') { + this.emitHostKeyVerificationConfig() + } + this.auth.checkInitialAuth() } @@ -107,8 +114,11 @@ export class ServiceSocketAdapter { }) this.socket.on(SOCKET_EVENTS.AUTH, async (credentials: AuthCredentials | { responses: string[] }) => { + // Keyboard-interactive responses are SSH-only if ('responses' in credentials) { - await this.auth.handleKeyboardInteractiveResponse(credentials.responses) + if (this.protocol === 'ssh') { + await this.auth.handleKeyboardInteractiveResponse(credentials.responses) + } return } @@ -127,9 +137,12 @@ export class ServiceSocketAdapter { this.terminal.handleData(data) }) - this.socket.on(SOCKET_EVENTS.EXEC, request => { - void this.terminal.handleExec(request) - }) + // Exec is SSH-only + if (this.protocol === 'ssh') { + this.socket.on(SOCKET_EVENTS.EXEC, request => { + void this.terminal.handleExec(request) + }) + } this.socket.on(SOCKET_EVENTS.CONTROL, message => { this.control.handleControl(message) @@ -137,7 +150,9 @@ export class ServiceSocketAdapter { this.socket.on(SOCKET_EVENTS.DISCONNECT, () => { this.control.handleDisconnect() - this.sftp.handleDisconnect() + if (this.protocol === 'ssh') { + this.sftp.handleDisconnect() + } this.prompt.handleDisconnect() }) @@ -146,42 +161,44 @@ export class ServiceSocketAdapter { void this.prompt.handlePromptResponse(response) }) - // SFTP event handlers - this.socket.on(SOCKET_EVENTS.SFTP_LIST, request => { - void this.sftp.handleList(request) - }) + // SFTP event handlers (SSH-only) + if (this.protocol === 'ssh') { + this.socket.on(SOCKET_EVENTS.SFTP_LIST, request => { + void this.sftp.handleList(request) + }) - this.socket.on(SOCKET_EVENTS.SFTP_STAT, request => { - void this.sftp.handleStat(request) - }) + this.socket.on(SOCKET_EVENTS.SFTP_STAT, request => { + void this.sftp.handleStat(request) + }) - this.socket.on(SOCKET_EVENTS.SFTP_MKDIR, request => { - void this.sftp.handleMkdir(request) - }) + this.socket.on(SOCKET_EVENTS.SFTP_MKDIR, request => { + void this.sftp.handleMkdir(request) + }) - this.socket.on(SOCKET_EVENTS.SFTP_DELETE, request => { - void this.sftp.handleDelete(request) - }) + this.socket.on(SOCKET_EVENTS.SFTP_DELETE, request => { + void this.sftp.handleDelete(request) + }) - this.socket.on(SOCKET_EVENTS.SFTP_UPLOAD_START, request => { - void this.sftp.handleUploadStart(request) - }) + this.socket.on(SOCKET_EVENTS.SFTP_UPLOAD_START, request => { + void this.sftp.handleUploadStart(request) + }) - this.socket.on(SOCKET_EVENTS.SFTP_UPLOAD_CHUNK, request => { - void this.sftp.handleUploadChunk(request) - }) + this.socket.on(SOCKET_EVENTS.SFTP_UPLOAD_CHUNK, request => { + void this.sftp.handleUploadChunk(request) + }) - this.socket.on(SOCKET_EVENTS.SFTP_UPLOAD_CANCEL, request => { - this.sftp.handleUploadCancel(request) - }) + this.socket.on(SOCKET_EVENTS.SFTP_UPLOAD_CANCEL, request => { + this.sftp.handleUploadCancel(request) + }) - this.socket.on(SOCKET_EVENTS.SFTP_DOWNLOAD_START, request => { - void this.sftp.handleDownloadStart(request) - }) + this.socket.on(SOCKET_EVENTS.SFTP_DOWNLOAD_START, request => { + void this.sftp.handleDownloadStart(request) + }) - this.socket.on(SOCKET_EVENTS.SFTP_DOWNLOAD_CANCEL, request => { - this.sftp.handleDownloadCancel(request) - }) + this.socket.on(SOCKET_EVENTS.SFTP_DOWNLOAD_CANCEL, request => { + this.sftp.handleDownloadCancel(request) + }) + } } private logSessionInit(): void { diff --git a/app/socket/adapters/service-socket-authentication.ts b/app/socket/adapters/service-socket-authentication.ts index 3686625c0..d04fc39a5 100644 --- a/app/socket/adapters/service-socket-authentication.ts +++ b/app/socket/adapters/service-socket-authentication.ts @@ -9,10 +9,11 @@ import type { Credentials } from '../../validation/credentials.js' import type { SessionId, AuthMethodToken } from '../../types/branded.js' import type { AdapterContext } from './service-socket-shared.js' import type { ServiceSocketPrompt } from './service-socket-prompt.js' -import type { KeyboardInteractiveContext, KeyboardInteractiveHandler } from '../../services/interfaces.js' +import type { KeyboardInteractiveContext, KeyboardInteractiveHandler, TelnetConnectionConfig } from '../../services/interfaces.js' import { SOCKET_EVENTS } from '../../constants/socket-events.js' import { VALIDATION_MESSAGES } from '../../constants/validation.js' import { PROMPT_INPUT_TYPES } from '../../constants/prompt.js' +import { TELNET_DEFAULTS } from '../../constants/core.js' import { buildSSHConfig, type KeyboardInteractiveOptions } from './ssh-config.js' import { emitSocketLog } from '../../logging/socket-logger.js' import { evaluateAuthMethodPolicy, isAuthMethodAllowed } from '../../auth/auth-method-policy.js' @@ -46,7 +47,8 @@ export class ServiceSocketAuthentication { } if (authPipeline.requiresAuthRequest()) { - if (this.context.config.ssh.alwaysSendKeyboardInteractivePrompts === true) { + // Keyboard-interactive is SSH-only + if (this.context.protocol === 'ssh' && this.context.config.ssh.alwaysSendKeyboardInteractivePrompts === true) { this.requestKeyboardInteractiveAuth() } else { this.requestAuthentication() @@ -213,12 +215,16 @@ export class ServiceSocketAuthentication { return } - const sshResult = await this.connectSSH(authCredentials, authResult.sessionId) - if (sshResult === null) { + // Branch connection based on protocol + const connectionResult = this.context.protocol === 'telnet' + ? await this.connectTelnet(authCredentials, authResult.sessionId) + : await this.connectSSH(authCredentials, authResult.sessionId) + + if (connectionResult === null) { return } - this.finalizeAuthentication(authCredentials, authResult.sessionId, sshResult.id) + this.finalizeAuthentication(authCredentials, authResult.sessionId, connectionResult.id) } catch (error) { this.handleAuthenticationError(error) } @@ -241,7 +247,8 @@ export class ServiceSocketAuthentication { return null } - if (!this.enforceAuthMethodPolicy(validatedCreds)) { + // Auth method policy enforcement is SSH-only + if (this.context.protocol === 'ssh' && !this.enforceAuthMethodPolicy(validatedCreds)) { return null } @@ -437,6 +444,55 @@ export class ServiceSocketAuthentication { return null } + private async connectTelnet( + authCredentials: AuthCredentials, + sessionId: SessionId + ): Promise<{ id: string } | null> { + const telnetService = this.context.services.telnet + if (telnetService === undefined) { + this.emitAuthFailure('Telnet is not enabled on this server') + return null + } + + const telnetConfig: TelnetConnectionConfig = { + sessionId, + host: authCredentials.host, + port: authCredentials.port, + username: authCredentials.username, + timeout: this.context.config.telnet?.timeout ?? TELNET_DEFAULTS.TIMEOUT_MS, + term: this.context.config.telnet?.term ?? TELNET_DEFAULTS.TERM, + expectTimeout: this.context.config.telnet?.auth.expectTimeout ?? TELNET_DEFAULTS.EXPECT_TIMEOUT_MS, + } + + if (authCredentials.password !== undefined && authCredentials.password !== '') { + telnetConfig.password = authCredentials.password + } + + const loginPrompt = buildOptionalRegex(this.context.config.telnet?.auth.loginPrompt) + if (loginPrompt !== undefined) { + telnetConfig.loginPrompt = loginPrompt + } + + const passwordPrompt = buildOptionalRegex(this.context.config.telnet?.auth.passwordPrompt) + if (passwordPrompt !== undefined) { + telnetConfig.passwordPrompt = passwordPrompt + } + + const failurePattern = buildOptionalRegex(this.context.config.telnet?.auth.failurePattern) + if (failurePattern !== undefined) { + telnetConfig.failurePattern = failurePattern + } + + const result = await telnetService.connect(telnetConfig) + + if (result.ok) { + return result.value + } + + this.emitAuthFailure(result.error.message) + return null + } + private finalizeAuthentication( authCredentials: AuthCredentials, sessionId: SessionId, @@ -676,25 +732,36 @@ export class ServiceSocketAuthentication { success: true }) - const hostKeyVerificationConfig = config.ssh.hostKeyVerification - socket.emit(SOCKET_EVENTS.PERMISSIONS, { - autoLog: config.options.autoLog, - allowReplay: config.options.allowReplay, - allowReconnect: config.options.allowReconnect, - allowReauth: config.options.allowReauth, - hostKeyVerification: { - enabled: hostKeyVerificationConfig.enabled, - clientStoreEnabled: hostKeyVerificationConfig.clientStore.enabled, - unknownKeyAction: hostKeyVerificationConfig.unknownKeyAction, - }, - }) - - // Emit SFTP status after successful authentication - this.emitSftpStatus() + if (this.context.protocol === 'telnet') { + // Telnet: simplified permissions (no host key verification, no SFTP) + socket.emit(SOCKET_EVENTS.PERMISSIONS, { + autoLog: config.options.autoLog, + allowReplay: config.options.allowReplay, + allowReconnect: config.options.allowReconnect, + allowReauth: config.options.allowReauth, + }) + } else { + const hostKeyVerificationConfig = config.ssh.hostKeyVerification + socket.emit(SOCKET_EVENTS.PERMISSIONS, { + autoLog: config.options.autoLog, + allowReplay: config.options.allowReplay, + allowReconnect: config.options.allowReconnect, + allowReauth: config.options.allowReauth, + hostKeyVerification: { + enabled: hostKeyVerificationConfig.enabled, + clientStoreEnabled: hostKeyVerificationConfig.clientStore.enabled, + unknownKeyAction: hostKeyVerificationConfig.unknownKeyAction, + }, + }) + + // Emit SFTP status after successful SSH authentication + this.emitSftpStatus() + } socket.emit(SOCKET_EVENTS.GET_TERMINAL, true) - const connectionString = `ssh://${credentials.host}:${credentials.port}` + const protocolScheme = this.context.protocol === 'telnet' ? 'telnet' : 'ssh' + const connectionString = `${protocolScheme}://${credentials.host}:${credentials.port}` socket.emit(SOCKET_EVENTS.UPDATE_UI, { element: 'footer', value: connectionString }) socket.emit(SOCKET_EVENTS.UPDATE_UI, { element: 'status', value: 'Connected' }) } @@ -773,3 +840,19 @@ export class ServiceSocketAuthentication { } } } + +/** + * Convert a config string pattern to a RegExp, returning undefined + * for empty or missing patterns. Catches invalid regex syntax to + * prevent crashes from misconfigured patterns. + */ +function buildOptionalRegex(pattern: string | undefined): RegExp | undefined { + if (pattern === undefined || pattern === '') { + return undefined + } + try { + return new RegExp(pattern) + } catch { + return undefined + } +} diff --git a/app/socket/adapters/service-socket-control.ts b/app/socket/adapters/service-socket-control.ts index fbf576b07..0031084b5 100644 --- a/app/socket/adapters/service-socket-control.ts +++ b/app/socket/adapters/service-socket-control.ts @@ -26,7 +26,12 @@ export class ServiceSocketControl { this.context.debug('Client disconnected') if (this.context.state.connectionId !== null) { - void this.context.services.ssh.disconnect(createConnectionId(this.context.state.connectionId)) + const connId = createConnectionId(this.context.state.connectionId) + if (this.context.protocol === 'telnet' && this.context.services.telnet !== undefined) { + void this.context.services.telnet.disconnect(connId) + } else { + void this.context.services.ssh.disconnect(connId) + } } if (this.context.state.sessionId !== null) { diff --git a/app/socket/adapters/service-socket-shared.ts b/app/socket/adapters/service-socket-shared.ts index a24ef195b..b770ab271 100644 --- a/app/socket/adapters/service-socket-shared.ts +++ b/app/socket/adapters/service-socket-shared.ts @@ -1,6 +1,6 @@ import type { Socket } from 'socket.io' import type { Duplex } from 'node:stream' -import type { Services } from '../../services/interfaces.js' +import type { Services, ProtocolType } from '../../services/interfaces.js' import type { Config } from '../../types/config.js' import type { SessionId } from '../../types/branded.js' import type { UnifiedAuthPipeline } from '../../auth/auth-pipeline.js' @@ -40,6 +40,7 @@ export interface AdapterContext { services: Services authPipeline: UnifiedAuthPipeline state: AdapterSharedState + protocol: ProtocolType debug: (...args: unknown[]) => void logger: StructuredLogger } diff --git a/app/socket/adapters/service-socket-terminal.ts b/app/socket/adapters/service-socket-terminal.ts index b6b055e4f..908fc593e 100644 --- a/app/socket/adapters/service-socket-terminal.ts +++ b/app/socket/adapters/service-socket-terminal.ts @@ -147,11 +147,31 @@ export class ServiceSocketTerminal { return } - if (this.context.state.shellStream?.setWindow !== undefined) { + // Send resize to the appropriate protocol service + if (this.context.protocol === 'telnet') { + this.resizeTelnet(dimensions) + } else if (this.context.state.shellStream?.setWindow !== undefined) { this.context.state.shellStream.setWindow(dimensions.rows, dimensions.cols) } } + private resizeTelnet(dimensions: { rows: number; cols: number }): void { + const telnetService = this.context.services.telnet + if (telnetService === undefined || this.context.state.connectionId === null) { + return + } + + const connectionId = createConnectionIdentifier(this.context) + if (connectionId === null) { + return + } + + const result = telnetService.resize(connectionId, dimensions.rows, dimensions.cols) + if (!result.ok) { + this.context.debug('Telnet resize failed:', result.error.message) + } + } + handleData(data: string): void { this.context.state.shellStream?.write(data) } @@ -226,15 +246,35 @@ export class ServiceSocketTerminal { term: config.term, rows: config.rows, cols: config.cols, - hasEnv: Object.keys(config.env).length > 0 + hasEnv: Object.keys(config.env).length > 0, + protocol: this.context.protocol }) - const shellResult = await this.context.services.ssh.shell(connectionId, { + const shellOptions = { term: config.term, rows: config.rows, cols: config.cols, env: config.env - }) + } + + // Use telnet service for shell when protocol is telnet + if (this.context.protocol === 'telnet') { + const telnetService = this.context.services.telnet + if (telnetService === undefined) { + this.context.socket.emit(SOCKET_EVENTS.SSH_ERROR, 'Telnet service not available') + return null + } + + const shellResult = await telnetService.shell(connectionId, shellOptions) + if (shellResult.ok) { + return shellResult.value as SSH2Stream + } + + this.context.socket.emit(SOCKET_EVENTS.SSH_ERROR, shellResult.error.message) + return null + } + + const shellResult = await this.context.services.ssh.shell(connectionId, shellOptions) if (shellResult.ok) { return shellResult.value diff --git a/app/socket/handlers/exec-handler.ts b/app/socket/handlers/exec-handler.ts index c98e1a720..bfc1530bb 100644 --- a/app/socket/handlers/exec-handler.ts +++ b/app/socket/handlers/exec-handler.ts @@ -5,9 +5,9 @@ import { validateExecPayload, createExecState } from './exec-validator.js' import { mergeEnvironmentVariables } from './exec-environment.js' // Re-export validator and safety functions for backwards compatibility -export { validateExecPayload, createExecState } +export { validateExecPayload, createExecState } from './exec-validator.js' export { isCommandSafe, sanitizeEnvVarName, filterEnvironmentVariables } from './exec-safety.js' -export { mergeEnvironmentVariables } +export { mergeEnvironmentVariables } from './exec-environment.js' export interface ExecState { command: string diff --git a/app/types/config.ts b/app/types/config.ts index 5413be63c..8949f923c 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/app/utils/html-transformer.ts b/app/utils/html-transformer.ts index 65491e72b..560374d19 100644 --- a/app/utils/html-transformer.ts +++ b/app/utils/html-transformer.ts @@ -4,12 +4,13 @@ /** * Transform HTML by modifying asset paths * Pure function - no side effects - * + * * @param html - HTML string to transform + * @param basePath - Base path for assets (default: '/ssh/assets/') * @returns Transformed HTML with updated asset paths */ -export function transformAssetPaths(html: string): string { - return html.replaceAll(/(src|href)="(?!http|\/\/)/g, '$1="/ssh/assets/') +export function transformAssetPaths(html: string, basePath: string = '/ssh/assets/'): string { + return html.replaceAll(/(src|href)="(?!http|\/\/)/g, `$1="${basePath}`) } /** @@ -30,12 +31,13 @@ export function injectConfig(html: string, config: unknown): string { /** * Transform HTML with asset paths and config injection * Composition of pure transformation functions - * + * * @param html - HTML string to transform * @param config - Configuration object to inject + * @param basePath - Base path for assets (default: '/ssh/assets/') * @returns Fully transformed HTML */ -export function transformHtml(html: string, config: unknown): string { - const htmlWithAssetPaths = transformAssetPaths(html) +export function transformHtml(html: string, config: unknown, basePath?: string): string { + const htmlWithAssetPaths = transformAssetPaths(html, basePath) return injectConfig(htmlWithAssetPaths, config) } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 08943f13f..280eca718 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "socket.io": "^4.8.1", "ssh2": "1.17", "validator": "^13.15.23", - "webssh2_client": "^3.4.1", + "webssh2_client": "^3.5.0-telnet.1", "zod": "^4.1.12" }, "bin": { @@ -1611,9 +1611,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -4167,9 +4167,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5762,9 +5762,9 @@ } }, "node_modules/webssh2_client": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/webssh2_client/-/webssh2_client-3.4.1.tgz", - "integrity": "sha512-xqVSf5GGzkFkDJpsDA9pQ6aWJh1VH0hjOq7pbk+47kmWvAS/alqfNt0JTG2OyPfJCFas9EkUOAZ4Cqt/TywBEA==", + "version": "3.5.0-telnet.1", + "resolved": "https://registry.npmjs.org/webssh2_client/-/webssh2_client-3.5.0-telnet.1.tgz", + "integrity": "sha512-HqZgGPiMbRIBDBZoPOg46Sz3cPMj18nS4/+iTaIOfnLW+s5bGNaAA+j69CeXUpRRuLsax/FpGj6rxaD1rEi6iA==", "license": "MIT", "dependencies": { "@xterm/addon-search": "^0.16.0" diff --git a/package.json b/package.json index b977caed0..82bfd32b3 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "socket.io": "^4.8.1", "ssh2": "1.17", "validator": "^13.15.23", - "webssh2_client": "^3.4.1", + "webssh2_client": "^3.5.0-telnet.1", "zod": "^4.1.12" }, "scripts": { diff --git a/tests/unit/config/telnet-config.vitest.ts b/tests/unit/config/telnet-config.vitest.ts new file mode 100644 index 000000000..dcbaffcbb --- /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) + }) +}) diff --git a/tests/unit/logging/socket-logger.vitest.ts b/tests/unit/logging/socket-logger.vitest.ts index cd17ea564..ee6c76988 100644 --- a/tests/unit/logging/socket-logger.vitest.ts +++ b/tests/unit/logging/socket-logger.vitest.ts @@ -33,6 +33,7 @@ const createTestAdapterContext = (): { context: AdapterContext; logger: Structur services: {} as Services, authPipeline: {} as UnifiedAuthPipeline, state, + protocol: 'ssh', debug: () => undefined, logger } diff --git a/tests/unit/routes/telnet-routes.vitest.ts b/tests/unit/routes/telnet-routes.vitest.ts new file mode 100644 index 000000000..b152c5acc --- /dev/null +++ b/tests/unit/routes/telnet-routes.vitest.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import { createTelnetRoutes } from '../../../app/routes/telnet-routes.js' +import { createDefaultConfig } from '../../../app/config/config-processor.js' +import { TELNET_DEFAULTS, HTTP } from '../../../app/constants/index.js' +import { TEST_SESSION_SECRET_VALID } from '@tests/test-constants.js' +import type { Config } from '../../../app/types/config.js' + +type LayerRoute = { + path: string + methods: Record + stack: Array<{ handle: (req: unknown, res: unknown) => void }> +} + +type RouterStack = { stack: Array<{ route?: LayerRoute }> } + +/** + * Create a test config with telnet enabled + */ +function createTelnetEnabledConfig(): Config { + const secret: string = TEST_SESSION_SECRET_VALID + const config = createDefaultConfig(secret) + config.telnet = { + enabled: true, + defaultPort: TELNET_DEFAULTS.PORT, + timeout: TELNET_DEFAULTS.TIMEOUT_MS, + term: TELNET_DEFAULTS.TERM, + auth: { + loginPrompt: TELNET_DEFAULTS.LOGIN_PROMPT, + passwordPrompt: TELNET_DEFAULTS.PASSWORD_PROMPT, + failurePattern: TELNET_DEFAULTS.FAILURE_PATTERN, + expectTimeout: TELNET_DEFAULTS.EXPECT_TIMEOUT_MS, + }, + allowedSubnets: [], + } + return config +} + +function extractRoutes(router: unknown): Array<{ path: string; methods: string[] }> { + const stack = (router as RouterStack).stack + const routes: Array<{ path: string; methods: string[] }> = [] + for (const layer of stack) { + if (layer.route !== undefined) { + routes.push({ + path: layer.route.path, + methods: Object.keys(layer.route.methods), + }) + } + } + return routes +} + +function findConfigHandler(router: unknown): LayerRoute['stack'][0] | undefined { + const stack = (router as RouterStack).stack + for (const layer of stack) { + const route = layer.route + if (route?.path === '/config' && route.methods['get'] === true) { + return route.stack[0] + } + } + return undefined +} + +function createMockResponse(): { + statusCode: number + headers: Record + body: Record + setHeader: (key: string, value: string) => unknown + status: (code: number) => unknown + json: (data: Record) => unknown +} { + const state = { + statusCode: 0, + headers: {} as Record, + body: {} as Record, + } + return { + get statusCode() { return state.statusCode }, + get headers() { return state.headers }, + get body() { return state.body }, + setHeader(key: string, value: string) { + state.headers[key] = value + return this + }, + status(code: number) { + state.statusCode = code + return this + }, + json(data: Record) { + state.body = data + return this + }, + } +} + +describe('createTelnetRoutes', () => { + it('returns a valid Express router', () => { + const config = createTelnetEnabledConfig() + const router = createTelnetRoutes(config) + expect(router).toBeDefined() + // Express Router is a function + expect(typeof router).toBe('function') + }) + + it('registers expected route paths', () => { + const config = createTelnetEnabledConfig() + const router = createTelnetRoutes(config) + const routePaths = extractRoutes(router) + + // Verify GET / exists + expect(routePaths).toContainEqual( + expect.objectContaining({ path: '/', methods: expect.arrayContaining(['get']) }) + ) + + // Verify GET /config exists + expect(routePaths).toContainEqual( + expect.objectContaining({ path: '/config', methods: expect.arrayContaining(['get']) }) + ) + + // Verify GET /host/:host exists + expect(routePaths).toContainEqual( + expect.objectContaining({ path: '/host/:host', methods: expect.arrayContaining(['get']) }) + ) + + // Verify POST / exists + expect(routePaths).toContainEqual( + expect.objectContaining({ path: '/', methods: expect.arrayContaining(['post']) }) + ) + + // Verify POST /host/:host exists + expect(routePaths).toContainEqual( + expect.objectContaining({ path: '/host/:host', methods: expect.arrayContaining(['post']) }) + ) + }) + + it('config endpoint returns telnet protocol info', () => { + const config = createTelnetEnabledConfig() + const router = createTelnetRoutes(config) + const handler = findConfigHandler(router) + expect(handler).toBeDefined() + + const mockRes = createMockResponse() + handler?.handle({}, mockRes) + + expect(mockRes.statusCode).toBe(HTTP.OK) + expect(mockRes.body).toEqual({ + protocol: 'telnet', + defaultPort: TELNET_DEFAULTS.PORT, + term: TELNET_DEFAULTS.TERM, + }) + }) + + it('config endpoint uses defaults when telnet config is absent', () => { + const secret: string = TEST_SESSION_SECRET_VALID + const config = createDefaultConfig(secret) + // telnet is undefined on this config + const router = createTelnetRoutes(config) + const handler = findConfigHandler(router) + expect(handler).toBeDefined() + + const mockRes = createMockResponse() + handler?.handle({}, mockRes) + + expect(mockRes.statusCode).toBe(HTTP.OK) + expect(mockRes.body).toEqual({ + protocol: 'telnet', + defaultPort: TELNET_DEFAULTS.PORT, + term: TELNET_DEFAULTS.TERM, + }) + }) + + it('config endpoint sets Cache-Control no-store header', () => { + const config = createTelnetEnabledConfig() + const router = createTelnetRoutes(config) + const handler = findConfigHandler(router) + expect(handler).toBeDefined() + + const mockRes = createMockResponse() + handler?.handle({}, mockRes) + + expect(mockRes.headers['Cache-Control']).toBe('no-store') + }) +}) + +describe('telnet route handler protocol injection', () => { + it('creates exactly five route layers', () => { + const config = createTelnetEnabledConfig() + const router = createTelnetRoutes(config) + const routePaths = extractRoutes(router) + + // Should have: GET /, GET /config, GET /host/:host, POST /, POST /host/:host + expect(routePaths.length).toBe(5) + }) +}) 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 000000000..cd6c8031c --- /dev/null +++ b/tests/unit/services/telnet/telnet-auth.vitest.ts @@ -0,0 +1,413 @@ +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', () => { + vi.useFakeTimers() + + 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) + // Data is buffered during the settle delay, not forwarded yet + expect(auth.state).toBe('waiting-result' satisfies TelnetAuthState) + expect(shellResult.forwardToClient).toBeNull() + + // Register settle callback and advance past settle delay + const onSettled = vi.fn() + auth.setOnAuthSettled(onSettled) + vi.advanceTimersByTime(500) + + expect(auth.state).toBe('authenticated' satisfies TelnetAuthState) + expect(onSettled).toHaveBeenCalledOnce() + // Settle callback receives all buffered data + const buffered = onSettled.mock.calls[0]?.[0] as Buffer + expect(buffered.toString()).toContain('user@host:~$ ') + + vi.useRealTimers() + 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', () => { + vi.useFakeTimers() + + const auth = new TelnetAuthenticator(baseOptions()) + + // Complete auth + auth.processData(Buffer.from('login: ')) + auth.processData(Buffer.from('Password: ')) + auth.processData(Buffer.from('user@host:~$ ')) + + // Advance past settle delay to complete authentication + auth.setOnAuthSettled(() => { /* no-op */ }) + vi.advanceTimersByTime(500) + 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() + + vi.useRealTimers() + 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 settle delay (500ms) to trigger auth success + auth.setOnAuthSettled(() => { /* no-op */ }) + vi.advanceTimersByTime(500) + expect(auth.state).toBe('authenticated' satisfies TelnetAuthState) + + // Advance past original timeout + vi.advanceTimersByTime(10_000) + + expect(onTimeout).not.toHaveBeenCalled() + + vi.useRealTimers() + auth.destroy() + }) + }) +}) 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 000000000..0454b599e --- /dev/null +++ b/tests/unit/services/telnet/telnet-connection-pool.vitest.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import type { Socket } from 'node:net' +import { createConnectionId, createSessionId } from '../../../../app/types/branded.js' +import { TelnetConnectionPool, 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([]) + }) +}) 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 000000000..a234485c2 --- /dev/null +++ b/tests/unit/services/telnet/telnet-negotiation.vitest.ts @@ -0,0 +1,410 @@ +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])) + }) + }) + + describe('processInbound - state-aware DO handling', () => { + it('should skip WILL when option was already offered proactively', () => { + const negotiator = new TelnetNegotiator('xterm-256color') + negotiator.buildProactiveOffers() + + // Server responds DO TERMINAL_TYPE to our proactive WILL + const input = Buffer.from([IAC, DO, TERMINAL_TYPE]) + const result = negotiator.processInbound(input) + + // Should NOT re-send WILL TERMINAL_TYPE (already sent proactively) + expect(result.responses).toHaveLength(0) + }) + + it('should send NAWS data when NAWS transitions to active from offered', () => { + const negotiator = new TelnetNegotiator() + negotiator.setWindowSize(120, 40) + negotiator.buildProactiveOffers() + + // Server responds DO NAWS to our proactive WILL + const input = Buffer.from([IAC, DO, NAWS]) + const result = negotiator.processInbound(input) + + // Should send NAWS data (but no duplicate WILL) + expect(result.responses).toHaveLength(1) + expect(result.responses[0]).toEqual( + Buffer.from([IAC, SB, NAWS, 0, 120, 0, 40, IAC, SE]) + ) + }) + + it('should still send WILL + NAWS data for server-initiated DO NAWS (no proactive offer)', () => { + const negotiator = new TelnetNegotiator() + negotiator.setWindowSize(80, 24) + + // Server initiates DO NAWS without proactive offer + const input = Buffer.from([IAC, DO, NAWS]) + const result = negotiator.processInbound(input) + + // Should send WILL NAWS + NAWS data + expect(result.responses).toHaveLength(2) + expect(result.responses[0]).toEqual(Buffer.from([IAC, WILL, NAWS])) + expect(result.responses[1]).toEqual( + Buffer.from([IAC, SB, NAWS, 0, 80, 0, 24, IAC, SE]) + ) + }) + + it('should still send WILL for server-initiated DO TERMINAL_TYPE (no proactive offer)', () => { + const negotiator = new TelnetNegotiator('xterm-256color') + + // Server initiates DO TERMINAL_TYPE without proactive offer + const input = Buffer.from([IAC, DO, TERMINAL_TYPE]) + const result = negotiator.processInbound(input) + + // Should send WILL TERMINAL_TYPE + expect(result.responses).toHaveLength(1) + expect(result.responses[0]).toEqual(Buffer.from([IAC, WILL, TERMINAL_TYPE])) + }) + }) + + describe('buildProactiveOffers', () => { + it('should return WILL TERMINAL_TYPE and WILL NAWS buffers', () => { + const negotiator = new TelnetNegotiator('xterm-256color') + const offers = negotiator.buildProactiveOffers() + + expect(offers).toHaveLength(2) + expect(offers[0]).toEqual(Buffer.from([IAC, WILL, TERMINAL_TYPE])) + expect(offers[1]).toEqual(Buffer.from([IAC, WILL, NAWS])) + }) + + it('should be idempotent - calling twice returns empty on second call', () => { + const negotiator = new TelnetNegotiator() + const first = negotiator.buildProactiveOffers() + const second = negotiator.buildProactiveOffers() + + expect(first).toHaveLength(2) + expect(second).toHaveLength(0) + }) + }) +}) 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 000000000..69e350309 --- /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