192 lines
5.9 KiB
JavaScript
192 lines
5.9 KiB
JavaScript
// Persistent state for dsh-remote: device identity, paired devices (master) or master
|
|
// link credentials (slave), sync bookkeeping. One JSON file, written atomically, 0600.
|
|
import crypto from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
let PROFILE_DIR = ''
|
|
|
|
/** Set once from ctx.profileContext.dir at plugin start (authoritative). */
|
|
export function setProfileDir(dir) { if (dir) PROFILE_DIR = dir }
|
|
|
|
export function profileDir() {
|
|
if (PROFILE_DIR) return PROFILE_DIR
|
|
if (process.env.DSH_PROFILE_DIR) return process.env.DSH_PROFILE_DIR
|
|
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
return path.join(home, 'profiles', process.env.DSH_PROFILE || 'web')
|
|
}
|
|
|
|
export function stateDir() {
|
|
return path.join(profileDir(), 'dsh-remote')
|
|
}
|
|
|
|
const b64u = (buf) => Buffer.from(buf).toString('base64url')
|
|
export const randomId = (bytes = 16) => b64u(crypto.randomBytes(bytes))
|
|
export const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex')
|
|
|
|
export function safeEqual(a, b) {
|
|
const x = Buffer.from(String(a))
|
|
const y = Buffer.from(String(b))
|
|
return x.length === y.length && crypto.timingSafeEqual(x, y)
|
|
}
|
|
|
|
/** Device id: lowercase slug, safe in URL paths (/r/<id>/). */
|
|
export function slug(s) {
|
|
const v = String(s || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
|
return v || 'device'
|
|
}
|
|
|
|
export class Store {
|
|
constructor(file = path.join(stateDir(), 'state.json')) {
|
|
this.file = file
|
|
this.data = this._load()
|
|
}
|
|
|
|
_load() {
|
|
try {
|
|
const v = JSON.parse(fs.readFileSync(this.file, 'utf8'))
|
|
if (v && typeof v === 'object') return v
|
|
} catch { /* first run */ }
|
|
return {}
|
|
}
|
|
|
|
save() {
|
|
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 })
|
|
const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`
|
|
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 })
|
|
fs.renameSync(tmp, this.file)
|
|
}
|
|
|
|
/** This installation's stable identity. */
|
|
identity(defaultName) {
|
|
if (!this.data.self) {
|
|
const name = defaultName || os.hostname()
|
|
this.data.self = { id: slug(name), name, createdAt: Date.now() }
|
|
this.save()
|
|
}
|
|
return this.data.self
|
|
}
|
|
|
|
// ---- master side: paired devices + pairing codes -------------------------
|
|
devices() { return (this.data.devices ||= {}) }
|
|
|
|
pairingCodes() { return (this.data.pairing ||= {}) }
|
|
|
|
createPairingCode({ ttlMs = 15 * 60 * 1000, label = '' } = {}) {
|
|
// 4 groups of 4 from an unambiguous alphabet: easy to type, ~80 bits.
|
|
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
const raw = crypto.randomBytes(16)
|
|
const code = [...raw].map((b) => alphabet[b % alphabet.length]).join('').replace(/(.{4})(?=.)/g, '$1-')
|
|
this.pairingCodes()[sha256(code)] = { expiresAt: Date.now() + ttlMs, label, createdAt: Date.now() }
|
|
this._prunePairing()
|
|
this.save()
|
|
return { code, expiresAt: Date.now() + ttlMs }
|
|
}
|
|
|
|
consumePairingCode(code) {
|
|
this._prunePairing()
|
|
const key = sha256(String(code || '').trim().toUpperCase())
|
|
const rec = this.pairingCodes()[key]
|
|
if (!rec) return null
|
|
delete this.pairingCodes()[key]
|
|
this.save()
|
|
return rec
|
|
}
|
|
|
|
_prunePairing() {
|
|
const now = Date.now()
|
|
for (const [k, v] of Object.entries(this.pairingCodes())) if (!v || v.expiresAt < now) delete this.pairingCodes()[k]
|
|
}
|
|
|
|
/** Register (or re-register) a device; returns its secret key (shown once). */
|
|
addDevice({ id, name, platform }) {
|
|
const devices = this.devices()
|
|
let devId = slug(id || name)
|
|
// Keep ids unique; a re-pair of the same machine replaces its key.
|
|
const secret = randomId(32)
|
|
devices[devId] = {
|
|
id: devId,
|
|
name: name || devId,
|
|
platform: platform || '',
|
|
keyHash: sha256(secret),
|
|
createdAt: devices[devId]?.createdAt || Date.now(),
|
|
revoked: false,
|
|
sync: devices[devId]?.sync || { enabled: true, allow: [], deny: [], secrets: [] },
|
|
}
|
|
this.save()
|
|
return { id: devId, secret }
|
|
}
|
|
|
|
verifyDevice(id, secret) {
|
|
const d = this.devices()[id]
|
|
if (!d || d.revoked) return null
|
|
return safeEqual(d.keyHash, sha256(String(secret || ''))) ? d : null
|
|
}
|
|
|
|
revokeDevice(id) {
|
|
const d = this.devices()[id]
|
|
if (!d) return false
|
|
d.revoked = true
|
|
d.revokedAt = Date.now()
|
|
this.save()
|
|
return true
|
|
}
|
|
|
|
removeDevice(id) {
|
|
if (!this.devices()[id]) return false
|
|
delete this.devices()[id]
|
|
this.save()
|
|
return true
|
|
}
|
|
|
|
// ---- UI-managed node settings ---------------------------------------------
|
|
/** { nodeType?: 'master'|'slave', authMiddleware?: boolean } — set from Settings → Remote devices. */
|
|
settings() { return (this.data.settings ||= {}) }
|
|
|
|
setSettings(patch) {
|
|
this.data.settings = { ...this.settings(), ...patch }
|
|
this.save()
|
|
return this.data.settings
|
|
}
|
|
|
|
// ---- slave side: link to master ------------------------------------------
|
|
get master() { return this.data.master || null }
|
|
|
|
setMaster(m) {
|
|
this.data.master = m
|
|
this.save()
|
|
}
|
|
|
|
// ---- session version control & offline notification queue ----------------
|
|
sessionLineage() { return (this.data.sessionLineage ||= {}) }
|
|
|
|
recordLineage(sessionId, info) {
|
|
const table = this.sessionLineage()
|
|
table[sessionId] = { ...(table[sessionId] || {}), ...info, updatedAt: Date.now() }
|
|
this.save()
|
|
return table[sessionId]
|
|
}
|
|
|
|
pendingMoves() { return (this.data.pendingMoves ||= {}) }
|
|
|
|
queueMoveNotice(notice) {
|
|
const key = `${notice.fromDevice}:${notice.sessionId}`
|
|
this.pendingMoves()[key] = { ...notice, queuedAt: Date.now() }
|
|
this.save()
|
|
}
|
|
|
|
popPendingMovesForDevice(deviceId) {
|
|
const moves = this.pendingMoves()
|
|
const matching = []
|
|
for (const [k, v] of Object.entries(moves)) {
|
|
if (v.fromDevice === deviceId) {
|
|
matching.push(v)
|
|
delete moves[k]
|
|
}
|
|
}
|
|
if (matching.length > 0) this.save()
|
|
return matching
|
|
}
|
|
}
|