pinomad 0.4.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.
- package/README.md +4 -2
- package/dist/{chunk-fgx2sh7d.js → chunk-1d9sppj0.js} +1 -1
- package/dist/{chunk-5785kwej.js → chunk-26d04z02.js} +3 -5
- package/dist/{chunk-vw5yb5pd.js → chunk-4by4x5c4.js} +290 -37
- package/dist/{chunk-prhvksh3.js → chunk-5ng86fmy.js} +4 -4
- package/dist/{chunk-b3f0ced1.js → chunk-9d59jjmt.js} +11 -14
- package/dist/{chunk-akgxwg8f.js → chunk-aqj4wg33.js} +2 -2
- package/dist/{chunk-17p6bfvy.js → chunk-crv3dqzp.js} +4 -4
- package/dist/{chunk-z5pmcscx.js → chunk-d4wdt5qs.js} +594 -212
- package/dist/{chunk-837gh8nr.js → chunk-ebfaf3k2.js} +69 -17
- package/dist/{chunk-xhwkx9bd.js → chunk-f0v3sht0.js} +11 -14
- package/dist/{chunk-n73t5xzw.js → chunk-gqr7ahv4.js} +1 -1
- package/dist/{chunk-myjp910s.js → chunk-hsp12cjt.js} +10 -6
- package/dist/{chunk-7gmkgyaz.js → chunk-kmbh44dt.js} +286 -2
- package/dist/{chunk-374zqx5h.js → chunk-m4q9wafw.js} +14 -1
- package/dist/{chunk-d4y99pfy.js → chunk-zttf1hvh.js} +2 -2
- package/dist/pinomad.js +14 -14
- package/package.json +1 -1
- package/web/apple-touch-icon.png +0 -0
- package/web/assets/index-BkF0-im5.css +1 -0
- package/web/assets/{index-8mNNPXPT.js → index-D4lgm3Y4.js} +89 -89
- package/web/favicon.svg +1 -0
- package/web/index.html +4 -2
- package/dist/chunk-302y6by9.js +0 -50
- package/dist/chunk-knaw1a9g.js +0 -182
- package/dist/chunk-xrvmnymc.js +0 -33
- package/web/assets/index-BFJJgGGo.css +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PiNomad
|
|
2
2
|
|
|
3
|
-
English · [中文](README.zh-CN.md)
|
|
3
|
+
English · [中文](README.zh-CN.md) · [pinomad.si](https://pinomad.si)
|
|
4
4
|
|
|
5
5
|
A self-hosted coding agent built on [Pi Durable](https://www.npmjs.com/package/@earendil-works/pi-durable). The agent runs on your own machine; the Mac app, the web client, and the iPhone app all attach to the same runs.
|
|
6
6
|
|
|
@@ -87,8 +87,10 @@ These docs are written in Chinese.
|
|
|
87
87
|
| [Usage](docs/usage.md) | Projects, worktrees, tools, MCP configuration |
|
|
88
88
|
| [Remote access](docs/remote-access.md) | Pairing, tunnels, self-hosted relay (VPS or container platforms) |
|
|
89
89
|
| [Running as a service](docs/service.md) | `service` and `upgrade` commands, platform notes |
|
|
90
|
+
| [Website](docs/website.md) | The pinomad.si landing page and the hosted app.pinomad.si instance |
|
|
91
|
+
| [Architecture overview](docs/architecture.md) | How the parts and languages fit together, with send/receive diagrams |
|
|
90
92
|
| [Architecture decisions](docs/adr/README.md) | Why things are the way they are |
|
|
91
|
-
| [Roadmap](docs/roadmap.md) |
|
|
93
|
+
| [Roadmap](docs/roadmap.md) | Direction and order; items live in GitHub Issues |
|
|
92
94
|
| [Glossary](GLOSSARY.md) | Domain terms |
|
|
93
95
|
|
|
94
96
|
## Contributing
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// apps/host/src/distribution.ts
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
var packagedVersion = "0.
|
|
3
|
+
var packagedVersion = "0.5.0";
|
|
4
4
|
var webRoot = fileURLToPath(new URL(packagedVersion === undefined ? "../../web/dist" : "../web/", import.meta.url));
|
|
5
5
|
var hostEntry = packagedVersion === undefined ? { script: fileURLToPath(new URL("./main.ts", import.meta.url)), args: [] } : { script: fileURLToPath(new URL("./pinomad.js", import.meta.url)), args: ["host"] };
|
|
6
6
|
var commandHint = (name) => packagedVersion === undefined ? `bun run ${name}` : `pinomad ${name}`;
|
|
@@ -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-kmbh44dt.js";
|
|
13
11
|
|
|
14
12
|
// apps/host/src/cli/server-ops.ts
|
|
15
13
|
import { WebSocket } from "ws";
|
|
@@ -118,7 +116,7 @@ async function runServerOperation(options) {
|
|
|
118
116
|
finish(() => reject(new Error(`${origin} ignored the operation — the server is too old for accounts; upgrade it first`)));
|
|
119
117
|
return;
|
|
120
118
|
}
|
|
121
|
-
if (frame.t === "incoming" || frame.t === "pairing-request")
|
|
119
|
+
if (frame.t === "incoming" || frame.t === "pairing-request" || frame.t === "push-result")
|
|
122
120
|
return;
|
|
123
121
|
finish(() => resolve(frame));
|
|
124
122
|
});
|
|
@@ -2,35 +2,33 @@
|
|
|
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-kmbh44dt.js";
|
|
13
|
+
import {
|
|
14
|
+
commandHint,
|
|
15
|
+
webRoot
|
|
16
|
+
} from "./chunk-1d9sppj0.js";
|
|
19
17
|
import"./chunk-df32avw6.js";
|
|
20
18
|
|
|
21
|
-
// apps/
|
|
19
|
+
// apps/server/src/main.ts
|
|
22
20
|
import { homedir } from "node:os";
|
|
23
21
|
import { join as join2, resolve } from "node:path";
|
|
24
22
|
import { parseArgs } from "node:util";
|
|
25
23
|
|
|
26
|
-
// apps/
|
|
24
|
+
// apps/server/src/accounts.ts
|
|
27
25
|
import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
28
26
|
import { mkdir } from "node:fs/promises";
|
|
29
27
|
import { join } from "node:path";
|
|
30
28
|
import { DatabaseSync } from "node:sqlite";
|
|
31
29
|
import { promisify } from "node:util";
|
|
32
30
|
|
|
33
|
-
// apps/
|
|
31
|
+
// apps/server/src/lock.ts
|
|
34
32
|
import lockfile from "proper-lockfile";
|
|
35
33
|
async function lockDataDir(dir, options) {
|
|
36
34
|
const stale = Math.max(2000, options?.staleMs ?? 1e4);
|
|
@@ -44,6 +42,8 @@ async function lockDataDir(dir, options) {
|
|
|
44
42
|
throw error;
|
|
45
43
|
}
|
|
46
44
|
}).catch((error) => {
|
|
45
|
+
if (error.code !== "ELOCKED")
|
|
46
|
+
throw error;
|
|
47
47
|
throw new Error(`Data directory is already in use by a running process: ${dir}`, { cause: error });
|
|
48
48
|
});
|
|
49
49
|
return async () => {
|
|
@@ -52,7 +52,7 @@ async function lockDataDir(dir, options) {
|
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// apps/
|
|
55
|
+
// apps/server/src/accounts.ts
|
|
56
56
|
var DB_FILE = "server.sqlite";
|
|
57
57
|
var SCHEMA_VERSION = 1;
|
|
58
58
|
var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -394,13 +394,220 @@ async function changePassword(options) {
|
|
|
394
394
|
}
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
-
// apps/
|
|
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
|
|
398
605
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
399
606
|
import { once } from "node:events";
|
|
400
607
|
import { createServer } from "node:http";
|
|
401
608
|
import { WebSocket, WebSocketServer } from "ws";
|
|
402
609
|
|
|
403
|
-
// apps/
|
|
610
|
+
// apps/server/src/api.ts
|
|
404
611
|
var SESSION_COOKIE = "__Host-pinomad-session";
|
|
405
612
|
var MAX_BODY = 8 * 1024;
|
|
406
613
|
var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
@@ -635,7 +842,7 @@ async function handleApiRequest(request, response, ctx) {
|
|
|
635
842
|
deny(404, "not_found");
|
|
636
843
|
}
|
|
637
844
|
|
|
638
|
-
// apps/
|
|
845
|
+
// apps/server/src/relay.ts
|
|
639
846
|
var MAX_PAYLOAD = 128 * 1024;
|
|
640
847
|
var MAX_BUFFERED = 64 * 1024;
|
|
641
848
|
var HIGH_WATER = 1024 * 1024;
|
|
@@ -647,11 +854,12 @@ var isSendableCloseCode = (code) => code >= 1000 && code <= 1003 || code >= 1007
|
|
|
647
854
|
var rawLength = (data) => Array.isArray(data) ? data.reduce((sum, part) => sum + part.length, 0) : data.byteLength;
|
|
648
855
|
var MAX_PAIRING_PER_HOST = 4;
|
|
649
856
|
var MAX_PAIRING_PER_SESSION = 2;
|
|
857
|
+
var MAX_PUSH_PER_HOST = 4;
|
|
858
|
+
var MAX_PUSH_TOTAL = 32;
|
|
650
859
|
async function startRelay(options) {
|
|
651
860
|
const publicOrigin = options.publicOrigin.replace(/\/+$/, "");
|
|
652
861
|
if (publicOrigin === "")
|
|
653
862
|
throw new Error("publicOrigin is required");
|
|
654
|
-
const allowedHosts = new Set(options.allowedHosts);
|
|
655
863
|
const acceptTimeoutMs = options.acceptTimeoutMs ?? 1e4;
|
|
656
864
|
const registerTimeoutMs = options.registerTimeoutMs ?? 1e4;
|
|
657
865
|
const pingIntervalMs = options.pingIntervalMs ?? 30000;
|
|
@@ -659,6 +867,12 @@ async function startRelay(options) {
|
|
|
659
867
|
const accounts = options.accounts;
|
|
660
868
|
const trustProxy = options.trustProxy ?? false;
|
|
661
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;
|
|
662
876
|
const controls = new Map;
|
|
663
877
|
const pending = new Map;
|
|
664
878
|
const pairingRequests = new Map;
|
|
@@ -732,6 +946,45 @@ async function startRelay(options) {
|
|
|
732
946
|
});
|
|
733
947
|
}
|
|
734
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
|
+
}
|
|
735
988
|
function runOp(socket, frame, noisePublicKey) {
|
|
736
989
|
const answer = (reply) => socket.send(encodeRelayFrame(reply), () => socket.close(1000));
|
|
737
990
|
if (accounts === undefined) {
|
|
@@ -763,8 +1016,7 @@ async function startRelay(options) {
|
|
|
763
1016
|
return;
|
|
764
1017
|
}
|
|
765
1018
|
answer({ t: "left" });
|
|
766
|
-
|
|
767
|
-
controls.get(frame.hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
|
|
1019
|
+
controls.get(frame.hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
|
|
768
1020
|
}
|
|
769
1021
|
function onControl(socket) {
|
|
770
1022
|
track(socket);
|
|
@@ -785,6 +1037,10 @@ async function startRelay(options) {
|
|
|
785
1037
|
socket.close(1008, "malformed frame");
|
|
786
1038
|
return;
|
|
787
1039
|
}
|
|
1040
|
+
if (answer.t === "push") {
|
|
1041
|
+
handlePush(socket, hostId, answer);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
788
1044
|
if (answer.t === "pairing" || answer.t === "pairing-refused") {
|
|
789
1045
|
const request = pairingRequests.get(answer.requestId);
|
|
790
1046
|
if (request === undefined || request.control !== socket)
|
|
@@ -826,7 +1082,7 @@ async function startRelay(options) {
|
|
|
826
1082
|
runOp(socket, frame, noisePublicKey);
|
|
827
1083
|
return;
|
|
828
1084
|
}
|
|
829
|
-
if (
|
|
1085
|
+
if (accounts?.hasHost(frame.hostId) !== true) {
|
|
830
1086
|
socket.close(RELAY_CLOSE.hostNotAllowed);
|
|
831
1087
|
return;
|
|
832
1088
|
}
|
|
@@ -841,7 +1097,7 @@ async function startRelay(options) {
|
|
|
841
1097
|
...frame.features === undefined ? {} : { features: frame.features }
|
|
842
1098
|
});
|
|
843
1099
|
accounts?.markHostSeen(hostId);
|
|
844
|
-
socket.send(encodeRelayFrame({ t: "registered" }));
|
|
1100
|
+
socket.send(encodeRelayFrame({ t: "registered", push: pushEnvironments }));
|
|
845
1101
|
});
|
|
846
1102
|
socket.once("close", () => {
|
|
847
1103
|
clearTimeout(deadline);
|
|
@@ -1016,11 +1272,12 @@ async function startRelay(options) {
|
|
|
1016
1272
|
await closing;
|
|
1017
1273
|
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
1018
1274
|
await accounts?.close();
|
|
1275
|
+
await apns?.close();
|
|
1019
1276
|
}
|
|
1020
1277
|
};
|
|
1021
1278
|
}
|
|
1022
1279
|
|
|
1023
|
-
// apps/
|
|
1280
|
+
// apps/server/src/main.ts
|
|
1024
1281
|
function resolveDataDir(flag) {
|
|
1025
1282
|
return resolve(flag ?? process.env.PINOMAD_SERVER_DATA_DIR ?? join2(homedir(), ".pinomad-server"));
|
|
1026
1283
|
}
|
|
@@ -1045,7 +1302,7 @@ async function passwdMain(argv) {
|
|
|
1045
1302
|
strict: true
|
|
1046
1303
|
});
|
|
1047
1304
|
if (process.stdin.isTTY) {
|
|
1048
|
-
console.error("pipe the new password on stdin, e.g. `printenv NEW_PINOMAD_SERVER_PASSWORD | " + commandHint("
|
|
1305
|
+
console.error("pipe the new password on stdin, e.g. `printenv NEW_PINOMAD_SERVER_PASSWORD | " + commandHint("server") + " passwd`");
|
|
1049
1306
|
process.exit(1);
|
|
1050
1307
|
}
|
|
1051
1308
|
const dataDir = resolveDataDir(values["data-dir"]);
|
|
@@ -1067,44 +1324,40 @@ async function main(argv) {
|
|
|
1067
1324
|
port: { type: "string", default: "7430" },
|
|
1068
1325
|
listen: { type: "string", default: "127.0.0.1" },
|
|
1069
1326
|
"public-origin": { type: "string" },
|
|
1070
|
-
"allow-host": { type: "string", multiple: true },
|
|
1071
1327
|
"data-dir": { type: "string" },
|
|
1072
1328
|
"trust-proxy": { type: "boolean", default: false }
|
|
1073
1329
|
}
|
|
1074
1330
|
});
|
|
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
1331
|
if (values["public-origin"] === undefined) {
|
|
1080
|
-
console.error(`usage: ${commandHint("
|
|
1081
|
-
` + ` ${commandHint("
|
|
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)
|
|
1082
1334
|
` + " env: PINOMAD_SERVER_USER, PINOMAD_SERVER_PASSWORD, PINOMAD_SERVER_DATA_DIR");
|
|
1083
1335
|
process.exit(1);
|
|
1084
1336
|
}
|
|
1085
|
-
const accounts =
|
|
1337
|
+
const accounts = await openAccounts({
|
|
1086
1338
|
dataDir: resolveDataDir(values["data-dir"]),
|
|
1087
|
-
bootstrapUser,
|
|
1088
|
-
bootstrapPassword
|
|
1339
|
+
bootstrapUser: process.env.PINOMAD_SERVER_USER,
|
|
1340
|
+
bootstrapPassword: process.env.PINOMAD_SERVER_PASSWORD
|
|
1089
1341
|
}).catch((error) => {
|
|
1090
1342
|
console.error(error instanceof Error ? error.message : String(error));
|
|
1091
1343
|
process.exit(1);
|
|
1092
|
-
})
|
|
1344
|
+
});
|
|
1345
|
+
const apnsConfig = loadApnsConfig();
|
|
1346
|
+
const apns = apnsConfig === undefined ? undefined : openApns(apnsConfig);
|
|
1093
1347
|
const relay = await startRelay({
|
|
1094
1348
|
port: Number(values.port),
|
|
1095
1349
|
listenHost: values.listen,
|
|
1096
1350
|
publicOrigin: values["public-origin"],
|
|
1097
|
-
allowedHosts,
|
|
1098
1351
|
webRoot,
|
|
1099
1352
|
accounts,
|
|
1100
|
-
trustProxy: values["trust-proxy"]
|
|
1353
|
+
trustProxy: values["trust-proxy"],
|
|
1354
|
+
...apns === undefined ? {} : { push: apns }
|
|
1101
1355
|
});
|
|
1102
1356
|
console.log(JSON.stringify({
|
|
1103
1357
|
event: "ready",
|
|
1104
1358
|
url: relay.url,
|
|
1105
1359
|
publicOrigin: values["public-origin"],
|
|
1106
|
-
|
|
1107
|
-
accounts: accounts !== undefined
|
|
1360
|
+
push: apns?.environments ?? []
|
|
1108
1361
|
}));
|
|
1109
1362
|
let stopping = false;
|
|
1110
1363
|
const stop = () => {
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import {
|
|
3
3
|
main,
|
|
4
4
|
restartService
|
|
5
|
-
} from "./chunk-
|
|
6
|
-
import"./chunk-
|
|
7
|
-
import"./chunk-
|
|
8
|
-
import"./chunk-
|
|
5
|
+
} from "./chunk-aqj4wg33.js";
|
|
6
|
+
import"./chunk-1d9sppj0.js";
|
|
7
|
+
import"./chunk-m4q9wafw.js";
|
|
8
|
+
import"./chunk-hsp12cjt.js";
|
|
9
9
|
import"./chunk-cjfmfyjb.js";
|
|
10
10
|
import"./chunk-df32avw6.js";
|
|
11
11
|
export {
|
|
@@ -3,31 +3,28 @@ import {
|
|
|
3
3
|
assertSecureServerOrigin,
|
|
4
4
|
probeServerAccounts,
|
|
5
5
|
runServerOperation
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-26d04z02.js";
|
|
7
7
|
import {
|
|
8
|
+
loadHostKey,
|
|
9
|
+
loadRelayKey,
|
|
8
10
|
writeServerConfig
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import
|
|
11
|
+
} from "./chunk-ebfaf3k2.js";
|
|
12
|
+
import {
|
|
13
|
+
relayHostId
|
|
14
|
+
} from "./chunk-kmbh44dt.js";
|
|
11
15
|
import {
|
|
12
16
|
argValue,
|
|
13
17
|
installSpec,
|
|
14
18
|
restartService,
|
|
15
19
|
serviceManager,
|
|
16
20
|
withoutRelayArgs
|
|
17
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-aqj4wg33.js";
|
|
18
22
|
import {
|
|
19
23
|
commandHint,
|
|
20
24
|
hostEntry
|
|
21
|
-
} from "./chunk-
|
|
22
|
-
import"./chunk-
|
|
23
|
-
import"./chunk-
|
|
24
|
-
import {
|
|
25
|
-
loadHostKey,
|
|
26
|
-
loadRelayKey
|
|
27
|
-
} from "./chunk-837gh8nr.js";
|
|
28
|
-
import {
|
|
29
|
-
relayHostId
|
|
30
|
-
} from "./chunk-7gmkgyaz.js";
|
|
25
|
+
} from "./chunk-1d9sppj0.js";
|
|
26
|
+
import"./chunk-m4q9wafw.js";
|
|
27
|
+
import"./chunk-hsp12cjt.js";
|
|
31
28
|
import {
|
|
32
29
|
DEFAULT_DATA_DIR
|
|
33
30
|
} from "./chunk-cjfmfyjb.js";
|
|
@@ -2,10 +2,10 @@ import {
|
|
|
2
2
|
commandHint,
|
|
3
3
|
hostEntry,
|
|
4
4
|
packagedVersion
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-1d9sppj0.js";
|
|
6
6
|
import {
|
|
7
7
|
connectRemoteDurable
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-m4q9wafw.js";
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_DATA_DIR,
|
|
11
11
|
hostAddress
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
import {
|
|
3
3
|
restartService,
|
|
4
4
|
serviceManager
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-aqj4wg33.js";
|
|
6
6
|
import {
|
|
7
7
|
globalPrefixOf,
|
|
8
8
|
packageDir,
|
|
9
9
|
packagedVersion
|
|
10
|
-
} from "./chunk-
|
|
11
|
-
import"./chunk-
|
|
12
|
-
import"./chunk-
|
|
10
|
+
} from "./chunk-1d9sppj0.js";
|
|
11
|
+
import"./chunk-m4q9wafw.js";
|
|
12
|
+
import"./chunk-hsp12cjt.js";
|
|
13
13
|
import"./chunk-cjfmfyjb.js";
|
|
14
14
|
import"./chunk-df32avw6.js";
|
|
15
15
|
|