Skip to content

Commit dcaf257

Browse files
authored
fix: respect config ssh port (#432)
1 parent 107cd3d commit dcaf257

File tree

8 files changed

+244
-19
lines changed

8 files changed

+244
-19
lines changed

app/auth/credential-processor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ export function validateConnectionParams(params: ConnectionParams): ValidatedCon
9797
throw new Error('Host is required but not provided')
9898
}
9999

100-
// Determine port
101-
const port = getValidatedPort(params.port)
100+
// Determine port - fall back to configured SSH port when not provided
101+
const port = getValidatedPort(params.port ?? params.config.ssh.port)
102102

103103
// Determine terminal
104104
const term = params.sshterm == null
@@ -180,4 +180,4 @@ export function extractReadyTimeout(params: Record<string, unknown>): number | n
180180
}
181181

182182
return null
183-
}
183+
}

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

playwright.config.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* - If ENABLE_E2E_SSH=1, starts containerized sshd in global setup and stops in teardown
55
*/
66
import { defineConfig, devices } from '@playwright/test'
7-
import { WEB_PORT, BASE_URL, TIMEOUTS } from './tests/playwright/constants.js'
7+
import { WEB_PORT, BASE_URL, TIMEOUTS, SSH_PORT, SSH_HOST, USERNAME, PASSWORD } from './tests/playwright/constants.js'
88

99
const enableE2E = process.env.ENABLE_E2E_SSH === '1'
1010

@@ -30,12 +30,17 @@ export default defineConfig({
3030
],
3131
webServer: {
3232
// Keep server logs quiet by default; opt-in with E2E_DEBUG
33-
command: `WEBSSH2_LISTEN_PORT=${WEB_PORT} node dist/index.js`,
33+
command: 'node ./tests/playwright/scripts/start-server.mjs',
3434
url: `${BASE_URL}/ssh`,
3535
reuseExistingServer: true,
3636
timeout: TIMEOUTS.WEB_SERVER,
3737
env: {
3838
DEBUG: process.env.E2E_DEBUG ?? '',
39+
WEBSSH2_LISTEN_PORT: String(WEB_PORT),
40+
E2E_SSH_HOST: SSH_HOST,
41+
E2E_SSH_PORT: String(SSH_PORT),
42+
E2E_SSH_USER: USERNAME,
43+
E2E_SSH_PASS: PASSWORD,
3944
WEBSSH2_SSH_READY_TIMEOUT: '10000' // Faster timeout for test suite
4045
},
4146
},
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
{
2+
"listen": {
3+
"ip": "0.0.0.0",
4+
"port": 0
5+
},
6+
"http": {
7+
"origins": ["*:*"]
8+
},
9+
"user": {
10+
"name": null,
11+
"password": null,
12+
"privateKey": null,
13+
"passphrase": null
14+
},
15+
"ssh": {
16+
"host": null,
17+
"port": 0,
18+
"term": "xterm-256color",
19+
"readyTimeout": 20000,
20+
"keepaliveInterval": 120000,
21+
"keepaliveCountMax": 10,
22+
"allowedSubnets": [],
23+
"alwaysSendKeyboardInteractivePrompts": false,
24+
"disableInteractiveAuth": false,
25+
"algorithms": {
26+
"cipher": [
27+
"chacha20-poly1305@openssh.com",
28+
"aes128-gcm",
29+
"aes128-gcm@openssh.com",
30+
"aes256-gcm",
31+
"aes256-gcm@openssh.com",
32+
"aes128-ctr",
33+
"aes192-ctr",
34+
"aes256-ctr",
35+
"aes256-cbc"
36+
],
37+
"compress": ["none", "zlib@openssh.com", "zlib"],
38+
"hmac": [
39+
"hmac-sha2-256-etm@openssh.com",
40+
"hmac-sha2-512-etm@openssh.com",
41+
"hmac-sha1-etm@openssh.com",
42+
"hmac-sha2-256",
43+
"hmac-sha2-512",
44+
"hmac-sha1"
45+
],
46+
"kex": [
47+
"curve25519-sha256",
48+
"curve25519-sha256@libssh.org",
49+
"ecdh-sha2-nistp256",
50+
"ecdh-sha2-nistp384",
51+
"ecdh-sha2-nistp521",
52+
"diffie-hellman-group14-sha256",
53+
"diffie-hellman-group-exchange-sha256",
54+
"diffie-hellman-group14-sha1"
55+
],
56+
"serverHostKey": [
57+
"ssh-ed25519",
58+
"rsa-sha2-512",
59+
"rsa-sha2-256",
60+
"ecdsa-sha2-nistp256",
61+
"ecdsa-sha2-nistp384",
62+
"ecdsa-sha2-nistp521",
63+
"ssh-rsa"
64+
]
65+
},
66+
"envAllowlist": []
67+
},
68+
"header": {
69+
"text": null,
70+
"background": "green"
71+
},
72+
"options": {
73+
"challengeButton": true,
74+
"autoLog": false,
75+
"allowReauth": true,
76+
"allowReconnect": true,
77+
"allowReplay": true,
78+
"replayCRLF": false
79+
},
80+
"session": {
81+
"secret": "test-session-secret-0123456789abcdef0123456789abcdef",
82+
"name": "webssh2.sid"
83+
},
84+
"sso": {
85+
"enabled": false,
86+
"csrfProtection": false,
87+
"trustedProxies": [],
88+
"headerMapping": {
89+
"username": "x-apm-username",
90+
"password": "x-apm-password",
91+
"session": "x-apm-session"
92+
}
93+
},
94+
"logging": {
95+
"minimumLevel": "info",
96+
"stdout": {
97+
"enabled": true
98+
}
99+
}
100+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { test } from '@playwright/test'
2+
import { TEST_CONFIG } from './constants.js'
3+
import { connectWithBasicAuth, waitForV2Prompt, executeAndVerifyCommand } from './v2-helpers.js'
4+
5+
const E2E_ENABLED = process.env.ENABLE_E2E_SSH === '1'
6+
7+
test.describe('Config SSH port fallback', () => {
8+
test.skip(!E2E_ENABLED, 'Set ENABLE_E2E_SSH=1 to run this test')
9+
10+
test('uses configured ssh.port when Basic Auth URL omits port parameter', async ({ page }) => {
11+
await connectWithBasicAuth(
12+
page,
13+
TEST_CONFIG.baseUrl,
14+
TEST_CONFIG.validUsername,
15+
TEST_CONFIG.validPassword,
16+
TEST_CONFIG.sshHost
17+
)
18+
19+
await waitForV2Prompt(page)
20+
await executeAndVerifyCommand(page, 'whoami', TEST_CONFIG.validUsername)
21+
})
22+
})
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { spawn } from 'node:child_process'
2+
import { fileURLToPath } from 'node:url'
3+
import path from 'node:path'
4+
import { readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs'
5+
6+
const __filename = fileURLToPath(import.meta.url)
7+
const __dirname = path.dirname(__filename)
8+
const rootDir = path.resolve(__dirname, '..', '..', '..')
9+
const templatePath = path.resolve(__dirname, '..', 'assets', 'config.template.json')
10+
const configPath = path.resolve(rootDir, 'config.json')
11+
12+
function loadTemplate() {
13+
const raw = readFileSync(templatePath, 'utf8')
14+
return JSON.parse(raw)
15+
}
16+
17+
function buildConfig() {
18+
const template = loadTemplate()
19+
const listenPort = Number.parseInt(process.env.WEBSSH2_LISTEN_PORT ?? '', 10) || 4444
20+
const sshPort = Number.parseInt(process.env.E2E_SSH_PORT ?? '', 10) || 22
21+
22+
template.listen.port = listenPort
23+
template.ssh.port = sshPort
24+
25+
return template
26+
}
27+
28+
function writeConfig(config) {
29+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
30+
}
31+
32+
function cleanup() {
33+
if (existsSync(configPath)) {
34+
try {
35+
rmSync(configPath)
36+
} catch (error) {
37+
console.warn('[playwright:start-server] Failed to remove config.json:', error)
38+
}
39+
}
40+
}
41+
42+
const config = buildConfig()
43+
writeConfig(config)
44+
45+
const childEnv = {
46+
...process.env,
47+
WEBSSH2_LISTEN_PORT: String(config.listen.port)
48+
}
49+
50+
const child = spawn(process.execPath, ['dist/index.js'], {
51+
cwd: rootDir,
52+
stdio: 'inherit',
53+
env: childEnv
54+
})
55+
56+
child.on('error', (error) => {
57+
console.error('[playwright:start-server] Failed to start WebSSH2 server:', error)
58+
cleanup()
59+
process.exit(1)
60+
})
61+
62+
const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT']
63+
64+
for (const signal of signals) {
65+
process.on(signal, () => {
66+
if (child.killed) {
67+
return
68+
}
69+
child.kill(signal)
70+
})
71+
}
72+
73+
process.on('exit', () => {
74+
cleanup()
75+
})
76+
77+
child.on('exit', (code, signal) => {
78+
cleanup()
79+
const exitCode = code ?? (typeof signal === 'string' ? 1 : 0)
80+
process.exit(exitCode)
81+
})

tests/playwright/v2-helpers.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,16 +168,30 @@ export async function connectV2(page: Page, options: {
168168
/**
169169
* Builds a Basic Auth URL for SSH connection
170170
*/
171-
export function buildBasicAuthUrl(baseUrl: string, username: string, password: string, host: string, port: string | number): string {
171+
export function buildBasicAuthUrl(
172+
baseUrl: string,
173+
username: string,
174+
password: string,
175+
host: string,
176+
port?: string | number
177+
): string {
172178
const basicAuth = `${username}:${password}`
173179
const baseUrlWithAuth = baseUrl.replace('://', `://${basicAuth}@`)
174-
return `${baseUrlWithAuth}/ssh/host/${host}?port=${port}`
180+
const portQuery = port == null ? '' : `?port=${port}`
181+
return `${baseUrlWithAuth}/ssh/host/${host}${portQuery}`
175182
}
176183

177184
/**
178185
* Navigates to SSH with Basic Auth and waits for connection
179186
*/
180-
export async function connectWithBasicAuth(page: Page, baseUrl: string, username: string, password: string, host: string, port: string | number): Promise<void> {
187+
export async function connectWithBasicAuth(
188+
page: Page,
189+
baseUrl: string,
190+
username: string,
191+
password: string,
192+
host: string,
193+
port?: string | number
194+
): Promise<void> {
181195
const url = buildBasicAuthUrl(baseUrl, username, password, host, port)
182196
await page.goto(url)
183197
await waitForV2Connection(page)

tests/unit/auth/credential-processor.vitest.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,15 @@ describe('validateConnectionParams', () => {
103103
it('should fallback to config host', () => {
104104
const config = createDefaultConfig()
105105
config.ssh.host = 'default.com'
106-
106+
config.ssh.port = 2022
107+
107108
const params = { config }
108-
109+
109110
const result = validateConnectionParams(params)
110-
111+
111112
expect(result).toEqual({
112113
host: 'default.com',
113-
port: 22,
114+
port: 2022,
114115
term: 'xterm-256color'
115116
})
116117
})
@@ -135,15 +136,16 @@ describe('validateConnectionParams', () => {
135136
expect(() => validateConnectionParams(params)).toThrow('Host is required')
136137
})
137138

138-
it('should use default port when not provided', () => {
139+
it('should use config port when not provided', () => {
139140
const config = createDefaultConfig()
140141
config.ssh.host = 'example.com'
141-
142-
const params = { config }
143-
142+
config.ssh.port = 3022
143+
144+
const params = { host: 'example.com', config }
145+
144146
const result = validateConnectionParams(params)
145-
146-
expect(result.port).toBe(22)
147+
148+
expect(result.port).toBe(3022)
147149
})
148150
})
149151

@@ -239,4 +241,4 @@ describe('extractReadyTimeout', () => {
239241
expect(extractReadyTimeout({ readyTimeout: -1000 })).toBe(null)
240242
expect(extractReadyTimeout({ readyTimeout: 0 })).toBe(null)
241243
})
242-
})
244+
})

0 commit comments

Comments
 (0)