#!/usr/bin/env bun // SysDeck web edition — local console-account manager (v0.4.0). // // Manages the SdUser table used by SYSDECK_AUTH_MODE=local / pam+local // (the fallback when the host PAM path can't serve the install — e.g. // a non-root service uid, where unix_chkpwd only verifies the invoking // user). PAM is still the primary login path; these accounts are the // documented escape hatch, not the default. // // bun scripts/manage-users.mjs list // bun scripts/manage-users.mjs add [--realname "Jane Doe"] // bun scripts/manage-users.mjs passwd # prompts, hidden // bun scripts/manage-users.mjs disable // bun scripts/manage-users.mjs enable // bun scripts/manage-users.mjs remove // // Run from web/ (the db path comes from DATABASE_URL, default ../db/custom.db). import { createInterface } from 'node:readline' import { randomBytes, scryptSync } from 'node:crypto' import { existsSync, mkdirSync } from 'node:fs' import path from 'node:path' // minimal prisma client bootstrap without a generated engine: // talk straight to SQLite via bun:sqlite — the table is tiny and the // schema is fixed, so hand-rolled SQL beats shipping prisma here. import { Database } from 'bun:sqlite' const dbUrl = process.env.DATABASE_URL ?? 'file:../db/custom.db' const dbFile = dbFileOf(dbUrl) function dbFileOf(url) { const raw = url.replace(/^file:/, '').split('?')[0] return path.resolve(process.cwd(), raw) } function openDb() { if (!existsSync(dbFile)) { mkdirSync(path.dirname(dbFile), { recursive: true }) } const db = new Database(dbFile) db.exec(`CREATE TABLE IF NOT EXISTS "SdUser" ( "username" TEXT NOT NULL PRIMARY KEY, "realname" TEXT, "hash" TEXT NOT NULL, "disabled" BOOLEAN NOT NULL DEFAULT 0, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL, "lastLoginAt" DATETIME, "lastLoginIp" TEXT )`) return db } // scrypt in the exact format src/lib/sysdeck/users.ts verifies: // salt$N$r$p$hex (constant-time verify lives there) const N = 16384, R = 8, P = 1, KEYLEN = 32 function hashPassword(password) { const salt = randomBytes(16).toString('hex') const key = scryptSync(password, salt, KEYLEN, { N, r: R, p: P }) return [salt, N, R, P, key.toString('hex')].join('$') } function promptPassword() { const rl = createInterface({ input: process.stdin, output: process.stderr }) return new Promise((resolve) => { process.stderr.write('password: ') rl.once('line', (line) => { process.stderr.write('\n') rl.close() resolve(line) }) }) } function validName(name) { return /^[a-z_][a-z0-9_.-]*\$?$/i.test(name) && name.length <= 64 } const [cmd, ...rest] = process.argv.slice(2) const db = openDb() try { switch (cmd) { case 'list': { const rows = db.query('SELECT username, realname, disabled, lastLoginAt, lastLoginIp, createdAt FROM SdUser ORDER BY username').all() if (!rows.length) { console.log('no local accounts (SdUser is empty — PAM logins are the default path)') break } for (const r of rows) { const state = r.disabled ? 'disabled' : 'active' const last = r.lastLoginAt ? `last login ${r.lastLoginIp ?? '?'} @ ${r.lastLoginAt}` : 'never logged in' console.log(` ${String(r.username).padEnd(16)} ${state.padEnd(9)} ${r.realname ?? ''} (${last})`) } break } case 'add': { const username = rest[0] const realname = rest[1] === '--realname' ? rest[2] : null if (!username || !validName(username)) { console.error('usage: add [--realname "Jane Doe"]') process.exit(2) } const exists = db.query('SELECT 1 FROM SdUser WHERE username = ?').get(username) if (exists) { console.error(`account '${username}' already exists (use passwd)`) process.exit(2) } const password = await promptPassword() if (!password || password.length < 4) { console.error('password too short (min 4 chars)') process.exit(2) } db.query('INSERT INTO SdUser (username, realname, hash, disabled, updatedAt) VALUES (?, ?, ?, 0, CURRENT_TIMESTAMP)') .run(username, realname, hashPassword(password)) console.log(`added local account '${username}'`) break } case 'passwd': { const username = rest[0] if (!username) { console.error('usage: passwd ') process.exit(2) } const row = db.query('SELECT username FROM SdUser WHERE username = ?').get(username) if (!row) { console.error(`no local account '${username}'`) process.exit(2) } const password = await promptPassword() if (!password || password.length < 4) { console.error('password too short (min 4 chars)') process.exit(2) } db.query('UPDATE SdUser SET hash = ?, updatedAt = CURRENT_TIMESTAMP WHERE username = ?') .run(hashPassword(password), username) console.log(`password updated for '${username}'`) break } case 'disable': case 'enable': { const username = rest[0] if (!username) { console.error(`usage: ${cmd} `) process.exit(2) } const res = db.query('UPDATE SdUser SET disabled = ?, updatedAt = CURRENT_TIMESTAMP WHERE username = ?') .run(cmd === 'disable' ? 1 : 0, username) if (!res.changes) { console.error(`no local account '${username}'`) process.exit(2) } console.log(`${cmd === 'disable' ? 'disabled' : 'enabled'} local account '${username}'`) break } case 'remove': { const username = rest[0] if (!username) { console.error('usage: remove ') process.exit(2) } const res = db.query('DELETE FROM SdUser WHERE username = ?').run(username) if (!res.changes) { console.error(`no local account '${username}'`) process.exit(2) } console.log(`removed local account '${username}'`) break } default: console.log(`SysDeck local account manager (SdUser) list show local console accounts add [--realname] create an account (prompts for password) passwd rotate an account password disable block sign-ins enable re-allow sign-ins remove delete the account DB: ${dbFile} These accounts only matter for SYSDECK_AUTH_MODE=local / pam+local — PAM (the host unix accounts) is the default login path, like Cockpit.`) process.exit(cmd ? 2 : 0) } } finally { db.close() }