@juspay/neurolink 11.29.2 → 11.30.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/CHANGELOG.md +3 -3
- package/dist/auth/anthropicOAuth.d.ts +50 -0
- package/dist/auth/anthropicOAuth.js +78 -0
- package/dist/browser/neurolink.min.js +393 -393
- package/dist/cli/commands/proxy.d.ts +2 -0
- package/dist/cli/commands/proxy.js +284 -4
- package/dist/cli/commands/proxyExpose.d.ts +35 -0
- package/dist/cli/commands/proxyExpose.js +252 -0
- package/dist/cli/commands/proxyPeer.d.ts +29 -0
- package/dist/cli/commands/proxyPeer.js +738 -0
- package/dist/cli/commands/proxyShare.d.ts +37 -0
- package/dist/cli/commands/proxyShare.js +1080 -0
- package/dist/cli/parser.js +7 -1
- package/dist/proxy/peerStore.d.ts +52 -0
- package/dist/proxy/peerStore.js +324 -0
- package/dist/proxy/peerTransport.d.ts +38 -0
- package/dist/proxy/peerTransport.js +242 -0
- package/dist/proxy/proxyPaths.d.ts +8 -0
- package/dist/proxy/proxyPaths.js +55 -17
- package/dist/proxy/requestLogger.js +8 -0
- package/dist/proxy/residentGrants.d.ts +57 -0
- package/dist/proxy/residentGrants.js +393 -0
- package/dist/proxy/shareAudit.d.ts +81 -0
- package/dist/proxy/shareAudit.js +280 -0
- package/dist/proxy/shareContext.d.ts +38 -0
- package/dist/proxy/shareContext.js +92 -0
- package/dist/proxy/shareGate.d.ts +64 -0
- package/dist/proxy/shareGate.js +216 -0
- package/dist/proxy/shareGrants.d.ts +115 -0
- package/dist/proxy/shareGrants.js +590 -0
- package/dist/proxy/shareLease.d.ts +101 -0
- package/dist/proxy/shareLease.js +192 -0
- package/dist/proxy/shareLedger.d.ts +105 -0
- package/dist/proxy/shareLedger.js +406 -0
- package/dist/proxy/shareListener.d.ts +60 -0
- package/dist/proxy/shareListener.js +143 -0
- package/dist/proxy/shareNotes.d.ts +97 -0
- package/dist/proxy/shareNotes.js +234 -0
- package/dist/proxy/sharePolicy.d.ts +110 -0
- package/dist/proxy/sharePolicy.js +366 -0
- package/dist/proxy/shareProvisioning.d.ts +110 -0
- package/dist/proxy/shareProvisioning.js +237 -0
- package/dist/proxy/shareReceipts.d.ts +99 -0
- package/dist/proxy/shareReceipts.js +303 -0
- package/dist/proxy/shareSigning.d.ts +40 -0
- package/dist/proxy/shareSigning.js +78 -0
- package/dist/server/routes/claudeProxyRoutes.js +1066 -3
- package/dist/types/cli.d.ts +61 -0
- package/dist/types/proxy.d.ts +781 -0
- package/package.json +2 -1
|
@@ -72,6 +72,8 @@ export declare function createProxyStartApp(params: {
|
|
|
72
72
|
accountAllowlist: AccountAllowlist | undefined;
|
|
73
73
|
runtimeConfigStore?: ProxyRuntimeConfigStore;
|
|
74
74
|
updateControlToken?: string;
|
|
75
|
+
/** Port of the gate-only share listener, when one is configured. */
|
|
76
|
+
sharePort?: number;
|
|
75
77
|
}): Promise<{
|
|
76
78
|
app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
77
79
|
readiness: ProxyReadinessState;
|
|
@@ -45,7 +45,26 @@ const PROXY_VERSION = packageJson.version;
|
|
|
45
45
|
const PROXY_INTERNAL_ACCOUNT_LABEL = "proxy/internal";
|
|
46
46
|
const PROXY_INTERNAL_ACCOUNT_TYPE = "internal";
|
|
47
47
|
const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/observability/manage-local-openobserve.sh", import.meta.url));
|
|
48
|
+
/**
|
|
49
|
+
* Requests accepted by the gate-only share listener.
|
|
50
|
+
*
|
|
51
|
+
* Both listeners serve the same Hono app, so the only thing separating a gated
|
|
52
|
+
* request from an ungated one is which socket accepted it. Recovering that from
|
|
53
|
+
* `c.env.incoming.socket.localPort` worked but failed in two directions: an
|
|
54
|
+
* unreadable port answered "not gated", which is fail-open on the one listener
|
|
55
|
+
* that must never fail open, and a share port configured equal to the main port
|
|
56
|
+
* answered "gated" for the operator's own untokened traffic.
|
|
57
|
+
*
|
|
58
|
+
* Stamping the Request as the share listener hands it to the app settles both.
|
|
59
|
+
* The mark is applied by the accepting listener before any handler runs, it
|
|
60
|
+
* says nothing about ports, and nothing a client sends can add or remove it. A
|
|
61
|
+
* `WeakSet` keyed on the Request holds it for exactly as long as the request
|
|
62
|
+
* object lives and not a moment longer.
|
|
63
|
+
*/
|
|
64
|
+
const gatedShareRequests = new WeakSet();
|
|
48
65
|
const PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
66
|
+
/** How long shutdown waits on the share listener before moving on. */
|
|
67
|
+
const SHARE_LISTENER_CLOSE_TIMEOUT_MS = 10_000;
|
|
49
68
|
const LEGACY_STATUS_ACCOUNT_CACHE_TTL_MS = 5_000;
|
|
50
69
|
const PROXY_STATUS_TOKEN_READ_TIMEOUT_MS = 2_000;
|
|
51
70
|
const PROXY_STATUS_RECONCILE_TIMEOUT_MS = 750;
|
|
@@ -1033,6 +1052,39 @@ async function createProxyNeurolinkRuntime(logsDir) {
|
|
|
1033
1052
|
logsDir: logsDir ?? join(homedir(), ".neurolink", "logs"),
|
|
1034
1053
|
};
|
|
1035
1054
|
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Replace account identity in a `/status` payload with a stable placeholder.
|
|
1057
|
+
*
|
|
1058
|
+
* The shape is preserved — same rows, same counters — so status tooling keeps
|
|
1059
|
+
* working and only the names go away. Callers that legitimately need the names
|
|
1060
|
+
* present the update-control token.
|
|
1061
|
+
*/
|
|
1062
|
+
function redactStatusAccounts(rows, allowed) {
|
|
1063
|
+
if (allowed) {
|
|
1064
|
+
return rows;
|
|
1065
|
+
}
|
|
1066
|
+
return rows.map((row, index) => ({
|
|
1067
|
+
...row,
|
|
1068
|
+
label: `account-${index + 1}`,
|
|
1069
|
+
...(row.email !== undefined ? { email: null } : {}),
|
|
1070
|
+
}));
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* The same treatment for the configured/effective primary account block.
|
|
1074
|
+
*
|
|
1075
|
+
* Named fields rather than "every non-empty string": `source` is a discriminant
|
|
1076
|
+
* (`configured` | `fallback`), not an identity, and blanking it both left the
|
|
1077
|
+
* value outside its own union and hid the one thing a caller reads this block
|
|
1078
|
+
* for — whether the configured primary is the account actually in use.
|
|
1079
|
+
*/
|
|
1080
|
+
function redactStatusPrimaryAccount(primary) {
|
|
1081
|
+
return {
|
|
1082
|
+
configured: primary.configured === null ? null : "redacted",
|
|
1083
|
+
key: primary.key === null ? null : "redacted",
|
|
1084
|
+
label: primary.label === null ? null : "redacted",
|
|
1085
|
+
source: primary.source,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1036
1088
|
function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
1037
1089
|
const trackingHandler = async (c, next) => {
|
|
1038
1090
|
const startedMonotonicMs = performance.now();
|
|
@@ -1061,6 +1113,10 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1061
1113
|
: beginProxyRequest();
|
|
1062
1114
|
const finish = () => {
|
|
1063
1115
|
finishActivity();
|
|
1116
|
+
// Borrowed traffic holds a concurrency slot for the lifetime of the
|
|
1117
|
+
// response body, so it is released here rather than when the handler
|
|
1118
|
+
// returns. Idempotent — a request can finish more than one way.
|
|
1119
|
+
metadata.shareRelease?.();
|
|
1064
1120
|
requestMetadata.delete(c.req.raw);
|
|
1065
1121
|
};
|
|
1066
1122
|
// The route adapter populates model/stream/toolCount after parsing. Omit
|
|
@@ -1136,6 +1192,11 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1136
1192
|
// Keep metadata available to app.onError, which records the client-facing
|
|
1137
1193
|
// failure with the same request ID before deleting the WeakMap entry.
|
|
1138
1194
|
finishActivity();
|
|
1195
|
+
// A handler that threw never produced a body for `trackProxyResponse` to
|
|
1196
|
+
// follow, so `finish` will not run and the borrowed-traffic slot (and its
|
|
1197
|
+
// coin hold) would be held until the process restarts. Both releases are
|
|
1198
|
+
// idempotent, so the double call on paths that do reach `finish` is free.
|
|
1199
|
+
metadata.shareRelease?.();
|
|
1139
1200
|
logProxyLifecycleEvent({
|
|
1140
1201
|
event: "request_terminal",
|
|
1141
1202
|
requestId: metadata.requestId,
|
|
@@ -1174,8 +1235,24 @@ export async function createProxyStartApp(params) {
|
|
|
1174
1235
|
const { createGeminiProxyRoutes } = await import("../../server/routes/geminiProxyRoutes.js");
|
|
1175
1236
|
const { logBodyCapture, logRequest } = await import("../../proxy/requestLogger.js");
|
|
1176
1237
|
const { recordFinalError } = await import("../../proxy/usageStats.js");
|
|
1238
|
+
const { admitInboundShareRequest, isGrantRequiredByEnv } = await import("../../proxy/shareGate.js");
|
|
1239
|
+
const { runWithShareContext } = await import("../../proxy/shareContext.js");
|
|
1177
1240
|
const { Hono } = await import("hono");
|
|
1178
1241
|
const app = new Hono();
|
|
1242
|
+
/**
|
|
1243
|
+
* Is this request arriving on the gate-only share listener?
|
|
1244
|
+
*
|
|
1245
|
+
* Decided by the **local port the connection was accepted on**, which no
|
|
1246
|
+
* client can influence. A header or an address check could not do this job:
|
|
1247
|
+
* cloudflared and every reverse proxy connect from 127.0.0.1, so tunnelled
|
|
1248
|
+
* traffic is indistinguishable from the operator's own by origin alone.
|
|
1249
|
+
*
|
|
1250
|
+
* The share port refuses untokened requests by construction; the main port
|
|
1251
|
+
* keeps its existing behaviour unless the operator opts in with
|
|
1252
|
+
* `NEUROLINK_PROXY_REQUIRE_GRANT` — which stays the answer for anyone binding
|
|
1253
|
+
* `0.0.0.0` with nothing in front of it.
|
|
1254
|
+
*/
|
|
1255
|
+
const isGatedListener = (c) => isGrantRequiredByEnv() || gatedShareRequests.has(c.req.raw);
|
|
1179
1256
|
const readiness = createProxyReadinessState();
|
|
1180
1257
|
const requestMetadata = new WeakMap();
|
|
1181
1258
|
const recordRuntimeError = async (metadata, status, errorType, errorMessage, options) => {
|
|
@@ -1281,6 +1358,15 @@ export async function createProxyStartApp(params) {
|
|
|
1281
1358
|
});
|
|
1282
1359
|
});
|
|
1283
1360
|
registerProxyRequestTracking(app, requestMetadata, readiness);
|
|
1361
|
+
// Complete-mode credentials adopted from a lender have to check in, or they
|
|
1362
|
+
// stop when their offline grace runs out. The timer is unref'd so it never
|
|
1363
|
+
// keeps the process alive, and failures are the lender being unreachable —
|
|
1364
|
+
// exactly the case the grace period exists for, not an error.
|
|
1365
|
+
const { heartbeatDueResidentGrants } = await import("../../proxy/residentGrants.js");
|
|
1366
|
+
const residentHeartbeatTimer = setInterval(() => {
|
|
1367
|
+
void heartbeatDueResidentGrants().catch(() => undefined);
|
|
1368
|
+
}, 5 * 60 * 1000);
|
|
1369
|
+
residentHeartbeatTimer.unref();
|
|
1284
1370
|
const runtimeConfigStore = params.runtimeConfigStore;
|
|
1285
1371
|
const runtimeConfigProvider = runtimeConfigStore
|
|
1286
1372
|
? () => runtimeConfigStore.getSnapshot()
|
|
@@ -1358,6 +1444,60 @@ export async function createProxyStartApp(params) {
|
|
|
1358
1444
|
}
|
|
1359
1445
|
const logModel = sanitizeForLog(String(model));
|
|
1360
1446
|
logger.always(`[proxy] ${c.req.method} ${c.req.path} → model=${logModel} ${stream} tools=${toolCount}`);
|
|
1447
|
+
// Peer-sharing gate. Runs here rather than as middleware because the
|
|
1448
|
+
// model is only known after the body is parsed, and the model allowlist
|
|
1449
|
+
// is one of the gates. Requests with no share token take the `local`
|
|
1450
|
+
// branch untouched, which is every request on a node that shares nothing.
|
|
1451
|
+
// `Number()` answers 0 for `null`, `""` and `[]` — all finite, all wrong.
|
|
1452
|
+
// A zero reaches `estimateHoldCoins` and clamps up to its 256 floor
|
|
1453
|
+
// rather than the 4096 an absent value defaults to, so the hold opened
|
|
1454
|
+
// for the request comes out sixteen times too small. Only a real positive
|
|
1455
|
+
// number counts here; anything else is treated as absent, which is the
|
|
1456
|
+
// conservative direction because it holds more, not less.
|
|
1457
|
+
const rawMaxTokens = body?.max_tokens;
|
|
1458
|
+
const requestedMaxTokens = typeof rawMaxTokens === "number" &&
|
|
1459
|
+
Number.isFinite(rawMaxTokens) &&
|
|
1460
|
+
rawMaxTokens > 0
|
|
1461
|
+
? rawMaxTokens
|
|
1462
|
+
: undefined;
|
|
1463
|
+
// The heartbeat surface authenticates itself against the grant's lease
|
|
1464
|
+
// secret. Running it through the request gate as well would spend the
|
|
1465
|
+
// grant's rate allowance and open a coin hold for a call that consumes no
|
|
1466
|
+
// capacity at all.
|
|
1467
|
+
const shareOutcome = c.req.path.startsWith("/peer/")
|
|
1468
|
+
? { kind: "local" }
|
|
1469
|
+
: await admitInboundShareRequest({
|
|
1470
|
+
headers: Object.fromEntries(c.req.raw.headers.entries()),
|
|
1471
|
+
model: String(model),
|
|
1472
|
+
...(requestedMaxTokens !== undefined
|
|
1473
|
+
? { maxTokens: requestedMaxTokens }
|
|
1474
|
+
: {}),
|
|
1475
|
+
requireGrant: isGatedListener(c),
|
|
1476
|
+
});
|
|
1477
|
+
if (shareOutcome.kind === "refused") {
|
|
1478
|
+
const refusal = shareOutcome.response;
|
|
1479
|
+
for (const [key, value] of Object.entries(refusal.headers)) {
|
|
1480
|
+
c.header(key, value);
|
|
1481
|
+
}
|
|
1482
|
+
if (metadata) {
|
|
1483
|
+
metadata.terminalErrorType = `share_${refusal.body.error.type}`;
|
|
1484
|
+
}
|
|
1485
|
+
// Narrow to the literals the gate can actually produce; Hono's json()
|
|
1486
|
+
// wants a status literal and the alternative is a cast.
|
|
1487
|
+
const refusalStatus = refusal.status === 401 ? 401 : refusal.status === 403 ? 403 : 429;
|
|
1488
|
+
return c.json(refusal.body, refusalStatus);
|
|
1489
|
+
}
|
|
1490
|
+
const shareContext = shareOutcome.kind === "admitted" ? shareOutcome.context : undefined;
|
|
1491
|
+
if (shareOutcome.kind === "admitted") {
|
|
1492
|
+
if (metadata) {
|
|
1493
|
+
metadata.shareRelease = shareOutcome.release;
|
|
1494
|
+
}
|
|
1495
|
+
else {
|
|
1496
|
+
// No tracked metadata means nothing will call the completion hook;
|
|
1497
|
+
// release immediately rather than leaking the concurrency slot.
|
|
1498
|
+
shareOutcome.release();
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1361
1501
|
const ctx = {
|
|
1362
1502
|
requestId: metadata?.requestId ?? crypto.randomUUID(),
|
|
1363
1503
|
method: c.req.method,
|
|
@@ -1385,7 +1525,9 @@ export async function createProxyStartApp(params) {
|
|
|
1385
1525
|
c.header(key, value);
|
|
1386
1526
|
}
|
|
1387
1527
|
};
|
|
1388
|
-
const result =
|
|
1528
|
+
const result = shareContext
|
|
1529
|
+
? await runWithShareContext(shareContext, () => route.handler(ctx))
|
|
1530
|
+
: await route.handler(ctx);
|
|
1389
1531
|
if (result instanceof Response) {
|
|
1390
1532
|
// Streaming responses own their headers; merge in anything the
|
|
1391
1533
|
// handler published on the context that the Response lacks. A Response
|
|
@@ -1485,6 +1627,19 @@ export async function createProxyStartApp(params) {
|
|
|
1485
1627
|
}));
|
|
1486
1628
|
});
|
|
1487
1629
|
app.get("/status", async (c) => {
|
|
1630
|
+
// A gated proxy is, by definition, one that may be exposed. `/status`
|
|
1631
|
+
// enumerates account labels — which for an OAuth account is an email — plus
|
|
1632
|
+
// quota and cooldown state, and it is not behind the request gate, so
|
|
1633
|
+
// without this anyone who reaches the tunnel could read the pool's identity.
|
|
1634
|
+
//
|
|
1635
|
+
// A loopback check would be no defence: cloudflared runs locally and
|
|
1636
|
+
// connects to 127.0.0.1, so tunnelled traffic arrives from loopback too.
|
|
1637
|
+
// Identity is therefore released only to a caller holding the update-control
|
|
1638
|
+
// token, and redacted for everyone else. Counters and health stay visible so
|
|
1639
|
+
// liveness tooling keeps working.
|
|
1640
|
+
const statusIdentityAllowed = !isGatedListener(c) ||
|
|
1641
|
+
c.req.header("x-neurolink-update-token") ===
|
|
1642
|
+
(params.updateControlToken ?? PROXY_UPDATE_CONTROL_TOKEN);
|
|
1488
1643
|
const runtimeConfig = params.runtimeConfigStore?.getSnapshot();
|
|
1489
1644
|
const runtimeConfigStatus = params.runtimeConfigStore?.getStatus();
|
|
1490
1645
|
const activeStrategy = runtimeConfig
|
|
@@ -1749,8 +1904,10 @@ export async function createProxyStartApp(params) {
|
|
|
1749
1904
|
terminalErrorDetailsMissing,
|
|
1750
1905
|
terminalErrorDetailsExcess,
|
|
1751
1906
|
snapshotSource,
|
|
1752
|
-
accounts: accountRows,
|
|
1753
|
-
primaryAccount
|
|
1907
|
+
accounts: redactStatusAccounts(accountRows, statusIdentityAllowed),
|
|
1908
|
+
primaryAccount: statusIdentityAllowed
|
|
1909
|
+
? primaryAccount
|
|
1910
|
+
: redactStatusPrimaryAccount(primaryAccount),
|
|
1754
1911
|
persistence: getUsageStatsPersistenceStatus(),
|
|
1755
1912
|
},
|
|
1756
1913
|
activity: (() => {
|
|
@@ -2018,6 +2175,33 @@ function startProxyBackgroundMaintenance(logsDir, getAccountAllowlist) {
|
|
|
2018
2175
|
}
|
|
2019
2176
|
function registerProxyShutdownHandlers(params) {
|
|
2020
2177
|
let shutdownStarted = false;
|
|
2178
|
+
/**
|
|
2179
|
+
* Await one shutdown step, but never for longer than `ms`.
|
|
2180
|
+
*
|
|
2181
|
+
* By the time a signal has arrived every step here is best-effort: the
|
|
2182
|
+
* process is going away either way, and the only thing an unbounded await
|
|
2183
|
+
* buys is the chance that the supervisor's patience runs out first and turns
|
|
2184
|
+
* a clean exit into a SIGKILL.
|
|
2185
|
+
*/
|
|
2186
|
+
const withShutdownDeadline = async (step, ms, label) => {
|
|
2187
|
+
if (!step) {
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
let timer;
|
|
2191
|
+
const timedOut = await Promise.race([
|
|
2192
|
+
step.then(() => false, () => false),
|
|
2193
|
+
new Promise((resolve) => {
|
|
2194
|
+
timer = setTimeout(() => resolve(true), ms);
|
|
2195
|
+
timer.unref?.();
|
|
2196
|
+
}),
|
|
2197
|
+
]);
|
|
2198
|
+
if (timer) {
|
|
2199
|
+
clearTimeout(timer);
|
|
2200
|
+
}
|
|
2201
|
+
if (timedOut) {
|
|
2202
|
+
logger.always(`[proxy] ${label} did not close in time; continuing`);
|
|
2203
|
+
}
|
|
2204
|
+
};
|
|
2021
2205
|
const closeServer = async () => {
|
|
2022
2206
|
const close = params.server.close?.bind(params.server);
|
|
2023
2207
|
if (!close) {
|
|
@@ -2057,6 +2241,11 @@ function registerProxyShutdownHandlers(params) {
|
|
|
2057
2241
|
await params.logCleanupScheduler.stop();
|
|
2058
2242
|
params.updaterSupervisor?.stop();
|
|
2059
2243
|
params.stopRuntimeConfig?.();
|
|
2244
|
+
// Bounded like the main server's close below. `stop()` awaits the
|
|
2245
|
+
// listener's own close, and a borrower holding a stream open keeps that
|
|
2246
|
+
// pending for as long as it likes — an unbounded await here is a proxy
|
|
2247
|
+
// that appears to ignore SIGTERM until the borrower hangs up.
|
|
2248
|
+
await withShutdownDeadline(params.shareListener?.stop(), SHARE_LISTENER_CLOSE_TIMEOUT_MS, "share listener");
|
|
2060
2249
|
logger.always(`\nShutting down proxy (${signal})...`);
|
|
2061
2250
|
let exitCode = signal === "SIGINT" || signal === "ROLLING_DRAIN" ? 0 : 1;
|
|
2062
2251
|
if (!options?.skipServerClose) {
|
|
@@ -2140,6 +2329,66 @@ async function startProxyRuntime(params) {
|
|
|
2140
2329
|
port: params.port,
|
|
2141
2330
|
hostname: params.host,
|
|
2142
2331
|
});
|
|
2332
|
+
// The gate-only listener. It exists only while this node lends something, and
|
|
2333
|
+
// it is the port an operator exposes: the main port keeps serving the
|
|
2334
|
+
// operator's own untokened client exactly as before.
|
|
2335
|
+
const { isShareListenerDisabled, superviseShareListener } = await import("../../proxy/shareListener.js");
|
|
2336
|
+
// Runs under socket workers too. During a rolling replacement both
|
|
2337
|
+
// generations are briefly live and the incoming one loses the bind; that is
|
|
2338
|
+
// now a logged retry rather than a crash, and the supervisor picks the port up
|
|
2339
|
+
// on its next poll once the outgoing worker drains. Disabling it here instead
|
|
2340
|
+
// would leave launchd installs — the main production shape — with no share
|
|
2341
|
+
// listener at all, which is the thing this exists to remove.
|
|
2342
|
+
const shareListener = isShareListenerDisabled()
|
|
2343
|
+
? undefined
|
|
2344
|
+
: superviseShareListener({
|
|
2345
|
+
start: async () => {
|
|
2346
|
+
// Bind before reporting success, and take the bind error as a
|
|
2347
|
+
// rejection rather than an unhandled `error` event — a port
|
|
2348
|
+
// collision on the derived `port + 1` must cost the operator a log
|
|
2349
|
+
// line and a `--share-port`, never the whole proxy.
|
|
2350
|
+
const shareServer = await new Promise((resolve, reject) => {
|
|
2351
|
+
let listening = false;
|
|
2352
|
+
const started = serve({
|
|
2353
|
+
// Every request this listener accepts is gated, and this is
|
|
2354
|
+
// the only place that fact is known for certain — see
|
|
2355
|
+
// `gatedShareRequests`.
|
|
2356
|
+
fetch: (request, env) => {
|
|
2357
|
+
gatedShareRequests.add(request);
|
|
2358
|
+
return params.app.fetch(request, env);
|
|
2359
|
+
},
|
|
2360
|
+
port: params.sharePort,
|
|
2361
|
+
hostname: params.host,
|
|
2362
|
+
}, () => {
|
|
2363
|
+
listening = true;
|
|
2364
|
+
resolve(started);
|
|
2365
|
+
});
|
|
2366
|
+
started.on("error", (error) => {
|
|
2367
|
+
if (!listening) {
|
|
2368
|
+
reject(error);
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
// Past startup a listener error is a runtime event, not a
|
|
2372
|
+
// reason to take the process down with it.
|
|
2373
|
+
logger.always(`[proxy] share listener error: ${error.message}`);
|
|
2374
|
+
});
|
|
2375
|
+
});
|
|
2376
|
+
return {
|
|
2377
|
+
port: params.sharePort,
|
|
2378
|
+
close: () => new Promise((resolve) => {
|
|
2379
|
+
const close = shareServer.close?.bind(shareServer);
|
|
2380
|
+
if (!close) {
|
|
2381
|
+
resolve();
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
close(() => resolve());
|
|
2385
|
+
}),
|
|
2386
|
+
};
|
|
2387
|
+
},
|
|
2388
|
+
});
|
|
2389
|
+
// Bring it up now if grants already exist, rather than waiting a poll cycle
|
|
2390
|
+
// for a proxy that restarted with shares already issued.
|
|
2391
|
+
await shareListener?.poll();
|
|
2143
2392
|
const managedByLaunchd = isLaunchdManagedProcess() || socketWorker;
|
|
2144
2393
|
// launchd already owns restart supervision. A second detached supervisor can
|
|
2145
2394
|
// outlive its parent and terminate a healthy replacement, so the guard is
|
|
@@ -2216,6 +2465,7 @@ async function startProxyRuntime(params) {
|
|
|
2216
2465
|
pid: process.pid,
|
|
2217
2466
|
port: params.port,
|
|
2218
2467
|
host: params.host,
|
|
2468
|
+
sharePort: shareListener ? params.sharePort : undefined,
|
|
2219
2469
|
strategy: activeStrategy,
|
|
2220
2470
|
startTime: new Date().toISOString(),
|
|
2221
2471
|
ready: true,
|
|
@@ -2355,6 +2605,7 @@ async function startProxyRuntime(params) {
|
|
|
2355
2605
|
isDev,
|
|
2356
2606
|
updaterSupervisor,
|
|
2357
2607
|
stopRuntimeConfig,
|
|
2608
|
+
shareListener,
|
|
2358
2609
|
registerSignals: !socketWorker,
|
|
2359
2610
|
...maintenance,
|
|
2360
2611
|
});
|
|
@@ -2497,7 +2748,7 @@ async function startProxyCommandHandler(argv) {
|
|
|
2497
2748
|
}
|
|
2498
2749
|
// In dev mode: redirect writable state to .neurolink-dev/ and skip singleton check
|
|
2499
2750
|
let devPaths;
|
|
2500
|
-
const { resolveProxyPaths, resolveProxyUsageStatsPath } = await import("../../proxy/proxyPaths.js");
|
|
2751
|
+
const { resolveProxyPaths, resolveProxyUsageStatsPath, resolveProxyGrantsPath, resolveProxyLedgerPath, resolveProxyPeersPath, resolveProxyResidentGrantsPath, resolveProxyNotesPath, resolveProxyProvisioningPath, resolveProxyReceiptsPath, resolveProxyShareAuditPath, } = await import("../../proxy/proxyPaths.js");
|
|
2501
2752
|
const proxyPaths = resolveProxyPaths(isDev);
|
|
2502
2753
|
if (isDev) {
|
|
2503
2754
|
devPaths = proxyPaths;
|
|
@@ -2506,6 +2757,22 @@ async function startProxyCommandHandler(argv) {
|
|
|
2506
2757
|
initAccountQuota(devPaths.quotaFile);
|
|
2507
2758
|
const { initAccountCooldown } = await import("../../proxy/accountCooldown.js");
|
|
2508
2759
|
initAccountCooldown(devPaths.cooldownFile);
|
|
2760
|
+
const { initShareGrants } = await import("../../proxy/shareGrants.js");
|
|
2761
|
+
initShareGrants(resolveProxyGrantsPath(devPaths));
|
|
2762
|
+
const { initShareLedger } = await import("../../proxy/shareLedger.js");
|
|
2763
|
+
initShareLedger(resolveProxyLedgerPath(devPaths));
|
|
2764
|
+
const { initPeerStore } = await import("../../proxy/peerStore.js");
|
|
2765
|
+
initPeerStore(resolveProxyPeersPath(devPaths));
|
|
2766
|
+
const { initResidentGrants } = await import("../../proxy/residentGrants.js");
|
|
2767
|
+
initResidentGrants(resolveProxyResidentGrantsPath(devPaths));
|
|
2768
|
+
const { initShareAudit } = await import("../../proxy/shareAudit.js");
|
|
2769
|
+
initShareAudit(resolveProxyShareAuditPath(devPaths));
|
|
2770
|
+
const { initShareProvisioning } = await import("../../proxy/shareProvisioning.js");
|
|
2771
|
+
initShareProvisioning(resolveProxyProvisioningPath(devPaths));
|
|
2772
|
+
const { initShareReceipts } = await import("../../proxy/shareReceipts.js");
|
|
2773
|
+
initShareReceipts(resolveProxyReceiptsPath(devPaths));
|
|
2774
|
+
const { initShareNotes } = await import("../../proxy/shareNotes.js");
|
|
2775
|
+
initShareNotes(resolveProxyNotesPath(devPaths));
|
|
2509
2776
|
// Ensure the dev state directory exists
|
|
2510
2777
|
const { mkdirSync, existsSync } = await import("fs");
|
|
2511
2778
|
if (!existsSync(devPaths.stateDir)) {
|
|
@@ -2562,6 +2829,11 @@ async function startProxyCommandHandler(argv) {
|
|
|
2562
2829
|
spinner.text = "Configuring server...";
|
|
2563
2830
|
}
|
|
2564
2831
|
const port = argv.port ?? 55669;
|
|
2832
|
+
const { resolveSharePort } = await import("../../proxy/shareListener.js");
|
|
2833
|
+
const sharePort = resolveSharePort({
|
|
2834
|
+
...(argv.sharePort !== undefined ? { explicit: argv.sharePort } : {}),
|
|
2835
|
+
mainPort: port,
|
|
2836
|
+
});
|
|
2565
2837
|
const host = argv.host ?? "127.0.0.1";
|
|
2566
2838
|
const { app, readiness } = await createProxyStartApp({
|
|
2567
2839
|
neurolink,
|
|
@@ -2574,6 +2846,7 @@ async function startProxyCommandHandler(argv) {
|
|
|
2574
2846
|
primaryAccountKey,
|
|
2575
2847
|
accountAllowlist,
|
|
2576
2848
|
runtimeConfigStore,
|
|
2849
|
+
sharePort,
|
|
2577
2850
|
});
|
|
2578
2851
|
await initializeProxyOpenTelemetry();
|
|
2579
2852
|
if (spinner) {
|
|
@@ -2586,6 +2859,7 @@ async function startProxyCommandHandler(argv) {
|
|
|
2586
2859
|
readiness,
|
|
2587
2860
|
host,
|
|
2588
2861
|
port,
|
|
2862
|
+
sharePort,
|
|
2589
2863
|
strategy,
|
|
2590
2864
|
proxyConfig,
|
|
2591
2865
|
accountAllowlist,
|
|
@@ -2619,6 +2893,12 @@ export const proxyStartCommand = {
|
|
|
2619
2893
|
alias: "p",
|
|
2620
2894
|
default: 55669,
|
|
2621
2895
|
description: "Port to listen on",
|
|
2896
|
+
})
|
|
2897
|
+
.option("share-port", {
|
|
2898
|
+
type: "number",
|
|
2899
|
+
alias: "sharePort",
|
|
2900
|
+
description: "Gate-only listener port for peer sharing (default: port + 1). " +
|
|
2901
|
+
"Runs only while at least one active grant exists; this is the port to expose.",
|
|
2622
2902
|
})
|
|
2623
2903
|
.option("host", {
|
|
2624
2904
|
type: "string",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `neurolink proxy expose` — put this node's proxy on a public URL.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `cloudflared` because that is the shortest path from a laptop to a URL
|
|
5
|
+
* a peer can reach, with no port forwarding and no inbound firewall change.
|
|
6
|
+
*
|
|
7
|
+
* **The safety check is the point.** Whether a port is gated depends on the
|
|
8
|
+
* proxy process — which listener it is, and how that process was started —
|
|
9
|
+
* none of which this command can read. So instead of trusting configuration, it
|
|
10
|
+
* asks the running proxy directly: a request with no share token must be
|
|
11
|
+
* refused. A port that answers one is open, and exposing it would publish the
|
|
12
|
+
* operator's subscription to anyone who finds the URL, so the tunnel is refused
|
|
13
|
+
* rather than opened.
|
|
14
|
+
*
|
|
15
|
+
* With no `--port` it targets the gate-only share listener, which is the port
|
|
16
|
+
* that exists to face outward.
|
|
17
|
+
*
|
|
18
|
+
* @module cli/commands/proxyExpose
|
|
19
|
+
*/
|
|
20
|
+
import type { CommandModule } from "yargs";
|
|
21
|
+
import type { ProxyExposeArgs, ProxyGateProbe } from "../../types/index.js";
|
|
22
|
+
/**
|
|
23
|
+
* Ask the running proxy whether it refuses untokened traffic.
|
|
24
|
+
*
|
|
25
|
+
* A refusal carrying `x-neurolink-grant-reason: missing_token` is proof the
|
|
26
|
+
* gate is live. Anything else — an answer, an upstream error, a credentials
|
|
27
|
+
* complaint — means the request got past the gate, which is the dangerous case.
|
|
28
|
+
*
|
|
29
|
+
* `scheme` matters: probing a TLS address over plain http fails to connect,
|
|
30
|
+
* which reads as unreachable, and an unreachable address is reported as *not*
|
|
31
|
+
* dangerous. A public `https://` URL would therefore never raise the warning it
|
|
32
|
+
* exists to raise.
|
|
33
|
+
*/
|
|
34
|
+
export declare function probeProxyGate(host: string, port: number, scheme?: "http" | "https"): Promise<ProxyGateProbe>;
|
|
35
|
+
export declare const proxyExposeCommand: CommandModule<object, ProxyExposeArgs>;
|