pinomad 0.3.0 → 0.5.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.
@@ -0,0 +1,1376 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ serveWebClient
4
+ } from "./chunk-r6w5aqkw.js";
5
+ import {
6
+ RELAY_CLOSE,
7
+ decodeHostToRelay,
8
+ encodeRelayFrame,
9
+ relayWsBase,
10
+ verifyRelayChallenge,
11
+ verifyRelayChallengeV2
12
+ } from "./chunk-kmbh44dt.js";
13
+ import {
14
+ commandHint,
15
+ webRoot
16
+ } from "./chunk-1d9sppj0.js";
17
+ import"./chunk-df32avw6.js";
18
+
19
+ // apps/server/src/main.ts
20
+ import { homedir } from "node:os";
21
+ import { join as join2, resolve } from "node:path";
22
+ import { parseArgs } from "node:util";
23
+
24
+ // apps/server/src/accounts.ts
25
+ import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
26
+ import { mkdir } from "node:fs/promises";
27
+ import { join } from "node:path";
28
+ import { DatabaseSync } from "node:sqlite";
29
+ import { promisify } from "node:util";
30
+
31
+ // apps/server/src/lock.ts
32
+ import lockfile from "proper-lockfile";
33
+ async function lockDataDir(dir, options) {
34
+ const stale = Math.max(2000, options?.staleMs ?? 1e4);
35
+ let releasing = false;
36
+ const release = await lockfile.lock(dir, {
37
+ realpath: false,
38
+ stale,
39
+ retries: { retries: options?.retries ?? Math.ceil(stale / 1000) + 2, minTimeout: 1000, maxTimeout: 1000 },
40
+ onCompromised: (error) => {
41
+ if (!releasing)
42
+ throw error;
43
+ }
44
+ }).catch((error) => {
45
+ if (error.code !== "ELOCKED")
46
+ throw error;
47
+ throw new Error(`Data directory is already in use by a running process: ${dir}`, { cause: error });
48
+ });
49
+ return async () => {
50
+ releasing = true;
51
+ await release();
52
+ };
53
+ }
54
+
55
+ // apps/server/src/accounts.ts
56
+ var DB_FILE = "server.sqlite";
57
+ var SCHEMA_VERSION = 1;
58
+ var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
59
+ var JOIN_TOKEN_TTL_MS = 10 * 60 * 1000;
60
+ var SCRYPT = { N: 16384, r: 8, p: 1 };
61
+ var KEY_LEN = 32;
62
+ var SALT_LEN = 16;
63
+ var MIN_PASSWORD_CODEPOINTS = 12;
64
+ var MAX_PASSWORD_CODEPOINTS = 1024;
65
+ var MAX_USERNAME_CODEPOINTS = 254;
66
+ var scryptAsync = promisify(scrypt);
67
+ var DUMMY_HASH = `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${"A".repeat(22)}$${"A".repeat(43)}`;
68
+ var sha256Hex = (input) => createHash("sha256").update(input, "utf8").digest("hex");
69
+ async function hashPassword(password) {
70
+ const salt = randomBytes(SALT_LEN);
71
+ const hash = await scryptAsync(password, salt, KEY_LEN, SCRYPT);
72
+ return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString("base64url")}$${hash.toString("base64url")}`;
73
+ }
74
+ async function verifyPassword(password, stored) {
75
+ const parts = stored.split("$");
76
+ if (parts.length !== 6 || parts[0] !== "scrypt")
77
+ return false;
78
+ const [n, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];
79
+ if (!Number.isSafeInteger(n) || !Number.isSafeInteger(r) || !Number.isSafeInteger(p))
80
+ return false;
81
+ if (n < 1024 || n > 1 << 20 || r < 1 || r > 32 || p < 1 || p > 16)
82
+ return false;
83
+ let salt, expected;
84
+ try {
85
+ salt = Buffer.from(parts[4], "base64url");
86
+ expected = Buffer.from(parts[5], "base64url");
87
+ } catch {
88
+ return false;
89
+ }
90
+ if (salt.length === 0 || expected.length === 0 || expected.length > 256)
91
+ return false;
92
+ const derived = await scryptAsync(password, salt, expected.length, { N: n, r, p });
93
+ return timingSafeEqual(derived, expected);
94
+ }
95
+ function assertPasswordPolicy(password) {
96
+ const length = [...password].length;
97
+ if (length < MIN_PASSWORD_CODEPOINTS || length > MAX_PASSWORD_CODEPOINTS) {
98
+ throw new Error(`password must be ${MIN_PASSWORD_CODEPOINTS}–${MAX_PASSWORD_CODEPOINTS} characters`);
99
+ }
100
+ }
101
+ function assertUsernamePolicy(username) {
102
+ const length = [...username].length;
103
+ if (length === 0 || length > MAX_USERNAME_CODEPOINTS) {
104
+ throw new Error(`username must be 1–${MAX_USERNAME_CODEPOINTS} characters`);
105
+ }
106
+ }
107
+ function migrate(db) {
108
+ db.exec("PRAGMA journal_mode = WAL");
109
+ db.exec("PRAGMA foreign_keys = ON");
110
+ const row = db.prepare("PRAGMA user_version").get();
111
+ const version = row?.user_version ?? 0;
112
+ if (version > SCHEMA_VERSION) {
113
+ throw new Error(`server database schema ${version} is newer than this build (${SCHEMA_VERSION})`);
114
+ }
115
+ if (version === SCHEMA_VERSION)
116
+ return;
117
+ db.exec(`
118
+ BEGIN;
119
+ CREATE TABLE users (
120
+ id INTEGER PRIMARY KEY,
121
+ username TEXT NOT NULL UNIQUE,
122
+ password_hash TEXT NOT NULL,
123
+ created_at INTEGER NOT NULL
124
+ );
125
+ CREATE TABLE sessions (
126
+ token_hash TEXT PRIMARY KEY,
127
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
128
+ created_at INTEGER NOT NULL,
129
+ expires_at INTEGER NOT NULL
130
+ );
131
+ CREATE TABLE join_tokens (
132
+ token_hash TEXT PRIMARY KEY,
133
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
134
+ created_at INTEGER NOT NULL,
135
+ expires_at INTEGER NOT NULL,
136
+ used_at INTEGER
137
+ );
138
+ CREATE TABLE hosts (
139
+ host_id TEXT PRIMARY KEY,
140
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
141
+ noise_public_key TEXT NOT NULL,
142
+ name TEXT NOT NULL,
143
+ joined_at INTEGER NOT NULL,
144
+ last_seen_at INTEGER NOT NULL
145
+ );
146
+ PRAGMA user_version = ${SCHEMA_VERSION};
147
+ COMMIT;
148
+ `);
149
+ }
150
+
151
+ class WindowedCounter {
152
+ map = new Map;
153
+ maxEntries;
154
+ now;
155
+ constructor(maxEntries, now) {
156
+ this.maxEntries = maxEntries;
157
+ this.now = now;
158
+ }
159
+ hit(key, max, windowMs) {
160
+ if (this.isOver(key, max))
161
+ return true;
162
+ this.bump(key, windowMs);
163
+ return false;
164
+ }
165
+ isOver(key, max) {
166
+ const entry = this.map.get(key);
167
+ return entry !== undefined && entry.resetAt > this.now() && entry.count >= max;
168
+ }
169
+ bump(key, windowMs) {
170
+ const now = this.now();
171
+ let entry = this.map.get(key);
172
+ if (entry === undefined || entry.resetAt <= now) {
173
+ entry = { count: 0, resetAt: now + windowMs };
174
+ this.map.delete(key);
175
+ if (this.map.size >= this.maxEntries) {
176
+ const oldest = this.map.keys().next();
177
+ if (!oldest.done)
178
+ this.map.delete(oldest.value);
179
+ }
180
+ this.map.set(key, entry);
181
+ }
182
+ entry.count += 1;
183
+ }
184
+ reset(key) {
185
+ this.map.delete(key);
186
+ }
187
+ }
188
+
189
+ class ScryptGate {
190
+ running = 0;
191
+ waiters = [];
192
+ concurrency;
193
+ maxQueue;
194
+ constructor(concurrency, maxQueue) {
195
+ this.concurrency = concurrency;
196
+ this.maxQueue = maxQueue;
197
+ }
198
+ run(task) {
199
+ if (this.running < this.concurrency) {
200
+ this.running += 1;
201
+ return this.finish(task());
202
+ }
203
+ if (this.waiters.length >= this.maxQueue)
204
+ return;
205
+ return new Promise((resolve, reject) => {
206
+ this.waiters.push(() => {
207
+ this.running += 1;
208
+ this.finish(task()).then(resolve, reject);
209
+ });
210
+ });
211
+ }
212
+ async finish(pending) {
213
+ try {
214
+ return await pending;
215
+ } finally {
216
+ this.running -= 1;
217
+ this.waiters.shift()?.();
218
+ }
219
+ }
220
+ }
221
+ async function openAccounts(options) {
222
+ const now = options.now ?? Date.now;
223
+ await mkdir(options.dataDir, { recursive: true });
224
+ const release = await lockDataDir(options.dataDir, { staleMs: options.lockStaleMs });
225
+ let db;
226
+ try {
227
+ db = new DatabaseSync(join(options.dataDir, DB_FILE));
228
+ migrate(db);
229
+ const userCount = db.prepare("SELECT COUNT(*) AS n FROM users").get().n;
230
+ if (userCount === 0) {
231
+ if (options.bootstrapUser === undefined || options.bootstrapPassword === undefined) {
232
+ throw new Error("PINOMAD_SERVER_USER and PINOMAD_SERVER_PASSWORD are required to create the first account");
233
+ }
234
+ assertUsernamePolicy(options.bootstrapUser);
235
+ assertPasswordPolicy(options.bootstrapPassword);
236
+ const hash = await hashPassword(options.bootstrapPassword);
237
+ db.prepare("INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)").run(options.bootstrapUser, hash, now());
238
+ }
239
+ } catch (error) {
240
+ db?.close();
241
+ await release();
242
+ throw error;
243
+ }
244
+ const limits = {
245
+ scryptConcurrency: options.limits?.scryptConcurrency ?? 4,
246
+ scryptQueue: options.limits?.scryptQueue ?? 64,
247
+ ipMaxAttempts: options.limits?.ipMaxAttempts ?? 30,
248
+ ipWindowMs: options.limits?.ipWindowMs ?? 5 * 60000,
249
+ userMaxFailures: options.limits?.userMaxFailures ?? 5,
250
+ userWindowMs: options.limits?.userWindowMs ?? 15 * 60000,
251
+ counterMaxEntries: options.limits?.counterMaxEntries ?? 4096
252
+ };
253
+ const gate = new ScryptGate(limits.scryptConcurrency, limits.scryptQueue);
254
+ const ipAttempts = new WindowedCounter(limits.counterMaxEntries, now);
255
+ const userFailures = new WindowedCounter(limits.counterMaxEntries, now);
256
+ return {
257
+ dbPath: join(options.dataDir, DB_FILE),
258
+ async login(username, password, ip) {
259
+ if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
260
+ return { status: "limited" };
261
+ if (userFailures.isOver(username, limits.userMaxFailures))
262
+ return { status: "limited" };
263
+ const user = db.prepare("SELECT id, username, password_hash AS passwordHash FROM users WHERE username = ?").get(username);
264
+ const outcome = await gate.run(async () => {
265
+ if (userFailures.isOver(username, limits.userMaxFailures))
266
+ return "limited";
267
+ const ok = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH);
268
+ if (!ok || user === undefined) {
269
+ userFailures.bump(username, limits.userWindowMs);
270
+ return "invalid";
271
+ }
272
+ return user.id;
273
+ });
274
+ if (outcome === undefined || outcome === "limited")
275
+ return { status: "limited" };
276
+ if (outcome === "invalid")
277
+ return { status: "invalid" };
278
+ userFailures.reset(username);
279
+ db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(now());
280
+ const token = randomBytes(32).toString("base64url");
281
+ const expiresAt = now() + SESSION_TTL_MS;
282
+ db.prepare("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(token), outcome, now(), expiresAt);
283
+ return { status: "ok", token, expiresAt };
284
+ },
285
+ authenticate(token) {
286
+ const row = db.prepare(`SELECT s.user_id AS userId, u.username AS username, s.expires_at AS expiresAt
287
+ FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?`).get(sha256Hex(token));
288
+ if (row === undefined || row.expiresAt <= now())
289
+ return;
290
+ return { userId: row.userId, username: row.username };
291
+ },
292
+ logout(token) {
293
+ db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256Hex(token));
294
+ },
295
+ revokeAllSessions(userId) {
296
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
297
+ },
298
+ hasHost(hostId) {
299
+ return db.prepare("SELECT 1 AS x FROM hosts WHERE host_id = ?").get(hostId) !== undefined;
300
+ },
301
+ createJoinToken(userId) {
302
+ db.prepare("DELETE FROM join_tokens WHERE expires_at <= ?").run(now());
303
+ const token = randomBytes(32).toString("base64url");
304
+ const expiresAt = now() + JOIN_TOKEN_TTL_MS;
305
+ db.prepare("INSERT INTO join_tokens (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt);
306
+ return { token, expiresAt };
307
+ },
308
+ joinHost({ token, hostId, noisePublicKey, name }) {
309
+ const tokenHash = sha256Hex(token);
310
+ const at = now();
311
+ db.exec("BEGIN");
312
+ try {
313
+ 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);
314
+ let failure;
315
+ if (row === undefined || row.usedAt !== null)
316
+ failure = "invalid_token";
317
+ else if (row.expiresAt <= at)
318
+ failure = "expired_token";
319
+ else {
320
+ const consumed = db.prepare("UPDATE join_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL").run(at, tokenHash);
321
+ if (Number(consumed.changes) !== 1)
322
+ failure = "invalid_token";
323
+ }
324
+ if (failure !== undefined) {
325
+ db.exec("ROLLBACK");
326
+ return { status: "refused", reason: failure };
327
+ }
328
+ const hostName = name === undefined || name.trim() === "" ? "Unknown host" : name.trim().slice(0, 64);
329
+ db.prepare(`INSERT INTO hosts (host_id, user_id, noise_public_key, name, joined_at, last_seen_at)
330
+ VALUES (?, ?, ?, ?, ?, ?)
331
+ ON CONFLICT(host_id) DO UPDATE SET
332
+ user_id = excluded.user_id,
333
+ noise_public_key = excluded.noise_public_key,
334
+ name = excluded.name,
335
+ joined_at = excluded.joined_at,
336
+ last_seen_at = excluded.last_seen_at`).run(hostId, row.userId, noisePublicKey, hostName, at, at);
337
+ const user = db.prepare("SELECT username FROM users WHERE id = ?").get(row.userId);
338
+ db.exec("COMMIT");
339
+ return { status: "joined", username: user?.username ?? "unknown" };
340
+ } catch (error) {
341
+ db.exec("ROLLBACK");
342
+ throw error;
343
+ }
344
+ },
345
+ leaveHost(hostId) {
346
+ return Number(db.prepare("DELETE FROM hosts WHERE host_id = ?").run(hostId).changes) > 0;
347
+ },
348
+ listHosts(userId) {
349
+ return db.prepare(`SELECT host_id AS hostId, user_id AS userId, noise_public_key AS noisePublicKey,
350
+ name, joined_at AS joinedAt, last_seen_at AS lastSeenAt
351
+ FROM hosts WHERE user_id = ? ORDER BY joined_at`).all(userId);
352
+ },
353
+ hostFor(userId, hostId) {
354
+ return db.prepare(`SELECT host_id AS hostId, user_id AS userId, noise_public_key AS noisePublicKey,
355
+ name, joined_at AS joinedAt, last_seen_at AS lastSeenAt
356
+ FROM hosts WHERE user_id = ? AND host_id = ?`).get(userId, hostId);
357
+ },
358
+ markHostSeen(hostId) {
359
+ db.prepare("UPDATE hosts SET last_seen_at = ? WHERE host_id = ?").run(now(), hostId);
360
+ },
361
+ async close() {
362
+ db.close();
363
+ await release();
364
+ }
365
+ };
366
+ }
367
+ async function changePassword(options) {
368
+ assertPasswordPolicy(options.password);
369
+ await mkdir(options.dataDir, { recursive: true });
370
+ const release = await lockDataDir(options.dataDir, { retries: 0 });
371
+ const db = new DatabaseSync(join(options.dataDir, DB_FILE));
372
+ try {
373
+ migrate(db);
374
+ const users = options.user === undefined ? db.prepare("SELECT id FROM users").all() : db.prepare("SELECT id FROM users WHERE username = ?").all(options.user);
375
+ if (users.length === 0)
376
+ throw new Error(options.user === undefined ? "no account exists yet" : `no such account: ${options.user}`);
377
+ if (users.length > 1)
378
+ throw new Error("more than one account exists — pass --user");
379
+ const userId = users[0].id;
380
+ const hash = await hashPassword(options.password);
381
+ db.exec("BEGIN");
382
+ try {
383
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
384
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
385
+ db.prepare("DELETE FROM join_tokens WHERE user_id = ? AND used_at IS NULL").run(userId);
386
+ db.exec("COMMIT");
387
+ } catch (error) {
388
+ db.exec("ROLLBACK");
389
+ throw error;
390
+ }
391
+ } finally {
392
+ db.close();
393
+ await release();
394
+ }
395
+ }
396
+
397
+ // apps/server/src/apns.ts
398
+ import { createPrivateKey, sign } from "node:crypto";
399
+ import { connect } from "node:http2";
400
+ var AUTHORITIES = {
401
+ production: "https://api.push.apple.com",
402
+ development: "https://api.sandbox.push.apple.com"
403
+ };
404
+ var PAYLOAD_MAX_BYTES = 4096;
405
+ var EXPIRATION_SECONDS = 3600;
406
+ var TOKEN_REFRESH_MS = 45 * 60 * 1000;
407
+ var REQUEST_TIMEOUT_MS = 8000;
408
+ var SERVER_ERROR_RETRY_MS = 15 * 60 * 1000;
409
+ var base64url = (text) => Buffer.from(text, "utf8").toString("base64url");
410
+ function loadApnsConfig(env = process.env) {
411
+ const teamId = env.PINOMAD_SERVER_APNS_TEAM_ID?.trim();
412
+ const topic = env.PINOMAD_SERVER_APNS_TOPIC?.trim();
413
+ if (teamId === undefined || teamId === "" || topic === undefined || topic === "")
414
+ return;
415
+ const loadEnv = (name) => {
416
+ const keyId = env[`PINOMAD_SERVER_APNS_${name}_KEY_ID`]?.trim();
417
+ const keyPem = env[`PINOMAD_SERVER_APNS_${name}_KEY`]?.replace(/\\n/g, `
418
+ `).trim();
419
+ if (keyId === undefined && keyPem === undefined)
420
+ return;
421
+ if (keyId === undefined || keyId === "" || keyPem === undefined || keyPem === "") {
422
+ console.error(`apns: PINOMAD_SERVER_APNS_${name}_KEY and _KEY_ID must both be set — environment disabled`);
423
+ return;
424
+ }
425
+ return { keyId, keyPem };
426
+ };
427
+ const production = loadEnv("PRODUCTION");
428
+ const development = loadEnv("DEVELOPMENT");
429
+ return {
430
+ teamId,
431
+ topic,
432
+ environments: {
433
+ ...production === undefined ? {} : { production },
434
+ ...development === undefined ? {} : { development }
435
+ }
436
+ };
437
+ }
438
+ function providerToken(key, keyId, teamId, iatSeconds) {
439
+ const signingInput = `${base64url(JSON.stringify({ alg: "ES256", kid: keyId }))}.${base64url(JSON.stringify({ iss: teamId, iat: iatSeconds }))}`;
440
+ const signature = sign("sha256", Buffer.from(signingInput, "utf8"), { key, dsaEncoding: "ieee-p1363" });
441
+ return `${signingInput}.${signature.toString("base64url")}`;
442
+ }
443
+ function openApns(config) {
444
+ const states = new Map;
445
+ for (const environment of ["production", "development"]) {
446
+ const env = config.environments[environment];
447
+ if (env === undefined)
448
+ continue;
449
+ try {
450
+ states.set(environment, {
451
+ key: createPrivateKey(env.keyPem),
452
+ keyId: env.keyId,
453
+ authority: env.authority ?? AUTHORITIES[environment]
454
+ });
455
+ } catch {
456
+ console.error(`apns: the ${environment} key is not a valid EC private key — environment disabled`);
457
+ }
458
+ }
459
+ const environments = [...states.keys()];
460
+ const bearerFor = (state) => {
461
+ const now = Date.now();
462
+ if (state.token !== undefined && now - state.token.issuedAt < TOKEN_REFRESH_MS)
463
+ return state.token.jwt;
464
+ const jwt = providerToken(state.key, state.keyId, config.teamId, Math.floor(now / 1000));
465
+ state.token = { jwt, issuedAt: now };
466
+ return jwt;
467
+ };
468
+ const sessionFor = (environment, state) => {
469
+ const existing = state.session;
470
+ if (existing !== undefined && !existing.destroyed && !existing.closed)
471
+ return existing;
472
+ const session = connect(state.authority);
473
+ const drop = () => {
474
+ if (state.session === session)
475
+ state.session = undefined;
476
+ if (!session.destroyed)
477
+ session.destroy();
478
+ };
479
+ session.once("goaway", drop);
480
+ session.once("error", drop);
481
+ session.once("close", () => {
482
+ if (state.session === session)
483
+ state.session = undefined;
484
+ });
485
+ state.session = session;
486
+ return session;
487
+ };
488
+ const payloadFor = (notification) => JSON.stringify({
489
+ aps: {
490
+ alert: { title: notification.title, body: notification.body },
491
+ sound: "default",
492
+ "thread-id": notification.collapseId
493
+ },
494
+ hostKey: notification.hostKey,
495
+ conversationId: notification.conversationId
496
+ });
497
+ const classify = (environment, status, headers, body, state) => {
498
+ const reason = typeof body?.reason === "string" ? body.reason : `HTTP ${status}`;
499
+ const timestamp = body?.timestamp;
500
+ if (status === 200)
501
+ return { result: "accepted", reason: "ok" };
502
+ if (status === 410) {
503
+ return {
504
+ result: "unregistered",
505
+ reason,
506
+ unregisteredAt: typeof timestamp === "number" ? timestamp : Date.now()
507
+ };
508
+ }
509
+ if (status === 403 && reason === "ExpiredProviderToken") {
510
+ state.token = undefined;
511
+ return { result: "retry", reason };
512
+ }
513
+ if (reason === "InvalidProviderToken" || reason === "TooManyProviderTokenUpdates") {
514
+ console.error(`apns: provider token problem for ${environment}: ${reason}`);
515
+ return { result: "rejected", reason };
516
+ }
517
+ if (status === 429) {
518
+ const retryAfter = Number(headers["retry-after"]);
519
+ return {
520
+ result: "retry",
521
+ reason,
522
+ ...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterMs: retryAfter * 1000 } : {}
523
+ };
524
+ }
525
+ if (status >= 500)
526
+ return { result: "retry", reason, retryAfterMs: SERVER_ERROR_RETRY_MS };
527
+ return { result: "rejected", reason };
528
+ };
529
+ return {
530
+ environments,
531
+ async send(request) {
532
+ const state = states.get(request.environment);
533
+ if (state === undefined)
534
+ return { result: "unavailable", reason: "environment not configured" };
535
+ const body = payloadFor(request.notification);
536
+ if (Buffer.byteLength(body, "utf8") > PAYLOAD_MAX_BYTES) {
537
+ return { result: "rejected", reason: "PayloadTooLarge" };
538
+ }
539
+ return await new Promise((resolve) => {
540
+ let session;
541
+ try {
542
+ session = sessionFor(request.environment, state);
543
+ } catch {
544
+ resolve({ result: "retry", reason: "session unavailable" });
545
+ return;
546
+ }
547
+ let stream;
548
+ try {
549
+ stream = session.request({
550
+ ":method": "POST",
551
+ ":path": `/3/device/${request.token}`,
552
+ authorization: `bearer ${bearerFor(state)}`,
553
+ "apns-push-type": "alert",
554
+ "apns-topic": config.topic,
555
+ "apns-expiration": String(Math.floor(Date.now() / 1000) + EXPIRATION_SECONDS),
556
+ "apns-collapse-id": request.notification.collapseId,
557
+ "content-type": "application/json"
558
+ });
559
+ } catch {
560
+ resolve({ result: "retry", reason: "session unavailable" });
561
+ return;
562
+ }
563
+ let status = 0;
564
+ let responseHeaders = {};
565
+ const chunks = [];
566
+ let settled = false;
567
+ const finish = (verdict) => {
568
+ if (settled)
569
+ return;
570
+ settled = true;
571
+ clearTimeout(timer);
572
+ stream.destroy();
573
+ resolve(verdict);
574
+ };
575
+ const timer = setTimeout(() => finish({ result: "retry", reason: "apns timeout" }), REQUEST_TIMEOUT_MS);
576
+ stream.on("response", (headers) => {
577
+ status = Number(headers[":status"] ?? 0);
578
+ responseHeaders = headers;
579
+ });
580
+ stream.on("data", (chunk) => chunks.push(chunk));
581
+ stream.on("end", () => {
582
+ let body2;
583
+ try {
584
+ body2 = JSON.parse(Buffer.concat(chunks).toString("utf8"));
585
+ } catch {
586
+ body2 = undefined;
587
+ }
588
+ finish(classify(request.environment, status, responseHeaders, body2, state));
589
+ });
590
+ stream.on("error", () => finish({ result: "retry", reason: "connection error" }));
591
+ stream.on("aborted", () => finish({ result: "retry", reason: "connection aborted" }));
592
+ stream.end(body);
593
+ });
594
+ },
595
+ close() {
596
+ for (const state of states.values())
597
+ state.session?.destroy();
598
+ states.clear();
599
+ return Promise.resolve();
600
+ }
601
+ };
602
+ }
603
+
604
+ // apps/server/src/relay.ts
605
+ import { randomBytes as randomBytes2 } from "node:crypto";
606
+ import { once } from "node:events";
607
+ import { createServer } from "node:http";
608
+ import { WebSocket, WebSocketServer } from "ws";
609
+
610
+ // apps/server/src/api.ts
611
+ var SESSION_COOKIE = "__Host-pinomad-session";
612
+ var MAX_BODY = 8 * 1024;
613
+ var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
614
+ var DESKTOP_ORIGIN = "app://pinomad";
615
+ var isLoopback = (address) => address !== undefined && (address.startsWith("127.") || address === "::1" || address.startsWith("::ffff:127."));
616
+ function secureTransport(request, trustProxy) {
617
+ if (trustProxy)
618
+ return request.headers["x-forwarded-proto"] === "https";
619
+ return isLoopback(request.socket.remoteAddress);
620
+ }
621
+ function clientIp(request, trustProxy) {
622
+ if (trustProxy) {
623
+ const forwarded = request.headers["x-forwarded-for"];
624
+ const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim();
625
+ if (first !== undefined && first !== "")
626
+ return first;
627
+ }
628
+ return request.socket.remoteAddress ?? "unknown";
629
+ }
630
+ function respond(response, status, body, headers) {
631
+ const all = { ...headers };
632
+ if (body !== undefined)
633
+ all["content-type"] = "application/json";
634
+ response.writeHead(status, all);
635
+ response.end(body === undefined ? undefined : JSON.stringify(body));
636
+ }
637
+ async function readJson(request) {
638
+ const chunks = [];
639
+ let size = 0;
640
+ let tooLarge = false;
641
+ for await (const chunk of request) {
642
+ size += chunk.length;
643
+ if (size > MAX_BODY) {
644
+ tooLarge = true;
645
+ } else if (!tooLarge) {
646
+ chunks.push(chunk);
647
+ }
648
+ }
649
+ if (tooLarge)
650
+ return { ok: false, tooLarge: true };
651
+ try {
652
+ return { ok: true, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
653
+ } catch {
654
+ return { ok: false, tooLarge: false };
655
+ }
656
+ }
657
+ function sessionToken(request) {
658
+ const authorization = request.headers.authorization;
659
+ if (authorization !== undefined && authorization.startsWith("Bearer "))
660
+ return authorization.slice("Bearer ".length);
661
+ const header = request.headers.cookie;
662
+ if (header === undefined)
663
+ return;
664
+ for (const part of header.split(";")) {
665
+ const eq = part.indexOf("=");
666
+ if (eq > 0 && part.slice(0, eq).trim() === SESSION_COOKIE)
667
+ return part.slice(eq + 1).trim();
668
+ }
669
+ return;
670
+ }
671
+ var cookieAttributes = "Path=/; Secure; HttpOnly; SameSite=Strict";
672
+ var setSessionCookie = (token) => `${SESSION_COOKIE}=${token}; ${cookieAttributes}`;
673
+ var clearSessionCookie = `${SESSION_COOKIE}=; ${cookieAttributes}; Max-Age=0`;
674
+ async function handleApiRequest(request, response, ctx) {
675
+ const { accounts } = ctx;
676
+ const origin = request.headers.origin;
677
+ const method = request.method ?? "";
678
+ let pathname;
679
+ try {
680
+ pathname = new URL(request.url ?? "/", "http://server.invalid").pathname;
681
+ } catch {
682
+ pathname = "";
683
+ }
684
+ const cors = origin === DESKTOP_ORIGIN ? { "access-control-allow-origin": DESKTOP_ORIGIN, vary: "Origin" } : {};
685
+ const send = (status, body, headers) => respond(response, status, body, { ...cors, ...headers });
686
+ const deny = (status, error, headers) => send(status, { error }, headers);
687
+ if (!secureTransport(request, ctx.trustProxy)) {
688
+ deny(403, "https_required");
689
+ return;
690
+ }
691
+ if (method === "OPTIONS") {
692
+ send(204, undefined, origin === DESKTOP_ORIGIN ? {
693
+ "access-control-allow-methods": "GET, POST",
694
+ "access-control-allow-headers": "authorization, content-type"
695
+ } : {});
696
+ return;
697
+ }
698
+ const bearer = (request.headers.authorization ?? "").startsWith("Bearer ");
699
+ const loginTokenMode = pathname === "/api/login" && (origin === undefined || origin === DESKTOP_ORIGIN);
700
+ if (MUTATING.has(method) && !bearer && !loginTokenMode && origin !== ctx.publicOrigin) {
701
+ deny(403, "origin_mismatch");
702
+ return;
703
+ }
704
+ if (pathname === "/api/login") {
705
+ if (method !== "POST") {
706
+ deny(405, "method_not_allowed", { allow: "POST" });
707
+ return;
708
+ }
709
+ const body = await readJson(request);
710
+ if (!body.ok) {
711
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
712
+ return;
713
+ }
714
+ const value = body.value;
715
+ if (typeof value !== "object" || value === null || typeof value.username !== "string" || typeof value.password !== "string") {
716
+ deny(400, "invalid_request");
717
+ return;
718
+ }
719
+ const { username, password } = value;
720
+ if ([...username].length === 0 || [...username].length > MAX_USERNAME_CODEPOINTS || [...password].length > MAX_PASSWORD_CODEPOINTS) {
721
+ deny(400, "invalid_request");
722
+ return;
723
+ }
724
+ const result = await accounts.login(username, password, clientIp(request, ctx.trustProxy));
725
+ if (result.status === "limited") {
726
+ deny(429, "rate_limited");
727
+ return;
728
+ }
729
+ if (result.status === "invalid") {
730
+ deny(401, "invalid_credentials");
731
+ return;
732
+ }
733
+ const cookieMode = origin === ctx.publicOrigin;
734
+ send(200, { user: { username }, expiresAt: result.expiresAt, ...cookieMode ? {} : { token: result.token } }, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
735
+ return;
736
+ }
737
+ const hostPairing = /^\/api\/hosts\/([A-Za-z0-9_-]{43})\/pairing$/.exec(pathname);
738
+ if (pathname === "/api/hosts" || hostPairing !== null) {
739
+ const expected = pathname === "/api/hosts" ? "GET" : "POST";
740
+ if (method !== expected) {
741
+ deny(405, "method_not_allowed", { allow: expected });
742
+ return;
743
+ }
744
+ const token = sessionToken(request);
745
+ const session = token === undefined ? undefined : accounts.authenticate(token);
746
+ if (session === undefined) {
747
+ deny(401, "unauthenticated");
748
+ return;
749
+ }
750
+ if (pathname === "/api/hosts") {
751
+ send(200, accounts.listHosts(session.userId).map((row2) => {
752
+ const pairing = ctx.pairing.status(row2.hostId, row2.noisePublicKey);
753
+ return {
754
+ hostId: row2.hostId,
755
+ name: row2.name,
756
+ noisePublicKey: row2.noisePublicKey,
757
+ online: pairing !== "offline",
758
+ lastSeenAt: row2.lastSeenAt,
759
+ pairing
760
+ };
761
+ }));
762
+ return;
763
+ }
764
+ const row = accounts.hostFor(session.userId, hostPairing[1]);
765
+ if (row === undefined) {
766
+ deny(404, "not_found");
767
+ return;
768
+ }
769
+ const status = ctx.pairing.status(row.hostId, row.noisePublicKey);
770
+ if (status !== "available") {
771
+ deny(409, status === "offline" ? "host_offline" : status);
772
+ return;
773
+ }
774
+ const abort = new AbortController;
775
+ response.once("close", () => {
776
+ if (!response.writableEnded)
777
+ abort.abort();
778
+ });
779
+ const result = await ctx.pairing.request(row.hostId, token, abort.signal);
780
+ if (result.status === "ok" && accounts.hostFor(session.userId, row.hostId) === undefined) {
781
+ deny(404, "not_found");
782
+ return;
783
+ }
784
+ switch (result.status) {
785
+ case "ok":
786
+ send(200, {
787
+ url: `${relayWsBase(ctx.publicOrigin)}/c/${row.hostId}`,
788
+ hostKey: row.noisePublicKey,
789
+ secret: result.secret,
790
+ expiresAt: result.expiresAt
791
+ });
792
+ return;
793
+ case "refused":
794
+ deny(409, result.reason);
795
+ return;
796
+ case "gone":
797
+ deny(409, "host_offline");
798
+ return;
799
+ case "timeout":
800
+ deny(504, "host_timeout");
801
+ return;
802
+ case "too_many":
803
+ deny(429, "rate_limited");
804
+ return;
805
+ }
806
+ return;
807
+ }
808
+ if (pathname === "/api/me" || pathname === "/api/logout" || pathname === "/api/sessions/revoke-all" || pathname === "/api/join-tokens") {
809
+ const expected = pathname === "/api/me" ? "GET" : "POST";
810
+ if (method !== expected) {
811
+ deny(405, "method_not_allowed", { allow: expected });
812
+ return;
813
+ }
814
+ const token = sessionToken(request);
815
+ const session = token === undefined ? undefined : accounts.authenticate(token);
816
+ if (session === undefined) {
817
+ deny(401, "unauthenticated");
818
+ return;
819
+ }
820
+ if (pathname === "/api/me") {
821
+ send(200, { user: { username: session.username } });
822
+ return;
823
+ }
824
+ if (pathname === "/api/join-tokens") {
825
+ const joinToken = accounts.createJoinToken(session.userId);
826
+ send(200, {
827
+ token: joinToken.token,
828
+ expiresAt: joinToken.expiresAt,
829
+ command: `npm i -g pinomad && pinomad join ${ctx.publicOrigin} ${joinToken.token}`
830
+ });
831
+ return;
832
+ }
833
+ if (pathname === "/api/logout") {
834
+ accounts.logout(token);
835
+ send(204, undefined, { "set-cookie": clearSessionCookie });
836
+ return;
837
+ }
838
+ accounts.revokeAllSessions(session.userId);
839
+ send(204, undefined, { "set-cookie": clearSessionCookie });
840
+ return;
841
+ }
842
+ deny(404, "not_found");
843
+ }
844
+
845
+ // apps/server/src/relay.ts
846
+ var MAX_PAYLOAD = 128 * 1024;
847
+ var MAX_BUFFERED = 64 * 1024;
848
+ var HIGH_WATER = 1024 * 1024;
849
+ var LOW_WATER = 256 * 1024;
850
+ var HOST_PATH = "/host";
851
+ var DEVICE_PATH = /^\/c\/([A-Za-z0-9_-]{43})$/;
852
+ var ACCEPT_PATH = /^\/accept\/([A-Za-z0-9_-]{22})$/;
853
+ var isSendableCloseCode = (code) => code >= 1000 && code <= 1003 || code >= 1007 && code <= 1014 || code >= 3000 && code <= 4999;
854
+ var rawLength = (data) => Array.isArray(data) ? data.reduce((sum, part) => sum + part.length, 0) : data.byteLength;
855
+ var MAX_PAIRING_PER_HOST = 4;
856
+ var MAX_PAIRING_PER_SESSION = 2;
857
+ var MAX_PUSH_PER_HOST = 4;
858
+ var MAX_PUSH_TOTAL = 32;
859
+ async function startRelay(options) {
860
+ const publicOrigin = options.publicOrigin.replace(/\/+$/, "");
861
+ if (publicOrigin === "")
862
+ throw new Error("publicOrigin is required");
863
+ const acceptTimeoutMs = options.acceptTimeoutMs ?? 1e4;
864
+ const registerTimeoutMs = options.registerTimeoutMs ?? 1e4;
865
+ const pingIntervalMs = options.pingIntervalMs ?? 30000;
866
+ const listenHost = options.listenHost ?? "127.0.0.1";
867
+ const accounts = options.accounts;
868
+ const trustProxy = options.trustProxy ?? false;
869
+ const pairingTimeoutMs = options.pairingTimeoutMs ?? 1e4;
870
+ const apns = options.push;
871
+ const pushEnvironments = apns?.environments ?? [];
872
+ const pushPerHost = options.pushLimits?.perHost ?? MAX_PUSH_PER_HOST;
873
+ const pushTotal = options.pushLimits?.total ?? MAX_PUSH_TOTAL;
874
+ let pushInflightTotal = 0;
875
+ const pushInflightHosts = new Map;
876
+ const controls = new Map;
877
+ const pending = new Map;
878
+ const pairingRequests = new Map;
879
+ const sockets = new Set;
880
+ const alive = new WeakMap;
881
+ function track(socket) {
882
+ sockets.add(socket);
883
+ alive.set(socket, true);
884
+ socket.on("pong", () => alive.set(socket, true));
885
+ socket.on("close", () => sockets.delete(socket));
886
+ socket.on("error", () => socket.terminate());
887
+ }
888
+ function splice(a, b) {
889
+ const forward = (from, to) => {
890
+ from.on("message", (data, isBinary) => {
891
+ if (to.readyState !== WebSocket.OPEN)
892
+ return;
893
+ to.send(data, { binary: isBinary }, () => {
894
+ if (from.isPaused && to.bufferedAmount <= LOW_WATER)
895
+ from.resume();
896
+ });
897
+ if (to.bufferedAmount > HIGH_WATER)
898
+ from.pause();
899
+ });
900
+ from.on("close", (code, reason) => {
901
+ if (to.readyState !== WebSocket.OPEN)
902
+ return;
903
+ to.close(isSendableCloseCode(code) ? code : 1001, reason);
904
+ });
905
+ };
906
+ forward(a, b);
907
+ forward(b, a);
908
+ }
909
+ function pairingStatus(hostId, boundNoiseKey) {
910
+ const control = controls.get(hostId);
911
+ if (control === undefined)
912
+ return "offline";
913
+ if (control.noisePublicKey === undefined || control.features?.includes("pairing") !== true)
914
+ return "host_too_old";
915
+ return control.noisePublicKey === boundNoiseKey ? "available" : "key_mismatch";
916
+ }
917
+ function requestPairing(hostId, sessionKey, signal) {
918
+ const control = controls.get(hostId);
919
+ if (control === undefined)
920
+ return Promise.resolve({ status: "gone" });
921
+ let perHost = 0;
922
+ let perSession = 0;
923
+ for (const entry of pairingRequests.values()) {
924
+ if (entry.hostId === hostId)
925
+ perHost += 1;
926
+ if (entry.sessionKey === sessionKey)
927
+ perSession += 1;
928
+ }
929
+ if (perHost >= MAX_PAIRING_PER_HOST || perSession >= MAX_PAIRING_PER_SESSION) {
930
+ return Promise.resolve({ status: "too_many" });
931
+ }
932
+ return new Promise((resolve) => {
933
+ const requestId = randomBytes2(16).toString("base64url");
934
+ const settle = (result) => {
935
+ if (!pairingRequests.delete(requestId))
936
+ return;
937
+ clearTimeout(timer);
938
+ signal.removeEventListener("abort", abort);
939
+ resolve(result);
940
+ };
941
+ const timer = setTimeout(() => settle({ status: "timeout" }), pairingTimeoutMs);
942
+ const abort = () => settle({ status: "cancelled" });
943
+ pairingRequests.set(requestId, { control: control.socket, hostId, sessionKey, settle, timer });
944
+ signal.addEventListener("abort", abort, { once: true });
945
+ control.socket.send(encodeRelayFrame({ t: "pairing-request", requestId }));
946
+ });
947
+ }
948
+ const pairing = { status: pairingStatus, request: requestPairing };
949
+ function handlePush(socket, hostId, frame) {
950
+ const startedAt = Date.now();
951
+ const finish = (verdict) => {
952
+ console.log(JSON.stringify({
953
+ event: "push",
954
+ hostId,
955
+ environment: frame.environment,
956
+ result: verdict.result,
957
+ reason: verdict.reason,
958
+ ms: Date.now() - startedAt
959
+ }));
960
+ if (socket.readyState === WebSocket.OPEN) {
961
+ socket.send(encodeRelayFrame({ t: "push-result", requestId: frame.requestId, ...verdict }));
962
+ }
963
+ };
964
+ if (accounts?.hasHost(hostId) !== true) {
965
+ finish({ result: "rejected", reason: "unauthorized" });
966
+ return;
967
+ }
968
+ if (apns === undefined || !pushEnvironments.includes(frame.environment)) {
969
+ finish({ result: "unavailable", reason: "environment not configured" });
970
+ return;
971
+ }
972
+ const perHost = pushInflightHosts.get(hostId) ?? 0;
973
+ if (perHost >= pushPerHost || pushInflightTotal >= pushTotal) {
974
+ finish({ result: "retry", reason: "rate_limited" });
975
+ return;
976
+ }
977
+ pushInflightTotal += 1;
978
+ pushInflightHosts.set(hostId, perHost + 1);
979
+ apns.send({ token: frame.token, environment: frame.environment, notification: frame.notification }).then(finish, () => finish({ result: "retry", reason: "server error" })).finally(() => {
980
+ pushInflightTotal -= 1;
981
+ const left = (pushInflightHosts.get(hostId) ?? 1) - 1;
982
+ if (left <= 0)
983
+ pushInflightHosts.delete(hostId);
984
+ else
985
+ pushInflightHosts.set(hostId, left);
986
+ });
987
+ }
988
+ function runOp(socket, frame, noisePublicKey) {
989
+ const answer = (reply) => socket.send(encodeRelayFrame(reply), () => socket.close(1000));
990
+ if (accounts === undefined) {
991
+ answer({ t: "refused", reason: "accounts_disabled" });
992
+ return;
993
+ }
994
+ if (frame.op === "join") {
995
+ if (frame.joinToken === undefined || noisePublicKey === undefined) {
996
+ answer({ t: "refused", reason: "bad_request" });
997
+ return;
998
+ }
999
+ const result = accounts.joinHost({
1000
+ token: frame.joinToken,
1001
+ hostId: frame.hostId,
1002
+ noisePublicKey,
1003
+ ...frame.name === undefined ? {} : { name: frame.name }
1004
+ });
1005
+ if (result.status === "joined") {
1006
+ for (const request of pairingRequests.values()) {
1007
+ if (request.hostId === frame.hostId)
1008
+ request.settle({ status: "gone" });
1009
+ }
1010
+ }
1011
+ answer(result.status === "joined" ? { t: "joined", username: result.username } : { t: "refused", reason: result.reason });
1012
+ return;
1013
+ }
1014
+ if (!accounts.leaveHost(frame.hostId)) {
1015
+ answer({ t: "refused", reason: "not_joined" });
1016
+ return;
1017
+ }
1018
+ answer({ t: "left" });
1019
+ controls.get(frame.hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
1020
+ }
1021
+ function onControl(socket) {
1022
+ track(socket);
1023
+ const nonce = randomBytes2(32).toString("base64url");
1024
+ socket.send(encodeRelayFrame({ t: "challenge", nonce }));
1025
+ let hostId;
1026
+ const deadline = setTimeout(() => socket.close(1008, "register timeout"), registerTimeoutMs);
1027
+ socket.on("message", (data, isBinary) => {
1028
+ if (isBinary) {
1029
+ socket.close(1008, "unexpected frame");
1030
+ return;
1031
+ }
1032
+ if (hostId !== undefined) {
1033
+ let answer;
1034
+ try {
1035
+ answer = decodeHostToRelay(data.toString());
1036
+ } catch {
1037
+ socket.close(1008, "malformed frame");
1038
+ return;
1039
+ }
1040
+ if (answer.t === "push") {
1041
+ handlePush(socket, hostId, answer);
1042
+ return;
1043
+ }
1044
+ if (answer.t === "pairing" || answer.t === "pairing-refused") {
1045
+ const request = pairingRequests.get(answer.requestId);
1046
+ if (request === undefined || request.control !== socket)
1047
+ return;
1048
+ request.settle(answer.t === "pairing" ? { status: "ok", secret: answer.secret, expiresAt: answer.expiresAt } : { status: "refused", reason: answer.reason });
1049
+ return;
1050
+ }
1051
+ socket.close(1008, "unexpected frame");
1052
+ return;
1053
+ }
1054
+ let frame;
1055
+ try {
1056
+ const decoded = decodeHostToRelay(data.toString());
1057
+ if (decoded.t !== "register")
1058
+ throw new Error("expected register");
1059
+ frame = decoded;
1060
+ } catch {
1061
+ socket.close(1008, "malformed frame");
1062
+ return;
1063
+ }
1064
+ let noisePublicKey;
1065
+ if (frame.signatureV2 !== undefined) {
1066
+ const bound = frame.noisePublicKey !== undefined && verifyRelayChallengeV2(frame.signatureV2, {
1067
+ relayOrigin: publicOrigin,
1068
+ hostId: frame.hostId,
1069
+ nonce,
1070
+ noisePublicKey: frame.noisePublicKey
1071
+ });
1072
+ if (!bound) {
1073
+ socket.close(RELAY_CLOSE.badSignature);
1074
+ return;
1075
+ }
1076
+ noisePublicKey = frame.noisePublicKey;
1077
+ } else if (!verifyRelayChallenge(frame.signature, { relayOrigin: publicOrigin, hostId: frame.hostId, nonce })) {
1078
+ socket.close(RELAY_CLOSE.badSignature);
1079
+ return;
1080
+ }
1081
+ if (frame.op !== undefined) {
1082
+ runOp(socket, frame, noisePublicKey);
1083
+ return;
1084
+ }
1085
+ if (accounts?.hasHost(frame.hostId) !== true) {
1086
+ socket.close(RELAY_CLOSE.hostNotAllowed);
1087
+ return;
1088
+ }
1089
+ hostId = frame.hostId;
1090
+ clearTimeout(deadline);
1091
+ const previous = controls.get(hostId);
1092
+ if (previous !== undefined)
1093
+ previous.socket.close(RELAY_CLOSE.replaced);
1094
+ controls.set(hostId, {
1095
+ socket,
1096
+ ...noisePublicKey === undefined ? {} : { noisePublicKey },
1097
+ ...frame.features === undefined ? {} : { features: frame.features }
1098
+ });
1099
+ accounts?.markHostSeen(hostId);
1100
+ socket.send(encodeRelayFrame({ t: "registered", push: pushEnvironments }));
1101
+ });
1102
+ socket.once("close", () => {
1103
+ clearTimeout(deadline);
1104
+ if (hostId !== undefined && controls.get(hostId)?.socket === socket)
1105
+ controls.delete(hostId);
1106
+ for (const [connId, entry] of pending) {
1107
+ if (entry.control === socket) {
1108
+ pending.delete(connId);
1109
+ clearTimeout(entry.timer);
1110
+ entry.socket.close(RELAY_CLOSE.hostOffline);
1111
+ }
1112
+ }
1113
+ for (const request of pairingRequests.values()) {
1114
+ if (request.control === socket)
1115
+ request.settle({ status: "gone" });
1116
+ }
1117
+ });
1118
+ }
1119
+ function onDevice(socket, hostId) {
1120
+ track(socket);
1121
+ const control = controls.get(hostId)?.socket;
1122
+ if (control === undefined) {
1123
+ socket.close(RELAY_CLOSE.hostOffline);
1124
+ return;
1125
+ }
1126
+ const connId = randomBytes2(16).toString("base64url");
1127
+ const entry = {
1128
+ control,
1129
+ socket,
1130
+ buffered: [],
1131
+ bufferedBytes: 0,
1132
+ timer: setTimeout(() => {
1133
+ if (pending.delete(connId))
1134
+ socket.close(RELAY_CLOSE.acceptTimeout);
1135
+ }, acceptTimeoutMs),
1136
+ onMessage(data, isBinary) {
1137
+ entry.buffered.push({ data, isBinary });
1138
+ entry.bufferedBytes += rawLength(data);
1139
+ if (entry.bufferedBytes > MAX_BUFFERED && pending.delete(connId)) {
1140
+ clearTimeout(entry.timer);
1141
+ socket.close(1009);
1142
+ }
1143
+ },
1144
+ onClose() {
1145
+ if (pending.delete(connId))
1146
+ clearTimeout(entry.timer);
1147
+ }
1148
+ };
1149
+ pending.set(connId, entry);
1150
+ socket.on("message", entry.onMessage);
1151
+ socket.once("close", entry.onClose);
1152
+ control.send(encodeRelayFrame({ t: "incoming", connId }));
1153
+ }
1154
+ function onAccept(socket, connId) {
1155
+ track(socket);
1156
+ const entry = pending.get(connId);
1157
+ if (entry === undefined) {
1158
+ socket.close(RELAY_CLOSE.unknownConnection);
1159
+ return;
1160
+ }
1161
+ pending.delete(connId);
1162
+ clearTimeout(entry.timer);
1163
+ const device = entry.socket;
1164
+ device.removeListener("message", entry.onMessage);
1165
+ device.removeListener("close", entry.onClose);
1166
+ splice(socket, device);
1167
+ for (const { data, isBinary } of entry.buffered)
1168
+ socket.send(data, { binary: isBinary });
1169
+ }
1170
+ const httpServer = createServer((request, response) => {
1171
+ let pathname;
1172
+ try {
1173
+ pathname = new URL(request.url ?? "/", "http://relay.invalid").pathname;
1174
+ } catch {
1175
+ pathname = "";
1176
+ }
1177
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
1178
+ if (accounts === undefined) {
1179
+ response.writeHead(404, { "content-type": "application/json" });
1180
+ response.end(JSON.stringify({ error: "accounts_disabled" }));
1181
+ return;
1182
+ }
1183
+ handleApiRequest(request, response, { accounts, publicOrigin, trustProxy, pairing }).catch(() => {
1184
+ if (!response.headersSent)
1185
+ response.writeHead(500);
1186
+ response.end();
1187
+ });
1188
+ return;
1189
+ }
1190
+ if (options.webRoot !== undefined) {
1191
+ serveWebClient(request, response, options.webRoot).catch(() => {
1192
+ if (!response.headersSent)
1193
+ response.writeHead(500);
1194
+ response.end();
1195
+ });
1196
+ return;
1197
+ }
1198
+ response.writeHead(404);
1199
+ response.end();
1200
+ });
1201
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD });
1202
+ httpServer.on("upgrade", (request, socket, head) => {
1203
+ let pathname;
1204
+ try {
1205
+ pathname = new URL(request.url ?? "/", "http://relay.invalid").pathname;
1206
+ } catch {
1207
+ pathname = "";
1208
+ }
1209
+ const upgrade = (handle) => {
1210
+ wss.handleUpgrade(request, socket, head, handle);
1211
+ };
1212
+ const device = DEVICE_PATH.exec(pathname);
1213
+ const accept = ACCEPT_PATH.exec(pathname);
1214
+ if (pathname === HOST_PATH) {
1215
+ if (accounts !== undefined && !secureTransport(request, trustProxy)) {
1216
+ socket.write(`HTTP/1.1 403 Forbidden\r
1217
+ \r
1218
+ `);
1219
+ socket.destroy();
1220
+ } else {
1221
+ upgrade(onControl);
1222
+ }
1223
+ } else if (device !== null)
1224
+ upgrade((ws) => onDevice(ws, device[1]));
1225
+ else if (accept !== null)
1226
+ upgrade((ws) => onAccept(ws, accept[1]));
1227
+ else {
1228
+ socket.write(`HTTP/1.1 404 Not Found\r
1229
+ \r
1230
+ `);
1231
+ socket.destroy();
1232
+ }
1233
+ });
1234
+ await new Promise((resolveListen, reject) => {
1235
+ httpServer.once("listening", resolveListen);
1236
+ httpServer.once("error", reject);
1237
+ httpServer.listen(options.port, listenHost);
1238
+ });
1239
+ const { port } = httpServer.address();
1240
+ const pingTimer = setInterval(() => {
1241
+ for (const socket of sockets) {
1242
+ if (alive.get(socket) === true) {
1243
+ alive.set(socket, false);
1244
+ socket.ping();
1245
+ } else {
1246
+ socket.terminate();
1247
+ }
1248
+ }
1249
+ }, pingIntervalMs);
1250
+ return {
1251
+ port,
1252
+ url: `ws://${listenHost.includes(":") ? `[${listenHost}]` : listenHost}:${port}`,
1253
+ controlInfo(hostId) {
1254
+ const entry = controls.get(hostId);
1255
+ if (entry === undefined)
1256
+ return;
1257
+ return {
1258
+ ...entry.noisePublicKey === undefined ? {} : { noisePublicKey: entry.noisePublicKey },
1259
+ ...entry.features === undefined ? {} : { features: entry.features }
1260
+ };
1261
+ },
1262
+ pairing,
1263
+ async close() {
1264
+ clearInterval(pingTimer);
1265
+ for (const entry of pending.values())
1266
+ clearTimeout(entry.timer);
1267
+ pending.clear();
1268
+ for (const socket of sockets)
1269
+ socket.terminate();
1270
+ const closing = once(wss, "close");
1271
+ wss.close();
1272
+ await closing;
1273
+ await new Promise((resolve) => httpServer.close(() => resolve()));
1274
+ await accounts?.close();
1275
+ await apns?.close();
1276
+ }
1277
+ };
1278
+ }
1279
+
1280
+ // apps/server/src/main.ts
1281
+ function resolveDataDir(flag) {
1282
+ return resolve(flag ?? process.env.PINOMAD_SERVER_DATA_DIR ?? join2(homedir(), ".pinomad-server"));
1283
+ }
1284
+ async function readStdin(limit) {
1285
+ const chunks = [];
1286
+ let size = 0;
1287
+ for await (const chunk of process.stdin) {
1288
+ size += chunk.length;
1289
+ if (size > limit)
1290
+ throw new Error("stdin exceeds the password size limit");
1291
+ chunks.push(chunk);
1292
+ }
1293
+ return Buffer.concat(chunks).toString("utf8");
1294
+ }
1295
+ async function passwdMain(argv) {
1296
+ const { values } = parseArgs({
1297
+ args: [...argv],
1298
+ options: {
1299
+ "data-dir": { type: "string" },
1300
+ user: { type: "string" }
1301
+ },
1302
+ strict: true
1303
+ });
1304
+ if (process.stdin.isTTY) {
1305
+ console.error("pipe the new password on stdin, e.g. `printenv NEW_PINOMAD_SERVER_PASSWORD | " + commandHint("server") + " passwd`");
1306
+ process.exit(1);
1307
+ }
1308
+ const dataDir = resolveDataDir(values["data-dir"]);
1309
+ const password = (await readStdin(16 * 1024)).replace(/[\r\n]+$/, "");
1310
+ await changePassword({ dataDir, user: values.user, password });
1311
+ console.log(JSON.stringify({ event: "passwd", dataDir }));
1312
+ }
1313
+ async function main(argv) {
1314
+ if (argv[0] === "passwd") {
1315
+ await passwdMain(argv.slice(1)).catch((error) => {
1316
+ console.error(error instanceof Error ? error.message : String(error));
1317
+ process.exit(1);
1318
+ });
1319
+ return;
1320
+ }
1321
+ const { values } = parseArgs({
1322
+ args: [...argv],
1323
+ options: {
1324
+ port: { type: "string", default: "7430" },
1325
+ listen: { type: "string", default: "127.0.0.1" },
1326
+ "public-origin": { type: "string" },
1327
+ "data-dir": { type: "string" },
1328
+ "trust-proxy": { type: "boolean", default: false }
1329
+ }
1330
+ });
1331
+ if (values["public-origin"] === undefined) {
1332
+ console.error(`usage: ${commandHint("server")} --public-origin ORIGIN [--data-dir DIR] [--trust-proxy] [--port 7430] [--listen 127.0.0.1]
1333
+ ` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user NAME] (server stopped; new password on stdin)
1334
+ ` + " env: PINOMAD_SERVER_USER, PINOMAD_SERVER_PASSWORD, PINOMAD_SERVER_DATA_DIR");
1335
+ process.exit(1);
1336
+ }
1337
+ const accounts = await openAccounts({
1338
+ dataDir: resolveDataDir(values["data-dir"]),
1339
+ bootstrapUser: process.env.PINOMAD_SERVER_USER,
1340
+ bootstrapPassword: process.env.PINOMAD_SERVER_PASSWORD
1341
+ }).catch((error) => {
1342
+ console.error(error instanceof Error ? error.message : String(error));
1343
+ process.exit(1);
1344
+ });
1345
+ const apnsConfig = loadApnsConfig();
1346
+ const apns = apnsConfig === undefined ? undefined : openApns(apnsConfig);
1347
+ const relay = await startRelay({
1348
+ port: Number(values.port),
1349
+ listenHost: values.listen,
1350
+ publicOrigin: values["public-origin"],
1351
+ webRoot,
1352
+ accounts,
1353
+ trustProxy: values["trust-proxy"],
1354
+ ...apns === undefined ? {} : { push: apns }
1355
+ });
1356
+ console.log(JSON.stringify({
1357
+ event: "ready",
1358
+ url: relay.url,
1359
+ publicOrigin: values["public-origin"],
1360
+ push: apns?.environments ?? []
1361
+ }));
1362
+ let stopping = false;
1363
+ const stop = () => {
1364
+ if (stopping)
1365
+ return;
1366
+ stopping = true;
1367
+ relay.close().finally(() => process.exit(0));
1368
+ };
1369
+ process.on("SIGINT", stop);
1370
+ process.on("SIGTERM", stop);
1371
+ }
1372
+ if (false)
1373
+ ;
1374
+ export {
1375
+ main
1376
+ };