SysDeck/web/scripts/manage-users.mjs

188 lines
6.6 KiB
JavaScript

#!/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 <username> [--realname "Jane Doe"]
// bun scripts/manage-users.mjs passwd <username> # prompts, hidden
// bun scripts/manage-users.mjs disable <username>
// bun scripts/manage-users.mjs enable <username>
// bun scripts/manage-users.mjs remove <username>
//
// 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 <username> [--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 <username>')
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} <username>`)
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 <username>')
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 <name> [--realname] create an account (prompts for password)
passwd <name> rotate an account password
disable <name> block sign-ins
enable <name> re-allow sign-ins
remove <name> 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()
}