my-durable 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/apps/dpi-backend/src/config.ts +10 -3
- package/apps/dpi-backend/src/daemon.ts +422 -0
- package/apps/dpi-backend/src/main.ts +148 -7
- package/apps/dpi-backend/src/pair-page.ts +111 -0
- package/apps/dpi-backend/src/pairing-files.ts +39 -9
- package/apps/dpi-backend/src/pairing.ts +368 -59
- package/apps/dpi-backend/src/routes.ts +14 -1
- package/apps/dpi-backend/src/store.ts +1 -0
- package/apps/dpi-backend/src/tunnel.ts +294 -55
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
- package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
- package/apps/dpi-sdk/src/api.ts +2 -0
- package/apps/dpi-sdk/src/node/private-file.ts +140 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +9 -0
- package/apps/dpi-sdk/src/node/registry.ts +6 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
- package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
- package/apps/dpi-tui/src/app.ts +889 -187
- package/apps/dpi-tui/src/hub-feed.ts +71 -0
- package/apps/dpi-tui/src/hub-mux.ts +168 -0
- package/apps/dpi-tui/src/main.ts +5 -1
- package/apps/dpi-tui/src/theme.ts +14 -0
- package/apps/dpi-tui/src/views.ts +311 -0
- package/bin/dpi.mjs +8 -3
- package/package.json +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +0 -77
package/README.md
CHANGED
|
@@ -15,6 +15,12 @@ npm install -g my-durable
|
|
|
15
15
|
|
|
16
16
|
dpi hub # web UI + device tunnel server → http://127.0.0.1:4781
|
|
17
17
|
dpi device # device daemon (pairs to a hub, serves this machine)
|
|
18
|
+
dpi device start # run it in the background — no terminal needed (log: <home>/device.log)
|
|
19
|
+
dpi device stop|status # graceful stop (token-authed local API) · health + pairing state
|
|
20
|
+
dpi device install # OS service: systemd --user + linger (Linux) · LaunchAgent (macOS).
|
|
21
|
+
# Windows installs no autostart (like the reference clients) —
|
|
22
|
+
# run `dpi device start` after login
|
|
23
|
+
dpi device uninstall
|
|
18
24
|
dpi tui # terminal admin client
|
|
19
25
|
dpi --help # commands, flags and environment variables
|
|
20
26
|
dpi update --check # is a newer published version available?
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Device configuration: ~/.dpi/backend.env (or $DPI_ENV_FILE) plus CLI flags.
|
|
2
2
|
// Nothing here knows about specific machines; dpi-network.yaml on the operator's laptop
|
|
3
3
|
// writes this env file through `just net up <device>`.
|
|
4
|
-
import {
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
import { homedir, hostname } from "node:os";
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
|
+
import { writePrivateFileAtomic } from "../../dpi-sdk/src/node/private-file.js";
|
|
8
9
|
|
|
9
10
|
export interface BackendConfig {
|
|
10
11
|
name: string;
|
|
@@ -22,7 +23,7 @@ export interface BackendConfig {
|
|
|
22
23
|
|
|
23
24
|
const expand = (p: string) => (p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
|
|
24
25
|
|
|
25
|
-
export function loadConfig(argv = process.argv.slice(2), env = process.env): BackendConfig {
|
|
26
|
+
export async function loadConfig(argv = process.argv.slice(2), env = process.env): Promise<BackendConfig> {
|
|
26
27
|
const flag = (key: string, fallback: string) => {
|
|
27
28
|
const i = argv.indexOf(key);
|
|
28
29
|
if (i < 0) return fallback;
|
|
@@ -44,7 +45,13 @@ export function loadConfig(argv = process.argv.slice(2), env = process.env): Bac
|
|
|
44
45
|
token = `dpi_${randomBytes(32).toString("base64url")}`;
|
|
45
46
|
tokenCreated = true;
|
|
46
47
|
if (!existsSync(home)) mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
47
|
-
|
|
48
|
+
// Atomic, content-preserving (orca STA-3941 / paseo writePrivateFileAtomicSync): a
|
|
49
|
+
// plain append could glue the token onto an unterminated last line or leave a
|
|
50
|
+
// half-written token if the boot dies mid-write. The shared writer publishes the
|
|
51
|
+
// new bytes only via rename and also enforces owner-only access on Windows.
|
|
52
|
+
const prior = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
|
53
|
+
const glued = prior !== "" && !prior.endsWith("\n") ? "\n" : "";
|
|
54
|
+
await writePrivateFileAtomic(envFile, `${prior}${glued}DPI_TOKEN=${token}\n`);
|
|
48
55
|
}
|
|
49
56
|
const config: BackendConfig = {
|
|
50
57
|
name: flag("--name", env.DPI_DEVICE_NAME ?? hostname().replace(/\.local$/, "")),
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dpi device start|stop|status|install|uninstall` — run the device daemon
|
|
3
|
+
* WITHOUT a terminal. The reference clients keep their daemon alive through a
|
|
4
|
+
* desktop supervisor and spawn it detached (paseo `spawnDetached`: detached +
|
|
5
|
+
* stdio redirect + unref + stripped Electron env). We are headless, so the OS
|
|
6
|
+
* supervisor replaces the desktop app:
|
|
7
|
+
*
|
|
8
|
+
* start detached spawn, logs to <home>/device.log (paseo-style hygiene)
|
|
9
|
+
* stop graceful: token-authed POST /v1/shutdown, pid kill as fallback
|
|
10
|
+
* status health + pid + pairing state
|
|
11
|
+
* install linux: systemd --user unit (+ enable-linger so logout keeps it)
|
|
12
|
+
* macOS: LaunchAgent plist
|
|
13
|
+
* windows: hidden Startup-folder VBS (no console flash at logon)
|
|
14
|
+
*
|
|
15
|
+
* DPI_TOKEN never lands in any unit/plist/script — it lives in the env file
|
|
16
|
+
* the daemon bootstraps from.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn, execFile } from "node:child_process";
|
|
19
|
+
import { openSync, closeSync, existsSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
|
|
23
|
+
export type DeviceSub = "run" | "start" | "stop" | "status" | "install" | "uninstall";
|
|
24
|
+
|
|
25
|
+
/** First argv word decides: a subcommand runs and exits, anything else = foreground daemon. */
|
|
26
|
+
export function parseDeviceSub(argv: string[]): { sub: DeviceSub; rest: string[] } {
|
|
27
|
+
const subs: DeviceSub[] = ["start", "stop", "status", "install", "uninstall"];
|
|
28
|
+
const [first, ...rest] = argv;
|
|
29
|
+
return subs.includes(first as DeviceSub)
|
|
30
|
+
? { sub: first as DeviceSub, rest }
|
|
31
|
+
: { sub: "run", rest: argv };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const expand = (p: string) => (p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
|
|
35
|
+
|
|
36
|
+
export interface DaemonPaths {
|
|
37
|
+
home: string;
|
|
38
|
+
pid: string;
|
|
39
|
+
log: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Same home rule as loadConfig: $DPI_HOME or ~/.dpi. */
|
|
43
|
+
export function daemonPaths(env: { DPI_HOME?: string } = {}): DaemonPaths {
|
|
44
|
+
const home = expand(env.DPI_HOME ?? join(homedir(), ".dpi"));
|
|
45
|
+
return { home, pid: join(home, "device.pid"), log: join(home, "device.log") };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Knobs a service file may carry. The token is deliberately absent — it stays in the env file. */
|
|
49
|
+
export function serviceEnv(env: Record<string, string | undefined>): Record<string, string> {
|
|
50
|
+
const keys = [
|
|
51
|
+
"DPI_HOME",
|
|
52
|
+
"DPI_HUB_URL",
|
|
53
|
+
"DPI_HUB_CA",
|
|
54
|
+
"DPI_PROVIDER",
|
|
55
|
+
"DPI_START_DIRS",
|
|
56
|
+
"DPI_HOST",
|
|
57
|
+
"DPI_PORT",
|
|
58
|
+
"DPI_DEVICE_NAME",
|
|
59
|
+
"DPI_DATA_DIR",
|
|
60
|
+
"DPI_ENV_FILE",
|
|
61
|
+
];
|
|
62
|
+
const out: Record<string, string> = {};
|
|
63
|
+
for (const k of keys) if (env[k]) out[k] = env[k] as string;
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The env the detached child inherits: drop the Electron-node hazards (paseo lesson). */
|
|
68
|
+
export function cleanChildEnv(env: Record<string, string | undefined>): NodeJS.ProcessEnv {
|
|
69
|
+
const out: NodeJS.ProcessEnv = { ...env };
|
|
70
|
+
delete out.ELECTRON_RUN_AS_NODE;
|
|
71
|
+
delete out.ELECTRON_NO_ATTACH_CONSOLE;
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const q = (s: string) => `"${s.replaceAll('"', "")}"`;
|
|
76
|
+
|
|
77
|
+
export function systemdUnit(o: { node: string; dpi: string; env: Record<string, string> }): string {
|
|
78
|
+
const envLines = Object.entries(o.env)
|
|
79
|
+
.map(([k, v]) => `Environment="${k}=${v.replaceAll('"', "")}"`)
|
|
80
|
+
.join("\n");
|
|
81
|
+
return `[Unit]
|
|
82
|
+
Description=Durable Pi device daemon
|
|
83
|
+
After=network-online.target
|
|
84
|
+
Wants=network-online.target
|
|
85
|
+
|
|
86
|
+
[Service]
|
|
87
|
+
Type=simple
|
|
88
|
+
ExecStart=${q(o.node)} ${q(o.dpi)} device
|
|
89
|
+
${envLines}
|
|
90
|
+
Restart=on-failure
|
|
91
|
+
RestartSec=2
|
|
92
|
+
|
|
93
|
+
[Install]
|
|
94
|
+
WantedBy=default.target
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function launchdPlist(o: {
|
|
99
|
+
node: string;
|
|
100
|
+
dpi: string;
|
|
101
|
+
env: Record<string, string>;
|
|
102
|
+
log: string;
|
|
103
|
+
}): string {
|
|
104
|
+
const envXml = Object.entries(o.env)
|
|
105
|
+
.map(([k, v]) => ` <key>${k}</key><string>${v.replaceAll("&", "&").replaceAll("<", "<")}</string>`)
|
|
106
|
+
.join("\n");
|
|
107
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
108
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
109
|
+
<plist version="1.0">
|
|
110
|
+
<dict>
|
|
111
|
+
<key>Label</key><string>io.durable-pi.device</string>
|
|
112
|
+
<key>ProgramArguments</key><array><string>${o.node}</string><string>${o.dpi}</string><string>device</string></array>
|
|
113
|
+
<key>EnvironmentVariables</key>
|
|
114
|
+
<dict>
|
|
115
|
+
${envXml}
|
|
116
|
+
</dict>
|
|
117
|
+
<key>RunAtLoad</key><true/>
|
|
118
|
+
<key>StandardOutPath</key><string>${o.log}</string>
|
|
119
|
+
<key>StandardErrorPath</key><string>${o.log}</string>
|
|
120
|
+
</dict>
|
|
121
|
+
</plist>
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------- command IO
|
|
126
|
+
|
|
127
|
+
export interface RunOpts {
|
|
128
|
+
env?: Record<string, string | undefined>;
|
|
129
|
+
platform?: NodeJS.Platform;
|
|
130
|
+
uid?: number;
|
|
131
|
+
fetchImpl?: typeof fetch;
|
|
132
|
+
execImpl?: (cmd: string, args: string[]) => Promise<{ ok: boolean; output: string; code: number }>;
|
|
133
|
+
writeFileImpl?: (path: string, body: string) => Promise<void>;
|
|
134
|
+
killImpl?: (pid: number) => boolean;
|
|
135
|
+
print?: (line: string) => void;
|
|
136
|
+
quiet?: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface Io {
|
|
140
|
+
env: Record<string, string | undefined>;
|
|
141
|
+
platform: NodeJS.Platform;
|
|
142
|
+
uid: number;
|
|
143
|
+
fetchImpl: typeof fetch;
|
|
144
|
+
exec: (cmd: string, args: string[]) => Promise<{ ok: boolean; output: string; code: number }>;
|
|
145
|
+
writeFile: (path: string, body: string) => Promise<void>;
|
|
146
|
+
kill: (pid: number) => boolean;
|
|
147
|
+
print: (line: string) => void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const execDefault = (cmd: string, args: string[]) =>
|
|
151
|
+
new Promise<{ ok: boolean; output: string; code: number }>((resolve) => {
|
|
152
|
+
execFile(cmd, args, { timeout: 30_000 }, (error, stdout, stderr) => {
|
|
153
|
+
const code = (error as { code?: number } | null)?.code ?? 0;
|
|
154
|
+
resolve({ ok: !error, output: String(stdout ?? "") + String(stderr ?? ""), code });
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
159
|
+
|
|
160
|
+
function ioOf(opts: RunOpts): Io {
|
|
161
|
+
return {
|
|
162
|
+
env: opts.env ?? process.env,
|
|
163
|
+
platform: opts.platform ?? process.platform,
|
|
164
|
+
uid: opts.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0),
|
|
165
|
+
fetchImpl: opts.fetchImpl ?? fetch,
|
|
166
|
+
exec: opts.execImpl ?? execDefault,
|
|
167
|
+
writeFile:
|
|
168
|
+
opts.writeFileImpl ??
|
|
169
|
+
(async (path, body) => {
|
|
170
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
171
|
+
writeFileSync(path, body, { mode: 0o644 });
|
|
172
|
+
}),
|
|
173
|
+
kill:
|
|
174
|
+
opts.killImpl ??
|
|
175
|
+
((pid) => {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, "SIGTERM");
|
|
178
|
+
return true;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}),
|
|
183
|
+
print: opts.print ?? ((line) => !opts.quiet && console.log(line)),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function portOf(io: Io): number {
|
|
188
|
+
return Number(io.env.DPI_PORT ?? "4780");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Token for the local API: env first, then the env file the daemon bootstrapped from. */
|
|
192
|
+
function tokenOf(io: Io, paths: DaemonPaths): string {
|
|
193
|
+
if (io.env.DPI_TOKEN) return io.env.DPI_TOKEN;
|
|
194
|
+
const envFile = expand(io.env.DPI_ENV_FILE ?? join(paths.home, "backend.env"));
|
|
195
|
+
try {
|
|
196
|
+
const m = readFileSync(envFile, "utf8").match(/^DPI_TOKEN=(.+)$/m);
|
|
197
|
+
if (m) return m[1].trim();
|
|
198
|
+
} catch {
|
|
199
|
+
/* no env file yet */
|
|
200
|
+
}
|
|
201
|
+
return "";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function health(io: Io): Promise<Response | undefined> {
|
|
205
|
+
try {
|
|
206
|
+
return await io.fetchImpl(`http://127.0.0.1:${portOf(io)}/healthz`, {
|
|
207
|
+
signal: AbortSignal.timeout(800),
|
|
208
|
+
});
|
|
209
|
+
} catch {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function waitHealthDown(io: Io, ms: number): Promise<boolean> {
|
|
215
|
+
const end = Date.now() + ms;
|
|
216
|
+
while (Date.now() < end) {
|
|
217
|
+
const r = await health(io);
|
|
218
|
+
if (!r || !r.ok) return true;
|
|
219
|
+
await delay(200);
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function waitHealthUp(io: Io, ms: number): Promise<boolean> {
|
|
225
|
+
const end = Date.now() + ms;
|
|
226
|
+
while (Date.now() < end) {
|
|
227
|
+
const r = await health(io);
|
|
228
|
+
if (r?.ok) return true;
|
|
229
|
+
await delay(200);
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function pidFrom(paths: DaemonPaths): number | undefined {
|
|
235
|
+
try {
|
|
236
|
+
const n = Number(readFileSync(paths.pid, "utf8").trim());
|
|
237
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
238
|
+
} catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------- commands
|
|
244
|
+
|
|
245
|
+
async function cmdStart(io: Io, rest: string[]): Promise<number> {
|
|
246
|
+
const paths = daemonPaths(io.env);
|
|
247
|
+
if ((await health(io))?.ok) {
|
|
248
|
+
io.print(`already running (pid ${pidFrom(paths) ?? "?"}) on port ${portOf(io)}`);
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
const dpi = io.env.DPI_BIN ?? (process.argv[1]?.endsWith(".mjs") ? process.argv[1] : "");
|
|
252
|
+
if (!dpi || !existsSync(dpi))
|
|
253
|
+
return io.print("start needs the dpi launcher (run via `dpi device start`)"), 1;
|
|
254
|
+
mkdirSync(paths.home, { recursive: true, mode: 0o700 });
|
|
255
|
+
const fd = openSync(paths.log, "a");
|
|
256
|
+
const child = spawn(process.execPath, [dpi, "device", ...rest], {
|
|
257
|
+
detached: true,
|
|
258
|
+
stdio: ["ignore", fd, fd],
|
|
259
|
+
env: cleanChildEnv(io.env),
|
|
260
|
+
cwd: dirname(dpi),
|
|
261
|
+
});
|
|
262
|
+
child.unref();
|
|
263
|
+
closeSync(fd);
|
|
264
|
+
io.print(`starting dpi device … (log: ${paths.log})`);
|
|
265
|
+
if (await waitHealthUp(io, 20_000)) {
|
|
266
|
+
io.print(`running on http://127.0.0.1:${portOf(io)} (pid ${pidFrom(paths) ?? "?"})`);
|
|
267
|
+
io.print(`stop: dpi device stop survive logon: dpi device install`);
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
io.print(`did not come up within 20s — tail the log: tail -50 ${paths.log}`);
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function cmdStop(io: Io): Promise<number> {
|
|
275
|
+
const paths = daemonPaths(io.env);
|
|
276
|
+
const live = (await health(io))?.ok;
|
|
277
|
+
const pid = pidFrom(paths);
|
|
278
|
+
if (!live) {
|
|
279
|
+
if (pid) rmSync(paths.pid, { force: true });
|
|
280
|
+
io.print("not running");
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
const token = tokenOf(io, paths);
|
|
284
|
+
if (token) {
|
|
285
|
+
try {
|
|
286
|
+
const r = await io.fetchImpl(`http://127.0.0.1:${portOf(io)}/v1/shutdown`, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: { authorization: `Bearer ${token}` },
|
|
289
|
+
signal: AbortSignal.timeout(3_000),
|
|
290
|
+
});
|
|
291
|
+
if (r.ok && (await waitHealthDown(io, 10_000))) {
|
|
292
|
+
rmSync(paths.pid, { force: true });
|
|
293
|
+
io.print(`stopped (graceful http shutdown, pid ${pid ?? "?"})`);
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
/* fall through to the pid kill */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (pid && io.kill(pid) && (await waitHealthDown(io, 10_000))) {
|
|
301
|
+
rmSync(paths.pid, { force: true });
|
|
302
|
+
io.print(`stopped (SIGTERM pid ${pid})`);
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
io.print(`still answering on port ${portOf(io)} — kill the process manually (pid ${pid ?? "?"})`);
|
|
306
|
+
return 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function cmdStatus(io: Io): Promise<number> {
|
|
310
|
+
const paths = daemonPaths(io.env);
|
|
311
|
+
const pid = pidFrom(paths);
|
|
312
|
+
const h = await health(io);
|
|
313
|
+
if (!h?.ok) {
|
|
314
|
+
io.print(`not running (pid file: ${pid ?? "none"})`);
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
317
|
+
io.print(`running pid ${pid ?? "?"} http://127.0.0.1:${portOf(io)}`);
|
|
318
|
+
const token = tokenOf(io, paths);
|
|
319
|
+
if (!token) return 0;
|
|
320
|
+
try {
|
|
321
|
+
const auth = { authorization: `Bearer ${token}` } as Record<string, string>;
|
|
322
|
+
const device = (await (await io.fetchImpl(`http://127.0.0.1:${portOf(io)}/v1/device`, { headers: auth })).json()) as {
|
|
323
|
+
name?: string;
|
|
324
|
+
uid?: string;
|
|
325
|
+
provider?: string;
|
|
326
|
+
};
|
|
327
|
+
io.print(`device ${device.name ?? "?"} (${device.uid ?? "?"}) provider ${device.provider ?? "?"}`);
|
|
328
|
+
const pairing = (await (
|
|
329
|
+
await io.fetchImpl(`http://127.0.0.1:${portOf(io)}/v1/pairing`, { headers: auth })
|
|
330
|
+
).json()) as { state?: string; hubOrigin?: string | null; fingerprint?: string };
|
|
331
|
+
io.print(`pairing ${pairing.state ?? "?"}${pairing.hubOrigin ? ` → ${pairing.hubOrigin}` : ""}`);
|
|
332
|
+
if (pairing.fingerprint) io.print(` fingerprint ${pairing.fingerprint}`);
|
|
333
|
+
} catch {
|
|
334
|
+
io.print("(local API not reachable with the saved token)");
|
|
335
|
+
}
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function cmdInstall(io: Io): Promise<number> {
|
|
340
|
+
const env = serviceEnv(io.env);
|
|
341
|
+
const p = io.platform;
|
|
342
|
+
if (p === "win32") {
|
|
343
|
+
// The reference clients register NO OS autostart either: their daemon lives
|
|
344
|
+
// inside a desktop app the user opens by hand. We are a console daemon with
|
|
345
|
+
// no GUI host, so the honest equivalent on Windows is `dpi device start`
|
|
346
|
+
// after login — no hidden scripts, no registry entries, nothing to forget
|
|
347
|
+
// about at uninstall time.
|
|
348
|
+
io.print("Windows: no OS autostart is installed (the reference clients install none either).");
|
|
349
|
+
io.print("Start the daemon after login with: dpi device start");
|
|
350
|
+
io.print("Stop it with: dpi device stop");
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
const node = process.execPath;
|
|
354
|
+
const dpi = io.env.DPI_BIN ?? "";
|
|
355
|
+
if (!dpi || !existsSync(dpi)) return io.print("install needs the dpi launcher (run via `dpi device install`)"), 1;
|
|
356
|
+
if (!env.DPI_HOME) env.DPI_HOME = daemonPaths(io.env).home;
|
|
357
|
+
if (p === "linux") {
|
|
358
|
+
const unit = join(homedir(), ".config/systemd/user/dpi-device.service");
|
|
359
|
+
await io.writeFile(unit, systemdUnit({ node, dpi, env }));
|
|
360
|
+
await io.exec("systemctl", ["--user", "daemon-reload"]);
|
|
361
|
+
const enable = await io.exec("systemctl", ["--user", "enable", "--now", "dpi-device.service"]);
|
|
362
|
+
if (!enable.ok) {
|
|
363
|
+
io.print(`systemctl enable failed: ${enable.output}`);
|
|
364
|
+
return 1;
|
|
365
|
+
}
|
|
366
|
+
// Logout would otherwise stop a user service; linger keeps it. Best-effort:
|
|
367
|
+
// on locked-down hosts the operator adds it once by hand.
|
|
368
|
+
const linger = await io.exec("loginctl", ["enable-linger", String(io.uid)]);
|
|
369
|
+
io.print(`installed systemd user service (${unit})`);
|
|
370
|
+
io.print(linger.ok ? "linger enabled — the daemon survives logout and starts at boot" : "note: enable-linger was refused; the service still starts at every login");
|
|
371
|
+
io.print(`check: systemctl --user status dpi-device`);
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
if (p === "darwin") {
|
|
375
|
+
const paths = daemonPaths(io.env);
|
|
376
|
+
const plist = join(homedir(), "Library/LaunchAgents/io.durable-pi.device.plist");
|
|
377
|
+
await io.writeFile(plist, launchdPlist({ node, dpi, env, log: paths.log }));
|
|
378
|
+
let boot = await io.exec("launchctl", ["bootstrap", `gui/${io.uid}`, plist]);
|
|
379
|
+
if (!boot.ok) boot = await io.exec("launchctl", ["load", "-w", plist]);
|
|
380
|
+
if (!boot.ok) {
|
|
381
|
+
io.print(`launchctl failed: ${boot.output}`);
|
|
382
|
+
return 1;
|
|
383
|
+
}
|
|
384
|
+
await io.exec("launchctl", ["enable", `gui/${io.uid}/io.durable-pi.device`]);
|
|
385
|
+
io.print(`installed LaunchAgent (${plist}) — starts at login`);
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
io.print(`no service installer for platform ${p} — use \`dpi device start\` after login`);
|
|
389
|
+
return 1;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function cmdUninstall(io: Io): Promise<number> {
|
|
393
|
+
await cmdStop(io);
|
|
394
|
+
const p = io.platform;
|
|
395
|
+
if (p === "linux") {
|
|
396
|
+
await io.exec("systemctl", ["--user", "disable", "--now", "dpi-device.service"]);
|
|
397
|
+
const unit = join(homedir(), ".config/systemd/user/dpi-device.service");
|
|
398
|
+
if (existsSync(unit)) rmSync(unit);
|
|
399
|
+
await io.exec("systemctl", ["--user", "daemon-reload"]);
|
|
400
|
+
await io.exec("loginctl", ["disable-linger", String(io.uid)]);
|
|
401
|
+
io.print("removed systemd user service");
|
|
402
|
+
} else if (p === "darwin") {
|
|
403
|
+
const plist = join(homedir(), "Library/LaunchAgents/io.durable-pi.device.plist");
|
|
404
|
+
let boot = await io.exec("launchctl", ["bootout", `gui/${io.uid}/io.durable-pi.device`]);
|
|
405
|
+
if (!boot.ok) await io.exec("launchctl", ["unload", "-w", plist]);
|
|
406
|
+
if (existsSync(plist)) rmSync(plist);
|
|
407
|
+
io.print("removed LaunchAgent");
|
|
408
|
+
} else if (p === "win32") {
|
|
409
|
+
io.print("Windows installs nothing at the OS level — only the daemon was stopped");
|
|
410
|
+
}
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function runDeviceCommand(sub: DeviceSub, rest: string[], opts: RunOpts = {}): Promise<number> {
|
|
415
|
+
const io = ioOf(opts);
|
|
416
|
+
if (sub === "start") return cmdStart(io, rest);
|
|
417
|
+
if (sub === "stop") return cmdStop(io);
|
|
418
|
+
if (sub === "status") return cmdStatus(io);
|
|
419
|
+
if (sub === "install") return cmdInstall(io);
|
|
420
|
+
if (sub === "uninstall") return cmdUninstall(io);
|
|
421
|
+
return 0;
|
|
422
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// dpi-backend: one device of the Durable Agent Network.
|
|
2
2
|
import express from "express";
|
|
3
|
+
import type { Request, Response, NextFunction } from "express";
|
|
3
4
|
import { assertLimitsConfig } from "../../dpi-sdk/src/protocol/limits.js";
|
|
4
5
|
import { createServer } from "node:http";
|
|
6
|
+
import { writeFileSync, rmSync } from "node:fs";
|
|
5
7
|
import { networkInterfaces } from "node:os";
|
|
6
8
|
import { loadConfig } from "./config.js";
|
|
7
9
|
import { Store } from "./store.js";
|
|
@@ -10,10 +12,19 @@ import { errorMiddleware } from "./errors.js";
|
|
|
10
12
|
import { v1 } from "./routes.js";
|
|
11
13
|
import { DevicePairing } from "./pairing.js";
|
|
12
14
|
import { DeviceConnector, readCaPem } from "./tunnel.js";
|
|
15
|
+
import { PAIR_PAGE_HTML } from "./pair-page.js";
|
|
16
|
+
import { parseDeviceSub, runDeviceCommand, daemonPaths } from "./daemon.js";
|
|
17
|
+
|
|
18
|
+
// `dpi device start|stop|status|install|uninstall` manage a background daemon
|
|
19
|
+
// and exit; everything else (bare `dpi device` or flags) runs the daemon here.
|
|
20
|
+
{
|
|
21
|
+
const { sub, rest } = parseDeviceSub(process.argv.slice(2));
|
|
22
|
+
if (sub !== "run") process.exit(await runDeviceCommand(sub, rest));
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
assertLimitsConfig();
|
|
15
26
|
|
|
16
|
-
const config = loadConfig();
|
|
27
|
+
const config = await loadConfig();
|
|
17
28
|
const store = await Store.open({
|
|
18
29
|
dataDir: config.dataDir,
|
|
19
30
|
startDirs: config.startDirs,
|
|
@@ -44,24 +55,149 @@ const connector = new DeviceConnector(
|
|
|
44
55
|
onRevoked: (epoch, reason) => pairing.markRevoked(reason, epoch),
|
|
45
56
|
onRotated: (file) => pairing.markRotated(file),
|
|
46
57
|
},
|
|
47
|
-
{
|
|
58
|
+
{
|
|
59
|
+
baseUrl: `http://${config.host === "0.0.0.0" ? "127.0.0.1" : config.host}:${config.port}`,
|
|
60
|
+
token: config.token,
|
|
61
|
+
},
|
|
48
62
|
);
|
|
49
63
|
connector.start();
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
// GET /pair + /v1/pair-enroll — the device-side browser enrollment surface
|
|
65
|
+
// (gap #4). Unauthenticated BUT narrow: the custom x-dpi-pair header forces a
|
|
66
|
+
// CORS preflight on every foreign origin (a drive-by page cannot POST), the
|
|
67
|
+
// POST only runs while UNPAIRED (a paired device re-pairs through the TUI's
|
|
68
|
+
// token-authed route with force), and the status view carries no secrets.
|
|
69
|
+
const pairEnrollGuard = (req: Request, res: Response, next: NextFunction) => {
|
|
70
|
+
if (req.get("x-dpi-pair") !== "1")
|
|
71
|
+
return res
|
|
72
|
+
.status(403)
|
|
73
|
+
.json({ error: { code: "forbidden", message: "missing x-dpi-pair header" } });
|
|
74
|
+
next();
|
|
75
|
+
};
|
|
76
|
+
app.get("/pair", (_req, res) => res.type("html").send(PAIR_PAGE_HTML));
|
|
77
|
+
app.get("/v1/pair-enroll", pairEnrollGuard, (_req, res) => {
|
|
78
|
+
const s = pairing.status();
|
|
79
|
+
res.json({
|
|
80
|
+
state: s.state,
|
|
81
|
+
deviceName: s.deviceName,
|
|
82
|
+
fingerprint: s.fingerprint,
|
|
83
|
+
hubOrigin: s.hubOrigin ?? s.pending?.hubOrigin,
|
|
84
|
+
lastError: s.lastError,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
app.post("/v1/pair-enroll", pairEnrollGuard, express.json({ limit: "8kb" }), (req, res) => {
|
|
88
|
+
void (async () => {
|
|
89
|
+
const s = pairing.status();
|
|
90
|
+
if (s.state === "paired")
|
|
91
|
+
return res
|
|
92
|
+
.status(409)
|
|
93
|
+
.json({
|
|
94
|
+
error: {
|
|
95
|
+
code: "paired",
|
|
96
|
+
message:
|
|
97
|
+
"this device is already paired; re-pairing runs in the TUI (dpi tui) so it stays operator-driven",
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
if (s.state === "pairing")
|
|
101
|
+
return res
|
|
102
|
+
.status(409)
|
|
103
|
+
.json({
|
|
104
|
+
error: {
|
|
105
|
+
code: "busy",
|
|
106
|
+
message: "a pairing operation is already running — watch its status below",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const pairUrl = (req.body as { pairUrl?: unknown } | null)?.pairUrl;
|
|
110
|
+
if (typeof pairUrl !== "string" || pairUrl.length === 0 || pairUrl.length > 4096)
|
|
111
|
+
return res
|
|
112
|
+
.status(400)
|
|
113
|
+
.json({
|
|
114
|
+
error: {
|
|
115
|
+
code: "invalid",
|
|
116
|
+
message: "body: { pairUrl: string } — the offer link from the hub",
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
await pairing.start({ code: "", pairUrl });
|
|
121
|
+
const after = pairing.status();
|
|
122
|
+
res.json({
|
|
123
|
+
state: after.state,
|
|
124
|
+
deviceName: after.deviceName,
|
|
125
|
+
fingerprint: after.fingerprint,
|
|
126
|
+
hubOrigin: after.pending?.hubOrigin,
|
|
127
|
+
lastError: after.lastError,
|
|
128
|
+
});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
res.status((e as { status?: number }).status ?? 400).json({
|
|
131
|
+
error: { code: (e as { code?: string }).code ?? "invalid", message: (e as Error).message },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
});
|
|
136
|
+
app.use(
|
|
137
|
+
"/v1",
|
|
138
|
+
bearerAuth(config.token),
|
|
139
|
+
express.json({ limit: "256kb" }),
|
|
140
|
+
v1(store, pairing, connector, () => void shutdown()),
|
|
141
|
+
);
|
|
142
|
+
app.use((_req, res) =>
|
|
143
|
+
res.status(404).json({ error: { code: "not_found", message: "Route not found" } }),
|
|
144
|
+
);
|
|
52
145
|
app.use(errorMiddleware);
|
|
53
146
|
|
|
54
147
|
const server = createServer(app);
|
|
55
148
|
server.keepAliveTimeout = 65_000;
|
|
56
149
|
server.listen(config.port, config.host, () => {
|
|
57
|
-
|
|
150
|
+
// Pid file for `dpi device stop/status` (written fresh every boot).
|
|
151
|
+
try {
|
|
152
|
+
writeFileSync(daemonPaths(process.env).pid, String(process.pid));
|
|
153
|
+
} catch {
|
|
154
|
+
/* read-only home: stop falls back to the HTTP shutdown */
|
|
155
|
+
}
|
|
156
|
+
console.log(
|
|
157
|
+
`dpi-backend "${config.name}" (uid ${store.uid}) on http://${config.host}:${config.port}`,
|
|
158
|
+
);
|
|
58
159
|
if (config.tokenCreated)
|
|
59
160
|
console.log(`first run: created DPI_TOKEN in ${config.envFile} — keep this file private`);
|
|
60
|
-
console.log(
|
|
161
|
+
console.log(
|
|
162
|
+
`data: ${config.dataDir} start dirs: ${config.startDirs.join(", ")} provider: ${config.provider}`,
|
|
163
|
+
);
|
|
61
164
|
if (config.host === "0.0.0.0")
|
|
62
165
|
for (const addresses of Object.values(networkInterfaces()))
|
|
63
166
|
for (const a of addresses ?? [])
|
|
64
|
-
if (a.family === "IPv4" && !a.internal)
|
|
167
|
+
if (a.family === "IPv4" && !a.internal)
|
|
168
|
+
console.log(`LAN: http://${a.address}:${config.port}`);
|
|
169
|
+
console.log(`background: dpi device start | stop | status | install (service)`);
|
|
170
|
+
|
|
171
|
+
// Enrollment guidance: an unpaired machine must be told exactly what to do,
|
|
172
|
+
// and — when a hub is configured — it asks for a code ITSELF so the operator
|
|
173
|
+
// reads this machine's identity in the hub's approval list (no secret to carry).
|
|
174
|
+
const boot = pairing.status();
|
|
175
|
+
if (boot.state === "unpaired") {
|
|
176
|
+
const hubOrigin = process.env.DPI_HUB_URL;
|
|
177
|
+
console.log("");
|
|
178
|
+
console.log(`unpaired: open http://${config.host === "0.0.0.0" ? "127.0.0.1" : config.host}:${config.port}/pair`);
|
|
179
|
+
if (hubOrigin) {
|
|
180
|
+
console.log(`requesting a pairing code from ${hubOrigin} …`);
|
|
181
|
+
void pairing
|
|
182
|
+
.start({ hub: hubOrigin })
|
|
183
|
+
.then((s) => {
|
|
184
|
+
if (s.state !== "pairing") return;
|
|
185
|
+
console.log("");
|
|
186
|
+
console.log(` APPROVE THIS MACHINE ON THE HUB (${s.pending?.hubOrigin ?? hubOrigin}):`);
|
|
187
|
+
console.log(` device ${s.deviceName}`);
|
|
188
|
+
console.log(` fingerprint ${s.fingerprint}`);
|
|
189
|
+
console.log(" Compare that fingerprint with the hub's approval card, then approve there.");
|
|
190
|
+
console.log(" This command keeps running; the credential is saved automatically.");
|
|
191
|
+
})
|
|
192
|
+
.catch((e: Error) => {
|
|
193
|
+
console.log(`pairing request failed: ${e.message}`);
|
|
194
|
+
console.log(`retry from the TUI (dpi tui, then /pair --hub ${hubOrigin}) or open /pair`);
|
|
195
|
+
});
|
|
196
|
+
} else {
|
|
197
|
+
console.log(` then pair from the hub wizard, or run: dpi tui → /pair <code> --hub <origin>`);
|
|
198
|
+
console.log(" set DPI_HUB_URL=<https origin> to let this machine request a code by itself");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
65
201
|
});
|
|
66
202
|
|
|
67
203
|
let closing = false;
|
|
@@ -72,6 +208,11 @@ const shutdown = async () => {
|
|
|
72
208
|
server.close();
|
|
73
209
|
server.closeAllConnections();
|
|
74
210
|
await store.close();
|
|
211
|
+
try {
|
|
212
|
+
rmSync(daemonPaths(process.env).pid, { force: true });
|
|
213
|
+
} catch {
|
|
214
|
+
/* nothing to clean */
|
|
215
|
+
}
|
|
75
216
|
process.exit(0);
|
|
76
217
|
};
|
|
77
218
|
process.on("SIGTERM", () => void shutdown());
|