Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1fb9487
chore: add .worktrees/ to .gitignore
billchurch Feb 26, 2026
382852f
feat(telnet): add types, constants, and ProtocolService interface
billchurch Feb 26, 2026
b364064
feat(telnet): add default config and environment variable mapping
billchurch Feb 26, 2026
aeaac31
feat(telnet): add IAC negotiation layer for telnet protocol
billchurch Feb 26, 2026
06af9a2
feat(telnet): add expect-style authenticator for telnet login prompts
billchurch Feb 26, 2026
8077922
feat(telnet): add TelnetConnectionPool for managing telnet connections
billchurch Feb 26, 2026
d95672d
feat(telnet): add TelnetServiceImpl assembling negotiation, auth, and…
billchurch Feb 26, 2026
92b4a71
feat(telnet): add Express routes for /telnet/ mirroring SSH route pat…
billchurch Feb 26, 2026
e644107
feat(telnet): wire Socket.IO telnet namespace and protocol-aware adap…
billchurch Feb 26, 2026
ade0a19
fix: resolve lint errors in telnet test and config files
billchurch Feb 26, 2026
22519e3
docs: add telnet configuration to CONFIG-JSON.md and ENVIRONMENT-VARI…
billchurch Feb 26, 2026
f95012c
fix(telnet): address code review findings - security and robustness
billchurch Feb 26, 2026
6188b25
feat(telnet): add option state tracking and buildProactiveOffers()
billchurch Feb 26, 2026
813b369
feat(telnet): make handleDo() state-aware to prevent duplicate WILL
billchurch Feb 26, 2026
104edb8
feat(telnet): add IAC debug logging (webssh2:telnet:iac)
billchurch Feb 26, 2026
0e21ade
fix(telnet): replace unsolicited NAWS with proactive option offers
billchurch Feb 26, 2026
f556f5e
fix: resolve SonarQube issues across server codebase
billchurch Feb 27, 2026
4e72ebb
chore: update webssh2_client to 3.5.0-telnet.1, add rollup darwin-arm64
billchurch Feb 27, 2026
5f532c6
fix: remove platform-specific @rollup/rollup-darwin-arm64 from depend…
billchurch Feb 27, 2026
d460cdd
docs: add telnet routes and feature documentation [skip ci]
billchurch Mar 1, 2026
d04d14e
chore: update deps and security review dates
billchurch Mar 5, 2026
bedbd68
feat: update webssh2_client to 3.5.0
billchurch Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ dist/
.migration/

.regression/
.worktrees/
config.json.backup

release-artifacts/
Expand Down
123 changes: 123 additions & 0 deletions DOCS/configuration/CONFIG-JSON.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
60 changes: 60 additions & 0 deletions DOCS/configuration/ENVIRONMENT-VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 19 additions & 4 deletions app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -65,9 +73,16 @@ export async function initializeServerAsync(): Promise<{
}
const io = configureSocketIO(server, sessionMiddleware, cfgForIO)

// Pass services to socket initialization
initSocket(io as Parameters<typeof initSocket>[0], appConfig, services)

// Pass services to socket initialization (SSH)
initSocket(io as Parameters<typeof initSocket>[0], appConfig, services, 'ssh')

// Configure telnet namespace if enabled
const telnetIo = configureTelnetNamespace(server, sessionMiddleware, appConfig)
if (telnetIo !== null) {
initSocket(telnetIo as Parameters<typeof initSocket>[0], appConfig, services, 'telnet')
debug('Telnet Socket.IO namespace initialized')
}

startServer(server, appConfig)
debug('Server initialized asynchronously')
return { server, io, app, config: appConfig, services }
Expand Down
43 changes: 40 additions & 3 deletions app/config/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -143,6 +144,19 @@ export const DEFAULT_CONFIG_BASE: Omit<Config, 'session'> & { session: Omit<Conf
enabled: true
}
},
telnet: {
enabled: false,
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: [],
},
}

/**
Expand Down Expand Up @@ -197,6 +211,9 @@ export function createCompleteDefaultConfig(sessionSecret?: string): Config {
// Generate a secure secret if none provided
const secret = sessionSecret ?? crypto.randomBytes(32).toString('hex')
const loggingConfig = cloneLoggingConfig(DEFAULT_CONFIG_BASE.logging)
const telnetConfig = DEFAULT_CONFIG_BASE.telnet === undefined
? undefined
: cloneTelnetConfig(DEFAULT_CONFIG_BASE.telnet)
return {
listen: { ...DEFAULT_CONFIG_BASE.listen },
http: { origins: [...DEFAULT_CONFIG_BASE.http.origins] },
Expand All @@ -213,7 +230,8 @@ export function createCompleteDefaultConfig(sessionSecret?: string): Config {
trustedProxies: [...DEFAULT_CONFIG_BASE.sso.trustedProxies],
headerMapping: { ...DEFAULT_CONFIG_BASE.sso.headerMapping },
},
...(loggingConfig === undefined ? {} : { logging: loggingConfig })
...(loggingConfig === undefined ? {} : { logging: loggingConfig }),
...(telnetConfig === undefined ? {} : { telnet: telnetConfig }),
}
}

Expand Down Expand Up @@ -340,6 +358,25 @@ function cloneLoggingSyslogTls(
}
}

/**
* Deep clone telnet configuration
*/
function cloneTelnetConfig(telnet: TelnetConfig): TelnetConfig {
return {
enabled: telnet.enabled,
defaultPort: telnet.defaultPort,
timeout: telnet.timeout,
term: telnet.term,
auth: {
loginPrompt: telnet.auth.loginPrompt,
passwordPrompt: telnet.auth.passwordPrompt,
failurePattern: telnet.auth.failurePattern,
expectTimeout: telnet.auth.expectTimeout,
},
allowedSubnets: [...telnet.allowedSubnets],
}
}

/**
* Deep clone SFTP configuration
*/
Expand Down
10 changes: 10 additions & 0 deletions app/config/env-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ export const ENV_VAR_MAPPING: Record<string, EnvVarMap> = {
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' },
}

/**
Expand Down
Loading
Loading