Files
floci-ui/packages/api/src/services/sshCheck.ts
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00

195 lines
7.1 KiB
TypeScript

import {createConnection} from 'node:net'
const DEFAULT_TIMEOUT_MS = 3_000
const MAX_BANNER_BYTES = 8_192
type Ec2DiagnosticInstance = {
instanceId?: string
sshHost?: string
sshPort?: number
containerStatus?: 'running' | 'stopped' | 'unavailable'
}
type DiagnosticResponse = {
ec2?: {instances?: Ec2DiagnosticInstance[]}
}
export type SshTarget = {
host: string
port: number
}
export type SshCheckResult = {
ok: true
banner: string
checkedFrom: 'UI backend'
checkedAt: string
target: SshTarget
}
export type SshCheckErrorCode =
| 'DIAGNOSTICS_UNAVAILABLE'
| 'INSTANCE_NOT_RUNNING'
| 'CONNECTION_REFUSED'
| 'CONNECTION_TIMEOUT'
| 'SSH_BANNER_MISSING'
export class SshCheckError extends Error {
constructor(
readonly code: SshCheckErrorCode,
message: string,
readonly status: 404 | 409 | 502 | 503 | 504,
) {
super(message)
this.name = 'SshCheckError'
}
}
export type DiagnosticFetcher = () => Promise<DiagnosticResponse>
export type SshSocketConnector = (target: SshTarget, timeoutMs: number) => Promise<string>
type SshCheckDependencies = {
fetchDiagnostics?: DiagnosticFetcher
connect?: SshSocketConnector
loopbackHost?: string
now?: () => Date
timeoutMs?: number
}
async function fetchDiagnostics(): Promise<DiagnosticResponse> {
const endpoint = process.env.FLOCI_ENDPOINT
if (!endpoint) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
}
let response: Response
try {
response = await fetch(`${endpoint}/_floci/diagnose`, {signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)})
} catch {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
}
if (!response.ok) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
}
try {
return await response.json() as DiagnosticResponse
} catch {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics returned an invalid response', 502)
}
}
export function readSshBanner(target: SshTarget, timeoutMs: number): Promise<string> {
return new Promise((resolve, reject) => {
const socket = createConnection(target)
let settled = false
let buffer = Buffer.alloc(0)
const finish = (error?: SshCheckError, banner?: string) => {
if (settled) return
settled = true
clearTimeout(timer)
socket.destroy()
if (error) reject(error)
else resolve(banner!)
}
const timer = setTimeout(() => {
finish(new SshCheckError('CONNECTION_TIMEOUT', `SSH connection to ${target.host}:${target.port} timed out`, 504))
}, timeoutMs)
socket.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk])
if (buffer.length > MAX_BANNER_BYTES) {
finish(new SshCheckError('SSH_BANNER_MISSING', 'SSH server did not send a valid banner', 502))
return
}
const lines = buffer.toString('utf8').split(/\r?\n/)
for (let index = 0; index < lines.length - 1; index++) {
const line = lines[index]
if (/^SSH-\d+\.\d+-[\x21-\x7e]+(?: [\x20-\x7e]*)?$/.test(line)
&& Buffer.byteLength(line, 'ascii') <= 253) {
finish(undefined, line)
return
}
}
})
socket.on('end', () => {
finish(new SshCheckError('SSH_BANNER_MISSING', 'SSH server closed without sending a valid banner', 502))
})
socket.on('close', () => {
finish(new SshCheckError('SSH_BANNER_MISSING', 'SSH server closed without sending a valid banner', 502))
})
socket.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'ECONNREFUSED') {
finish(new SshCheckError('CONNECTION_REFUSED', `SSH connection to ${target.host}:${target.port} was refused`, 503))
return
}
if (error.code === 'ETIMEDOUT') {
finish(new SshCheckError('CONNECTION_TIMEOUT', `SSH connection to ${target.host}:${target.port} timed out`, 504))
return
}
finish(new SshCheckError('CONNECTION_REFUSED', `SSH connection to ${target.host}:${target.port} failed`, 503))
})
})
}
export function createSshCheckService(dependencies: SshCheckDependencies = {}) {
const diagnosticFetcher = dependencies.fetchDiagnostics ?? fetchDiagnostics
const connector = dependencies.connect ?? readSshBanner
const sshHost = dependencies.loopbackHost || process.env.FLOCI_EC2_SSH_HOST || 'localhost'
const now = dependencies.now ?? (() => new Date())
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS
return {
async check(instanceId: string): Promise<SshCheckResult> {
let diagnostics: DiagnosticResponse
try {
diagnostics = await diagnosticFetcher()
} catch (error) {
if (error instanceof SshCheckError) throw error
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
}
if (!diagnostics || typeof diagnostics !== 'object'
|| !Array.isArray(diagnostics.ec2?.instances)) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics returned an invalid response', 502)
}
const instance = diagnostics.ec2.instances.find((candidate) => candidate.instanceId === instanceId)
if (!instance) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', `SSH diagnostics for instance ${instanceId} are unavailable`, 404)
}
if (!instance.containerStatus) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', `SSH diagnostics for instance ${instanceId} are unavailable`, 404)
}
if (instance.containerStatus !== 'running') {
throw new SshCheckError('INSTANCE_NOT_RUNNING', `Instance ${instanceId} is not running`, 409)
}
if (typeof instance.sshPort !== 'number' || !Number.isInteger(instance.sshPort)
|| instance.sshPort < 1 || instance.sshPort > 65_535) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', `SSH diagnostics for instance ${instanceId} are unavailable`, 404)
}
const target = {
host: sshHost,
port: instance.sshPort,
}
const banner = await connector(target, timeoutMs)
if (!/^SSH-\d+\.\d+-/.test(banner)) {
throw new SshCheckError('SSH_BANNER_MISSING', 'SSH server did not send a valid banner', 502)
}
return {
ok: true,
banner,
checkedFrom: 'UI backend',
checkedAt: now().toISOString(),
target,
}
},
}
}
export const sshCheckService = createSshCheckService()