pinomad 0.10.0 → 0.12.0
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 +11 -9
- package/dist/chunk-41q0saz0.js +73 -0
- package/dist/chunk-42nqtx5r.js +22 -0
- package/dist/{chunk-wb10efyq.js → chunk-445cevpm.js} +1 -1
- package/dist/{chunk-jgd38rna.js → chunk-47ff1d1t.js} +206 -178
- package/dist/chunk-5qf1grkk.js +220 -0
- package/dist/{chunk-4h7pkkzr.js → chunk-7mn38n4f.js} +3 -3
- package/dist/{chunk-zehwx009.js → chunk-7wj0jh3t.js} +1 -1
- package/dist/{chunk-5791mb0b.js → chunk-agq4tn9r.js} +47 -23
- package/dist/{chunk-c5p9xkd8.js → chunk-aqj9wfmb.js} +3 -3
- package/dist/{chunk-cx8k63by.js → chunk-g3ahp7b9.js} +12 -1
- package/dist/chunk-gg2dcqbg.js +458 -0
- package/dist/{chunk-73efzaf1.js → chunk-jeqsdcj1.js} +30 -13
- package/dist/{chunk-czj9nfrz.js → chunk-ksfxqd8s.js} +500 -41
- package/dist/{chunk-p978qj9m.js → chunk-mnmqyq7d.js} +565 -39
- package/dist/{chunk-4sepcfpy.js → chunk-np4jd070.js} +155 -3
- package/dist/{chunk-v414b5pp.js → chunk-nppgxh6c.js} +2 -2
- package/dist/chunk-nq57fdnq.js +106 -0
- package/dist/{chunk-sygeb9qb.js → chunk-pqe7q1s7.js} +1 -1
- package/dist/{chunk-w2m9xkks.js → chunk-t5abqsva.js} +199 -72
- package/dist/{chunk-8gywfw5n.js → chunk-t91zt5f0.js} +2 -51
- package/dist/{chunk-vax4zspw.js → chunk-vqkb70d7.js} +3 -3
- package/dist/chunk-vx7dy07y.js +26 -0
- package/dist/pinomad.js +19 -12
- package/package.json +2 -1
- package/web/assets/astro-DOly5024.js +1 -0
- package/web/assets/c-BIGW1oBm.js +1 -0
- package/web/assets/core-DnpzcQzs.js +4 -0
- package/web/assets/cpp-DddhVWhK.js +1 -0
- package/web/assets/csharp-B_TYUkTI.js +1 -0
- package/web/assets/css-Z72CF80r.js +1 -0
- package/web/assets/dart-CtKP3W-j.js +1 -0
- package/web/assets/diff-D97Zzqfu.js +1 -0
- package/web/assets/dockerfile-BcOcwvcX.js +1 -0
- package/web/assets/elixir-DkJLSxtU.js +1 -0
- package/web/assets/engine-javascript-BHPwbUfN.js +141 -0
- package/web/assets/engine-oniguruma-CQjQPVHy.js +1 -0
- package/web/assets/fish-BvzEVeQv.js +1 -0
- package/web/assets/go-C27-OAKa.js +1 -0
- package/web/assets/graphql-ChdNCCLP.js +1 -0
- package/web/assets/haskell-Df6bDoY_.js +1 -0
- package/web/assets/hcl-BWvSN4gD.js +1 -0
- package/web/assets/highlighted-diff-Dn5ifsvK.js +175 -0
- package/web/assets/highlights-DnHv6NBM.js +4 -0
- package/web/assets/highlights-themes-CUdrlZk3.js +1 -0
- package/web/assets/html-BlS82ilI.js +1 -0
- package/web/assets/{index-DoyHMPwf.css → index-BV0ecolo.css} +1 -1
- package/web/assets/index-DNsTTgPE.js +396 -0
- package/web/assets/ini-BEwlwnbL.js +1 -0
- package/web/assets/java-CylS5w8V.js +1 -0
- package/web/assets/javascript-wDzz0qaB.js +1 -0
- package/web/assets/json-Cp-IABpG.js +1 -0
- package/web/assets/jsonc-Des-eS-w.js +1 -0
- package/web/assets/jsx-g9-lgVsj.js +1 -0
- package/web/assets/kotlin-BdnUsdx6.js +1 -0
- package/web/assets/less-B1dDrJ26.js +1 -0
- package/web/assets/lua-BaeVxFsk.js +1 -0
- package/web/assets/make-CHLpvVh8.js +1 -0
- package/web/assets/markdown-Cvjx9yec.js +1 -0
- package/web/assets/nix-CwoSXNpI.js +1 -0
- package/web/assets/objective-c-DXmwc3jG.js +1 -0
- package/web/assets/php-DoruYMF0.js +1 -0
- package/web/assets/pierre-dark-CyvmCCZW.js +1 -0
- package/web/assets/pierre-dark-VKo95ZgA.js +1 -0
- package/web/assets/pierre-light-480U9XYS.js +1 -0
- package/web/assets/pierre-light-BVSW-Xev.js +1 -0
- package/web/assets/postcss-DnzrXTVv.js +1 -0
- package/web/assets/powershell-Dd0kTxKP.js +1 -0
- package/web/assets/prisma-Vru482bI.js +1 -0
- package/web/assets/proto-C7zT0LnQ.js +1 -0
- package/web/assets/python-B6aJPvgy.js +1 -0
- package/web/assets/ruby-B43yHlf9.js +1 -0
- package/web/assets/rust-DPvrY3IQ.js +1 -0
- package/web/assets/scala-CqE71os6.js +1 -0
- package/web/assets/scss-Cw6XMfVs.js +1 -0
- package/web/assets/shellscript-Yzrsuije.js +1 -0
- package/web/assets/shiki-BuPP5HCg.js +2 -0
- package/web/assets/shiki-langs-FJk0v0Ga.js +2 -0
- package/web/assets/shiki-wasm-CgFRq91U.js +1 -0
- package/web/assets/sql-DkvoVjww.js +1 -0
- package/web/assets/stream-tokenizer-Bl6bisIa.js +4 -0
- package/web/assets/svelte-DoIXUgJc.js +1 -0
- package/web/assets/swift-C2oV4EkX.js +1 -0
- package/web/assets/terraform-BETggiCN.js +1 -0
- package/web/assets/theming-themes-BL_CC-Mm.js +1 -0
- package/web/assets/toml-vGWfd6FD.js +1 -0
- package/web/assets/tsx-COt5Ahok.js +1 -0
- package/web/assets/typescript-BPQ3VLAy.js +1 -0
- package/web/assets/vue-CwSeJimo.js +1 -0
- package/web/assets/xml-sdJ4AIDG.js +1 -0
- package/web/assets/xterm-view-6GBZ9nXN.css +32 -0
- package/web/assets/xterm-view-ZsuzJb2C.js +38 -0
- package/web/assets/yaml-Buea-lGh.js +1 -0
- package/web/assets/zig-VOosw3JB.js +1 -0
- package/web/index.html +2 -2
- package/dist/chunk-bhtx2c8a.js +0 -16
- package/dist/chunk-c05g401k.js +0 -170
- package/web/assets/index-BdBjP1qJ.js +0 -395
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
serveWebClient
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-445cevpm.js";
|
|
5
5
|
import {
|
|
6
6
|
RELAY_CLOSE,
|
|
7
7
|
decodeHostToRelay,
|
|
@@ -9,14 +9,14 @@ import {
|
|
|
9
9
|
relayWsBase,
|
|
10
10
|
verifyRelayChallenge,
|
|
11
11
|
verifyRelayChallengeV2
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-g3ahp7b9.js";
|
|
13
|
+
import {
|
|
14
|
+
normalizeHostName
|
|
15
|
+
} from "./chunk-pqe7q1s7.js";
|
|
13
16
|
import {
|
|
14
17
|
commandHint,
|
|
15
18
|
webRoot
|
|
16
|
-
} from "./chunk-
|
|
17
|
-
import {
|
|
18
|
-
normalizeHostName
|
|
19
|
-
} from "./chunk-sygeb9qb.js";
|
|
19
|
+
} from "./chunk-7wj0jh3t.js";
|
|
20
20
|
import"./chunk-df32avw6.js";
|
|
21
21
|
|
|
22
22
|
// apps/server/src/main.ts
|
|
@@ -25,7 +25,7 @@ import { join as join2, resolve } from "node:path";
|
|
|
25
25
|
import { parseArgs } from "node:util";
|
|
26
26
|
|
|
27
27
|
// apps/server/src/accounts.ts
|
|
28
|
-
import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
28
|
+
import { createHash, randomBytes, randomInt, scrypt, timingSafeEqual } from "node:crypto";
|
|
29
29
|
import { mkdir } from "node:fs/promises";
|
|
30
30
|
import { join } from "node:path";
|
|
31
31
|
import { DatabaseSync } from "node:sqlite";
|
|
@@ -58,9 +58,27 @@ async function lockDataDir(dir, options) {
|
|
|
58
58
|
|
|
59
59
|
// apps/server/src/accounts.ts
|
|
60
60
|
var DB_FILE = "server.sqlite";
|
|
61
|
-
var SCHEMA_VERSION =
|
|
61
|
+
var SCHEMA_VERSION = 8;
|
|
62
62
|
var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
63
63
|
var JOIN_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
64
|
+
var USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
|
|
65
|
+
var USER_CODE_LENGTH = 8;
|
|
66
|
+
var HOST_LINK_TTL_MS = JOIN_TOKEN_TTL_MS;
|
|
67
|
+
var HOST_LINK_INTERVAL_S = 5;
|
|
68
|
+
var HOST_LINK_CODE_TTL_MS = 2 * 60000;
|
|
69
|
+
var SLOW_DOWN_STEP_S = 5;
|
|
70
|
+
var POLL_SLACK_MS = 1000;
|
|
71
|
+
var HOST_OS_MAX_CODEPOINTS = 64;
|
|
72
|
+
function normalizeUserCode(input) {
|
|
73
|
+
const code = input.replace(/[\s-]/g, "").toUpperCase();
|
|
74
|
+
if (code.length !== USER_CODE_LENGTH)
|
|
75
|
+
return;
|
|
76
|
+
for (const letter of code)
|
|
77
|
+
if (!USER_CODE_ALPHABET.includes(letter))
|
|
78
|
+
return;
|
|
79
|
+
return code;
|
|
80
|
+
}
|
|
81
|
+
var formatUserCode = (code) => `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
64
82
|
var INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
65
83
|
var SIGN_UP_USERNAME = /^[a-z0-9][a-z0-9._-]{1,31}$/;
|
|
66
84
|
var PUSH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
@@ -97,6 +115,11 @@ var MAX_USERNAME_CODEPOINTS = 254;
|
|
|
97
115
|
var scryptAsync = promisify(scrypt);
|
|
98
116
|
var DUMMY_HASH = `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${"A".repeat(22)}$${"A".repeat(43)}`;
|
|
99
117
|
var sha256Hex = (input) => createHash("sha256").update(input, "utf8").digest("hex");
|
|
118
|
+
var safeEqual = (a, b) => {
|
|
119
|
+
const left = Buffer.from(a);
|
|
120
|
+
const right = Buffer.from(b);
|
|
121
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
122
|
+
};
|
|
100
123
|
async function hashPassword(password) {
|
|
101
124
|
const salt = randomBytes(SALT_LEN);
|
|
102
125
|
const hash = await scryptAsync(password, salt, KEY_LEN, SCRYPT);
|
|
@@ -190,6 +213,64 @@ function migrate(db) {
|
|
|
190
213
|
}
|
|
191
214
|
if (version < 4)
|
|
192
215
|
migrateTo4(db);
|
|
216
|
+
if (version < 5) {
|
|
217
|
+
db.exec(`
|
|
218
|
+
BEGIN;
|
|
219
|
+
ALTER TABLE push_tokens ADD COLUMN device_id INTEGER REFERENCES account_devices(id) ON DELETE SET NULL;
|
|
220
|
+
CREATE INDEX push_tokens_device ON push_tokens (device_id);
|
|
221
|
+
PRAGMA user_version = 5;
|
|
222
|
+
COMMIT;
|
|
223
|
+
`);
|
|
224
|
+
}
|
|
225
|
+
if (version < 6) {
|
|
226
|
+
db.exec(`
|
|
227
|
+
BEGIN;
|
|
228
|
+
CREATE TABLE host_link_requests (
|
|
229
|
+
device_code_hash TEXT PRIMARY KEY,
|
|
230
|
+
user_code_hash TEXT NOT NULL UNIQUE,
|
|
231
|
+
host_id TEXT NOT NULL,
|
|
232
|
+
host_name TEXT,
|
|
233
|
+
host_os TEXT,
|
|
234
|
+
source_ip TEXT,
|
|
235
|
+
state TEXT NOT NULL DEFAULT 'pending',
|
|
236
|
+
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
237
|
+
interval_s INTEGER NOT NULL,
|
|
238
|
+
last_poll_at INTEGER,
|
|
239
|
+
created_at INTEGER NOT NULL,
|
|
240
|
+
expires_at INTEGER NOT NULL
|
|
241
|
+
);
|
|
242
|
+
CREATE INDEX host_link_requests_expiry ON host_link_requests (expires_at);
|
|
243
|
+
ALTER TABLE join_tokens ADD COLUMN host_id TEXT;
|
|
244
|
+
PRAGMA user_version = 6;
|
|
245
|
+
COMMIT;
|
|
246
|
+
`);
|
|
247
|
+
}
|
|
248
|
+
if (version < 7) {
|
|
249
|
+
db.exec(`
|
|
250
|
+
BEGIN;
|
|
251
|
+
CREATE TABLE removed_hosts (
|
|
252
|
+
host_id TEXT PRIMARY KEY,
|
|
253
|
+
removed_at INTEGER NOT NULL
|
|
254
|
+
);
|
|
255
|
+
PRAGMA user_version = 7;
|
|
256
|
+
COMMIT;
|
|
257
|
+
`);
|
|
258
|
+
}
|
|
259
|
+
if (version < 8) {
|
|
260
|
+
db.exec(`
|
|
261
|
+
BEGIN;
|
|
262
|
+
ALTER TABLE host_link_requests ADD COLUMN request_id_hash TEXT;
|
|
263
|
+
ALTER TABLE host_link_requests ADD COLUMN redirect_uri TEXT;
|
|
264
|
+
ALTER TABLE host_link_requests ADD COLUMN code_challenge TEXT;
|
|
265
|
+
ALTER TABLE host_link_requests ADD COLUMN client_state TEXT;
|
|
266
|
+
ALTER TABLE host_link_requests ADD COLUMN auth_code_hash TEXT;
|
|
267
|
+
ALTER TABLE host_link_requests ADD COLUMN auth_code_expires_at INTEGER;
|
|
268
|
+
CREATE UNIQUE INDEX host_link_requests_request_id ON host_link_requests (request_id_hash);
|
|
269
|
+
CREATE UNIQUE INDEX host_link_requests_auth_code ON host_link_requests (auth_code_hash);
|
|
270
|
+
PRAGMA user_version = 8;
|
|
271
|
+
COMMIT;
|
|
272
|
+
`);
|
|
273
|
+
}
|
|
193
274
|
}
|
|
194
275
|
function migrateTo4(db) {
|
|
195
276
|
db.exec("BEGIN");
|
|
@@ -280,6 +361,12 @@ function migrateTo1(db) {
|
|
|
280
361
|
COMMIT;
|
|
281
362
|
`);
|
|
282
363
|
}
|
|
364
|
+
var loopbackRedirect = (redirectUri, params) => {
|
|
365
|
+
const url = new URL(redirectUri);
|
|
366
|
+
for (const [key, value] of Object.entries(params))
|
|
367
|
+
url.searchParams.set(key, value);
|
|
368
|
+
return url.toString();
|
|
369
|
+
};
|
|
283
370
|
|
|
284
371
|
class WindowedCounter {
|
|
285
372
|
map = new Map;
|
|
@@ -386,6 +473,73 @@ async function openAccounts(options) {
|
|
|
386
473
|
const gate = new ScryptGate(limits.scryptConcurrency, limits.scryptQueue);
|
|
387
474
|
const ipAttempts = new WindowedCounter(limits.counterMaxEntries, now);
|
|
388
475
|
const userFailures = new WindowedCounter(limits.counterMaxEntries, now);
|
|
476
|
+
const linkLimits = {
|
|
477
|
+
startMaxPerIp: options.hostLinkLimits?.startMaxPerIp ?? 10,
|
|
478
|
+
startWindowMs: options.hostLinkLimits?.startWindowMs ?? 10 * 60000,
|
|
479
|
+
maxPending: options.hostLinkLimits?.maxPending ?? 1000,
|
|
480
|
+
userMaxFailures: options.hostLinkLimits?.userMaxFailures ?? 10,
|
|
481
|
+
ipMaxFailures: options.hostLinkLimits?.ipMaxFailures ?? 30,
|
|
482
|
+
failureWindowMs: options.hostLinkLimits?.failureWindowMs ?? 15 * 60000,
|
|
483
|
+
counterMaxEntries: options.hostLinkLimits?.counterMaxEntries ?? 4096
|
|
484
|
+
};
|
|
485
|
+
const linkStarts = new WindowedCounter(linkLimits.counterMaxEntries, now);
|
|
486
|
+
const linkUserFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
|
|
487
|
+
const linkIpFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
|
|
488
|
+
const PENDING_SELECT = `SELECT host_id AS hostId, host_name AS name, host_os AS os, source_ip AS sourceIp, expires_at AS expiresAt,
|
|
489
|
+
redirect_uri AS redirectUri, client_state AS clientState FROM host_link_requests`;
|
|
490
|
+
const sweepExpired = () => {
|
|
491
|
+
const at = now();
|
|
492
|
+
db.prepare("DELETE FROM join_tokens WHERE expires_at <= ?").run(at);
|
|
493
|
+
db.prepare("DELETE FROM host_link_requests WHERE expires_at <= ?").run(at);
|
|
494
|
+
};
|
|
495
|
+
const mintJoinToken = (userId, hostId) => {
|
|
496
|
+
const token = randomBytes(32).toString("base64url");
|
|
497
|
+
const expiresAt = now() + JOIN_TOKEN_TTL_MS;
|
|
498
|
+
db.prepare("INSERT INTO join_tokens (token_hash, user_id, created_at, expires_at, host_id) VALUES (?, ?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt, hostId);
|
|
499
|
+
return { token, expiresAt };
|
|
500
|
+
};
|
|
501
|
+
const pendingByRef = (userId, ref, ip) => {
|
|
502
|
+
if ("requestId" in ref) {
|
|
503
|
+
const hash = sha256Hex(ref.requestId);
|
|
504
|
+
const row2 = db.prepare(`${PENDING_SELECT} WHERE request_id_hash = ? AND state = 'pending' AND expires_at > ?`).get(hash, now());
|
|
505
|
+
return row2 === undefined ? "not_found" : { column: "request_id_hash", hash, row: row2 };
|
|
506
|
+
}
|
|
507
|
+
const { userCode } = ref;
|
|
508
|
+
const userKey = String(userId);
|
|
509
|
+
if (linkUserFailures.isOver(userKey, linkLimits.userMaxFailures) || linkIpFailures.isOver(ip, linkLimits.ipMaxFailures)) {
|
|
510
|
+
return "limited";
|
|
511
|
+
}
|
|
512
|
+
const code = normalizeUserCode(userCode);
|
|
513
|
+
const codeHash = code === undefined ? undefined : sha256Hex(code);
|
|
514
|
+
const row = codeHash === undefined ? undefined : db.prepare(`${PENDING_SELECT} WHERE user_code_hash = ? AND state = 'pending' AND expires_at > ?`).get(codeHash, now());
|
|
515
|
+
if (codeHash === undefined || row === undefined) {
|
|
516
|
+
linkUserFailures.bump(userKey, linkLimits.failureWindowMs);
|
|
517
|
+
linkIpFailures.bump(ip, linkLimits.failureWindowMs);
|
|
518
|
+
return "not_found";
|
|
519
|
+
}
|
|
520
|
+
return { column: "user_code_hash", hash: codeHash, row };
|
|
521
|
+
};
|
|
522
|
+
const settleHostLink = (args, state) => {
|
|
523
|
+
const found = pendingByRef(args.userId, args, args.ip);
|
|
524
|
+
if (found === "limited" || found === "not_found")
|
|
525
|
+
return { status: found };
|
|
526
|
+
const { column, hash, row } = found;
|
|
527
|
+
const at = now();
|
|
528
|
+
const loopback = row.redirectUri === null ? undefined : { redirectUri: row.redirectUri, state: row.clientState ?? "" };
|
|
529
|
+
if (loopback !== undefined && state === "denied") {
|
|
530
|
+
const dropped = db.prepare(`DELETE FROM host_link_requests WHERE ${column} = ? AND state = 'pending' AND expires_at > ?`).run(hash, at);
|
|
531
|
+
if (Number(dropped.changes) !== 1)
|
|
532
|
+
return { status: "not_found" };
|
|
533
|
+
return { status: "ok", redirect: loopbackRedirect(loopback.redirectUri, { error: "access_denied", state: loopback.state }) };
|
|
534
|
+
}
|
|
535
|
+
const code = loopback === undefined ? undefined : randomBytes(32).toString("base64url");
|
|
536
|
+
const settled = db.prepare(`UPDATE host_link_requests SET state = ?, user_id = ?, host_name = NULL, host_os = NULL, source_ip = NULL,
|
|
537
|
+
auth_code_hash = ?, auth_code_expires_at = ?
|
|
538
|
+
WHERE ${column} = ? AND state = 'pending' AND expires_at > ?`).run(state, state === "confirmed" ? args.userId : null, code === undefined ? null : sha256Hex(code), code === undefined ? null : at + HOST_LINK_CODE_TTL_MS, hash, at);
|
|
539
|
+
if (Number(settled.changes) !== 1)
|
|
540
|
+
return { status: "not_found" };
|
|
541
|
+
return loopback === undefined || code === undefined ? { status: "ok" } : { status: "ok", redirect: loopbackRedirect(loopback.redirectUri, { code, state: loopback.state }) };
|
|
542
|
+
};
|
|
389
543
|
const deviceForSignIn = (userId, claim) => {
|
|
390
544
|
const at = now();
|
|
391
545
|
if (claim === undefined) {
|
|
@@ -411,6 +565,19 @@ async function openAccounts(options) {
|
|
|
411
565
|
const sweepVouchings = () => {
|
|
412
566
|
db.prepare("DELETE FROM vouchings WHERE public_key IS NULL AND expires_at <= ?").run(now() - ADMISSION_REPORT_GRACE_MS);
|
|
413
567
|
};
|
|
568
|
+
const withdrawDevice = (deviceId) => {
|
|
569
|
+
const at = now();
|
|
570
|
+
sweepVouchings();
|
|
571
|
+
db.prepare("DELETE FROM sessions WHERE device_id = ?").run(deviceId);
|
|
572
|
+
db.prepare("DELETE FROM push_tokens WHERE device_id = ?").run(deviceId);
|
|
573
|
+
db.prepare("UPDATE account_devices SET kicked_at = ? WHERE id = ?").run(at, deviceId);
|
|
574
|
+
const hostIds = db.prepare("SELECT DISTINCT host_id AS hostId FROM vouchings WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL").all(deviceId).map((row) => row.hostId);
|
|
575
|
+
db.prepare(`INSERT OR IGNORE INTO pending_revocations (host_id, public_key, created_at)
|
|
576
|
+
SELECT host_id, public_key, ? FROM vouchings
|
|
577
|
+
WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL`).run(at, deviceId);
|
|
578
|
+
db.prepare("UPDATE vouchings SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL").run(at, deviceId);
|
|
579
|
+
return hostIds;
|
|
580
|
+
};
|
|
414
581
|
const currentHash = (userId) => db.prepare("SELECT password_hash AS passwordHash FROM users WHERE id = ?").get(userId)?.passwordHash;
|
|
415
582
|
const checkPassword = async (userId, username, password) => {
|
|
416
583
|
if (userFailures.isOver(username, limits.userMaxFailures))
|
|
@@ -549,8 +716,23 @@ async function openAccounts(options) {
|
|
|
549
716
|
...row.deviceId === null ? {} : { deviceId: row.deviceId }
|
|
550
717
|
};
|
|
551
718
|
},
|
|
552
|
-
logout(token) {
|
|
553
|
-
|
|
719
|
+
logout(token, options2) {
|
|
720
|
+
const tokenHash = sha256Hex(token);
|
|
721
|
+
if (options2?.revoke !== true) {
|
|
722
|
+
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
|
|
723
|
+
return [];
|
|
724
|
+
}
|
|
725
|
+
db.exec("BEGIN IMMEDIATE");
|
|
726
|
+
try {
|
|
727
|
+
const session = db.prepare("SELECT device_id AS deviceId FROM sessions WHERE token_hash = ?").get(tokenHash);
|
|
728
|
+
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
|
|
729
|
+
const hostIds = session?.deviceId == null ? [] : withdrawDevice(session.deviceId);
|
|
730
|
+
db.exec("COMMIT");
|
|
731
|
+
return hostIds;
|
|
732
|
+
} catch (error) {
|
|
733
|
+
db.exec("ROLLBACK");
|
|
734
|
+
throw error;
|
|
735
|
+
}
|
|
554
736
|
},
|
|
555
737
|
async changePassword({ userId, username, keepToken, currentPassword, newPassword }) {
|
|
556
738
|
const length = [...newPassword].length;
|
|
@@ -622,13 +804,14 @@ async function openAccounts(options) {
|
|
|
622
804
|
const row = db.prepare("SELECT user_id AS userId FROM hosts WHERE host_id = ?").get(hostId);
|
|
623
805
|
return row?.userId;
|
|
624
806
|
},
|
|
625
|
-
registerPushToken(userId, token, environment) {
|
|
807
|
+
registerPushToken(userId, token, environment, deviceId) {
|
|
626
808
|
const at = now();
|
|
627
809
|
db.exec("BEGIN");
|
|
628
810
|
try {
|
|
629
811
|
db.prepare("DELETE FROM push_tokens WHERE updated_at < ?").run(at - PUSH_TOKEN_TTL_MS);
|
|
630
|
-
db.prepare(`INSERT INTO push_tokens (token, environment, user_id, updated_at) VALUES (?, ?, ?, ?)
|
|
631
|
-
ON CONFLICT (token, environment) DO UPDATE SET
|
|
812
|
+
db.prepare(`INSERT INTO push_tokens (token, environment, user_id, updated_at, device_id) VALUES (?, ?, ?, ?, ?)
|
|
813
|
+
ON CONFLICT (token, environment) DO UPDATE SET
|
|
814
|
+
user_id = excluded.user_id, updated_at = excluded.updated_at, device_id = excluded.device_id`).run(token.toLowerCase(), environment, userId, at, deviceId ?? null);
|
|
632
815
|
db.prepare(`DELETE FROM push_tokens WHERE user_id = ? AND rowid NOT IN (
|
|
633
816
|
SELECT rowid FROM push_tokens WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?
|
|
634
817
|
)`).run(userId, userId, MAX_PUSH_TOKENS_PER_USER);
|
|
@@ -649,22 +832,125 @@ async function openAccounts(options) {
|
|
|
649
832
|
db.prepare("DELETE FROM push_tokens WHERE token = ? AND environment = ? AND updated_at < ?").run(token.toLowerCase(), environment, unregisteredAt);
|
|
650
833
|
},
|
|
651
834
|
createJoinToken(userId) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const expiresAt = now() + JOIN_TOKEN_TTL_MS;
|
|
655
|
-
db.prepare("INSERT INTO join_tokens (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt);
|
|
656
|
-
return { token, expiresAt };
|
|
835
|
+
sweepExpired();
|
|
836
|
+
return mintJoinToken(userId, null);
|
|
657
837
|
},
|
|
838
|
+
startHostLink({ hostId, name, os, ip, loopback }) {
|
|
839
|
+
if (linkStarts.hit(ip, linkLimits.startMaxPerIp, linkLimits.startWindowMs))
|
|
840
|
+
return { status: "limited" };
|
|
841
|
+
sweepExpired();
|
|
842
|
+
const live = db.prepare("SELECT COUNT(*) AS n FROM host_link_requests").get().n;
|
|
843
|
+
if (live >= linkLimits.maxPending)
|
|
844
|
+
return { status: "busy" };
|
|
845
|
+
const deviceCode = randomBytes(32).toString("base64url");
|
|
846
|
+
const at = now();
|
|
847
|
+
const expiresAt = at + HOST_LINK_TTL_MS;
|
|
848
|
+
const hostName = normalizeHostName(name);
|
|
849
|
+
const hostOs = [...os.trim()].slice(0, HOST_OS_MAX_CODEPOINTS).join("");
|
|
850
|
+
if (loopback !== undefined) {
|
|
851
|
+
const requestId = randomBytes(32).toString("base64url");
|
|
852
|
+
db.prepare(`INSERT INTO host_link_requests
|
|
853
|
+
(device_code_hash, user_code_hash, request_id_hash, redirect_uri, code_challenge, client_state,
|
|
854
|
+
host_id, host_name, host_os, source_ip, interval_s, created_at, expires_at)
|
|
855
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sha256Hex(randomBytes(32).toString("base64url")), sha256Hex(randomBytes(32).toString("base64url")), sha256Hex(requestId), loopback.redirectUri, loopback.codeChallenge, loopback.state, hostId, hostName, hostOs, ip, HOST_LINK_INTERVAL_S, at, expiresAt);
|
|
856
|
+
return { status: "ok", mode: "loopback", requestId, expiresAt };
|
|
857
|
+
}
|
|
858
|
+
const insert = db.prepare(`INSERT INTO host_link_requests
|
|
859
|
+
(device_code_hash, user_code_hash, host_id, host_name, host_os, source_ip, interval_s, created_at, expires_at)
|
|
860
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
861
|
+
for (let attempt = 0;; attempt++) {
|
|
862
|
+
const code = Array.from({ length: USER_CODE_LENGTH }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
|
|
863
|
+
try {
|
|
864
|
+
insert.run(sha256Hex(deviceCode), sha256Hex(code), hostId, hostName, hostOs, ip, HOST_LINK_INTERVAL_S, at, expiresAt);
|
|
865
|
+
return { status: "ok", mode: "code", deviceCode, userCode: formatUserCode(code), expiresAt, interval: HOST_LINK_INTERVAL_S };
|
|
866
|
+
} catch (error) {
|
|
867
|
+
if (attempt >= 4 || !String(error).includes("UNIQUE"))
|
|
868
|
+
throw error;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
pollHostLink(deviceCode) {
|
|
873
|
+
const deviceCodeHash = sha256Hex(deviceCode);
|
|
874
|
+
const at = now();
|
|
875
|
+
db.exec("BEGIN IMMEDIATE");
|
|
876
|
+
try {
|
|
877
|
+
const row = db.prepare(`SELECT host_id AS hostId, state, user_id AS userId, interval_s AS intervalS, last_poll_at AS lastPollAt, expires_at AS expiresAt
|
|
878
|
+
FROM host_link_requests WHERE device_code_hash = ?`).get(deviceCodeHash);
|
|
879
|
+
let result;
|
|
880
|
+
if (row === undefined || row.expiresAt <= at) {
|
|
881
|
+
db.prepare("DELETE FROM host_link_requests WHERE device_code_hash = ?").run(deviceCodeHash);
|
|
882
|
+
result = { status: "expired" };
|
|
883
|
+
} else if (row.lastPollAt !== null && at - row.lastPollAt < row.intervalS * 1000 - POLL_SLACK_MS) {
|
|
884
|
+
const interval = row.intervalS + SLOW_DOWN_STEP_S;
|
|
885
|
+
db.prepare("UPDATE host_link_requests SET interval_s = ?, last_poll_at = ? WHERE device_code_hash = ?").run(interval, at, deviceCodeHash);
|
|
886
|
+
result = { status: "slow_down", interval };
|
|
887
|
+
} else if (row.state === "pending") {
|
|
888
|
+
db.prepare("UPDATE host_link_requests SET last_poll_at = ? WHERE device_code_hash = ?").run(at, deviceCodeHash);
|
|
889
|
+
result = { status: "pending" };
|
|
890
|
+
} else {
|
|
891
|
+
db.prepare("DELETE FROM host_link_requests WHERE device_code_hash = ?").run(deviceCodeHash);
|
|
892
|
+
result = row.state === "confirmed" && row.userId !== null ? { status: "confirmed", ...mintJoinToken(row.userId, row.hostId) } : { status: "denied" };
|
|
893
|
+
}
|
|
894
|
+
db.exec("COMMIT");
|
|
895
|
+
return result;
|
|
896
|
+
} catch (error) {
|
|
897
|
+
db.exec("ROLLBACK");
|
|
898
|
+
throw error;
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
lookupHostLink(args) {
|
|
902
|
+
const { userId, ip } = args;
|
|
903
|
+
const found = pendingByRef(userId, args, ip);
|
|
904
|
+
if (found === "limited" || found === "not_found")
|
|
905
|
+
return { status: found };
|
|
906
|
+
const { row } = found;
|
|
907
|
+
return {
|
|
908
|
+
status: "ok",
|
|
909
|
+
request: {
|
|
910
|
+
hostId: row.hostId,
|
|
911
|
+
name: row.name ?? "",
|
|
912
|
+
os: row.os ?? "",
|
|
913
|
+
sourceIp: row.sourceIp ?? "",
|
|
914
|
+
ipDiffers: row.sourceIp !== ip,
|
|
915
|
+
expiresAt: row.expiresAt
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
},
|
|
919
|
+
confirmHostLink(args) {
|
|
920
|
+
return settleHostLink(args, "confirmed");
|
|
921
|
+
},
|
|
922
|
+
denyHostLink(args) {
|
|
923
|
+
return settleHostLink(args, "denied");
|
|
924
|
+
},
|
|
925
|
+
exchangeHostLinkCode(code, codeVerifier) {
|
|
926
|
+
const codeHash = sha256Hex(code);
|
|
927
|
+
const at = now();
|
|
928
|
+
db.exec("BEGIN IMMEDIATE");
|
|
929
|
+
try {
|
|
930
|
+
const row = db.prepare(`SELECT host_id AS hostId, user_id AS userId, code_challenge AS challenge, auth_code_expires_at AS codeExpiresAt
|
|
931
|
+
FROM host_link_requests WHERE auth_code_hash = ? AND state = 'confirmed'`).get(codeHash);
|
|
932
|
+
if (row !== undefined)
|
|
933
|
+
db.prepare("DELETE FROM host_link_requests WHERE auth_code_hash = ?").run(codeHash);
|
|
934
|
+
const valid = row !== undefined && row.userId !== null && row.challenge !== null && row.codeExpiresAt !== null && row.codeExpiresAt > at && safeEqual(createHash("sha256").update(codeVerifier).digest("base64url"), row.challenge);
|
|
935
|
+
const result = valid ? { status: "ok", ...mintJoinToken(row.userId, row.hostId) } : { status: "invalid" };
|
|
936
|
+
db.exec("COMMIT");
|
|
937
|
+
return result;
|
|
938
|
+
} catch (error) {
|
|
939
|
+
db.exec("ROLLBACK");
|
|
940
|
+
throw error;
|
|
941
|
+
}
|
|
942
|
+
},
|
|
943
|
+
sweepExpired,
|
|
658
944
|
joinHost({ token, hostId, noisePublicKey, name }) {
|
|
659
945
|
const tokenHash = sha256Hex(token);
|
|
660
946
|
const at = now();
|
|
661
947
|
db.exec("BEGIN");
|
|
662
948
|
try {
|
|
663
|
-
const row = db.prepare("SELECT user_id AS userId, expires_at AS expiresAt, used_at AS usedAt FROM join_tokens WHERE token_hash = ?").get(tokenHash);
|
|
949
|
+
const row = db.prepare("SELECT user_id AS userId, expires_at AS expiresAt, used_at AS usedAt, host_id AS boundHostId FROM join_tokens WHERE token_hash = ?").get(tokenHash);
|
|
664
950
|
let failure;
|
|
665
|
-
if (row === undefined || row.usedAt !== null)
|
|
951
|
+
if (row === undefined || row.usedAt !== null || row.boundHostId !== null && row.boundHostId !== hostId) {
|
|
666
952
|
failure = "invalid_token";
|
|
667
|
-
else if (row.expiresAt <= at)
|
|
953
|
+
} else if (row.expiresAt <= at)
|
|
668
954
|
failure = "expired_token";
|
|
669
955
|
else {
|
|
670
956
|
const consumed = db.prepare("UPDATE join_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL").run(at, tokenHash);
|
|
@@ -689,6 +975,7 @@ async function openAccounts(options) {
|
|
|
689
975
|
name = excluded.name,
|
|
690
976
|
joined_at = excluded.joined_at,
|
|
691
977
|
last_seen_at = excluded.last_seen_at`).run(hostId, row.userId, noisePublicKey, hostName, at, at);
|
|
978
|
+
db.prepare("DELETE FROM removed_hosts WHERE host_id = ?").run(hostId);
|
|
692
979
|
const user = db.prepare("SELECT username FROM users WHERE id = ?").get(row.userId);
|
|
693
980
|
db.exec("COMMIT");
|
|
694
981
|
return { status: "joined", username: user?.username ?? "unknown" };
|
|
@@ -700,6 +987,37 @@ async function openAccounts(options) {
|
|
|
700
987
|
leaveHost(hostId) {
|
|
701
988
|
return Number(db.prepare("DELETE FROM hosts WHERE host_id = ?").run(hostId).changes) > 0;
|
|
702
989
|
},
|
|
990
|
+
async removeHost({ userId, username, password, hostId }) {
|
|
991
|
+
const owned = () => db.prepare("SELECT 1 AS x FROM hosts WHERE host_id = ? AND user_id = ?").get(hostId, userId) !== undefined;
|
|
992
|
+
if (!owned())
|
|
993
|
+
return { status: "refused", reason: "not_found" };
|
|
994
|
+
const checked = await checkPassword(userId, username, password);
|
|
995
|
+
if (checked === "limited")
|
|
996
|
+
return { status: "limited" };
|
|
997
|
+
if (checked === "wrong_password")
|
|
998
|
+
return { status: "refused", reason: "wrong_password" };
|
|
999
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1000
|
+
try {
|
|
1001
|
+
if (currentHash(userId) !== checked.verifiedHash) {
|
|
1002
|
+
db.exec("ROLLBACK");
|
|
1003
|
+
return { status: "refused", reason: "wrong_password" };
|
|
1004
|
+
}
|
|
1005
|
+
if (!owned()) {
|
|
1006
|
+
db.exec("ROLLBACK");
|
|
1007
|
+
return { status: "refused", reason: "not_found" };
|
|
1008
|
+
}
|
|
1009
|
+
db.prepare("DELETE FROM hosts WHERE host_id = ?").run(hostId);
|
|
1010
|
+
db.prepare("INSERT INTO removed_hosts (host_id, removed_at) VALUES (?, ?) ON CONFLICT(host_id) DO UPDATE SET removed_at = excluded.removed_at").run(hostId, now());
|
|
1011
|
+
db.exec("COMMIT");
|
|
1012
|
+
return { status: "ok" };
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
db.exec("ROLLBACK");
|
|
1015
|
+
throw error;
|
|
1016
|
+
}
|
|
1017
|
+
},
|
|
1018
|
+
wasRemoved(hostId) {
|
|
1019
|
+
return db.prepare("SELECT 1 AS x FROM removed_hosts WHERE host_id = ?").get(hostId) !== undefined;
|
|
1020
|
+
},
|
|
703
1021
|
listHosts(userId) {
|
|
704
1022
|
return db.prepare(`SELECT host_id AS hostId, user_id AS userId, noise_public_key AS noisePublicKey,
|
|
705
1023
|
name, joined_at AS joinedAt, last_seen_at AS lastSeenAt, device_reports AS deviceReports
|
|
@@ -800,15 +1118,7 @@ async function openAccounts(options) {
|
|
|
800
1118
|
db.exec("ROLLBACK");
|
|
801
1119
|
return { status: "refused", reason: "not_found" };
|
|
802
1120
|
}
|
|
803
|
-
const
|
|
804
|
-
sweepVouchings();
|
|
805
|
-
db.prepare("DELETE FROM sessions WHERE device_id = ?").run(deviceId);
|
|
806
|
-
db.prepare("UPDATE account_devices SET kicked_at = ? WHERE id = ?").run(at, deviceId);
|
|
807
|
-
const hostIds = db.prepare("SELECT DISTINCT host_id AS hostId FROM vouchings WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL").all(deviceId).map((row) => row.hostId);
|
|
808
|
-
db.prepare(`INSERT OR IGNORE INTO pending_revocations (host_id, public_key, created_at)
|
|
809
|
-
SELECT host_id, public_key, ? FROM vouchings
|
|
810
|
-
WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL`).run(at, deviceId);
|
|
811
|
-
db.prepare("UPDATE vouchings SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL").run(at, deviceId);
|
|
1121
|
+
const hostIds = withdrawDevice(deviceId);
|
|
812
1122
|
db.exec("COMMIT");
|
|
813
1123
|
return { status: "ok", hostIds };
|
|
814
1124
|
} catch (error) {
|
|
@@ -831,6 +1141,7 @@ function dropCredentials(db, userId, keepTokenHash) {
|
|
|
831
1141
|
else
|
|
832
1142
|
db.prepare("DELETE FROM sessions WHERE user_id = ? AND token_hash <> ?").run(userId, keepTokenHash);
|
|
833
1143
|
db.prepare("DELETE FROM join_tokens WHERE user_id = ? AND used_at IS NULL").run(userId);
|
|
1144
|
+
db.prepare("DELETE FROM host_link_requests WHERE user_id = ?").run(userId);
|
|
834
1145
|
db.prepare("DELETE FROM push_tokens WHERE user_id = ?").run(userId);
|
|
835
1146
|
}
|
|
836
1147
|
async function resetPassword(options) {
|
|
@@ -1229,6 +1540,28 @@ function createRelayLimiter(limits, now = Date.now) {
|
|
|
1229
1540
|
|
|
1230
1541
|
// apps/server/src/api.ts
|
|
1231
1542
|
var SESSION_COOKIE = "__Host-pinomad-session";
|
|
1543
|
+
var HOST_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
1544
|
+
var PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
|
|
1545
|
+
var PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
1546
|
+
var LOOPBACK_STATE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
1547
|
+
var MAX_REDIRECT_URI = 256;
|
|
1548
|
+
function parseLoopback(value) {
|
|
1549
|
+
const { redirectUri, codeChallenge, state } = value;
|
|
1550
|
+
if (typeof redirectUri !== "string" || redirectUri.length > MAX_REDIRECT_URI)
|
|
1551
|
+
return null;
|
|
1552
|
+
if (typeof codeChallenge !== "string" || !PKCE_CHALLENGE.test(codeChallenge))
|
|
1553
|
+
return null;
|
|
1554
|
+
if (typeof state !== "string" || !LOOPBACK_STATE.test(state))
|
|
1555
|
+
return null;
|
|
1556
|
+
let url;
|
|
1557
|
+
try {
|
|
1558
|
+
url = new URL(redirectUri);
|
|
1559
|
+
} catch {
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
const loopbackOnly = url.protocol === "http:" && url.hostname === "127.0.0.1" && url.port !== "" && url.username === "" && url.password === "" && url.search === "" && url.hash === "" && url.href === redirectUri;
|
|
1563
|
+
return loopbackOnly ? { redirectUri, codeChallenge, state } : null;
|
|
1564
|
+
}
|
|
1232
1565
|
var MAX_BODY = 8 * 1024;
|
|
1233
1566
|
var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
1234
1567
|
var DESKTOP_ORIGIN = "app://pinomad";
|
|
@@ -1254,7 +1587,7 @@ function respond(response, status, body, headers) {
|
|
|
1254
1587
|
response.writeHead(status, all);
|
|
1255
1588
|
response.end(body === undefined ? undefined : JSON.stringify(body));
|
|
1256
1589
|
}
|
|
1257
|
-
async function readJson(request) {
|
|
1590
|
+
async function readJson(request, optional = false) {
|
|
1258
1591
|
const chunks = [];
|
|
1259
1592
|
let size = 0;
|
|
1260
1593
|
let tooLarge = false;
|
|
@@ -1268,6 +1601,8 @@ async function readJson(request) {
|
|
|
1268
1601
|
}
|
|
1269
1602
|
if (tooLarge)
|
|
1270
1603
|
return { ok: false, tooLarge: true };
|
|
1604
|
+
if (optional && size === 0)
|
|
1605
|
+
return { ok: true, value: undefined };
|
|
1271
1606
|
try {
|
|
1272
1607
|
return { ok: true, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
|
|
1273
1608
|
} catch {
|
|
@@ -1317,7 +1652,8 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1317
1652
|
}
|
|
1318
1653
|
const bearer = (request.headers.authorization ?? "").startsWith("Bearer ");
|
|
1319
1654
|
const loginTokenMode = (pathname === "/api/login" || pathname === "/api/register") && (origin === undefined || origin === DESKTOP_ORIGIN);
|
|
1320
|
-
|
|
1655
|
+
const hostLinkHostMode = (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll" || pathname === "/api/host-link/token") && origin === undefined;
|
|
1656
|
+
if (MUTATING.has(method) && !bearer && !loginTokenMode && !hostLinkHostMode && origin !== ctx.publicOrigin) {
|
|
1321
1657
|
deny(403, "origin_mismatch");
|
|
1322
1658
|
return;
|
|
1323
1659
|
}
|
|
@@ -1397,6 +1733,136 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1397
1733
|
send(200, { user: { username }, expiresAt: result.expiresAt, ...cookieMode ? {} : { token: result.token } }, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
|
|
1398
1734
|
return;
|
|
1399
1735
|
}
|
|
1736
|
+
if (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll" || pathname === "/api/host-link/token") {
|
|
1737
|
+
if (method !== "POST") {
|
|
1738
|
+
deny(405, "method_not_allowed", { allow: "POST" });
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
const body = await readJson(request);
|
|
1742
|
+
if (!body.ok) {
|
|
1743
|
+
deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
if (pathname === "/api/host-link/start") {
|
|
1747
|
+
const value = body.value;
|
|
1748
|
+
const loopback = value?.redirectUri === undefined ? undefined : parseLoopback(value);
|
|
1749
|
+
if (typeof value?.hostId !== "string" || !HOST_ID_PATTERN.test(value.hostId) || typeof value.name !== "string" || typeof value.os !== "string" || loopback === null) {
|
|
1750
|
+
deny(400, "invalid_request");
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
const result2 = accounts.startHostLink({
|
|
1754
|
+
hostId: value.hostId,
|
|
1755
|
+
name: value.name,
|
|
1756
|
+
os: value.os,
|
|
1757
|
+
ip: clientIp(request, ctx.trustProxy),
|
|
1758
|
+
...loopback === undefined ? {} : { loopback }
|
|
1759
|
+
});
|
|
1760
|
+
if (result2.status === "limited") {
|
|
1761
|
+
deny(429, "rate_limited");
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
if (result2.status === "busy") {
|
|
1765
|
+
deny(503, "busy");
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
if (result2.mode === "loopback") {
|
|
1769
|
+
send(200, {
|
|
1770
|
+
requestId: result2.requestId,
|
|
1771
|
+
verifyUrl: `${ctx.publicOrigin}/host-link?request=${result2.requestId}`,
|
|
1772
|
+
expiresAt: result2.expiresAt
|
|
1773
|
+
});
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
send(200, {
|
|
1777
|
+
deviceCode: result2.deviceCode,
|
|
1778
|
+
userCode: result2.userCode,
|
|
1779
|
+
verifyUrl: `${ctx.publicOrigin}/host-link`,
|
|
1780
|
+
interval: result2.interval,
|
|
1781
|
+
expiresAt: result2.expiresAt
|
|
1782
|
+
});
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
if (pathname === "/api/host-link/token") {
|
|
1786
|
+
const value = body.value;
|
|
1787
|
+
if (typeof value?.code !== "string" || value.code.length > 256 || typeof value.codeVerifier !== "string" || !PKCE_VERIFIER.test(value.codeVerifier)) {
|
|
1788
|
+
deny(400, "invalid_request");
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
const result2 = accounts.exchangeHostLinkCode(value.code, value.codeVerifier);
|
|
1792
|
+
if (result2.status === "invalid")
|
|
1793
|
+
deny(400, "invalid_grant");
|
|
1794
|
+
else
|
|
1795
|
+
send(200, { token: result2.token, expiresAt: result2.expiresAt });
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
const deviceCode = body.value?.deviceCode;
|
|
1799
|
+
if (typeof deviceCode !== "string" || deviceCode.length > 256) {
|
|
1800
|
+
deny(400, "invalid_request");
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
const result = accounts.pollHostLink(deviceCode);
|
|
1804
|
+
switch (result.status) {
|
|
1805
|
+
case "confirmed":
|
|
1806
|
+
send(200, { token: result.token, expiresAt: result.expiresAt });
|
|
1807
|
+
return;
|
|
1808
|
+
case "pending":
|
|
1809
|
+
deny(400, "authorization_pending");
|
|
1810
|
+
return;
|
|
1811
|
+
case "slow_down":
|
|
1812
|
+
send(400, { error: "slow_down", interval: result.interval });
|
|
1813
|
+
return;
|
|
1814
|
+
case "denied":
|
|
1815
|
+
deny(400, "access_denied");
|
|
1816
|
+
return;
|
|
1817
|
+
case "expired":
|
|
1818
|
+
deny(400, "expired_token");
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
if (pathname === "/api/host-link/lookup" || pathname === "/api/host-link/confirm" || pathname === "/api/host-link/deny") {
|
|
1823
|
+
if (method !== "POST") {
|
|
1824
|
+
deny(405, "method_not_allowed", { allow: "POST" });
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
const token = sessionToken(request);
|
|
1828
|
+
if (token === undefined || accounts.authenticate(token) === undefined) {
|
|
1829
|
+
deny(401, "unauthenticated");
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
const body = await readJson(request);
|
|
1833
|
+
if (!body.ok) {
|
|
1834
|
+
deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
const named = body.value;
|
|
1838
|
+
const ref = typeof named?.userCode === "string" && named.userCode.length <= 64 && named.requestId === undefined ? { userCode: named.userCode } : typeof named?.requestId === "string" && named.requestId.length <= 64 && named.userCode === undefined ? { requestId: named.requestId } : undefined;
|
|
1839
|
+
if (ref === undefined) {
|
|
1840
|
+
deny(400, "invalid_request");
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
const session = accounts.authenticate(token);
|
|
1844
|
+
if (session === undefined) {
|
|
1845
|
+
deny(401, "unauthenticated");
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
const args = { userId: session.userId, ip: clientIp(request, ctx.trustProxy), ...ref };
|
|
1849
|
+
const result = pathname === "/api/host-link/lookup" ? accounts.lookupHostLink(args) : pathname === "/api/host-link/confirm" ? accounts.confirmHostLink(args) : accounts.denyHostLink(args);
|
|
1850
|
+
if (result.status === "limited") {
|
|
1851
|
+
deny(429, "rate_limited");
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
if (result.status === "not_found") {
|
|
1855
|
+
deny(404, "not_found");
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
if ("request" in result)
|
|
1859
|
+
send(200, result.request);
|
|
1860
|
+
else if (result.redirect !== undefined)
|
|
1861
|
+
send(200, { redirect: result.redirect });
|
|
1862
|
+
else
|
|
1863
|
+
send(204);
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1400
1866
|
if (pathname === "/api/invites" || pathname === "/api/invites/revoke") {
|
|
1401
1867
|
const allowed = pathname === "/api/invites" ? ["GET", "POST"] : ["POST"];
|
|
1402
1868
|
if (!allowed.includes(method)) {
|
|
@@ -1440,6 +1906,50 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1440
1906
|
deny(404, "not_found");
|
|
1441
1907
|
return;
|
|
1442
1908
|
}
|
|
1909
|
+
const hostRemove = /^\/api\/hosts\/([A-Za-z0-9_-]{43})\/remove$/.exec(pathname);
|
|
1910
|
+
if (hostRemove !== null) {
|
|
1911
|
+
if (method !== "POST") {
|
|
1912
|
+
deny(405, "method_not_allowed", { allow: "POST" });
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
const token = sessionToken(request);
|
|
1916
|
+
if (token === undefined || accounts.authenticate(token) === undefined) {
|
|
1917
|
+
deny(401, "unauthenticated");
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
const body = await readJson(request);
|
|
1921
|
+
if (!body.ok) {
|
|
1922
|
+
deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
const value = body.value;
|
|
1926
|
+
if (typeof value?.password !== "string" || [...value.password].length > MAX_PASSWORD_CODEPOINTS) {
|
|
1927
|
+
deny(400, "invalid_request");
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const current = accounts.authenticate(token);
|
|
1931
|
+
if (current === undefined) {
|
|
1932
|
+
deny(401, "unauthenticated");
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
const result = await accounts.removeHost({
|
|
1936
|
+
userId: current.userId,
|
|
1937
|
+
username: current.username,
|
|
1938
|
+
password: value.password,
|
|
1939
|
+
hostId: hostRemove[1]
|
|
1940
|
+
});
|
|
1941
|
+
if (result.status === "limited") {
|
|
1942
|
+
deny(429, "rate_limited");
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (result.status === "refused") {
|
|
1946
|
+
deny(result.reason === "not_found" ? 404 : 403, result.reason);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
ctx.disconnectHost(hostRemove[1], true);
|
|
1950
|
+
send(204);
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1443
1953
|
const hostPairing = /^\/api\/hosts\/([A-Za-z0-9_-]{43})\/pairing$/.exec(pathname);
|
|
1444
1954
|
if (pathname === "/api/hosts" || hostPairing !== null) {
|
|
1445
1955
|
const expected = pathname === "/api/hosts" ? "GET" : "POST";
|
|
@@ -1598,12 +2108,13 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1598
2108
|
deny(400, "invalid_request");
|
|
1599
2109
|
return;
|
|
1600
2110
|
}
|
|
1601
|
-
|
|
2111
|
+
const current = accounts.authenticate(token);
|
|
2112
|
+
if (current === undefined) {
|
|
1602
2113
|
deny(401, "unauthenticated");
|
|
1603
2114
|
return;
|
|
1604
2115
|
}
|
|
1605
2116
|
if (pathname === "/api/push-tokens")
|
|
1606
|
-
accounts.registerPushToken(session.userId, apns, environment);
|
|
2117
|
+
accounts.registerPushToken(session.userId, apns, environment, current.deviceId);
|
|
1607
2118
|
else
|
|
1608
2119
|
accounts.removePushToken(session.userId, apns, environment);
|
|
1609
2120
|
send(204);
|
|
@@ -1669,12 +2180,24 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1669
2180
|
send(200, {
|
|
1670
2181
|
token: joinToken.token,
|
|
1671
2182
|
expiresAt: joinToken.expiresAt,
|
|
1672
|
-
command: `
|
|
2183
|
+
command: `curl -fsSL https://pinomad.si/install.sh | sh -s -- join ${ctx.publicOrigin} ${joinToken.token}`
|
|
1673
2184
|
});
|
|
1674
2185
|
return;
|
|
1675
2186
|
}
|
|
1676
2187
|
if (pathname === "/api/logout") {
|
|
1677
|
-
|
|
2188
|
+
const body = await readJson(request, true);
|
|
2189
|
+
if (!body.ok) {
|
|
2190
|
+
deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
const value = body.value;
|
|
2194
|
+
const revoke = typeof value === "object" && value !== null ? value.revoke : undefined;
|
|
2195
|
+
if (value === null || value !== undefined && typeof value !== "object" || revoke !== undefined && typeof revoke !== "boolean") {
|
|
2196
|
+
deny(400, "invalid_request");
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
for (const hostId of accounts.logout(token, { revoke: revoke === true }))
|
|
2200
|
+
ctx.sendRevocations(hostId);
|
|
1678
2201
|
send(204, undefined, { "set-cookie": clearSessionCookie });
|
|
1679
2202
|
return;
|
|
1680
2203
|
}
|
|
@@ -1805,8 +2328,8 @@ async function startRelay(options) {
|
|
|
1805
2328
|
});
|
|
1806
2329
|
}
|
|
1807
2330
|
const pairing = { status: pairingStatus, request: requestPairing };
|
|
1808
|
-
function disconnectHost(hostId) {
|
|
1809
|
-
controls.get(hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
|
|
2331
|
+
function disconnectHost(hostId, removed = false) {
|
|
2332
|
+
controls.get(hostId)?.socket.close(removed ? RELAY_CLOSE.hostRemoved : RELAY_CLOSE.hostNotAllowed);
|
|
1810
2333
|
closeHostDevices(hostId, RELAY_CLOSE.hostNotAllowed, "host left its account");
|
|
1811
2334
|
}
|
|
1812
2335
|
function closeHostDevices(hostId, code, reason) {
|
|
@@ -1993,7 +2516,7 @@ async function startRelay(options) {
|
|
|
1993
2516
|
return;
|
|
1994
2517
|
}
|
|
1995
2518
|
if (accounts?.hasHost(frame.hostId) !== true) {
|
|
1996
|
-
socket.close(RELAY_CLOSE.hostNotAllowed);
|
|
2519
|
+
socket.close(accounts?.wasRemoved(frame.hostId) === true ? RELAY_CLOSE.hostRemoved : RELAY_CLOSE.hostNotAllowed);
|
|
1997
2520
|
return;
|
|
1998
2521
|
}
|
|
1999
2522
|
hostId = frame.hostId;
|
|
@@ -2192,6 +2715,9 @@ async function startRelay(options) {
|
|
|
2192
2715
|
socket.terminate();
|
|
2193
2716
|
}
|
|
2194
2717
|
}
|
|
2718
|
+
try {
|
|
2719
|
+
accounts?.sweepExpired();
|
|
2720
|
+
} catch {}
|
|
2195
2721
|
}, pingIntervalMs);
|
|
2196
2722
|
return {
|
|
2197
2723
|
port,
|