diff --git a/DOCS/configuration/CONFIG-JSON.md b/DOCS/configuration/CONFIG-JSON.md index 7d62375f..6b7f10c0 100644 --- a/DOCS/configuration/CONFIG-JSON.md +++ b/DOCS/configuration/CONFIG-JSON.md @@ -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, diff --git a/DOCS/configuration/ENVIRONMENT-VARIABLES.md b/DOCS/configuration/ENVIRONMENT-VARIABLES.md index b5ea908e..eec1d4ac 100644 --- a/DOCS/configuration/ENVIRONMENT-VARIABLES.md +++ b/DOCS/configuration/ENVIRONMENT-VARIABLES.md @@ -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 | diff --git a/DOCS/configuration/URL-PARAMETERS.md b/DOCS/configuration/URL-PARAMETERS.md index d2155e93..f9e7a026 100644 --- a/DOCS/configuration/URL-PARAMETERS.md +++ b/DOCS/configuration/URL-PARAMETERS.md @@ -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 | @@ -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` diff --git a/app/connectionHandler.ts b/app/connectionHandler.ts index 6c8bb932..f5369d64 100644 --- a/app/connectionHandler.ts +++ b/app/connectionHandler.ts @@ -95,9 +95,23 @@ function buildConnectionMode(opts: ConnectionOptions | undefined): Record { + if (isTelnet || cfg.ssh.term === '') { + return {} + } + return { ssh: { sshterm: cfg.ssh.term } } +} + function buildSshCredentials( session: Sess | undefined, req: Request & { sessionID?: string }, + defaultTerm: string, ): Record { if (session == null || !hasSessionCredentials(session) || session.sshCredentials == null) { return {} @@ -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', { @@ -251,7 +267,9 @@ export function buildTempConfig( const isTelnet = opts?.protocol === 'telnet' const tempConfig: Record = 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 diff --git a/app/socket/adapters/ssh-config.ts b/app/socket/adapters/ssh-config.ts index f3296980..9267b6c3 100644 --- a/app/socket/adapters/ssh-config.ts +++ b/app/socket/adapters/ssh-config.ts @@ -106,9 +106,12 @@ export function buildTerminalDefaults( const req = context.socket.request as { session?: { envVars?: Record } } 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 diff --git a/tests/unit/connection-handler/term-injection.vitest.ts b/tests/unit/connection-handler/term-injection.vitest.ts new file mode 100644 index 00000000..1d7eb817 --- /dev/null +++ b/tests/unit/connection-handler/term-injection.vitest.ts @@ -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): Record { + return tempConfig['ssh'] as Record +} + +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 }) + }) +}) diff --git a/tests/unit/socket/ssh-config-terminal-defaults.vitest.ts b/tests/unit/socket/ssh-config-terminal-defaults.vitest.ts new file mode 100644 index 00000000..36ea0e27 --- /dev/null +++ b/tests/unit/socket/ssh-config-terminal-defaults.vitest.ts @@ -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) + }) +})