keysink 0.1.5 → 0.2.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 CHANGED
@@ -33,10 +33,10 @@ the others keep working.
33
33
 
34
34
  **Retries that don't double-bill.** `429`/`503`/`504` are retried twice, but a
35
35
  POST whose network call failed is never replayed, and nothing is retried once SSE
36
- streaming has started. Most naive proxies get this wrong and you pay for it twice.
36
+ streaming has started. A replayed POST is a second charge on your bill.
37
37
 
38
- And the part it's named after: an agent that gets prompt-injected can leak
39
- everything in its environment — but the real key was never in there.
38
+ And the name: a prompt-injected agent can dump everything in its environment, and
39
+ the real key was never in it.
40
40
 
41
41
  ## Quick start
42
42
 
@@ -53,8 +53,9 @@ everything in its environment — but the real key was never in there.
53
53
  Prebuilt binaries are also on
54
54
  [Releases](https://github.com/wusende/keysink/releases) — Linux / macOS /
55
55
  Windows, amd64 / arm64 — put one on your `PATH`. The npm package is a thin
56
- launcher around that same binary; Linux x64 needs glibc 2.35+, and anything
57
- uncovered (musl/Alpine, Windows arm64) falls back to `cargo install`.
56
+ launcher around that same binary; Linux x86_64 needs glibc 2.35+, arm64 needs
57
+ 2.39+, and anything uncovered (musl/Alpine, Windows arm64) falls back to
58
+ `cargo install`.
58
59
 
59
60
  2. Start the gateway (standalone mode — no root, no service):
60
61
 
@@ -71,8 +72,21 @@ everything in its environment — but the real key was never in there.
71
72
  # non-interactive: printf %s "$KEY" | keysink route add claude --base-url … --auth x-api-key
72
73
  ```
73
74
 
74
- It prints the two lines your agent needs. Routes hot-reload into a running
75
- gateway within ~1s, so adding one never requires a restart.
75
+ It prints the vkey and the two lines your agent needs:
76
+
77
+ ```
78
+ route `claude` created (key goes in, never out: cannot be read/exported)
79
+ auth: x-api-key base_url: https://api.anthropic.com
80
+ vkey: ks_R6OpUgxRjVnuCwISI0_BgtAi1E8FGUILJHQouA50tYc
81
+ (vkey is shown only this once; if lost, only `keysink route rotate claude` can issue a new one)
82
+ gateway: http://127.0.0.1:8787 (running)
83
+ agent-side config (OpenAI-compatible clients usually need /v1 in base_url):
84
+ BASE_URL=http://127.0.0.1:8787
85
+ TOKEN=ks_R6OpUgxRjVnuCwISI0_BgtAi1E8FGUILJHQouA50tYc
86
+ ```
87
+
88
+ Routes hot-reload into a running gateway within ~1s, so adding one never
89
+ requires a restart.
76
90
 
77
91
  4. Point your agent at it:
78
92
 
@@ -86,8 +100,7 @@ everything in its environment — but the real key was never in there.
86
100
 
87
101
  The vkey rides in whatever header your client already sends — `Authorization:
88
102
  Bearer`, `x-api-key`, `x-goog-api-key` or `api-key` — so for most clients this
89
- is a base-URL change and nothing else. Your existing client code, SDK and auth
90
- flow stay untouched.
103
+ is a base-URL change and nothing else.
91
104
 
92
105
  ## How routing works
93
106
 
@@ -102,9 +115,20 @@ never smuggle its own credentials through. Everything else — method, path, bod
102
115
  preserved with the auth parameter managed by the route. SSE chunks are forwarded
103
116
  as they arrive instead of being buffered into a whole response.
104
117
 
105
- Because the vkey picks the route, "which upstream does this agent talk to" is a
106
- keysink-side decision, not a client-side one. Two agents on the same port can
107
- talk to two different providers, or to the same provider with different keys.
118
+ Two edge refusals keep that passthrough honest: a request path containing dot
119
+ segments (`..`, `%2e%2e`, or their `\`-separated forms) gets a 404 — the
120
+ downstream URL parser collapses those, and forwarding one would let a request
121
+ escape the route's base_url prefix on the upstream host. `CONNECT`, `TRACE` and
122
+ `TRACK` get a 405 — a TRACE the upstream implements would echo the forwarded
123
+ headers, real key included, back to the caller.
124
+
125
+ `--auth` describes keysink → upstream only. Inbound, the vkey is accepted in any
126
+ of `Authorization: Bearer`, `x-api-key`, `x-goog-api-key`, `api-key`,
127
+ `anthropic-api-key` or `openai-api-key` — the same names stripped before
128
+ forwarding — so the client's auth style and the route's never have to match.
129
+
130
+ Two agents on the same port can talk to two different providers, or to the same
131
+ provider with different keys, without either of them knowing.
108
132
 
109
133
  ## Everyday commands
110
134
 
@@ -123,10 +147,44 @@ Route options worth knowing:
123
147
  - `--auth bearer|x-api-key|header|basic|query|none` — OpenAI is Bearer, Claude
124
148
  `x-api-key`, Gemini `x-goog-api-key`. For anything else use `--auth header`
125
149
  plus `--auth-header NAME` and optional `--auth-prefix 'Bearer '`
126
- - `--header 'K:V'` injects static headers (repeatable); `--proxy URL|direct` sets
127
- a per-route upstream proxy, defaulting to `HTTP(S)_PROXY`
150
+ - `--header 'K:V'` injects static headers (repeatable) — a fallback, not an
151
+ override: if the client sends the same header, the client's value wins.
152
+ Only the auth header names are protected (they cannot be set statically at
153
+ all); treat this as route defaults, never as enforcement;
154
+ `--proxy URL|direct` sets a per-route upstream proxy, defaulting to
155
+ `HTTP(S)_PROXY`
128
156
  - `--retry-on-status CODE` overrides the retryable set per route (400..=599)
129
157
 
158
+ Common moves:
159
+
160
+ ```bash
161
+ keysink route update claude --base-url https://api.openai.com # switch upstream
162
+ printf %s "$NEW_KEY" | keysink route update claude --route-key - # swap the real key
163
+ keysink route rotate claude # new vkey, old one stops working
164
+ ```
165
+
166
+ `status` prints the vault summary (home, schema, route count). `doctor` runs the
167
+ integrity checks — owner, permissions, key parses, store decrypts, backup
168
+ decrypts — and is what to run when something looks wrong.
169
+
170
+ To check the gateway itself, `GET /__keysink/health` returns
171
+ `{"ok":true,"degraded":false,"version":"0.2.0","uptime_secs":…,"routes":2}`,
172
+ plus the state that used to be invisible: `snapshot_age_secs` (how long the
173
+ served snapshot has been in place — the watcher only reloads on vault changes,
174
+ so on a stable vault this grows with uptime; it reports data freshness, not
175
+ watcher health, so don't alert on it), `reload_failures` / `reload_attempts`
176
+ (the actual reload signals: failures climbs while the vault stays broken, e.g.
177
+ undecryptable), `shed_connections` / `accept_errors` (connection-cap and
178
+ per-connection accept-error counts), and `audit` / `audit_ok` /
179
+ `audit_dropped` (a dead audit writer reports `audit_ok:false` instead of
180
+ silently dropping every line).
181
+
182
+ `--accept-degraded` covers a broken vault: when the primary store fails to
183
+ decrypt, the gateway can start from `vault.age.bak` instead. That backup is one
184
+ write behind, so vkeys rotated or deleted since then work again — a stopgap to
185
+ keep agents running, not a state to stay in. `doctor` tells you which file is
186
+ bad and how to restore it.
187
+
130
188
  ## Where the vault lives
131
189
 
132
190
  `~/.local/share/keysink` (Windows: `%LOCALAPPDATA%\keysink`; system installs:
@@ -136,6 +194,12 @@ The master password is only used to encrypt and restore `export` backups —
136
194
  serving, adding, rotating and disabling keys never ask for it, so a daemon
137
195
  restart can't be blocked by a forgotten password.
138
196
 
197
+ `vault.key` is a plain age identity file (`0600`), not something the master
198
+ password protects: the vault's protection is file mode plus a separate UID. For
199
+ services, the identity can live outside the vault directory — `KEYSINK_MASTER_KEY`
200
+ (inline age identity) or `KEYSINK_MASTER_KEY_FILE` (path to one), which is what
201
+ systemd's `LoadCredential` wants.
202
+
139
203
  ## Linux system service
140
204
 
141
205
  ```bash
@@ -148,11 +212,18 @@ binary to `/usr/local/bin/keysink`, writes the systemd unit and the sudoers rule
148
212
  password, entered twice), then enables the service. Idempotent — re-running
149
213
  upgrades in place and restarts. Removal is symmetric: `sudo keysink uninstall`.
150
214
 
151
- The unit refuses to start until the vault exists (`ConditionPathExists`). Without
152
- a terminal, init manually:
215
+ The unit refuses to start until the vault exists (`ConditionPathExists`). With no
216
+ terminal on the deploy host, init manually — interactively:
153
217
 
154
218
  ```bash
155
- runuser -u keysink -- /usr/local/bin/keysink --home /var/lib/keysink init
219
+ sudo -u keysink /usr/local/bin/keysink --home /var/lib/keysink init
220
+ ```
221
+
222
+ or fully headless (`init` refuses to prompt without a TTY, so pipe the password):
223
+
224
+ ```bash
225
+ printf '%s\n' "$MASTER_PASSWORD" | sudo -u keysink /usr/local/bin/keysink \
226
+ --home /var/lib/keysink init --password-stdin
156
227
  ```
157
228
 
158
229
  Admin commands then go through `sudo -u keysink /usr/local/bin/keysink …`; each
@@ -177,16 +248,31 @@ machine-level isolation stays your job (tight sudo, separate UID).
177
248
 
178
249
  `--audit-log` records route, vkey fingerprint, status and latency — never
179
250
  headers, body or query. Off by default. Five rotated generations (10 MB each)
180
- are kept, and rejected requests are admitted at 1/s per fingerprint — a flood
181
- of unauthenticated requests cannot rotate the forensic trail away.
251
+ are kept for real traffic. Rejected requests are throttled (1/s per
252
+ fingerprint, 100/s globally) and written to their own rotation chain
253
+ (`<audit-file>.rej`, another 5 × 10 MB) — a flood of unauthenticated requests
254
+ can only ever rotate its own trail away, never the forensic record of real
255
+ traffic.
182
256
 
183
257
  `--listen` will bind a non-loopback address if you insist, but then TLS and
184
258
  network exposure are yours to solve; the intended deployment is loopback only.
185
- Everything a request touches is bounded by default — body size, in-flight
186
- requests, connections, time — so a hung upstream or a misbehaving agent can't
187
- take the gateway down. Idle connections are bounded too: http1 heads must
188
- finish within 30s, and h2 connections that stop answering keep-alive PINGs
189
- are closed.
259
+
260
+ Nothing is unbounded: 32 MB bodies (`--body-limit-mb`), 64 in-flight requests
261
+ (`--max-concurrent`), a capped connection count, per-phase timeouts, and 30s to
262
+ finish reading http1 request heads. A non-streaming response body is bounded
263
+ both per chunk and as a whole, so an upstream that trickles a byte a minute
264
+ cannot hold a connection forever. An h2 connection that stops answering
265
+ keep-alive PINGs is closed. A hung upstream or a misbehaving agent can't take
266
+ the gateway down.
267
+
268
+ Shutdown is graceful and bounded: `serve` stops accepting, tells every live
269
+ connection to finish and close (`Connection: close`, h2 `GOAWAY`), and drains
270
+ for at most `--drain-timeout-secs` (default 10, `0` = wait forever) before
271
+ dropping what is left with a warning. Idle keep-alive sockets close
272
+ immediately, so a restart no longer waits out a 30s header timeout; the
273
+ systemd unit's `TimeoutStopSec=30` is the backstop above that. The store
274
+ watcher is supervised too: if it dies it is restarted, and if it stops ticking
275
+ the log says so.
190
276
 
191
277
  ## What this is not
192
278
 
@@ -202,23 +288,30 @@ are closed.
202
288
 
203
289
  **Why isn't the vault in the current directory?**
204
290
 
205
- - **One vault per user, not per directory.** The gateway runs as a daemon while
206
- the CLI is invoked from anywhere; a CWD-relative vault would mean a different
207
- vault per directory, routes would "vanish", and hot reload would break. Use
208
- `--home` / `KEYSINK_VAULT_HOME` when you want another location.
209
- - **The hardening needs a directory it controls.** `0700`/`0600`,
210
- `O_NOFOLLOW` and owner checks only mean something in a dedicated directory.
211
- CWD has arbitrary permissions and may well be a git repo or a synced folder
212
- where `vault.key` + `vault.age` leak.
213
- - **It's where the OS expects app data** — XDG `$XDG_DATA_HOME`,
214
- `%LOCALAPPDATA%`, `/var/lib` for services. Same convention as `~/.ssh`,
215
- `~/.gnupg` or `/var/lib/docker`.
291
+ One vault per user, not per directory. The gateway runs as a daemon while the CLI
292
+ is invoked from anywhere, so a CWD-relative vault would mean a different vault
293
+ per directory, routes that "vanish", and broken hot reload. The hardening needs
294
+ a directory it controls too: `0700`/`0600`, `O_NOFOLLOW` and owner checks only
295
+ mean something in a dedicated directory, while a CWD may well be a git repo or a
296
+ synced folder where `vault.key` + `vault.age` leak. It is also where the OS
297
+ expects app data (XDG `$XDG_DATA_HOME`, `%LOCALAPPDATA%`, `/var/lib` for
298
+ services — same convention as `~/.ssh` or `/var/lib/docker`). Use `--home` /
299
+ `KEYSINK_VAULT_HOME` when you want another location.
216
300
 
217
301
  **Is a local proxy a single point of failure?**
218
302
 
219
- It's a local process, so yes — if it's down, requests fail. `keysink status`
220
- reports the live listen address and probes reachability, and under systemd the
221
- unit restarts on failure. The vault is plain files, so a crash never loses keys.
303
+ It's a local process, so yes — if it's down, requests fail. `GET
304
+ /__keysink/health` says whether it's up and whether it's serving a stale backup;
305
+ under systemd the unit restarts on failure. The vault is plain files, so a crash
306
+ never loses keys.
307
+
308
+ **My agent gets 401 — is the vkey wrong or the real key?**
309
+
310
+ keysink's own 401 has a JSON body from keysink and an `x-keysink-request-id`
311
+ header, e.g. `{"error":{"type":"authentication_error","message":"virtual key is
312
+ invalid or has been rotated …"}}`. A bad *real* key comes back from the upstream
313
+ instead — same status, but the body is the provider's own error, and the request
314
+ never reached the vault.
222
315
 
223
316
  ## Development
224
317
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keysink",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "Local gateway that keeps real AI API keys out of agents (Claude Code, OpenAI, MCP): agents hold revocable virtual keys, real keys stay in an encrypted vault.",
5
5
  "license": "MIT",
6
6
  "author": "wusende",
Binary file
Binary file
Binary file
Binary file
Binary file