@askalf/dario 4.8.118 → 4.8.120
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/dist/health-response.d.ts +19 -1
- package/dist/health-response.js +34 -8
- package/dist/proxy.js +48 -6
- package/package.json +1 -1
|
@@ -41,4 +41,22 @@ export interface PoolDerivedStatus {
|
|
|
41
41
|
accounts: number;
|
|
42
42
|
}
|
|
43
43
|
export declare function derivePoolStatus(accounts: readonly PoolAccountStatusLike[], now: number, adminEnabled: boolean): PoolDerivedStatus;
|
|
44
|
-
export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number,
|
|
44
|
+
export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, includeInternal: boolean): HealthResponse;
|
|
45
|
+
/**
|
|
46
|
+
* Decide whether a /health caller may see the OAuth internals (#642).
|
|
47
|
+
*
|
|
48
|
+
* /health is intentionally auth-free (docker healthchecks need it before a
|
|
49
|
+
* key is configured), so we cannot simply gate on the API key. Trust model:
|
|
50
|
+
* - authenticated (valid DARIO_API_KEY) -> internal (an internal caller)
|
|
51
|
+
* - came via the Cloudflare tunnel (cf-ray) -> public (world-reachable)
|
|
52
|
+
* - otherwise bare loopback -> internal (docker HC / doctor)
|
|
53
|
+
* - otherwise (LAN, other container, WAN) -> public
|
|
54
|
+
* The cf-ray check is now only ever used to DENY (force public), never to
|
|
55
|
+
* grant, so spoofing it cannot widen disclosure — the previous fail-open
|
|
56
|
+
* direction is closed.
|
|
57
|
+
*/
|
|
58
|
+
export declare function shouldDiscloseHealthInternals(opts: {
|
|
59
|
+
authenticated: boolean;
|
|
60
|
+
loopback: boolean;
|
|
61
|
+
viaCfRay: boolean;
|
|
62
|
+
}): boolean;
|
package/dist/health-response.js
CHANGED
|
@@ -59,24 +59,50 @@ export function derivePoolStatus(accounts, now, adminEnabled) {
|
|
|
59
59
|
expiresIn: formatMsLeft(earliest - now),
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
-
export function buildHealthResponse(s, requestCount,
|
|
62
|
+
export function buildHealthResponse(s, requestCount, includeInternal) {
|
|
63
63
|
const dead = s.status === 'broken' ||
|
|
64
64
|
s.status === 'none' ||
|
|
65
65
|
(s.status === 'expired' && s.canRefresh === false);
|
|
66
66
|
const httpStatus = dead ? 503 : 200;
|
|
67
67
|
const liveness = { status: dead ? 'degraded' : 'ok' };
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
// Only trusted callers (authenticated, or bare loopback not via the CF
|
|
69
|
+
// tunnel — see shouldDiscloseHealthInternals) get the OAuth internals.
|
|
70
|
+
// Everyone else (LAN, public tunnel) gets the liveness verdict only; the
|
|
71
|
+
// HTTP status is identical either way so uptime checks still work. #642:
|
|
72
|
+
// this used to key on the presence of the client-suppliable `cf-ray`
|
|
73
|
+
// header, which failed OPEN — a direct non-tunnel caller omits it and got
|
|
74
|
+
// the full internal view. `lastRefreshError` is no longer exposed here at
|
|
75
|
+
// all (it can carry a raw upstream error string); it remains on the
|
|
76
|
+
// key-gated /status.
|
|
77
|
+
const body = includeInternal
|
|
78
|
+
? {
|
|
71
79
|
...liveness,
|
|
72
|
-
// Version for internal callers only — an update-check signal (#640),
|
|
73
|
-
// withheld from the public-tunnel view alongside the OAuth internals.
|
|
74
80
|
...(s.version ? { version: s.version } : {}),
|
|
75
81
|
oauth: s.status,
|
|
76
82
|
expiresIn: s.expiresIn,
|
|
77
83
|
requests: requestCount,
|
|
78
84
|
...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
}
|
|
86
|
+
: liveness;
|
|
81
87
|
return { httpStatus, body };
|
|
82
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Decide whether a /health caller may see the OAuth internals (#642).
|
|
91
|
+
*
|
|
92
|
+
* /health is intentionally auth-free (docker healthchecks need it before a
|
|
93
|
+
* key is configured), so we cannot simply gate on the API key. Trust model:
|
|
94
|
+
* - authenticated (valid DARIO_API_KEY) -> internal (an internal caller)
|
|
95
|
+
* - came via the Cloudflare tunnel (cf-ray) -> public (world-reachable)
|
|
96
|
+
* - otherwise bare loopback -> internal (docker HC / doctor)
|
|
97
|
+
* - otherwise (LAN, other container, WAN) -> public
|
|
98
|
+
* The cf-ray check is now only ever used to DENY (force public), never to
|
|
99
|
+
* grant, so spoofing it cannot widen disclosure — the previous fail-open
|
|
100
|
+
* direction is closed.
|
|
101
|
+
*/
|
|
102
|
+
export function shouldDiscloseHealthInternals(opts) {
|
|
103
|
+
if (opts.authenticated)
|
|
104
|
+
return true;
|
|
105
|
+
if (opts.viaCfRay)
|
|
106
|
+
return false;
|
|
107
|
+
return opts.loopback;
|
|
108
|
+
}
|
package/dist/proxy.js
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import { setDefaultResultOrder } from 'node:dns';
|
|
8
8
|
import { arch, platform } from 'node:process';
|
|
9
9
|
import { getAccessToken, getStatus } from './oauth.js';
|
|
10
|
-
import { buildHealthResponse, derivePoolStatus } from './health-response.js';
|
|
10
|
+
import { buildHealthResponse, derivePoolStatus, shouldDiscloseHealthInternals } from './health-response.js';
|
|
11
11
|
import { darioVersion } from './version.js';
|
|
12
12
|
import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, CC_TEMPLATE } from './cc-template.js';
|
|
13
13
|
import { stampCch } from './cch.js';
|
|
@@ -38,6 +38,18 @@ function isLoopbackHost(host) {
|
|
|
38
38
|
return true;
|
|
39
39
|
return host.startsWith('127.');
|
|
40
40
|
}
|
|
41
|
+
// A socket peer address is loopback if it is 127.0.0.0/8 or ::1, including the
|
|
42
|
+
// IPv4-mapped-IPv6 form Node reports on dual-stack sockets (`::ffff:127.0.0.1`).
|
|
43
|
+
// Distinct from isLoopbackHost (which classifies a bind-address string): this
|
|
44
|
+
// classifies an inbound connection's peer for the /health disclosure gate (#642).
|
|
45
|
+
function isLoopbackAddr(addr) {
|
|
46
|
+
if (!addr)
|
|
47
|
+
return false;
|
|
48
|
+
if (addr === '::1')
|
|
49
|
+
return true;
|
|
50
|
+
const v4 = addr.startsWith('::ffff:') ? addr.slice(7) : addr;
|
|
51
|
+
return v4 === '127.0.0.1' || v4.startsWith('127.');
|
|
52
|
+
}
|
|
41
53
|
// Concurrency control: see src/request-queue.ts for the bounded queue
|
|
42
54
|
// (replaced the v3.30.x-and-earlier simple unbounded semaphore in dario#80).
|
|
43
55
|
// Billing tag hash seed — matches Claude Code's value
|
|
@@ -1169,6 +1181,14 @@ export async function startProxy(opts = {}) {
|
|
|
1169
1181
|
const adminTokenBuf = adminToken ? Buffer.from(adminToken) : null;
|
|
1170
1182
|
if (adminEnabled) {
|
|
1171
1183
|
console.log(`[dario] admin API enabled at /admin/* (token ${adminTokenBuf ? 'configured' : 'MISSING — endpoints return 403 until DARIO_ADMIN_TOKEN is set'})`);
|
|
1184
|
+
// Hardening (#642): the admin API adds/removes OAuth accounts. When it
|
|
1185
|
+
// shares DARIO_API_KEY (which is embedded in every client config) instead
|
|
1186
|
+
// of a distinct DARIO_ADMIN_TOKEN, every client that can proxy can also
|
|
1187
|
+
// control the account pool. Non-fatal for back-compat, but warn loudly.
|
|
1188
|
+
if (adminTokenBuf && !process.env.DARIO_ADMIN_TOKEN) {
|
|
1189
|
+
console.warn('[dario] WARNING: admin API is using DARIO_API_KEY as its token (no DARIO_ADMIN_TOKEN set).');
|
|
1190
|
+
console.warn('[dario] every client holding the proxy key can add/remove accounts. Set a distinct DARIO_ADMIN_TOKEN.');
|
|
1191
|
+
}
|
|
1172
1192
|
}
|
|
1173
1193
|
// Admin rate limiting (#620). Two token buckets, created once per proxy:
|
|
1174
1194
|
// failed auth (brute-force / broken-client throttle) and mutations. Global,
|
|
@@ -1269,10 +1289,16 @@ export async function startProxy(opts = {}) {
|
|
|
1269
1289
|
// react instead of cheerfully passing while every /v1/messages 401s.
|
|
1270
1290
|
if (urlPath === '/health' || urlPath === '/') {
|
|
1271
1291
|
const s = await currentStatus();
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
|
|
1292
|
+
// Disclose OAuth internals only to trusted callers (#642). This used to
|
|
1293
|
+
// key on the presence of the client-suppliable `cf-ray` header and failed
|
|
1294
|
+
// OPEN — a direct non-tunnel caller omits it and got the full internal
|
|
1295
|
+
// view. Now: authenticated, OR bare loopback that did not arrive via CF.
|
|
1296
|
+
const includeInternal = shouldDiscloseHealthInternals({
|
|
1297
|
+
authenticated: authenticateRequest(req.headers, apiKeyBuf),
|
|
1298
|
+
loopback: isLoopbackAddr(req.socket?.remoteAddress),
|
|
1299
|
+
viaCfRay: req.headers['cf-ray'] !== undefined,
|
|
1300
|
+
});
|
|
1301
|
+
const { httpStatus, body } = buildHealthResponse({ ...s, version: darioVersion() }, requestCount, includeInternal);
|
|
1276
1302
|
res.writeHead(httpStatus, JSON_HEADERS);
|
|
1277
1303
|
res.end(JSON.stringify(body));
|
|
1278
1304
|
return;
|
|
@@ -3173,7 +3199,17 @@ export async function startProxy(opts = {}) {
|
|
|
3173
3199
|
return;
|
|
3174
3200
|
lastPresencePulse = now;
|
|
3175
3201
|
try {
|
|
3176
|
-
|
|
3202
|
+
// In pool mode the pool refresh loop (above) is the SOLE refresher of
|
|
3203
|
+
// every account's token lineage. credentials.json shares its refresh-
|
|
3204
|
+
// token family with accounts/login.json after a login->pool migration
|
|
3205
|
+
// (ensureLoginCredentialsInPool), so refreshing credentials.json here
|
|
3206
|
+
// via getAccessToken() races the pool refreshing login.json and trips
|
|
3207
|
+
// Anthropic's refresh-token reuse-detection (the 2026-06-23 fleet
|
|
3208
|
+
// outage, dario#641-audit). Pulse with a token the pool already keeps
|
|
3209
|
+
// fresh; never refresh from this side in pool mode.
|
|
3210
|
+
const token = pool ? (pool.all()[0]?.accessToken ?? '') : await getAccessToken();
|
|
3211
|
+
if (!token)
|
|
3212
|
+
return;
|
|
3177
3213
|
const presenceUrl = `${ANTHROPIC_API}/v1/code/sessions/${SESSION_ID}/client/presence`;
|
|
3178
3214
|
await fetch(presenceUrl, {
|
|
3179
3215
|
method: 'POST',
|
|
@@ -3191,6 +3227,12 @@ export async function startProxy(opts = {}) {
|
|
|
3191
3227
|
}, 5000);
|
|
3192
3228
|
// Periodic token refresh (every 15 minutes)
|
|
3193
3229
|
const refreshInterval = setInterval(async () => {
|
|
3230
|
+
// Pool mode: the pool's own 15-min refresh loop (above) owns token refresh
|
|
3231
|
+
// for every account. Refreshing credentials.json here too would double-
|
|
3232
|
+
// refresh a shared token lineage (see the presence-loop note) -> reuse-
|
|
3233
|
+
// detection. Skip; the pool is the sole refresher.
|
|
3234
|
+
if (pool)
|
|
3235
|
+
return;
|
|
3194
3236
|
try {
|
|
3195
3237
|
const s = await getStatus();
|
|
3196
3238
|
if (s.status === 'expiring' || s.status === 'expired') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.120",
|
|
4
4
|
"description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|