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