Compare commits
9 Commits
58d5d787dd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a640bc1faf | ||
|
|
04b48e05e4 | ||
|
|
8dcb675ad2 | ||
|
|
bb7f890a68 | ||
|
|
74122b25cd | ||
|
|
1b4cd9b826 | ||
|
|
74bee56c54 | ||
|
|
b74564fd9b | ||
|
|
442c3b7cf5 |
@@ -1,6 +1,9 @@
|
||||
PORT=3050
|
||||
DATA_DIR=./data
|
||||
|
||||
# Image to run via docker compose, e.g. repo.example.com/owner/keep:latest
|
||||
IMAGE=
|
||||
|
||||
# Gates recipient add/remove and revoke. Unset disables /api/admin/* (503)
|
||||
# but push/pull/grant keep working for already-registered recipients.
|
||||
ADMIN_PASSWORD=change-me
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,4 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-bundle/
|
||||
.env
|
||||
data/
|
||||
|
||||
# Personal rollout notes — real vault keys, recipient ids, VPS hostnames.
|
||||
# Not secrets themselves, but fleet topology that doesn't belong in a
|
||||
# public repo. See SETUP.local.md.example for the template.
|
||||
SETUP.local.md
|
||||
|
||||
@@ -31,6 +31,60 @@ A deploy identity should almost always be granted **read-only** access
|
||||
needs to `pull`, and a read-only grant means a compromised deploy box
|
||||
can't also rotate the vault or add itself more recipients.
|
||||
|
||||
When you do the first `keep push` for a project, push the *real*,
|
||||
hand-maintained `.env` rather than a stripped-down one — any `# get this
|
||||
from X` / `# rotate quarterly` comments in it get preserved (values
|
||||
redacted) as a template alongside the secrets, retrievable later with
|
||||
`keep pull <vault> --template`. That's the one moment this context is
|
||||
available at all; a `.env` reconstructed later from memory won't have it.
|
||||
|
||||
## Getting the `keep` CLI onto a machine
|
||||
|
||||
Chicken-and-egg: every pattern below assumes `keep` is already a runnable
|
||||
command on the machine doing the pulling. Two ways to get there, pick
|
||||
based on whether you're fine with a git checkout existing on that
|
||||
machine:
|
||||
|
||||
**A machine you're already developing on, or don't mind cloning onto**
|
||||
(your laptop, a VPS you administer directly):
|
||||
|
||||
```bash
|
||||
git clone <this repo>
|
||||
cd keep
|
||||
npm install
|
||||
./scripts/install-cli.sh # builds, symlinks dist/cli/index.js onto PATH as `keep`
|
||||
```
|
||||
|
||||
Symlinked rather than copied — a future `git pull && npm run build` is
|
||||
the entire upgrade story, no need to re-run the install script.
|
||||
|
||||
**A deploy target you'd rather not clone a git repo onto** — bundle the
|
||||
CLI and ship it over SSH, the same way flit/waste-go's `deploy-*.sh`
|
||||
scripts ship a compiled binary:
|
||||
|
||||
```bash
|
||||
HOST=user@your-vps ./deploy-cli.sh
|
||||
```
|
||||
|
||||
Not quite a single file: the bundle itself (esbuild, ~18kb) covers
|
||||
everything except `libsodium-wrappers`, which ships alongside it as the
|
||||
real, unmodified package (~1.6MB total with its own dependency,
|
||||
`libsodium`) rather than being force-inlined. That package's published
|
||||
ESM build follows a broken relative import (see `src/shared/crypto.ts`'s
|
||||
own comment on this), and statically inlining it via esbuild — technically
|
||||
possible by routing through a literal `require()` call instead of
|
||||
`import` — breaks its WASM engine's own Node-crypto feature detection at
|
||||
runtime ("No secure random number generator found"), confirmed by
|
||||
testing, not assumed. Shipping the real package is more reliable than
|
||||
fighting that.
|
||||
|
||||
The script tars `dist-bundle/` (the esbuild output plus that one
|
||||
dependency) and extracts it to `~/.keep-cli/` on the target over SSH,
|
||||
then symlinks `~/bin/keep` to the entry point inside it — no git, no npm
|
||||
install on that machine at all, just a `node` runtime (already there, if
|
||||
it's running any of the other Node-based projects in this family). Re-run
|
||||
it any time the CLI changes; safe to overwrite.
|
||||
|
||||
## Pattern: several docker-compose projects on one or more VPSes
|
||||
|
||||
The common shape once more than one or two projects are on `keep`:
|
||||
|
||||
49
README.md
49
README.md
@@ -91,24 +91,53 @@ cp .env.example .env
|
||||
npm install
|
||||
npm run dev:server
|
||||
|
||||
# 2. Each machine/person that needs access generates its own identity
|
||||
npm run cli -- identity init
|
||||
# 2. Build the CLI and link it onto PATH as a real `keep` command --
|
||||
# no npm run cli --, no cd-ing into this repo from wherever you
|
||||
# actually need it (a deploy directory on some VPS, for instance).
|
||||
# Symlinked, not copied: a future `git pull && npm run build` here
|
||||
# is the entire upgrade story, no need to re-run this.
|
||||
./scripts/install-cli.sh
|
||||
|
||||
# 3. Each machine/person that needs access generates its own identity
|
||||
keep identity init
|
||||
# → prints a public key
|
||||
|
||||
# 3. An admin registers that public key as a recipient
|
||||
KEEP_ADMIN_PASSWORD=... npm run cli -- recipient add --label "my-laptop" --pubkey <hex>
|
||||
# 4. An admin registers that public key as a recipient
|
||||
KEEP_ADMIN_PASSWORD=... keep recipient add --label "my-laptop" --pubkey <hex>
|
||||
# → prints a recipient id
|
||||
|
||||
# 4. The machine that generated the identity records its assigned id
|
||||
npm run cli -- identity set-id <recipient-id>
|
||||
# 5. The machine that generated the identity records its assigned id
|
||||
keep identity set-id <recipient-id>
|
||||
|
||||
# 5. Push a vault — the pusher is automatically its first recipient
|
||||
npm run cli -- push myapp/production --file .env.production
|
||||
# 6. Push a vault — the pusher is automatically its first recipient
|
||||
keep push myapp/production --file .env.production
|
||||
|
||||
# 6. Anyone else with a grant can pull it, decrypted, ready to use
|
||||
npm run cli -- pull myapp/production > .env
|
||||
# 7. Anyone else with a grant can pull it, decrypted, ready to use
|
||||
keep pull myapp/production > .env
|
||||
```
|
||||
|
||||
## Templates
|
||||
|
||||
`keep push` also saves a redacted copy of the source `.env` alongside
|
||||
the secrets — same keys, comments, and blank lines, values stripped.
|
||||
Comments explaining *where a value comes from* or *why it exists* are
|
||||
exactly the kind of context a bare key/value pair throws away, so this
|
||||
keeps it without keeping the secret itself around unencrypted anywhere.
|
||||
|
||||
A plain `keep pull` uses that template by default: if the vault has one,
|
||||
you get real values filled back into it, comments and all — not just a
|
||||
bare key/value dump. Ask for the redacted structure itself with:
|
||||
|
||||
```bash
|
||||
keep pull myapp/production --template > .env.example
|
||||
```
|
||||
|
||||
Vaults pushed before this existed just don't have a saved template —
|
||||
`keep pull` on one of those falls back to a plain key/value dump, and
|
||||
`keep pull --template` fails with a clear error instead of silently
|
||||
returning nothing. No migration needed; push again and the template
|
||||
gets backfilled from whatever `.env` you push next.
|
||||
|
||||
## Granting, revoking, rotating
|
||||
|
||||
```bash
|
||||
|
||||
80
SETUP.local.md.example
Normal file
80
SETUP.local.md.example
Normal file
@@ -0,0 +1,80 @@
|
||||
# keep fleet setup — personal notes (copy to SETUP.local.md, gitignored)
|
||||
|
||||
Fill this in as you actually run the commands. It's your own record of
|
||||
what recipient id belongs to which machine and which vault backs which
|
||||
project — none of this is secret by itself (a recipient id or a vault
|
||||
key doesn't grant access without the matching private key), but it's
|
||||
real fleet topology that doesn't belong in a public repo.
|
||||
|
||||
## 0. Server
|
||||
|
||||
- [ ] Deployed at: `___________________` (e.g. https://keep.example.internal)
|
||||
- [ ] `ADMIN_PASSWORD` stored in: `___________________` (password manager entry, not here)
|
||||
- [ ] `KEEP_SERVER_URL` exported in shell profile on every machine below
|
||||
|
||||
## 1. Identities
|
||||
|
||||
One row per machine. `keep identity init` prints the public key; an
|
||||
admin runs `keep recipient add`, which prints back a recipient id; that
|
||||
id gets recorded locally with `keep identity set-id <id>`.
|
||||
|
||||
| Machine | Recipient id | Grant type (per vault, see §3) |
|
||||
|---|---|---|
|
||||
| this laptop | `___________________` | rw on everything it pushes |
|
||||
| vps-1 (`___________________` hostname) | `___________________` | r- (read-only) |
|
||||
| vps-2 | `___________________` | r- (read-only) |
|
||||
|
||||
Commands, run once per machine:
|
||||
|
||||
```bash
|
||||
keep identity init # on the machine itself
|
||||
KEEP_ADMIN_PASSWORD=... keep recipient add --label "<name>" --pubkey <hex> # from an admin machine
|
||||
keep identity set-id <recipient-id> # back on the machine itself
|
||||
```
|
||||
|
||||
## 2. Projects → vaults
|
||||
|
||||
One vault per project+environment. Vault key is a free-form string —
|
||||
it does NOT need to match the repo name or the deploy directory name
|
||||
(see IMPLEMENTATION.md — these are three independent strings in this
|
||||
fleet on purpose).
|
||||
|
||||
| Project | Deploy dir | Vault key | `.keep-vault` written? |
|
||||
|---|---|---|---|
|
||||
| myapp | `/opt/docker/myapp` | `myapp/production` | [ ] |
|
||||
| otherapp | `/opt/docker/otherapp` | `otherapp/production` | [ ] |
|
||||
|
||||
Bootstrap a project:
|
||||
|
||||
```bash
|
||||
keep push myapp/production --file .env.production # from the laptop, becomes rw grantee
|
||||
keep grant myapp/production <vps-1-recipient-id> --read-only
|
||||
echo "myapp/production" > /opt/docker/myapp/.keep-vault # on vps-1, next to its docker-compose.yml
|
||||
```
|
||||
|
||||
Or use `scripts/bootstrap-project.sh` (see below) to do the push+grant
|
||||
step in one call for multiple VPS recipients at once.
|
||||
|
||||
## 3. CI announce (optional, per project)
|
||||
|
||||
Only needed if you want `keep watch` on a VPS to skip waiting for the
|
||||
next scheduled sweep. Add to that project's own
|
||||
`.gitea/workflows/docker.yml`, after the build-and-push step:
|
||||
|
||||
```yaml
|
||||
- name: announce to keep
|
||||
continue-on-error: true
|
||||
run: keep announce myapp/production --tag ${{ steps.meta.outputs.short_sha }}
|
||||
```
|
||||
|
||||
The vault key here must be the literal string from §2's table — not
|
||||
derived from the repo name.
|
||||
|
||||
## 4. Sanity check
|
||||
|
||||
```bash
|
||||
KEEP_ADMIN_PASSWORD=... keep overview
|
||||
```
|
||||
|
||||
Confirm every project vault shows up with exactly the grants you
|
||||
expect — laptop `rw`, each deploying VPS `r-`, nothing else.
|
||||
38
deploy-cli.sh
Executable file
38
deploy-cli.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy-cli.sh — package the keep CLI (bundle + its one real runtime
|
||||
# dependency, libsodium-wrappers) and ship it straight to a host over SSH.
|
||||
# No git clone, no npm install on the target.
|
||||
#
|
||||
# Not a single file: libsodium-wrappers' published ESM build is broken
|
||||
# (a relative import that doesn't resolve outside a bundler-aware
|
||||
# context — see src/shared/crypto.ts), and statically force-inlining it
|
||||
# via esbuild breaks its own Node-crypto feature-detection at runtime
|
||||
# ("No secure random number generator found"). Shipping the real,
|
||||
# unmodified package alongside a small (~18kb) bundle for everything
|
||||
# else is more reliable than fighting that — the whole package is still
|
||||
# only ~1.6MB and this script is still the entire deploy step.
|
||||
#
|
||||
# Usage: HOST=user@host ./deploy-cli.sh
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${HOST:?usage: HOST=user@host ./deploy-cli.sh}"
|
||||
BIN_DIR="${BIN_DIR:-~/bin}"
|
||||
INSTALL_DIR="${INSTALL_DIR:-~/.keep-cli}"
|
||||
|
||||
echo "→ bundling keep CLI…"
|
||||
npm run build:cli-bundle
|
||||
|
||||
echo "→ packaging…"
|
||||
tar -C dist-bundle -czf /tmp/keep-cli.tar.gz .
|
||||
|
||||
echo "→ uploading to $HOST:$INSTALL_DIR…"
|
||||
ssh "$HOST" "mkdir -p $INSTALL_DIR && tar -C $INSTALL_DIR -xzf -" < /tmp/keep-cli.tar.gz
|
||||
rm /tmp/keep-cli.tar.gz
|
||||
|
||||
echo "→ linking $BIN_DIR/keep…"
|
||||
ssh "$HOST" "mkdir -p $BIN_DIR && chmod +x $INSTALL_DIR/keep.mjs && ln -sf $INSTALL_DIR/keep.mjs $BIN_DIR/keep"
|
||||
|
||||
echo "→ verifying…"
|
||||
ssh "$HOST" "$BIN_DIR/keep --help" | head -1
|
||||
|
||||
echo "✓ done — re-run this script any time the CLI changes; safe to overwrite."
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"@types/node": "^22.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"tsx": "^4.15.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
|
||||
@@ -10,18 +10,20 @@
|
||||
"dev:server": "tsx watch --env-file=.env src/server/index.ts",
|
||||
"cli": "tsx src/cli/index.ts",
|
||||
"build": "tsc",
|
||||
"build:cli-bundle": "esbuild src/cli/index.ts --bundle --platform=node --format=esm --outfile=dist-bundle/keep.mjs --external:node:* --external:libsodium-wrappers && node scripts/copy-cli-deps.mjs",
|
||||
"start": "node --env-file=.env dist/server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"express": "^4.19.2",
|
||||
"libsodium-wrappers": "^0.7.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"@types/node": "^22.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"tsx": "^4.15.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
|
||||
31
scripts/bootstrap-project.sh
Executable file
31
scripts/bootstrap-project.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pushes a project's first env file and grants read-only access to one
|
||||
# or more already-registered recipients (typically the VPS boxes that
|
||||
# will deploy it), in one call.
|
||||
#
|
||||
# Usage:
|
||||
# ./bootstrap-project.sh <vault> <env-file> <recipient-id> [recipient-id...]
|
||||
#
|
||||
# The pusher (this machine's identity) becomes the vault's first,
|
||||
# read-write recipient automatically — see IMPLEMENTATION.md. Every
|
||||
# recipient-id argument here is granted read-only; run `keep grant`
|
||||
# by hand afterward for anyone who needs write access too.
|
||||
set -euo pipefail
|
||||
|
||||
vault="${1:?usage: bootstrap-project.sh <vault> <env-file> <recipient-id> [recipient-id...]}"
|
||||
file="${2:?usage: bootstrap-project.sh <vault> <env-file> <recipient-id> [recipient-id...]}"
|
||||
shift 2
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "error: at least one recipient id is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
keep push "$vault" --file "$file"
|
||||
|
||||
for recipient in "$@"; do
|
||||
keep grant "$vault" "$recipient" --read-only
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "done. verify with: KEEP_ADMIN_PASSWORD=... keep overview"
|
||||
32
scripts/copy-cli-deps.mjs
Normal file
32
scripts/copy-cli-deps.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copies the one real runtime dependency the CLI bundle can't statically
|
||||
// inline (libsodium-wrappers, and its own dependency libsodium) next to
|
||||
// the bundle, so dist-bundle/ is a small, complete, self-contained
|
||||
// directory -- no npm install needed wherever it ends up.
|
||||
//
|
||||
// Why not just bundle it: libsodium-wrappers' published ESM build follows
|
||||
// a relative import ("./libsodium.mjs") that's broken in that package's
|
||||
// own dist layout -- the exact bug crypto.ts's own comment documents.
|
||||
// Forcing static inlining via a literal require() (the only way to route
|
||||
// esbuild's resolver to the *working* CJS build instead) was tried and
|
||||
// technically resolved, but produced "No secure random number generator
|
||||
// found" at runtime -- inlining the WASM engine breaks its own Node
|
||||
// crypto feature-detection. Shipping the real, unmodified package files
|
||||
// is more reliable than fighting that.
|
||||
import { cpSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const outDir = join(root, 'dist-bundle', 'node_modules');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
for (const pkg of ['libsodium-wrappers', 'libsodium']) {
|
||||
const src = join(root, 'node_modules', pkg);
|
||||
if (!existsSync(src)) {
|
||||
console.error(`missing node_modules/${pkg} -- run npm install first`);
|
||||
process.exit(1);
|
||||
}
|
||||
cpSync(src, join(outDir, pkg), { recursive: true });
|
||||
}
|
||||
|
||||
console.log('dist-bundle/node_modules populated (libsodium-wrappers, libsodium)');
|
||||
@@ -1,14 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pulls a project's env from keep, then brings its docker compose stack up.
|
||||
# Pulls a project's env from keep (if it's bootstrapped onto keep), then
|
||||
# brings its docker compose stack up.
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy.sh <project> [env]
|
||||
#
|
||||
# Expects, per project, a directory at $DEPLOY_ROOT/<project>/ containing
|
||||
# a docker-compose.yml. Vault key pulled is "<project>/<env>" (env
|
||||
# defaults to "production"). Requires a keep identity already registered
|
||||
# and granted read access to that vault on this machine
|
||||
# (see INTEGRATION.md).
|
||||
# Expects a directory at $DEPLOY_ROOT/<project>/ containing a
|
||||
# docker-compose.yml.
|
||||
#
|
||||
# The vault key is NOT assumed to match the deploy-directory name — repo
|
||||
# names, deploy directory names, and vault keys are three independent
|
||||
# strings in practice (a repo can be named one thing, its deploy
|
||||
# directory another, entirely by history/accident). Resolution:
|
||||
# 1. $dir/.keep-vault, if present — a one-time-per-project marker
|
||||
# (`echo "<vault key>" > "$dir/.keep-vault"` when bootstrapping),
|
||||
# one line, human-readable, cat-able to check what's configured.
|
||||
# 2. Otherwise falls back to "<project>/<env>" — works when the vault
|
||||
# key genuinely does match, and is also the graceful "not
|
||||
# bootstrapped yet" case for a keep pull that's expected to fail.
|
||||
#
|
||||
# `keep pull` is always best-effort: a missing vault, a missing grant, or
|
||||
# `keep` not being installed at all is never fatal here — it means "keep
|
||||
# doesn't apply to this project (yet)," never "stop the deploy." Whatever
|
||||
# .env already exists on disk (hand-copied, or from a previous keep pull)
|
||||
# is what docker compose reads either way.
|
||||
set -euo pipefail
|
||||
|
||||
project="${1:?usage: deploy.sh <project> [env]}"
|
||||
@@ -21,5 +36,18 @@ if [ ! -d "$dir" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
keep pull "$project/$env" --out "$dir/.env"
|
||||
if [ -f "$dir/.keep-vault" ]; then
|
||||
vault="$(cat "$dir/.keep-vault")"
|
||||
else
|
||||
vault="$project/$env"
|
||||
fi
|
||||
|
||||
if command -v keep >/dev/null 2>&1 && keep pull "$vault" --out "$dir/.env.new" 2>/dev/null; then
|
||||
mv "$dir/.env.new" "$dir/.env"
|
||||
echo " ✓ pulled $vault"
|
||||
else
|
||||
rm -f "$dir/.env.new"
|
||||
echo " · keep pull skipped or failed for $vault (using existing .env, if any)"
|
||||
fi
|
||||
|
||||
(cd "$dir" && docker compose up -d)
|
||||
|
||||
23
scripts/install-cli.sh
Executable file
23
scripts/install-cli.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Builds keep and symlinks the CLI onto PATH as a real `keep` command --
|
||||
# no `npm run cli --` from inside this repo, no npm global install (which
|
||||
# needs root/write access to the system Node install on most hosts).
|
||||
#
|
||||
# Since this is a symlink to dist/cli/index.js rather than a copy, a
|
||||
# future `git pull && npm run build` here is the entire upgrade story --
|
||||
# no need to re-run this script.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
BIN_DIR="${KEEP_BIN_DIR:-$HOME/.local/bin}"
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
npm run build
|
||||
chmod +x dist/cli/index.js
|
||||
ln -sf "$(pwd)/dist/cli/index.js" "$BIN_DIR/keep"
|
||||
|
||||
echo "keep CLI linked: $BIN_DIR/keep -> $(pwd)/dist/cli/index.js"
|
||||
case ":$PATH:" in
|
||||
*":$BIN_DIR:"*) echo "$BIN_DIR is already on PATH — try: keep --help" ;;
|
||||
*) echo "warning: $BIN_DIR is not on PATH — add it to your shell profile" ;;
|
||||
esac
|
||||
@@ -1,4 +1,4 @@
|
||||
import { adminFetch, expectOk } from '../signedFetch.js';
|
||||
import { adminFetch, expectOk, serverUrl } from '../signedFetch.js';
|
||||
|
||||
export async function recipientAdd(label: string, publicKey: string): Promise<void> {
|
||||
const data = await expectOk(await adminFetch('POST', '/api/admin/recipients', { label, publicKey }));
|
||||
@@ -20,9 +20,18 @@ export async function recipientRemove(recipientId: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function adminOverview(): Promise<void> {
|
||||
// Printed before the fetch, not after -- which server this hit is exactly
|
||||
// the thing that's ambiguous from the rest of the output alone (no vault
|
||||
// or recipient here says "production" or "dev"), and defaults silently
|
||||
// to localhost:3050 if KEEP_SERVER_URL isn't set. Printing it first means
|
||||
// it still shows up even when the fetch itself fails ("error: fetch
|
||||
// failed") -- the exact case where knowing the target mattered most and
|
||||
// previously showed nothing at all.
|
||||
console.log(`server: ${serverUrl()}`);
|
||||
|
||||
const data = await expectOk(await adminFetch('GET', '/api/admin/overview'));
|
||||
|
||||
console.log('recipients:');
|
||||
console.log('\nrecipients:');
|
||||
if (!data.recipients.length) console.log(' none yet.');
|
||||
for (const r of data.recipients) {
|
||||
console.log(` ${r.recipientId} ${r.label}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import { requireIdentityAndRecipientId } from '../identityStore.js';
|
||||
import { signedFetch, adminFetch, expectOk } from '../signedFetch.js';
|
||||
import { parseEnv, serializeEnv } from '../envFormat.js';
|
||||
import { parseEnv, serializeEnv, buildEnvTemplate, fillEnvTemplate } from '../envFormat.js';
|
||||
import {
|
||||
ready,
|
||||
Identity,
|
||||
@@ -16,8 +16,13 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean):
|
||||
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||
|
||||
if (!fs.existsSync(file)) throw new Error(`file not found: ${file}`);
|
||||
const values = parseEnv(fs.readFileSync(file, 'utf8'));
|
||||
const payload = enc.fromUtf8(JSON.stringify(values));
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
const values = parseEnv(raw);
|
||||
const template = buildEnvTemplate(raw);
|
||||
// __keepEnvelope marks the new wrapped shape so vaultPull can tell it
|
||||
// apart from an old vault pushed before templates existed, which is
|
||||
// just the flat { KEY: value } object with no wrapper at all.
|
||||
const payload = enc.fromUtf8(JSON.stringify({ __keepEnvelope: true, values, template }));
|
||||
|
||||
const existsRes = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/exists`));
|
||||
|
||||
@@ -55,7 +60,7 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean):
|
||||
console.log(`pushed '${vaultKey}' — ${Object.keys(values).length} key${Object.keys(values).length === 1 ? '' : 's'}, wrapped for ${wrapFor.size} recipient${wrapFor.size === 1 ? '' : 's'}${purge ? ' (purged previous version)' : ''}.`);
|
||||
}
|
||||
|
||||
export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean): Promise<void> {
|
||||
export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean, wantTemplate: boolean): Promise<void> {
|
||||
await ready();
|
||||
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||
|
||||
@@ -66,9 +71,27 @@ export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFil
|
||||
if (!symmetricKey) throw new Error('failed to unwrap this vault\'s key — wrong identity, or the grant is stale');
|
||||
|
||||
const plaintext = secretboxDecrypt(symmetricKey, enc.fromBase64(data.nonce), enc.fromBase64(data.ciphertext));
|
||||
const values = JSON.parse(enc.toUtf8(plaintext)) as Record<string, string>;
|
||||
const parsed = JSON.parse(enc.toUtf8(plaintext)) as Record<string, unknown>;
|
||||
|
||||
const out = format === 'json' ? JSON.stringify(values, null, 2) : serializeEnv(values);
|
||||
// Vaults pushed before templates existed are just the flat { KEY: value }
|
||||
// object with no wrapper — __keepEnvelope tells the two shapes apart.
|
||||
const values = (parsed.__keepEnvelope ? parsed.values : parsed) as Record<string, string>;
|
||||
const template = parsed.__keepEnvelope ? (parsed.template as string | undefined) : undefined;
|
||||
|
||||
if (wantTemplate) {
|
||||
if (template === undefined) throw new Error(`vault '${vaultKey}' has no saved template (pushed before template support, or the source file had no comments to preserve)`);
|
||||
if (outFile) {
|
||||
fs.writeFileSync(outFile, template);
|
||||
console.error(`wrote template to ${outFile}${previous ? ' (previous version)' : ''}`);
|
||||
} else {
|
||||
process.stdout.write(template);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const out = format === 'json'
|
||||
? JSON.stringify(values, null, 2)
|
||||
: template !== undefined ? fillEnvTemplate(template, values) : serializeEnv(values);
|
||||
if (outFile) {
|
||||
fs.writeFileSync(outFile, out);
|
||||
console.error(`wrote ${Object.keys(values).length} key(s) to ${outFile}${previous ? ' (previous version)' : ''}`);
|
||||
|
||||
@@ -20,3 +20,38 @@ export function serializeEnv(values: Record<string, string>): string {
|
||||
.map(([k, v]) => `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`)
|
||||
.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// Redacts values while keeping comments, blank lines, and key order intact
|
||||
// — the whole point is to preserve the "why"/"where to get this" context
|
||||
// that plain parseEnv() throws away, without keeping the actual secret
|
||||
// around in the template.
|
||||
// Inverse of buildEnvTemplate: substitutes real values back into a
|
||||
// template's `KEY=` lines while leaving comments/blank lines/order intact.
|
||||
export function fillEnvTemplate(template: string, values: Record<string, string>): string {
|
||||
return template
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
const eq = line.indexOf('=');
|
||||
if (eq === -1) return line;
|
||||
const key = line.slice(0, eq);
|
||||
const value = values[key.trim()] ?? '';
|
||||
return `${key}=${/[\s#"']/.test(value) ? JSON.stringify(value) : value}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildEnvTemplate(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map(rawLine => {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
const eq = line.indexOf('=');
|
||||
if (eq === -1) return line;
|
||||
return `${line.slice(0, eq)}=`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ async function main(): Promise<void> {
|
||||
if (cmd === 'overview') return await adminOverview();
|
||||
|
||||
if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!, boolFlag(rest, 'purge'));
|
||||
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'));
|
||||
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'), boolFlag(rest, 'template'));
|
||||
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
|
||||
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
|
||||
if (cmd === 'log') return await vaultLog(sub);
|
||||
@@ -54,7 +54,9 @@ function printUsage(): void {
|
||||
keep identity set-id <recipient-id>
|
||||
|
||||
keep push <vault> [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback)
|
||||
keep pull <vault> [--format env|json] [--out <path>] [--previous]
|
||||
keep pull <vault> [--format env|json] [--out <path>] [--previous] [--template]
|
||||
(fills the saved template with real values by default, if one exists;
|
||||
--template: comments/structure with values redacted, not the secrets)
|
||||
keep grant <vault> <recipient-id> [--read-only]
|
||||
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
|
||||
keep log <vault>
|
||||
|
||||
Reference in New Issue
Block a user