keysink 0.1.4 → 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.
|
|
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
|
|
|
@@ -47,11 +47,15 @@ everything in its environment — but the real key was never in there.
|
|
|
47
47
|
cargo install keysink # from crates.io, needs a Rust toolchain
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
`cargo install` lands in `~/.cargo/bin`, which sudo can't see. So if you later
|
|
51
|
+
run `sudo keysink deploy`, use `sudo ~/.cargo/bin/keysink deploy`.
|
|
52
|
+
|
|
50
53
|
Prebuilt binaries are also on
|
|
51
54
|
[Releases](https://github.com/wusende/keysink/releases) — Linux / macOS /
|
|
52
55
|
Windows, amd64 / arm64 — put one on your `PATH`. The npm package is a thin
|
|
53
|
-
launcher around that same binary; Linux
|
|
54
|
-
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`.
|
|
55
59
|
|
|
56
60
|
2. Start the gateway (standalone mode — no root, no service):
|
|
57
61
|
|
|
@@ -68,8 +72,21 @@ everything in its environment — but the real key was never in there.
|
|
|
68
72
|
# non-interactive: printf %s "$KEY" | keysink route add claude --base-url … --auth x-api-key
|
|
69
73
|
```
|
|
70
74
|
|
|
71
|
-
It prints the two lines your agent needs
|
|
72
|
-
|
|
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.
|
|
73
90
|
|
|
74
91
|
4. Point your agent at it:
|
|
75
92
|
|
|
@@ -83,8 +100,7 @@ everything in its environment — but the real key was never in there.
|
|
|
83
100
|
|
|
84
101
|
The vkey rides in whatever header your client already sends — `Authorization:
|
|
85
102
|
Bearer`, `x-api-key`, `x-goog-api-key` or `api-key` — so for most clients this
|
|
86
|
-
is a base-URL change and nothing else.
|
|
87
|
-
flow stay untouched.
|
|
103
|
+
is a base-URL change and nothing else.
|
|
88
104
|
|
|
89
105
|
## How routing works
|
|
90
106
|
|
|
@@ -99,9 +115,12 @@ never smuggle its own credentials through. Everything else — method, path, bod
|
|
|
99
115
|
preserved with the auth parameter managed by the route. SSE chunks are forwarded
|
|
100
116
|
as they arrive instead of being buffered into a whole response.
|
|
101
117
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
105
124
|
|
|
106
125
|
## Everyday commands
|
|
107
126
|
|
|
@@ -124,6 +143,36 @@ Route options worth knowing:
|
|
|
124
143
|
a per-route upstream proxy, defaulting to `HTTP(S)_PROXY`
|
|
125
144
|
- `--retry-on-status CODE` overrides the retryable set per route (400..=599)
|
|
126
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
|
+
|
|
127
176
|
## Where the vault lives
|
|
128
177
|
|
|
129
178
|
`~/.local/share/keysink` (Windows: `%LOCALAPPDATA%\keysink`; system installs:
|
|
@@ -133,6 +182,12 @@ The master password is only used to encrypt and restore `export` backups —
|
|
|
133
182
|
serving, adding, rotating and disabling keys never ask for it, so a daemon
|
|
134
183
|
restart can't be blocked by a forgotten password.
|
|
135
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
|
+
|
|
136
191
|
## Linux system service
|
|
137
192
|
|
|
138
193
|
```bash
|
|
@@ -145,15 +200,23 @@ binary to `/usr/local/bin/keysink`, writes the systemd unit and the sudoers rule
|
|
|
145
200
|
password, entered twice), then enables the service. Idempotent — re-running
|
|
146
201
|
upgrades in place and restarts. Removal is symmetric: `sudo keysink uninstall`.
|
|
147
202
|
|
|
148
|
-
The unit refuses to start until the vault exists (`ConditionPathExists`).
|
|
149
|
-
|
|
203
|
+
The unit refuses to start until the vault exists (`ConditionPathExists`). With no
|
|
204
|
+
terminal on the deploy host, init manually — interactively:
|
|
150
205
|
|
|
151
206
|
```bash
|
|
152
|
-
|
|
207
|
+
sudo -u keysink /usr/local/bin/keysink --home /var/lib/keysink init
|
|
153
208
|
```
|
|
154
209
|
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Admin commands then go through `sudo -u keysink /usr/local/bin/keysink …`; each
|
|
218
|
+
invocation re-authenticates (`timestamp_timeout=0`), so an agent cannot silently
|
|
219
|
+
run them.
|
|
157
220
|
|
|
158
221
|
## Security model
|
|
159
222
|
|
|
@@ -178,11 +241,23 @@ of unauthenticated requests cannot rotate the forensic trail away.
|
|
|
178
241
|
|
|
179
242
|
`--listen` will bind a non-loopback address if you insist, but then TLS and
|
|
180
243
|
network exposure are yours to solve; the intended deployment is loopback only.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
finish
|
|
185
|
-
|
|
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.
|
|
186
261
|
|
|
187
262
|
## What this is not
|
|
188
263
|
|
|
@@ -198,23 +273,30 @@ are closed.
|
|
|
198
273
|
|
|
199
274
|
**Why isn't the vault in the current directory?**
|
|
200
275
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
`%LOCALAPPDATA%`, `/var/lib` for services. Same convention as `~/.ssh`,
|
|
211
|
-
`~/.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.
|
|
212
285
|
|
|
213
286
|
**Is a local proxy a single point of failure?**
|
|
214
287
|
|
|
215
|
-
It's a local process, so yes — if it's down, requests fail. `
|
|
216
|
-
|
|
217
|
-
unit restarts on failure. The vault is plain files, so a crash
|
|
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.
|
|
218
300
|
|
|
219
301
|
## Development
|
|
220
302
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keysink",
|
|
3
|
-
"version": "0.
|
|
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
|
package/vendor/linux-x64/keysink
CHANGED
|
Binary file
|
|
Binary file
|