keysink 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,12 @@ 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
+ `--auth` describes keysink → upstream only. Inbound, the vkey is accepted in any
119
+ of `Authorization: Bearer`, `x-api-key`, `x-goog-api-key` or `api-key`, so the
120
+ client's auth style and the route's never have to match.
121
+
122
+ Two agents on the same port can talk to two different providers, or to the same
123
+ provider with different keys, without either of them knowing.
108
124
 
109
125
  ## Everyday commands
110
126
 
@@ -127,6 +143,36 @@ Route options worth knowing:
127
143
  a per-route upstream proxy, defaulting to `HTTP(S)_PROXY`
128
144
  - `--retry-on-status CODE` overrides the retryable set per route (400..=599)
129
145
 
146
+ Common moves:
147
+
148
+ ```bash
149
+ keysink route update claude --base-url https://api.openai.com # switch upstream
150
+ printf %s "$NEW_KEY" | keysink route update claude --route-key - # swap the real key
151
+ keysink route rotate claude # new vkey, old one stops working
152
+ ```
153
+
154
+ `status` prints the vault summary (home, schema, route count). `doctor` runs the
155
+ integrity checks — owner, permissions, key parses, store decrypts, backup
156
+ decrypts — and is what to run when something looks wrong.
157
+
158
+ To check the gateway itself, `GET /__keysink/health` returns
159
+ `{"ok":true,"degraded":false,"version":"0.2.0","uptime_secs":…,"routes":2}`,
160
+ plus the state that used to be invisible: `snapshot_age_secs` (how long the
161
+ served snapshot has been in place — the watcher only reloads on vault changes,
162
+ so on a stable vault this grows with uptime; it reports data freshness, not
163
+ watcher health, so don't alert on it), `reload_failures` / `reload_attempts`
164
+ (the actual reload signals: failures climbs while the vault stays broken, e.g.
165
+ undecryptable), `shed_connections` / `accept_errors` (connection-cap and
166
+ per-connection accept-error counts), and `audit` / `audit_ok` /
167
+ `audit_dropped` (a dead audit writer reports `audit_ok:false` instead of
168
+ silently dropping every line).
169
+
170
+ `--accept-degraded` covers a broken vault: when the primary store fails to
171
+ decrypt, the gateway can start from `vault.age.bak` instead. That backup is one
172
+ write behind, so vkeys rotated or deleted since then work again — a stopgap to
173
+ keep agents running, not a state to stay in. `doctor` tells you which file is
174
+ bad and how to restore it.
175
+
130
176
  ## Where the vault lives
131
177
 
132
178
  `~/.local/share/keysink` (Windows: `%LOCALAPPDATA%\keysink`; system installs:
@@ -136,6 +182,12 @@ The master password is only used to encrypt and restore `export` backups —
136
182
  serving, adding, rotating and disabling keys never ask for it, so a daemon
137
183
  restart can't be blocked by a forgotten password.
138
184
 
185
+ `vault.key` is a plain age identity file (`0600`), not something the master
186
+ password protects: the vault's protection is file mode plus a separate UID. For
187
+ services, the identity can live outside the vault directory — `KEYSINK_MASTER_KEY`
188
+ (inline age identity) or `KEYSINK_MASTER_KEY_FILE` (path to one), which is what
189
+ systemd's `LoadCredential` wants.
190
+
139
191
  ## Linux system service
140
192
 
141
193
  ```bash
@@ -148,11 +200,18 @@ binary to `/usr/local/bin/keysink`, writes the systemd unit and the sudoers rule
148
200
  password, entered twice), then enables the service. Idempotent — re-running
149
201
  upgrades in place and restarts. Removal is symmetric: `sudo keysink uninstall`.
150
202
 
151
- The unit refuses to start until the vault exists (`ConditionPathExists`). Without
152
- a terminal, init manually:
203
+ The unit refuses to start until the vault exists (`ConditionPathExists`). With no
204
+ terminal on the deploy host, init manually — interactively:
153
205
 
154
206
  ```bash
155
- runuser -u keysink -- /usr/local/bin/keysink --home /var/lib/keysink init
207
+ sudo -u keysink /usr/local/bin/keysink --home /var/lib/keysink init
208
+ ```
209
+
210
+ or fully headless (`init` refuses to prompt without a TTY, so pipe the password):
211
+
212
+ ```bash
213
+ printf '%s\n' "$MASTER_PASSWORD" | sudo -u keysink /usr/local/bin/keysink \
214
+ --home /var/lib/keysink init --password-stdin
156
215
  ```
157
216
 
158
217
  Admin commands then go through `sudo -u keysink /usr/local/bin/keysink …`; each
@@ -182,11 +241,23 @@ of unauthenticated requests cannot rotate the forensic trail away.
182
241
 
183
242
  `--listen` will bind a non-loopback address if you insist, but then TLS and
184
243
  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.
244
+
245
+ Nothing is unbounded: 32 MB bodies (`--body-limit-mb`), 64 in-flight requests
246
+ (`--max-concurrent`), a capped connection count, per-phase timeouts, and 30s to
247
+ finish reading http1 request heads. A non-streaming response body is bounded
248
+ both per chunk and as a whole, so an upstream that trickles a byte a minute
249
+ cannot hold a connection forever. An h2 connection that stops answering
250
+ keep-alive PINGs is closed. A hung upstream or a misbehaving agent can't take
251
+ the gateway down.
252
+
253
+ Shutdown is graceful and bounded: `serve` stops accepting, tells every live
254
+ connection to finish and close (`Connection: close`, h2 `GOAWAY`), and drains
255
+ for at most `--drain-timeout-secs` (default 10, `0` = wait forever) before
256
+ dropping what is left with a warning. Idle keep-alive sockets close
257
+ immediately, so a restart no longer waits out a 30s header timeout; the
258
+ systemd unit's `TimeoutStopSec=30` is the backstop above that. The store
259
+ watcher is supervised too: if it dies it is restarted, and if it stops ticking
260
+ the log says so.
190
261
 
191
262
  ## What this is not
192
263
 
@@ -202,23 +273,30 @@ are closed.
202
273
 
203
274
  **Why isn't the vault in the current directory?**
204
275
 
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`.
276
+ One vault per user, not per directory. The gateway runs as a daemon while the CLI
277
+ is invoked from anywhere, so a CWD-relative vault would mean a different vault
278
+ per directory, routes that "vanish", and broken hot reload. The hardening needs
279
+ a directory it controls too: `0700`/`0600`, `O_NOFOLLOW` and owner checks only
280
+ mean something in a dedicated directory, while a CWD may well be a git repo or a
281
+ synced folder where `vault.key` + `vault.age` leak. It is also where the OS
282
+ expects app data (XDG `$XDG_DATA_HOME`, `%LOCALAPPDATA%`, `/var/lib` for
283
+ services — same convention as `~/.ssh` or `/var/lib/docker`). Use `--home` /
284
+ `KEYSINK_VAULT_HOME` when you want another location.
216
285
 
217
286
  **Is a local proxy a single point of failure?**
218
287
 
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.
288
+ It's a local process, so yes — if it's down, requests fail. `GET
289
+ /__keysink/health` says whether it's up and whether it's serving a stale backup;
290
+ under systemd the unit restarts on failure. The vault is plain files, so a crash
291
+ never loses keys.
292
+
293
+ **My agent gets 401 — is the vkey wrong or the real key?**
294
+
295
+ keysink's own 401 has a JSON body from keysink and an `x-keysink-request-id`
296
+ header, e.g. `{"error":{"type":"authentication_error","message":"virtual key is
297
+ invalid or has been rotated …"}}`. A bad *real* key comes back from the upstream
298
+ instead — same status, but the body is the provider's own error, and the request
299
+ never reached the vault.
222
300
 
223
301
  ## Development
224
302
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keysink",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
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