pinomad 0.9.1 → 0.11.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.
@@ -9,14 +9,14 @@ import {
9
9
  relayWsBase,
10
10
  verifyRelayChallenge,
11
11
  verifyRelayChallengeV2
12
- } from "./chunk-khcpv54k.js";
12
+ } from "./chunk-y127fe1a.js";
13
+ import {
14
+ normalizeHostName
15
+ } from "./chunk-pqe7q1s7.js";
13
16
  import {
14
17
  commandHint,
15
18
  webRoot
16
- } from "./chunk-fqgvf8v0.js";
17
- import {
18
- normalizeHostName
19
- } from "./chunk-sygeb9qb.js";
19
+ } from "./chunk-sd5fkxjj.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";
@@ -33,6 +33,7 @@ import { promisify } from "node:util";
33
33
 
34
34
  // apps/server/src/lock.ts
35
35
  import lockfile from "proper-lockfile";
36
+ var isDataDirInUse = (error) => error.cause?.code === "ELOCKED";
36
37
  async function lockDataDir(dir, options) {
37
38
  const stale = Math.max(2000, options?.staleMs ?? 1e4);
38
39
  let releasing = false;
@@ -57,15 +58,49 @@ async function lockDataDir(dir, options) {
57
58
 
58
59
  // apps/server/src/accounts.ts
59
60
  var DB_FILE = "server.sqlite";
60
- var SCHEMA_VERSION = 3;
61
+ var SCHEMA_VERSION = 6;
61
62
  var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
62
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 SLOW_DOWN_STEP_S = 5;
69
+ var POLL_SLACK_MS = 1000;
70
+ var HOST_OS_MAX_CODEPOINTS = 64;
71
+ function normalizeUserCode(input) {
72
+ const code = input.replace(/[\s-]/g, "").toUpperCase();
73
+ if (code.length !== USER_CODE_LENGTH)
74
+ return;
75
+ for (const letter of code)
76
+ if (!USER_CODE_ALPHABET.includes(letter))
77
+ return;
78
+ return code;
79
+ }
80
+ var formatUserCode = (code) => `${code.slice(0, 4)}-${code.slice(4)}`;
63
81
  var INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
64
82
  var SIGN_UP_USERNAME = /^[a-z0-9][a-z0-9._-]{1,31}$/;
65
83
  var PUSH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
66
84
  var MAX_PUSH_TOKENS_PER_USER = 32;
67
85
  var APNS_TOKEN_PATTERN = /^(?:[0-9a-f]{2})+$/;
68
86
  var APNS_TOKEN_MAX_CHARS = 256;
87
+ var DEVICE_ID_PATTERN = /^[A-Za-z0-9_-]{22,64}$/;
88
+ var DEVICE_NAME_MAX_CODEPOINTS = 64;
89
+ var ADMISSION_REPORT_GRACE_MS = 24 * 60 * 60 * 1000;
90
+ var MAX_OFFER_TTL_MS = 6 * 60 * 1000;
91
+ function parseDeviceClaim(raw) {
92
+ if (raw === undefined)
93
+ return;
94
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
95
+ return "invalid";
96
+ const { id, name } = raw;
97
+ if (typeof id !== "string" || !DEVICE_ID_PATTERN.test(id))
98
+ return "invalid";
99
+ if (name !== undefined && typeof name !== "string")
100
+ return "invalid";
101
+ const clipped = [...(name ?? "").trim()].slice(0, DEVICE_NAME_MAX_CODEPOINTS).join("").trim();
102
+ return clipped === "" ? { id } : { id, name: clipped };
103
+ }
69
104
  function normalizeApnsToken(raw) {
70
105
  const token = raw.toLowerCase();
71
106
  return token.length <= APNS_TOKEN_MAX_CHARS && APNS_TOKEN_PATTERN.test(token) ? token : undefined;
@@ -117,11 +152,17 @@ function assertUsernamePolicy(username) {
117
152
  throw new Error(`username must be 1–${MAX_USERNAME_CODEPOINTS} characters`);
118
153
  }
119
154
  }
120
- function migrate(db) {
155
+ var BUSY_TIMEOUT_MS = 5000;
156
+ function openDatabase(dataDir) {
157
+ const db = new DatabaseSync(join(dataDir, DB_FILE));
158
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
121
159
  db.exec("PRAGMA journal_mode = WAL");
122
160
  db.exec("PRAGMA foreign_keys = ON");
123
- const row = db.prepare("PRAGMA user_version").get();
124
- const version = row?.user_version ?? 0;
161
+ return db;
162
+ }
163
+ var schemaVersion = (db) => db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
164
+ function migrate(db) {
165
+ const version = schemaVersion(db);
125
166
  if (version > SCHEMA_VERSION) {
126
167
  throw new Error(`server database schema ${version} is newer than this build (${SCHEMA_VERSION})`);
127
168
  }
@@ -164,6 +205,95 @@ function migrate(db) {
164
205
  COMMIT;
165
206
  `);
166
207
  }
208
+ if (version < 4)
209
+ migrateTo4(db);
210
+ if (version < 5) {
211
+ db.exec(`
212
+ BEGIN;
213
+ ALTER TABLE push_tokens ADD COLUMN device_id INTEGER REFERENCES account_devices(id) ON DELETE SET NULL;
214
+ CREATE INDEX push_tokens_device ON push_tokens (device_id);
215
+ PRAGMA user_version = 5;
216
+ COMMIT;
217
+ `);
218
+ }
219
+ if (version < 6) {
220
+ db.exec(`
221
+ BEGIN;
222
+ CREATE TABLE host_link_requests (
223
+ device_code_hash TEXT PRIMARY KEY,
224
+ user_code_hash TEXT NOT NULL UNIQUE,
225
+ host_id TEXT NOT NULL,
226
+ host_name TEXT,
227
+ host_os TEXT,
228
+ source_ip TEXT,
229
+ state TEXT NOT NULL DEFAULT 'pending',
230
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
231
+ interval_s INTEGER NOT NULL,
232
+ last_poll_at INTEGER,
233
+ created_at INTEGER NOT NULL,
234
+ expires_at INTEGER NOT NULL
235
+ );
236
+ CREATE INDEX host_link_requests_expiry ON host_link_requests (expires_at);
237
+ ALTER TABLE join_tokens ADD COLUMN host_id TEXT;
238
+ PRAGMA user_version = 6;
239
+ COMMIT;
240
+ `);
241
+ }
242
+ }
243
+ function migrateTo4(db) {
244
+ db.exec("BEGIN");
245
+ try {
246
+ db.exec(`
247
+ -- AUTOINCREMENT: ids appear in kick URLs, and a reused one would let a
248
+ -- stale list kick a device that signed in later.
249
+ CREATE TABLE account_devices (
250
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
251
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
252
+ -- The client's own ID; NULL for clients that send none, which never
253
+ -- match each other (SQLite UNIQUE treats NULLs as distinct).
254
+ client_id TEXT,
255
+ name TEXT,
256
+ created_at INTEGER NOT NULL,
257
+ last_seen_at INTEGER NOT NULL,
258
+ kicked_at INTEGER,
259
+ UNIQUE (user_id, client_id)
260
+ );
261
+ ALTER TABLE sessions ADD COLUMN device_id INTEGER REFERENCES account_devices(id) ON DELETE CASCADE;
262
+ CREATE INDEX sessions_device ON sessions (device_id);
263
+ ALTER TABLE hosts ADD COLUMN device_reports INTEGER NOT NULL DEFAULT 0;
264
+ CREATE TABLE vouchings (
265
+ request_id TEXT PRIMARY KEY,
266
+ device_id INTEGER NOT NULL REFERENCES account_devices(id) ON DELETE CASCADE,
267
+ host_id TEXT NOT NULL REFERENCES hosts(host_id) ON DELETE CASCADE,
268
+ expires_at INTEGER NOT NULL,
269
+ -- Filled by the host's device-admitted report.
270
+ public_key TEXT,
271
+ admitted_at INTEGER,
272
+ -- Set when the account withdrew this vouching (a kick), reported
273
+ -- yet or not: a report that arrives later is revoked at once.
274
+ revoked_at INTEGER
275
+ );
276
+ CREATE INDEX vouchings_device ON vouchings (device_id);
277
+ CREATE INDEX vouchings_host_key ON vouchings (host_id, public_key);
278
+ CREATE TABLE pending_revocations (
279
+ host_id TEXT NOT NULL REFERENCES hosts(host_id) ON DELETE CASCADE,
280
+ public_key TEXT NOT NULL,
281
+ created_at INTEGER NOT NULL,
282
+ PRIMARY KEY (host_id, public_key)
283
+ );
284
+ `);
285
+ const sessions = db.prepare("SELECT token_hash AS tokenHash, user_id AS userId, created_at AS createdAt FROM sessions").all();
286
+ const insert = db.prepare("INSERT INTO account_devices (user_id, created_at, last_seen_at) VALUES (?, ?, ?)");
287
+ const link = db.prepare("UPDATE sessions SET device_id = ? WHERE token_hash = ?");
288
+ for (const session of sessions) {
289
+ const deviceId = insert.run(session.userId, session.createdAt, session.createdAt).lastInsertRowid;
290
+ link.run(deviceId, session.tokenHash);
291
+ }
292
+ db.exec("PRAGMA user_version = 4; COMMIT");
293
+ } catch (error) {
294
+ db.exec("ROLLBACK");
295
+ throw error;
296
+ }
167
297
  }
168
298
  function migrateTo1(db) {
169
299
  db.exec(`
@@ -276,7 +406,7 @@ async function openAccounts(options) {
276
406
  const release = await lockDataDir(options.dataDir, { staleMs: options.lockStaleMs });
277
407
  let db;
278
408
  try {
279
- db = new DatabaseSync(join(options.dataDir, DB_FILE));
409
+ db = openDatabase(options.dataDir);
280
410
  migrate(db);
281
411
  const userCount = db.prepare("SELECT COUNT(*) AS n FROM users").get().n;
282
412
  if (userCount === 0) {
@@ -305,13 +435,112 @@ async function openAccounts(options) {
305
435
  const gate = new ScryptGate(limits.scryptConcurrency, limits.scryptQueue);
306
436
  const ipAttempts = new WindowedCounter(limits.counterMaxEntries, now);
307
437
  const userFailures = new WindowedCounter(limits.counterMaxEntries, now);
308
- const issueSession = (userId) => {
438
+ const linkLimits = {
439
+ startMaxPerIp: options.hostLinkLimits?.startMaxPerIp ?? 10,
440
+ startWindowMs: options.hostLinkLimits?.startWindowMs ?? 10 * 60000,
441
+ maxPending: options.hostLinkLimits?.maxPending ?? 1000,
442
+ userMaxFailures: options.hostLinkLimits?.userMaxFailures ?? 10,
443
+ ipMaxFailures: options.hostLinkLimits?.ipMaxFailures ?? 30,
444
+ failureWindowMs: options.hostLinkLimits?.failureWindowMs ?? 15 * 60000,
445
+ counterMaxEntries: options.hostLinkLimits?.counterMaxEntries ?? 4096
446
+ };
447
+ const linkStarts = new WindowedCounter(linkLimits.counterMaxEntries, now);
448
+ const linkUserFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
449
+ const linkIpFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
450
+ const sweepExpired = () => {
451
+ const at = now();
452
+ db.prepare("DELETE FROM join_tokens WHERE expires_at <= ?").run(at);
453
+ db.prepare("DELETE FROM host_link_requests WHERE expires_at <= ?").run(at);
454
+ };
455
+ const mintJoinToken = (userId, hostId) => {
456
+ const token = randomBytes(32).toString("base64url");
457
+ const expiresAt = now() + JOIN_TOKEN_TTL_MS;
458
+ db.prepare("INSERT INTO join_tokens (token_hash, user_id, created_at, expires_at, host_id) VALUES (?, ?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt, hostId);
459
+ return { token, expiresAt };
460
+ };
461
+ const pendingByCode = (userId, userCode, ip) => {
462
+ const userKey = String(userId);
463
+ if (linkUserFailures.isOver(userKey, linkLimits.userMaxFailures) || linkIpFailures.isOver(ip, linkLimits.ipMaxFailures)) {
464
+ return "limited";
465
+ }
466
+ const code = normalizeUserCode(userCode);
467
+ const codeHash = code === undefined ? undefined : sha256Hex(code);
468
+ const row = codeHash === undefined ? undefined : db.prepare(`SELECT host_id AS hostId, host_name AS name, host_os AS os, source_ip AS sourceIp, expires_at AS expiresAt
469
+ FROM host_link_requests WHERE user_code_hash = ? AND state = 'pending' AND expires_at > ?`).get(codeHash, now());
470
+ if (codeHash === undefined || row === undefined) {
471
+ linkUserFailures.bump(userKey, linkLimits.failureWindowMs);
472
+ linkIpFailures.bump(ip, linkLimits.failureWindowMs);
473
+ return "not_found";
474
+ }
475
+ return { codeHash, row };
476
+ };
477
+ const settleHostLink = (args, state) => {
478
+ const found = pendingByCode(args.userId, args.userCode, args.ip);
479
+ if (found === "limited" || found === "not_found")
480
+ return { status: found };
481
+ const settled = db.prepare(`UPDATE host_link_requests SET state = ?, user_id = ?, host_name = NULL, host_os = NULL, source_ip = NULL
482
+ WHERE user_code_hash = ? AND state = 'pending' AND expires_at > ?`).run(state, state === "confirmed" ? args.userId : null, found.codeHash, now());
483
+ return Number(settled.changes) === 1 ? { status: "ok" } : { status: "not_found" };
484
+ };
485
+ const deviceForSignIn = (userId, claim) => {
486
+ const at = now();
487
+ if (claim === undefined) {
488
+ db.prepare(`DELETE FROM account_devices WHERE user_id = ? AND client_id IS NULL
489
+ AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.device_id = account_devices.id AND s.expires_at > ?)
490
+ AND NOT EXISTS (SELECT 1 FROM vouchings v WHERE v.device_id = account_devices.id)`).run(userId, at);
491
+ return Number(db.prepare("INSERT INTO account_devices (user_id, created_at, last_seen_at) VALUES (?, ?, ?)").run(userId, at, at).lastInsertRowid);
492
+ }
493
+ const row = db.prepare(`INSERT INTO account_devices (user_id, client_id, name, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)
494
+ ON CONFLICT (user_id, client_id) DO UPDATE SET
495
+ name = COALESCE(excluded.name, name), last_seen_at = excluded.last_seen_at, kicked_at = NULL
496
+ RETURNING id`).get(userId, claim.id, claim.name ?? null, at, at);
497
+ return row.id;
498
+ };
499
+ const issueSession = (userId, device) => {
309
500
  db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(now());
501
+ const deviceId = deviceForSignIn(userId, device);
310
502
  const token = randomBytes(32).toString("base64url");
311
503
  const expiresAt = now() + SESSION_TTL_MS;
312
- db.prepare("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt);
504
+ db.prepare("INSERT INTO sessions (token_hash, user_id, created_at, expires_at, device_id) VALUES (?, ?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt, deviceId);
313
505
  return { token, expiresAt };
314
506
  };
507
+ const sweepVouchings = () => {
508
+ db.prepare("DELETE FROM vouchings WHERE public_key IS NULL AND expires_at <= ?").run(now() - ADMISSION_REPORT_GRACE_MS);
509
+ };
510
+ const withdrawDevice = (deviceId) => {
511
+ const at = now();
512
+ sweepVouchings();
513
+ db.prepare("DELETE FROM sessions WHERE device_id = ?").run(deviceId);
514
+ db.prepare("DELETE FROM push_tokens WHERE device_id = ?").run(deviceId);
515
+ db.prepare("UPDATE account_devices SET kicked_at = ? WHERE id = ?").run(at, deviceId);
516
+ 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);
517
+ db.prepare(`INSERT OR IGNORE INTO pending_revocations (host_id, public_key, created_at)
518
+ SELECT host_id, public_key, ? FROM vouchings
519
+ WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL`).run(at, deviceId);
520
+ db.prepare("UPDATE vouchings SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL").run(at, deviceId);
521
+ return hostIds;
522
+ };
523
+ const currentHash = (userId) => db.prepare("SELECT password_hash AS passwordHash FROM users WHERE id = ?").get(userId)?.passwordHash;
524
+ const checkPassword = async (userId, username, password) => {
525
+ if (userFailures.isOver(username, limits.userMaxFailures))
526
+ return "limited";
527
+ const stored = currentHash(userId);
528
+ const outcome = await gate.run(async () => {
529
+ if (userFailures.isOver(username, limits.userMaxFailures))
530
+ return "limited";
531
+ if (!await verifyPassword(password, stored ?? DUMMY_HASH) || stored === undefined) {
532
+ userFailures.bump(username, limits.userWindowMs);
533
+ return "wrong_password";
534
+ }
535
+ return "ok";
536
+ });
537
+ if (outcome === undefined || outcome === "limited")
538
+ return "limited";
539
+ if (outcome === "wrong_password")
540
+ return "wrong_password";
541
+ userFailures.reset(username);
542
+ return { verifiedHash: stored };
543
+ };
315
544
  const inviteState = (codeHash) => {
316
545
  const row = db.prepare("SELECT expires_at AS expiresAt, used_at AS usedAt FROM invites WHERE code_hash = ?").get(codeHash);
317
546
  if (row === undefined || row.usedAt !== null)
@@ -321,7 +550,7 @@ async function openAccounts(options) {
321
550
  const usernameTaken = (username) => db.prepare("SELECT 1 AS x FROM users WHERE lower(username) = ?").get(username) !== undefined;
322
551
  return {
323
552
  dbPath: join(options.dataDir, DB_FILE),
324
- async login(username, password, ip) {
553
+ async login(username, password, ip, device) {
325
554
  if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
326
555
  return { status: "limited" };
327
556
  if (userFailures.isOver(username, limits.userMaxFailures))
@@ -341,10 +570,22 @@ async function openAccounts(options) {
341
570
  return { status: "limited" };
342
571
  if (outcome === "invalid")
343
572
  return { status: "invalid" };
573
+ db.exec("BEGIN IMMEDIATE");
574
+ let session;
575
+ try {
576
+ if (currentHash(outcome) === user?.passwordHash)
577
+ session = issueSession(outcome, device);
578
+ db.exec("COMMIT");
579
+ } catch (error) {
580
+ db.exec("ROLLBACK");
581
+ throw error;
582
+ }
583
+ if (session === undefined)
584
+ return { status: "invalid" };
344
585
  userFailures.reset(username);
345
- return { status: "ok", ...issueSession(outcome), admin: user?.admin === 1 };
586
+ return { status: "ok", ...session, admin: user?.admin === 1 };
346
587
  },
347
- async register({ invite, username, password, ip }) {
588
+ async register({ invite, username, password, ip, device }) {
348
589
  if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
349
590
  return { status: "limited" };
350
591
  const codeHash = sha256Hex(invite);
@@ -376,7 +617,7 @@ async function openAccounts(options) {
376
617
  db.exec("ROLLBACK");
377
618
  return { status: "refused", reason: "invalid_invite" };
378
619
  }
379
- const session = issueSession(userId);
620
+ const session = issueSession(userId, device);
380
621
  db.exec("COMMIT");
381
622
  return { status: "ok", ...session };
382
623
  } catch (error) {
@@ -406,14 +647,86 @@ async function openAccounts(options) {
406
647
  return Number(db.prepare("DELETE FROM invites WHERE id = ? AND used_at IS NULL").run(id).changes) > 0;
407
648
  },
408
649
  authenticate(token) {
409
- const row = db.prepare(`SELECT s.user_id AS userId, u.username AS username, u.admin AS admin, s.expires_at AS expiresAt
650
+ const row = db.prepare(`SELECT s.user_id AS userId, u.username AS username, u.admin AS admin, s.expires_at AS expiresAt, s.device_id AS deviceId
410
651
  FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?`).get(sha256Hex(token));
411
652
  if (row === undefined || row.expiresAt <= now())
412
653
  return;
413
- return { userId: row.userId, username: row.username, admin: row.admin === 1 };
654
+ return {
655
+ userId: row.userId,
656
+ username: row.username,
657
+ admin: row.admin === 1,
658
+ ...row.deviceId === null ? {} : { deviceId: row.deviceId }
659
+ };
660
+ },
661
+ logout(token, options2) {
662
+ const tokenHash = sha256Hex(token);
663
+ if (options2?.revoke !== true) {
664
+ db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
665
+ return [];
666
+ }
667
+ db.exec("BEGIN IMMEDIATE");
668
+ try {
669
+ const session = db.prepare("SELECT device_id AS deviceId FROM sessions WHERE token_hash = ?").get(tokenHash);
670
+ db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
671
+ const hostIds = session?.deviceId == null ? [] : withdrawDevice(session.deviceId);
672
+ db.exec("COMMIT");
673
+ return hostIds;
674
+ } catch (error) {
675
+ db.exec("ROLLBACK");
676
+ throw error;
677
+ }
414
678
  },
415
- logout(token) {
416
- db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256Hex(token));
679
+ async changePassword({ userId, username, keepToken, currentPassword, newPassword }) {
680
+ const length = [...newPassword].length;
681
+ if (length < MIN_PASSWORD_CODEPOINTS || length > MAX_PASSWORD_CODEPOINTS) {
682
+ return { status: "refused", reason: "weak_password" };
683
+ }
684
+ const checked = await checkPassword(userId, username, currentPassword);
685
+ if (checked === "limited")
686
+ return { status: "limited" };
687
+ if (checked === "wrong_password")
688
+ return { status: "refused", reason: "wrong_password" };
689
+ const hash = await gate.run(() => hashPassword(newPassword));
690
+ if (hash === undefined)
691
+ return { status: "limited" };
692
+ db.exec("BEGIN IMMEDIATE");
693
+ try {
694
+ const swapped = db.prepare("UPDATE users SET password_hash = ? WHERE id = ? AND password_hash = ?").run(hash, userId, checked.verifiedHash);
695
+ if (Number(swapped.changes) !== 1) {
696
+ db.exec("ROLLBACK");
697
+ return { status: "refused", reason: "wrong_password" };
698
+ }
699
+ dropCredentials(db, userId, sha256Hex(keepToken));
700
+ db.exec("COMMIT");
701
+ } catch (error) {
702
+ db.exec("ROLLBACK");
703
+ throw error;
704
+ }
705
+ return { status: "ok" };
706
+ },
707
+ async deleteAccount({ userId, username, password }) {
708
+ const user = db.prepare("SELECT admin FROM users WHERE id = ?").get(userId);
709
+ if (user?.admin === 1)
710
+ return { status: "refused", reason: "admin_account" };
711
+ const checked = await checkPassword(userId, username, password);
712
+ if (checked === "limited")
713
+ return { status: "limited" };
714
+ if (checked === "wrong_password")
715
+ return { status: "refused", reason: "wrong_password" };
716
+ db.exec("BEGIN IMMEDIATE");
717
+ try {
718
+ const hostIds = db.prepare("SELECT host_id AS hostId FROM hosts WHERE user_id = ?").all(userId).map((row) => row.hostId);
719
+ const deleted = db.prepare("DELETE FROM users WHERE id = ? AND password_hash = ?").run(userId, checked.verifiedHash);
720
+ if (Number(deleted.changes) !== 1) {
721
+ db.exec("ROLLBACK");
722
+ return { status: "refused", reason: "wrong_password" };
723
+ }
724
+ db.exec("COMMIT");
725
+ return { status: "ok", hostIds };
726
+ } catch (error) {
727
+ db.exec("ROLLBACK");
728
+ throw error;
729
+ }
417
730
  },
418
731
  revokeAllSessions(userId) {
419
732
  db.exec("BEGIN");
@@ -433,13 +746,14 @@ async function openAccounts(options) {
433
746
  const row = db.prepare("SELECT user_id AS userId FROM hosts WHERE host_id = ?").get(hostId);
434
747
  return row?.userId;
435
748
  },
436
- registerPushToken(userId, token, environment) {
749
+ registerPushToken(userId, token, environment, deviceId) {
437
750
  const at = now();
438
751
  db.exec("BEGIN");
439
752
  try {
440
753
  db.prepare("DELETE FROM push_tokens WHERE updated_at < ?").run(at - PUSH_TOKEN_TTL_MS);
441
- db.prepare(`INSERT INTO push_tokens (token, environment, user_id, updated_at) VALUES (?, ?, ?, ?)
442
- ON CONFLICT (token, environment) DO UPDATE SET user_id = excluded.user_id, updated_at = excluded.updated_at`).run(token.toLowerCase(), environment, userId, at);
754
+ db.prepare(`INSERT INTO push_tokens (token, environment, user_id, updated_at, device_id) VALUES (?, ?, ?, ?, ?)
755
+ ON CONFLICT (token, environment) DO UPDATE SET
756
+ user_id = excluded.user_id, updated_at = excluded.updated_at, device_id = excluded.device_id`).run(token.toLowerCase(), environment, userId, at, deviceId ?? null);
443
757
  db.prepare(`DELETE FROM push_tokens WHERE user_id = ? AND rowid NOT IN (
444
758
  SELECT rowid FROM push_tokens WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?
445
759
  )`).run(userId, userId, MAX_PUSH_TOKENS_PER_USER);
@@ -460,22 +774,96 @@ async function openAccounts(options) {
460
774
  db.prepare("DELETE FROM push_tokens WHERE token = ? AND environment = ? AND updated_at < ?").run(token.toLowerCase(), environment, unregisteredAt);
461
775
  },
462
776
  createJoinToken(userId) {
463
- db.prepare("DELETE FROM join_tokens WHERE expires_at <= ?").run(now());
464
- const token = randomBytes(32).toString("base64url");
465
- const expiresAt = now() + JOIN_TOKEN_TTL_MS;
466
- db.prepare("INSERT INTO join_tokens (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt);
467
- return { token, expiresAt };
777
+ sweepExpired();
778
+ return mintJoinToken(userId, null);
779
+ },
780
+ startHostLink({ hostId, name, os, ip }) {
781
+ if (linkStarts.hit(ip, linkLimits.startMaxPerIp, linkLimits.startWindowMs))
782
+ return { status: "limited" };
783
+ sweepExpired();
784
+ const live = db.prepare("SELECT COUNT(*) AS n FROM host_link_requests").get().n;
785
+ if (live >= linkLimits.maxPending)
786
+ return { status: "busy" };
787
+ const deviceCode = randomBytes(32).toString("base64url");
788
+ const at = now();
789
+ const expiresAt = at + HOST_LINK_TTL_MS;
790
+ const insert = db.prepare(`INSERT INTO host_link_requests
791
+ (device_code_hash, user_code_hash, host_id, host_name, host_os, source_ip, interval_s, created_at, expires_at)
792
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
793
+ for (let attempt = 0;; attempt++) {
794
+ const code = Array.from({ length: USER_CODE_LENGTH }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
795
+ try {
796
+ insert.run(sha256Hex(deviceCode), sha256Hex(code), hostId, normalizeHostName(name), [...os.trim()].slice(0, HOST_OS_MAX_CODEPOINTS).join(""), ip, HOST_LINK_INTERVAL_S, at, expiresAt);
797
+ return { status: "ok", deviceCode, userCode: formatUserCode(code), expiresAt, interval: HOST_LINK_INTERVAL_S };
798
+ } catch (error) {
799
+ if (attempt >= 4 || !String(error).includes("UNIQUE"))
800
+ throw error;
801
+ }
802
+ }
803
+ },
804
+ pollHostLink(deviceCode) {
805
+ const deviceCodeHash = sha256Hex(deviceCode);
806
+ const at = now();
807
+ db.exec("BEGIN IMMEDIATE");
808
+ try {
809
+ 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
810
+ FROM host_link_requests WHERE device_code_hash = ?`).get(deviceCodeHash);
811
+ let result;
812
+ if (row === undefined || row.expiresAt <= at) {
813
+ db.prepare("DELETE FROM host_link_requests WHERE device_code_hash = ?").run(deviceCodeHash);
814
+ result = { status: "expired" };
815
+ } else if (row.lastPollAt !== null && at - row.lastPollAt < row.intervalS * 1000 - POLL_SLACK_MS) {
816
+ const interval = row.intervalS + SLOW_DOWN_STEP_S;
817
+ db.prepare("UPDATE host_link_requests SET interval_s = ?, last_poll_at = ? WHERE device_code_hash = ?").run(interval, at, deviceCodeHash);
818
+ result = { status: "slow_down", interval };
819
+ } else if (row.state === "pending") {
820
+ db.prepare("UPDATE host_link_requests SET last_poll_at = ? WHERE device_code_hash = ?").run(at, deviceCodeHash);
821
+ result = { status: "pending" };
822
+ } else {
823
+ db.prepare("DELETE FROM host_link_requests WHERE device_code_hash = ?").run(deviceCodeHash);
824
+ result = row.state === "confirmed" && row.userId !== null ? { status: "confirmed", ...mintJoinToken(row.userId, row.hostId) } : { status: "denied" };
825
+ }
826
+ db.exec("COMMIT");
827
+ return result;
828
+ } catch (error) {
829
+ db.exec("ROLLBACK");
830
+ throw error;
831
+ }
468
832
  },
833
+ lookupHostLink({ userId, userCode, ip }) {
834
+ const found = pendingByCode(userId, userCode, ip);
835
+ if (found === "limited" || found === "not_found")
836
+ return { status: found };
837
+ const { row } = found;
838
+ return {
839
+ status: "ok",
840
+ request: {
841
+ hostId: row.hostId,
842
+ name: row.name ?? "",
843
+ os: row.os ?? "",
844
+ sourceIp: row.sourceIp ?? "",
845
+ ipDiffers: row.sourceIp !== ip,
846
+ expiresAt: row.expiresAt
847
+ }
848
+ };
849
+ },
850
+ confirmHostLink(args) {
851
+ return settleHostLink(args, "confirmed");
852
+ },
853
+ denyHostLink(args) {
854
+ return settleHostLink(args, "denied");
855
+ },
856
+ sweepExpired,
469
857
  joinHost({ token, hostId, noisePublicKey, name }) {
470
858
  const tokenHash = sha256Hex(token);
471
859
  const at = now();
472
860
  db.exec("BEGIN");
473
861
  try {
474
- 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);
862
+ 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);
475
863
  let failure;
476
- if (row === undefined || row.usedAt !== null)
864
+ if (row === undefined || row.usedAt !== null || row.boundHostId !== null && row.boundHostId !== hostId) {
477
865
  failure = "invalid_token";
478
- else if (row.expiresAt <= at)
866
+ } else if (row.expiresAt <= at)
479
867
  failure = "expired_token";
480
868
  else {
481
869
  const consumed = db.prepare("UPDATE join_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL").run(at, tokenHash);
@@ -487,6 +875,11 @@ async function openAccounts(options) {
487
875
  return { status: "refused", reason: failure };
488
876
  }
489
877
  const hostName = name === undefined ? "Unknown host" : normalizeHostName(name);
878
+ const previousOwner = db.prepare("SELECT user_id AS userId FROM hosts WHERE host_id = ?").get(hostId)?.userId;
879
+ if (previousOwner !== undefined && previousOwner !== row.userId) {
880
+ db.prepare("DELETE FROM vouchings WHERE host_id = ?").run(hostId);
881
+ db.prepare("DELETE FROM pending_revocations WHERE host_id = ?").run(hostId);
882
+ }
490
883
  db.prepare(`INSERT INTO hosts (host_id, user_id, noise_public_key, name, joined_at, last_seen_at)
491
884
  VALUES (?, ?, ?, ?, ?, ?)
492
885
  ON CONFLICT(host_id) DO UPDATE SET
@@ -508,16 +901,111 @@ async function openAccounts(options) {
508
901
  },
509
902
  listHosts(userId) {
510
903
  return db.prepare(`SELECT host_id AS hostId, user_id AS userId, noise_public_key AS noisePublicKey,
511
- name, joined_at AS joinedAt, last_seen_at AS lastSeenAt
512
- FROM hosts WHERE user_id = ? ORDER BY joined_at`).all(userId);
904
+ name, joined_at AS joinedAt, last_seen_at AS lastSeenAt, device_reports AS deviceReports
905
+ FROM hosts WHERE user_id = ? ORDER BY joined_at`).all(userId).map((row) => ({ ...row, deviceReports: row.deviceReports === 1 }));
513
906
  },
514
907
  hostFor(userId, hostId) {
515
- return db.prepare(`SELECT host_id AS hostId, user_id AS userId, noise_public_key AS noisePublicKey,
516
- name, joined_at AS joinedAt, last_seen_at AS lastSeenAt
908
+ const row = db.prepare(`SELECT host_id AS hostId, user_id AS userId, noise_public_key AS noisePublicKey,
909
+ name, joined_at AS joinedAt, last_seen_at AS lastSeenAt, device_reports AS deviceReports
517
910
  FROM hosts WHERE user_id = ? AND host_id = ?`).get(userId, hostId);
911
+ return row === undefined ? undefined : { ...row, deviceReports: row.deviceReports === 1 };
912
+ },
913
+ markHostSeen(hostId, deviceReports) {
914
+ db.prepare("UPDATE hosts SET last_seen_at = ?, device_reports = ? WHERE host_id = ?").run(now(), deviceReports ? 1 : 0, hostId);
915
+ },
916
+ listDevices(userId) {
917
+ const devices = db.prepare(`SELECT id, name, created_at AS createdAt, last_seen_at AS lastSeenAt, kicked_at AS kickedAt
918
+ FROM account_devices WHERE user_id = ? ORDER BY last_seen_at DESC, id DESC`).all(userId);
919
+ const keys = db.prepare(`SELECT v.device_id AS deviceId, v.host_id AS hostId, h.name AS hostName,
920
+ v.revoked_at IS NOT NULL AS revoked,
921
+ EXISTS (SELECT 1 FROM pending_revocations p WHERE p.host_id = v.host_id AND p.public_key = v.public_key) AS pending
922
+ FROM vouchings v JOIN hosts h ON h.host_id = v.host_id JOIN account_devices d ON d.id = v.device_id
923
+ WHERE d.user_id = ? AND v.public_key IS NOT NULL
924
+ ORDER BY h.joined_at, v.host_id`).all(userId);
925
+ const rank = { registered: 2, revoking: 1, revoked: 0 };
926
+ const hosts = new Map;
927
+ for (const key of keys) {
928
+ const state = key.pending === 1 ? "revoking" : key.revoked === 1 ? "revoked" : "registered";
929
+ const perDevice = hosts.get(key.deviceId) ?? new Map;
930
+ hosts.set(key.deviceId, perDevice);
931
+ const seen = perDevice.get(key.hostId);
932
+ if (seen === undefined || rank[state] > rank[seen.state])
933
+ perDevice.set(key.hostId, { hostId: key.hostId, name: key.hostName, state });
934
+ }
935
+ return devices.map((device) => ({
936
+ id: device.id,
937
+ ...device.name === null ? {} : { name: device.name },
938
+ createdAt: device.createdAt,
939
+ lastSeenAt: device.lastSeenAt,
940
+ ...device.kickedAt === null ? {} : { kickedAt: device.kickedAt },
941
+ hosts: [...hosts.get(device.id)?.values() ?? []]
942
+ }));
518
943
  },
519
- markHostSeen(hostId) {
520
- db.prepare("UPDATE hosts SET last_seen_at = ? WHERE host_id = ?").run(now(), hostId);
944
+ recordVouching({ token, hostId, requestId, expiresAt }) {
945
+ sweepVouchings();
946
+ const session = db.prepare(`SELECT s.device_id AS deviceId FROM sessions s JOIN hosts h ON h.user_id = s.user_id
947
+ WHERE s.token_hash = ? AND s.expires_at > ? AND h.host_id = ?`).get(sha256Hex(token), now(), hostId);
948
+ if (session === undefined)
949
+ return false;
950
+ if (session.deviceId !== null) {
951
+ db.prepare("INSERT OR IGNORE INTO vouchings (request_id, device_id, host_id, expires_at) VALUES (?, ?, ?, ?)").run(requestId, session.deviceId, hostId, Math.min(expiresAt, now() + MAX_OFFER_TTL_MS));
952
+ }
953
+ return true;
954
+ },
955
+ recordAdmission(hostId, requestId, publicKey) {
956
+ db.exec("BEGIN");
957
+ try {
958
+ const vouching = db.prepare(`SELECT expires_at AS expiresAt, public_key AS publicKey, revoked_at AS revokedAt
959
+ FROM vouchings WHERE request_id = ? AND host_id = ?`).get(requestId, hostId);
960
+ if (vouching === undefined || vouching.publicKey !== null || vouching.expiresAt + ADMISSION_REPORT_GRACE_MS <= now()) {
961
+ db.exec("ROLLBACK");
962
+ return "ignored";
963
+ }
964
+ db.prepare("UPDATE vouchings SET public_key = ?, admitted_at = ? WHERE request_id = ?").run(publicKey, now(), requestId);
965
+ if (vouching.revokedAt !== null) {
966
+ db.prepare("INSERT OR IGNORE INTO pending_revocations (host_id, public_key, created_at) VALUES (?, ?, ?)").run(hostId, publicKey, now());
967
+ }
968
+ db.exec("COMMIT");
969
+ return vouching.revokedAt === null ? "recorded" : "revoke";
970
+ } catch (error) {
971
+ db.exec("ROLLBACK");
972
+ throw error;
973
+ }
974
+ },
975
+ pendingRevocations(hostId) {
976
+ return db.prepare("SELECT public_key AS publicKey FROM pending_revocations WHERE host_id = ? ORDER BY created_at").all(hostId).map((row) => row.publicKey);
977
+ },
978
+ confirmRevocation(hostId, publicKey) {
979
+ db.prepare("DELETE FROM pending_revocations WHERE host_id = ? AND public_key = ?").run(hostId, publicKey);
980
+ },
981
+ async kickDevice({ userId, username, password, deviceId, currentDeviceId }) {
982
+ const owned = () => db.prepare("SELECT 1 AS x FROM account_devices WHERE id = ? AND user_id = ?").get(deviceId, userId) !== undefined;
983
+ if (!owned())
984
+ return { status: "refused", reason: "not_found" };
985
+ if (deviceId === currentDeviceId)
986
+ return { status: "refused", reason: "current_device" };
987
+ const checked = await checkPassword(userId, username, password);
988
+ if (checked === "limited")
989
+ return { status: "limited" };
990
+ if (checked === "wrong_password")
991
+ return { status: "refused", reason: "wrong_password" };
992
+ db.exec("BEGIN IMMEDIATE");
993
+ try {
994
+ if (currentHash(userId) !== checked.verifiedHash) {
995
+ db.exec("ROLLBACK");
996
+ return { status: "refused", reason: "wrong_password" };
997
+ }
998
+ if (!owned()) {
999
+ db.exec("ROLLBACK");
1000
+ return { status: "refused", reason: "not_found" };
1001
+ }
1002
+ const hostIds = withdrawDevice(deviceId);
1003
+ db.exec("COMMIT");
1004
+ return { status: "ok", hostIds };
1005
+ } catch (error) {
1006
+ db.exec("ROLLBACK");
1007
+ throw error;
1008
+ }
521
1009
  },
522
1010
  renameHost(hostId, name) {
523
1011
  db.prepare("UPDATE hosts SET name = ? WHERE host_id = ?").run(normalizeHostName(name), hostId);
@@ -528,26 +1016,41 @@ async function openAccounts(options) {
528
1016
  }
529
1017
  };
530
1018
  }
531
- async function changePassword(options) {
1019
+ function dropCredentials(db, userId, keepTokenHash) {
1020
+ if (keepTokenHash === undefined)
1021
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
1022
+ else
1023
+ db.prepare("DELETE FROM sessions WHERE user_id = ? AND token_hash <> ?").run(userId, keepTokenHash);
1024
+ db.prepare("DELETE FROM join_tokens WHERE user_id = ? AND used_at IS NULL").run(userId);
1025
+ db.prepare("DELETE FROM host_link_requests WHERE user_id = ?").run(userId);
1026
+ db.prepare("DELETE FROM push_tokens WHERE user_id = ?").run(userId);
1027
+ }
1028
+ async function resetPassword(options) {
532
1029
  assertPasswordPolicy(options.password);
533
1030
  await mkdir(options.dataDir, { recursive: true });
534
- const release = await lockDataDir(options.dataDir, { retries: 0 });
535
- const db = new DatabaseSync(join(options.dataDir, DB_FILE));
1031
+ const release = await lockDataDir(options.dataDir, { retries: 0 }).catch((error) => {
1032
+ if (isDataDirInUse(error))
1033
+ return;
1034
+ throw error;
1035
+ });
1036
+ const db = openDatabase(options.dataDir);
536
1037
  try {
537
- migrate(db);
538
- const users = options.user === undefined ? db.prepare("SELECT id FROM users").all() : db.prepare("SELECT id FROM users WHERE username = ?").all(options.user);
539
- if (users.length === 0)
540
- throw new Error(options.user === undefined ? "no account exists yet" : `no such account: ${options.user}`);
541
- if (users.length > 1)
542
- throw new Error("more than one account exists — pass --user");
543
- const userId = users[0].id;
1038
+ if (release !== undefined) {
1039
+ migrate(db);
1040
+ } else if (schemaVersion(db) !== SCHEMA_VERSION) {
1041
+ throw new Error(`the running server's database schema (${schemaVersion(db)}) differs from this build's (${SCHEMA_VERSION}); restart the server on this version first`);
1042
+ }
544
1043
  const hash = await hashPassword(options.password);
545
- db.exec("BEGIN");
1044
+ db.exec("BEGIN IMMEDIATE");
546
1045
  try {
1046
+ const users = options.user === undefined ? db.prepare("SELECT id FROM users").all() : db.prepare("SELECT id FROM users WHERE username = ?").all(options.user);
1047
+ if (users.length === 0)
1048
+ throw new Error(options.user === undefined ? "no account exists yet" : `no such account: ${options.user}`);
1049
+ if (users.length > 1)
1050
+ throw new Error("more than one account exists — pass --user");
1051
+ const userId = users[0].id;
547
1052
  db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
548
- db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
549
- db.prepare("DELETE FROM join_tokens WHERE user_id = ? AND used_at IS NULL").run(userId);
550
- db.prepare("DELETE FROM push_tokens WHERE user_id = ?").run(userId);
1053
+ dropCredentials(db, userId);
551
1054
  db.exec("COMMIT");
552
1055
  } catch (error) {
553
1056
  db.exec("ROLLBACK");
@@ -555,7 +1058,7 @@ async function changePassword(options) {
555
1058
  }
556
1059
  } finally {
557
1060
  db.close();
558
- await release();
1061
+ await release?.();
559
1062
  }
560
1063
  }
561
1064
 
@@ -789,8 +1292,136 @@ import { once } from "node:events";
789
1292
  import { createServer } from "node:http";
790
1293
  import { WebSocket, WebSocketServer } from "ws";
791
1294
 
1295
+ // apps/server/src/relay-limits.ts
1296
+ var DEFAULT_RELAY_LIMITS = {
1297
+ perHost: 64,
1298
+ perAccount: 256,
1299
+ unauthenticatedPerHost: 32,
1300
+ handshakeTimeoutMs: 30000,
1301
+ handshakeBytes: 64 * 1024,
1302
+ accountBytesPerSecond: 4 * 1024 * 1024,
1303
+ accountBurstBytes: 1024 * 1024 * 1024
1304
+ };
1305
+ var RELAY_LIMIT_OPTIONS = [
1306
+ { field: "perHost", flag: "relay-max-per-host", env: "PINOMAD_SERVER_RELAY_MAX_PER_HOST", unit: "count" },
1307
+ { field: "perAccount", flag: "relay-max-per-account", env: "PINOMAD_SERVER_RELAY_MAX_PER_ACCOUNT", unit: "count" },
1308
+ {
1309
+ field: "unauthenticatedPerHost",
1310
+ flag: "relay-max-unauthenticated-per-host",
1311
+ env: "PINOMAD_SERVER_RELAY_MAX_UNAUTHENTICATED_PER_HOST",
1312
+ unit: "count"
1313
+ },
1314
+ { field: "handshakeTimeoutMs", flag: "relay-handshake-timeout", env: "PINOMAD_SERVER_RELAY_HANDSHAKE_TIMEOUT", unit: "seconds" },
1315
+ { field: "handshakeBytes", flag: "relay-handshake-bytes", env: "PINOMAD_SERVER_RELAY_HANDSHAKE_BYTES", unit: "bytes" },
1316
+ { field: "accountBytesPerSecond", flag: "relay-account-rate", env: "PINOMAD_SERVER_RELAY_ACCOUNT_RATE", unit: "bytes" },
1317
+ { field: "accountBurstBytes", flag: "relay-account-burst", env: "PINOMAD_SERVER_RELAY_ACCOUNT_BURST", unit: "bytes" }
1318
+ ];
1319
+ var BYTE_SUFFIX = { "": 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 };
1320
+ function readRelayLimits(flags, env) {
1321
+ const limits = {};
1322
+ for (const option of RELAY_LIMIT_OPTIONS) {
1323
+ const flag = flags[option.flag];
1324
+ const raw = typeof flag === "string" ? flag : env[option.env];
1325
+ if (raw === undefined)
1326
+ continue;
1327
+ const match = /^(\d+)([kmg]?)$/i.exec(raw.trim());
1328
+ const suffix = match?.[2]?.toLowerCase() ?? "";
1329
+ if (match === null || option.unit !== "bytes" && suffix !== "") {
1330
+ throw new Error(`--${option.flag} (${option.env}) must be a whole number${option.unit === "bytes" ? " with an optional k/m/g suffix" : ""}, got "${raw}"`);
1331
+ }
1332
+ const value = Number(match[1]) * BYTE_SUFFIX[suffix];
1333
+ limits[option.field] = option.unit === "seconds" ? value * 1000 : value;
1334
+ }
1335
+ return limits;
1336
+ }
1337
+ var TRAFFIC_LIMIT = { code: RELAY_CLOSE.trafficLimit, reason: "account traffic limit" };
1338
+ var HANDSHAKE_TRAFFIC = { code: RELAY_CLOSE.trafficLimit, reason: "too much data before the handshake" };
1339
+ function createRelayLimiter(limits, now = Date.now) {
1340
+ const hosts = new Map;
1341
+ const accounts = new Map;
1342
+ const rate = limits.accountBytesPerSecond;
1343
+ const capacity = limits.accountBurstBytes > 0 ? limits.accountBurstBytes : rate;
1344
+ const over = (cap, count) => cap > 0 && count >= cap;
1345
+ function refill(state) {
1346
+ const at = now();
1347
+ state.tokens = Math.min(capacity, state.tokens + (at - state.updatedAt) / 1000 * rate);
1348
+ state.updatedAt = at;
1349
+ }
1350
+ return {
1351
+ admit(hostId, account) {
1352
+ const host = hosts.get(hostId) ?? { connections: 0, unauthenticated: 0 };
1353
+ const owner = accounts.get(account) ?? { connections: 0, tokens: capacity, updatedAt: now() };
1354
+ if (rate > 0) {
1355
+ refill(owner);
1356
+ if (owner.tokens < 0)
1357
+ return { ok: false, ...TRAFFIC_LIMIT };
1358
+ }
1359
+ if (over(limits.perHost, host.connections)) {
1360
+ return { ok: false, code: RELAY_CLOSE.tooManyConnections, reason: "too many connections to this host" };
1361
+ }
1362
+ if (over(limits.perAccount, owner.connections)) {
1363
+ return { ok: false, code: RELAY_CLOSE.tooManyConnections, reason: "too many connections for this account" };
1364
+ }
1365
+ if (over(limits.unauthenticatedPerHost, host.unauthenticated)) {
1366
+ return { ok: false, code: RELAY_CLOSE.tooManyConnections, reason: "too many pending handshakes for this host" };
1367
+ }
1368
+ hosts.set(hostId, host);
1369
+ accounts.set(account, owner);
1370
+ host.connections += 1;
1371
+ host.unauthenticated += 1;
1372
+ owner.connections += 1;
1373
+ let authenticated = false;
1374
+ let released = false;
1375
+ let handshakeBytes = 0;
1376
+ return {
1377
+ ok: true,
1378
+ lease: {
1379
+ authenticate() {
1380
+ if (authenticated || released)
1381
+ return;
1382
+ authenticated = true;
1383
+ host.unauthenticated -= 1;
1384
+ },
1385
+ charge(bytes) {
1386
+ if (!authenticated) {
1387
+ handshakeBytes += bytes;
1388
+ return limits.handshakeBytes > 0 && handshakeBytes > limits.handshakeBytes ? HANDSHAKE_TRAFFIC : undefined;
1389
+ }
1390
+ if (rate <= 0)
1391
+ return;
1392
+ refill(owner);
1393
+ owner.tokens -= bytes;
1394
+ return owner.tokens < 0 ? TRAFFIC_LIMIT : undefined;
1395
+ },
1396
+ release() {
1397
+ if (released)
1398
+ return;
1399
+ released = true;
1400
+ host.connections -= 1;
1401
+ if (!authenticated)
1402
+ host.unauthenticated -= 1;
1403
+ owner.connections -= 1;
1404
+ if (host.connections === 0 && hosts.get(hostId) === host)
1405
+ hosts.delete(hostId);
1406
+ if (owner.connections === 0 && accounts.get(account) === owner) {
1407
+ if (rate > 0)
1408
+ refill(owner);
1409
+ if (rate <= 0 || owner.tokens >= capacity)
1410
+ accounts.delete(account);
1411
+ }
1412
+ }
1413
+ }
1414
+ };
1415
+ },
1416
+ forgetAccount(account) {
1417
+ accounts.delete(account);
1418
+ }
1419
+ };
1420
+ }
1421
+
792
1422
  // apps/server/src/api.ts
793
1423
  var SESSION_COOKIE = "__Host-pinomad-session";
1424
+ var HOST_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
794
1425
  var MAX_BODY = 8 * 1024;
795
1426
  var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
796
1427
  var DESKTOP_ORIGIN = "app://pinomad";
@@ -816,7 +1447,7 @@ function respond(response, status, body, headers) {
816
1447
  response.writeHead(status, all);
817
1448
  response.end(body === undefined ? undefined : JSON.stringify(body));
818
1449
  }
819
- async function readJson(request) {
1450
+ async function readJson(request, optional = false) {
820
1451
  const chunks = [];
821
1452
  let size = 0;
822
1453
  let tooLarge = false;
@@ -830,6 +1461,8 @@ async function readJson(request) {
830
1461
  }
831
1462
  if (tooLarge)
832
1463
  return { ok: false, tooLarge: true };
1464
+ if (optional && size === 0)
1465
+ return { ok: true, value: undefined };
833
1466
  try {
834
1467
  return { ok: true, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
835
1468
  } catch {
@@ -879,7 +1512,8 @@ async function handleApiRequest(request, response, ctx) {
879
1512
  }
880
1513
  const bearer = (request.headers.authorization ?? "").startsWith("Bearer ");
881
1514
  const loginTokenMode = (pathname === "/api/login" || pathname === "/api/register") && (origin === undefined || origin === DESKTOP_ORIGIN);
882
- if (MUTATING.has(method) && !bearer && !loginTokenMode && origin !== ctx.publicOrigin) {
1515
+ const hostLinkHostMode = (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll") && origin === undefined;
1516
+ if (MUTATING.has(method) && !bearer && !loginTokenMode && !hostLinkHostMode && origin !== ctx.publicOrigin) {
883
1517
  deny(403, "origin_mismatch");
884
1518
  return;
885
1519
  }
@@ -899,11 +1533,12 @@ async function handleApiRequest(request, response, ctx) {
899
1533
  return;
900
1534
  }
901
1535
  const { username, password } = value;
902
- if ([...username].length === 0 || [...username].length > MAX_USERNAME_CODEPOINTS || [...password].length > MAX_PASSWORD_CODEPOINTS) {
1536
+ const device = parseDeviceClaim(value.device);
1537
+ if ([...username].length === 0 || [...username].length > MAX_USERNAME_CODEPOINTS || [...password].length > MAX_PASSWORD_CODEPOINTS || device === "invalid") {
903
1538
  deny(400, "invalid_request");
904
1539
  return;
905
1540
  }
906
- const result = await accounts.login(username, password, clientIp(request, ctx.trustProxy));
1541
+ const result = await accounts.login(username, password, clientIp(request, ctx.trustProxy), device);
907
1542
  if (result.status === "limited") {
908
1543
  deny(429, "rate_limited");
909
1544
  return;
@@ -932,7 +1567,8 @@ async function handleApiRequest(request, response, ctx) {
932
1567
  return;
933
1568
  }
934
1569
  const value = body.value;
935
- if (typeof value?.invite !== "string" || typeof value.username !== "string" || typeof value.password !== "string") {
1570
+ const device = parseDeviceClaim(value?.device);
1571
+ if (typeof value?.invite !== "string" || typeof value.username !== "string" || typeof value.password !== "string" || device === "invalid") {
936
1572
  deny(400, "invalid_request");
937
1573
  return;
938
1574
  }
@@ -941,7 +1577,8 @@ async function handleApiRequest(request, response, ctx) {
941
1577
  invite,
942
1578
  username,
943
1579
  password: value.password,
944
- ip: clientIp(request, ctx.trustProxy)
1580
+ ip: clientIp(request, ctx.trustProxy),
1581
+ ...device === undefined ? {} : { device }
945
1582
  });
946
1583
  if (result.status === "limited") {
947
1584
  deny(429, "rate_limited");
@@ -956,6 +1593,110 @@ async function handleApiRequest(request, response, ctx) {
956
1593
  send(200, { user: { username }, expiresAt: result.expiresAt, ...cookieMode ? {} : { token: result.token } }, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
957
1594
  return;
958
1595
  }
1596
+ if (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll") {
1597
+ if (method !== "POST") {
1598
+ deny(405, "method_not_allowed", { allow: "POST" });
1599
+ return;
1600
+ }
1601
+ const body = await readJson(request);
1602
+ if (!body.ok) {
1603
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1604
+ return;
1605
+ }
1606
+ if (pathname === "/api/host-link/start") {
1607
+ const value = body.value;
1608
+ if (typeof value?.hostId !== "string" || !HOST_ID_PATTERN.test(value.hostId) || typeof value.name !== "string" || typeof value.os !== "string") {
1609
+ deny(400, "invalid_request");
1610
+ return;
1611
+ }
1612
+ const result2 = accounts.startHostLink({
1613
+ hostId: value.hostId,
1614
+ name: value.name,
1615
+ os: value.os,
1616
+ ip: clientIp(request, ctx.trustProxy)
1617
+ });
1618
+ if (result2.status === "limited") {
1619
+ deny(429, "rate_limited");
1620
+ return;
1621
+ }
1622
+ if (result2.status === "busy") {
1623
+ deny(503, "busy");
1624
+ return;
1625
+ }
1626
+ send(200, {
1627
+ deviceCode: result2.deviceCode,
1628
+ userCode: result2.userCode,
1629
+ verifyUrl: `${ctx.publicOrigin}/host-link`,
1630
+ interval: result2.interval,
1631
+ expiresAt: result2.expiresAt
1632
+ });
1633
+ return;
1634
+ }
1635
+ const deviceCode = body.value?.deviceCode;
1636
+ if (typeof deviceCode !== "string" || deviceCode.length > 256) {
1637
+ deny(400, "invalid_request");
1638
+ return;
1639
+ }
1640
+ const result = accounts.pollHostLink(deviceCode);
1641
+ switch (result.status) {
1642
+ case "confirmed":
1643
+ send(200, { token: result.token, expiresAt: result.expiresAt });
1644
+ return;
1645
+ case "pending":
1646
+ deny(400, "authorization_pending");
1647
+ return;
1648
+ case "slow_down":
1649
+ send(400, { error: "slow_down", interval: result.interval });
1650
+ return;
1651
+ case "denied":
1652
+ deny(400, "access_denied");
1653
+ return;
1654
+ case "expired":
1655
+ deny(400, "expired_token");
1656
+ return;
1657
+ }
1658
+ }
1659
+ if (pathname === "/api/host-link/lookup" || pathname === "/api/host-link/confirm" || pathname === "/api/host-link/deny") {
1660
+ if (method !== "POST") {
1661
+ deny(405, "method_not_allowed", { allow: "POST" });
1662
+ return;
1663
+ }
1664
+ const token = sessionToken(request);
1665
+ if (token === undefined || accounts.authenticate(token) === undefined) {
1666
+ deny(401, "unauthenticated");
1667
+ return;
1668
+ }
1669
+ const body = await readJson(request);
1670
+ if (!body.ok) {
1671
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1672
+ return;
1673
+ }
1674
+ const userCode = body.value?.userCode;
1675
+ if (typeof userCode !== "string" || userCode.length > 64) {
1676
+ deny(400, "invalid_request");
1677
+ return;
1678
+ }
1679
+ const session = accounts.authenticate(token);
1680
+ if (session === undefined) {
1681
+ deny(401, "unauthenticated");
1682
+ return;
1683
+ }
1684
+ const args = { userId: session.userId, userCode, ip: clientIp(request, ctx.trustProxy) };
1685
+ const result = pathname === "/api/host-link/lookup" ? accounts.lookupHostLink(args) : pathname === "/api/host-link/confirm" ? accounts.confirmHostLink(args) : accounts.denyHostLink(args);
1686
+ if (result.status === "limited") {
1687
+ deny(429, "rate_limited");
1688
+ return;
1689
+ }
1690
+ if (result.status === "not_found") {
1691
+ deny(404, "not_found");
1692
+ return;
1693
+ }
1694
+ if ("request" in result)
1695
+ send(200, result.request);
1696
+ else
1697
+ send(204);
1698
+ return;
1699
+ }
959
1700
  if (pathname === "/api/invites" || pathname === "/api/invites/revoke") {
960
1701
  const allowed = pathname === "/api/invites" ? ["GET", "POST"] : ["POST"];
961
1702
  if (!allowed.includes(method)) {
@@ -1021,7 +1762,8 @@ async function handleApiRequest(request, response, ctx) {
1021
1762
  noisePublicKey: row2.noisePublicKey,
1022
1763
  online: pairing !== "offline",
1023
1764
  lastSeenAt: row2.lastSeenAt,
1024
- pairing
1765
+ pairing,
1766
+ deviceReports: row2.deviceReports
1025
1767
  };
1026
1768
  }));
1027
1769
  return;
@@ -1046,6 +1788,10 @@ async function handleApiRequest(request, response, ctx) {
1046
1788
  deny(404, "not_found");
1047
1789
  return;
1048
1790
  }
1791
+ if (result.status === "ok" && !accounts.recordVouching({ token, hostId: row.hostId, requestId: result.requestId, expiresAt: result.expiresAt })) {
1792
+ deny(401, "unauthenticated");
1793
+ return;
1794
+ }
1049
1795
  switch (result.status) {
1050
1796
  case "ok":
1051
1797
  send(200, {
@@ -1071,7 +1817,59 @@ async function handleApiRequest(request, response, ctx) {
1071
1817
  }
1072
1818
  return;
1073
1819
  }
1074
- if (pathname === "/api/me" || pathname === "/api/logout" || pathname === "/api/sessions/revoke-all" || pathname === "/api/join-tokens" || pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
1820
+ const deviceKick = /^\/api\/devices\/([1-9][0-9]{0,14})\/kick$/.exec(pathname);
1821
+ if (pathname === "/api/devices" || deviceKick !== null) {
1822
+ const expected = pathname === "/api/devices" ? "GET" : "POST";
1823
+ if (method !== expected) {
1824
+ deny(405, "method_not_allowed", { allow: expected });
1825
+ return;
1826
+ }
1827
+ const token = sessionToken(request);
1828
+ const session = token === undefined ? undefined : accounts.authenticate(token);
1829
+ if (session === undefined) {
1830
+ deny(401, "unauthenticated");
1831
+ return;
1832
+ }
1833
+ if (deviceKick === null) {
1834
+ send(200, accounts.listDevices(session.userId).map((device) => ({ ...device, current: device.id === session.deviceId })));
1835
+ return;
1836
+ }
1837
+ const body = await readJson(request);
1838
+ if (!body.ok) {
1839
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1840
+ return;
1841
+ }
1842
+ const value = body.value;
1843
+ if (typeof value?.password !== "string" || [...value.password].length > MAX_PASSWORD_CODEPOINTS) {
1844
+ deny(400, "invalid_request");
1845
+ return;
1846
+ }
1847
+ const current = accounts.authenticate(token);
1848
+ if (current === undefined) {
1849
+ deny(401, "unauthenticated");
1850
+ return;
1851
+ }
1852
+ const result = await accounts.kickDevice({
1853
+ userId: current.userId,
1854
+ username: current.username,
1855
+ password: value.password,
1856
+ deviceId: Number(deviceKick[1]),
1857
+ ...current.deviceId === undefined ? {} : { currentDeviceId: current.deviceId }
1858
+ });
1859
+ if (result.status === "limited") {
1860
+ deny(429, "rate_limited");
1861
+ return;
1862
+ }
1863
+ if (result.status === "refused") {
1864
+ deny(result.reason === "not_found" ? 404 : result.reason === "current_device" ? 409 : 403, result.reason);
1865
+ return;
1866
+ }
1867
+ for (const hostId of result.hostIds)
1868
+ ctx.sendRevocations(hostId);
1869
+ send(204);
1870
+ return;
1871
+ }
1872
+ if (pathname === "/api/me" || pathname === "/api/logout" || pathname === "/api/sessions/revoke-all" || pathname === "/api/password" || pathname === "/api/me/delete" || pathname === "/api/join-tokens" || pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
1075
1873
  const expected = pathname === "/api/me" ? "GET" : "POST";
1076
1874
  if (method !== expected) {
1077
1875
  deny(405, "method_not_allowed", { allow: expected });
@@ -1100,28 +1898,96 @@ async function handleApiRequest(request, response, ctx) {
1100
1898
  deny(400, "invalid_request");
1101
1899
  return;
1102
1900
  }
1103
- if (accounts.authenticate(token) === undefined) {
1901
+ const current = accounts.authenticate(token);
1902
+ if (current === undefined) {
1104
1903
  deny(401, "unauthenticated");
1105
1904
  return;
1106
1905
  }
1107
1906
  if (pathname === "/api/push-tokens")
1108
- accounts.registerPushToken(session.userId, apns, environment);
1907
+ accounts.registerPushToken(session.userId, apns, environment, current.deviceId);
1109
1908
  else
1110
1909
  accounts.removePushToken(session.userId, apns, environment);
1111
1910
  send(204);
1112
1911
  return;
1113
1912
  }
1913
+ if (pathname === "/api/me/delete") {
1914
+ const body = await readJson(request);
1915
+ if (!body.ok) {
1916
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1917
+ return;
1918
+ }
1919
+ const value = body.value;
1920
+ if (typeof value?.password !== "string" || [...value.password].length > MAX_PASSWORD_CODEPOINTS) {
1921
+ deny(400, "invalid_request");
1922
+ return;
1923
+ }
1924
+ const result = await accounts.deleteAccount({ userId: session.userId, username: session.username, password: value.password });
1925
+ if (result.status === "limited") {
1926
+ deny(429, "rate_limited");
1927
+ return;
1928
+ }
1929
+ if (result.status === "refused") {
1930
+ deny(403, result.reason);
1931
+ return;
1932
+ }
1933
+ for (const hostId of result.hostIds)
1934
+ ctx.disconnectHost(hostId);
1935
+ ctx.forgetAccount(session.userId);
1936
+ send(204, undefined, { "set-cookie": clearSessionCookie });
1937
+ return;
1938
+ }
1939
+ if (pathname === "/api/password") {
1940
+ const body = await readJson(request);
1941
+ if (!body.ok) {
1942
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1943
+ return;
1944
+ }
1945
+ const value = body.value;
1946
+ if (typeof value?.currentPassword !== "string" || typeof value.newPassword !== "string" || [...value.currentPassword].length > MAX_PASSWORD_CODEPOINTS) {
1947
+ deny(400, "invalid_request");
1948
+ return;
1949
+ }
1950
+ const result = await accounts.changePassword({
1951
+ userId: session.userId,
1952
+ username: session.username,
1953
+ keepToken: token,
1954
+ currentPassword: value.currentPassword,
1955
+ newPassword: value.newPassword
1956
+ });
1957
+ if (result.status === "limited") {
1958
+ deny(429, "rate_limited");
1959
+ return;
1960
+ }
1961
+ if (result.status === "refused") {
1962
+ deny(result.reason === "wrong_password" ? 403 : 400, result.reason);
1963
+ return;
1964
+ }
1965
+ send(204);
1966
+ return;
1967
+ }
1114
1968
  if (pathname === "/api/join-tokens") {
1115
1969
  const joinToken = accounts.createJoinToken(session.userId);
1116
1970
  send(200, {
1117
1971
  token: joinToken.token,
1118
1972
  expiresAt: joinToken.expiresAt,
1119
- command: `npm i -g pinomad && pinomad join ${ctx.publicOrigin} ${joinToken.token}`
1973
+ command: `curl -fsSL https://pinomad.si/install.sh | sh -s -- join ${ctx.publicOrigin} ${joinToken.token}`
1120
1974
  });
1121
1975
  return;
1122
1976
  }
1123
1977
  if (pathname === "/api/logout") {
1124
- accounts.logout(token);
1978
+ const body = await readJson(request, true);
1979
+ if (!body.ok) {
1980
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1981
+ return;
1982
+ }
1983
+ const value = body.value;
1984
+ const revoke = typeof value === "object" && value !== null ? value.revoke : undefined;
1985
+ if (value === null || value !== undefined && typeof value !== "object" || revoke !== undefined && typeof revoke !== "boolean") {
1986
+ deny(400, "invalid_request");
1987
+ return;
1988
+ }
1989
+ for (const hostId of accounts.logout(token, { revoke: revoke === true }))
1990
+ ctx.sendRevocations(hostId);
1125
1991
  send(204, undefined, { "set-cookie": clearSessionCookie });
1126
1992
  return;
1127
1993
  }
@@ -1163,6 +2029,10 @@ async function startRelay(options) {
1163
2029
  const pushTotal = options.pushLimits?.total ?? MAX_PUSH_TOTAL;
1164
2030
  let pushInflightTotal = 0;
1165
2031
  const pushInflightHosts = new Map;
2032
+ const limits = { ...DEFAULT_RELAY_LIMITS, ...options.limits };
2033
+ const limiter = createRelayLimiter(limits, options.now);
2034
+ const accountKey = (userId) => `account:${userId}`;
2035
+ const hostDevices = new Map;
1166
2036
  const controls = new Map;
1167
2037
  const pending = new Map;
1168
2038
  const pairingRequests = new Map;
@@ -1175,11 +2045,23 @@ async function startRelay(options) {
1175
2045
  socket.on("close", () => sockets.delete(socket));
1176
2046
  socket.on("error", () => socket.terminate());
1177
2047
  }
1178
- function splice(a, b) {
2048
+ function splice(host, device, lease, handshakeTimer) {
2049
+ let authenticated = false;
1179
2050
  const forward = (from, to) => {
1180
2051
  from.on("message", (data, isBinary) => {
1181
2052
  if (to.readyState !== WebSocket.OPEN)
1182
2053
  return;
2054
+ if (from === host && !authenticated) {
2055
+ authenticated = true;
2056
+ lease.authenticate();
2057
+ clearTimeout(handshakeTimer);
2058
+ }
2059
+ const refusal = lease.charge(rawLength(data));
2060
+ if (refusal !== undefined) {
2061
+ device.close(refusal.code, refusal.reason);
2062
+ host.close(refusal.code, refusal.reason);
2063
+ return;
2064
+ }
1183
2065
  to.send(data, { binary: isBinary }, () => {
1184
2066
  if (from.isPaused && to.bufferedAmount <= LOW_WATER)
1185
2067
  from.resume();
@@ -1193,8 +2075,8 @@ async function startRelay(options) {
1193
2075
  to.close(isSendableCloseCode(code) ? code : 1001, reason);
1194
2076
  });
1195
2077
  };
1196
- forward(a, b);
1197
- forward(b, a);
2078
+ forward(host, device);
2079
+ forward(device, host);
1198
2080
  }
1199
2081
  function pairingStatus(hostId, boundNoiseKey) {
1200
2082
  const control = controls.get(hostId);
@@ -1236,6 +2118,22 @@ async function startRelay(options) {
1236
2118
  });
1237
2119
  }
1238
2120
  const pairing = { status: pairingStatus, request: requestPairing };
2121
+ function disconnectHost(hostId) {
2122
+ controls.get(hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
2123
+ closeHostDevices(hostId, RELAY_CLOSE.hostNotAllowed, "host left its account");
2124
+ }
2125
+ function closeHostDevices(hostId, code, reason) {
2126
+ for (const device of hostDevices.get(hostId) ?? [])
2127
+ device.close(code, reason);
2128
+ }
2129
+ function sendRevocations(hostId) {
2130
+ const control = controls.get(hostId);
2131
+ if (accounts === undefined || control?.features?.includes("device-reports") !== true)
2132
+ return;
2133
+ for (const publicKey of accounts.pendingRevocations(hostId)) {
2134
+ control.socket.send(encodeRelayFrame({ t: "revoke-device", publicKey }));
2135
+ }
2136
+ }
1239
2137
  function handlePush(socket, hostId, frame) {
1240
2138
  const startedAt = Date.now();
1241
2139
  const finish = (verdict) => {
@@ -1296,6 +2194,7 @@ async function startRelay(options) {
1296
2194
  answer({ t: "refused", reason: "bad_request" });
1297
2195
  return;
1298
2196
  }
2197
+ const previousOwner = accounts.hostOwner(frame.hostId);
1299
2198
  const result = accounts.joinHost({
1300
2199
  token: frame.joinToken,
1301
2200
  hostId: frame.hostId,
@@ -1307,6 +2206,9 @@ async function startRelay(options) {
1307
2206
  if (request.hostId === frame.hostId)
1308
2207
  request.settle({ status: "gone" });
1309
2208
  }
2209
+ if (previousOwner !== undefined && previousOwner !== accounts.hostOwner(frame.hostId)) {
2210
+ closeHostDevices(frame.hostId, RELAY_CLOSE.replaced, "host moved to another account");
2211
+ }
1310
2212
  }
1311
2213
  answer(result.status === "joined" ? { t: "joined", username: result.username } : { t: "refused", reason: result.reason });
1312
2214
  return;
@@ -1316,7 +2218,7 @@ async function startRelay(options) {
1316
2218
  return;
1317
2219
  }
1318
2220
  answer({ t: "left" });
1319
- controls.get(frame.hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
2221
+ disconnectHost(frame.hostId);
1320
2222
  }
1321
2223
  function onControl(socket) {
1322
2224
  track(socket);
@@ -1341,6 +2243,16 @@ async function startRelay(options) {
1341
2243
  handlePush(socket, hostId, answer);
1342
2244
  return;
1343
2245
  }
2246
+ if (answer.t === "device-admitted") {
2247
+ if (accounts?.recordAdmission(hostId, answer.requestId, answer.publicKey) === "revoke") {
2248
+ sendRevocations(hostId);
2249
+ }
2250
+ return;
2251
+ }
2252
+ if (answer.t === "device-revoked") {
2253
+ accounts?.confirmRevocation(hostId, answer.publicKey);
2254
+ return;
2255
+ }
1344
2256
  if (answer.t === "name") {
1345
2257
  if (controls.get(hostId)?.socket === socket)
1346
2258
  accounts?.renameHost(hostId, answer.name);
@@ -1352,6 +2264,7 @@ async function startRelay(options) {
1352
2264
  return;
1353
2265
  request.settle(answer.t === "pairing" ? {
1354
2266
  status: "ok",
2267
+ requestId: answer.requestId,
1355
2268
  secret: answer.secret,
1356
2269
  expiresAt: answer.expiresAt,
1357
2270
  ...answer.endpointSet === undefined ? {} : { endpointSet: answer.endpointSet }
@@ -1406,10 +2319,11 @@ async function startRelay(options) {
1406
2319
  ...noisePublicKey === undefined ? {} : { noisePublicKey },
1407
2320
  ...frame.features === undefined ? {} : { features: frame.features }
1408
2321
  });
1409
- accounts?.markHostSeen(hostId);
2322
+ accounts?.markHostSeen(hostId, frame.features?.includes("device-reports") === true);
1410
2323
  if (frame.name !== undefined)
1411
2324
  accounts?.renameHost(hostId, frame.name);
1412
- socket.send(encodeRelayFrame({ t: "registered", push: pushEnvironments, features: ["name"] }));
2325
+ socket.send(encodeRelayFrame({ t: "registered", push: pushEnvironments, features: ["name", "device-reports"] }));
2326
+ sendRevocations(hostId);
1413
2327
  });
1414
2328
  socket.once("close", () => {
1415
2329
  clearTimeout(deadline);
@@ -1435,10 +2349,29 @@ async function startRelay(options) {
1435
2349
  socket.close(RELAY_CLOSE.hostOffline);
1436
2350
  return;
1437
2351
  }
2352
+ const owner = accounts?.hostOwner(hostId);
2353
+ const admission = limiter.admit(hostId, owner === undefined ? `host:${hostId}` : accountKey(owner));
2354
+ if (!admission.ok) {
2355
+ socket.close(admission.code, admission.reason);
2356
+ return;
2357
+ }
2358
+ const { lease } = admission;
2359
+ const handshakeTimer = limits.handshakeTimeoutMs > 0 ? setTimeout(() => socket.close(RELAY_CLOSE.handshakeTimeout, "handshake timeout"), limits.handshakeTimeoutMs) : undefined;
2360
+ const devices = hostDevices.get(hostId) ?? new Set;
2361
+ hostDevices.set(hostId, devices.add(socket));
2362
+ socket.once("close", () => {
2363
+ clearTimeout(handshakeTimer);
2364
+ lease.release();
2365
+ devices.delete(socket);
2366
+ if (devices.size === 0 && hostDevices.get(hostId) === devices)
2367
+ hostDevices.delete(hostId);
2368
+ });
1438
2369
  const connId = randomBytes2(16).toString("base64url");
1439
2370
  const entry = {
1440
2371
  control,
1441
2372
  socket,
2373
+ lease,
2374
+ handshakeTimer,
1442
2375
  buffered: [],
1443
2376
  bufferedBytes: 0,
1444
2377
  timer: setTimeout(() => {
@@ -1475,7 +2408,13 @@ async function startRelay(options) {
1475
2408
  const device = entry.socket;
1476
2409
  device.removeListener("message", entry.onMessage);
1477
2410
  device.removeListener("close", entry.onClose);
1478
- splice(socket, device);
2411
+ const refusal = entry.lease.charge(entry.bufferedBytes);
2412
+ if (refusal !== undefined) {
2413
+ device.close(refusal.code, refusal.reason);
2414
+ socket.close(refusal.code, refusal.reason);
2415
+ return;
2416
+ }
2417
+ splice(socket, device, entry.lease, entry.handshakeTimer);
1479
2418
  for (const { data, isBinary } of entry.buffered)
1480
2419
  socket.send(data, { binary: isBinary });
1481
2420
  }
@@ -1492,7 +2431,15 @@ async function startRelay(options) {
1492
2431
  response.end(JSON.stringify({ error: "accounts_disabled" }));
1493
2432
  return;
1494
2433
  }
1495
- handleApiRequest(request, response, { accounts, publicOrigin, trustProxy, pairing }).catch(() => {
2434
+ handleApiRequest(request, response, {
2435
+ accounts,
2436
+ publicOrigin,
2437
+ trustProxy,
2438
+ pairing,
2439
+ disconnectHost,
2440
+ forgetAccount: (userId) => limiter.forgetAccount(accountKey(userId)),
2441
+ sendRevocations
2442
+ }).catch(() => {
1496
2443
  if (!response.headersSent)
1497
2444
  response.writeHead(500);
1498
2445
  response.end();
@@ -1558,6 +2505,9 @@ async function startRelay(options) {
1558
2505
  socket.terminate();
1559
2506
  }
1560
2507
  }
2508
+ try {
2509
+ accounts?.sweepExpired();
2510
+ } catch {}
1561
2511
  }, pingIntervalMs);
1562
2512
  return {
1563
2513
  port,
@@ -1619,7 +2569,7 @@ async function passwdMain(argv) {
1619
2569
  }
1620
2570
  const dataDir = resolveDataDir(values["data-dir"]);
1621
2571
  const password = (await readStdin(16 * 1024)).replace(/[\r\n]+$/, "");
1622
- await changePassword({ dataDir, user: values.user, password });
2572
+ await resetPassword({ dataDir, user: values.user, password });
1623
2573
  console.log(JSON.stringify({ event: "passwd", dataDir }));
1624
2574
  }
1625
2575
  async function main(argv) {
@@ -1637,13 +2587,22 @@ async function main(argv) {
1637
2587
  listen: { type: "string", default: "127.0.0.1" },
1638
2588
  "public-origin": { type: "string" },
1639
2589
  "data-dir": { type: "string" },
1640
- "trust-proxy": { type: "boolean", default: false }
2590
+ "trust-proxy": { type: "boolean", default: false },
2591
+ ...Object.fromEntries(RELAY_LIMIT_OPTIONS.map((option) => [option.flag, { type: "string" }]))
1641
2592
  }
1642
2593
  });
1643
2594
  if (values["public-origin"] === undefined) {
1644
2595
  console.error(`usage: ${commandHint("server")} --public-origin ORIGIN [--data-dir DIR] [--trust-proxy] [--port 7430] [--listen 127.0.0.1]
1645
- ` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user NAME] (server stopped; new password on stdin)
1646
- ` + " env: PINOMAD_SERVER_USER, PINOMAD_SERVER_PASSWORD, PINOMAD_SERVER_DATA_DIR");
2596
+ ` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user NAME] (reset a password, server running or not; new password on stdin)
2597
+ ` + ` env: PINOMAD_SERVER_USER, PINOMAD_SERVER_PASSWORD, PINOMAD_SERVER_DATA_DIR
2598
+ ` + " relay limits (flag or env, 0 = off): " + RELAY_LIMIT_OPTIONS.map((option) => `--${option.flag} / ${option.env}`).join(", "));
2599
+ process.exit(1);
2600
+ }
2601
+ let limits;
2602
+ try {
2603
+ limits = readRelayLimits(values, process.env);
2604
+ } catch (error) {
2605
+ console.error(error instanceof Error ? error.message : String(error));
1647
2606
  process.exit(1);
1648
2607
  }
1649
2608
  const accounts = await openAccounts({
@@ -1663,13 +2622,15 @@ async function main(argv) {
1663
2622
  webRoot,
1664
2623
  accounts,
1665
2624
  trustProxy: values["trust-proxy"],
1666
- ...apns === undefined ? {} : { push: apns }
2625
+ ...apns === undefined ? {} : { push: apns },
2626
+ limits
1667
2627
  });
1668
2628
  console.log(JSON.stringify({
1669
2629
  event: "ready",
1670
2630
  url: relay.url,
1671
2631
  publicOrigin: values["public-origin"],
1672
- push: apns?.environments ?? []
2632
+ push: apns?.environments ?? [],
2633
+ relayLimits: { ...DEFAULT_RELAY_LIMITS, ...limits }
1673
2634
  }));
1674
2635
  let stopping = false;
1675
2636
  const stop = () => {