/** * Minimal SFTP over Bare OS VFS (bare-ssh2 SFTP server). * Readable paths match interactive shell / VFS (home, /mnt, system image, pseudo, …). * Writes are denied on typical read-only system prefixes; other paths defer to VFS ACLs. */ import { S_IFDIR, S_IFLNK, S_IFREG, formatLsMtime, formatModeString } from '../vfs/vfs-posix-meta.js' /** * @typedef {{ type: string, mode?: number, size?: number, uid?: number, gid?: number, user?: string, group?: string, mtimeMs?: number }} VfsStatRow */ /** * Logical paths visible over SFTP (parity with shell `createVfs` routing). * @param {string} abs resolved absolute logical path * @param {string} homeLogical e.g. /home/guest */ export function bareOsSftpAllowedLogicalPath(abs, homeLogical) { const h = String(homeLogical || '/').replace(/\/+$/, '') || '/' const a = String(abs || '').replace(/\/+/g, '/') || '/' if (a === h || a.startsWith(h + '/')) return true /** @type {string[]} */ const roots = [ '/mnt', '/media', '/mount', '/mirror', '/bin', '/boot', '/lib', '/etc', '/usr', '/var', '/proc', '/sys', '/dev', '/run', '/tmp', '/snapshots', '/root', '/home', '/opt' ] for (const pre of roots) { if (a === pre || a.startsWith(pre + '/')) return true } return false } /** * Deny OPEN when the client requests mutating access on typical system-image paths. * Personal home, /tmp, /var, /root, and /mnt are left to VFS (mounts may be writable). * @param {string} abs * @param {string} homeLogical * @param {number} flags SFTP OPEN_MODE bitmask * @param {{ READ: number, WRITE: number, APPEND: number, CREAT: number, TRUNC: number }} OPEN_MODE */ export function bareOsSftpDeniesWriteOpen(abs, homeLogical, flags, OPEN_MODE) { const h = String(homeLogical || '/').replace(/\/+$/, '') || '/' const a = String(abs || '').replace(/\/+/g, '/') || '/' const wantMutate = !!(flags & OPEN_MODE.WRITE) || !!(flags & OPEN_MODE.APPEND) || !!(flags & OPEN_MODE.CREAT) || !!(flags & OPEN_MODE.TRUNC) if (!wantMutate) return false if (a === h || a.startsWith(h + '/')) return false if (a === '/tmp' || a.startsWith('/tmp/')) return false if (a === '/var' || a.startsWith('/var/')) return false if (a === '/root' || a.startsWith('/root/')) return false const readOnlyRoots = [ '/bin', '/boot', '/lib', '/etc', '/usr', '/proc', '/sys', '/dev', '/run', '/snapshots', '/opt', '/mirror' ] for (const pre of readOnlyRoots) { if (a === pre || a.startsWith(pre + '/')) return true } return false } /** * @param {VfsStatRow} st * @param {number} [sizeOverride] */ function vfsStatToSftpAttrs(st, sizeOverride) { const t = st.type const isDir = t === 'directory' const isLink = t === 'symlink' const mode = (isDir ? S_IFDIR : isLink ? S_IFLNK : S_IFREG) | ((typeof st.mode === 'number' ? st.mode : 0) & 0o777) const sz = sizeOverride !== undefined && sizeOverride !== null ? Number(sizeOverride) : Number(st.size || 0) const mtimeMs = typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs) ? st.mtimeMs : Date.now() return { mode, uid: typeof st.uid === 'number' ? st.uid : 1000, gid: typeof st.gid === 'number' ? st.gid : 1000, size: sz, atime: mtimeMs, mtime: mtimeMs } } /** * @param {VfsStatRow} st * @param {string} filename * @param {number} [sizeOverride] */ function vfsStatToSftpLongname(st, filename, sizeOverride) { const t = st.type const modeStr = formatModeString( typeof st.mode === 'number' ? st.mode : 0o644, t === 'directory' ? 'directory' : t === 'symlink' ? 'symlink' : 'file' ) const u = String(st.user || 'user') const g = String(st.group || 'user') const sz = sizeOverride !== undefined && sizeOverride !== null ? Number(sizeOverride) : Number(st.size || 0) const mtimeMs = typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs) ? st.mtimeMs : Date.now() const mt = formatLsMtime(mtimeMs) return `${modeStr} 1 ${u} ${g} ${String(sz).padStart(8, ' ')} ${mt} ${filename}` } /** * @param {string} dirAbs * @param {string} name */ function joinDirEntry(dirAbs, name) { const d = String(dirAbs || '/').replace(/\/+$/, '') || '/' const n = String(name || '') if (!n) return d return d === '/' ? '/' + n : d + '/' + n } /** * Coerce vfs.readFile results for SFTP OPEN/READ. Empty string is falsy and used to make * READ return SSH_FX_FAILURE; ArrayBuffer and non-Buffer views may lack `.subarray`. * @param {unknown} x * @returns {Buffer | null} */ function normalizeVfsBytesForSftp(x) { if (x == null) return null if (typeof x === 'string') return Buffer.from(x, 'utf8') if (Buffer.isBuffer(x)) return x if (x instanceof ArrayBuffer) return Buffer.from(x) if (ArrayBuffer.isView(x)) return Buffer.from(x.buffer, x.byteOffset, x.byteLength) return null } /** * SFTP inbound handles come from bare-ssh2 bufferSlice → often Uint8Array / safer-buffer views * that do not satisfy `Buffer.isBuffer` (see vendor utils.js bufferSlice comment). * @param {unknown} h * @returns {number | null} */ function sftpHandleId(h) { if (h == null) return null if (Buffer.isBuffer(h) && h.length >= 4) return h.readUInt32BE(0) if (ArrayBuffer.isView(h) && h.byteLength >= 4) { return new DataView(h.buffer, h.byteOffset, 4).getUint32(0, false) } return null } /** * @param {import('events').EventEmitter} sftp * @param {Record} vfs * @param {string} homeLogical absolute logical home (e.g. /home/guest) * @param {(p: string) => string} resolveLogical * @param {{ OPEN_MODE: Record, STATUS_CODE: Record }} sftpConsts */ export function attachBareOsSftp( sftp, vfs, homeLogical, resolveLogical, sftpConsts ) { const { OPEN_MODE, STATUS_CODE } = sftpConsts /** @type {Map} */ const handles = new Map() /** @type {Map} */ const dirs = new Map() /** Single counter so directory and file handles never share an id */ let nextHandleId = 1 /** * Strict SFTP clients (e.g. FileZilla) expect outbound replies in the same order as * requests were received; overlapping async handlers otherwise reorder packets and * trigger "request ID mismatch". After serializing replies here, a rejected handler * that sent no packet could stall clients; thrown errors during SFTP encode have been * seen as native `bad_optional_access` (-fno-exceptions) killing sshd — always emit * STATUS FAILURE on handler failure. */ /** @type {Promise} */ let sftpReplyChain = Promise.resolve() /** * @param {number} reqid * @param {() => void | Promise} fn */ function enqueueSftpReply(reqid, fn) { const next = sftpReplyChain.then(async () => { try { await Promise.resolve().then(fn) } catch { try { sftp.status(reqid, STATUS_CODE.FAILURE) } catch { /* ignore */ } } }) sftpReplyChain = next.catch(() => {}) } function ok(reqid) { sftp.status(reqid, STATUS_CODE.OK) } /** * @param {string} p * @returns {string | null} */ function mapPath(p) { const norm = String(p || '').replace(/\\/g, '/').replace(/\/+/g, '/') let logical if (norm === '.' || norm === '') logical = homeLogical else if (norm.startsWith('/')) logical = resolveLogical(norm) else logical = resolveLogical(`${homeLogical}/${norm}`.replace(/\/+/g, '/')) const abs = logical if (!bareOsSftpAllowedLogicalPath(abs, homeLogical)) return null return abs } async function statLike(reqid, p, _isLstat) { const abs = mapPath(p) if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED) try { const st = await vfs.lstat(abs) if (!st) return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE) const attrs = vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), undefined) sftp.attrs(reqid, attrs) } catch { sftp.status(reqid, STATUS_CODE.FAILURE) } } /** * @param {string} abs already-resolved logical path (no mapPath) */ async function statAbsolute(reqid, abs, sizeOverride) { try { const st = await vfs.lstat(abs) if (!st) return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE) const attrs = vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), sizeOverride) sftp.attrs(reqid, attrs) } catch { sftp.status(reqid, STATUS_CODE.FAILURE) } } sftp.on('REALPATH', (reqid, p) => { enqueueSftpReply(reqid, async () => { const abs = mapPath(p) || homeLogical try { const st = await vfs.lstat(abs) const attrs = st ? vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), undefined) : {} const name = [ { filename: abs, longname: st ? vfsStatToSftpLongname( /** @type {VfsStatRow} */ (st), abs.split('/').pop() || abs, undefined ) : 'drwxr-xr-x 1 user user 0 Jan 1 00:00 ' + abs, attrs } ] sftp.name(reqid, name) } catch { sftp.name(reqid, [ { filename: abs, longname: 'drwxr-xr-x 1 user user 0 Jan 1 00:00 ' + abs, attrs: {} } ]) } }) }) sftp.on('STAT', (reqid, p) => { enqueueSftpReply(reqid, () => statLike(reqid, p, false)) }) sftp.on('LSTAT', (reqid, p) => { enqueueSftpReply(reqid, () => statLike(reqid, p, true)) }) sftp.on('OPENDIR', (reqid, p) => { enqueueSftpReply(reqid, async () => { const abs = mapPath(p) if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED) try { const list = await vfs.readdir(abs) if (!Array.isArray(list)) return sftp.status(reqid, STATUS_CODE.FAILURE) const h = Buffer.alloc(4) const id = nextHandleId++ h.writeUInt32BE(id, 0) dirs.set(id, { path: abs, entries: list, idx: 0 }) sftp.handle(reqid, h) } catch { sftp.status(reqid, STATUS_CODE.FAILURE) } }) }) sftp.on('READDIR', (reqid, handle) => { enqueueSftpReply(reqid, async () => { const id = sftpHandleId(handle) if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE) const d = dirs.get(id) if (!d || !d.entries) return sftp.status(reqid, STATUS_CODE.FAILURE) if (d.idx >= d.entries.length) return sftp.status(reqid, STATUS_CODE.EOF) const chunk = d.entries.slice(d.idx, d.idx + 32) d.idx += chunk.length const names = [] for (const ent of chunk) { const childAbs = joinDirEntry(d.path, ent) try { const st = await vfs.lstat(childAbs) if (!st) { names.push({ filename: ent, longname: '-rw-r--r-- 1 user user 0 Jan 1 00:00 ' + ent, attrs: {} }) continue } const bl = /** @type {VfsStatRow} */ (st) names.push({ filename: ent, longname: vfsStatToSftpLongname(bl, ent, undefined), attrs: vfsStatToSftpAttrs(bl, undefined) }) } catch { names.push({ filename: ent, longname: '-rw-r--r-- 1 user user 0 Jan 1 00:00 ' + ent, attrs: {} }) } } sftp.name(reqid, names) }) }) sftp.on('OPEN', (reqid, filename, flags) => { enqueueSftpReply(reqid, async () => { const abs = mapPath(filename) if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED) if (bareOsSftpDeniesWriteOpen(abs, homeLogical, flags, OPEN_MODE)) { return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED) } const wantRead = !!(flags & OPEN_MODE.READ) const wantWrite = !!(flags & OPEN_MODE.WRITE) || !!(flags & OPEN_MODE.APPEND) const wantCreat = !!(flags & OPEN_MODE.CREAT) try { let buf = null if (!wantCreat) { try { buf = await vfs.readFile(abs) } catch { buf = null } } buf = normalizeVfsBytesForSftp(buf) if (buf == null && !wantCreat && wantRead) { return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE) } if (buf == null && wantCreat) buf = Buffer.alloc(0) if (buf == null) buf = Buffer.alloc(0) const h = Buffer.alloc(4) const id = nextHandleId++ h.writeUInt32BE(id, 0) handles.set(id, { path: abs, buf, pos: 0, write: wantWrite || wantCreat }) sftp.handle(reqid, h) } catch { sftp.status(reqid, STATUS_CODE.FAILURE) } }) }) sftp.on('READ', (reqid, handle, offset, length) => { enqueueSftpReply(reqid, () => { try { const id = sftpHandleId(handle) if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE) const f = handles.get(id) if (!f || f.buf == null) return sftp.status(reqid, STATUS_CODE.FAILURE) const raw = f.buf const body = Buffer.isBuffer(raw) || ArrayBuffer.isView(raw) ? raw : typeof raw === 'string' ? Buffer.from(raw, 'utf8') : null if (!body) return sftp.status(reqid, STATUS_CODE.FAILURE) const off = Number(offset) const len = Number(length) if (!Number.isFinite(off) || !Number.isFinite(len)) { return sftp.status(reqid, STATUS_CODE.FAILURE) } const end = Math.min(off + len, body.length) if (off >= body.length) return sftp.status(reqid, STATUS_CODE.EOF) let slice = body.subarray(off, end) if (!Buffer.isBuffer(slice)) { slice = Buffer.from(slice.buffer, slice.byteOffset, slice.byteLength) } sftp.data(reqid, slice) } catch { sftp.status(reqid, STATUS_CODE.FAILURE) } }) }) sftp.on('FSTAT', (reqid, handle) => { enqueueSftpReply(reqid, async () => { const id = sftpHandleId(handle) if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE) const f = handles.get(id) if (f) { const hint = f.buf ? f.buf.length : undefined return statAbsolute(reqid, f.path, hint) } const d = dirs.get(id) if (d && d.path) return statAbsolute(reqid, d.path, undefined) sftp.status(reqid, STATUS_CODE.FAILURE) }) }) sftp.on('WRITE', (reqid, handle, offset, data) => { enqueueSftpReply(reqid, async () => { const id = sftpHandleId(handle) if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE) const f = handles.get(id) if (!f || !f.write) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED) try { const u8 = data instanceof Uint8Array ? data : new Uint8Array(data) const prev = f.buf ? new Uint8Array(f.buf) : new Uint8Array(0) const need = offset + u8.length const out = new Uint8Array(Math.max(prev.length, need)) out.set(prev) out.set(u8, offset) f.buf = out await vfs.writeFile(f.path, out) ok(reqid) } catch { sftp.status(reqid, STATUS_CODE.FAILURE) } }) }) sftp.on('CLOSE', (reqid, handle) => { enqueueSftpReply(reqid, () => { const id = sftpHandleId(handle) if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE) handles.delete(id) dirs.delete(id) ok(reqid) }) }) sftp.on('REMOVE', (reqid, p) => { enqueueSftpReply(reqid, async () => { const abs = mapPath(p) if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED) try { await vfs.unlink(abs) ok(reqid) } catch { sftp.status(reqid, STATUS_CODE.FAILURE) } }) }) sftp.on('MKDIR', (reqid, p) => { enqueueSftpReply(reqid, async () => { const abs = mapPath(p) if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED) try { await vfs.mkdir(abs, { recursive: true }) ok(reqid) } catch { sftp.status(reqid, STATUS_CODE.FAILURE) } }) }) sftp.on('RMDIR', (reqid, p) => { enqueueSftpReply(reqid, async () => { const abs = mapPath(p) if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED) try { await vfs.rmdir(abs) ok(reqid) } catch { sftp.status(reqid, STATUS_CODE.FAILURE) } }) }) sftp.on('SETSTAT', (reqid) => { enqueueSftpReply(reqid, () => { sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED) }) }) sftp.on('FSETSTAT', (reqid) => { enqueueSftpReply(reqid, () => { sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED) }) }) sftp.on('EXTENDED', (reqid) => { enqueueSftpReply(reqid, () => { sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED) }) }) sftp.on('RENAME', (reqid) => { enqueueSftpReply(reqid, () => { sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED) }) }) sftp.on('READLINK', (reqid) => { enqueueSftpReply(reqid, () => { sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED) }) }) sftp.on('SYMLINK', (reqid) => { enqueueSftpReply(reqid, () => { sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED) }) }) }