Files
dsh-remote/lib/store.mjs
T
lyuksovannyy 8872e73e76 dsh-remote 0.1.0: master/slave remote control for DSH
- authenticated WebSocket link (dependency-free RFC6455 + stream mux), pairing
  codes, per-device keys (hashed), revoke/remove
- /r/<device>/ proxy of the full DSH UI (HTTP, Remote WebSocket, SSE) with
  per-device cookie minting; slave->slave relay through the master
- Devices sidebar panel (all devices' sessions, new session on device),
  Remote devices settings page, finished-session toasts
- plugin + config sync master->slave: registry bundles via pluginManager,
  link: plugins as tarballs, cordis.patch.yml into a managed bundle layer
  (!!js preserved), allow/deny, opt-in per-secret credential sync
- deploy helpers + e2e probes, unit tests
2026-10-11 08:23:19 +00:00

151 lines
4.7 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
}
// ---- slave side: link to master ------------------------------------------
get master() { return this.data.master || null }
setMaster(m) {
this.data.master = m
this.save()
}
}