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.
- package/dist/{chunk-3p1eysm9.js → chunk-4h7pkkzr.js} +3 -2
- package/dist/{chunk-nj5gz6qh.js → chunk-5791mb0b.js} +5 -5
- package/dist/{chunk-azarj0f4.js → chunk-73efzaf1.js} +2 -2
- package/dist/{chunk-5gp9svzs.js → chunk-8gywfw5n.js} +2 -2
- package/dist/{chunk-3gcpzpjq.js → chunk-bhtx2c8a.js} +2 -2
- package/dist/{chunk-n58m5hgh.js → chunk-c05g401k.js} +5 -5
- package/dist/{chunk-029mmh7d.js → chunk-c5p9xkd8.js} +1 -1
- package/dist/{chunk-khcpv54k.js → chunk-cx8k63by.js} +58 -3
- package/dist/{chunk-n8cz7skh.js → chunk-czj9nfrz.js} +41 -22
- package/dist/{chunk-cx8jgpdt.js → chunk-p978qj9m.js} +701 -56
- package/dist/{chunk-32g3m2h0.js → chunk-w2m9xkks.js} +1 -1
- package/dist/{chunk-fqgvf8v0.js → chunk-zehwx009.js} +1 -1
- package/dist/pinomad.js +10 -10
- package/package.json +1 -1
- package/web/assets/{index-1g60bhv2.js → index-BdBjP1qJ.js} +101 -101
- package/web/index.html +1 -1
|
@@ -9,11 +9,11 @@ import {
|
|
|
9
9
|
relayWsBase,
|
|
10
10
|
verifyRelayChallenge,
|
|
11
11
|
verifyRelayChallengeV2
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-cx8k63by.js";
|
|
13
13
|
import {
|
|
14
14
|
commandHint,
|
|
15
15
|
webRoot
|
|
16
|
-
} from "./chunk-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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 =
|
|
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
|
|
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", ...
|
|
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 {
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
db.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
if (
|
|
540
|
-
throw new Error(
|
|
541
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1197
|
-
forward(
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
|
1646
|
-
` +
|
|
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 = () => {
|