pinomad 0.4.0 → 0.6.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 +4 -2
- package/dist/{chunk-837gh8nr.js → chunk-0xh6cy9v.js} +69 -17
- package/dist/{chunk-n73t5xzw.js → chunk-367n9je8.js} +2 -2
- package/dist/{chunk-xhwkx9bd.js → chunk-3dnnaz4j.js} +14 -15
- package/dist/{chunk-z5pmcscx.js → chunk-4btj08we.js} +676 -377
- package/dist/{chunk-7gmkgyaz.js → chunk-7qb9tq54.js} +324 -4
- package/dist/{chunk-d4y99pfy.js → chunk-7s1f4z4g.js} +4 -3
- package/dist/chunk-b1h374jr.js +159 -0
- package/dist/{chunk-fgx2sh7d.js → chunk-c8nxym2z.js} +1 -1
- package/dist/{chunk-akgxwg8f.js → chunk-d5ypmmr7.js} +29 -12
- package/dist/{chunk-374zqx5h.js → chunk-d93kvxvc.js} +18 -2
- package/dist/chunk-ggb1taqy.js +16 -0
- package/dist/chunk-jn7y815x.js +79 -0
- package/dist/{chunk-b3f0ced1.js → chunk-k2s1hfq5.js} +39 -16
- package/dist/chunk-kzbzacz9.js +12 -0
- package/dist/{chunk-myjp910s.js → chunk-n4tjhcb7.js} +25 -21
- package/dist/{chunk-cjfmfyjb.js → chunk-ngf1zatm.js} +7 -6
- package/dist/{chunk-vw5yb5pd.js → chunk-wqvp1fwh.js} +304 -38
- package/dist/{chunk-5785kwej.js → chunk-ywmm63ks.js} +9 -7
- package/dist/chunk-znhn8af7.js +33 -0
- package/dist/pinomad.js +40 -16
- package/package.json +1 -1
- package/web/apple-touch-icon.png +0 -0
- package/web/assets/index-C_aU_ZiV.js +383 -0
- package/web/assets/index-DaPcXZPM.css +1 -0
- package/web/favicon.svg +1 -0
- package/web/index.html +4 -2
- package/dist/chunk-17p6bfvy.js +0 -63
- package/dist/chunk-302y6by9.js +0 -50
- package/dist/chunk-knaw1a9g.js +0 -182
- package/dist/chunk-prhvksh3.js +0 -14
- package/dist/chunk-xrvmnymc.js +0 -33
- package/web/assets/index-8mNNPXPT.js +0 -383
- package/web/assets/index-BFJJgGGo.css +0 -1
|
@@ -2,35 +2,36 @@
|
|
|
2
2
|
import {
|
|
3
3
|
serveWebClient
|
|
4
4
|
} from "./chunk-r6w5aqkw.js";
|
|
5
|
-
import {
|
|
6
|
-
relayWsBase
|
|
7
|
-
} from "./chunk-knaw1a9g.js";
|
|
8
|
-
import {
|
|
9
|
-
commandHint,
|
|
10
|
-
webRoot
|
|
11
|
-
} from "./chunk-fgx2sh7d.js";
|
|
12
5
|
import {
|
|
13
6
|
RELAY_CLOSE,
|
|
14
7
|
decodeHostToRelay,
|
|
15
8
|
encodeRelayFrame,
|
|
9
|
+
relayWsBase,
|
|
16
10
|
verifyRelayChallenge,
|
|
17
11
|
verifyRelayChallengeV2
|
|
18
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-7qb9tq54.js";
|
|
13
|
+
import {
|
|
14
|
+
commandHint,
|
|
15
|
+
webRoot
|
|
16
|
+
} from "./chunk-c8nxym2z.js";
|
|
17
|
+
import {
|
|
18
|
+
normalizeHostName
|
|
19
|
+
} from "./chunk-znhn8af7.js";
|
|
19
20
|
import"./chunk-df32avw6.js";
|
|
20
21
|
|
|
21
|
-
// apps/
|
|
22
|
+
// apps/server/src/main.ts
|
|
22
23
|
import { homedir } from "node:os";
|
|
23
24
|
import { join as join2, resolve } from "node:path";
|
|
24
25
|
import { parseArgs } from "node:util";
|
|
25
26
|
|
|
26
|
-
// apps/
|
|
27
|
+
// apps/server/src/accounts.ts
|
|
27
28
|
import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
28
29
|
import { mkdir } from "node:fs/promises";
|
|
29
30
|
import { join } from "node:path";
|
|
30
31
|
import { DatabaseSync } from "node:sqlite";
|
|
31
32
|
import { promisify } from "node:util";
|
|
32
33
|
|
|
33
|
-
// apps/
|
|
34
|
+
// apps/server/src/lock.ts
|
|
34
35
|
import lockfile from "proper-lockfile";
|
|
35
36
|
async function lockDataDir(dir, options) {
|
|
36
37
|
const stale = Math.max(2000, options?.staleMs ?? 1e4);
|
|
@@ -44,6 +45,8 @@ async function lockDataDir(dir, options) {
|
|
|
44
45
|
throw error;
|
|
45
46
|
}
|
|
46
47
|
}).catch((error) => {
|
|
48
|
+
if (error.code !== "ELOCKED")
|
|
49
|
+
throw error;
|
|
47
50
|
throw new Error(`Data directory is already in use by a running process: ${dir}`, { cause: error });
|
|
48
51
|
});
|
|
49
52
|
return async () => {
|
|
@@ -52,7 +55,7 @@ async function lockDataDir(dir, options) {
|
|
|
52
55
|
};
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
// apps/
|
|
58
|
+
// apps/server/src/accounts.ts
|
|
56
59
|
var DB_FILE = "server.sqlite";
|
|
57
60
|
var SCHEMA_VERSION = 1;
|
|
58
61
|
var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -325,7 +328,7 @@ async function openAccounts(options) {
|
|
|
325
328
|
db.exec("ROLLBACK");
|
|
326
329
|
return { status: "refused", reason: failure };
|
|
327
330
|
}
|
|
328
|
-
const hostName = name === undefined
|
|
331
|
+
const hostName = name === undefined ? "Unknown host" : normalizeHostName(name);
|
|
329
332
|
db.prepare(`INSERT INTO hosts (host_id, user_id, noise_public_key, name, joined_at, last_seen_at)
|
|
330
333
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
331
334
|
ON CONFLICT(host_id) DO UPDATE SET
|
|
@@ -358,6 +361,9 @@ async function openAccounts(options) {
|
|
|
358
361
|
markHostSeen(hostId) {
|
|
359
362
|
db.prepare("UPDATE hosts SET last_seen_at = ? WHERE host_id = ?").run(now(), hostId);
|
|
360
363
|
},
|
|
364
|
+
renameHost(hostId, name) {
|
|
365
|
+
db.prepare("UPDATE hosts SET name = ? WHERE host_id = ?").run(normalizeHostName(name), hostId);
|
|
366
|
+
},
|
|
361
367
|
async close() {
|
|
362
368
|
db.close();
|
|
363
369
|
await release();
|
|
@@ -394,13 +400,220 @@ async function changePassword(options) {
|
|
|
394
400
|
}
|
|
395
401
|
}
|
|
396
402
|
|
|
397
|
-
// apps/
|
|
403
|
+
// apps/server/src/apns.ts
|
|
404
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
405
|
+
import { connect } from "node:http2";
|
|
406
|
+
var AUTHORITIES = {
|
|
407
|
+
production: "https://api.push.apple.com",
|
|
408
|
+
development: "https://api.sandbox.push.apple.com"
|
|
409
|
+
};
|
|
410
|
+
var PAYLOAD_MAX_BYTES = 4096;
|
|
411
|
+
var EXPIRATION_SECONDS = 3600;
|
|
412
|
+
var TOKEN_REFRESH_MS = 45 * 60 * 1000;
|
|
413
|
+
var REQUEST_TIMEOUT_MS = 8000;
|
|
414
|
+
var SERVER_ERROR_RETRY_MS = 15 * 60 * 1000;
|
|
415
|
+
var base64url = (text) => Buffer.from(text, "utf8").toString("base64url");
|
|
416
|
+
function loadApnsConfig(env = process.env) {
|
|
417
|
+
const teamId = env.PINOMAD_SERVER_APNS_TEAM_ID?.trim();
|
|
418
|
+
const topic = env.PINOMAD_SERVER_APNS_TOPIC?.trim();
|
|
419
|
+
if (teamId === undefined || teamId === "" || topic === undefined || topic === "")
|
|
420
|
+
return;
|
|
421
|
+
const loadEnv = (name) => {
|
|
422
|
+
const keyId = env[`PINOMAD_SERVER_APNS_${name}_KEY_ID`]?.trim();
|
|
423
|
+
const keyPem = env[`PINOMAD_SERVER_APNS_${name}_KEY`]?.replace(/\\n/g, `
|
|
424
|
+
`).trim();
|
|
425
|
+
if (keyId === undefined && keyPem === undefined)
|
|
426
|
+
return;
|
|
427
|
+
if (keyId === undefined || keyId === "" || keyPem === undefined || keyPem === "") {
|
|
428
|
+
console.error(`apns: PINOMAD_SERVER_APNS_${name}_KEY and _KEY_ID must both be set — environment disabled`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
return { keyId, keyPem };
|
|
432
|
+
};
|
|
433
|
+
const production = loadEnv("PRODUCTION");
|
|
434
|
+
const development = loadEnv("DEVELOPMENT");
|
|
435
|
+
return {
|
|
436
|
+
teamId,
|
|
437
|
+
topic,
|
|
438
|
+
environments: {
|
|
439
|
+
...production === undefined ? {} : { production },
|
|
440
|
+
...development === undefined ? {} : { development }
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function providerToken(key, keyId, teamId, iatSeconds) {
|
|
445
|
+
const signingInput = `${base64url(JSON.stringify({ alg: "ES256", kid: keyId }))}.${base64url(JSON.stringify({ iss: teamId, iat: iatSeconds }))}`;
|
|
446
|
+
const signature = sign("sha256", Buffer.from(signingInput, "utf8"), { key, dsaEncoding: "ieee-p1363" });
|
|
447
|
+
return `${signingInput}.${signature.toString("base64url")}`;
|
|
448
|
+
}
|
|
449
|
+
function openApns(config) {
|
|
450
|
+
const states = new Map;
|
|
451
|
+
for (const environment of ["production", "development"]) {
|
|
452
|
+
const env = config.environments[environment];
|
|
453
|
+
if (env === undefined)
|
|
454
|
+
continue;
|
|
455
|
+
try {
|
|
456
|
+
states.set(environment, {
|
|
457
|
+
key: createPrivateKey(env.keyPem),
|
|
458
|
+
keyId: env.keyId,
|
|
459
|
+
authority: env.authority ?? AUTHORITIES[environment]
|
|
460
|
+
});
|
|
461
|
+
} catch {
|
|
462
|
+
console.error(`apns: the ${environment} key is not a valid EC private key — environment disabled`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const environments = [...states.keys()];
|
|
466
|
+
const bearerFor = (state) => {
|
|
467
|
+
const now = Date.now();
|
|
468
|
+
if (state.token !== undefined && now - state.token.issuedAt < TOKEN_REFRESH_MS)
|
|
469
|
+
return state.token.jwt;
|
|
470
|
+
const jwt = providerToken(state.key, state.keyId, config.teamId, Math.floor(now / 1000));
|
|
471
|
+
state.token = { jwt, issuedAt: now };
|
|
472
|
+
return jwt;
|
|
473
|
+
};
|
|
474
|
+
const sessionFor = (environment, state) => {
|
|
475
|
+
const existing = state.session;
|
|
476
|
+
if (existing !== undefined && !existing.destroyed && !existing.closed)
|
|
477
|
+
return existing;
|
|
478
|
+
const session = connect(state.authority);
|
|
479
|
+
const drop = () => {
|
|
480
|
+
if (state.session === session)
|
|
481
|
+
state.session = undefined;
|
|
482
|
+
if (!session.destroyed)
|
|
483
|
+
session.destroy();
|
|
484
|
+
};
|
|
485
|
+
session.once("goaway", drop);
|
|
486
|
+
session.once("error", drop);
|
|
487
|
+
session.once("close", () => {
|
|
488
|
+
if (state.session === session)
|
|
489
|
+
state.session = undefined;
|
|
490
|
+
});
|
|
491
|
+
state.session = session;
|
|
492
|
+
return session;
|
|
493
|
+
};
|
|
494
|
+
const payloadFor = (notification) => JSON.stringify({
|
|
495
|
+
aps: {
|
|
496
|
+
alert: { title: notification.title, body: notification.body },
|
|
497
|
+
sound: "default",
|
|
498
|
+
"thread-id": notification.collapseId
|
|
499
|
+
},
|
|
500
|
+
hostKey: notification.hostKey,
|
|
501
|
+
conversationId: notification.conversationId
|
|
502
|
+
});
|
|
503
|
+
const classify = (environment, status, headers, body, state) => {
|
|
504
|
+
const reason = typeof body?.reason === "string" ? body.reason : `HTTP ${status}`;
|
|
505
|
+
const timestamp = body?.timestamp;
|
|
506
|
+
if (status === 200)
|
|
507
|
+
return { result: "accepted", reason: "ok" };
|
|
508
|
+
if (status === 410) {
|
|
509
|
+
return {
|
|
510
|
+
result: "unregistered",
|
|
511
|
+
reason,
|
|
512
|
+
unregisteredAt: typeof timestamp === "number" ? timestamp : Date.now()
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
if (status === 403 && reason === "ExpiredProviderToken") {
|
|
516
|
+
state.token = undefined;
|
|
517
|
+
return { result: "retry", reason };
|
|
518
|
+
}
|
|
519
|
+
if (reason === "InvalidProviderToken" || reason === "TooManyProviderTokenUpdates") {
|
|
520
|
+
console.error(`apns: provider token problem for ${environment}: ${reason}`);
|
|
521
|
+
return { result: "rejected", reason };
|
|
522
|
+
}
|
|
523
|
+
if (status === 429) {
|
|
524
|
+
const retryAfter = Number(headers["retry-after"]);
|
|
525
|
+
return {
|
|
526
|
+
result: "retry",
|
|
527
|
+
reason,
|
|
528
|
+
...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterMs: retryAfter * 1000 } : {}
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
if (status >= 500)
|
|
532
|
+
return { result: "retry", reason, retryAfterMs: SERVER_ERROR_RETRY_MS };
|
|
533
|
+
return { result: "rejected", reason };
|
|
534
|
+
};
|
|
535
|
+
return {
|
|
536
|
+
environments,
|
|
537
|
+
async send(request) {
|
|
538
|
+
const state = states.get(request.environment);
|
|
539
|
+
if (state === undefined)
|
|
540
|
+
return { result: "unavailable", reason: "environment not configured" };
|
|
541
|
+
const body = payloadFor(request.notification);
|
|
542
|
+
if (Buffer.byteLength(body, "utf8") > PAYLOAD_MAX_BYTES) {
|
|
543
|
+
return { result: "rejected", reason: "PayloadTooLarge" };
|
|
544
|
+
}
|
|
545
|
+
return await new Promise((resolve) => {
|
|
546
|
+
let session;
|
|
547
|
+
try {
|
|
548
|
+
session = sessionFor(request.environment, state);
|
|
549
|
+
} catch {
|
|
550
|
+
resolve({ result: "retry", reason: "session unavailable" });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
let stream;
|
|
554
|
+
try {
|
|
555
|
+
stream = session.request({
|
|
556
|
+
":method": "POST",
|
|
557
|
+
":path": `/3/device/${request.token}`,
|
|
558
|
+
authorization: `bearer ${bearerFor(state)}`,
|
|
559
|
+
"apns-push-type": "alert",
|
|
560
|
+
"apns-topic": config.topic,
|
|
561
|
+
"apns-expiration": String(Math.floor(Date.now() / 1000) + EXPIRATION_SECONDS),
|
|
562
|
+
"apns-collapse-id": request.notification.collapseId,
|
|
563
|
+
"content-type": "application/json"
|
|
564
|
+
});
|
|
565
|
+
} catch {
|
|
566
|
+
resolve({ result: "retry", reason: "session unavailable" });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
let status = 0;
|
|
570
|
+
let responseHeaders = {};
|
|
571
|
+
const chunks = [];
|
|
572
|
+
let settled = false;
|
|
573
|
+
const finish = (verdict) => {
|
|
574
|
+
if (settled)
|
|
575
|
+
return;
|
|
576
|
+
settled = true;
|
|
577
|
+
clearTimeout(timer);
|
|
578
|
+
stream.destroy();
|
|
579
|
+
resolve(verdict);
|
|
580
|
+
};
|
|
581
|
+
const timer = setTimeout(() => finish({ result: "retry", reason: "apns timeout" }), REQUEST_TIMEOUT_MS);
|
|
582
|
+
stream.on("response", (headers) => {
|
|
583
|
+
status = Number(headers[":status"] ?? 0);
|
|
584
|
+
responseHeaders = headers;
|
|
585
|
+
});
|
|
586
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
587
|
+
stream.on("end", () => {
|
|
588
|
+
let body2;
|
|
589
|
+
try {
|
|
590
|
+
body2 = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
591
|
+
} catch {
|
|
592
|
+
body2 = undefined;
|
|
593
|
+
}
|
|
594
|
+
finish(classify(request.environment, status, responseHeaders, body2, state));
|
|
595
|
+
});
|
|
596
|
+
stream.on("error", () => finish({ result: "retry", reason: "connection error" }));
|
|
597
|
+
stream.on("aborted", () => finish({ result: "retry", reason: "connection aborted" }));
|
|
598
|
+
stream.end(body);
|
|
599
|
+
});
|
|
600
|
+
},
|
|
601
|
+
close() {
|
|
602
|
+
for (const state of states.values())
|
|
603
|
+
state.session?.destroy();
|
|
604
|
+
states.clear();
|
|
605
|
+
return Promise.resolve();
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// apps/server/src/relay.ts
|
|
398
611
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
399
612
|
import { once } from "node:events";
|
|
400
613
|
import { createServer } from "node:http";
|
|
401
614
|
import { WebSocket, WebSocketServer } from "ws";
|
|
402
615
|
|
|
403
|
-
// apps/
|
|
616
|
+
// apps/server/src/api.ts
|
|
404
617
|
var SESSION_COOKIE = "__Host-pinomad-session";
|
|
405
618
|
var MAX_BODY = 8 * 1024;
|
|
406
619
|
var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
@@ -635,7 +848,7 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
635
848
|
deny(404, "not_found");
|
|
636
849
|
}
|
|
637
850
|
|
|
638
|
-
// apps/
|
|
851
|
+
// apps/server/src/relay.ts
|
|
639
852
|
var MAX_PAYLOAD = 128 * 1024;
|
|
640
853
|
var MAX_BUFFERED = 64 * 1024;
|
|
641
854
|
var HIGH_WATER = 1024 * 1024;
|
|
@@ -647,11 +860,12 @@ var isSendableCloseCode = (code) => code >= 1000 && code <= 1003 || code >= 1007
|
|
|
647
860
|
var rawLength = (data) => Array.isArray(data) ? data.reduce((sum, part) => sum + part.length, 0) : data.byteLength;
|
|
648
861
|
var MAX_PAIRING_PER_HOST = 4;
|
|
649
862
|
var MAX_PAIRING_PER_SESSION = 2;
|
|
863
|
+
var MAX_PUSH_PER_HOST = 4;
|
|
864
|
+
var MAX_PUSH_TOTAL = 32;
|
|
650
865
|
async function startRelay(options) {
|
|
651
866
|
const publicOrigin = options.publicOrigin.replace(/\/+$/, "");
|
|
652
867
|
if (publicOrigin === "")
|
|
653
868
|
throw new Error("publicOrigin is required");
|
|
654
|
-
const allowedHosts = new Set(options.allowedHosts);
|
|
655
869
|
const acceptTimeoutMs = options.acceptTimeoutMs ?? 1e4;
|
|
656
870
|
const registerTimeoutMs = options.registerTimeoutMs ?? 1e4;
|
|
657
871
|
const pingIntervalMs = options.pingIntervalMs ?? 30000;
|
|
@@ -659,6 +873,12 @@ async function startRelay(options) {
|
|
|
659
873
|
const accounts = options.accounts;
|
|
660
874
|
const trustProxy = options.trustProxy ?? false;
|
|
661
875
|
const pairingTimeoutMs = options.pairingTimeoutMs ?? 1e4;
|
|
876
|
+
const apns = options.push;
|
|
877
|
+
const pushEnvironments = apns?.environments ?? [];
|
|
878
|
+
const pushPerHost = options.pushLimits?.perHost ?? MAX_PUSH_PER_HOST;
|
|
879
|
+
const pushTotal = options.pushLimits?.total ?? MAX_PUSH_TOTAL;
|
|
880
|
+
let pushInflightTotal = 0;
|
|
881
|
+
const pushInflightHosts = new Map;
|
|
662
882
|
const controls = new Map;
|
|
663
883
|
const pending = new Map;
|
|
664
884
|
const pairingRequests = new Map;
|
|
@@ -732,6 +952,45 @@ async function startRelay(options) {
|
|
|
732
952
|
});
|
|
733
953
|
}
|
|
734
954
|
const pairing = { status: pairingStatus, request: requestPairing };
|
|
955
|
+
function handlePush(socket, hostId, frame) {
|
|
956
|
+
const startedAt = Date.now();
|
|
957
|
+
const finish = (verdict) => {
|
|
958
|
+
console.log(JSON.stringify({
|
|
959
|
+
event: "push",
|
|
960
|
+
hostId,
|
|
961
|
+
environment: frame.environment,
|
|
962
|
+
result: verdict.result,
|
|
963
|
+
reason: verdict.reason,
|
|
964
|
+
ms: Date.now() - startedAt
|
|
965
|
+
}));
|
|
966
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
967
|
+
socket.send(encodeRelayFrame({ t: "push-result", requestId: frame.requestId, ...verdict }));
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
if (accounts?.hasHost(hostId) !== true) {
|
|
971
|
+
finish({ result: "rejected", reason: "unauthorized" });
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (apns === undefined || !pushEnvironments.includes(frame.environment)) {
|
|
975
|
+
finish({ result: "unavailable", reason: "environment not configured" });
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const perHost = pushInflightHosts.get(hostId) ?? 0;
|
|
979
|
+
if (perHost >= pushPerHost || pushInflightTotal >= pushTotal) {
|
|
980
|
+
finish({ result: "retry", reason: "rate_limited" });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
pushInflightTotal += 1;
|
|
984
|
+
pushInflightHosts.set(hostId, perHost + 1);
|
|
985
|
+
apns.send({ token: frame.token, environment: frame.environment, notification: frame.notification }).then(finish, () => finish({ result: "retry", reason: "server error" })).finally(() => {
|
|
986
|
+
pushInflightTotal -= 1;
|
|
987
|
+
const left = (pushInflightHosts.get(hostId) ?? 1) - 1;
|
|
988
|
+
if (left <= 0)
|
|
989
|
+
pushInflightHosts.delete(hostId);
|
|
990
|
+
else
|
|
991
|
+
pushInflightHosts.set(hostId, left);
|
|
992
|
+
});
|
|
993
|
+
}
|
|
735
994
|
function runOp(socket, frame, noisePublicKey) {
|
|
736
995
|
const answer = (reply) => socket.send(encodeRelayFrame(reply), () => socket.close(1000));
|
|
737
996
|
if (accounts === undefined) {
|
|
@@ -763,8 +1022,7 @@ async function startRelay(options) {
|
|
|
763
1022
|
return;
|
|
764
1023
|
}
|
|
765
1024
|
answer({ t: "left" });
|
|
766
|
-
|
|
767
|
-
controls.get(frame.hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
|
|
1025
|
+
controls.get(frame.hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
|
|
768
1026
|
}
|
|
769
1027
|
function onControl(socket) {
|
|
770
1028
|
track(socket);
|
|
@@ -785,6 +1043,15 @@ async function startRelay(options) {
|
|
|
785
1043
|
socket.close(1008, "malformed frame");
|
|
786
1044
|
return;
|
|
787
1045
|
}
|
|
1046
|
+
if (answer.t === "push") {
|
|
1047
|
+
handlePush(socket, hostId, answer);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (answer.t === "name") {
|
|
1051
|
+
if (controls.get(hostId)?.socket === socket)
|
|
1052
|
+
accounts?.renameHost(hostId, answer.name);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
788
1055
|
if (answer.t === "pairing" || answer.t === "pairing-refused") {
|
|
789
1056
|
const request = pairingRequests.get(answer.requestId);
|
|
790
1057
|
if (request === undefined || request.control !== socket)
|
|
@@ -826,7 +1093,7 @@ async function startRelay(options) {
|
|
|
826
1093
|
runOp(socket, frame, noisePublicKey);
|
|
827
1094
|
return;
|
|
828
1095
|
}
|
|
829
|
-
if (
|
|
1096
|
+
if (accounts?.hasHost(frame.hostId) !== true) {
|
|
830
1097
|
socket.close(RELAY_CLOSE.hostNotAllowed);
|
|
831
1098
|
return;
|
|
832
1099
|
}
|
|
@@ -841,7 +1108,9 @@ async function startRelay(options) {
|
|
|
841
1108
|
...frame.features === undefined ? {} : { features: frame.features }
|
|
842
1109
|
});
|
|
843
1110
|
accounts?.markHostSeen(hostId);
|
|
844
|
-
|
|
1111
|
+
if (frame.name !== undefined)
|
|
1112
|
+
accounts?.renameHost(hostId, frame.name);
|
|
1113
|
+
socket.send(encodeRelayFrame({ t: "registered", push: pushEnvironments, features: ["name"] }));
|
|
845
1114
|
});
|
|
846
1115
|
socket.once("close", () => {
|
|
847
1116
|
clearTimeout(deadline);
|
|
@@ -1016,11 +1285,12 @@ async function startRelay(options) {
|
|
|
1016
1285
|
await closing;
|
|
1017
1286
|
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
1018
1287
|
await accounts?.close();
|
|
1288
|
+
await apns?.close();
|
|
1019
1289
|
}
|
|
1020
1290
|
};
|
|
1021
1291
|
}
|
|
1022
1292
|
|
|
1023
|
-
// apps/
|
|
1293
|
+
// apps/server/src/main.ts
|
|
1024
1294
|
function resolveDataDir(flag) {
|
|
1025
1295
|
return resolve(flag ?? process.env.PINOMAD_SERVER_DATA_DIR ?? join2(homedir(), ".pinomad-server"));
|
|
1026
1296
|
}
|
|
@@ -1045,7 +1315,7 @@ async function passwdMain(argv) {
|
|
|
1045
1315
|
strict: true
|
|
1046
1316
|
});
|
|
1047
1317
|
if (process.stdin.isTTY) {
|
|
1048
|
-
console.error("pipe the new password on stdin, e.g. `printenv NEW_PINOMAD_SERVER_PASSWORD | " + commandHint("
|
|
1318
|
+
console.error("pipe the new password on stdin, e.g. `printenv NEW_PINOMAD_SERVER_PASSWORD | " + commandHint("server") + " passwd`");
|
|
1049
1319
|
process.exit(1);
|
|
1050
1320
|
}
|
|
1051
1321
|
const dataDir = resolveDataDir(values["data-dir"]);
|
|
@@ -1067,44 +1337,40 @@ async function main(argv) {
|
|
|
1067
1337
|
port: { type: "string", default: "7430" },
|
|
1068
1338
|
listen: { type: "string", default: "127.0.0.1" },
|
|
1069
1339
|
"public-origin": { type: "string" },
|
|
1070
|
-
"allow-host": { type: "string", multiple: true },
|
|
1071
1340
|
"data-dir": { type: "string" },
|
|
1072
1341
|
"trust-proxy": { type: "boolean", default: false }
|
|
1073
1342
|
}
|
|
1074
1343
|
});
|
|
1075
|
-
const allowedHosts = values["allow-host"] ?? [];
|
|
1076
|
-
const bootstrapUser = process.env.PINOMAD_SERVER_USER;
|
|
1077
|
-
const bootstrapPassword = process.env.PINOMAD_SERVER_PASSWORD;
|
|
1078
|
-
const accountMode = allowedHosts.length === 0 || values["data-dir"] !== undefined || process.env.PINOMAD_SERVER_DATA_DIR !== undefined || bootstrapUser !== undefined || bootstrapPassword !== undefined;
|
|
1079
1344
|
if (values["public-origin"] === undefined) {
|
|
1080
|
-
console.error(`usage: ${commandHint("
|
|
1081
|
-
` + ` ${commandHint("
|
|
1345
|
+
console.error(`usage: ${commandHint("server")} --public-origin ORIGIN [--data-dir DIR] [--trust-proxy] [--port 7430] [--listen 127.0.0.1]
|
|
1346
|
+
` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user NAME] (server stopped; new password on stdin)
|
|
1082
1347
|
` + " env: PINOMAD_SERVER_USER, PINOMAD_SERVER_PASSWORD, PINOMAD_SERVER_DATA_DIR");
|
|
1083
1348
|
process.exit(1);
|
|
1084
1349
|
}
|
|
1085
|
-
const accounts =
|
|
1350
|
+
const accounts = await openAccounts({
|
|
1086
1351
|
dataDir: resolveDataDir(values["data-dir"]),
|
|
1087
|
-
bootstrapUser,
|
|
1088
|
-
bootstrapPassword
|
|
1352
|
+
bootstrapUser: process.env.PINOMAD_SERVER_USER,
|
|
1353
|
+
bootstrapPassword: process.env.PINOMAD_SERVER_PASSWORD
|
|
1089
1354
|
}).catch((error) => {
|
|
1090
1355
|
console.error(error instanceof Error ? error.message : String(error));
|
|
1091
1356
|
process.exit(1);
|
|
1092
|
-
})
|
|
1357
|
+
});
|
|
1358
|
+
const apnsConfig = loadApnsConfig();
|
|
1359
|
+
const apns = apnsConfig === undefined ? undefined : openApns(apnsConfig);
|
|
1093
1360
|
const relay = await startRelay({
|
|
1094
1361
|
port: Number(values.port),
|
|
1095
1362
|
listenHost: values.listen,
|
|
1096
1363
|
publicOrigin: values["public-origin"],
|
|
1097
|
-
allowedHosts,
|
|
1098
1364
|
webRoot,
|
|
1099
1365
|
accounts,
|
|
1100
|
-
trustProxy: values["trust-proxy"]
|
|
1366
|
+
trustProxy: values["trust-proxy"],
|
|
1367
|
+
...apns === undefined ? {} : { push: apns }
|
|
1101
1368
|
});
|
|
1102
1369
|
console.log(JSON.stringify({
|
|
1103
1370
|
event: "ready",
|
|
1104
1371
|
url: relay.url,
|
|
1105
1372
|
publicOrigin: values["public-origin"],
|
|
1106
|
-
|
|
1107
|
-
accounts: accounts !== undefined
|
|
1373
|
+
push: apns?.environments ?? []
|
|
1108
1374
|
}));
|
|
1109
1375
|
let stopping = false;
|
|
1110
1376
|
const stop = () => {
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
relayWsBase
|
|
3
|
-
} from "./chunk-knaw1a9g.js";
|
|
4
1
|
import {
|
|
5
2
|
RELAY_CLOSE,
|
|
6
3
|
decodeRelayToHost,
|
|
7
4
|
encodeRelayFrame,
|
|
8
5
|
relayHostId,
|
|
6
|
+
relayWsBase,
|
|
9
7
|
signRelayChallenge,
|
|
10
8
|
signRelayChallengeV2,
|
|
11
9
|
toBase64Url
|
|
12
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-7qb9tq54.js";
|
|
13
11
|
|
|
14
12
|
// apps/host/src/cli/server-ops.ts
|
|
15
13
|
import { WebSocket } from "ws";
|
|
@@ -20,7 +18,10 @@ async function probeAccounts(origin, fetchImpl = fallbackFetch) {
|
|
|
20
18
|
const timeout = new AbortController;
|
|
21
19
|
const timer = setTimeout(() => timeout.abort(), 5000);
|
|
22
20
|
try {
|
|
23
|
-
const response = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/me`, {
|
|
21
|
+
const response = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/me`, {
|
|
22
|
+
signal: timeout.signal,
|
|
23
|
+
redirect: "manual"
|
|
24
|
+
});
|
|
24
25
|
if (response.status === 401)
|
|
25
26
|
return "enabled";
|
|
26
27
|
if (response.status === 200) {
|
|
@@ -48,7 +49,8 @@ function isSecureServerOrigin(origin) {
|
|
|
48
49
|
return false;
|
|
49
50
|
}
|
|
50
51
|
const host = url.hostname;
|
|
51
|
-
const
|
|
52
|
+
const ipv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
53
|
+
const loopback = host === "localhost" || host.endsWith(".localhost") || ipv4Loopback || host === "::1" || host === "[::1]";
|
|
52
54
|
return url.protocol === "https:" || url.protocol === "http:" && loopback;
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -118,7 +120,7 @@ async function runServerOperation(options) {
|
|
|
118
120
|
finish(() => reject(new Error(`${origin} ignored the operation — the server is too old for accounts; upgrade it first`)));
|
|
119
121
|
return;
|
|
120
122
|
}
|
|
121
|
-
if (frame.t === "incoming" || frame.t === "pairing-request")
|
|
123
|
+
if (frame.t === "incoming" || frame.t === "pairing-request" || frame.t === "push-result")
|
|
122
124
|
return;
|
|
123
125
|
finish(() => resolve(frame));
|
|
124
126
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// packages/protocol/src/settings.ts
|
|
2
|
+
var HOST_SETTING_KEY_SET = {
|
|
3
|
+
defaultModel: true,
|
|
4
|
+
defaultThinkingLevel: true,
|
|
5
|
+
steeringMode: true,
|
|
6
|
+
followUpMode: true,
|
|
7
|
+
acceptAccountDevices: true,
|
|
8
|
+
hostName: true
|
|
9
|
+
};
|
|
10
|
+
var HOST_SETTING_KEYS = Object.keys(HOST_SETTING_KEY_SET);
|
|
11
|
+
var THINKING_LEVEL_KEYS = { off: true, minimal: true, low: true, medium: true, high: true, xhigh: true, max: true };
|
|
12
|
+
var THINKING_LEVELS = Object.keys(THINKING_LEVEL_KEYS);
|
|
13
|
+
var isThinkingLevel = (value) => typeof value === "string" && Object.prototype.hasOwnProperty.call(THINKING_LEVEL_KEYS, value);
|
|
14
|
+
var isQueueMode = (value) => value === "all" || value === "one-at-a-time";
|
|
15
|
+
var HOST_NAME_FALLBACK = "pinomad";
|
|
16
|
+
var HOST_NAME_MAX_UNITS = 64;
|
|
17
|
+
var isHostNameControl = (code) => code < 32 || code >= 127 && code <= 159;
|
|
18
|
+
function cleanHostName(name) {
|
|
19
|
+
let cleaned = name;
|
|
20
|
+
for (;; ) {
|
|
21
|
+
const local = cleaned.endsWith(".local") ? cleaned.slice(0, -".local".length) : cleaned;
|
|
22
|
+
const next = [...local.trim()].filter((c) => !isHostNameControl(c.codePointAt(0))).join("").slice(0, HOST_NAME_MAX_UNITS);
|
|
23
|
+
if (next === cleaned)
|
|
24
|
+
return next;
|
|
25
|
+
cleaned = next;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function normalizeHostName(name) {
|
|
29
|
+
const cleaned = cleanHostName(name);
|
|
30
|
+
return cleaned === "" ? HOST_NAME_FALLBACK : cleaned;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { HOST_SETTING_KEYS, THINKING_LEVELS, isThinkingLevel, isQueueMode, HOST_NAME_MAX_UNITS, cleanHostName, normalizeHostName };
|
package/dist/pinomad.js
CHANGED
|
@@ -1,39 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
isParseArgsError,
|
|
4
|
+
reportParseArgsError
|
|
5
|
+
} from "./chunk-kzbzacz9.js";
|
|
2
6
|
import {
|
|
3
7
|
packagedVersion
|
|
4
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-c8nxym2z.js";
|
|
5
9
|
import {
|
|
6
10
|
__require
|
|
7
11
|
} from "./chunk-df32avw6.js";
|
|
8
12
|
|
|
9
13
|
// apps/cli/src/pinomad.ts
|
|
10
14
|
var COMMANDS = {
|
|
11
|
-
host: () => import("./chunk-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
join: () => import("./chunk-
|
|
19
|
-
leave: () => import("./chunk-
|
|
15
|
+
host: () => import("./chunk-4btj08we.js"),
|
|
16
|
+
server: () => import("./chunk-wqvp1fwh.js"),
|
|
17
|
+
relay: () => import("./chunk-wqvp1fwh.js"),
|
|
18
|
+
service: () => import("./chunk-ggb1taqy.js"),
|
|
19
|
+
upgrade: () => import("./chunk-jn7y815x.js"),
|
|
20
|
+
link: () => import("./chunk-367n9je8.js"),
|
|
21
|
+
pair: () => import("./chunk-7s1f4z4g.js"),
|
|
22
|
+
join: () => import("./chunk-k2s1hfq5.js"),
|
|
23
|
+
leave: () => import("./chunk-3dnnaz4j.js")
|
|
20
24
|
};
|
|
21
|
-
var USAGE = `pinomad — PiNomad host,
|
|
25
|
+
var USAGE = `pinomad — PiNomad host, server, and utilities
|
|
22
26
|
|
|
23
27
|
usage: pinomad <command> [args…]
|
|
24
28
|
|
|
25
|
-
host [
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
host [--data-dir DIR] [--port 7420] [--project DIR]… run the host in the foreground
|
|
30
|
+
host --remote-port PORT [--public-url URL] remote access, LAN (ADR-0008)
|
|
31
|
+
host --relay URL remote access via relay (ADR-0015)
|
|
32
|
+
host --faux "answer" [--faux-tps 40] no real model, for tests
|
|
33
|
+
server --public-origin O [--data-dir D] [--trust-proxy] run the server
|
|
34
|
+
server passwd [--data-dir D] [--user U] change the account password (stdin)
|
|
35
|
+
relay … old name of server
|
|
28
36
|
service install|uninstall|status|restart|logs user-level host service
|
|
29
37
|
upgrade [--force] [address flags] upgrade the package and restart the service
|
|
30
38
|
link [--data-dir D|--url U …] print the browser link
|
|
31
39
|
pair [--data-dir D|--url U …] print a pairing QR
|
|
32
|
-
relay-id [--data-dir D] print this host's relay id (for --allow-host)
|
|
33
40
|
join <origin> <token> [--data-dir D] join this host to a server account
|
|
34
41
|
leave [--data-dir D] leave the server account
|
|
35
42
|
--version, -v print the version
|
|
36
43
|
`;
|
|
44
|
+
function usageFor(command) {
|
|
45
|
+
const rows = USAGE.split(`
|
|
46
|
+
`).filter((line) => line.startsWith(` ${command} `));
|
|
47
|
+
return `usage: pinomad ${command} [args…]
|
|
48
|
+
${rows.join(`
|
|
49
|
+
`)}`;
|
|
50
|
+
}
|
|
37
51
|
async function main(argv) {
|
|
38
52
|
const [command, ...rest] = argv;
|
|
39
53
|
if (command === "--version" || command === "-v") {
|
|
@@ -51,7 +65,17 @@ async function main(argv) {
|
|
|
51
65
|
process.exitCode = 1;
|
|
52
66
|
return;
|
|
53
67
|
}
|
|
54
|
-
|
|
68
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
69
|
+
console.log(usageFor(command));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
await (await load()).main(rest);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (!isParseArgsError(error))
|
|
76
|
+
throw error;
|
|
77
|
+
reportParseArgsError(`pinomad ${command}`, error);
|
|
78
|
+
}
|
|
55
79
|
}
|
|
56
80
|
if (__require.main == __require.module)
|
|
57
81
|
await main(process.argv.slice(2));
|