Files
bare-operating-system/packages/bare-os-booter/lib/services/bare-openssh.js
T
2026-08-18 18:11:28 -04:00

1088 lines
32 KiB
JavaScript

/**
* 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<string, string | undefined> | 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<string, unknown>} 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-<port>`).
* @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<string, unknown>} ctx
* @param {Record<string, string | undefined>} extra
* @returns {() => void}
*/
function patchVfsEnv(ctx, extra) {
const vfs = ctx.vfs
const env = vfs && vfs.env
if (!env || typeof env !== 'object') return () => {}
/** @type {Record<string, string | undefined>} */
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<string, unknown>, 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} ctx
* @param {string} configText
* @param {ReturnType<typeof parseSshdConfig>} 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<string, unknown>} 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<string, unknown>} 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<string, string | undefined> | 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<string, string | undefined> | null | undefined} env
*/
setPtyEnvTarget(env) {
ptyEnvTarget = env && typeof env === 'object' ? env : null
syncPtyEnvTarget()
}
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {import('events').EventEmitter} client
* @param {ReturnType<typeof parseSshdConfig>} 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<string, unknown>} ctx
* @param {import('stream').Duplex} stream
* @param {ReturnType<typeof parseSshdConfig>} cfg
* @param {ReturnType<typeof wireSshSessionTerminalDims>} 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<string, unknown>} ctx
* @param {import('stream').Duplex} stream
* @param {string} command
* @param {ReturnType<typeof parseSshdConfig>} cfg
* @param {ReturnType<typeof wireSshSessionTerminalDims>} 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<string, unknown>} 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<string, unknown>} 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<string, string | undefined>} */ (ctx.vfs.env)
: /** @type {Record<string, string | undefined>} */ ({})
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
})