/** * bare-openssh — SSH-2 server (bare-ssh2) + bare-initd unit `bare-openssh`. */ import b4a from 'b4a' import bareSsh2 from 'bare-ssh2' import { logicalAuthorizedKeysPath, parseSshdConfig } from './sshd-config-parse.js' import { appendVarLog, BARE_OS_VAR_LOG_DIR } from './bare-os-var-log.js' import { registerBareService } from './bare-initd.js' import { execShellLine, loadBarerc } from './shell.js' import { bareOsKernelMetricInc } from './bare-os-kernel-metrics.js' import { attachBareOsSftp } from './bare-openssh-sftp.js' import { createBareReadlineSession, createStreamLineReader, sanitizeInteractiveShellLine } from './cli-readline.js' import { createFishReadLine, disableFishRawMode, releaseFishStdin, resumeFishStdinAfterSubprocess, suspendFishStdinForSubprocess } from './fish-readline.js' import { bareOpensshWritePtyConsoleLine } from './bare-openssh-pty-console.js' import { ensureBareOsSshHolesailTunnel } from './bare-os-ssh-holesail.js' import { bindFallbackEnabled, isAddrInUse, readListeningTcpPort } from './bare-os-bind-fallback.js' export { bareOpensshFormatConsoleTextForPty } from './bare-openssh-pty-console.js' /** Same as `identity-account` `ACCOUNT_PATH` — inlined to avoid static `bare-crypto` import at module load. */ const BARE_ACCOUNT_DRIVE_KEY = '/.bare/account' /** >1024: unprivileged Pear/Bare sessions cannot bind port 22 without capabilities. */ const BARE_OS_SSH_UNPRIV_PORT = 2222 /** @param {unknown} err */ function isPrivilegePortBindError(err) { const o = err && typeof err === 'object' ? err : null const code = o && (o.code || o.errno) if (code === 'EACCES' || code === 'EPERM') return true const msg = String((o && o.message) || err || '').toLowerCase() return ( msg.includes('permission denied') || msg.includes('eacces') || msg.includes('eperm') ) } /** * @param {{ listen: (...args: unknown[]) => void, off: Function, once: Function }} server * @param {number} p * @param {string} h */ function listenSshTcp(server, p, h) { return new Promise((resolve, reject) => { const onErr = (e) => { server.off('error', onErr) reject(e) } server.once('error', onErr) server.listen(p, h, () => { server.off('error', onErr) resolve(null) }) }) } /** @param {string} abs */ function posixDirname(abs) { const s = String(abs || '').replace(/\/+/g, '/').replace(/\/$/, '') || '/' const i = s.lastIndexOf('/') if (i <= 0) return '/' return s.slice(0, i) || '/' } /** ESM import so Pear stages `bare-ssh2` (CJS `require` in *.cjs was not traced). */ let bareSsh2Cache = null function loadBareSsh2() { if (!bareSsh2Cache) { bareSsh2Cache = { ssh2: bareSsh2, keygen: bareSsh2.utils, parseKey: bareSsh2.utils.parseKey } } return bareSsh2Cache } export const BARE_OPENSSH_LOG = `${BARE_OS_VAR_LOG_DIR}/openssh.log` /** @type {import('events').EventEmitter | null} */ let sshServer = null let listenHost = '' let listenPort = 0 let activeClients = 0 let lastError = '' let startedAtMs = 0 /** @type {(() => void) | null} */ let foregroundResolve = null /** ssh2 Client instances — ended on stop so restart is not blocked and sockets tear down cleanly. */ const sshClientInstances = new Set() /** * Match console-style prompt when PS1 is unset (PTY sends CR, not LF — see createStreamLineReader). * @param {{ getcwd?: () => string } | null | undefined} vfs * @param {Record | null | undefined} env */ function buildSshShellPs1(vfs, env) { const user = String(env?.USER || 'guest') let disp = '~' if (vfs && typeof vfs.getcwd === 'function') { try { let cwd = String(vfs.getcwd() || '').replace(/\/+$/, '') || '/' const home = String(env?.HOME || '/home/guest').replace(/\/+$/, '') if (cwd === home) disp = '~' else if (home && cwd.startsWith(home + '/')) disp = '~' + cwd.slice(home.length) else disp = cwd } catch { disp = '~' } } return `[${user}@bare-os:${disp}] > ` } /** * @param {Record} sessionCtx */ function sshSessionPs1(sessionCtx) { const curEnv = sessionCtx.vfs?.env return String(curEnv?.PS1 || buildSshShellPs1(sessionCtx.vfs, curEnv)) } /** @param {unknown} err */ function isBenignSshSocketError(err) { const o = err && typeof err === 'object' ? err : null const code = o && /** @type {{ code?: string }} */ (o).code if ( code === 'EPIPE' || code === 'ECONNRESET' || code === 'ECONNABORTED' || code === 'ERR_STREAM_DESTROYED' ) { return true } const msg = String((o && /** @type {{ message?: string }} */ (o).message) || err || '').toLowerCase() return msg.includes('broken pipe') } /** * When bare-openssh is listening, returns the bound **`host`** / **`port`** for managed Holesail (`bare-ssh-`). * @returns {{ host: string, port: number } | null} */ export function bareOpensshGetListenEndpoint() { if (!sshServer || !listenPort) return null return { host: listenHost || '127.0.0.1', port: listenPort } } export function getBareOpensshProcJsonText() { return `${JSON.stringify({ schema: 1, atMs: Date.now(), listening: !!( sshServer && typeof sshServer.address === 'function' && sshServer.address() ), listenHost, listenPort, activeClients, startedAtMs: startedAtMs || undefined, lastError: lastError || undefined, note: 'Password auth may call unlockIdentity (shared primary vfs.env). Each SSH shell/exec/SFTP uses bareOsForkShellEnv when available (env/cwd snapshot at channel start; identity ref is shared).' })}\n` } /** * @param {Record} ctx * @param {Record} extra * @returns {() => void} */ function patchVfsEnv(ctx, extra) { const vfs = ctx.vfs const env = vfs && vfs.env if (!env || typeof env !== 'object') return () => {} /** @type {Record} */ const saved = {} for (const k of Object.keys(extra)) { saved[k] = env[k] env[k] = extra[k] } return () => { for (const k of Object.keys(extra)) { if (saved[k] === undefined) delete env[k] else env[k] = saved[k] } } } /** * @param {'shell' | 'exec'} mode * @returns {{ sessionCtx: Record, restore: () => void }} */ function bareOpensshSessionCtx(ctx, mode) { const env = ctx.vfs?.env const home = String(env?.HOME || '/home/guest') const vfs = ctx.vfs const user = String(env?.USER || 'guest') const logname = String(env?.LOGNAME || env?.USER || 'guest') const patchShell = { SSH_CLIENT: 'bare-openssh', SHELL: String(env?.SHELL || '/bin/sh'), HOME: home, USER: user, LOGNAME: logname } const patchExec = { SSH_CLIENT: 'bare-openssh', HOME: home, USER: user } if (!vfs || typeof vfs.bareOsForkShellEnv !== 'function') { return { sessionCtx: ctx, restore: patchVfsEnv(ctx, mode === 'exec' ? patchExec : patchShell) } } const forked = vfs.bareOsForkShellEnv(mode === 'exec' ? patchExec : patchShell) return { sessionCtx: Object.assign({}, ctx, { vfs: forked }), restore: () => {} } } /** * VFS for SFTP: fork when available (never mutates global env via patch). * @param {Record} ctx */ function bareOpensshSftpVfs(ctx) { const env = ctx.vfs?.env const home = String(env?.HOME || '/home/guest') const vfs = ctx.vfs if (vfs && typeof vfs.bareOsForkShellEnv === 'function') { return vfs.bareOsForkShellEnv({ SSH_CLIENT: 'bare-openssh', SHELL: String(env?.SHELL || '/bin/sh'), HOME: home, USER: String(env?.USER || 'guest'), LOGNAME: String(env?.LOGNAME || env?.USER || 'guest') }) } return vfs } /** @param {Record} ctx @param {unknown} err @param {string} where */ function logSshSessionError(ctx, err, where) { if (isBenignSshSocketError(err)) return void appendVarLog( ctx, BARE_OPENSSH_LOG, where, String((err && /** @type {{ message?: string }} */ (err).message) || err) ) } /** * @param {Record} ctx * @param {import('stream').Duplex} stream */ function createSshChildCtx(ctx, stream) { const w = (chunk) => { if (stream.writable) stream.write(chunk) } const log = (...args) => { bareOpensshWritePtyConsoleLine(stream, args) } const err = (...args) => { bareOpensshWritePtyConsoleLine(stream, args) } return Object.assign({}, ctx, { console: { log, info: log, warn: err, error: err, debug: log }, writeScreen: (s) => w(String(s)), /** * Raw stream writes from utilities (e.g. `/bin/agent` SSE chunks) must newline-normalize like * `bareOpensshWritePtyConsoleLine` so streaming text renders over SSH PTYs. */ bareOsPtyStdoutCrlf: true, /** `/bin/edit`, baretop, etc. use replStdin/replStdout — not Pear CLI. */ replStdin: stream, replStdout: stream, stdout: stream, exitCode: 0 }) } /** * @param {Record} ctx * @param {string} configText * @param {ReturnType} cfg */ /** * Returns PEM strings (or buffers) for `ssh2.Server` — it only accepts * Buffer | string | { key, passphrase? }, not pre-parsed Key objects (else it * calls parseKey(undefined) and blows up with constructor/read errors). */ async function loadHostKeyPemStrings(ctx, cfg) { const vfs = ctx.vfs if (!vfs || typeof vfs.readFile !== 'function') throw new Error('vfs missing') /** @type {string[]} */ const keys = [] for (const rel of cfg.hostKeyPaths) { const abs = vfs.resolveLogical(rel.trim()) let buf try { buf = await vfs.readFile(abs) } catch { buf = null } if (!buf || buf.length === 0) { const pair = loadBareSsh2().keygen.generateKeyPairSync('ed25519') const privStr = pair.private const parent = posixDirname(abs) try { await vfs.mkdir(parent, { recursive: true }) } catch { /* exists */ } await vfs.writeFile(abs, b4a.from(privStr)) buf = await vfs.readFile(abs) } const text = b4a.toString(buf) const pk = loadBareSsh2().parseKey(text) if (pk instanceof Error) throw new Error('host key parse: ' + pk.message) keys.push(text) } return keys } /** * @param {Record} ctx * @param {string} authKeysRel */ async function loadAuthorizedKeyObjects(ctx, authKeysRel) { const vfs = ctx.vfs const env = vfs?.env const home = String(env?.HOME || '/home/guest') const path = logicalAuthorizedKeysPath(home, authKeysRel) const abs = vfs.resolveLogical(path) let raw try { raw = await vfs.readFile(abs) } catch { return [] } if (raw == null) return [] const text = b4a.toString(raw) /** @type {unknown[]} */ const out = [] for (const line of text.split(/\r?\n/)) { const t = line.trim() if (!t || t.startsWith('#')) continue const k = loadBareSsh2().parseKey(t) if (!(k instanceof Error) && k.getPublicSSH) out.push(k) } return out } /** * Run optional `/etc/bare-os/profile` and `/etc/bare-os/rc` like a POSIX login shell. * The line shell has no `.`/`source` builtin, so we read each file from the VFS and * execute non-comment lines with `execShellLine` (same as stock `/etc/bare-os/rc` usage). * * @param {Record} ctx * @param {string} logicalPath e.g. `/etc/bare-os/rc` */ async function execOptionalEtcBareOsScript(ctx, logicalPath) { const vfs = ctx.vfs if (!vfs || typeof vfs.readFile !== 'function' || typeof vfs.resolveLogical !== 'function') { return } let abs try { abs = vfs.resolveLogical(logicalPath) } catch { return } let buf try { buf = await vfs.readFile(abs) } catch { return } if (buf == null) return const text = b4a.toString(buf) for (const line of text.split(/\r?\n/)) { const t = line.trim() if (!t || t.startsWith('#')) continue try { await execShellLine(ctx, t) } catch { /* optional startup script — ignore parse/runtime errors */ } } } /** Max cols/rows from SSH pty (sanity cap). */ const SSH_PTY_DIM_CAP = 4096 /** * Wire `pty-req` and `window-change` so channel streams expose `columns` / `rows` * (used by `/bin/edit`, baretop, etc. — see kernel/bin/edit `termDims`). * @param {import('events').EventEmitter} session */ function wireSshSessionTerminalDims(session) { const termSize = { cols: 80, rows: 24 } /** @type {import('stream').Duplex | null} */ let channelStream = null /** @type {Record | null} */ let ptyEnvTarget = null function syncPtyEnvTarget() { if (!ptyEnvTarget || typeof ptyEnvTarget !== 'object') return ptyEnvTarget.COLUMNS = String(termSize.cols) ptyEnvTarget.LINES = String(termSize.rows) // Do not set TERM from pty-req: the client often sends xterm*, which makes baretop(1) // default to incremental line-diff; those patches mis-render on some SSH PTY paths. // Keep the forked session's inherited TERM (same as the in-app terminal guest env). } function bindDimsToStream(stream) { if (!stream || typeof stream !== 'object') return stream.columns = termSize.cols stream.rows = termSize.rows syncPtyEnvTarget() } /** @param {{ cols?: number, rows?: number } | null | undefined} info */ function mergeDims(info) { if (!info || typeof info !== 'object') return const c = Number(info.cols) const r = Number(info.rows) if (Number.isFinite(c) && c > 0) { termSize.cols = Math.min(Math.floor(c), SSH_PTY_DIM_CAP) } if (Number.isFinite(r) && r > 0) { termSize.rows = Math.min(Math.floor(r), SSH_PTY_DIM_CAP) } } session.on('pty', (accept, _reject, info) => { if (typeof accept === 'function') accept() mergeDims(info) bindDimsToStream(channelStream) }) session.on('window-change', (accept, _reject, info) => { if (typeof accept === 'function') accept() mergeDims(info) bindDimsToStream(channelStream) }) return { /** @param {import('stream').Duplex} stream */ attachChannelStream(stream) { channelStream = stream bindDimsToStream(stream) }, /** @param {import('stream').Duplex} stream */ detachChannelStream(stream) { if (channelStream === stream) channelStream = null }, /** * Forked session `vfs.env` — `COLUMNS` / `LINES` updated when PTY dimensions change. * @param {Record | null | undefined} env */ setPtyEnvTarget(env) { ptyEnvTarget = env && typeof env === 'object' ? env : null syncPtyEnvTarget() } } } /** * @param {Record} ctx * @param {import('events').EventEmitter} client * @param {ReturnType} cfg */ function wireSshClient(ctx, client, cfg) { client.on('authentication', (authCtx) => { const env = ctx.vfs?.env const expectUser = String(env?.USER || 'guest').trim() const u = String(authCtx.username || '').trim() if (authCtx.method === 'none') return authCtx.reject(['publickey', 'password']) if (u === 'root' && !cfg.permitRootLogin) return authCtx.reject() if (u !== expectUser && !(u === 'root' && cfg.permitRootLogin)) { return authCtx.reject() } if (authCtx.method === 'password') { if (!cfg.passwordAuthentication) return authCtx.reject() const pwd = String(authCtx.password || '') void (async () => { try { const { decodeAccount } = await import('./identity-account.js') const { unlockIdentity } = await import('./identity-session.js') const buf = await ctx.personalDrive.get(BARE_ACCOUNT_DRIVE_KEY) if (!buf) return authCtx.reject() try { decodeAccount(pwd, b4a.from(buf)) } catch { return authCtx.reject() } await unlockIdentity(ctx, pwd) authCtx.accept() } catch { authCtx.reject() } })() return } if (authCtx.method === 'publickey') { if (!cfg.pubkeyAuthentication) return authCtx.reject() void (async () => { const keys = await loadAuthorizedKeyObjects(ctx, cfg.authorizedKeysFile) const algo = authCtx.key?.algo const data = authCtx.key?.data if (!algo || !data) return authCtx.reject() let match = null for (const pk of keys) { if (pk.type !== algo) continue try { if (!pk.getPublicSSH().equals(data)) continue } catch { continue } match = pk break } if (!match) return authCtx.reject() if (!authCtx.signature) return authCtx.accept() try { const ok = match.verify(authCtx.blob, authCtx.signature, authCtx.hashAlgo) if (ok === true) authCtx.accept() else authCtx.reject() } catch { authCtx.reject() } })() return } authCtx.reject() }) client.on('ready', () => { client.on('session', (acceptSession) => { const session = acceptSession() const termDims = wireSshSessionTerminalDims(session) session.on('shell', (acceptShell) => { const stream = acceptShell() if (!stream) return termDims.attachChannelStream(stream) stream.on('close', () => termDims.detachChannelStream(stream)) void runSshShellSession(ctx, stream, cfg, termDims).catch((err) => logSshSessionError(ctx, err, 'shell') ) }) session.on('exec', (acceptExec, _rej, info) => { const stream = acceptExec() if (!stream) return termDims.attachChannelStream(stream) stream.on('close', () => termDims.detachChannelStream(stream)) void runSshExecSession( ctx, stream, String(info.command || ''), cfg, termDims ).catch((err) => logSshSessionError(ctx, err, 'exec')) }) if (cfg.subsystemSftp && /internal/i.test(cfg.subsystemSftp)) { session.on('sftp', (acceptSftp) => { const sftp = acceptSftp() if (!sftp) return const vfs = bareOpensshSftpVfs(ctx) if (!vfs) return const home = vfs.resolveLogical(String(vfs.env?.HOME || '~/')) attachBareOsSftp( sftp, vfs, home, (p) => vfs.resolveLogical(p), loadBareSsh2().ssh2.utils.sftp ) }) } }) }) } /** * @param {Record} ctx * @param {import('stream').Duplex} stream * @param {ReturnType} cfg * @param {ReturnType} termDims */ async function runSshShellSession(ctx, stream, _cfg, termDims) { const { sessionCtx, restore } = bareOpensshSessionCtx(ctx, 'shell') if (sessionCtx.vfs !== ctx.vfs) { termDims.setPtyEnvTarget(sessionCtx.vfs?.env || null) } stream.isTTY = true stream.setRawMode = stream.setRawMode || function () { return this } const childCtx = createSshChildCtx(sessionCtx, stream) /** Duplex SSH: stdout logs from startup scripts do not feed readline stdin. */ let readLineClose = () => {} let readLine /** * When true, tear down bare-readline between commands so TUIs own stdin. * Fish stays attached across commands; fullscreen apps use suspend/resume hooks. */ let recycleLineEditor = false /** Same fish instance as the in-app terminal (history, arrows, completion, …). */ let useFishEditor = false function applySshSubprocessSuspendHooks() { if (useFishEditor) { childCtx.suspendReplForSubprocess = () => { suspendFishStdinForSubprocess(stream) } childCtx.resumeReplAfterSubprocess = () => { resumeFishStdinAfterSubprocess(stream) } } else { childCtx.suspendReplForSubprocess = () => {} childCtx.resumeReplAfterSubprocess = () => {} } } async function attachReadline() { readLineClose() const ps1ForRl = sshSessionPs1(sessionCtx) const fishOff = globalThis.process?.env?.BARE_OS_FISH === '0' || sessionCtx.vfs?.env?.BARE_OS_FISH === '0' if (!fishOff) { const fishRead = await createFishReadLine(childCtx, { stdin: stream, stdout: stream, writeScreen: childCtx.writeScreen }) if (fishRead) { readLine = fishRead readLineClose = () => {} recycleLineEditor = false useFishEditor = true childCtx.readLine = readLine applySshSubprocessSuspendHooks() return } } useFishEditor = false try { const session = await createBareReadlineSession(stream, stream, { initialPrompt: ps1ForRl }) readLine = session.readLine readLineClose = session.close recycleLineEditor = true } catch { readLine = createStreamLineReader(stream, stream) readLineClose = () => {} recycleLineEditor = false } childCtx.readLine = readLine applySshSubprocessSuspendHooks() } try { await execOptionalEtcBareOsScript(childCtx, '/etc/bare-os/profile') await execOptionalEtcBareOsScript(childCtx, '/etc/bare-os/rc') } catch { /* optional */ } try { await loadBarerc(childCtx, { createSkeletonIfMissing: true }) } catch { /* optional */ } await attachReadline() try { for (;;) { const ps1 = sshSessionPs1(sessionCtx) const line = await readLine(ps1) if (line == null) break const t = sanitizeInteractiveShellLine(String(line)).trimEnd() if (t === 'exit' || t === 'logout') break if (recycleLineEditor) readLineClose() // Fish / bare-readline finish the edited line with `\n` only. Many SSH clients leave the // cursor at the same column on the next row (no implicit CR), so command output starts // mid-line unless we reset to column 0 before running the command. if (stream.writable && typeof stream.write === 'function') { try { stream.write('\r') } catch { /* ignore */ } } try { await execShellLine(childCtx, t) } finally { try { if (typeof stream.resume === 'function') stream.resume() } catch { /* ignore */ } if (recycleLineEditor) { try { await attachReadline() } catch (e) { logSshSessionError(ctx, e, 'readline-reattach') readLine = createStreamLineReader(stream, stream) readLineClose = () => {} recycleLineEditor = false useFishEditor = false childCtx.readLine = readLine applySshSubprocessSuspendHooks() } } } if ( !recycleLineEditor && !useFishEditor && typeof stream.write === 'function' ) { stream.write('\r\n\x1b[?25h') } } } finally { readLineClose() if (useFishEditor) { disableFishRawMode(stream) releaseFishStdin(stream) } termDims.setPtyEnvTarget(null) restore() try { stream.exit(0) } catch { /* ignore */ } stream.end() } } /** * @param {Record} ctx * @param {import('stream').Duplex} stream * @param {string} command * @param {ReturnType} cfg * @param {ReturnType} termDims */ async function runSshExecSession(ctx, stream, command, _cfg, termDims) { const { sessionCtx, restore } = bareOpensshSessionCtx(ctx, 'exec') if (sessionCtx.vfs !== ctx.vfs) { termDims.setPtyEnvTarget(sessionCtx.vfs?.env || null) } stream.isTTY = true stream.setRawMode = stream.setRawMode || function () { return this } const childCtx = createSshChildCtx(sessionCtx, stream) childCtx.exitCode = 0 try { await execShellLine(childCtx, command) } finally { termDims.setPtyEnvTarget(null) restore() const code = Number(childCtx.exitCode) || 0 try { stream.exit(code) } catch { /* ignore */ } stream.end() } } /** * @param {Record} ctx * @param {string[]} argv */ export async function runSshdCli(ctx, argv) { const args = argv.slice(1) let configPath = '/etc/ssh/sshd_config' let portOverride = null let foreground = false let testConfig = false for (let i = 0; i < args.length; i++) { if (args[i] === '-f' && args[i + 1]) configPath = String(args[++i]) else if (args[i] === '-p' && args[i + 1]) portOverride = Number.parseInt(args[++i], 10) else if (args[i] === '-D') foreground = true else if (args[i] === '-t') testConfig = true } if (testConfig) { let cfgText = '' try { const buf = await ctx.vfs.readFile(ctx.vfs.resolveLogical(configPath)) cfgText = b4a.toString(buf) } catch { cfgText = `Port ${BARE_OS_SSH_UNPRIV_PORT}\nListenAddress 127.0.0.1\nHostKey ~/.config/bare-os/ssh/host/ssh_host_ed25519_key\nPasswordAuthentication yes\nPubkeyAuthentication yes\n` } try { parseSshdConfig(cfgText) ctx.console.log('sshd: config ok') ctx.exitCode = 0 } catch (e) { ctx.console.error('sshd: config error: ' + ((e && e.message) || String(e))) ctx.exitCode = 1 } return } if (foreground) { await startBareOpenSshd(ctx, { configPath, portOverride, foreground: true }) return } try { await startBareOpenSshd(ctx, { configPath, portOverride, foreground: false }) if (ctx.exitCode == null) ctx.exitCode = 0 } catch (e) { ctx.console.error('sshd: ' + ((e && e.message) || String(e))) ctx.exitCode = 1 } } /** * @param {Record} ctx * @param {{ configPath?: string, portOverride?: number | null, foreground?: boolean }} [opts] */ export async function startBareOpenSshd(ctx, opts = {}) { const configPath = opts.configPath || '/etc/ssh/sshd_config' const foreground = !!opts.foreground if (sshServer) await stopBareOpenSshd() let cfgText = '' try { const buf = await ctx.vfs.readFile(ctx.vfs.resolveLogical(configPath)) cfgText = b4a.toString(buf) } catch { cfgText = `Port ${BARE_OS_SSH_UNPRIV_PORT}\nListenAddress 127.0.0.1\nHostKey ~/.config/bare-os/ssh/host/ssh_host_ed25519_key\nPasswordAuthentication yes\nPubkeyAuthentication yes\n` } const env = ctx.vfs?.env const cfg = parseSshdConfig(cfgText) let port = opts.portOverride != null ? opts.portOverride : cfg.port const portEnv = String(env?.BARE_OS_SSH_LISTEN_PORT || '').trim() if (portEnv !== '') { const pe = Number.parseInt(portEnv, 10) if (portEnv === '0' || (Number.isFinite(pe) && pe >= 0)) port = pe } if (!Number.isFinite(port) || port < 0) port = BARE_OS_SSH_UNPRIV_PORT if (port > 65535) port = BARE_OS_SSH_UNPRIV_PORT let host = cfg.listenAddress || '127.0.0.1' if ( env?.BARE_OS_SSH_BIND_ALL === '1' || env?.BARE_OS_SSH_BIND_ALL === 'true' ) { if (host === '127.0.0.1') host = '0.0.0.0' } const hostKeys = await loadHostKeyPemStrings(ctx, cfg) const kaMs = cfg.clientAliveInterval > 0 ? cfg.clientAliveInterval * 1000 : 15000 const { ssh2 } = loadBareSsh2() sshServer = new ssh2.Server( { hostKeys, keepaliveInterval: kaMs, keepaliveCountMax: 3, // Bare/Pear zlib has no Node-style _handle; bare-ssh2 zlib.js only works // with none negotiated (see vendor patch + getZlibHandleCtor). algorithms: { compress: ['none'] } }, (client) => { activeClients++ bareOsKernelMetricInc('openssh.connection_accept', 1) sshClientInstances.add(client) // After SSH banner, bare-ssh2 removes its pre-handshake error noop — without a // listener, socket EPIPE/ECONNRESET becomes an uncaught 'error' and aborts Pear. client.on('error', (err) => { if (isBenignSshSocketError(err)) return void appendVarLog( ctx, BARE_OPENSSH_LOG, 'client', String((err && /** @type {{ message?: string }} */ (err).message) || err) ) }) try { wireSshClient(ctx, client, cfg) } catch (e) { lastError = (e && e.message) || String(e) } client.on('close', () => { sshClientInstances.delete(client) activeClients = Math.max(0, activeClients - 1) }) } ) sshServer.on('error', (err) => { lastError = (err && err.message) || String(err) void appendVarLog(ctx, BARE_OPENSSH_LOG, 'server', lastError) }) const shellEnvBind = ctx.vfs?.env && typeof ctx.vfs.env === 'object' ? /** @type {Record} */ (ctx.vfs.env) : /** @type {Record} */ ({}) let boundPort = port try { await listenSshTcp(sshServer, port, host) boundPort = readListeningTcpPort(sshServer, port) } catch (e) { if ( port > 0 && port < 1024 && port !== BARE_OS_SSH_UNPRIV_PORT && isPrivilegePortBindError(e) ) { void appendVarLog( ctx, BARE_OPENSSH_LOG, 'listen', `port ${port}: ${(e && e.message) || String(e)}; binding ${BARE_OS_SSH_UNPRIV_PORT} (ports under 1024 require elevated privileges)` ) try { await listenSshTcp(sshServer, BARE_OS_SSH_UNPRIV_PORT, host) boundPort = readListeningTcpPort(sshServer, BARE_OS_SSH_UNPRIV_PORT) } catch (e2) { if (bindFallbackEnabled(shellEnvBind) && isAddrInUse(e2)) { try { await listenSshTcp(sshServer, 0, host) boundPort = readListeningTcpPort(sshServer, 0) void appendVarLog( ctx, BARE_OPENSSH_LOG, 'listen', `port ${BARE_OS_SSH_UNPRIV_PORT} in use; bound ephemeral ${boundPort}` ) } catch (e3) { lastError = (e3 && e3.message) || String(e3) void appendVarLog(ctx, BARE_OPENSSH_LOG, 'listen', lastError) sshServer = null throw e3 } } else { lastError = (e2 && e2.message) || String(e2) void appendVarLog(ctx, BARE_OPENSSH_LOG, 'listen', lastError) sshServer = null throw e2 } } } else if (bindFallbackEnabled(shellEnvBind) && isAddrInUse(e) && port !== 0) { try { await listenSshTcp(sshServer, 0, host) boundPort = readListeningTcpPort(sshServer, 0) void appendVarLog( ctx, BARE_OPENSSH_LOG, 'listen', `port ${port} in use; bound ephemeral ${boundPort}` ) } catch (e2) { lastError = (e2 && e2.message) || String(e2) void appendVarLog(ctx, BARE_OPENSSH_LOG, 'listen', lastError) sshServer = null throw e2 } } else { lastError = (e && e.message) || String(e) void appendVarLog(ctx, BARE_OPENSSH_LOG, 'listen', lastError) sshServer = null throw e } } listenHost = host listenPort = boundPort startedAtMs = Date.now() lastError = '' try { await ensureBareOsSshHolesailTunnel(ctx, shellEnvBind, boundPort, host) } catch (e) { void appendVarLog( ctx, BARE_OPENSSH_LOG, 'holesail', String((e && /** @type {{ message?: string }} */ (e).message) || e) ) } if (foreground) { await new Promise((resolve) => { foregroundResolve = resolve }) } } export async function stopBareOpenSshd() { for (const client of [...sshClientInstances]) { try { if (client && typeof client.end === 'function') client.end() } catch { /* ignore */ } } sshClientInstances.clear() activeClients = 0 if (sshServer && typeof sshServer.close === 'function') { await new Promise((r) => sshServer.close(() => r(null))) } sshServer = null listenHost = '' listenPort = 0 if (foregroundResolve) { foregroundResolve() foregroundResolve = null } } async function initdStartBareOpenSsh(ctx) { try { await startBareOpenSshd(ctx, { foreground: false }) } catch (e) { lastError = (e && e.message) || String(e) void appendVarLog(ctx, BARE_OPENSSH_LOG, 'start', lastError) throw e } } registerBareService({ name: 'bare-openssh', description: 'SSH-2 server (bare-ssh2): sshd / remote shell and minimal SFTP; see man sshd', logPath: BARE_OPENSSH_LOG, start: initdStartBareOpenSsh, stop: stopBareOpenSshd })