@wolpertingerlabs/drawlatch 1.0.0-alpha.19 → 1.0.0-alpha.29
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 +111 -4
- package/bin/drawlatch.js +158 -0
- package/dist/auth/auth.d.ts +10 -0
- package/dist/auth/auth.js +146 -0
- package/dist/auth/env-writer.d.ts +6 -0
- package/dist/auth/env-writer.js +52 -0
- package/dist/auth/password.d.ts +8 -0
- package/dist/auth/password.js +28 -0
- package/dist/auth/sessions.d.ts +13 -0
- package/dist/auth/sessions.js +85 -0
- package/dist/remote/admin-mutations.d.ts +37 -0
- package/dist/remote/admin-mutations.js +251 -0
- package/dist/remote/admin-types.d.ts +149 -0
- package/dist/remote/admin-types.js +11 -0
- package/dist/remote/admin.d.ts +11 -3
- package/dist/remote/admin.js +91 -7
- package/dist/remote/caller-bootstrap.d.ts +71 -0
- package/dist/remote/caller-bootstrap.js +141 -0
- package/dist/remote/server.d.ts +1 -54
- package/dist/remote/server.js +204 -881
- package/dist/remote/tool-dispatch.d.ts +84 -0
- package/dist/remote/tool-dispatch.js +910 -0
- package/dist/remote/tunnel-state.d.ts +14 -0
- package/dist/remote/tunnel-state.js +20 -0
- package/dist/shared/config.d.ts +6 -0
- package/dist/shared/migrations.d.ts +40 -0
- package/dist/shared/migrations.js +122 -0
- package/frontend/dist/assets/index-D4k5QKBP.css +1 -0
- package/frontend/dist/assets/index-IzwnSOmu.js +267 -0
- package/frontend/dist/index.html +15 -0
- package/package.json +51 -12
package/README.md
CHANGED
|
@@ -137,6 +137,86 @@ drawlatch start
|
|
|
137
137
|
drawlatch doctor # Validate full setup
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
## Admin Dashboard
|
|
141
|
+
|
|
142
|
+
`drawlatch start` serves a built-in web dashboard — a React single-page app — that **fully manages** your running daemon: enable/disable connections per caller, set and clear secrets, create and delete callers, configure and control event listeners (start/stop/restart, multi-instance management), and watch the live event/log feed — all from the browser, no config-file editing required. Every change is applied with a **live reload** (the daemon re-resolves routes and ingestors in place), so there is no "restart to apply" step. drawlatch owns 100% of its own state through this password-gated surface; nothing external writes its config.
|
|
143
|
+
|
|
144
|
+
### Architecture
|
|
145
|
+
|
|
146
|
+
There is no separate UI service to run. The React app, the `/api/admin/*` API, and the MCP protocol endpoints (`/handshake`, `/request`, `/events`, `/webhooks`, …) are all served by the **same Express process on the same port** as the daemon (default `http://127.0.0.1:9999/`):
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
┌────────────────────────────── drawlatch daemon (one process, port 9999) ──────────────────────────────┐
|
|
150
|
+
│ │
|
|
151
|
+
│ GET / → React SPA (served from frontend/dist in production) │
|
|
152
|
+
│ /api/admin/* → read + mutating JSON API ──┐ │
|
|
153
|
+
│ POST /api/auth/* → login / logout / check ├─ password-gated (session cookie) │
|
|
154
|
+
│ /handshake /request … → MCP protocol (E2EE) ─┘ ← unaffected by dashboard auth │
|
|
155
|
+
│ │
|
|
156
|
+
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Any unmatched non-API GET falls back to `index.html` so client-side routing works. The MCP protocol endpoints are independent of the dashboard — they keep serving agents even when the dashboard is locked (see below).
|
|
160
|
+
|
|
161
|
+
### Setup
|
|
162
|
+
|
|
163
|
+
**1. Set a password** (required — the dashboard is locked until one is set):
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
drawlatch set-password # prompts on a TTY, or reads a password piped on stdin
|
|
167
|
+
# echo 'my-strong-password' | drawlatch set-password # non-interactive
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The password is hashed with **scrypt** (random 16-byte salt, verified with a constant-time compare); the hash + salt are written to `~/.drawlatch/.env` (`AUTH_PASSWORD_HASH` / `AUTH_PASSWORD_SALT`, mode `0600`). Your plaintext password is never stored. Use `drawlatch change-password` (an alias of the same command) to rotate it later — rotating signs out every other session.
|
|
171
|
+
|
|
172
|
+
**2. Open the dashboard** at `http://127.0.0.1:9999/` and log in. `drawlatch status` prints the dashboard URL and whether a password is configured.
|
|
173
|
+
|
|
174
|
+
### Pages
|
|
175
|
+
|
|
176
|
+
| Page | Route | What it shows | Refresh |
|
|
177
|
+
|------|-------|---------------|---------|
|
|
178
|
+
| **Overview** | `/` | Daemon health at a glance: status, PID, port, version, uptime, active session count, ingestor state breakdown, and secrets-configured progress. | on load |
|
|
179
|
+
| **Connections** | `/connections` | Full management: connections grouped by category with search + a stable/beta/dev filter; a caller selector with create/delete-caller; per-connection enable toggle, secrets modal, test connection/listener, listener config panel (all field types + multi-instance), and quick start/stop/restart — with live ingestor state dots and secret-status badges. | live (5s) |
|
|
180
|
+
| **Logs** | `/logs` | Live event/log feed per caller — ingestor status cards, source filter pills, and expandable event rows (eventType, ids, timestamps, pretty-printed JSON payload). | every 5s |
|
|
181
|
+
| **Callers** | `/callers` | Registered MCP callers — alias, name, connection count, key fingerprint, and whether their keys directory exists. Click through for a caller's connections and secret status. | on load |
|
|
182
|
+
| **Ingestors** | `/ingestors` | Live table of every running ingestor (WebSocket / webhook / poll) — state, buffered event count, total events received, last activity, and any error. | every 2s |
|
|
183
|
+
| **Sessions** | `/sessions` | Active MCP proxy sessions — caller alias, created/last-active times, request count, and current per-window request rate. | every 5s |
|
|
184
|
+
| **Secrets** | `/secrets` | A (caller × connection × secret) matrix showing required/optional and present/missing — with a "only missing" filter. **Never shows secret values**, only whether each is set. | every 10s |
|
|
185
|
+
|
|
186
|
+
### The `/api/admin/*` API
|
|
187
|
+
|
|
188
|
+
The pages are views over the `/api/admin/*` JSON API. **Read** endpoints (`/meta`, `/health`, `/connections`, `/callers`, `/callers/:alias/connection-status`, `/callers/:alias/connections`, `/callers/:alias/ingestors`, `/callers/:alias/events`, `/ingestors`, `/sessions`, `/secrets`) **never return a secret value** — caller `env` maps are reduced to key *names*, secret state is reported as booleans, and session crypto material is never serialized.
|
|
189
|
+
|
|
190
|
+
**Mutating** endpoints (all behind the password gate) let the dashboard own management end-to-end:
|
|
191
|
+
|
|
192
|
+
| Method + path | Action |
|
|
193
|
+
|---|---|
|
|
194
|
+
| `POST /callers` | Create a caller **with a fresh keypair** (no interactive sync) |
|
|
195
|
+
| `DELETE /callers/:alias` | Delete a caller (its keys + prefixed env vars); `default` is protected |
|
|
196
|
+
| `POST /callers/:alias/connections/:connection` `{enabled}` | Enable/disable a connection |
|
|
197
|
+
| `PUT /callers/:alias/connections/:connection/secrets` `{secrets}` | Set/clear secrets (empty string = delete) — **write-only**, read back as booleans |
|
|
198
|
+
| `POST /callers/:alias/connections/:connection/test` · `/test-ingestor` | Run a connection / listener test |
|
|
199
|
+
| `POST /callers/:alias/connections/:connection/listener/control` `{action,instance_id?}` | Start/stop/restart a listener |
|
|
200
|
+
| `GET/PUT /…/listener/params`, `GET/POST/DELETE /…/listener/instances[/:id]`, `POST /…/listener/resolve-options` | Listener params + multi-instance management |
|
|
201
|
+
|
|
202
|
+
Secrets are **write-only** through this API: you `PUT` values, and every read path reports only booleans. After any mutation the daemon live-reloads routes/ingestors for the affected caller. The same logic powers the encrypted MCP tools and the admin API through a single shared `tool-dispatch` module, so the two surfaces can never drift.
|
|
203
|
+
|
|
204
|
+
A loopback-only `POST /sync/auto-enroll` lets a **co-located** client (one that shares drawlatch's filesystem) provision a caller with zero interaction by presenting the one-time token drawlatch writes to `~/.drawlatch/enroll.token` at startup.
|
|
205
|
+
|
|
206
|
+
### Security model
|
|
207
|
+
|
|
208
|
+
The **password is the trust boundary** for the dashboard and `/api/admin/*` — not loopback. That lets you expose the dashboard to a LAN by binding a non-loopback host:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
DRAWLATCH_HOST=0.0.0.0 drawlatch start # or: drawlatch start --host 0.0.0.0
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Auth uses a `drawlatch_session` cookie that is `httpOnly` and `sameSite=strict`, with a **7-day rolling expiry** (every authenticated request extends it). Login, password-change, and auth-check endpoints are rate-limited per IP (5/min for login & change-password, 20/min for checks). If **no** password is configured, the daemon still starts and serves MCP normally — only the dashboard is locked: `/api/auth/*` and `/api/admin/*` return `503` and the SPA shows a locked state prompting `drawlatch set-password`. The daemon never exits just because the dashboard is unconfigured.
|
|
215
|
+
|
|
216
|
+
> **Cookies run over plain HTTP** on loopback/LAN (no `secure` flag). Put the daemon behind a TLS-terminating reverse proxy if you expose it beyond a trusted network.
|
|
217
|
+
|
|
218
|
+
> **Migrating from `drawlatch-ui`?** The standalone `drawlatch-ui` service and its `~/.drawlatch-ui/` config directory are abandoned — its dashboard, auth gate, and password now live inside drawlatch. There is no automatic migration: just run `drawlatch set-password` once to set the password in `~/.drawlatch/.env`.
|
|
219
|
+
|
|
140
220
|
## MCP Tools
|
|
141
221
|
|
|
142
222
|
Once connected, agents get these tools:
|
|
@@ -274,7 +354,7 @@ Key paths are derived automatically — no configuration needed:
|
|
|
274
354
|
|
|
275
355
|
### Advanced Configuration
|
|
276
356
|
|
|
277
|
-
#### `MCP_CONFIG_DIR`
|
|
357
|
+
#### `MCP_CONFIG_DIR` — the config-dir contract
|
|
278
358
|
|
|
279
359
|
By default, all config and key files live in `~/.drawlatch/`. Override with:
|
|
280
360
|
|
|
@@ -282,7 +362,28 @@ By default, all config and key files live in `~/.drawlatch/`. Override with:
|
|
|
282
362
|
export MCP_CONFIG_DIR=/custom/path/to/config
|
|
283
363
|
```
|
|
284
364
|
|
|
285
|
-
Useful for CI environments or running multiple independent setups on the same machine.
|
|
365
|
+
Useful for CI environments or running multiple independent setups on the same machine. drawlatch **owns this layout as a stable contract** (and migrates legacy key layouts into it automatically on startup):
|
|
366
|
+
|
|
367
|
+
```
|
|
368
|
+
$MCP_CONFIG_DIR/ (default: ~/.drawlatch)
|
|
369
|
+
remote.config.json — RemoteServerConfig (callers, connectors, port, tunnel flag)
|
|
370
|
+
proxy.config.json — ProxyConfig (local MCP proxy → remote URL)
|
|
371
|
+
.env — secret values, prefixed per caller (mode 0600)
|
|
372
|
+
enroll.token — one-time loopback auto-enroll token (mode 0600)
|
|
373
|
+
keys/
|
|
374
|
+
server/ — the daemon's own Ed25519 + X25519 keypair
|
|
375
|
+
callers/<alias>/ — one keypair per caller alias
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Legacy `keys/local`, `keys/remote`, and `keys/peers/*` directories are migrated to `keys/callers` / `keys/server` on first start — idempotent and safe to re-run.
|
|
379
|
+
|
|
380
|
+
#### Self-managed tunnel
|
|
381
|
+
|
|
382
|
+
Set `"tunnel": true` in `remote.config.json` (or `DRAWLATCH_TUNNEL=1` / `drawlatch start --tunnel`) and drawlatch brings up and supervises its own Cloudflare quick tunnel on startup: it learns the public URL, injects it into callback-dependent connection configs (e.g. `TRELLO_CALLBACK_URL`) **before** secret resolution and ingestor start, and surfaces it in `drawlatch status`, the Overview page, and `/api/admin/meta`. It is a config flag, not a runtime control surface.
|
|
383
|
+
|
|
384
|
+
#### Daemon lifecycle
|
|
385
|
+
|
|
386
|
+
`drawlatch start` runs the **whole** daemon in one process (MCP protocol + admin API + dashboard UI + optional tunnel). It is daemon-first and cleanly supervisable: a PID file, a deterministic `start` / `stop` / `restart` / `status`, an unauthenticated `/health` endpoint, and `drawlatch start --foreground` for running under a process manager. Local and remote deployments differ only by host binding (`DRAWLATCH_HOST`) and the dashboard password.
|
|
286
387
|
|
|
287
388
|
## Connections
|
|
288
389
|
|
|
@@ -382,10 +483,11 @@ Commands:
|
|
|
382
483
|
start Start the remote server (background daemon)
|
|
383
484
|
stop Stop the remote server
|
|
384
485
|
restart Restart the remote server
|
|
385
|
-
status Show server status (PID, port, uptime, health, sessions)
|
|
486
|
+
status Show server status (PID, port, uptime, health, sessions, dashboard URL)
|
|
386
487
|
logs View server logs
|
|
387
488
|
config Show effective configuration and secret status
|
|
388
489
|
doctor Validate setup and diagnose issues
|
|
490
|
+
set-password Set/change the dashboard password (alias: change-password)
|
|
389
491
|
generate-keys Generate Ed25519 + X25519 keypairs
|
|
390
492
|
sync Exchange keys with a callboard instance
|
|
391
493
|
|
|
@@ -487,9 +589,11 @@ npm run dev:mcp # MCP proxy with hot reload
|
|
|
487
589
|
src/
|
|
488
590
|
├── cli/ # Key generation CLI
|
|
489
591
|
├── connections/ # 23 pre-built route templates (JSON)
|
|
592
|
+
├── auth/ # Dashboard auth (scrypt password, session cookies)
|
|
490
593
|
├── mcp/server.ts # Local MCP proxy (stdio transport)
|
|
491
594
|
├── remote/
|
|
492
|
-
│ ├── server.ts # Remote secure server (Express)
|
|
595
|
+
│ ├── server.ts # Remote secure server (Express) — also serves the dashboard
|
|
596
|
+
│ ├── admin.ts # Read-only /api/admin/* API
|
|
493
597
|
│ └── ingestors/ # Event ingestion system
|
|
494
598
|
│ ├── discord/ # Discord Gateway WebSocket
|
|
495
599
|
│ ├── slack/ # Slack Socket Mode WebSocket
|
|
@@ -501,6 +605,9 @@ src/
|
|
|
501
605
|
├── env-utils.ts # Environment variable utilities
|
|
502
606
|
├── crypto/ # Ed25519/X25519 keys, AES-256-GCM channel
|
|
503
607
|
└── protocol/ # Handshake, message types
|
|
608
|
+
|
|
609
|
+
frontend/ # React + Vite dashboard SPA (built to frontend/dist)
|
|
610
|
+
└── src/pages/ # Overview, Connections, Callers, Ingestors, Sessions, Secrets
|
|
504
611
|
```
|
|
505
612
|
|
|
506
613
|
## License
|
package/bin/drawlatch.js
CHANGED
|
@@ -186,6 +186,14 @@ switch (subcommand) {
|
|
|
186
186
|
await cmdSync();
|
|
187
187
|
}
|
|
188
188
|
break;
|
|
189
|
+
case "set-password":
|
|
190
|
+
case "change-password":
|
|
191
|
+
if (values.help) {
|
|
192
|
+
printSetPasswordHelp(subcommand);
|
|
193
|
+
} else {
|
|
194
|
+
await cmdSetPassword();
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
189
197
|
case "watch":
|
|
190
198
|
if (values.help) {
|
|
191
199
|
printWatchHelp();
|
|
@@ -464,6 +472,10 @@ async function cmdStatus() {
|
|
|
464
472
|
console.log("Drawlatch remote server is running.");
|
|
465
473
|
console.log(` PID: ${pid}`);
|
|
466
474
|
console.log(` Listening: ${host}:${port}`);
|
|
475
|
+
console.log(` Dashboard: http://${connectHost(host)}:${port}/`);
|
|
476
|
+
console.log(
|
|
477
|
+
` Password: ${isPasswordConfigured() ? "configured" : "not set (run: drawlatch set-password)"}`,
|
|
478
|
+
);
|
|
467
479
|
console.log(` Uptime: ${uptime}`);
|
|
468
480
|
console.log(
|
|
469
481
|
` Health: ${healthData ? "healthy" : "unhealthy (not responding)"}`,
|
|
@@ -978,6 +990,131 @@ async function promptInput(prompt) {
|
|
|
978
990
|
});
|
|
979
991
|
}
|
|
980
992
|
|
|
993
|
+
// ── set-password / change-password ────────────────────────────────
|
|
994
|
+
//
|
|
995
|
+
// Sets the dashboard/admin password. The scrypt hash + salt are written to
|
|
996
|
+
// ~/.drawlatch/.env (AUTH_PASSWORD_HASH / AUTH_PASSWORD_SALT) using the same
|
|
997
|
+
// env-writer the daemon's change-password handler uses, so both stay in sync.
|
|
998
|
+
async function cmdSetPassword() {
|
|
999
|
+
ensureConfigDir();
|
|
1000
|
+
|
|
1001
|
+
const { hashPassword, generateSalt } = await import(
|
|
1002
|
+
join(PKG_ROOT, "dist/auth/password.js")
|
|
1003
|
+
);
|
|
1004
|
+
const { updateEnvFile } = await import(
|
|
1005
|
+
join(PKG_ROOT, "dist/auth/env-writer.js")
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
let password, confirm;
|
|
1009
|
+
if (process.stdin.isTTY) {
|
|
1010
|
+
password = await promptPasswordTTY("Enter new password: ");
|
|
1011
|
+
if (!password) {
|
|
1012
|
+
console.error("Error: password cannot be empty.");
|
|
1013
|
+
process.exit(1);
|
|
1014
|
+
}
|
|
1015
|
+
if (password.length < 8) {
|
|
1016
|
+
console.error("Error: password must be at least 8 characters.");
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1019
|
+
confirm = await promptPasswordTTY("Confirm new password: ");
|
|
1020
|
+
} else {
|
|
1021
|
+
// Piped input — read both lines from a single readline interface so
|
|
1022
|
+
// closing it doesn't break the second prompt.
|
|
1023
|
+
const { createInterface } = await import("node:readline");
|
|
1024
|
+
const rl = createInterface({ input: process.stdin });
|
|
1025
|
+
[password, confirm] = await readTwoLines(rl);
|
|
1026
|
+
rl.close();
|
|
1027
|
+
if (!password) {
|
|
1028
|
+
console.error("Error: password cannot be empty.");
|
|
1029
|
+
process.exit(1);
|
|
1030
|
+
}
|
|
1031
|
+
if (password.length < 8) {
|
|
1032
|
+
console.error("Error: password must be at least 8 characters.");
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (password !== confirm) {
|
|
1038
|
+
console.error("Error: passwords do not match.");
|
|
1039
|
+
process.exit(1);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const salt = generateSalt();
|
|
1043
|
+
const hash = await hashPassword(password, salt);
|
|
1044
|
+
|
|
1045
|
+
updateEnvFile(
|
|
1046
|
+
{ AUTH_PASSWORD_HASH: hash, AUTH_PASSWORD_SALT: salt },
|
|
1047
|
+
["AUTH_PASSWORD"], // strip any plaintext leftover, just in case
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
console.log("\nPassword set successfully.");
|
|
1051
|
+
console.log(` Stored in: ${ENV_FILE}`);
|
|
1052
|
+
|
|
1053
|
+
const pid = readPid();
|
|
1054
|
+
if (pid) {
|
|
1055
|
+
console.log(" Restart the server to apply: drawlatch restart");
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function readTwoLines(rl) {
|
|
1060
|
+
return new Promise((resolveP) => {
|
|
1061
|
+
const lines = [];
|
|
1062
|
+
const onLine = (line) => {
|
|
1063
|
+
lines.push(line);
|
|
1064
|
+
if (lines.length >= 2) {
|
|
1065
|
+
rl.removeListener("line", onLine);
|
|
1066
|
+
resolveP(lines);
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
rl.on("line", onLine);
|
|
1070
|
+
rl.once("close", () => {
|
|
1071
|
+
while (lines.length < 2) lines.push("");
|
|
1072
|
+
resolveP(lines);
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function promptPasswordTTY(prompt) {
|
|
1078
|
+
return new Promise((resolveP) => {
|
|
1079
|
+
process.stdout.write(prompt);
|
|
1080
|
+
const stdin = process.stdin;
|
|
1081
|
+
stdin.setRawMode(true);
|
|
1082
|
+
stdin.resume();
|
|
1083
|
+
stdin.setEncoding("utf8");
|
|
1084
|
+
let password = "";
|
|
1085
|
+
const onData = (ch) => {
|
|
1086
|
+
ch = ch.toString();
|
|
1087
|
+
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
|
|
1088
|
+
stdin.setRawMode(false);
|
|
1089
|
+
stdin.pause();
|
|
1090
|
+
stdin.removeListener("data", onData);
|
|
1091
|
+
process.stdout.write("\n");
|
|
1092
|
+
resolveP(password);
|
|
1093
|
+
} else if (ch === "\u0003") {
|
|
1094
|
+
// Ctrl+C
|
|
1095
|
+
stdin.setRawMode(false);
|
|
1096
|
+
process.stdout.write("\n");
|
|
1097
|
+
process.exit(0);
|
|
1098
|
+
} else if (ch === "\u007F" || ch === "\b") {
|
|
1099
|
+
if (password.length > 0) password = password.slice(0, -1);
|
|
1100
|
+
} else {
|
|
1101
|
+
password += ch;
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
stdin.on("data", onData);
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Read ~/.drawlatch/.env and report whether a dashboard password hash is set. */
|
|
1109
|
+
function isPasswordConfigured() {
|
|
1110
|
+
try {
|
|
1111
|
+
const envVars = loadEnvFileVars();
|
|
1112
|
+
return !!envVars.AUTH_PASSWORD_HASH;
|
|
1113
|
+
} catch {
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
981
1118
|
// ── PID utilities ─────────────────────────────────────────────────
|
|
982
1119
|
|
|
983
1120
|
function isProcessAlive(pid) {
|
|
@@ -1253,6 +1390,7 @@ Commands:
|
|
|
1253
1390
|
doctor Validate setup and diagnose issues
|
|
1254
1391
|
generate-keys Generate Ed25519 + X25519 keypairs
|
|
1255
1392
|
sync Exchange keys with a callboard instance
|
|
1393
|
+
set-password Set/change the dashboard password (alias: change-password)
|
|
1256
1394
|
|
|
1257
1395
|
Options:
|
|
1258
1396
|
-h, --help Show this help message
|
|
@@ -1457,6 +1595,26 @@ The server must be running (drawlatch start) before using this command.
|
|
|
1457
1595
|
`);
|
|
1458
1596
|
}
|
|
1459
1597
|
|
|
1598
|
+
function printSetPasswordHelp(cmd = "set-password") {
|
|
1599
|
+
console.log(`
|
|
1600
|
+
drawlatch ${cmd}
|
|
1601
|
+
|
|
1602
|
+
Set or change the password that gates the admin dashboard and /api/admin/*.
|
|
1603
|
+
|
|
1604
|
+
Usage: drawlatch ${cmd} [options]
|
|
1605
|
+
|
|
1606
|
+
Options:
|
|
1607
|
+
-h, --help Show this help message
|
|
1608
|
+
|
|
1609
|
+
Prompts for a new password (hidden in a TTY; reads two lines when piped),
|
|
1610
|
+
hashes it with scrypt, and stores AUTH_PASSWORD_HASH / AUTH_PASSWORD_SALT in
|
|
1611
|
+
${ENV_FILE}.
|
|
1612
|
+
|
|
1613
|
+
The password must be at least 8 characters. 'change-password' is an alias.
|
|
1614
|
+
Restart the server afterwards (drawlatch restart) to apply the new password.
|
|
1615
|
+
`);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1460
1618
|
function printGenerateKeysHelp() {
|
|
1461
1619
|
console.log(`
|
|
1462
1620
|
drawlatch generate-keys
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
export declare const SESSION_COOKIE_NAME: string;
|
|
3
|
+
export declare const SESSION_TTL_MS: number;
|
|
4
|
+
export declare function isPasswordConfigured(): boolean;
|
|
5
|
+
export declare function loginHandler(req: Request, res: Response): Promise<void>;
|
|
6
|
+
export declare function logoutHandler(req: Request, res: Response): void;
|
|
7
|
+
export declare function checkAuthHandler(req: Request, res: Response): void;
|
|
8
|
+
export declare function changePasswordHandler(req: Request, res: Response): Promise<void>;
|
|
9
|
+
export declare function requireAuth(req: Request, res: Response, next: NextFunction): void;
|
|
10
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { getSession, createSession, deleteSession, extendSession, cleanupExpiredSessions, deleteAllSessionsExcept, } from './sessions.js';
|
|
3
|
+
import { verifyPassword, hashPassword, generateSalt } from './password.js';
|
|
4
|
+
import { updateEnvFile } from './env-writer.js';
|
|
5
|
+
export const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME ?? 'drawlatch_session';
|
|
6
|
+
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
7
|
+
const NO_PASSWORD_MESSAGE = 'Server misconfigured: no password set. Run: drawlatch set-password';
|
|
8
|
+
// ── Password helpers ────────────────────────────────────────────────
|
|
9
|
+
export function isPasswordConfigured() {
|
|
10
|
+
return !!process.env.AUTH_PASSWORD_HASH;
|
|
11
|
+
}
|
|
12
|
+
async function verifyConfiguredPassword(password) {
|
|
13
|
+
const storedHash = process.env.AUTH_PASSWORD_HASH;
|
|
14
|
+
if (!storedHash)
|
|
15
|
+
return false;
|
|
16
|
+
const salt = process.env.AUTH_PASSWORD_SALT ?? '';
|
|
17
|
+
return verifyPassword(password, storedHash, salt);
|
|
18
|
+
}
|
|
19
|
+
// ── Session helpers ─────────────────────────────────────────────────
|
|
20
|
+
function setSessionCookie(res, token) {
|
|
21
|
+
res.cookie(SESSION_COOKIE_NAME, token, {
|
|
22
|
+
httpOnly: true,
|
|
23
|
+
sameSite: 'strict',
|
|
24
|
+
// No `secure` flag — the daemon serves the dashboard over HTTP on
|
|
25
|
+
// loopback/LAN. Setting `secure: true` would prevent the cookie from
|
|
26
|
+
// being sent.
|
|
27
|
+
maxAge: SESSION_TTL_MS,
|
|
28
|
+
path: '/',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Roll the session: extend server-side expiry and refresh the cookie. */
|
|
32
|
+
function rollSession(token, res) {
|
|
33
|
+
extendSession(token, Date.now() + SESSION_TTL_MS);
|
|
34
|
+
setSessionCookie(res, token);
|
|
35
|
+
}
|
|
36
|
+
// Best-effort startup cleanup. Skipped when no password is configured —
|
|
37
|
+
// the dashboard is locked in that case anyway, but the import is still
|
|
38
|
+
// performed by tests, where an empty data dir is fine.
|
|
39
|
+
try {
|
|
40
|
+
cleanupExpiredSessions();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// sessions file may not exist yet on first run; cleanup is best-effort.
|
|
44
|
+
}
|
|
45
|
+
// ── Handlers ────────────────────────────────────────────────────────
|
|
46
|
+
export async function loginHandler(req, res) {
|
|
47
|
+
if (!isPasswordConfigured()) {
|
|
48
|
+
res.status(503).json({ error: NO_PASSWORD_MESSAGE });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const { password } = req.body ?? {};
|
|
52
|
+
if (typeof password !== 'string' || password.length === 0) {
|
|
53
|
+
res.status(400).json({ error: 'Password is required' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const valid = await verifyConfiguredPassword(password);
|
|
57
|
+
if (!valid) {
|
|
58
|
+
res.status(401).json({ error: 'Invalid password' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const token = randomBytes(32).toString('hex');
|
|
62
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
63
|
+
const firstForwarded = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined;
|
|
64
|
+
const ip = firstForwarded ?? req.ip;
|
|
65
|
+
createSession(token, Date.now() + SESSION_TTL_MS, ip);
|
|
66
|
+
setSessionCookie(res, token);
|
|
67
|
+
res.json({ ok: true });
|
|
68
|
+
}
|
|
69
|
+
export function logoutHandler(req, res) {
|
|
70
|
+
const token = req.cookies[SESSION_COOKIE_NAME];
|
|
71
|
+
if (token)
|
|
72
|
+
deleteSession(token);
|
|
73
|
+
res.clearCookie(SESSION_COOKIE_NAME, { path: '/' });
|
|
74
|
+
res.json({ ok: true });
|
|
75
|
+
}
|
|
76
|
+
export function checkAuthHandler(req, res) {
|
|
77
|
+
if (!isPasswordConfigured()) {
|
|
78
|
+
res.status(503).json({
|
|
79
|
+
authenticated: false,
|
|
80
|
+
error: NO_PASSWORD_MESSAGE,
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const token = req.cookies[SESSION_COOKIE_NAME];
|
|
85
|
+
if (!token) {
|
|
86
|
+
res.status(401).json({ authenticated: false });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const entry = getSession(token);
|
|
90
|
+
if (!entry || Date.now() > entry.expires_at) {
|
|
91
|
+
if (entry)
|
|
92
|
+
deleteSession(token);
|
|
93
|
+
res.status(401).json({ authenticated: false });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
rollSession(token, res);
|
|
97
|
+
res.json({ authenticated: true });
|
|
98
|
+
}
|
|
99
|
+
export async function changePasswordHandler(req, res) {
|
|
100
|
+
const { currentPassword, newPassword } = req.body ?? {};
|
|
101
|
+
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
|
102
|
+
res.status(400).json({ error: 'Both currentPassword and newPassword are required.' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (newPassword.length < 8) {
|
|
106
|
+
res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const valid = await verifyConfiguredPassword(currentPassword);
|
|
110
|
+
if (!valid) {
|
|
111
|
+
res.status(401).json({ error: 'Current password is incorrect.' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const salt = generateSalt();
|
|
115
|
+
const hash = await hashPassword(newPassword, salt);
|
|
116
|
+
updateEnvFile({ AUTH_PASSWORD_HASH: hash, AUTH_PASSWORD_SALT: salt });
|
|
117
|
+
process.env.AUTH_PASSWORD_HASH = hash;
|
|
118
|
+
process.env.AUTH_PASSWORD_SALT = salt;
|
|
119
|
+
// Invalidate every session except the caller's, so other browsers/devices
|
|
120
|
+
// are forced to re-authenticate with the new password.
|
|
121
|
+
const currentToken = req.cookies[SESSION_COOKIE_NAME];
|
|
122
|
+
deleteAllSessionsExcept(currentToken);
|
|
123
|
+
res.json({ ok: true });
|
|
124
|
+
}
|
|
125
|
+
// ── Middleware ───────────────────────────────────────────────────────
|
|
126
|
+
export function requireAuth(req, res, next) {
|
|
127
|
+
if (!isPasswordConfigured()) {
|
|
128
|
+
res.status(503).json({ error: NO_PASSWORD_MESSAGE });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const token = req.cookies[SESSION_COOKIE_NAME];
|
|
132
|
+
if (!token) {
|
|
133
|
+
res.status(401).json({ error: 'Not authenticated' });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const entry = getSession(token);
|
|
137
|
+
if (!entry || Date.now() > entry.expires_at) {
|
|
138
|
+
if (entry)
|
|
139
|
+
deleteSession(token);
|
|
140
|
+
res.status(401).json({ error: 'Session expired' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
rollSession(token, res);
|
|
144
|
+
next();
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the current .env file, update or add the specified key/value pairs,
|
|
3
|
+
* and write back with mode 0o600. Keys in `keysToRemove` are deleted entirely.
|
|
4
|
+
*/
|
|
5
|
+
export declare function updateEnvFile(updates: Record<string, string>, keysToRemove?: string[]): void;
|
|
6
|
+
//# sourceMappingURL=env-writer.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { getConfigDir, getEnvFilePath } from '../shared/config.js';
|
|
3
|
+
/** Ensure the daemon's config dir and .env file exist before reading/writing. */
|
|
4
|
+
function ensureEnvFile() {
|
|
5
|
+
mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
|
|
6
|
+
const envFile = getEnvFilePath();
|
|
7
|
+
if (!existsSync(envFile)) {
|
|
8
|
+
writeFileSync(envFile, '', { mode: 0o600 });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read the current .env file, update or add the specified key/value pairs,
|
|
13
|
+
* and write back with mode 0o600. Keys in `keysToRemove` are deleted entirely.
|
|
14
|
+
*/
|
|
15
|
+
export function updateEnvFile(updates, keysToRemove = []) {
|
|
16
|
+
ensureEnvFile();
|
|
17
|
+
const envFile = getEnvFilePath();
|
|
18
|
+
const content = readFileSync(envFile, 'utf-8');
|
|
19
|
+
const lines = content.split('\n');
|
|
20
|
+
const updatedKeys = new Set();
|
|
21
|
+
const result = [];
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
const shouldRemove = keysToRemove.some((key) => {
|
|
25
|
+
return trimmed.startsWith(`${key}=`) || trimmed === `${key}=`;
|
|
26
|
+
});
|
|
27
|
+
if (shouldRemove)
|
|
28
|
+
continue;
|
|
29
|
+
let replaced = false;
|
|
30
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
31
|
+
if (trimmed.startsWith(`${key}=`) || trimmed === `${key}=`) {
|
|
32
|
+
result.push(`${key}=${value}`);
|
|
33
|
+
updatedKeys.add(key);
|
|
34
|
+
replaced = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!replaced)
|
|
39
|
+
result.push(line);
|
|
40
|
+
}
|
|
41
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
42
|
+
if (!updatedKeys.has(key)) {
|
|
43
|
+
let insertIdx = result.length;
|
|
44
|
+
while (insertIdx > 0 && result[insertIdx - 1].trim() === '') {
|
|
45
|
+
insertIdx--;
|
|
46
|
+
}
|
|
47
|
+
result.splice(insertIdx, 0, `${key}=${value}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
writeFileSync(envFile, result.join('\n'), { mode: 0o600 });
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=env-writer.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function generateSalt(): string;
|
|
2
|
+
export declare function hashPassword(password: string, salt: string): Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* Verify a password against a stored hash and salt.
|
|
5
|
+
* Uses timing-safe comparison to prevent timing attacks.
|
|
6
|
+
*/
|
|
7
|
+
export declare function verifyPassword(password: string, storedHash: string, salt: string): Promise<boolean>;
|
|
8
|
+
//# sourceMappingURL=password.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const KEY_LENGTH = 64;
|
|
3
|
+
const SALT_LENGTH = 16;
|
|
4
|
+
export function generateSalt() {
|
|
5
|
+
return randomBytes(SALT_LENGTH).toString('hex');
|
|
6
|
+
}
|
|
7
|
+
export function hashPassword(password, salt) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
scrypt(password, salt, KEY_LENGTH, (err, derivedKey) => {
|
|
10
|
+
if (err)
|
|
11
|
+
return reject(err);
|
|
12
|
+
resolve(derivedKey.toString('hex'));
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Verify a password against a stored hash and salt.
|
|
18
|
+
* Uses timing-safe comparison to prevent timing attacks.
|
|
19
|
+
*/
|
|
20
|
+
export async function verifyPassword(password, storedHash, salt) {
|
|
21
|
+
const derivedKey = await hashPassword(password, salt);
|
|
22
|
+
const hashBuffer = Buffer.from(storedHash, 'hex');
|
|
23
|
+
const derivedBuffer = Buffer.from(derivedKey, 'hex');
|
|
24
|
+
if (hashBuffer.length !== derivedBuffer.length)
|
|
25
|
+
return false;
|
|
26
|
+
return timingSafeEqual(hashBuffer, derivedBuffer);
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=password.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface SessionData {
|
|
2
|
+
expires_at: number;
|
|
3
|
+
created_at: number;
|
|
4
|
+
ip?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function getSession(token: string): SessionData | undefined;
|
|
7
|
+
export declare function createSession(token: string, expiresAt: number, ip?: string): void;
|
|
8
|
+
export declare function extendSession(token: string, newExpiresAt: number): void;
|
|
9
|
+
export declare function deleteSession(token: string): void;
|
|
10
|
+
/** Used after change-password to log out every other session. */
|
|
11
|
+
export declare function deleteAllSessionsExcept(exceptToken?: string): void;
|
|
12
|
+
export declare function cleanupExpiredSessions(): number;
|
|
13
|
+
//# sourceMappingURL=sessions.d.ts.map
|