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.
|
|
36
|
+
streaming has started. A replayed POST is a second charge on your bill.
|
|
37
37
|
|
|
38
|
-
And the
|
|
39
|
-
|
|
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
|
|
57
|
-
uncovered (musl/Alpine, Windows arm64) falls back to
|
|
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
|
|
75
|
-
|
|
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.
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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)
|
|
127
|
-
|
|
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`).
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
finish
|
|
189
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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. `
|
|
220
|
-
|
|
221
|
-
unit restarts on failure. The vault is plain files, so a crash
|
|
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
|
|
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
|
package/vendor/linux-x64/keysink
CHANGED
|
Binary file
|
|
Binary file
|