@askalf/dario 6.12.30 → 6.13.1
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 +2 -2
- package/dist/cli.js +32 -2
- package/dist/keys.d.ts +22 -0
- package/dist/keys.js +21 -0
- package/dist/live-fingerprint.d.ts +1 -1
- package/dist/live-fingerprint.js +1 -1
- package/dist/proxy.d.ts +27 -0
- package/dist/proxy.js +169 -8
- package/docs/commands.md +1 -0
- package/docs/configuration.md +1 -0
- package/docs/keys.md +33 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
<p><strong>One local endpoint. Every AI tool you own. The subscriptions you already pay for.</strong></p>
|
|
30
30
|
|
|
31
|
-
<sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~
|
|
31
|
+
<sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~41k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
|
|
32
32
|
|
|
33
33
|
<sub><a href="#start-in-60-seconds">Start</a> · <a href="#why-people-pick-it">Why dario</a> · <a href="#what-it-does-with-a-request">Routing</a> · <a href="#will-my-account-get-suspended">Risk</a> · <a href="#reference">Reference</a> · <a href="#trust--transparency">Trust</a> · <a href="docs/returning.md">Coming back after a while?</a></sub>
|
|
34
34
|
|
|
@@ -115,7 +115,7 @@ dario runs entirely on your machine, authenticates as you with your own Claude l
|
|
|
115
115
|
|
|
116
116
|
| Signal | Status |
|
|
117
117
|
|---|---|
|
|
118
|
-
| Source | **~
|
|
118
|
+
| Source | **~41k** lines of TypeScript across **79** files, auditable in a weekend. One credential path since v5: the pool. |
|
|
119
119
|
| Dependencies | **0 runtime.** Verify: `npm ls --production` |
|
|
120
120
|
| Provenance | Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions + Sigstore, published with OIDC trusted publishing — no long-lived npm token exists to leak |
|
|
121
121
|
| Scanning | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) on every push to master, every PR and weekly · [ClusterFuzzLite](./.github/workflows/cflite.yml) fuzzes the SSE translator, the rejection parsers and the cch stamper weekly · [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/askalf/dario) and [Best Practices](https://www.bestpractices.dev/projects/13638) badges above are live |
|
package/dist/cli.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { unlink, writeFile } from 'node:fs/promises';
|
|
20
20
|
import { formatLedgerSummary, formatLedgerConsumers, formatUsd, renderLedgerCard, readLedgerFile, resolveLedgerPath, summarizeLedger } from './ledger.js';
|
|
21
21
|
import { renderSpendDonuts } from './donuts.js';
|
|
22
|
-
import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE, setKeyBudget, parseUsdBudget, parseTokenBudget, formatBudget } from './keys.js';
|
|
22
|
+
import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE, parseKeySocketSpec, setKeyBudget, parseUsdBudget, parseTokenBudget, formatBudget } from './keys.js';
|
|
23
23
|
/**
|
|
24
24
|
* `--budget=$5/day` / `--budget-tokens=2M/day` → a KeyBudget, or undefined when
|
|
25
25
|
* neither flag is present. A flag that does not parse exits 1 with the accepted
|
|
@@ -729,6 +729,29 @@ async function proxy() {
|
|
|
729
729
|
|| ['0', 'false', 'no', 'off'].includes((process.env['DARIO_KEYS'] ?? '').toLowerCase()));
|
|
730
730
|
const keysPathArg = args.find(a => a.startsWith('--keys-path='));
|
|
731
731
|
const keysPath = keysPathArg ? keysPathArg.slice('--keys-path='.length) : undefined;
|
|
732
|
+
// --key-socket=<path>=<key> (repeatable) / DARIO_KEY_SOCKETS (comma-separated)
|
|
733
|
+
// Each unix socket is bound to one named key; see ProxyOptions.keySockets.
|
|
734
|
+
const keySocketSpecs = [
|
|
735
|
+
...args.filter(a => a.startsWith('--key-socket=')).map(a => a.slice('--key-socket='.length)),
|
|
736
|
+
...(process.env['DARIO_KEY_SOCKETS'] ?? '').split(',').map(s => s.trim()).filter(Boolean),
|
|
737
|
+
];
|
|
738
|
+
const keySockets = [];
|
|
739
|
+
for (const spec of keySocketSpecs) {
|
|
740
|
+
const ks = parseKeySocketSpec(spec);
|
|
741
|
+
if (!ks) {
|
|
742
|
+
console.error(`[dario] --key-socket: "${spec}" is not <absolute socket path>=<key name>`);
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
if (keySockets.some(k => k.path === ks.path)) {
|
|
746
|
+
console.error(`[dario] --key-socket: ${ks.path} is given twice`);
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
keySockets.push(ks);
|
|
750
|
+
}
|
|
751
|
+
if (keySockets.length > 0 && !keys) {
|
|
752
|
+
console.error('[dario] --key-socket binds a named key, but named keys are off (--no-keys / DARIO_KEYS=0).');
|
|
753
|
+
process.exit(1);
|
|
754
|
+
}
|
|
732
755
|
// --preserve-output-format — carry the client body's `output_config.format`
|
|
733
756
|
// (structured-output JSON schema) through to upstream instead of dropping it
|
|
734
757
|
// during the CC rebuild. See ProxyOptions.preserveOutputFormat for rationale.
|
|
@@ -753,7 +776,7 @@ async function proxy() {
|
|
|
753
776
|
console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
|
|
754
777
|
process.exit(1);
|
|
755
778
|
}
|
|
756
|
-
await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, shutdownGraceMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath });
|
|
779
|
+
await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, shutdownGraceMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath, keySockets: keySockets.length > 0 ? keySockets : undefined });
|
|
757
780
|
}
|
|
758
781
|
/**
|
|
759
782
|
* `dario keys` — named keys for a shared dario (v6.8, dario#1318). One
|
|
@@ -2120,6 +2143,13 @@ async function help() {
|
|
|
2120
2143
|
DARIO_API_KEY authenticates. Env: DARIO_KEYS=0;
|
|
2121
2144
|
--keys-path=<file> / DARIO_KEYS_PATH moves the
|
|
2122
2145
|
file. See \`dario keys\`. (v6.8)
|
|
2146
|
+
--key-socket=<path>=<key>
|
|
2147
|
+
Also listen on a unix socket bound to one
|
|
2148
|
+
named key: every request on it is that
|
|
2149
|
+
key's, with no secret sent or stored by the
|
|
2150
|
+
caller. Who may connect is the socket's
|
|
2151
|
+
directory and group (it is created 0660).
|
|
2152
|
+
Repeatable. Env: DARIO_KEY_SOCKETS=<spec>,..
|
|
2123
2153
|
--session-idle-rotate=MS Idle ms before an account's session id
|
|
2124
2154
|
rotates (default: 900000 = 15 min).
|
|
2125
2155
|
Real CC rotates once per conversation, not
|
package/dist/keys.d.ts
CHANGED
|
@@ -207,6 +207,22 @@ export interface KeyPublic {
|
|
|
207
207
|
export declare function publicKey(k: KeyRecord, now?: number): KeyPublic;
|
|
208
208
|
/** `--expires=30d` / `12h` / `2026-12-31` → epoch ms, or null when unparseable. */
|
|
209
209
|
export declare function parseExpiry(value: string, now?: number): number | null;
|
|
210
|
+
/**
|
|
211
|
+
* A unix socket bound to one named key: a request that arrives on it IS that
|
|
212
|
+
* key's, with no secret on the wire and none on the caller's disk. Who may
|
|
213
|
+
* connect is decided by the filesystem (the socket's directory and mode), so
|
|
214
|
+
* a caller that must not hold a credential, such as a CI job running code it
|
|
215
|
+
* did not write, never has one to leak. A header on the socket names no
|
|
216
|
+
* other key: the socket decides.
|
|
217
|
+
*/
|
|
218
|
+
export interface KeySocket {
|
|
219
|
+
/** Absolute path of the socket file. */
|
|
220
|
+
path: string;
|
|
221
|
+
/** The named key every request on it is. */
|
|
222
|
+
key: string;
|
|
223
|
+
}
|
|
224
|
+
/** `<absolute path>=<key name>` (`--key-socket`, `DARIO_KEY_SOCKETS`), or null when malformed. */
|
|
225
|
+
export declare function parseKeySocketSpec(spec: string): KeySocket | null;
|
|
210
226
|
/**
|
|
211
227
|
* The proxy's live view of the file. Reloads when the file's mtime moves (a
|
|
212
228
|
* stat per auth — the cost of "no restart"), records last-used with a
|
|
@@ -228,6 +244,12 @@ export declare class KeyStore {
|
|
|
228
244
|
list(now?: number): KeyPublic[];
|
|
229
245
|
/** The record a request's credential names, or null. Reloads first when the file moved. */
|
|
230
246
|
match(provided: string | undefined, now?: number): KeyRecord | null;
|
|
247
|
+
/**
|
|
248
|
+
* The usable record named `name`, or null (unknown, revoked or expired).
|
|
249
|
+
* For a key socket, where the connection names the key and no secret is
|
|
250
|
+
* presented. Reloads first when the file moved.
|
|
251
|
+
*/
|
|
252
|
+
byName(name: string, now?: number): KeyRecord | null;
|
|
231
253
|
/** Note a use; written to disk after a quiet moment. */
|
|
232
254
|
touch(k: KeyRecord, now?: number): void;
|
|
233
255
|
/** Apply a mutation to the file on disk (re-read first), then adopt it. */
|
package/dist/keys.js
CHANGED
|
@@ -386,6 +386,17 @@ export function parseExpiry(value, now = Date.now()) {
|
|
|
386
386
|
const abs = Date.parse(v);
|
|
387
387
|
return Number.isNaN(abs) ? null : abs;
|
|
388
388
|
}
|
|
389
|
+
/** `<absolute path>=<key name>` (`--key-socket`, `DARIO_KEY_SOCKETS`), or null when malformed. */
|
|
390
|
+
export function parseKeySocketSpec(spec) {
|
|
391
|
+
const i = spec.lastIndexOf('=');
|
|
392
|
+
if (i <= 0)
|
|
393
|
+
return null;
|
|
394
|
+
const path = spec.slice(0, i);
|
|
395
|
+
const key = spec.slice(i + 1);
|
|
396
|
+
if (!path.startsWith('/') || path.includes('\0') || !KEY_NAME_RE.test(key))
|
|
397
|
+
return null;
|
|
398
|
+
return { path, key };
|
|
399
|
+
}
|
|
389
400
|
/**
|
|
390
401
|
* The proxy's live view of the file. Reloads when the file's mtime moves (a
|
|
391
402
|
* stat per auth — the cost of "no restart"), records last-used with a
|
|
@@ -435,6 +446,16 @@ export class KeyStore {
|
|
|
435
446
|
return null;
|
|
436
447
|
return matchKey(this.file, provided, now);
|
|
437
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* The usable record named `name`, or null (unknown, revoked or expired).
|
|
451
|
+
* For a key socket, where the connection names the key and no secret is
|
|
452
|
+
* presented. Reloads first when the file moved.
|
|
453
|
+
*/
|
|
454
|
+
byName(name, now = Date.now()) {
|
|
455
|
+
this.load();
|
|
456
|
+
const k = this.file.keys.find((r) => r.name === name);
|
|
457
|
+
return k && keyIsUsable(k, now) ? k : null;
|
|
458
|
+
}
|
|
438
459
|
/** Note a use; written to disk after a quiet moment. */
|
|
439
460
|
touch(k, now = Date.now()) {
|
|
440
461
|
const iso = new Date(now).toISOString();
|
|
@@ -510,7 +510,7 @@ export declare function detectDrift(t: TemplateData, installedOverride?: string
|
|
|
510
510
|
*/
|
|
511
511
|
export declare const SUPPORTED_CC_RANGE: {
|
|
512
512
|
readonly min: "1.0.0";
|
|
513
|
-
readonly maxTested: "2.1.
|
|
513
|
+
readonly maxTested: "2.1.292";
|
|
514
514
|
};
|
|
515
515
|
/**
|
|
516
516
|
* Compare two dotted-numeric version strings. Returns negative if `a<b`,
|
package/dist/live-fingerprint.js
CHANGED
|
@@ -1211,7 +1211,7 @@ export function detectDrift(t, installedOverride) {
|
|
|
1211
1211
|
*/
|
|
1212
1212
|
export const SUPPORTED_CC_RANGE = {
|
|
1213
1213
|
min: '1.0.0',
|
|
1214
|
-
maxTested: '2.1.
|
|
1214
|
+
maxTested: '2.1.292',
|
|
1215
1215
|
};
|
|
1216
1216
|
/**
|
|
1217
1217
|
* Compare two dotted-numeric version strings. Returns negative if `a<b`,
|
package/dist/proxy.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type IncomingMessage } from 'node:http';
|
|
|
2
2
|
import { type WriteStream } from 'node:fs';
|
|
3
3
|
import { getAccessToken, getStatus } from './oauth.js';
|
|
4
4
|
import { type EffortValue } from './cc-template.js';
|
|
5
|
+
import { type KeySocket } from './keys.js';
|
|
5
6
|
/**
|
|
6
7
|
* Assemble the `x-anthropic-billing-header` system-block text, following
|
|
7
8
|
* Claude Code's format:
|
|
@@ -398,6 +399,13 @@ interface ProxyOptions {
|
|
|
398
399
|
*/
|
|
399
400
|
keys?: boolean;
|
|
400
401
|
keysPath?: string;
|
|
402
|
+
/**
|
|
403
|
+
* Unix sockets bound to named keys (`--key-socket=<path>=<key>`). Every
|
|
404
|
+
* request on one is that key's, whatever its headers say, so a caller
|
|
405
|
+
* reaches dario with no secret at all and the socket's directory and mode
|
|
406
|
+
* decide who may (keys.ts, KeySocket). Needs named keys on. POSIX only.
|
|
407
|
+
*/
|
|
408
|
+
keySockets?: KeySocket[];
|
|
401
409
|
sessionIdleRotateMs?: number;
|
|
402
410
|
sessionRotateJitterMs?: number;
|
|
403
411
|
sessionMaxAgeMs?: number;
|
|
@@ -761,5 +769,24 @@ export declare function resolveSingleAccountStartupStatus(deps?: {
|
|
|
761
769
|
getStatus: typeof getStatus;
|
|
762
770
|
getAccessToken: typeof getAccessToken;
|
|
763
771
|
}): Promise<Awaited<ReturnType<typeof getStatus>>>;
|
|
772
|
+
/**
|
|
773
|
+
* Take `<path>.lock`, held from the stale-socket probe through the bind and
|
|
774
|
+
* released after either. Two starts that both found the same stale socket
|
|
775
|
+
* would otherwise both remove it, the second removing the first's fresh
|
|
776
|
+
* socket. Created with O_EXCL, so one start wins and the other fails. An
|
|
777
|
+
* existing lock is never removed here, however old: removing one is itself a
|
|
778
|
+
* race between two starts, so a lock left by a start that crashed while
|
|
779
|
+
* binding is the operator's to delete. Returns the release.
|
|
780
|
+
*/
|
|
781
|
+
export declare function acquireSocketLock(path: string): () => void;
|
|
782
|
+
/**
|
|
783
|
+
* Clear a key socket's path for a new bind. Call it holding acquireSocketLock. A socket nothing answers on is
|
|
784
|
+
* stale (a crashed or killed run) and is removed. One that accepts, or that
|
|
785
|
+
* neither accepts nor refuses within `timeoutMs`, belongs to a running
|
|
786
|
+
* listener: removing it would cut that listener off from every new client,
|
|
787
|
+
* so this throws instead. Any other file is left for the bind to refuse; an
|
|
788
|
+
* unexpected filesystem error is thrown as is.
|
|
789
|
+
*/
|
|
790
|
+
export declare function clearStaleSocket(path: string, timeoutMs?: number): Promise<void>;
|
|
764
791
|
export declare function startProxy(opts?: ProxyOptions): Promise<void>;
|
|
765
792
|
export {};
|
package/dist/proxy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomUUID, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
|
3
|
-
import { readFileSync, readdirSync, createWriteStream } from 'node:fs';
|
|
3
|
+
import { readFileSync, readdirSync, createWriteStream, lstatSync, unlinkSync, chmodSync, openSync, writeSync, closeSync } from 'node:fs';
|
|
4
|
+
import { createServer as createNetServer, connect as netConnect } from 'node:net';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { homedir } from 'node:os';
|
|
6
7
|
import { setDefaultResultOrder } from 'node:dns';
|
|
@@ -1230,6 +1231,73 @@ export async function resolveSingleAccountStartupStatus(deps = {
|
|
|
1230
1231
|
}
|
|
1231
1232
|
return status;
|
|
1232
1233
|
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Take `<path>.lock`, held from the stale-socket probe through the bind and
|
|
1236
|
+
* released after either. Two starts that both found the same stale socket
|
|
1237
|
+
* would otherwise both remove it, the second removing the first's fresh
|
|
1238
|
+
* socket. Created with O_EXCL, so one start wins and the other fails. An
|
|
1239
|
+
* existing lock is never removed here, however old: removing one is itself a
|
|
1240
|
+
* race between two starts, so a lock left by a start that crashed while
|
|
1241
|
+
* binding is the operator's to delete. Returns the release.
|
|
1242
|
+
*/
|
|
1243
|
+
export function acquireSocketLock(path) {
|
|
1244
|
+
const lock = `${path}.lock`;
|
|
1245
|
+
let fd;
|
|
1246
|
+
try {
|
|
1247
|
+
fd = openSync(lock, 'wx', 0o600);
|
|
1248
|
+
}
|
|
1249
|
+
catch (err) {
|
|
1250
|
+
if (err.code !== 'EEXIST')
|
|
1251
|
+
throw err;
|
|
1252
|
+
throw new Error(`${lock} exists: another start is binding this socket, or one crashed while binding it; if no dario is starting, remove it`);
|
|
1253
|
+
}
|
|
1254
|
+
try {
|
|
1255
|
+
writeSync(fd, `${process.pid}\n`);
|
|
1256
|
+
}
|
|
1257
|
+
finally {
|
|
1258
|
+
closeSync(fd);
|
|
1259
|
+
}
|
|
1260
|
+
return () => { try {
|
|
1261
|
+
unlinkSync(lock);
|
|
1262
|
+
}
|
|
1263
|
+
catch { /* already gone */ } };
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Clear a key socket's path for a new bind. Call it holding acquireSocketLock. A socket nothing answers on is
|
|
1267
|
+
* stale (a crashed or killed run) and is removed. One that accepts, or that
|
|
1268
|
+
* neither accepts nor refuses within `timeoutMs`, belongs to a running
|
|
1269
|
+
* listener: removing it would cut that listener off from every new client,
|
|
1270
|
+
* so this throws instead. Any other file is left for the bind to refuse; an
|
|
1271
|
+
* unexpected filesystem error is thrown as is.
|
|
1272
|
+
*/
|
|
1273
|
+
export async function clearStaleSocket(path, timeoutMs = 1000) {
|
|
1274
|
+
let isSocket;
|
|
1275
|
+
try {
|
|
1276
|
+
isSocket = lstatSync(path).isSocket();
|
|
1277
|
+
}
|
|
1278
|
+
catch (err) {
|
|
1279
|
+
if (err.code === 'ENOENT')
|
|
1280
|
+
return;
|
|
1281
|
+
throw err;
|
|
1282
|
+
}
|
|
1283
|
+
if (!isSocket)
|
|
1284
|
+
return;
|
|
1285
|
+
const live = await new Promise((resolve, reject) => {
|
|
1286
|
+
const c = netConnect(path);
|
|
1287
|
+
const timer = setTimeout(() => { c.destroy(); resolve(true); }, timeoutMs);
|
|
1288
|
+
c.once('connect', () => { clearTimeout(timer); c.destroy(); resolve(true); });
|
|
1289
|
+
c.once('error', (err) => {
|
|
1290
|
+
clearTimeout(timer);
|
|
1291
|
+
if (err.code === 'ECONNREFUSED')
|
|
1292
|
+
resolve(false);
|
|
1293
|
+
else
|
|
1294
|
+
reject(err);
|
|
1295
|
+
});
|
|
1296
|
+
});
|
|
1297
|
+
if (live)
|
|
1298
|
+
throw new Error(`${path} is in use by a running listener (another dario?)`);
|
|
1299
|
+
unlinkSync(path);
|
|
1300
|
+
}
|
|
1233
1301
|
export async function startProxy(opts = {}) {
|
|
1234
1302
|
const port = opts.port ?? DEFAULT_PORT;
|
|
1235
1303
|
const host = opts.host ?? process.env.DARIO_HOST ?? DEFAULT_HOST;
|
|
@@ -2255,6 +2323,8 @@ export async function startProxy(opts = {}) {
|
|
|
2255
2323
|
const ERR_UNAUTH = JSON.stringify({ error: 'Unauthorized', message: 'Invalid or missing API key' });
|
|
2256
2324
|
const ERR_FORBIDDEN = JSON.stringify({ error: 'Forbidden', message: 'Path not allowed. Supported paths: POST /v1/messages, POST /v1/messages/count_tokens, POST /v1/chat/completions, GET /v1/models' });
|
|
2257
2325
|
const ERR_METHOD = JSON.stringify({ error: 'Method not allowed' });
|
|
2326
|
+
/** The named key each key-socket connection is (opts.keySockets); absent for TCP. */
|
|
2327
|
+
const keySocketOf = new WeakMap();
|
|
2258
2328
|
/**
|
|
2259
2329
|
* Who is asking. A named key wins when it matches, and is then the
|
|
2260
2330
|
* request's consumer; otherwise the root key decides as it always has,
|
|
@@ -2262,6 +2332,15 @@ export async function startProxy(opts = {}) {
|
|
|
2262
2332
|
* A revoked or expired named key is indistinguishable from a wrong one.
|
|
2263
2333
|
*/
|
|
2264
2334
|
function resolveRequestAuth(req) {
|
|
2335
|
+
// A key socket names the key; no header can name another, or drop to the root key.
|
|
2336
|
+
const bound = keySocketOf.get(req.socket);
|
|
2337
|
+
if (bound !== undefined) {
|
|
2338
|
+
const key = keyStore ? keyStore.byName(bound) : null;
|
|
2339
|
+
if (!key)
|
|
2340
|
+
return { ok: false, key: null };
|
|
2341
|
+
keyStore.touch(key);
|
|
2342
|
+
return { ok: true, key };
|
|
2343
|
+
}
|
|
2265
2344
|
const provided = req.headers['x-api-key']
|
|
2266
2345
|
|| req.headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
2267
2346
|
const key = keyStore ? keyStore.match(provided) : null;
|
|
@@ -2483,13 +2562,35 @@ export async function startProxy(opts = {}) {
|
|
|
2483
2562
|
// The request's first stamp (src/timing.ts): every split below is
|
|
2484
2563
|
// measured from here, before any parsing, auth or queueing.
|
|
2485
2564
|
const arrivedAt = Date.now();
|
|
2565
|
+
// Strip query parameters for endpoint matching
|
|
2566
|
+
const urlPath = req.url?.split('?')[0] ?? '';
|
|
2567
|
+
// A key socket's request is its bound key's or it is refused, on every
|
|
2568
|
+
// route: no header (root key, analytics token, admin token) grants a
|
|
2569
|
+
// socket caller anything its key does not. An unusable bound key is a 401
|
|
2570
|
+
// here, before any route can answer; the admin API and seat pins, which
|
|
2571
|
+
// authenticate by their own token, are not served on a key socket.
|
|
2572
|
+
const socketKey = keySocketOf.get(req.socket);
|
|
2573
|
+
if (socketKey !== undefined) {
|
|
2574
|
+
if (!keyStore?.byName(socketKey)) {
|
|
2575
|
+
if (verbose)
|
|
2576
|
+
console.error(`[dario] 401 rejected (key socket: named key "${socketKey}" unknown, revoked or expired): ${req.method} ${urlPath}`);
|
|
2577
|
+
res.writeHead(401, JSON_HEADERS);
|
|
2578
|
+
res.end(ERR_UNAUTH);
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
if (urlPath.startsWith('/admin/') || req.headers[SEAT_PIN_HEADER] !== undefined) {
|
|
2582
|
+
res.writeHead(403, JSON_HEADERS);
|
|
2583
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'permission_error', message: 'the admin API and seat pins are not served on a key socket' } }));
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
// CORS preflight, after the key-socket gate so a socket's unusable key
|
|
2588
|
+
// refuses it like any other request.
|
|
2486
2589
|
if (req.method === 'OPTIONS') {
|
|
2487
2590
|
res.writeHead(204, CORS_HEADERS);
|
|
2488
2591
|
res.end();
|
|
2489
2592
|
return;
|
|
2490
2593
|
}
|
|
2491
|
-
// Strip query parameters for endpoint matching
|
|
2492
|
-
const urlPath = req.url?.split('?')[0] ?? '';
|
|
2493
2594
|
// Liveness probe — always 200 while the HTTP server is accepting requests,
|
|
2494
2595
|
// deliberately decoupled from OAuth state. Docker's healthcheck (and the
|
|
2495
2596
|
// autoheal watchdog that keys on it) points HERE, not /health: a broken or
|
|
@@ -2518,7 +2619,8 @@ export async function startProxy(opts = {}) {
|
|
|
2518
2619
|
// view. Now: authenticated, OR bare loopback that did not arrive via CF.
|
|
2519
2620
|
const viaCfRay = req.headers['cf-ray'] !== undefined;
|
|
2520
2621
|
const includeInternal = shouldDiscloseHealthInternals({
|
|
2521
|
-
|
|
2622
|
+
// On a key socket the caller is its named key, never the root key.
|
|
2623
|
+
authenticated: socketKey === undefined && authenticateRequest(req.headers, apiKeyBuf),
|
|
2522
2624
|
// Without this, `authenticated` is vacuously true on an unkeyed proxy
|
|
2523
2625
|
// and the tunnel check below is never reached — see the gate's docs.
|
|
2524
2626
|
keyConfigured: apiKeyBuf !== null,
|
|
@@ -2699,10 +2801,12 @@ export async function startProxy(opts = {}) {
|
|
|
2699
2801
|
res.end(ANALYTICS_UI_SHELL);
|
|
2700
2802
|
return;
|
|
2701
2803
|
}
|
|
2702
|
-
// Read-only analytics token: accepted on the read-only surfaces only
|
|
2703
|
-
//
|
|
2804
|
+
// Read-only analytics token: accepted on the read-only surfaces only, and
|
|
2805
|
+
// only over TCP: on a key socket the bound key decides, so no header can
|
|
2806
|
+
// stand in for one that is unusable. Anything else falls through to the
|
|
2807
|
+
// normal request auth below.
|
|
2704
2808
|
const analyticsRead = isAnalyticsReadPath(urlPath) && req.method === 'GET';
|
|
2705
|
-
const requestAuth = (analyticsRead && analyticsTokenBuf && authenticateRequest(req.headers, analyticsTokenBuf))
|
|
2809
|
+
const requestAuth = (analyticsRead && analyticsTokenBuf && !keySocketOf.has(req.socket) && authenticateRequest(req.headers, analyticsTokenBuf))
|
|
2706
2810
|
? { ok: true, key: null }
|
|
2707
2811
|
: resolveRequestAuth(req);
|
|
2708
2812
|
if (!requestAuth.ok) {
|
|
@@ -2713,7 +2817,10 @@ export async function startProxy(opts = {}) {
|
|
|
2713
2817
|
// `dk_` value that did not match is a named key that is unknown,
|
|
2714
2818
|
// revoked or expired — the three are one case on purpose.
|
|
2715
2819
|
const provided = req.headers['x-api-key'] || req.headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
2716
|
-
|
|
2820
|
+
const boundKey = keySocketOf.get(req.socket);
|
|
2821
|
+
console.error(boundKey !== undefined
|
|
2822
|
+
? `[dario] #${requestCount} 401 rejected (key socket: named key "${boundKey}" unknown, revoked or expired)`
|
|
2823
|
+
: `[dario] #${requestCount} 401 rejected (${looksLikeNamedKey(provided) ? 'named key unknown, revoked or expired' : 'DARIO_API_KEY mismatch'}): ${describeAuthReject(req.headers)}`);
|
|
2717
2824
|
}
|
|
2718
2825
|
writeLogLine(logFileStream, {
|
|
2719
2826
|
ts: new Date().toISOString(), req: requestCount,
|
|
@@ -5980,6 +6087,58 @@ export async function startProxy(opts = {}) {
|
|
|
5980
6087
|
else {
|
|
5981
6088
|
console.log('[dario] --no-live-capture: background live fingerprint refresh skipped; using bundled template.');
|
|
5982
6089
|
}
|
|
6090
|
+
// Key sockets: each connection is handed to the same HTTP server, tagged with
|
|
6091
|
+
// its key, so routing, limits, timeouts and the shutdown drain are the TCP
|
|
6092
|
+
// listener's own. A stale socket file from a crashed run is replaced; a
|
|
6093
|
+
// live one, or any other file at the path, is left alone and the start fails.
|
|
6094
|
+
const keySocketServers = [];
|
|
6095
|
+
for (const ks of opts.keySockets ?? []) {
|
|
6096
|
+
if (process.platform === 'win32')
|
|
6097
|
+
throw new Error('--key-socket needs a POSIX host (a unix socket)');
|
|
6098
|
+
if (!keyStore)
|
|
6099
|
+
throw new Error(`--key-socket ${ks.path}: named keys are off (--no-keys / DARIO_KEYS=0), so no key can be bound`);
|
|
6100
|
+
const store = keyStore;
|
|
6101
|
+
const ns = createNetServer((sock) => { keySocketOf.set(sock, ks.key); server.emit('connection', sock); });
|
|
6102
|
+
// Probe, removal and bind under one lock, so a concurrent start cannot
|
|
6103
|
+
// remove the socket this one just bound.
|
|
6104
|
+
let release = null;
|
|
6105
|
+
try {
|
|
6106
|
+
release = acquireSocketLock(ks.path);
|
|
6107
|
+
await clearStaleSocket(ks.path);
|
|
6108
|
+
await new Promise((resolve, reject) => {
|
|
6109
|
+
ns.once('error', reject);
|
|
6110
|
+
// Born 0660 (umask around the synchronous bind), not chmod'd after: no
|
|
6111
|
+
// moment where the socket is wider than it ends up.
|
|
6112
|
+
const umask = process.umask(0o117);
|
|
6113
|
+
try {
|
|
6114
|
+
ns.listen(ks.path, () => { ns.off('error', reject); resolve(); });
|
|
6115
|
+
}
|
|
6116
|
+
finally {
|
|
6117
|
+
process.umask(umask);
|
|
6118
|
+
}
|
|
6119
|
+
});
|
|
6120
|
+
}
|
|
6121
|
+
catch (err) {
|
|
6122
|
+
ns.close();
|
|
6123
|
+
throw new Error(`--key-socket ${ks.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
6124
|
+
}
|
|
6125
|
+
finally {
|
|
6126
|
+
release?.();
|
|
6127
|
+
}
|
|
6128
|
+
ns.on('error', (err) => {
|
|
6129
|
+
console.error(`[dario] key socket ${ks.path}: ${err.code ?? err.message}`);
|
|
6130
|
+
});
|
|
6131
|
+
// Owner and group read/write, nobody else: with the directory's own mode, the filesystem is the credential.
|
|
6132
|
+
// Kept for a platform that ignores umask on bind.
|
|
6133
|
+
try {
|
|
6134
|
+
chmodSync(ks.path, 0o660);
|
|
6135
|
+
}
|
|
6136
|
+
catch (err) {
|
|
6137
|
+
console.error(`[dario] key socket ${ks.path}: chmod failed (${err instanceof Error ? err.message : err})`);
|
|
6138
|
+
}
|
|
6139
|
+
console.log(`[dario] key socket: ${ks.path} → named key "${ks.key}"${store.byName(ks.key) ? '' : ' (no usable key of that name yet; its requests are refused until one exists)'}`);
|
|
6140
|
+
keySocketServers.push(ns);
|
|
6141
|
+
}
|
|
5983
6142
|
server.listen(port, host, () => {
|
|
5984
6143
|
const modeLine = passthrough
|
|
5985
6144
|
? 'Mode: passthrough (OAuth swap only, no injection)'
|
|
@@ -6114,6 +6273,8 @@ export async function startProxy(opts = {}) {
|
|
|
6114
6273
|
// force-exit guard sits past the grace so a stream that never ends cannot
|
|
6115
6274
|
// wedge shutdown, and a hung fsync cannot either.
|
|
6116
6275
|
server.close();
|
|
6276
|
+
for (const ns of keySocketServers)
|
|
6277
|
+
ns.close();
|
|
6117
6278
|
const { graceMs, forceExitMs } = shutdownTimers(opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
|
|
6118
6279
|
void drainThenClose(() => waitForIdle(() => queue.snapshot().active, { graceMs }), {
|
|
6119
6280
|
before: [flushPoolTokens, () => ledger?.flush()],
|
package/docs/commands.md
CHANGED
|
@@ -39,6 +39,7 @@ This page is the per-flag reference. For environment variables grouped by task
|
|
|
39
39
|
| `--host=<addr>` / `DARIO_HOST` | Bind address. Use `0.0.0.0` for LAN, or a specific IP (e.g. a Tailscale interface). When non-loopback, also set `DARIO_API_KEY`. | `127.0.0.1` |
|
|
40
40
|
| `--verbose` / `-v` | Log every request (one line per request — method + path + billing bucket) | off |
|
|
41
41
|
| `--verbose=2` / `-vv` / `DARIO_LOG_BODIES=1` | Also dump the outbound request body (redacted: bearer tokens, `sk-ant-*` keys, JWTs stripped; capped at 8KB). For wire-level client-compat debugging. | off |
|
|
42
|
+
| `--key-socket=<path>=<key>` / `DARIO_KEY_SOCKETS` | Also listen on a unix socket bound to one named key: every request on it is that key's, with no secret sent or stored by the caller; the socket's directory and group decide who may connect. Repeatable. See [Key sockets](./keys.md#a-key-with-no-secret-key-sockets). | none |
|
|
42
43
|
| `--no-keys` / `DARIO_KEYS=0`; `--keys-path=<file>` / `DARIO_KEYS_PATH` | Ignore named keys (`~/.dario/keys.json`) so only `DARIO_API_KEY` authenticates; or move the file. See [Named keys](./keys.md). (v6.8) | on, `~/.dario/keys.json` |
|
|
43
44
|
| `--log-file=<path>` / `DARIO_LOG_FILE` | Append one JSON-ND record per completed request to PATH. Useful for backgrounded proxies where stdout is unobserved (where `--verbose` can't help). Field set: `ts`, `req`, `method`, `path`, `model`, `status`, `latency_ms`, `in_tokens`, `out_tokens`, `cache_read`, `cache_create`, `claim`, `bucket`, `account`, `client`, `preserve_tools`, `stream`, plus `reject` / `error` on failure paths. Secrets scrubbed via the same redactor that `--verbose-bodies` uses; no request bodies. | off |
|
|
44
45
|
| `--pool-fallback=<models>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | Strictly opt-in. When every pool seat is drained or in auth cool-down (at selection, or mid-flight on a 429 with no peer left), serve the request as `<model>` from whichever provider can, instead of surfacing the 429/503. Accepts a **chain** — `gpt-5.6-sol,claude-sonnet-5` — read left to right, each provider taking the first entry it can serve. A Codex/ChatGPT subscription that lists the model is preferred (no per-token cost) and works on **both** wire shapes; otherwise a configured openai-compat backend, which is still OpenAI-path only (no Messages translation on that route). A chain also makes failover **symmetric**: a rate-limited or failing subscription hands the request back to the Claude pool. Only a 429/5xx fails over — a 400 surfaces, since a bad request would just reproduce itself elsewhere. Every substituted response carries `x-dario-pool-fallback: <model>` — never silent. Needs a Codex account (`dario add altman`) or a backend (`dario backend add …`); `dario doctor` reports it as INERT with neither. Empty pool still 503s (setup error, not traffic to re-bill). Empty flag value disables, overriding env + config. See [Pool-exhausted fallback](./multi-account-pool.md#pool-exhausted-fallback). | off |
|
package/docs/configuration.md
CHANGED
|
@@ -49,6 +49,7 @@ Halts the proxy when an upstream response reports `representative-claim: overage
|
|
|
49
49
|
|---|---|---|---|
|
|
50
50
|
| `DARIO_KEYS` | `--no-keys` | on | `0` ignores `~/.dario/keys.json`: only `DARIO_API_KEY` authenticates. See [Named keys](./keys.md) |
|
|
51
51
|
| `DARIO_KEYS_PATH` | `--keys-path=<file>` | `~/.dario/keys.json` | where the key hashes live; `dario keys` and the running proxy read the same file |
|
|
52
|
+
| `DARIO_KEY_SOCKETS` | `--key-socket=<path>=<key>` (repeatable) | none | unix sockets bound to one named key each: every request on one is that key's, with no secret sent. See [Key sockets](./keys.md#a-key-with-no-secret-key-sockets) |
|
|
52
53
|
|
|
53
54
|
## ChatGPT seats
|
|
54
55
|
|
package/docs/keys.md
CHANGED
|
@@ -146,6 +146,39 @@ For a deployment where only named keys should get in, set `DARIO_API_KEY` to
|
|
|
146
146
|
a long random value nobody is given — the non-loopback bind requires one
|
|
147
147
|
anyway — and hand out named keys.
|
|
148
148
|
|
|
149
|
+
## A key with no secret: key sockets
|
|
150
|
+
|
|
151
|
+
A caller that must not hold any credential (a CI job running code it did not
|
|
152
|
+
write, a sandboxed agent) can still be a named key. `--key-socket=<path>=<key>`
|
|
153
|
+
also listens on a unix socket, and every request on it is that key's: no
|
|
154
|
+
header is needed, and none can name another key or the root key. The key's
|
|
155
|
+
models, budgets, seat, expiry and revoke apply as on TCP; a socket whose key is
|
|
156
|
+
unknown, revoked or expired answers `401` on every route, `/health` and
|
|
157
|
+
`/livez` included. No header grants a socket caller more than its key: the
|
|
158
|
+
analytics token does not apply there, `/health` gives the public view, and the
|
|
159
|
+
admin API and seat pins are refused with `403`.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
dario keys create ci --models=claude-sonnet-5 --budget=20
|
|
163
|
+
sudo install -d -o dario -g ci-exec -m 2750 /run/dario # setgid: the socket's group is ci-exec
|
|
164
|
+
dario proxy --key-socket=/run/dario/ci.sock=ci
|
|
165
|
+
curl -s --unix-socket /run/dario/ci.sock http://dario/v1/messages -H 'content-type: application/json' -d '…'
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Who may connect is the filesystem's call: the socket is created `0660`, so its
|
|
169
|
+
owner and its group can connect and nobody else can. Put it in a directory
|
|
170
|
+
owned by the dario account, grouped to the accounts that may spend the key,
|
|
171
|
+
mode `2750`: the setgid bit gives the socket that group (without it the group
|
|
172
|
+
is dario's own, and the callers cannot connect), and the directory keeps every
|
|
173
|
+
other account from reaching the socket at all. Repeat the flag for more sockets, or list them in
|
|
174
|
+
`DARIO_KEY_SOCKETS=<path>=<key>,…`. A stale socket file at the path is
|
|
175
|
+
replaced; one a running listener still answers on, or any other file there,
|
|
176
|
+
stops the start, as do named keys being off and
|
|
177
|
+
a Windows host. A start holds `<path>.lock` while it checks and binds the
|
|
178
|
+
socket, so two starts at once cannot remove each other's. dario never removes
|
|
179
|
+
an existing lock: one left by a start that crashed while binding stops the
|
|
180
|
+
next start until it is deleted. POSIX only.
|
|
181
|
+
|
|
149
182
|
## Compared with a gateway
|
|
150
183
|
|
|
151
184
|
A LiteLLM-style gateway in front of dario does this with a database, a UI and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.13.1",
|
|
4
4
|
"description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|