mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 16:02:08 +00:00
* Revert "feat(orcad): source-side dormant export of a relay-hosted SSH target (#16741 T6-8) (#24519)" This reverts commit783101b304. * Revert "feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) (#24463)" This reverts commit38c2d1dcb9. * Revert "feat(ssh): deploy and pair an empty managed orcad server over SSH (#16741 T6-5) (#24453)" This reverts commit8b76683b40. * Revert "fix(ssh): orcad GC honors the activation journal; readiness requires proven daemon coverage (#16741 T6 follow-up) (#24451)" This reverts commitd3f8c5063b. * Revert "feat(ssh): remote orcad stop by request file and journaled decommission (#16741 T6-4) (#24449)" This reverts commit43d9b43d3f. * Revert "feat(orcad): supervisable server: stop requests, managed stop receipts and a lifetime that keeps its lock on failed teardown (#16741 T6-3) (#24433)" This reverts commitb093d3ab20. * Revert "feat(ssh): crash-safe orcad activation, rollback and recovery (#16741 T6-2) (#24423)" This reverts commit1a9ac0e955. * Revert "feat(runtime): SSH access links for paired servers in a downgrade-safe sidecar (#16741 T5-1+T5-2) (#24420)" This reverts commit99db2bfae4. * Revert "feat(relay): capability-gated owner reset with a durable preparation journal (#16741 T3 R1) (#24418)" This reverts commit34a582bd39. * Revert "feat(ssh): track connection-manager drains, test probes and provider continuations (#16741 T2 P3+P8a) (#24407)" This reverts commitd53063d2b1. * Revert "feat(daemon): idle retirement, session census and recovery-only provider (#16741 T2 P4b) (#24409)" This reverts commitff212dbbef. * Revert "feat(ssh): add pty.resumeClient and split SSH PTY process listing (#16741 T2 P5+P6) (#24414)" This reverts commit92cb71765e. * Revert "feat(relay): await owned watcher and agent children on shutdown (#16741 T2 P1) (#24400)" This reverts commit6b36e4f85b. * Revert "feat(session): retry failed renderer session writes and verify local folder PTYs (#16741 T2 P9) (#24406)" This reverts commitd23ecef301. * Revert "feat(ssh): remote orcad primitives on the pinned Node runtime (#16741 T6-1) (#24419)" This reverts commitdd87ae578d. * Revert "fix(runtime): fence runtime-environment subscriptions and status probes by identity (#16741 T5-3) (#24421)" This reverts commitece9e4d2e3. * Revert "feat(orcad): migration manifest and dormant-state contracts (#16741 T6-7) (#24422)" This reverts commit3fbdaba262. * Revert "feat(ssh): wire SshConnection through the work and transport close ledgers (#16741 T2 P2) (#24401)" This reverts commit4e8edc8872. * Revert "feat(profiles): carry markdown frontmatter visibility in project transfers (#16741 T2 P7) (#24405)" This reverts commit60c93263cc. * Revert "fix(runtime): project the PTY incarnation onto mobile session tabs (#24413)" This reverts commit99e0303572. * Revert "feat(daemon): tag daemon stream data with the PTY incarnation id (#16741 T2 P4a) (#24402)" This reverts commit817af768b0. * Revert "feat(ssh): port the SSH connection work ledger and transport close ledger (#16741 T2) (#24210)" This reverts commitc9918931c8. * Revert "feat(relay): fence and drain file and git response streams on shutdown (#24185)" This reverts commitdc08ffeba9. * Revert "refactor(runtime-rpc): extract the Node WebSocket lifecycle; opt-in pinned port (#24186)" This reverts commita789233bbb. * Revert "feat(relay): route relay handlers through work admission; producer publication drain (#24181)" This reverts commit0b812bd698. * Revert "feat(relay): land the #16741 T1 seam (work drain, publication drain, release gate) (#24156)" This reverts commit3aa2d3af7c. --------- Co-authored-by: m4air <m4air@Mac.localdomain>
270 lines
8.5 KiB
TypeScript
270 lines
8.5 KiB
TypeScript
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import {
|
|
Client,
|
|
Server as Ssh2Server,
|
|
utils,
|
|
type AuthContext,
|
|
type Connection,
|
|
type KeyboardAuthContext,
|
|
type PasswordAuthContext
|
|
} from 'ssh2'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
import type { SshResolvedConfig } from './ssh-config-parser'
|
|
import { buildConnectConfig } from './ssh-connection-utils'
|
|
|
|
// OpenSSH's default; a host that burns it disconnects before the MFA stage is reached.
|
|
const MAX_AUTH_TRIES = 6
|
|
const PASSWORD = 'stage-one-password'
|
|
const PASSCODE = '123456'
|
|
|
|
type AuthStage = 'password' | 'keyboard-interactive'
|
|
|
|
type MfaServer = {
|
|
port: number
|
|
attempts: string[]
|
|
close: () => Promise<void>
|
|
}
|
|
|
|
/** An OpenSSH-style `AuthenticationMethods a,b` host: each stage partial-succeeds into the next. */
|
|
async function startMultiFactorServer(stages: AuthStage[]): Promise<MfaServer> {
|
|
const attempts: string[] = []
|
|
const connections = new Set<Connection>()
|
|
// Ed25519 keygen can produce an invalid 31-byte key; ECDSA points always start with 0x04.
|
|
const hostKey = utils.generateKeyPairSync('ecdsa', { bits: 256 }).private
|
|
const server = new Ssh2Server({ hostKeys: [hostKey] }, (connection) => {
|
|
connections.add(connection)
|
|
connection.on('error', () => {})
|
|
connection.on('close', () => connections.delete(connection))
|
|
let stage = 0
|
|
let failures = 0
|
|
const remaining = (): AuthStage[] => [stages[stage]!]
|
|
const fail = (context: AuthContext): void => {
|
|
failures += 1
|
|
if (failures >= MAX_AUTH_TRIES) {
|
|
connection.end()
|
|
return
|
|
}
|
|
context.reject(remaining(), false)
|
|
}
|
|
connection.on('authentication', (context) => {
|
|
attempts.push(context.method)
|
|
if (context.method === 'none') {
|
|
context.reject(remaining(), false)
|
|
return
|
|
}
|
|
if (context.method !== stages[stage]) {
|
|
fail(context)
|
|
return
|
|
}
|
|
if (context.method === 'password') {
|
|
if ((context as PasswordAuthContext).password !== PASSWORD) {
|
|
fail(context)
|
|
return
|
|
}
|
|
stage += 1
|
|
if (stage === stages.length) {
|
|
context.accept()
|
|
return
|
|
}
|
|
context.reject(remaining(), true)
|
|
return
|
|
}
|
|
const keyboard = context as KeyboardAuthContext
|
|
keyboard.prompt(
|
|
[{ prompt: 'Duo passcode:', echo: false }],
|
|
'Duo two-factor login',
|
|
'Approve the push or enter a passcode.',
|
|
(answers) => {
|
|
if (answers?.[0] !== PASSCODE) {
|
|
fail(context)
|
|
return
|
|
}
|
|
stage += 1
|
|
if (stage === stages.length) {
|
|
context.accept()
|
|
return
|
|
}
|
|
context.reject(remaining(), true)
|
|
}
|
|
)
|
|
})
|
|
})
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(0, '127.0.0.1', () => {
|
|
server.removeListener('error', reject)
|
|
resolve()
|
|
})
|
|
})
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('MFA fixture did not bind a TCP port')
|
|
}
|
|
return {
|
|
port: address.port,
|
|
attempts,
|
|
close: async () => {
|
|
for (const connection of connections) {
|
|
connection.end()
|
|
}
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function makeTarget(port: number, overrides: Partial<SshTarget> = {}): SshTarget {
|
|
return {
|
|
id: 'mfa-target',
|
|
label: 'hpc',
|
|
source: 'manual',
|
|
host: '127.0.0.1',
|
|
port,
|
|
username: 'fixture',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function makeResolved(port: number, identityFile: string[]): SshResolvedConfig {
|
|
return {
|
|
hostname: '127.0.0.1',
|
|
port,
|
|
user: 'fixture',
|
|
identityFile,
|
|
identitiesOnly: true,
|
|
forwardAgent: false,
|
|
proxyUseFdpass: false,
|
|
controlMaster: 'no',
|
|
controlPersist: 'no',
|
|
userKnownHostsFiles: [],
|
|
globalKnownHostsFiles: [],
|
|
strictHostKeyChecking: 'ask',
|
|
hashKnownHosts: false,
|
|
updateHostKeys: 'no'
|
|
}
|
|
}
|
|
|
|
/** Drives ssh2 the way SshConnection does: one credential per keyboard-interactive prompt. */
|
|
function connectWithOrcaConfig(
|
|
target: SshTarget,
|
|
resolved: SshResolvedConfig | null,
|
|
password: string | undefined,
|
|
answers: string[]
|
|
): { ready: Promise<void>; prompts: string[] } {
|
|
const prompts: string[] = []
|
|
const config = buildConnectConfig(target, resolved, {
|
|
includeAgent: false,
|
|
includePrivateKey: true
|
|
})
|
|
if (password != null) {
|
|
config.password = password
|
|
}
|
|
const ready = new Promise<void>((resolve, reject) => {
|
|
const client = new Client()
|
|
let answerIndex = 0
|
|
client.on('keyboard-interactive', (_name, _instructions, _lang, requested, finish) => {
|
|
for (const requestedPrompt of requested) {
|
|
prompts.push(requestedPrompt.prompt)
|
|
}
|
|
finish(requested.map(() => answers[answerIndex++] ?? ''))
|
|
})
|
|
client.once('ready', () => {
|
|
client.end()
|
|
resolve()
|
|
})
|
|
client.once('error', reject)
|
|
client.once('close', () => reject(new Error('SSH connection closed during authentication')))
|
|
client.connect({ ...config, hostVerifier: () => true, readyTimeout: 10_000 })
|
|
})
|
|
return { ready, prompts }
|
|
}
|
|
|
|
describe('multi-stage SSH authentication', () => {
|
|
let tempDir: string
|
|
let keyPaths: string[]
|
|
let homeEnv: { HOME?: string; USERPROFILE?: string }
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'orca-mfa-'))
|
|
// Why: the cases below pass `resolved: null`, so `resolvePrivateKeys` falls through to
|
|
// `findDefaultKeyFile`, which reads `~/.ssh/id_*` through `homedir()`. On a developer
|
|
// machine that picks up a real key, and an encrypted one makes ssh2 reject with
|
|
// "Cannot parse privateKey" before authentication is exercised at all. Hosted CI has no
|
|
// key, so this only ever failed locally. Pointing home at the fixture directory keeps
|
|
// default-key discovery inside the test's control on every machine.
|
|
homeEnv = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }
|
|
process.env.HOME = tempDir
|
|
process.env.USERPROFILE = tempDir
|
|
keyPaths = ['id_a', 'id_b'].map((name) => {
|
|
const path = join(tempDir, name)
|
|
writeFileSync(path, utils.generateKeyPairSync('ecdsa', { bits: 256 }).private)
|
|
return path
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
for (const key of ['HOME', 'USERPROFILE'] as const) {
|
|
const previous = homeEnv[key]
|
|
if (previous === undefined) {
|
|
delete process.env[key]
|
|
} else {
|
|
process.env[key] = previous
|
|
}
|
|
}
|
|
rmSync(tempDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('answers a keyboard-interactive stage that follows a password partial success', async () => {
|
|
const server = await startMultiFactorServer(['password', 'keyboard-interactive'])
|
|
try {
|
|
const { ready, prompts } = connectWithOrcaConfig(makeTarget(server.port), null, PASSWORD, [
|
|
PASSCODE
|
|
])
|
|
|
|
await expect(ready).resolves.toBeUndefined()
|
|
expect(prompts).toEqual(['Duo passcode:'])
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it('answers a second keyboard-interactive stage after the first partially succeeds', async () => {
|
|
const server = await startMultiFactorServer(['keyboard-interactive', 'keyboard-interactive'])
|
|
try {
|
|
const { ready, prompts } = connectWithOrcaConfig(makeTarget(server.port), null, undefined, [
|
|
PASSCODE,
|
|
PASSCODE
|
|
])
|
|
|
|
await expect(ready).resolves.toBeUndefined()
|
|
expect(prompts).toEqual(['Duo passcode:', 'Duo passcode:'])
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it('reaches the MFA stage without burning the host auth-try budget on rejected keys', async () => {
|
|
const server = await startMultiFactorServer(['password', 'keyboard-interactive'])
|
|
try {
|
|
const target = makeTarget(server.port, { source: 'ssh-config', configHost: 'hpc' })
|
|
const { ready } = connectWithOrcaConfig(
|
|
target,
|
|
makeResolved(server.port, keyPaths),
|
|
PASSWORD,
|
|
[PASSCODE]
|
|
)
|
|
|
|
await expect(ready).resolves.toBeUndefined()
|
|
// After the password stage partially succeeds the host only offers keyboard-interactive;
|
|
// re-offering keys there is what exhausts MaxAuthTries on real MFA hosts.
|
|
expect(server.attempts.filter((method) => method === 'publickey')).toHaveLength(0)
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
})
|