hdms
Added ls alias to list.
Made delegate failures explicit and nonzero in packages/bare-os-coreutils/src/hdms.js.
Added nonzero error exit in packages/bare-os-booter/lib/hdms-manager.js.
git-pear
Implemented clone subcommand routing to git clone in packages/bare-os-coreutils/src/git-pear.js.
trustctl
Added ls alias to status/policy output in packages/bare-os-coreutils/src/trustctl.js.
oidc-publish
Added explicit unknown-subcommand handling and publish subcommand compatibility in packages/bare-os-coreutils/src/oidc-publish.js.
ssh-keygen
Wrapped delegate invocation with explicit error propagation in packages/bare-os-coreutils/src/ssh-keygen.js.
Added success output on generated keypair in packages/bare-os-booter/lib/ssh-keygen-cli.js.
sshd
Added -t config test mode and explicit exit semantics in packages/bare-os-booter/lib/bare-openssh.js.
Ensured wrapper sets exit code consistently in packages/bare-os-openssh/src/sshd.js.
telnet
Changed connector preference to use net.createConnection first when available, then syscall bridge fallback, in packages/bare-os-coreutils/src/telnet.js.
crontab -e flow
Implemented edit flow with VISUAL/EDITOR fallback, unlocked-state checks, temp file handling, install, and cleanup in packages/bare-os-coreutils/src/crontab.js.
Shell/runtime hardcore semantics
Added numeric brace range expansion {1..5} in packages/bare-os-booter/lib/shell-glob.js.
Enabled brace expansion by default unless explicitly disabled.
Added normalization for inline brace-expression tokens in packages/bare-os-booter/lib/shell.js.
Added arithmetic command-form handling for (( ... )) in packages/bare-os-booter/lib/shell.js.
Extended shell signal trap dispatch support for USR1/USR2 (in addition to INT/TERM) in packages/bare-os-booter/index.js.
Hardened kill command delivery validation in packages/bare-os-coreutils/src/kill.js.
Regression tests
Added new: packages/bare-os-coreutils/test/hardcore-bugs.test.mjs.
Extended shell tests in packages/bare-os-booter/test.js for:
default cmdsub behavior,
brace range expansion,
arithmetic command form.
Existing regression files still pass after updates.
1088 lines
32 KiB
JavaScript
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
|
|
})
|