Compare commits
5 Commits
442c3b7cf5
...
bb7f890a68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb7f890a68 | ||
|
|
74122b25cd | ||
|
|
1b4cd9b826 | ||
|
|
74bee56c54 | ||
|
|
b74564fd9b |
@@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-bundle/
|
||||
.env
|
||||
data/
|
||||
|
||||
|
||||
@@ -31,6 +31,53 @@ 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.
|
||||
|
||||
## 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`:
|
||||
|
||||
27
README.md
27
README.md
@@ -91,22 +91,29 @@ 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
|
||||
```
|
||||
|
||||
## Granting, revoking, rotating
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user