Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion DOCS/configuration/CONFIG-JSON.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ These settings are now managed client-side.
"ssh": {
"host": null,
"port": 22,
"term": "xterm-color",
"term": "xterm-256color",
"readyTimeout": 20000,
"keepaliveInterval": 120000,
"keepaliveCountMax": 10,
Expand Down
2 changes: 1 addition & 1 deletion DOCS/configuration/ENVIRONMENT-VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ WEBSSH2_CSP_FRAME_ANCESTORS="https://dashboard.example.com"
| `WEBSSH2_SSH_PORT` | number | `22` | Default SSH port |
| `WEBSSH2_SSH_LOCAL_ADDRESS` | string | `null` | Local address for SSH connections |
| `WEBSSH2_SSH_LOCAL_PORT` | number | `null` | Local port for SSH connections |
| `WEBSSH2_SSH_TERM` | string | `xterm-color` | Terminal type |
| `WEBSSH2_SSH_TERM` | string | `xterm-256color` | Default terminal type for SSH sessions (manual and auto-connect); `?sshterm=` overrides per session |
| `WEBSSH2_SSH_READY_TIMEOUT` | number | `20000` | Connection ready timeout (ms) |
| `WEBSSH2_SSH_KEEPALIVE_INTERVAL` | number | `120000` | Keep-alive interval (ms) |
| `WEBSSH2_SSH_KEEPALIVE_COUNT_MAX` | number | `10` | Maximum keep-alive count |
Expand Down
8 changes: 4 additions & 4 deletions DOCS/configuration/URL-PARAMETERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ WebSSH2 supports configuration through URL query parameters, allowing you to cus
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `port` | integer | 22 | SSH port to connect to |
| `sshterm` | string | xterm-color | Terminal type for the SSH session |
| `sshterm` | string | `ssh.term` (default xterm-256color) | Terminal type for the SSH session |
| `header` | string | - | Override the header text |
| `headerBackground` | string | green | Header background color |
| `env` | string | - | Environment variables for SSH session |
Expand Down Expand Up @@ -57,12 +57,12 @@ Specifies the SSH port on the target server.

### SSH Terminal Type

Sets the terminal type for the SSH session. This affects how the terminal displays colors and special characters.
Sets the terminal type for the SSH session. This affects how the terminal displays colors and special characters. When omitted, the server's `ssh.term` setting (`WEBSSH2_SSH_TERM`, default `xterm-256color`) is used for both manual and auto-connect sessions.

Common values:

- `xterm-color` (default)
- `xterm-256color` (256 color support)
- `xterm-256color` (default, 256 color support)
- `xterm-color`
- `xterm`
- `vt100`
- `linux`
Expand Down
24 changes: 21 additions & 3 deletions app/connectionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,23 @@ function buildConnectionMode(opts: ConnectionOptions | undefined): Record<string
return result
}

/**
* Build the default ssh.sshterm fragment so manual page loads pick up
* config.ssh.term (billchurch/webssh2#572). Only the terminal type is
* injected — never host/port/credentials. Telnet TERM is governed by
* config.telnet.term server-side, so telnet routes get nothing here.
*/
function buildTermConfig(cfg: Config, isTelnet: boolean): Record<string, unknown> {
if (isTelnet || cfg.ssh.term === '') {
return {}
}
return { ssh: { sshterm: cfg.ssh.term } }
}

function buildSshCredentials(
session: Sess | undefined,
req: Request & { sessionID?: string },
defaultTerm: string,
): Record<string, unknown> {
if (session == null || !hasSessionCredentials(session) || session.sshCredentials == null) {
return {}
Expand All @@ -107,8 +121,10 @@ function buildSshCredentials(
host: creds.host,
port: creds.port,
}
if (creds.term != null && creds.term !== '') {
sshFragment['sshterm'] = creds.term
// Session term > config.ssh.term > nothing (#572)
const term = creds.term == null || creds.term === '' ? defaultTerm : creds.term
if (term !== '') {
sshFragment['sshterm'] = term
}
const authType = session.usedBasicAuth === true ? 'Basic Auth' : (session.authMethod ?? 'Unknown')
debug('Session-only auth enabled - credentials remain server-side: %O', {
Expand Down Expand Up @@ -251,7 +267,9 @@ export function buildTempConfig(
const isTelnet = opts?.protocol === 'telnet'
const tempConfig: Record<string, unknown> = buildSocketConfig(req as Request, isTelnet, cfg)
Object.assign(tempConfig, buildConnectionMode(opts))
Object.assign(tempConfig, buildSshCredentials(req.session, req))
// Default term first; a session-supplied ssh fragment overwrites it wholesale.
Object.assign(tempConfig, buildTermConfig(cfg, isTelnet))
Object.assign(tempConfig, buildSshCredentials(req.session, req, cfg.ssh.term))
Object.assign(tempConfig, buildHeaderConfig(cfg, req.session))
Object.assign(tempConfig, buildTerminalConfig(cfg))
return tempConfig
Expand Down
5 changes: 4 additions & 1 deletion app/socket/adapters/ssh-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,12 @@ export function buildTerminalDefaults(
const req = context.socket.request as { session?: { envVars?: Record<string, string> } }
const envVars = req.session?.envVars ?? {}
const { initialTermSettings } = context.state
// Operator default (config.ssh.term) sits between session settings and the
// hard constant so clients that omit `term` still honour it (#572).
const configTerm = context.config.ssh.term === '' ? undefined : context.config.ssh.term

return {
term: settings?.term ?? initialTermSettings.term ?? TERMINAL_DEFAULTS.DEFAULT_TERM,
term: settings?.term ?? initialTermSettings.term ?? configTerm ?? TERMINAL_DEFAULTS.DEFAULT_TERM,
rows: settings?.rows ?? initialTermSettings.rows ?? TERMINAL_DEFAULTS.DEFAULT_ROWS,
cols: settings?.cols ?? initialTermSettings.cols ?? TERMINAL_DEFAULTS.DEFAULT_COLS,
env: envVars
Expand Down
87 changes: 87 additions & 0 deletions tests/unit/connection-handler/term-injection.vitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// tests/unit/connection-handler/term-injection.vitest.ts
// buildTempConfig ssh.sshterm slice (billchurch/webssh2#572)

import { describe, it, expect } from 'vitest'
import { buildTempConfig } from '../../../app/connectionHandler.js'
import type { Config } from '../../../app/types/config.js'
import { TEST_SSH } from '../../test-constants.js'
import { makeReq, defaultConfig, type TestReq } from './injection-test-helpers.js'

const CONFIGURED_TERM = 'xterm-256color'
const SESSION_TERM = 'vt100'

function cfgWithTerm(term: string): Config {
return { ...defaultConfig, ssh: { ...defaultConfig.ssh, term } }
}

function manualReq(): TestReq {
const req = makeReq()
req.session = undefined
;(req as { path: string }).path = '/'
return req
}

function sessionReq(term: string | undefined): TestReq {
const req = makeReq()
req.session = {
sshCredentials: { host: TEST_SSH.HOST, port: TEST_SSH.PORT, term },
usedBasicAuth: false,
authMethod: 'POST',
headerOverride: undefined
} as TestReq['session']
return req
}

function sshFragment(tempConfig: Record<string, unknown>): Record<string, unknown> {
return tempConfig['ssh'] as Record<string, unknown>
}

describe('buildTempConfig - ssh.sshterm slice (#572)', () => {
it('injects only sshterm on a manual SSH load with no session', () => {
const tempConfig = buildTempConfig(manualReq(), cfgWithTerm(CONFIGURED_TERM))
expect(sshFragment(tempConfig)).toEqual({ sshterm: CONFIGURED_TERM })
expect(tempConfig['autoConnect']).toBe(false)
})

it('omits the ssh key when config.ssh.term is empty', () => {
const tempConfig = buildTempConfig(manualReq(), cfgWithTerm(''))
expect('ssh' in tempConfig).toBe(false)
})

it('omits the ssh key on telnet routes', () => {
const tempConfig = buildTempConfig(manualReq(), cfgWithTerm(CONFIGURED_TERM), {
protocol: 'telnet'
})
expect('ssh' in tempConfig).toBe(false)
})

it('does not inject when the session lacks POST/basic-auth credentials', () => {
// makeReq() default session uses authMethod 'password' – not a session-credential flow
const tempConfig = buildTempConfig(makeReq(), cfgWithTerm(CONFIGURED_TERM))
expect(sshFragment(tempConfig)).toEqual({ sshterm: CONFIGURED_TERM })
})

it('session term wins over the configured default', () => {
const tempConfig = buildTempConfig(sessionReq(SESSION_TERM), cfgWithTerm(CONFIGURED_TERM))
expect(sshFragment(tempConfig)).toEqual({
host: TEST_SSH.HOST,
port: TEST_SSH.PORT,
sshterm: SESSION_TERM
})
expect(tempConfig['autoConnect']).toBe(true)
})

it('falls back to the configured default when the session has no term', () => {
const tempConfig = buildTempConfig(sessionReq(undefined), cfgWithTerm(CONFIGURED_TERM))
expect(sshFragment(tempConfig)).toEqual({
host: TEST_SSH.HOST,
port: TEST_SSH.PORT,
sshterm: CONFIGURED_TERM
})
})

it('omits sshterm entirely when neither session nor config provide one', () => {
const tempConfig = buildTempConfig(sessionReq(undefined), cfgWithTerm(''))
expect(sshFragment(tempConfig)).toEqual({ host: TEST_SSH.HOST, port: TEST_SSH.PORT })
})
})
48 changes: 48 additions & 0 deletions tests/unit/socket/ssh-config-terminal-defaults.vitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// tests/unit/socket/ssh-config-terminal-defaults.vitest.ts
// buildTerminalDefaults term fallback chain (billchurch/webssh2#572)

import { describe, it, expect } from 'vitest'
import { buildTerminalDefaults } from '../../../app/socket/adapters/ssh-config.js'
import {
createAdapterSharedState,
type AdapterContext
} from '../../../app/socket/adapters/service-socket-shared.js'
import { TERMINAL_DEFAULTS } from '../../../app/constants/terminal.js'

const CONFIG_TERM = 'linux'
const CLIENT_TERM = 'vt220'
const INITIAL_TERM = 'screen'

function makeContext(configTerm: string, initialTerm?: string): AdapterContext {
const state = createAdapterSharedState()
if (initialTerm !== undefined) {
state.initialTermSettings.term = initialTerm
}
return {
socket: { request: {} } as unknown as AdapterContext['socket'],
config: { ssh: { term: configTerm } } as unknown as AdapterContext['config'],
state
} as unknown as AdapterContext
}

describe('buildTerminalDefaults - term fallback (#572)', () => {
it('prefers the client-supplied term', () => {
const result = buildTerminalDefaults({ term: CLIENT_TERM }, makeContext(CONFIG_TERM, INITIAL_TERM))
expect(result.term).toBe(CLIENT_TERM)
})

it('falls back to initial (session) term settings', () => {
const result = buildTerminalDefaults(undefined, makeContext(CONFIG_TERM, INITIAL_TERM))
expect(result.term).toBe(INITIAL_TERM)
})

it('falls back to config.ssh.term when client and session are silent', () => {
const result = buildTerminalDefaults(undefined, makeContext(CONFIG_TERM))
expect(result.term).toBe(CONFIG_TERM)
})

it('falls back to the hard default when config.ssh.term is empty', () => {
const result = buildTerminalDefaults(undefined, makeContext(''))
expect(result.term).toBe(TERMINAL_DEFAULTS.DEFAULT_TERM)
})
})
Loading