Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ RUN apk add --no-cache ca-certificates tzdata wget tini git rsync openssh-client
# source build uses, so the image running in production was the one NOT
# checking what it executed.
ARG TARGETARCH
ARG TEPLOY_VERSION=v0.1.54
ARG TEPLOY_VERSION=v0.1.55
RUN set -eux; \
archive="teploy_linux_${TARGETARCH:-amd64}.tar.gz"; \
release_url="https://github.com/useteploy/teploy-cli/releases/download/${TEPLOY_VERSION}"; \
Expand All @@ -45,6 +45,10 @@ RUN set -eux; \

COPY --from=builder /teploy-dash /usr/local/bin/teploy-dash

# How `teploy server siblings` recognizes this container, and how Dash proves which
# server hosts it.
LABEL dev.teploy.product="dash"

EXPOSE 3456
VOLUME ["/var/teploy-dash"]

Expand Down
6 changes: 5 additions & 1 deletion Dockerfile.goreleaser
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ RUN apk add --no-cache ca-certificates tzdata wget tini git rsync openssh-client
# Teploy CLI - used by dash to delegate deploy/rollback actions. BuildKit sets
# TARGETARCH from the goreleaser build platform (amd64 or arm64).
ARG TARGETARCH
ARG TEPLOY_VERSION=v0.1.54
ARG TEPLOY_VERSION=v0.1.55
RUN set -eux; \
archive="teploy_linux_${TARGETARCH}.tar.gz"; \
release_url="https://github.com/useteploy/teploy-cli/releases/download/${TEPLOY_VERSION}"; \
Expand All @@ -23,6 +23,10 @@ RUN set -eux; \

COPY teploy-dash /usr/local/bin/teploy-dash

# How `teploy server siblings` recognizes this container, and how Dash proves which
# server hosts it.
LABEL dev.teploy.product="dash"

EXPOSE 3456
VOLUME ["/var/teploy-dash"]

Expand Down
82 changes: 66 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,38 @@ CLI's fleet engine (v0.1.52 on the dashboard host, capability `backup-fleet-v1`;
an older CLI is a note and the page stays empty — mixed fleets keep working
because the engine runs on each server, not here).

**Protect my data** (teploy v0.1.55, capability `backup-setup-v1`) takes a new user to
verified, encrypted, off-site backups from this page, in six steps. The manual controls
stay under **Advanced**; both drive the same engine.

1. **Check your servers**, before any key is asked for: reachable, teploy version, aws,
age, python3, free disk. Tools and old server binaries are only changed with consent.
2. **Storage.** Paste an expiring Backblaze key with listBuckets, writeBuckets and
writeKeys. Dash reads the capabilities and S3 endpoint from it, reuses a bucket or
creates a private `teploy-backups-<random>`, merges its lifecycle rules, and mints
one key per server limited to the bucket and `teploy/<server>/`, without deleteFiles.
Each key must really hide a test object before it reaches a server, or setup stops:
retention cannot work without that. The pasted key lives only in that request.
3. **Recovery key.** Made in your browser and saved as a file, which you then pick again
so the browser can check it. Only the public key reaches Dash.
4. **Alerts**, ntfy or email with a test send. Skippable, and it keeps reminding you.
5. **Turn on.** Shows what each server will cover, including databases teploy does not
manage, which are backed up too unless you untick them, and storage as a multiple of
one backup. "Back up now" or "Start with the next scheduled run".
6. **Prove recovery.** Pick the key file again: Dash reads the first 128 KiB of a real
backup from the server, your browser decrypts it, and Dash records the result.

**Protected** means a verified backup on each covered target, a restore test passed
within 21 days for each covered database, and no database left out of a backup. Volume-only
servers need no restore test. If you chose to skip a database, the page says "Protected,
except" and names it. If you untick everything on a server, Dash writes no policy or cron
line there, leaves any existing policy alone, keeps the server out of failures and alerts,
and shows it as skipped until you tick it again. A server that has a target is left alone unless you tick it, because a
push replaces its target and Dash cannot put the old key back. New,
rebuilt and retired servers each have their own short action on the page.
[docs/recovery.md](docs/recovery.md) covers getting data back; it has not been rehearsed
yet.

- **First-run checklist** — storage → alert channel → recovery-key drill → first
verified backup — shown until complete, then never again.
- **Connect storage once.** Presets for Backblaze B2 (recommended: application
Expand All @@ -284,9 +316,10 @@ because the engine runs on each server, not here).
probes write/read/delete first, then pushes `server backup-target set` to every
server with the credentials on stdin, then forgets them: the record keeps the
bucket name and per-server prefixes only. A B2 key that cannot delete is the
documented good setup (a compromised server cannot destroy history; the bucket's
lifecycle rules remove hidden versions after 30 days). Rotation re-prompts, pushes, verifies
each server and tells you when to revoke the old key.
documented good setup, so long as it can still hide: the probe confirms the test object
is hidden, and stops if the store refuses (a compromised server cannot destroy history;
the bucket's lifecycle rules remove hidden versions after 14 days). Rotation re-prompts,
pushes, verifies each server and tells you when to revoke the old key.
- **Recovery key.** `teploy backup keygen` output shown exactly once (re-auth
required; the private key is stored nowhere); the public half is pushed to
every server and every object is age-encrypted to it *and* the server's own
Expand Down Expand Up @@ -321,18 +354,22 @@ because the engine runs on each server, not here).
remove* once Dash has watched two consecutive good policy backups arrive AND a
restore test of the same engine kind passed. The known hand-written job names
(akiroo-backup, teploy-backup.sh, teploy-wp-dbbackup, omni-backup,
teploy-nucleus-backup, dash-state-backup) are listed as the owner's checklist;
removal itself stays a human action on the server, and old dumps stay until
they age out. Dash cannot list a server's systemd/cron jobs — no CLI read for
that exists yet (recorded as a gap).
- **Dash itself** is not yet covered by the policy engine: the CLI cannot encrypt
to the owner key alone or target arbitrary paths. Until it can, the page says
so and points at [`docs/rebuild-dash.md`](docs/rebuild-dash.md) — the rebuild
runbook and the interim dash-state job (age to the owner key only).

Every action is audited; connecting storage and the recovery key need
`administer.credentials`, the policy and excludes `execute.mutate`, and nothing
here is exposed over MCP.
teploy-nucleus-backup, dash-state-backup) are listed as the owner's checklist,
shown only when Dash finds signs of such jobs: legacy cron lines, or a policy
something else manages. Removal itself stays a human action on the server, and
old dumps stay until they age out. Dash cannot list a server's systemd/cron jobs
(no CLI read for that exists yet, recorded as a gap).
- **Dash itself** is backed up as the `dash/volumes` entry when it runs from the
deployment template. Objects encrypted to the owner key and the server open with
the owner key alone. Dash's volumes hold its SSH key, so a policy with no recovery
key excludes them on the server proved to host this Dash (`TEPLOY_APP` and the
`dev.teploy.product=dash` label, through `server siblings`). If that cannot be proved,
the page warns rather than excluding by name. Paths outside the template's volumes are
reported as uncovered. See [`docs/recovery.md`](docs/recovery.md).

Every action is audited; connecting storage, the recovery key and every step of
"Protect my data" need `administer.credentials`, the policy and excludes
`execute.mutate`, and nothing here is exposed over MCP.
### Cleanup

**Cleanup** answers "what is left over on each server?" with the CLI's read-only
Expand Down Expand Up @@ -739,7 +776,20 @@ so the direct role claim is available here and takes precedence over groups.
| GET | `/api/domains` | Domain health: one row per domain (DNS, route, certificate, HTTP, first failing layer, evidence age), per-server check state, CLI gate. `view.metadata`. |
| GET | `/api/backups` | The fleet backups view: cached per-server status (coverage rows, targets, tooling, last run), storage record (never credentials), recovery-key state + drill, the applied policy, dash-owned restore tests with history, migration readiness, projected storage. `view.metadata`. |
| POST | `/api/backups/refresh` | Ask for a new fleet status read now (the page follows the cache). `execute.mutate`. |
| POST | `/api/backups/storage` | Probe write/read/delete, then push `server backup-target set` to every server with the credentials on stdin and forget them. `{provider: b2\|r2\|custom, bucket, region/account_id/endpoint, base_prefix, access_key, secret_key, rotate?}`; B2 delete-refusal is accepted, others need `delete_refusal_ok`. `administer.credentials`. |
| POST | `/api/backups/storage` | Probe write/read/delete, then push `server backup-target set` to every server with the credentials on stdin and forget them. `{provider: b2\|r2\|custom, bucket, region/account_id/endpoint, base_prefix, access_key, secret_key, rotate?}`; a refused delete needs `delete_refusal_ok`, except on B2, where it connects with a warning, as does an accepted delete that leaves the object readable. `administer.credentials`. |
| POST | `/api/backups/setup/preflight` | Check servers: `{servers?, install_tools?, upgrade_binary?}`; the last two name servers whose tools or binary may be changed. `administer.credentials`, as is everything under `/api/backups/setup/`. |
| POST | `/api/backups/setup/storage/check` | Read a Backblaze key: `{key_id, application_key, bucket?}` returns its missing capabilities, expiry and buckets with their warnings. The key is forgotten with the request. |
| POST | `/api/backups/setup/storage/apply` | Set up a bucket and a key per named server: `{password, key_id, application_key, servers, bucket?, accept_warnings?, replace_configured?, rotate?, empty_base?, hide_days?, s3_managed?}`. Rerunning skips servers verified in the operation that pushed their key, and pushes a new key to the rest. Older keys go only after a push that succeeded and verified. |
| POST | `/api/backups/setup/owners` | Clear a rebuilt server's old marker: `{server, fingerprint, force?, password?}`. Forcing needs the password. |
| POST | `/api/backups/setup/leftovers/dismiss` | Dismiss a notice about a key Dash could not delete: `{key_id}`. |
| POST | `/api/backups/setup/recovery` | Record the public key the browser made: `{password, public_key}`. |
| POST | `/api/backups/setup/alerts/skip` | Skip alerts for now; the page keeps reminding. |
| POST | `/api/backups/setup/coverage` | What named servers would cover, with the storage multiplier: `{servers}`. |
| POST | `/api/backups/setup/turn-on` | Write the policy to named servers: `{password, servers, start: now\|next, skip?, interval?, preset?, skip_volumes?, volume_max_gb?}`. Unmanaged databases are adopted unless named in `skip` by server. What is left out keeps its existing value. |
| POST | `/api/backups/setup/run` | Back up now: `{servers}`, through a detached run. |
| POST | `/api/backups/setup/proof/start` | Read the first 128 KiB of the newest encrypted backup, as stored, for the browser to decrypt. |
| POST | `/api/backups/setup/proof/result` | Record the outcome: `{key, passed, detail?}`. |
| POST | `/api/backups/setup/retire` | Retire a server: `{password, key_id, application_key, server, data: keep\|remove, confirm?}`. |
| POST | `/api/backups/recovery-key/generate` | `teploy backup keygen` — the private key is returned exactly once and stored nowhere. Re-auth (account password) required. `administer.credentials`. |
| POST | `/api/backups/recovery-key/confirm` | "I saved it": pushes the public key as `--recipient` to every server. `administer.credentials`. |
| POST | `/api/backups/recovery-key/drill` | Record the decrypt drill: `{output}` is the JSON `teploy backup fetch --identity` printed; checked against the assigned object and its plausible plaintext size. `administer.credentials`. |
Expand Down
4 changes: 2 additions & 2 deletions ci/contracts.pin
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
# this file records WHICH revision dash CI proves it can decode. Bump both
# lines together, quoting the MANIFEST's revision table, in the same change
# that updates .github/workflows/ci.yml expectations.
cli_rev=59e37d6ba9fe67708f9415ba73fad3a3abf1c526
corpus_rev=20
cli_rev=d4c9bb3736871edcbcc936ec04a4558bfe6dfc7d
corpus_rev=21
5 changes: 5 additions & 0 deletions ci/validate-contracts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ const artifacts = {
"backup-status-envelope": { schema: "backup-status-envelope.schema.json", classes: { valid: "validate" } },
"backup-run-envelope": { schema: "backup-run-envelope.schema.json", classes: { valid: "validate" } },
"backup-policy-envelope": { schema: "backup-policy-envelope.schema.json", classes: { valid: "validate" } },
// Simple backup setup (corpus rev 21): preflight, the ranged read the browser
// proves a recovery key against, and the detached run.
"backup-preflight-envelope": { schema: "backup-preflight-envelope.schema.json", classes: { valid: "validate" } },
"backup-head-envelope": { schema: "backup-head-envelope.schema.json", classes: { valid: "validate" } },
"backup-detach-envelope": { schema: "backup-detach-envelope.schema.json", classes: { valid: "validate" } },
// Git-source artifacts (corpus rev 16, plan D4): the connected-source
// show envelope, credential provisioning receipts and the poll results.
// Dash adopts these surfaces separately (D4); classified here so the
Expand Down
73 changes: 73 additions & 0 deletions cmd/teploy-dash/frontend-test/age_vendor.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// The vendored browser crypto, on what Dash really runs on: plain HTTP, where
// crypto.subtle is undefined. Fixtures come from the native age CLI, so this also
// proves the two agree. testdata/age/test-key.txt exists only for this test.
import { test, before } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const here = dirname(fileURLToPath(import.meta.url));
const fixture = name => new Uint8Array(readFileSync(join(here, 'testdata/age', name)));
const keyFile = readFileSync(join(here, 'testdata/age/test-key.txt'), 'utf8');
let age;

before(() => {
Object.defineProperty(globalThis.crypto, 'subtle', { value: undefined, configurable: true });
assert.equal(globalThis.crypto.subtle, undefined);
new Function(readFileSync(join(here, '../frontend/js/age.vendor.js'), 'utf8'))();
age = globalThis.TeployAge;
});

test('generates a key and derives the same public half without crypto.subtle', async () => {
const { identity, publicKey } = await age.generate();
assert.match(identity, /^AGE-SECRET-KEY-1[0-9A-Z]+$/);
assert.match(publicKey, /^age1[0-9a-z]{58}$/);
assert.equal(await age.derivePublic(identity), publicKey);
});

test('the file parser skips comment lines and reads the notes', () => {
const text = '# Teploy backup recovery key.\n\n# bucket: my-bucket\n# endpoint: https://s3.us-west-004.backblazeb2.com\n# base: teploy\nAGE-SECRET-KEY-1ABC\n';
const parsed = age.parseIdentityFile(text);
assert.equal(parsed.identity, 'AGE-SECRET-KEY-1ABC');
assert.equal(parsed.notes.bucket, 'my-bucket');
assert.equal(parsed.notes.base, 'teploy');
assert.equal(age.parseIdentityFile('\r\n# c\r\nAGE-SECRET-KEY-1ABC\r\n').identity, 'AGE-SECRET-KEY-1ABC');
});

test('the file parser refuses what is not one key', () => {
assert.throws(() => age.parseIdentityFile('# only comments\n'), /no recovery key/);
assert.throws(() => age.parseIdentityFile('AGE-SECRET-KEY-1ABC\nAGE-SECRET-KEY-1DEF\n'), /more than one/);
assert.throws(() => age.parseIdentityFile('hello\n'), /does not look like/);
});

test('reads a real age-keygen file and matches its public key', async () => {
const { identity } = age.parseIdentityFile(keyFile);
const expected = /public key: (age1[0-9a-z]+)/.exec(keyFile)[1];
assert.equal(await age.derivePublic(identity), expected);
});

test('decrypts a whole file from the native age CLI', async () => {
const { identity } = age.parseIdentityFile(keyFile);
const out = await age.decryptStart(identity, fixture('whole.age'));
assert.equal(out.length, 'hello backups\n'.length);
assert.equal(out.truncated, false);
});

test('a truncated prefix gives its first chunk: 65536 bytes is the proof', async () => {
const { identity } = age.parseIdentityFile(keyFile);
const out = await age.decryptStart(identity, fixture('prefix-70k.age'));
assert.equal(out.length, 65536);
assert.equal(out.truncated, true);
});

test('a wrong key is refused before any byte comes out', async () => {
const { identity } = await age.generate();
await assert.rejects(() => age.decryptStart(identity, fixture('prefix-70k.age')));
await assert.rejects(() => age.decryptStart(identity, fixture('whole.age')));
});

test('a prefix cut before the first chunk ends proves nothing', async () => {
const { identity } = age.parseIdentityFile(keyFile);
await assert.rejects(() => age.decryptStart(identity, fixture('prefix-70k.age').slice(0, 1000)));
});
Loading
Loading