pinomad 0.10.0 → 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-5791mb0b.js → chunk-4dcs8sbj.js} +11 -9
- package/dist/{chunk-czj9nfrz.js → chunk-67gq282t.js} +15 -13
- package/dist/{chunk-73efzaf1.js → chunk-6d16c2fv.js} +21 -11
- package/dist/{chunk-p978qj9m.js → chunk-9h5vawnm.js} +351 -35
- package/dist/{chunk-c5p9xkd8.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-4h7pkkzr.js → chunk-ncbqa8jk.js} +3 -3
- package/dist/{chunk-sygeb9qb.js → chunk-pqe7q1s7.js} +1 -1
- package/dist/{chunk-zehwx009.js → chunk-sd5fkxjj.js} +1 -1
- package/dist/{chunk-8gywfw5n.js → chunk-t91zt5f0.js} +2 -51
- package/dist/{chunk-vax4zspw.js → chunk-tj57aqav.js} +3 -3
- package/dist/{chunk-w2m9xkks.js → chunk-vvj9b1gc.js} +29 -24
- package/dist/{chunk-jgd38rna.js → chunk-xqycs43g.js} +171 -171
- package/dist/{chunk-cx8k63by.js → chunk-y127fe1a.js} +1 -1
- package/dist/pinomad.js +17 -11
- package/package.json +1 -1
- package/web/assets/index-BhHrKpoo.js +395 -0
- package/web/index.html +1 -1
- package/dist/chunk-bhtx2c8a.js +0 -16
- package/dist/chunk-c05g401k.js +0 -170
- package/web/assets/index-BdBjP1qJ.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";
|
|
@@ -58,9 +58,26 @@ async function lockDataDir(dir, options) {
|
|
|
58
58
|
|
|
59
59
|
// apps/server/src/accounts.ts
|
|
60
60
|
var DB_FILE = "server.sqlite";
|
|
61
|
-
var SCHEMA_VERSION =
|
|
61
|
+
var SCHEMA_VERSION = 6;
|
|
62
62
|
var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
63
63
|
var JOIN_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
64
|
+
var USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
|
|
65
|
+
var USER_CODE_LENGTH = 8;
|
|
66
|
+
var HOST_LINK_TTL_MS = JOIN_TOKEN_TTL_MS;
|
|
67
|
+
var HOST_LINK_INTERVAL_S = 5;
|
|
68
|
+
var 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)}`;
|
|
64
81
|
var INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
65
82
|
var SIGN_UP_USERNAME = /^[a-z0-9][a-z0-9._-]{1,31}$/;
|
|
66
83
|
var PUSH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
@@ -190,6 +207,38 @@ function migrate(db) {
|
|
|
190
207
|
}
|
|
191
208
|
if (version < 4)
|
|
192
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
|
+
}
|
|
193
242
|
}
|
|
194
243
|
function migrateTo4(db) {
|
|
195
244
|
db.exec("BEGIN");
|
|
@@ -386,6 +435,53 @@ async function openAccounts(options) {
|
|
|
386
435
|
const gate = new ScryptGate(limits.scryptConcurrency, limits.scryptQueue);
|
|
387
436
|
const ipAttempts = new WindowedCounter(limits.counterMaxEntries, now);
|
|
388
437
|
const userFailures = new WindowedCounter(limits.counterMaxEntries, now);
|
|
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
|
+
};
|
|
389
485
|
const deviceForSignIn = (userId, claim) => {
|
|
390
486
|
const at = now();
|
|
391
487
|
if (claim === undefined) {
|
|
@@ -411,6 +507,19 @@ async function openAccounts(options) {
|
|
|
411
507
|
const sweepVouchings = () => {
|
|
412
508
|
db.prepare("DELETE FROM vouchings WHERE public_key IS NULL AND expires_at <= ?").run(now() - ADMISSION_REPORT_GRACE_MS);
|
|
413
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
|
+
};
|
|
414
523
|
const currentHash = (userId) => db.prepare("SELECT password_hash AS passwordHash FROM users WHERE id = ?").get(userId)?.passwordHash;
|
|
415
524
|
const checkPassword = async (userId, username, password) => {
|
|
416
525
|
if (userFailures.isOver(username, limits.userMaxFailures))
|
|
@@ -549,8 +658,23 @@ async function openAccounts(options) {
|
|
|
549
658
|
...row.deviceId === null ? {} : { deviceId: row.deviceId }
|
|
550
659
|
};
|
|
551
660
|
},
|
|
552
|
-
logout(token) {
|
|
553
|
-
|
|
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
|
+
}
|
|
554
678
|
},
|
|
555
679
|
async changePassword({ userId, username, keepToken, currentPassword, newPassword }) {
|
|
556
680
|
const length = [...newPassword].length;
|
|
@@ -622,13 +746,14 @@ async function openAccounts(options) {
|
|
|
622
746
|
const row = db.prepare("SELECT user_id AS userId FROM hosts WHERE host_id = ?").get(hostId);
|
|
623
747
|
return row?.userId;
|
|
624
748
|
},
|
|
625
|
-
registerPushToken(userId, token, environment) {
|
|
749
|
+
registerPushToken(userId, token, environment, deviceId) {
|
|
626
750
|
const at = now();
|
|
627
751
|
db.exec("BEGIN");
|
|
628
752
|
try {
|
|
629
753
|
db.prepare("DELETE FROM push_tokens WHERE updated_at < ?").run(at - PUSH_TOKEN_TTL_MS);
|
|
630
|
-
db.prepare(`INSERT INTO push_tokens (token, environment, user_id, updated_at) VALUES (?, ?, ?, ?)
|
|
631
|
-
ON CONFLICT (token, environment) DO UPDATE SET
|
|
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);
|
|
632
757
|
db.prepare(`DELETE FROM push_tokens WHERE user_id = ? AND rowid NOT IN (
|
|
633
758
|
SELECT rowid FROM push_tokens WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?
|
|
634
759
|
)`).run(userId, userId, MAX_PUSH_TOKENS_PER_USER);
|
|
@@ -649,22 +774,96 @@ async function openAccounts(options) {
|
|
|
649
774
|
db.prepare("DELETE FROM push_tokens WHERE token = ? AND environment = ? AND updated_at < ?").run(token.toLowerCase(), environment, unregisteredAt);
|
|
650
775
|
},
|
|
651
776
|
createJoinToken(userId) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
+
}
|
|
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
|
+
};
|
|
657
849
|
},
|
|
850
|
+
confirmHostLink(args) {
|
|
851
|
+
return settleHostLink(args, "confirmed");
|
|
852
|
+
},
|
|
853
|
+
denyHostLink(args) {
|
|
854
|
+
return settleHostLink(args, "denied");
|
|
855
|
+
},
|
|
856
|
+
sweepExpired,
|
|
658
857
|
joinHost({ token, hostId, noisePublicKey, name }) {
|
|
659
858
|
const tokenHash = sha256Hex(token);
|
|
660
859
|
const at = now();
|
|
661
860
|
db.exec("BEGIN");
|
|
662
861
|
try {
|
|
663
|
-
const row = db.prepare("SELECT user_id AS userId, expires_at AS expiresAt, used_at AS usedAt FROM join_tokens WHERE token_hash = ?").get(tokenHash);
|
|
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);
|
|
664
863
|
let failure;
|
|
665
|
-
if (row === undefined || row.usedAt !== null)
|
|
864
|
+
if (row === undefined || row.usedAt !== null || row.boundHostId !== null && row.boundHostId !== hostId) {
|
|
666
865
|
failure = "invalid_token";
|
|
667
|
-
else if (row.expiresAt <= at)
|
|
866
|
+
} else if (row.expiresAt <= at)
|
|
668
867
|
failure = "expired_token";
|
|
669
868
|
else {
|
|
670
869
|
const consumed = db.prepare("UPDATE join_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL").run(at, tokenHash);
|
|
@@ -800,15 +999,7 @@ async function openAccounts(options) {
|
|
|
800
999
|
db.exec("ROLLBACK");
|
|
801
1000
|
return { status: "refused", reason: "not_found" };
|
|
802
1001
|
}
|
|
803
|
-
const
|
|
804
|
-
sweepVouchings();
|
|
805
|
-
db.prepare("DELETE FROM sessions WHERE device_id = ?").run(deviceId);
|
|
806
|
-
db.prepare("UPDATE account_devices SET kicked_at = ? WHERE id = ?").run(at, deviceId);
|
|
807
|
-
const hostIds = db.prepare("SELECT DISTINCT host_id AS hostId FROM vouchings WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL").all(deviceId).map((row) => row.hostId);
|
|
808
|
-
db.prepare(`INSERT OR IGNORE INTO pending_revocations (host_id, public_key, created_at)
|
|
809
|
-
SELECT host_id, public_key, ? FROM vouchings
|
|
810
|
-
WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL`).run(at, deviceId);
|
|
811
|
-
db.prepare("UPDATE vouchings SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL").run(at, deviceId);
|
|
1002
|
+
const hostIds = withdrawDevice(deviceId);
|
|
812
1003
|
db.exec("COMMIT");
|
|
813
1004
|
return { status: "ok", hostIds };
|
|
814
1005
|
} catch (error) {
|
|
@@ -831,6 +1022,7 @@ function dropCredentials(db, userId, keepTokenHash) {
|
|
|
831
1022
|
else
|
|
832
1023
|
db.prepare("DELETE FROM sessions WHERE user_id = ? AND token_hash <> ?").run(userId, keepTokenHash);
|
|
833
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);
|
|
834
1026
|
db.prepare("DELETE FROM push_tokens WHERE user_id = ?").run(userId);
|
|
835
1027
|
}
|
|
836
1028
|
async function resetPassword(options) {
|
|
@@ -1229,6 +1421,7 @@ function createRelayLimiter(limits, now = Date.now) {
|
|
|
1229
1421
|
|
|
1230
1422
|
// apps/server/src/api.ts
|
|
1231
1423
|
var SESSION_COOKIE = "__Host-pinomad-session";
|
|
1424
|
+
var HOST_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
1232
1425
|
var MAX_BODY = 8 * 1024;
|
|
1233
1426
|
var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
1234
1427
|
var DESKTOP_ORIGIN = "app://pinomad";
|
|
@@ -1254,7 +1447,7 @@ function respond(response, status, body, headers) {
|
|
|
1254
1447
|
response.writeHead(status, all);
|
|
1255
1448
|
response.end(body === undefined ? undefined : JSON.stringify(body));
|
|
1256
1449
|
}
|
|
1257
|
-
async function readJson(request) {
|
|
1450
|
+
async function readJson(request, optional = false) {
|
|
1258
1451
|
const chunks = [];
|
|
1259
1452
|
let size = 0;
|
|
1260
1453
|
let tooLarge = false;
|
|
@@ -1268,6 +1461,8 @@ async function readJson(request) {
|
|
|
1268
1461
|
}
|
|
1269
1462
|
if (tooLarge)
|
|
1270
1463
|
return { ok: false, tooLarge: true };
|
|
1464
|
+
if (optional && size === 0)
|
|
1465
|
+
return { ok: true, value: undefined };
|
|
1271
1466
|
try {
|
|
1272
1467
|
return { ok: true, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
|
|
1273
1468
|
} catch {
|
|
@@ -1317,7 +1512,8 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1317
1512
|
}
|
|
1318
1513
|
const bearer = (request.headers.authorization ?? "").startsWith("Bearer ");
|
|
1319
1514
|
const loginTokenMode = (pathname === "/api/login" || pathname === "/api/register") && (origin === undefined || origin === DESKTOP_ORIGIN);
|
|
1320
|
-
|
|
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) {
|
|
1321
1517
|
deny(403, "origin_mismatch");
|
|
1322
1518
|
return;
|
|
1323
1519
|
}
|
|
@@ -1397,6 +1593,110 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1397
1593
|
send(200, { user: { username }, expiresAt: result.expiresAt, ...cookieMode ? {} : { token: result.token } }, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
|
|
1398
1594
|
return;
|
|
1399
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
|
+
}
|
|
1400
1700
|
if (pathname === "/api/invites" || pathname === "/api/invites/revoke") {
|
|
1401
1701
|
const allowed = pathname === "/api/invites" ? ["GET", "POST"] : ["POST"];
|
|
1402
1702
|
if (!allowed.includes(method)) {
|
|
@@ -1598,12 +1898,13 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1598
1898
|
deny(400, "invalid_request");
|
|
1599
1899
|
return;
|
|
1600
1900
|
}
|
|
1601
|
-
|
|
1901
|
+
const current = accounts.authenticate(token);
|
|
1902
|
+
if (current === undefined) {
|
|
1602
1903
|
deny(401, "unauthenticated");
|
|
1603
1904
|
return;
|
|
1604
1905
|
}
|
|
1605
1906
|
if (pathname === "/api/push-tokens")
|
|
1606
|
-
accounts.registerPushToken(session.userId, apns, environment);
|
|
1907
|
+
accounts.registerPushToken(session.userId, apns, environment, current.deviceId);
|
|
1607
1908
|
else
|
|
1608
1909
|
accounts.removePushToken(session.userId, apns, environment);
|
|
1609
1910
|
send(204);
|
|
@@ -1669,12 +1970,24 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
1669
1970
|
send(200, {
|
|
1670
1971
|
token: joinToken.token,
|
|
1671
1972
|
expiresAt: joinToken.expiresAt,
|
|
1672
|
-
command: `
|
|
1973
|
+
command: `curl -fsSL https://pinomad.si/install.sh | sh -s -- join ${ctx.publicOrigin} ${joinToken.token}`
|
|
1673
1974
|
});
|
|
1674
1975
|
return;
|
|
1675
1976
|
}
|
|
1676
1977
|
if (pathname === "/api/logout") {
|
|
1677
|
-
|
|
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);
|
|
1678
1991
|
send(204, undefined, { "set-cookie": clearSessionCookie });
|
|
1679
1992
|
return;
|
|
1680
1993
|
}
|
|
@@ -2192,6 +2505,9 @@ async function startRelay(options) {
|
|
|
2192
2505
|
socket.terminate();
|
|
2193
2506
|
}
|
|
2194
2507
|
}
|
|
2508
|
+
try {
|
|
2509
|
+
accounts?.sweepExpired();
|
|
2510
|
+
} catch {}
|
|
2195
2511
|
}, pingIntervalMs);
|
|
2196
2512
|
return {
|
|
2197
2513
|
port,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
readServerConfig
|
|
4
|
+
} from "./chunk-etf7ef97.js";
|
|
5
|
+
import {
|
|
6
|
+
argValue,
|
|
7
|
+
reachabilityLine,
|
|
8
|
+
serviceManager,
|
|
9
|
+
versionLine
|
|
10
|
+
} from "./chunk-vvj9b1gc.js";
|
|
11
|
+
import"./chunk-jt34g8st.js";
|
|
12
|
+
import"./chunk-kzbzacz9.js";
|
|
13
|
+
import"./chunk-xqycs43g.js";
|
|
14
|
+
import"./chunk-pqe7q1s7.js";
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_DATA_DIR
|
|
17
|
+
} from "./chunk-ngf1zatm.js";
|
|
18
|
+
import {
|
|
19
|
+
commandHint
|
|
20
|
+
} from "./chunk-sd5fkxjj.js";
|
|
21
|
+
import"./chunk-df32avw6.js";
|
|
22
|
+
|
|
23
|
+
// apps/host/src/cli/status.ts
|
|
24
|
+
import { resolve } from "node:path";
|
|
25
|
+
import { parseArgs } from "node:util";
|
|
26
|
+
async function statusReport(args, deps) {
|
|
27
|
+
const dataDir = resolve(args.dataDir);
|
|
28
|
+
const manager = deps?.manager ?? serviceManager();
|
|
29
|
+
const server = await readServerConfig(dataDir);
|
|
30
|
+
const service = await manager.state();
|
|
31
|
+
const spec = service.installed ? await manager.read() : undefined;
|
|
32
|
+
const own = spec !== undefined && resolve(spec.dataDir) === dataDir;
|
|
33
|
+
const lanPort = own ? argValue(spec.hostArgs, "--remote-port") : undefined;
|
|
34
|
+
const port = own ? argValue(spec.hostArgs, "--port") : undefined;
|
|
35
|
+
return {
|
|
36
|
+
...server === undefined ? {} : { server },
|
|
37
|
+
...lanPort === undefined ? {} : { lanPort },
|
|
38
|
+
service,
|
|
39
|
+
...port === undefined ? {} : { port },
|
|
40
|
+
...spec === undefined || own ? {} : { otherDataDir: spec.dataDir }
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function main(argv) {
|
|
44
|
+
const { values } = parseArgs({ args: [...argv], options: { "data-dir": { type: "string" } } });
|
|
45
|
+
const dataDir = resolve(values["data-dir"] ?? DEFAULT_DATA_DIR);
|
|
46
|
+
try {
|
|
47
|
+
const report = await statusReport({ dataDir });
|
|
48
|
+
const { server } = report;
|
|
49
|
+
console.log(server === undefined ? `server: not joined (\`${commandHint("up")}\` to join)` : `server: ${server.username === undefined ? "joined" : server.username} at ${server.origin}`);
|
|
50
|
+
console.log(`LAN and tailnet: ${report.lanPort === undefined ? "off" : `port ${report.lanPort}`}`);
|
|
51
|
+
console.log(`service: ${report.service.installed ? report.service.detail : "not installed"}`);
|
|
52
|
+
if (report.otherDataDir !== undefined)
|
|
53
|
+
console.log(`note: the installed service runs ${report.otherDataDir}, not ${dataDir}`);
|
|
54
|
+
console.log(await versionLine());
|
|
55
|
+
console.log(await reachabilityLine([
|
|
56
|
+
"--data-dir",
|
|
57
|
+
dataDir,
|
|
58
|
+
...report.port === undefined ? [] : ["--url", `ws://127.0.0.1:${report.port}`]
|
|
59
|
+
]));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (false)
|
|
66
|
+
;
|
|
67
|
+
export {
|
|
68
|
+
statusReport,
|
|
69
|
+
main
|
|
70
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// apps/host/src/server-config.ts
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
var SERVER_CONFIG_FILE = "server.json";
|
|
6
|
+
var normalizeOrigin = (origin) => origin.replace(/\/+$/, "");
|
|
7
|
+
async function readServerConfig(dataDir) {
|
|
8
|
+
let text;
|
|
9
|
+
try {
|
|
10
|
+
text = await readFile(join(dataDir, SERVER_CONFIG_FILE), "utf8");
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error.code === "ENOENT")
|
|
13
|
+
return;
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
let value;
|
|
17
|
+
try {
|
|
18
|
+
value = JSON.parse(text);
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error(`${join(dataDir, SERVER_CONFIG_FILE)} is not valid JSON — delete it or run \`pinomad leave\``);
|
|
21
|
+
}
|
|
22
|
+
const origin = value?.origin;
|
|
23
|
+
if (typeof origin !== "string" || origin.trim() === "") {
|
|
24
|
+
throw new Error(`${join(dataDir, SERVER_CONFIG_FILE)} is missing a string "origin"`);
|
|
25
|
+
}
|
|
26
|
+
const { binding, username } = value;
|
|
27
|
+
return {
|
|
28
|
+
origin,
|
|
29
|
+
...typeof binding === "string" && binding !== "" ? { binding } : {},
|
|
30
|
+
...typeof username === "string" && username !== "" ? { username } : {}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function writeServerConfig(dataDir, config) {
|
|
34
|
+
const path = join(dataDir, SERVER_CONFIG_FILE);
|
|
35
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
36
|
+
await writeFile(tmp, `${JSON.stringify({
|
|
37
|
+
origin: normalizeOrigin(config.origin),
|
|
38
|
+
binding: randomBytes(16).toString("base64url"),
|
|
39
|
+
...config.username === undefined ? {} : { username: config.username }
|
|
40
|
+
})}
|
|
41
|
+
`, { mode: 384 });
|
|
42
|
+
await rename(tmp, path);
|
|
43
|
+
}
|
|
44
|
+
var removeServerConfig = (dataDir) => rm(join(dataDir, SERVER_CONFIG_FILE), { force: true });
|
|
45
|
+
async function readServerBinding(dataDir) {
|
|
46
|
+
return (await readServerConfig(dataDir))?.binding;
|
|
47
|
+
}
|
|
48
|
+
function resolveRelayOrigin(flag, config) {
|
|
49
|
+
if (flag !== undefined && config !== undefined && normalizeOrigin(flag) !== normalizeOrigin(config.origin)) {
|
|
50
|
+
throw new Error(`--relay ${flag} disagrees with ${SERVER_CONFIG_FILE} (${config.origin}) — remove one`);
|
|
51
|
+
}
|
|
52
|
+
return flag ?? config?.origin;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { normalizeOrigin, readServerConfig, writeServerConfig, removeServerConfig, readServerBinding, resolveRelayOrigin };
|