Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
190 lines
5.5 KiB
JavaScript
190 lines
5.5 KiB
JavaScript
/**
|
|
* dsh-fleet-control — host half.
|
|
*
|
|
* Persists the set of session ids that "Stop All" gracefully cancelled so that
|
|
* "Resume All" can re-prompt exactly those sessions later — including after an
|
|
* app restart. State lives in ~/.dsh/fleet-control/stopped.json.
|
|
*
|
|
* Exposes one loopback-only route, GET/POST /fleet/stopped:
|
|
* GET -> { ok: true, value: { ids: string[], updatedAt: string } }
|
|
* POST { ids: string[] } -> replaces the set, returns the same envelope.
|
|
*
|
|
* @module dsh-fleet-control
|
|
*/
|
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
import { homedir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
|
|
const STORE_FILE = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'fleet-control', 'stopped.json')
|
|
|
|
export const name = 'dsh-fleet-control'
|
|
export const inject = ['webServer']
|
|
|
|
const JSON_HEADERS = {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'referrer-policy': 'no-referrer',
|
|
}
|
|
|
|
function writeJson(response, status, body) {
|
|
response.writeHead(status, JSON_HEADERS)
|
|
response.end(JSON.stringify(body))
|
|
}
|
|
|
|
// ---- loopback guards (mirrors dsh-open-in-smerge) -------------------------
|
|
function isIPv4Loopback(address) {
|
|
const parts = address.split('.')
|
|
return parts.length === 4
|
|
&& parts[0] === '127'
|
|
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
}
|
|
|
|
function isLoopbackAddress(address) {
|
|
if (address === undefined) return false
|
|
const normalized = address.toLowerCase()
|
|
if (normalized === '::1') return true
|
|
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
return isIPv4Loopback(normalized)
|
|
}
|
|
|
|
function isLoopbackHostname(hostname) {
|
|
return hostname === 'localhost' || hostname === '[::1]' || isIPv4Loopback(hostname)
|
|
}
|
|
|
|
export function isLoopbackRequest(request) {
|
|
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false
|
|
|
|
const host = request.headers.host
|
|
if (typeof host !== 'string') return false
|
|
|
|
let hostUrl
|
|
try {
|
|
hostUrl = new URL(`http://${host}`)
|
|
} catch {
|
|
return false
|
|
}
|
|
|
|
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
|
|
const origin = request.headers.origin
|
|
if (origin === undefined) return true
|
|
|
|
try {
|
|
return new URL(origin).host === hostUrl.host
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
// --------------------------------------------------------------------------
|
|
|
|
async function readJsonBody(request) {
|
|
const chunks = []
|
|
let size = 0
|
|
|
|
for await (const chunk of request) {
|
|
size += chunk.length
|
|
if (size > 64 * 1024) {
|
|
request.destroy()
|
|
return null
|
|
}
|
|
chunks.push(chunk)
|
|
}
|
|
|
|
if (chunks.length === 0) return null
|
|
|
|
try {
|
|
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function sanitizeIds(value) {
|
|
if (!Array.isArray(value)) return []
|
|
const out = []
|
|
for (const entry of value) {
|
|
if (typeof entry === 'string' && entry !== '' && out.indexOf(entry) === -1) out.push(entry)
|
|
}
|
|
return out
|
|
}
|
|
|
|
export class StoppedSessionStore {
|
|
constructor(filePath = STORE_FILE) {
|
|
this.filePath = filePath
|
|
this.ids = []
|
|
this.updatedAt = new Date(0).toISOString()
|
|
this.ready = this.load()
|
|
this.pendingWrite = Promise.resolve()
|
|
}
|
|
|
|
async load() {
|
|
try {
|
|
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'))
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return
|
|
this.ids = sanitizeIds(parsed.ids)
|
|
if (typeof parsed.updatedAt === 'string') this.updatedAt = parsed.updatedAt
|
|
} catch {
|
|
this.ids = []
|
|
}
|
|
}
|
|
|
|
async get() {
|
|
await this.ready
|
|
return { ids: this.ids.slice(), updatedAt: this.updatedAt }
|
|
}
|
|
|
|
async set(ids) {
|
|
await this.ready
|
|
this.ids = sanitizeIds(ids)
|
|
this.updatedAt = new Date().toISOString()
|
|
this.pendingWrite = this.pendingWrite.then(() => this.persist())
|
|
await this.pendingWrite
|
|
return { ids: this.ids.slice(), updatedAt: this.updatedAt }
|
|
}
|
|
|
|
async persist() {
|
|
await mkdir(dirname(this.filePath), { recursive: true })
|
|
const temporaryPath = `${this.filePath}.tmp-${process.pid}`
|
|
const body = JSON.stringify({ ids: this.ids, updatedAt: this.updatedAt }, null, 2)
|
|
await writeFile(temporaryPath, `${body}\n`, 'utf8')
|
|
await rename(temporaryPath, this.filePath)
|
|
}
|
|
}
|
|
|
|
export function apply(context) {
|
|
const store = new StoppedSessionStore()
|
|
|
|
const handler = async (request, response) => {
|
|
const method = (request.method ?? 'GET').toUpperCase()
|
|
|
|
if (method !== 'GET' && method !== 'POST') {
|
|
writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
|
|
return
|
|
}
|
|
|
|
if (!isLoopbackRequest(request)) {
|
|
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
|
|
return
|
|
}
|
|
|
|
if (method === 'GET') {
|
|
writeJson(response, 200, { ok: true, value: await store.get() })
|
|
return
|
|
}
|
|
|
|
const body = await readJsonBody(request)
|
|
const value = await store.set(body && body.ids)
|
|
writeJson(response, 200, { ok: true, value })
|
|
}
|
|
|
|
context.effect(() => {
|
|
const disposers = [
|
|
context.webServer.register({ kind: 'exact', path: '/fleet/stopped', handler }),
|
|
]
|
|
|
|
return () => {
|
|
for (const dispose of disposers.reverse()) dispose()
|
|
}
|
|
}, 'fleet-control: stopped-session store route')
|
|
}
|