pinomad 0.9.1 → 0.10.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,11 +9,11 @@ import {
9
9
  relayWsBase,
10
10
  verifyRelayChallenge,
11
11
  verifyRelayChallengeV2
12
- } from "./chunk-khcpv54k.js";
12
+ } from "./chunk-cx8k63by.js";
13
13
  import {
14
14
  commandHint,
15
15
  webRoot
16
- } from "./chunk-fqgvf8v0.js";
16
+ } from "./chunk-zehwx009.js";
17
17
  import {
18
18
  normalizeHostName
19
19
  } from "./chunk-sygeb9qb.js";
@@ -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,7 +58,7 @@ 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 = 4;
61
62
  var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
62
63
  var JOIN_TOKEN_TTL_MS = 10 * 60 * 1000;
63
64
  var INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -66,6 +67,23 @@ var PUSH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
66
67
  var MAX_PUSH_TOKENS_PER_USER = 32;
67
68
  var APNS_TOKEN_PATTERN = /^(?:[0-9a-f]{2})+$/;
68
69
  var APNS_TOKEN_MAX_CHARS = 256;
70
+ var DEVICE_ID_PATTERN = /^[A-Za-z0-9_-]{22,64}$/;
71
+ var DEVICE_NAME_MAX_CODEPOINTS = 64;
72
+ var ADMISSION_REPORT_GRACE_MS = 24 * 60 * 60 * 1000;
73
+ var MAX_OFFER_TTL_MS = 6 * 60 * 1000;
74
+ function parseDeviceClaim(raw) {
75
+ if (raw === undefined)
76
+ return;
77
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
78
+ return "invalid";
79
+ const { id, name } = raw;
80
+ if (typeof id !== "string" || !DEVICE_ID_PATTERN.test(id))
81
+ return "invalid";
82
+ if (name !== undefined && typeof name !== "string")
83
+ return "invalid";
84
+ const clipped = [...(name ?? "").trim()].slice(0, DEVICE_NAME_MAX_CODEPOINTS).join("").trim();
85
+ return clipped === "" ? { id } : { id, name: clipped };
86
+ }
69
87
  function normalizeApnsToken(raw) {
70
88
  const token = raw.toLowerCase();
71
89
  return token.length <= APNS_TOKEN_MAX_CHARS && APNS_TOKEN_PATTERN.test(token) ? token : undefined;
@@ -117,11 +135,17 @@ function assertUsernamePolicy(username) {
117
135
  throw new Error(`username must be 1–${MAX_USERNAME_CODEPOINTS} characters`);
118
136
  }
119
137
  }
120
- function migrate(db) {
138
+ var BUSY_TIMEOUT_MS = 5000;
139
+ function openDatabase(dataDir) {
140
+ const db = new DatabaseSync(join(dataDir, DB_FILE));
141
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
121
142
  db.exec("PRAGMA journal_mode = WAL");
122
143
  db.exec("PRAGMA foreign_keys = ON");
123
- const row = db.prepare("PRAGMA user_version").get();
124
- const version = row?.user_version ?? 0;
144
+ return db;
145
+ }
146
+ var schemaVersion = (db) => db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
147
+ function migrate(db) {
148
+ const version = schemaVersion(db);
125
149
  if (version > SCHEMA_VERSION) {
126
150
  throw new Error(`server database schema ${version} is newer than this build (${SCHEMA_VERSION})`);
127
151
  }
@@ -164,6 +188,63 @@ function migrate(db) {
164
188
  COMMIT;
165
189
  `);
166
190
  }
191
+ if (version < 4)
192
+ migrateTo4(db);
193
+ }
194
+ function migrateTo4(db) {
195
+ db.exec("BEGIN");
196
+ try {
197
+ db.exec(`
198
+ -- AUTOINCREMENT: ids appear in kick URLs, and a reused one would let a
199
+ -- stale list kick a device that signed in later.
200
+ CREATE TABLE account_devices (
201
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
202
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
203
+ -- The client's own ID; NULL for clients that send none, which never
204
+ -- match each other (SQLite UNIQUE treats NULLs as distinct).
205
+ client_id TEXT,
206
+ name TEXT,
207
+ created_at INTEGER NOT NULL,
208
+ last_seen_at INTEGER NOT NULL,
209
+ kicked_at INTEGER,
210
+ UNIQUE (user_id, client_id)
211
+ );
212
+ ALTER TABLE sessions ADD COLUMN device_id INTEGER REFERENCES account_devices(id) ON DELETE CASCADE;
213
+ CREATE INDEX sessions_device ON sessions (device_id);
214
+ ALTER TABLE hosts ADD COLUMN device_reports INTEGER NOT NULL DEFAULT 0;
215
+ CREATE TABLE vouchings (
216
+ request_id TEXT PRIMARY KEY,
217
+ device_id INTEGER NOT NULL REFERENCES account_devices(id) ON DELETE CASCADE,
218
+ host_id TEXT NOT NULL REFERENCES hosts(host_id) ON DELETE CASCADE,
219
+ expires_at INTEGER NOT NULL,
220
+ -- Filled by the host's device-admitted report.
221
+ public_key TEXT,
222
+ admitted_at INTEGER,
223
+ -- Set when the account withdrew this vouching (a kick), reported
224
+ -- yet or not: a report that arrives later is revoked at once.
225
+ revoked_at INTEGER
226
+ );
227
+ CREATE INDEX vouchings_device ON vouchings (device_id);
228
+ CREATE INDEX vouchings_host_key ON vouchings (host_id, public_key);
229
+ CREATE TABLE pending_revocations (
230
+ host_id TEXT NOT NULL REFERENCES hosts(host_id) ON DELETE CASCADE,
231
+ public_key TEXT NOT NULL,
232
+ created_at INTEGER NOT NULL,
233
+ PRIMARY KEY (host_id, public_key)
234
+ );
235
+ `);
236
+ const sessions = db.prepare("SELECT token_hash AS tokenHash, user_id AS userId, created_at AS createdAt FROM sessions").all();
237
+ const insert = db.prepare("INSERT INTO account_devices (user_id, created_at, last_seen_at) VALUES (?, ?, ?)");
238
+ const link = db.prepare("UPDATE sessions SET device_id = ? WHERE token_hash = ?");
239
+ for (const session of sessions) {
240
+ const deviceId = insert.run(session.userId, session.createdAt, session.createdAt).lastInsertRowid;
241
+ link.run(deviceId, session.tokenHash);
242
+ }
243
+ db.exec("PRAGMA user_version = 4; COMMIT");
244
+ } catch (error) {
245
+ db.exec("ROLLBACK");
246
+ throw error;
247
+ }
167
248
  }
168
249
  function migrateTo1(db) {
169
250
  db.exec(`
@@ -276,7 +357,7 @@ async function openAccounts(options) {
276
357
  const release = await lockDataDir(options.dataDir, { staleMs: options.lockStaleMs });
277
358
  let db;
278
359
  try {
279
- db = new DatabaseSync(join(options.dataDir, DB_FILE));
360
+ db = openDatabase(options.dataDir);
280
361
  migrate(db);
281
362
  const userCount = db.prepare("SELECT COUNT(*) AS n FROM users").get().n;
282
363
  if (userCount === 0) {
@@ -305,13 +386,52 @@ async function openAccounts(options) {
305
386
  const gate = new ScryptGate(limits.scryptConcurrency, limits.scryptQueue);
306
387
  const ipAttempts = new WindowedCounter(limits.counterMaxEntries, now);
307
388
  const userFailures = new WindowedCounter(limits.counterMaxEntries, now);
308
- const issueSession = (userId) => {
389
+ const deviceForSignIn = (userId, claim) => {
390
+ const at = now();
391
+ if (claim === undefined) {
392
+ db.prepare(`DELETE FROM account_devices WHERE user_id = ? AND client_id IS NULL
393
+ AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.device_id = account_devices.id AND s.expires_at > ?)
394
+ AND NOT EXISTS (SELECT 1 FROM vouchings v WHERE v.device_id = account_devices.id)`).run(userId, at);
395
+ return Number(db.prepare("INSERT INTO account_devices (user_id, created_at, last_seen_at) VALUES (?, ?, ?)").run(userId, at, at).lastInsertRowid);
396
+ }
397
+ const row = db.prepare(`INSERT INTO account_devices (user_id, client_id, name, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)
398
+ ON CONFLICT (user_id, client_id) DO UPDATE SET
399
+ name = COALESCE(excluded.name, name), last_seen_at = excluded.last_seen_at, kicked_at = NULL
400
+ RETURNING id`).get(userId, claim.id, claim.name ?? null, at, at);
401
+ return row.id;
402
+ };
403
+ const issueSession = (userId, device) => {
309
404
  db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(now());
405
+ const deviceId = deviceForSignIn(userId, device);
310
406
  const token = randomBytes(32).toString("base64url");
311
407
  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);
408
+ db.prepare("INSERT INTO sessions (token_hash, user_id, created_at, expires_at, device_id) VALUES (?, ?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt, deviceId);
313
409
  return { token, expiresAt };
314
410
  };
411
+ const sweepVouchings = () => {
412
+ db.prepare("DELETE FROM vouchings WHERE public_key IS NULL AND expires_at <= ?").run(now() - ADMISSION_REPORT_GRACE_MS);
413
+ };
414
+ const currentHash = (userId) => db.prepare("SELECT password_hash AS passwordHash FROM users WHERE id = ?").get(userId)?.passwordHash;
415
+ const checkPassword = async (userId, username, password) => {
416
+ if (userFailures.isOver(username, limits.userMaxFailures))
417
+ return "limited";
418
+ const stored = currentHash(userId);
419
+ const outcome = await gate.run(async () => {
420
+ if (userFailures.isOver(username, limits.userMaxFailures))
421
+ return "limited";
422
+ if (!await verifyPassword(password, stored ?? DUMMY_HASH) || stored === undefined) {
423
+ userFailures.bump(username, limits.userWindowMs);
424
+ return "wrong_password";
425
+ }
426
+ return "ok";
427
+ });
428
+ if (outcome === undefined || outcome === "limited")
429
+ return "limited";
430
+ if (outcome === "wrong_password")
431
+ return "wrong_password";
432
+ userFailures.reset(username);
433
+ return { verifiedHash: stored };
434
+ };
315
435
  const inviteState = (codeHash) => {
316
436
  const row = db.prepare("SELECT expires_at AS expiresAt, used_at AS usedAt FROM invites WHERE code_hash = ?").get(codeHash);
317
437
  if (row === undefined || row.usedAt !== null)
@@ -321,7 +441,7 @@ async function openAccounts(options) {
321
441
  const usernameTaken = (username) => db.prepare("SELECT 1 AS x FROM users WHERE lower(username) = ?").get(username) !== undefined;
322
442
  return {
323
443
  dbPath: join(options.dataDir, DB_FILE),
324
- async login(username, password, ip) {
444
+ async login(username, password, ip, device) {
325
445
  if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
326
446
  return { status: "limited" };
327
447
  if (userFailures.isOver(username, limits.userMaxFailures))
@@ -341,10 +461,22 @@ async function openAccounts(options) {
341
461
  return { status: "limited" };
342
462
  if (outcome === "invalid")
343
463
  return { status: "invalid" };
464
+ db.exec("BEGIN IMMEDIATE");
465
+ let session;
466
+ try {
467
+ if (currentHash(outcome) === user?.passwordHash)
468
+ session = issueSession(outcome, device);
469
+ db.exec("COMMIT");
470
+ } catch (error) {
471
+ db.exec("ROLLBACK");
472
+ throw error;
473
+ }
474
+ if (session === undefined)
475
+ return { status: "invalid" };
344
476
  userFailures.reset(username);
345
- return { status: "ok", ...issueSession(outcome), admin: user?.admin === 1 };
477
+ return { status: "ok", ...session, admin: user?.admin === 1 };
346
478
  },
347
- async register({ invite, username, password, ip }) {
479
+ async register({ invite, username, password, ip, device }) {
348
480
  if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
349
481
  return { status: "limited" };
350
482
  const codeHash = sha256Hex(invite);
@@ -376,7 +508,7 @@ async function openAccounts(options) {
376
508
  db.exec("ROLLBACK");
377
509
  return { status: "refused", reason: "invalid_invite" };
378
510
  }
379
- const session = issueSession(userId);
511
+ const session = issueSession(userId, device);
380
512
  db.exec("COMMIT");
381
513
  return { status: "ok", ...session };
382
514
  } catch (error) {
@@ -406,15 +538,72 @@ async function openAccounts(options) {
406
538
  return Number(db.prepare("DELETE FROM invites WHERE id = ? AND used_at IS NULL").run(id).changes) > 0;
407
539
  },
408
540
  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
541
+ 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
542
  FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?`).get(sha256Hex(token));
411
543
  if (row === undefined || row.expiresAt <= now())
412
544
  return;
413
- return { userId: row.userId, username: row.username, admin: row.admin === 1 };
545
+ return {
546
+ userId: row.userId,
547
+ username: row.username,
548
+ admin: row.admin === 1,
549
+ ...row.deviceId === null ? {} : { deviceId: row.deviceId }
550
+ };
414
551
  },
415
552
  logout(token) {
416
553
  db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256Hex(token));
417
554
  },
555
+ async changePassword({ userId, username, keepToken, currentPassword, newPassword }) {
556
+ const length = [...newPassword].length;
557
+ if (length < MIN_PASSWORD_CODEPOINTS || length > MAX_PASSWORD_CODEPOINTS) {
558
+ return { status: "refused", reason: "weak_password" };
559
+ }
560
+ const checked = await checkPassword(userId, username, currentPassword);
561
+ if (checked === "limited")
562
+ return { status: "limited" };
563
+ if (checked === "wrong_password")
564
+ return { status: "refused", reason: "wrong_password" };
565
+ const hash = await gate.run(() => hashPassword(newPassword));
566
+ if (hash === undefined)
567
+ return { status: "limited" };
568
+ db.exec("BEGIN IMMEDIATE");
569
+ try {
570
+ const swapped = db.prepare("UPDATE users SET password_hash = ? WHERE id = ? AND password_hash = ?").run(hash, userId, checked.verifiedHash);
571
+ if (Number(swapped.changes) !== 1) {
572
+ db.exec("ROLLBACK");
573
+ return { status: "refused", reason: "wrong_password" };
574
+ }
575
+ dropCredentials(db, userId, sha256Hex(keepToken));
576
+ db.exec("COMMIT");
577
+ } catch (error) {
578
+ db.exec("ROLLBACK");
579
+ throw error;
580
+ }
581
+ return { status: "ok" };
582
+ },
583
+ async deleteAccount({ userId, username, password }) {
584
+ const user = db.prepare("SELECT admin FROM users WHERE id = ?").get(userId);
585
+ if (user?.admin === 1)
586
+ return { status: "refused", reason: "admin_account" };
587
+ const checked = await checkPassword(userId, username, password);
588
+ if (checked === "limited")
589
+ return { status: "limited" };
590
+ if (checked === "wrong_password")
591
+ return { status: "refused", reason: "wrong_password" };
592
+ db.exec("BEGIN IMMEDIATE");
593
+ try {
594
+ const hostIds = db.prepare("SELECT host_id AS hostId FROM hosts WHERE user_id = ?").all(userId).map((row) => row.hostId);
595
+ const deleted = db.prepare("DELETE FROM users WHERE id = ? AND password_hash = ?").run(userId, checked.verifiedHash);
596
+ if (Number(deleted.changes) !== 1) {
597
+ db.exec("ROLLBACK");
598
+ return { status: "refused", reason: "wrong_password" };
599
+ }
600
+ db.exec("COMMIT");
601
+ return { status: "ok", hostIds };
602
+ } catch (error) {
603
+ db.exec("ROLLBACK");
604
+ throw error;
605
+ }
606
+ },
418
607
  revokeAllSessions(userId) {
419
608
  db.exec("BEGIN");
420
609
  try {
@@ -487,6 +676,11 @@ async function openAccounts(options) {
487
676
  return { status: "refused", reason: failure };
488
677
  }
489
678
  const hostName = name === undefined ? "Unknown host" : normalizeHostName(name);
679
+ const previousOwner = db.prepare("SELECT user_id AS userId FROM hosts WHERE host_id = ?").get(hostId)?.userId;
680
+ if (previousOwner !== undefined && previousOwner !== row.userId) {
681
+ db.prepare("DELETE FROM vouchings WHERE host_id = ?").run(hostId);
682
+ db.prepare("DELETE FROM pending_revocations WHERE host_id = ?").run(hostId);
683
+ }
490
684
  db.prepare(`INSERT INTO hosts (host_id, user_id, noise_public_key, name, joined_at, last_seen_at)
491
685
  VALUES (?, ?, ?, ?, ?, ?)
492
686
  ON CONFLICT(host_id) DO UPDATE SET
@@ -508,16 +702,119 @@ async function openAccounts(options) {
508
702
  },
509
703
  listHosts(userId) {
510
704
  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);
705
+ name, joined_at AS joinedAt, last_seen_at AS lastSeenAt, device_reports AS deviceReports
706
+ FROM hosts WHERE user_id = ? ORDER BY joined_at`).all(userId).map((row) => ({ ...row, deviceReports: row.deviceReports === 1 }));
513
707
  },
514
708
  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
709
+ const row = db.prepare(`SELECT host_id AS hostId, user_id AS userId, noise_public_key AS noisePublicKey,
710
+ name, joined_at AS joinedAt, last_seen_at AS lastSeenAt, device_reports AS deviceReports
517
711
  FROM hosts WHERE user_id = ? AND host_id = ?`).get(userId, hostId);
712
+ return row === undefined ? undefined : { ...row, deviceReports: row.deviceReports === 1 };
713
+ },
714
+ markHostSeen(hostId, deviceReports) {
715
+ db.prepare("UPDATE hosts SET last_seen_at = ?, device_reports = ? WHERE host_id = ?").run(now(), deviceReports ? 1 : 0, hostId);
716
+ },
717
+ listDevices(userId) {
718
+ const devices = db.prepare(`SELECT id, name, created_at AS createdAt, last_seen_at AS lastSeenAt, kicked_at AS kickedAt
719
+ FROM account_devices WHERE user_id = ? ORDER BY last_seen_at DESC, id DESC`).all(userId);
720
+ const keys = db.prepare(`SELECT v.device_id AS deviceId, v.host_id AS hostId, h.name AS hostName,
721
+ v.revoked_at IS NOT NULL AS revoked,
722
+ EXISTS (SELECT 1 FROM pending_revocations p WHERE p.host_id = v.host_id AND p.public_key = v.public_key) AS pending
723
+ FROM vouchings v JOIN hosts h ON h.host_id = v.host_id JOIN account_devices d ON d.id = v.device_id
724
+ WHERE d.user_id = ? AND v.public_key IS NOT NULL
725
+ ORDER BY h.joined_at, v.host_id`).all(userId);
726
+ const rank = { registered: 2, revoking: 1, revoked: 0 };
727
+ const hosts = new Map;
728
+ for (const key of keys) {
729
+ const state = key.pending === 1 ? "revoking" : key.revoked === 1 ? "revoked" : "registered";
730
+ const perDevice = hosts.get(key.deviceId) ?? new Map;
731
+ hosts.set(key.deviceId, perDevice);
732
+ const seen = perDevice.get(key.hostId);
733
+ if (seen === undefined || rank[state] > rank[seen.state])
734
+ perDevice.set(key.hostId, { hostId: key.hostId, name: key.hostName, state });
735
+ }
736
+ return devices.map((device) => ({
737
+ id: device.id,
738
+ ...device.name === null ? {} : { name: device.name },
739
+ createdAt: device.createdAt,
740
+ lastSeenAt: device.lastSeenAt,
741
+ ...device.kickedAt === null ? {} : { kickedAt: device.kickedAt },
742
+ hosts: [...hosts.get(device.id)?.values() ?? []]
743
+ }));
744
+ },
745
+ recordVouching({ token, hostId, requestId, expiresAt }) {
746
+ sweepVouchings();
747
+ const session = db.prepare(`SELECT s.device_id AS deviceId FROM sessions s JOIN hosts h ON h.user_id = s.user_id
748
+ WHERE s.token_hash = ? AND s.expires_at > ? AND h.host_id = ?`).get(sha256Hex(token), now(), hostId);
749
+ if (session === undefined)
750
+ return false;
751
+ if (session.deviceId !== null) {
752
+ 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));
753
+ }
754
+ return true;
518
755
  },
519
- markHostSeen(hostId) {
520
- db.prepare("UPDATE hosts SET last_seen_at = ? WHERE host_id = ?").run(now(), hostId);
756
+ recordAdmission(hostId, requestId, publicKey) {
757
+ db.exec("BEGIN");
758
+ try {
759
+ const vouching = db.prepare(`SELECT expires_at AS expiresAt, public_key AS publicKey, revoked_at AS revokedAt
760
+ FROM vouchings WHERE request_id = ? AND host_id = ?`).get(requestId, hostId);
761
+ if (vouching === undefined || vouching.publicKey !== null || vouching.expiresAt + ADMISSION_REPORT_GRACE_MS <= now()) {
762
+ db.exec("ROLLBACK");
763
+ return "ignored";
764
+ }
765
+ db.prepare("UPDATE vouchings SET public_key = ?, admitted_at = ? WHERE request_id = ?").run(publicKey, now(), requestId);
766
+ if (vouching.revokedAt !== null) {
767
+ db.prepare("INSERT OR IGNORE INTO pending_revocations (host_id, public_key, created_at) VALUES (?, ?, ?)").run(hostId, publicKey, now());
768
+ }
769
+ db.exec("COMMIT");
770
+ return vouching.revokedAt === null ? "recorded" : "revoke";
771
+ } catch (error) {
772
+ db.exec("ROLLBACK");
773
+ throw error;
774
+ }
775
+ },
776
+ pendingRevocations(hostId) {
777
+ return db.prepare("SELECT public_key AS publicKey FROM pending_revocations WHERE host_id = ? ORDER BY created_at").all(hostId).map((row) => row.publicKey);
778
+ },
779
+ confirmRevocation(hostId, publicKey) {
780
+ db.prepare("DELETE FROM pending_revocations WHERE host_id = ? AND public_key = ?").run(hostId, publicKey);
781
+ },
782
+ async kickDevice({ userId, username, password, deviceId, currentDeviceId }) {
783
+ const owned = () => db.prepare("SELECT 1 AS x FROM account_devices WHERE id = ? AND user_id = ?").get(deviceId, userId) !== undefined;
784
+ if (!owned())
785
+ return { status: "refused", reason: "not_found" };
786
+ if (deviceId === currentDeviceId)
787
+ return { status: "refused", reason: "current_device" };
788
+ const checked = await checkPassword(userId, username, password);
789
+ if (checked === "limited")
790
+ return { status: "limited" };
791
+ if (checked === "wrong_password")
792
+ return { status: "refused", reason: "wrong_password" };
793
+ db.exec("BEGIN IMMEDIATE");
794
+ try {
795
+ if (currentHash(userId) !== checked.verifiedHash) {
796
+ db.exec("ROLLBACK");
797
+ return { status: "refused", reason: "wrong_password" };
798
+ }
799
+ if (!owned()) {
800
+ db.exec("ROLLBACK");
801
+ return { status: "refused", reason: "not_found" };
802
+ }
803
+ const at = now();
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);
812
+ db.exec("COMMIT");
813
+ return { status: "ok", hostIds };
814
+ } catch (error) {
815
+ db.exec("ROLLBACK");
816
+ throw error;
817
+ }
521
818
  },
522
819
  renameHost(hostId, name) {
523
820
  db.prepare("UPDATE hosts SET name = ? WHERE host_id = ?").run(normalizeHostName(name), hostId);
@@ -528,26 +825,40 @@ async function openAccounts(options) {
528
825
  }
529
826
  };
530
827
  }
531
- async function changePassword(options) {
828
+ function dropCredentials(db, userId, keepTokenHash) {
829
+ if (keepTokenHash === undefined)
830
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
831
+ else
832
+ db.prepare("DELETE FROM sessions WHERE user_id = ? AND token_hash <> ?").run(userId, keepTokenHash);
833
+ db.prepare("DELETE FROM join_tokens WHERE user_id = ? AND used_at IS NULL").run(userId);
834
+ db.prepare("DELETE FROM push_tokens WHERE user_id = ?").run(userId);
835
+ }
836
+ async function resetPassword(options) {
532
837
  assertPasswordPolicy(options.password);
533
838
  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));
839
+ const release = await lockDataDir(options.dataDir, { retries: 0 }).catch((error) => {
840
+ if (isDataDirInUse(error))
841
+ return;
842
+ throw error;
843
+ });
844
+ const db = openDatabase(options.dataDir);
536
845
  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;
846
+ if (release !== undefined) {
847
+ migrate(db);
848
+ } else if (schemaVersion(db) !== SCHEMA_VERSION) {
849
+ 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`);
850
+ }
544
851
  const hash = await hashPassword(options.password);
545
- db.exec("BEGIN");
852
+ db.exec("BEGIN IMMEDIATE");
546
853
  try {
854
+ const users = options.user === undefined ? db.prepare("SELECT id FROM users").all() : db.prepare("SELECT id FROM users WHERE username = ?").all(options.user);
855
+ if (users.length === 0)
856
+ throw new Error(options.user === undefined ? "no account exists yet" : `no such account: ${options.user}`);
857
+ if (users.length > 1)
858
+ throw new Error("more than one account exists — pass --user");
859
+ const userId = users[0].id;
547
860
  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);
861
+ dropCredentials(db, userId);
551
862
  db.exec("COMMIT");
552
863
  } catch (error) {
553
864
  db.exec("ROLLBACK");
@@ -555,7 +866,7 @@ async function changePassword(options) {
555
866
  }
556
867
  } finally {
557
868
  db.close();
558
- await release();
869
+ await release?.();
559
870
  }
560
871
  }
561
872
 
@@ -789,6 +1100,133 @@ import { once } from "node:events";
789
1100
  import { createServer } from "node:http";
790
1101
  import { WebSocket, WebSocketServer } from "ws";
791
1102
 
1103
+ // apps/server/src/relay-limits.ts
1104
+ var DEFAULT_RELAY_LIMITS = {
1105
+ perHost: 64,
1106
+ perAccount: 256,
1107
+ unauthenticatedPerHost: 32,
1108
+ handshakeTimeoutMs: 30000,
1109
+ handshakeBytes: 64 * 1024,
1110
+ accountBytesPerSecond: 4 * 1024 * 1024,
1111
+ accountBurstBytes: 1024 * 1024 * 1024
1112
+ };
1113
+ var RELAY_LIMIT_OPTIONS = [
1114
+ { field: "perHost", flag: "relay-max-per-host", env: "PINOMAD_SERVER_RELAY_MAX_PER_HOST", unit: "count" },
1115
+ { field: "perAccount", flag: "relay-max-per-account", env: "PINOMAD_SERVER_RELAY_MAX_PER_ACCOUNT", unit: "count" },
1116
+ {
1117
+ field: "unauthenticatedPerHost",
1118
+ flag: "relay-max-unauthenticated-per-host",
1119
+ env: "PINOMAD_SERVER_RELAY_MAX_UNAUTHENTICATED_PER_HOST",
1120
+ unit: "count"
1121
+ },
1122
+ { field: "handshakeTimeoutMs", flag: "relay-handshake-timeout", env: "PINOMAD_SERVER_RELAY_HANDSHAKE_TIMEOUT", unit: "seconds" },
1123
+ { field: "handshakeBytes", flag: "relay-handshake-bytes", env: "PINOMAD_SERVER_RELAY_HANDSHAKE_BYTES", unit: "bytes" },
1124
+ { field: "accountBytesPerSecond", flag: "relay-account-rate", env: "PINOMAD_SERVER_RELAY_ACCOUNT_RATE", unit: "bytes" },
1125
+ { field: "accountBurstBytes", flag: "relay-account-burst", env: "PINOMAD_SERVER_RELAY_ACCOUNT_BURST", unit: "bytes" }
1126
+ ];
1127
+ var BYTE_SUFFIX = { "": 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 };
1128
+ function readRelayLimits(flags, env) {
1129
+ const limits = {};
1130
+ for (const option of RELAY_LIMIT_OPTIONS) {
1131
+ const flag = flags[option.flag];
1132
+ const raw = typeof flag === "string" ? flag : env[option.env];
1133
+ if (raw === undefined)
1134
+ continue;
1135
+ const match = /^(\d+)([kmg]?)$/i.exec(raw.trim());
1136
+ const suffix = match?.[2]?.toLowerCase() ?? "";
1137
+ if (match === null || option.unit !== "bytes" && suffix !== "") {
1138
+ throw new Error(`--${option.flag} (${option.env}) must be a whole number${option.unit === "bytes" ? " with an optional k/m/g suffix" : ""}, got "${raw}"`);
1139
+ }
1140
+ const value = Number(match[1]) * BYTE_SUFFIX[suffix];
1141
+ limits[option.field] = option.unit === "seconds" ? value * 1000 : value;
1142
+ }
1143
+ return limits;
1144
+ }
1145
+ var TRAFFIC_LIMIT = { code: RELAY_CLOSE.trafficLimit, reason: "account traffic limit" };
1146
+ var HANDSHAKE_TRAFFIC = { code: RELAY_CLOSE.trafficLimit, reason: "too much data before the handshake" };
1147
+ function createRelayLimiter(limits, now = Date.now) {
1148
+ const hosts = new Map;
1149
+ const accounts = new Map;
1150
+ const rate = limits.accountBytesPerSecond;
1151
+ const capacity = limits.accountBurstBytes > 0 ? limits.accountBurstBytes : rate;
1152
+ const over = (cap, count) => cap > 0 && count >= cap;
1153
+ function refill(state) {
1154
+ const at = now();
1155
+ state.tokens = Math.min(capacity, state.tokens + (at - state.updatedAt) / 1000 * rate);
1156
+ state.updatedAt = at;
1157
+ }
1158
+ return {
1159
+ admit(hostId, account) {
1160
+ const host = hosts.get(hostId) ?? { connections: 0, unauthenticated: 0 };
1161
+ const owner = accounts.get(account) ?? { connections: 0, tokens: capacity, updatedAt: now() };
1162
+ if (rate > 0) {
1163
+ refill(owner);
1164
+ if (owner.tokens < 0)
1165
+ return { ok: false, ...TRAFFIC_LIMIT };
1166
+ }
1167
+ if (over(limits.perHost, host.connections)) {
1168
+ return { ok: false, code: RELAY_CLOSE.tooManyConnections, reason: "too many connections to this host" };
1169
+ }
1170
+ if (over(limits.perAccount, owner.connections)) {
1171
+ return { ok: false, code: RELAY_CLOSE.tooManyConnections, reason: "too many connections for this account" };
1172
+ }
1173
+ if (over(limits.unauthenticatedPerHost, host.unauthenticated)) {
1174
+ return { ok: false, code: RELAY_CLOSE.tooManyConnections, reason: "too many pending handshakes for this host" };
1175
+ }
1176
+ hosts.set(hostId, host);
1177
+ accounts.set(account, owner);
1178
+ host.connections += 1;
1179
+ host.unauthenticated += 1;
1180
+ owner.connections += 1;
1181
+ let authenticated = false;
1182
+ let released = false;
1183
+ let handshakeBytes = 0;
1184
+ return {
1185
+ ok: true,
1186
+ lease: {
1187
+ authenticate() {
1188
+ if (authenticated || released)
1189
+ return;
1190
+ authenticated = true;
1191
+ host.unauthenticated -= 1;
1192
+ },
1193
+ charge(bytes) {
1194
+ if (!authenticated) {
1195
+ handshakeBytes += bytes;
1196
+ return limits.handshakeBytes > 0 && handshakeBytes > limits.handshakeBytes ? HANDSHAKE_TRAFFIC : undefined;
1197
+ }
1198
+ if (rate <= 0)
1199
+ return;
1200
+ refill(owner);
1201
+ owner.tokens -= bytes;
1202
+ return owner.tokens < 0 ? TRAFFIC_LIMIT : undefined;
1203
+ },
1204
+ release() {
1205
+ if (released)
1206
+ return;
1207
+ released = true;
1208
+ host.connections -= 1;
1209
+ if (!authenticated)
1210
+ host.unauthenticated -= 1;
1211
+ owner.connections -= 1;
1212
+ if (host.connections === 0 && hosts.get(hostId) === host)
1213
+ hosts.delete(hostId);
1214
+ if (owner.connections === 0 && accounts.get(account) === owner) {
1215
+ if (rate > 0)
1216
+ refill(owner);
1217
+ if (rate <= 0 || owner.tokens >= capacity)
1218
+ accounts.delete(account);
1219
+ }
1220
+ }
1221
+ }
1222
+ };
1223
+ },
1224
+ forgetAccount(account) {
1225
+ accounts.delete(account);
1226
+ }
1227
+ };
1228
+ }
1229
+
792
1230
  // apps/server/src/api.ts
793
1231
  var SESSION_COOKIE = "__Host-pinomad-session";
794
1232
  var MAX_BODY = 8 * 1024;
@@ -899,11 +1337,12 @@ async function handleApiRequest(request, response, ctx) {
899
1337
  return;
900
1338
  }
901
1339
  const { username, password } = value;
902
- if ([...username].length === 0 || [...username].length > MAX_USERNAME_CODEPOINTS || [...password].length > MAX_PASSWORD_CODEPOINTS) {
1340
+ const device = parseDeviceClaim(value.device);
1341
+ if ([...username].length === 0 || [...username].length > MAX_USERNAME_CODEPOINTS || [...password].length > MAX_PASSWORD_CODEPOINTS || device === "invalid") {
903
1342
  deny(400, "invalid_request");
904
1343
  return;
905
1344
  }
906
- const result = await accounts.login(username, password, clientIp(request, ctx.trustProxy));
1345
+ const result = await accounts.login(username, password, clientIp(request, ctx.trustProxy), device);
907
1346
  if (result.status === "limited") {
908
1347
  deny(429, "rate_limited");
909
1348
  return;
@@ -932,7 +1371,8 @@ async function handleApiRequest(request, response, ctx) {
932
1371
  return;
933
1372
  }
934
1373
  const value = body.value;
935
- if (typeof value?.invite !== "string" || typeof value.username !== "string" || typeof value.password !== "string") {
1374
+ const device = parseDeviceClaim(value?.device);
1375
+ if (typeof value?.invite !== "string" || typeof value.username !== "string" || typeof value.password !== "string" || device === "invalid") {
936
1376
  deny(400, "invalid_request");
937
1377
  return;
938
1378
  }
@@ -941,7 +1381,8 @@ async function handleApiRequest(request, response, ctx) {
941
1381
  invite,
942
1382
  username,
943
1383
  password: value.password,
944
- ip: clientIp(request, ctx.trustProxy)
1384
+ ip: clientIp(request, ctx.trustProxy),
1385
+ ...device === undefined ? {} : { device }
945
1386
  });
946
1387
  if (result.status === "limited") {
947
1388
  deny(429, "rate_limited");
@@ -1021,7 +1462,8 @@ async function handleApiRequest(request, response, ctx) {
1021
1462
  noisePublicKey: row2.noisePublicKey,
1022
1463
  online: pairing !== "offline",
1023
1464
  lastSeenAt: row2.lastSeenAt,
1024
- pairing
1465
+ pairing,
1466
+ deviceReports: row2.deviceReports
1025
1467
  };
1026
1468
  }));
1027
1469
  return;
@@ -1046,6 +1488,10 @@ async function handleApiRequest(request, response, ctx) {
1046
1488
  deny(404, "not_found");
1047
1489
  return;
1048
1490
  }
1491
+ if (result.status === "ok" && !accounts.recordVouching({ token, hostId: row.hostId, requestId: result.requestId, expiresAt: result.expiresAt })) {
1492
+ deny(401, "unauthenticated");
1493
+ return;
1494
+ }
1049
1495
  switch (result.status) {
1050
1496
  case "ok":
1051
1497
  send(200, {
@@ -1071,7 +1517,59 @@ async function handleApiRequest(request, response, ctx) {
1071
1517
  }
1072
1518
  return;
1073
1519
  }
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") {
1520
+ const deviceKick = /^\/api\/devices\/([1-9][0-9]{0,14})\/kick$/.exec(pathname);
1521
+ if (pathname === "/api/devices" || deviceKick !== null) {
1522
+ const expected = pathname === "/api/devices" ? "GET" : "POST";
1523
+ if (method !== expected) {
1524
+ deny(405, "method_not_allowed", { allow: expected });
1525
+ return;
1526
+ }
1527
+ const token = sessionToken(request);
1528
+ const session = token === undefined ? undefined : accounts.authenticate(token);
1529
+ if (session === undefined) {
1530
+ deny(401, "unauthenticated");
1531
+ return;
1532
+ }
1533
+ if (deviceKick === null) {
1534
+ send(200, accounts.listDevices(session.userId).map((device) => ({ ...device, current: device.id === session.deviceId })));
1535
+ return;
1536
+ }
1537
+ const body = await readJson(request);
1538
+ if (!body.ok) {
1539
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1540
+ return;
1541
+ }
1542
+ const value = body.value;
1543
+ if (typeof value?.password !== "string" || [...value.password].length > MAX_PASSWORD_CODEPOINTS) {
1544
+ deny(400, "invalid_request");
1545
+ return;
1546
+ }
1547
+ const current = accounts.authenticate(token);
1548
+ if (current === undefined) {
1549
+ deny(401, "unauthenticated");
1550
+ return;
1551
+ }
1552
+ const result = await accounts.kickDevice({
1553
+ userId: current.userId,
1554
+ username: current.username,
1555
+ password: value.password,
1556
+ deviceId: Number(deviceKick[1]),
1557
+ ...current.deviceId === undefined ? {} : { currentDeviceId: current.deviceId }
1558
+ });
1559
+ if (result.status === "limited") {
1560
+ deny(429, "rate_limited");
1561
+ return;
1562
+ }
1563
+ if (result.status === "refused") {
1564
+ deny(result.reason === "not_found" ? 404 : result.reason === "current_device" ? 409 : 403, result.reason);
1565
+ return;
1566
+ }
1567
+ for (const hostId of result.hostIds)
1568
+ ctx.sendRevocations(hostId);
1569
+ send(204);
1570
+ return;
1571
+ }
1572
+ 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
1573
  const expected = pathname === "/api/me" ? "GET" : "POST";
1076
1574
  if (method !== expected) {
1077
1575
  deny(405, "method_not_allowed", { allow: expected });
@@ -1111,6 +1609,61 @@ async function handleApiRequest(request, response, ctx) {
1111
1609
  send(204);
1112
1610
  return;
1113
1611
  }
1612
+ if (pathname === "/api/me/delete") {
1613
+ const body = await readJson(request);
1614
+ if (!body.ok) {
1615
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1616
+ return;
1617
+ }
1618
+ const value = body.value;
1619
+ if (typeof value?.password !== "string" || [...value.password].length > MAX_PASSWORD_CODEPOINTS) {
1620
+ deny(400, "invalid_request");
1621
+ return;
1622
+ }
1623
+ const result = await accounts.deleteAccount({ userId: session.userId, username: session.username, password: value.password });
1624
+ if (result.status === "limited") {
1625
+ deny(429, "rate_limited");
1626
+ return;
1627
+ }
1628
+ if (result.status === "refused") {
1629
+ deny(403, result.reason);
1630
+ return;
1631
+ }
1632
+ for (const hostId of result.hostIds)
1633
+ ctx.disconnectHost(hostId);
1634
+ ctx.forgetAccount(session.userId);
1635
+ send(204, undefined, { "set-cookie": clearSessionCookie });
1636
+ return;
1637
+ }
1638
+ if (pathname === "/api/password") {
1639
+ const body = await readJson(request);
1640
+ if (!body.ok) {
1641
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1642
+ return;
1643
+ }
1644
+ const value = body.value;
1645
+ if (typeof value?.currentPassword !== "string" || typeof value.newPassword !== "string" || [...value.currentPassword].length > MAX_PASSWORD_CODEPOINTS) {
1646
+ deny(400, "invalid_request");
1647
+ return;
1648
+ }
1649
+ const result = await accounts.changePassword({
1650
+ userId: session.userId,
1651
+ username: session.username,
1652
+ keepToken: token,
1653
+ currentPassword: value.currentPassword,
1654
+ newPassword: value.newPassword
1655
+ });
1656
+ if (result.status === "limited") {
1657
+ deny(429, "rate_limited");
1658
+ return;
1659
+ }
1660
+ if (result.status === "refused") {
1661
+ deny(result.reason === "wrong_password" ? 403 : 400, result.reason);
1662
+ return;
1663
+ }
1664
+ send(204);
1665
+ return;
1666
+ }
1114
1667
  if (pathname === "/api/join-tokens") {
1115
1668
  const joinToken = accounts.createJoinToken(session.userId);
1116
1669
  send(200, {
@@ -1163,6 +1716,10 @@ async function startRelay(options) {
1163
1716
  const pushTotal = options.pushLimits?.total ?? MAX_PUSH_TOTAL;
1164
1717
  let pushInflightTotal = 0;
1165
1718
  const pushInflightHosts = new Map;
1719
+ const limits = { ...DEFAULT_RELAY_LIMITS, ...options.limits };
1720
+ const limiter = createRelayLimiter(limits, options.now);
1721
+ const accountKey = (userId) => `account:${userId}`;
1722
+ const hostDevices = new Map;
1166
1723
  const controls = new Map;
1167
1724
  const pending = new Map;
1168
1725
  const pairingRequests = new Map;
@@ -1175,11 +1732,23 @@ async function startRelay(options) {
1175
1732
  socket.on("close", () => sockets.delete(socket));
1176
1733
  socket.on("error", () => socket.terminate());
1177
1734
  }
1178
- function splice(a, b) {
1735
+ function splice(host, device, lease, handshakeTimer) {
1736
+ let authenticated = false;
1179
1737
  const forward = (from, to) => {
1180
1738
  from.on("message", (data, isBinary) => {
1181
1739
  if (to.readyState !== WebSocket.OPEN)
1182
1740
  return;
1741
+ if (from === host && !authenticated) {
1742
+ authenticated = true;
1743
+ lease.authenticate();
1744
+ clearTimeout(handshakeTimer);
1745
+ }
1746
+ const refusal = lease.charge(rawLength(data));
1747
+ if (refusal !== undefined) {
1748
+ device.close(refusal.code, refusal.reason);
1749
+ host.close(refusal.code, refusal.reason);
1750
+ return;
1751
+ }
1183
1752
  to.send(data, { binary: isBinary }, () => {
1184
1753
  if (from.isPaused && to.bufferedAmount <= LOW_WATER)
1185
1754
  from.resume();
@@ -1193,8 +1762,8 @@ async function startRelay(options) {
1193
1762
  to.close(isSendableCloseCode(code) ? code : 1001, reason);
1194
1763
  });
1195
1764
  };
1196
- forward(a, b);
1197
- forward(b, a);
1765
+ forward(host, device);
1766
+ forward(device, host);
1198
1767
  }
1199
1768
  function pairingStatus(hostId, boundNoiseKey) {
1200
1769
  const control = controls.get(hostId);
@@ -1236,6 +1805,22 @@ async function startRelay(options) {
1236
1805
  });
1237
1806
  }
1238
1807
  const pairing = { status: pairingStatus, request: requestPairing };
1808
+ function disconnectHost(hostId) {
1809
+ controls.get(hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
1810
+ closeHostDevices(hostId, RELAY_CLOSE.hostNotAllowed, "host left its account");
1811
+ }
1812
+ function closeHostDevices(hostId, code, reason) {
1813
+ for (const device of hostDevices.get(hostId) ?? [])
1814
+ device.close(code, reason);
1815
+ }
1816
+ function sendRevocations(hostId) {
1817
+ const control = controls.get(hostId);
1818
+ if (accounts === undefined || control?.features?.includes("device-reports") !== true)
1819
+ return;
1820
+ for (const publicKey of accounts.pendingRevocations(hostId)) {
1821
+ control.socket.send(encodeRelayFrame({ t: "revoke-device", publicKey }));
1822
+ }
1823
+ }
1239
1824
  function handlePush(socket, hostId, frame) {
1240
1825
  const startedAt = Date.now();
1241
1826
  const finish = (verdict) => {
@@ -1296,6 +1881,7 @@ async function startRelay(options) {
1296
1881
  answer({ t: "refused", reason: "bad_request" });
1297
1882
  return;
1298
1883
  }
1884
+ const previousOwner = accounts.hostOwner(frame.hostId);
1299
1885
  const result = accounts.joinHost({
1300
1886
  token: frame.joinToken,
1301
1887
  hostId: frame.hostId,
@@ -1307,6 +1893,9 @@ async function startRelay(options) {
1307
1893
  if (request.hostId === frame.hostId)
1308
1894
  request.settle({ status: "gone" });
1309
1895
  }
1896
+ if (previousOwner !== undefined && previousOwner !== accounts.hostOwner(frame.hostId)) {
1897
+ closeHostDevices(frame.hostId, RELAY_CLOSE.replaced, "host moved to another account");
1898
+ }
1310
1899
  }
1311
1900
  answer(result.status === "joined" ? { t: "joined", username: result.username } : { t: "refused", reason: result.reason });
1312
1901
  return;
@@ -1316,7 +1905,7 @@ async function startRelay(options) {
1316
1905
  return;
1317
1906
  }
1318
1907
  answer({ t: "left" });
1319
- controls.get(frame.hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
1908
+ disconnectHost(frame.hostId);
1320
1909
  }
1321
1910
  function onControl(socket) {
1322
1911
  track(socket);
@@ -1341,6 +1930,16 @@ async function startRelay(options) {
1341
1930
  handlePush(socket, hostId, answer);
1342
1931
  return;
1343
1932
  }
1933
+ if (answer.t === "device-admitted") {
1934
+ if (accounts?.recordAdmission(hostId, answer.requestId, answer.publicKey) === "revoke") {
1935
+ sendRevocations(hostId);
1936
+ }
1937
+ return;
1938
+ }
1939
+ if (answer.t === "device-revoked") {
1940
+ accounts?.confirmRevocation(hostId, answer.publicKey);
1941
+ return;
1942
+ }
1344
1943
  if (answer.t === "name") {
1345
1944
  if (controls.get(hostId)?.socket === socket)
1346
1945
  accounts?.renameHost(hostId, answer.name);
@@ -1352,6 +1951,7 @@ async function startRelay(options) {
1352
1951
  return;
1353
1952
  request.settle(answer.t === "pairing" ? {
1354
1953
  status: "ok",
1954
+ requestId: answer.requestId,
1355
1955
  secret: answer.secret,
1356
1956
  expiresAt: answer.expiresAt,
1357
1957
  ...answer.endpointSet === undefined ? {} : { endpointSet: answer.endpointSet }
@@ -1406,10 +2006,11 @@ async function startRelay(options) {
1406
2006
  ...noisePublicKey === undefined ? {} : { noisePublicKey },
1407
2007
  ...frame.features === undefined ? {} : { features: frame.features }
1408
2008
  });
1409
- accounts?.markHostSeen(hostId);
2009
+ accounts?.markHostSeen(hostId, frame.features?.includes("device-reports") === true);
1410
2010
  if (frame.name !== undefined)
1411
2011
  accounts?.renameHost(hostId, frame.name);
1412
- socket.send(encodeRelayFrame({ t: "registered", push: pushEnvironments, features: ["name"] }));
2012
+ socket.send(encodeRelayFrame({ t: "registered", push: pushEnvironments, features: ["name", "device-reports"] }));
2013
+ sendRevocations(hostId);
1413
2014
  });
1414
2015
  socket.once("close", () => {
1415
2016
  clearTimeout(deadline);
@@ -1435,10 +2036,29 @@ async function startRelay(options) {
1435
2036
  socket.close(RELAY_CLOSE.hostOffline);
1436
2037
  return;
1437
2038
  }
2039
+ const owner = accounts?.hostOwner(hostId);
2040
+ const admission = limiter.admit(hostId, owner === undefined ? `host:${hostId}` : accountKey(owner));
2041
+ if (!admission.ok) {
2042
+ socket.close(admission.code, admission.reason);
2043
+ return;
2044
+ }
2045
+ const { lease } = admission;
2046
+ const handshakeTimer = limits.handshakeTimeoutMs > 0 ? setTimeout(() => socket.close(RELAY_CLOSE.handshakeTimeout, "handshake timeout"), limits.handshakeTimeoutMs) : undefined;
2047
+ const devices = hostDevices.get(hostId) ?? new Set;
2048
+ hostDevices.set(hostId, devices.add(socket));
2049
+ socket.once("close", () => {
2050
+ clearTimeout(handshakeTimer);
2051
+ lease.release();
2052
+ devices.delete(socket);
2053
+ if (devices.size === 0 && hostDevices.get(hostId) === devices)
2054
+ hostDevices.delete(hostId);
2055
+ });
1438
2056
  const connId = randomBytes2(16).toString("base64url");
1439
2057
  const entry = {
1440
2058
  control,
1441
2059
  socket,
2060
+ lease,
2061
+ handshakeTimer,
1442
2062
  buffered: [],
1443
2063
  bufferedBytes: 0,
1444
2064
  timer: setTimeout(() => {
@@ -1475,7 +2095,13 @@ async function startRelay(options) {
1475
2095
  const device = entry.socket;
1476
2096
  device.removeListener("message", entry.onMessage);
1477
2097
  device.removeListener("close", entry.onClose);
1478
- splice(socket, device);
2098
+ const refusal = entry.lease.charge(entry.bufferedBytes);
2099
+ if (refusal !== undefined) {
2100
+ device.close(refusal.code, refusal.reason);
2101
+ socket.close(refusal.code, refusal.reason);
2102
+ return;
2103
+ }
2104
+ splice(socket, device, entry.lease, entry.handshakeTimer);
1479
2105
  for (const { data, isBinary } of entry.buffered)
1480
2106
  socket.send(data, { binary: isBinary });
1481
2107
  }
@@ -1492,7 +2118,15 @@ async function startRelay(options) {
1492
2118
  response.end(JSON.stringify({ error: "accounts_disabled" }));
1493
2119
  return;
1494
2120
  }
1495
- handleApiRequest(request, response, { accounts, publicOrigin, trustProxy, pairing }).catch(() => {
2121
+ handleApiRequest(request, response, {
2122
+ accounts,
2123
+ publicOrigin,
2124
+ trustProxy,
2125
+ pairing,
2126
+ disconnectHost,
2127
+ forgetAccount: (userId) => limiter.forgetAccount(accountKey(userId)),
2128
+ sendRevocations
2129
+ }).catch(() => {
1496
2130
  if (!response.headersSent)
1497
2131
  response.writeHead(500);
1498
2132
  response.end();
@@ -1619,7 +2253,7 @@ async function passwdMain(argv) {
1619
2253
  }
1620
2254
  const dataDir = resolveDataDir(values["data-dir"]);
1621
2255
  const password = (await readStdin(16 * 1024)).replace(/[\r\n]+$/, "");
1622
- await changePassword({ dataDir, user: values.user, password });
2256
+ await resetPassword({ dataDir, user: values.user, password });
1623
2257
  console.log(JSON.stringify({ event: "passwd", dataDir }));
1624
2258
  }
1625
2259
  async function main(argv) {
@@ -1637,13 +2271,22 @@ async function main(argv) {
1637
2271
  listen: { type: "string", default: "127.0.0.1" },
1638
2272
  "public-origin": { type: "string" },
1639
2273
  "data-dir": { type: "string" },
1640
- "trust-proxy": { type: "boolean", default: false }
2274
+ "trust-proxy": { type: "boolean", default: false },
2275
+ ...Object.fromEntries(RELAY_LIMIT_OPTIONS.map((option) => [option.flag, { type: "string" }]))
1641
2276
  }
1642
2277
  });
1643
2278
  if (values["public-origin"] === undefined) {
1644
2279
  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");
2280
+ ` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user NAME] (reset a password, server running or not; new password on stdin)
2281
+ ` + ` env: PINOMAD_SERVER_USER, PINOMAD_SERVER_PASSWORD, PINOMAD_SERVER_DATA_DIR
2282
+ ` + " relay limits (flag or env, 0 = off): " + RELAY_LIMIT_OPTIONS.map((option) => `--${option.flag} / ${option.env}`).join(", "));
2283
+ process.exit(1);
2284
+ }
2285
+ let limits;
2286
+ try {
2287
+ limits = readRelayLimits(values, process.env);
2288
+ } catch (error) {
2289
+ console.error(error instanceof Error ? error.message : String(error));
1647
2290
  process.exit(1);
1648
2291
  }
1649
2292
  const accounts = await openAccounts({
@@ -1663,13 +2306,15 @@ async function main(argv) {
1663
2306
  webRoot,
1664
2307
  accounts,
1665
2308
  trustProxy: values["trust-proxy"],
1666
- ...apns === undefined ? {} : { push: apns }
2309
+ ...apns === undefined ? {} : { push: apns },
2310
+ limits
1667
2311
  });
1668
2312
  console.log(JSON.stringify({
1669
2313
  event: "ready",
1670
2314
  url: relay.url,
1671
2315
  publicOrigin: values["public-origin"],
1672
- push: apns?.environments ?? []
2316
+ push: apns?.environments ?? [],
2317
+ relayLimits: { ...DEFAULT_RELAY_LIMITS, ...limits }
1673
2318
  }));
1674
2319
  let stopping = false;
1675
2320
  const stop = () => {