Implement announce/watch: a deploy signal that reverses CI's reach
All checks were successful
Docker / build-and-push (push) Successful in 1m58s
All checks were successful
Docker / build-and-push (push) Successful in 1m58s
CI announces a new image tag to keep (any grant is enough, read-only included); each deploy target polls for it locally instead of CI holding standing SSH access to production. keep stays a pure relay — the tag lives outside the encrypted vault payload in its own table, and keep never executes anything itself. Adds vault_announcements, POST/GET /vaults/:key/announce, and the `keep announce`/`keep watch` CLI commands, per PROPOSAL-announce-and-agent.md (now marked implemented). Verified live: a read-only identity announcing successfully and being logged, watch picking up the tag on its first poll, and watch exiting nonzero for an unknown/ungranted vault. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,8 @@ keep push <vault> [--file .env] [--purge] -- encrypt + upload; requires a WRITE
|
||||
keep pull <vault> [--format env|json] [--out <path>] [--previous] -- fetch + decrypt; requires a grant
|
||||
keep grant <vault> <recipient-id> [--read-only] -- add a recipient to a vault you have WRITE access to
|
||||
keep log <vault> -- tail the access log for one vault
|
||||
keep announce <vault> --tag <tag> -- record a deploy tag; requires ANY grant on the vault (read-only is enough)
|
||||
keep watch <vault> [--interval 60] -- poll for a new announced tag; exits nonzero if the vault doesn't exist / no grant
|
||||
|
||||
# admin operations — gated by ADMIN_PASSWORD, see below
|
||||
keep recipient add --label "..." --pubkey <hex>
|
||||
@@ -215,6 +217,75 @@ Went with (2) — a global admin password is an acceptable trust model for
|
||||
"one person or small team running their own homelab," which is the
|
||||
actual scale this is built for.
|
||||
|
||||
## Announce/watch — a deploy signal, not a secret
|
||||
|
||||
CI producing a fresh image on every push still left deployment as a
|
||||
manual step, and the obvious fix — CI SSHes into the VPS and redeploys —
|
||||
was rejected: it means a standing SSH credential with production-deploy
|
||||
rights sits in CI secrets, reachable by whatever's executing on a shared
|
||||
runner across every project it builds. A compromised build step in one
|
||||
project could then reach every other project's deploy target, which is
|
||||
a real regression from `keep`'s own "read no longer implies write"
|
||||
posture (see above).
|
||||
|
||||
The fix that doesn't reintroduce that problem: reverse the direction.
|
||||
CI never reaches into a VPS — it tells `keep` "there's a new image for
|
||||
this project" (`keep announce <vault> --tag <tag>`), and each VPS, which
|
||||
already has to run *something* locally to redeploy itself, polls `keep`
|
||||
for that signal (`keep watch <vault>`) and acts on it locally, using an
|
||||
identity and grant it already legitimately holds. `keep`'s server never
|
||||
executes anything remotely — it stays a pure relay, same posture as
|
||||
everything else it does.
|
||||
|
||||
This was deliberately not built as "`keep` executes the redeploy
|
||||
itself," even though `keep` typically runs on the same box as its
|
||||
deploy targets and that would skip the polling indirection entirely.
|
||||
Doing so would trade away the property that's been the throughline of
|
||||
`keep`'s whole design: today, a fully compromised `keep` server — not
|
||||
just a compromised client identity — can't do anything worse than leak
|
||||
ciphertext and metadata, because the server never holds a decryption
|
||||
key. The moment `keep` can also execute a command on `announce`, an
|
||||
ordinary web app bug (not even a cryptographic break) becomes "attacker
|
||||
triggers arbitrary redeploys" — a new class of bad outcome, not a bigger
|
||||
version of an existing one.
|
||||
|
||||
An announced tag is stored outside the encrypted vault payload
|
||||
(`vault_announcements`, one row per vault — latest known state, not a
|
||||
history, same shape as `vaults.prev_ciphertext`) since an image tag
|
||||
isn't a secret and doesn't belong inside a secrets blob alongside real
|
||||
credentials. `POST /vaults/:key/announce` only requires *a* grant, not a
|
||||
write grant — `hasGrant`, not `hasWriteGrant` — since announcing isn't a
|
||||
mutation of a vault's secret content, just a signal; a CI identity for a
|
||||
project can stay read-only even with this feature in play. `keep watch`
|
||||
is deliberately dumb polling, not a websocket or long-poll — matches the
|
||||
pattern already used elsewhere in this project family, and a 60-second
|
||||
detection lag for a deploy is an acceptable tradeoff against the
|
||||
complexity of push-based notification.
|
||||
|
||||
Two things worth stating plainly about what this does and doesn't
|
||||
change:
|
||||
|
||||
- The tag itself is now server-visible, where previously not even the
|
||||
fact that a new image existed was something `keep` needed to know.
|
||||
Accepted tradeoff — a git short-SHA isn't sensitive, and the feature
|
||||
only works if `keep` can see it.
|
||||
- A compromised read-only CI identity could announce a *false* tag,
|
||||
pointing a deploy target at an attacker-chosen image. `keep` never
|
||||
pulls or runs that image itself, only relays the string — the actual
|
||||
`docker compose pull` on the deploy target still pulls from whatever
|
||||
registry it's configured for, using the tag it was told. This is
|
||||
equivalent in severity to that CI identity's build step being
|
||||
compromised in the first place (it could already push a bad image
|
||||
under a legitimate tag), not a new capability.
|
||||
|
||||
A bulk multi-project deploy script (looping over a fixed project list,
|
||||
`docker compose up -d` per project) adopts this by checking for a
|
||||
per-project `.keep-vault` marker file before calling `keep pull`; a
|
||||
missing marker falls through to exactly what the script did before
|
||||
`keep` existed — never a hard failure, since not every project will be
|
||||
bootstrapped onto `keep` at the same time. `scripts/deploy.sh` in this
|
||||
repo is the single-project version of the same pattern.
|
||||
|
||||
## Deploy integration
|
||||
|
||||
The actual motivating use case — a deploy script pulling secrets instead
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# Proposal: announce + a self-deploying agent, with a no-`keep` fallback
|
||||
|
||||
**Status:** proposed, not built. Self-contained — written so a fresh agent
|
||||
with no prior conversation context can pick this up and implement it
|
||||
without anything explained first.
|
||||
**Status:** implemented (§1–§3, §6 shape). `announce`/`watch`, the
|
||||
`vault_announcements` table, and the API routes described below are
|
||||
built — see [IMPLEMENTATION.md](./IMPLEMENTATION.md)'s "Announce/watch"
|
||||
section for the as-built version of the design rationale, and the CLI
|
||||
surface list there for the exact commands. §4 (patching the real bulk
|
||||
deploy script) and §6 (the CI workflow step) are per-project changes
|
||||
made outside this repo, on the host and in each project's own
|
||||
`.gitea/workflows/docker.yml` — not something this repo's code can
|
||||
contain. This file is kept as the original design record, superseded by
|
||||
IMPLEMENTATION.md as the source of truth for what's actually built.
|
||||
|
||||
## Context (read this first)
|
||||
|
||||
|
||||
39
README.md
39
README.md
@@ -141,6 +141,29 @@ Pushing to an existing vault requires a *write* grant — a read-only
|
||||
recipient can pull but can't push or grant others access. A brand-new
|
||||
vault's first push is always read-write for the pusher.
|
||||
|
||||
## Announcing deploys
|
||||
|
||||
`keep` can also relay a small "there's a new image" signal alongside a
|
||||
vault, so CI never needs standing SSH access to a deploy target — CI
|
||||
announces a tag, and whatever already redeploys that box locally polls
|
||||
for it instead of being reached into from outside:
|
||||
|
||||
```bash
|
||||
# CI, after a successful build/push — any grant is enough, read-only included:
|
||||
keep announce myapp/production --tag <short-sha>
|
||||
|
||||
# The deploy target polls for a change and prints it:
|
||||
keep watch myapp/production --interval 60
|
||||
```
|
||||
|
||||
An announced tag isn't a secret — it lives outside the encrypted vault
|
||||
payload, in its own small table, and is visible to the server (same as
|
||||
vault names and access timestamps already are). `keep` never pulls or
|
||||
runs the announced image itself; it only relays the string. See
|
||||
[IMPLEMENTATION.md](./IMPLEMENTATION.md) for how this fits into a bulk
|
||||
deploy script with a no-`keep` fallback for projects not yet bootstrapped
|
||||
onto it.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
@@ -177,12 +200,16 @@ CLI-side: `KEEP_SERVER_URL` (default `http://localhost:3050`),
|
||||
|
||||
Implemented: identity, push/pull/grant/revoke/log, read/write grant
|
||||
scoping, one-step rollback with an explicit purge mode for
|
||||
compromise-driven rotations, admin recipient management, Docker deploy.
|
||||
Verified end-to-end — two and three independent identities, read-only
|
||||
grants correctly blocked from push/grant, grants preserved across
|
||||
rotation, previous-version pull working and correctly wiped by
|
||||
`--purge` for every recipient, malformed-auth rejection — against both
|
||||
a local server and the built Docker image. See
|
||||
compromise-driven rotations, admin recipient management, an admin
|
||||
cross-vault `overview`, deploy-signal `announce`/`watch`, a
|
||||
`scripts/deploy.sh` wrapper, and Docker deploy. Verified end-to-end —
|
||||
two and three independent identities, read-only grants correctly
|
||||
blocked from push/grant, grants preserved across rotation,
|
||||
previous-version pull working and correctly wiped by `--purge` for
|
||||
every recipient, a read-only identity successfully announcing while
|
||||
still being logged, `watch` picking up an announced tag and exiting
|
||||
nonzero for an unknown/ungranted vault, malformed-auth rejection —
|
||||
against both a local server and the built Docker image. See
|
||||
[IMPLEMENTATION.md](./IMPLEMENTATION.md) for the full design and its
|
||||
resolved decisions (per-secret-key granularity deliberately not built —
|
||||
use multiple vaults instead).
|
||||
|
||||
@@ -114,3 +114,35 @@ export async function vaultLog(vaultKey: string): Promise<void> {
|
||||
console.log(`${new Date(e.accessed_at * 1000).toISOString()} ${e.action.padEnd(6)} ${e.recipient_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function vaultAnnounce(vaultKey: string, tag: string): Promise<void> {
|
||||
await ready();
|
||||
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||
await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/announce`, { tag }));
|
||||
console.log(`announced '${vaultKey}' @ ${tag}`);
|
||||
}
|
||||
|
||||
async function fetchAnnouncement(vaultKey: string): Promise<{ tag: string; announcedAt: number; announcedBy: string } | null> {
|
||||
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||
const res = await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/announce`);
|
||||
if (res.status === 404) return null;
|
||||
return await expectOk(res);
|
||||
}
|
||||
|
||||
// Deliberately dumb — plain polling, not a websocket or long-poll. A
|
||||
// 60s detection lag for a deploy signal is fine; don't build push-based
|
||||
// notification for this.
|
||||
export async function vaultWatch(vaultKey: string, intervalSeconds: number): Promise<void> {
|
||||
await ready();
|
||||
console.log(`watching '${vaultKey}' every ${intervalSeconds}s (ctrl-c to stop)...`);
|
||||
|
||||
let lastTag: string | null = null;
|
||||
for (;;) {
|
||||
const announcement = await fetchAnnouncement(vaultKey);
|
||||
if (announcement && announcement.tag !== lastTag) {
|
||||
lastTag = announcement.tag;
|
||||
console.log(`${new Date(announcement.announcedAt * 1000).toISOString()} ${vaultKey} ${announcement.tag} (by ${announcement.announcedBy})`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, intervalSeconds * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { identityInit, identityShow, identitySetId } from './commands/identity.js';
|
||||
import { recipientAdd, recipientList, recipientRemove, adminOverview } from './commands/recipient.js';
|
||||
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog } from './commands/vault.js';
|
||||
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog, vaultAnnounce, vaultWatch } from './commands/vault.js';
|
||||
|
||||
function flag(args: string[], name: string, fallback?: string): string | undefined {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
@@ -35,6 +35,8 @@ async function main(): Promise<void> {
|
||||
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);
|
||||
if (cmd === 'announce') return await vaultAnnounce(sub, flag(rest, 'tag')!);
|
||||
if (cmd === 'watch') return await vaultWatch(sub, Number(flag(rest, 'interval', '60')));
|
||||
|
||||
printUsage();
|
||||
process.exitCode = cmd ? 1 : 0;
|
||||
@@ -56,6 +58,8 @@ function printUsage(): void {
|
||||
keep grant <vault> <recipient-id> [--read-only]
|
||||
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
|
||||
keep log <vault>
|
||||
keep announce <vault> --tag <tag> (requires any grant, read-only is enough)
|
||||
keep watch <vault> [--interval 60] (polls for a new announced tag; ctrl-c to stop)
|
||||
|
||||
keep recipient add --label <label> --pubkey <hex> (admin)
|
||||
keep recipient list (admin)
|
||||
|
||||
@@ -61,6 +61,17 @@ db.exec(`
|
||||
action TEXT NOT NULL,
|
||||
accessed_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Latest known deploy tag for a vault — "current state", not a
|
||||
-- history, same one-row-per-vault shape as the vaults table's own
|
||||
-- prev_ciphertext retention. An image tag isn't a secret, so this
|
||||
-- lives outside the encrypted payload entirely.
|
||||
CREATE TABLE IF NOT EXISTS vault_announcements (
|
||||
vault_key TEXT PRIMARY KEY,
|
||||
tag TEXT NOT NULL,
|
||||
announced_at INTEGER NOT NULL,
|
||||
announced_by TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
export interface VaultRow {
|
||||
@@ -88,6 +99,13 @@ export interface GrantRow {
|
||||
granted_at: number;
|
||||
}
|
||||
|
||||
export interface AnnouncementRow {
|
||||
vault_key: string;
|
||||
tag: string;
|
||||
announced_at: number;
|
||||
announced_by: string;
|
||||
}
|
||||
|
||||
export function getVault(vaultKey: string): VaultRow | undefined {
|
||||
return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as VaultRow | undefined;
|
||||
}
|
||||
@@ -225,7 +243,7 @@ export function deleteGrant(vaultKey: string, recipientId: string): void {
|
||||
db.prepare(`DELETE FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`).run(vaultKey, recipientId);
|
||||
}
|
||||
|
||||
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke'): void {
|
||||
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke' | 'announce'): void {
|
||||
db.prepare(
|
||||
`INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`,
|
||||
).run(vaultKey, recipientId, action, Math.floor(Date.now() / 1000));
|
||||
@@ -236,3 +254,17 @@ export function getAccessLog(vaultKey: string, limit = 50): { recipient_id: stri
|
||||
`SELECT recipient_id, action, accessed_at FROM access_log WHERE vault_key = ? ORDER BY accessed_at DESC LIMIT ?`,
|
||||
).all(vaultKey, limit) as { recipient_id: string; action: string; accessed_at: number }[];
|
||||
}
|
||||
|
||||
// One row per vault — "latest known tag", not a history. Upsert.
|
||||
export function setAnnouncement(vaultKey: string, tag: string, announcedBy: string): void {
|
||||
db.prepare(
|
||||
`INSERT INTO vault_announcements (vault_key, tag, announced_at, announced_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(vault_key) DO UPDATE SET
|
||||
tag = excluded.tag, announced_at = excluded.announced_at, announced_by = excluded.announced_by`,
|
||||
).run(vaultKey, tag, Math.floor(Date.now() / 1000), announcedBy);
|
||||
}
|
||||
|
||||
export function getAnnouncement(vaultKey: string): AnnouncementRow | undefined {
|
||||
return db.prepare(`SELECT * FROM vault_announcements WHERE vault_key = ?`).get(vaultKey) as AnnouncementRow | undefined;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
logAccess,
|
||||
getAccessLog,
|
||||
listRecipients,
|
||||
setAnnouncement,
|
||||
getAnnouncement,
|
||||
} from './db.js';
|
||||
import { requireRecipientAuth, type AuthedRequest } from './auth.js';
|
||||
|
||||
@@ -179,3 +181,42 @@ router.get('/vaults/:key/log', (req: AuthedRequest, res) => {
|
||||
}
|
||||
res.json(getAccessLog(vaultKey));
|
||||
});
|
||||
|
||||
// A deploy tag isn't a secret — it's not stored in the encrypted payload,
|
||||
// just a small side table. Only requires ANY grant (read-only is enough)
|
||||
// since announcing isn't a mutation of vault secrets — a CI identity
|
||||
// should never need write access on a vault just to say "there's a new
|
||||
// image". Resist reaching for hasWriteGrant here by analogy with push.
|
||||
router.post('/vaults/:key/announce', (req: AuthedRequest, res) => {
|
||||
const vaultKey = req.params.key;
|
||||
const recipientId = req.recipientId!;
|
||||
|
||||
if (!hasGrant(vaultKey, recipientId)) {
|
||||
res.status(403).json({ error: 'no access to this vault' });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as { tag?: string };
|
||||
if (!body.tag) {
|
||||
res.status(400).json({ error: 'tag is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAnnouncement(vaultKey, body.tag, recipientId);
|
||||
logAccess(vaultKey, recipientId, 'announce');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/vaults/:key/announce', (req: AuthedRequest, res) => {
|
||||
const vaultKey = req.params.key;
|
||||
if (!hasGrant(vaultKey, req.recipientId!)) {
|
||||
res.status(403).json({ error: 'no access to this vault' });
|
||||
return;
|
||||
}
|
||||
const announcement = getAnnouncement(vaultKey);
|
||||
if (!announcement) {
|
||||
res.status(404).json({ error: 'nothing announced yet' });
|
||||
return;
|
||||
}
|
||||
res.json({ tag: announcement.tag, announcedAt: announcement.announced_at, announcedBy: announcement.announced_by });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user