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
This commit is contained in:
commit
8872e73e76
21 files changed
+2914
No files matched your search
+150
@@ -0,0 +1,150 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user