Skip to content
Open
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,8 @@ and upgrades from older hosts.
- **Local secrets.** Secrets are encrypted with the local `secret.key`; secret
APIs expose names only. A flat can read and return its own secrets, so
deploy code you trust with them. Anyone who can read the data directory
can recover those secrets.
can recover those secrets, unless `host.secret_key_path` keeps the key
elsewhere.
- **Back up your data.** Immutable code versions are not data backups. Back up
the data directory, including `secret.key`; see
[what to back up](docs/configuration.md#what-to-back-up).
Expand Down
199 changes: 182 additions & 17 deletions docs/configuration.md

Large diffs are not rendered by default.

68 changes: 67 additions & 1 deletion docs/container.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,70 @@ who can run `docker` on the host can read the data volume and reach the
container, so treat Docker access as operator access and do not give it to
agents.

## Keeping the key outside the volume

By default `secret.key` lives in the `/data` volume, so a copy of the volume
holds every sealed value and the key that opens it. To keep the key apart, mount
it as a file and name it with `host.secret_key_path`
([Keeping the key outside the data directory](configuration.md#keeping-the-key-outside-the-data-directory)).
The host only reads that file and never creates it, so a read-only mount works.
The container user `65532` must be able to read it. A bind-mounted file keeps its
own owner and mode on the host, so give it to that user, for example
`sudo chown 65532 flats.key && sudo chmod 0400 flats.key`.

Stop the container, move the key out of the volume, and set the path with
one-off containers on the same volume. The last command is the one that you
used to create the container, with the mount of the key added:

```sh
docker stop flats
(umask 077; docker run --rm -v flats-data:/data:ro alpine cat /data/secret.key > flats.key)
docker run --rm -v flats-data:/data alpine rm /data/secret.key
docker run --rm -v flats-data:/data ghcr.io/gosuda/flats:latest config set host.secret_key_path /run/secrets/flats.key
sudo chown 65532 flats.key && sudo chmod 0400 flats.key
docker rm flats
docker run -d --name flats --restart unless-stopped \
-v flats-data:/data -v "$PWD/flats.key:/run/secrets/flats.key:ro" \
ghcr.io/gosuda/flats:latest
```

Add the network and port options of your first command to the last one. A new
host needs a key first, because Flats creates none at a configured path: make it
with `head -c 32 /dev/urandom > flats.key` and set the path before the first
start.

With Docker Compose, add the file as a secret to the `flats` service in
`compose.yaml` (or in an override file). Then stop the service, move the key out
of the Compose volume with the container of the service, set the path, and start
the service again:

```yaml
services:
flats:
secrets:
- flats.key
secrets:
flats.key:
file: ./flats.key
```

```sh
docker compose stop flats
container=$(docker compose ps -a -q flats)
(umask 077; docker run --rm --volumes-from "$container":ro alpine:3.22 cat /data/secret.key > flats.key)
docker run --rm --volumes-from "$container" alpine:3.22 rm /data/secret.key
sudo chown 65532 flats.key && sudo chmod 0400 flats.key
docker compose run --rm --no-deps flats config set host.secret_key_path /run/secrets/flats.key
docker compose up -d --wait
```

A mounted key is visible only inside the container that mounts it. A command that
runs with `docker exec` or `docker compose run` sees it, because they use the
service's mounts. A one-off container from `docker run` and a process on the host
do not. For those, `flats backup` needs `--exclude-secret-key`, and
`flats secret restore-key` needs `--out FILE`. Back up the key file apart from
the volume, because a copy of the volume no longer holds it.

## Upgrading and backups

Upgrade by replacing the container and keeping the volume:
Expand All @@ -287,7 +351,9 @@ docker start flats
```

The backup holds the operator keys and the `secret.key` that opens them; keep
it as private as the keys.
it as private as the keys. With `host.secret_key_path` the key is not in the
volume, and you keep that file apart from the backup
([Keeping the key outside the volume](#keeping-the-key-outside-the-volume)).

## Building the image

Expand Down
9 changes: 7 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ The advisory lock uses the persistent file inode; do not remove or replace it.
Successful shutdown and process exit release it, and failed startup cleans up
before releasing it. `secret.key` is generated into a private synced temporary
file and atomically linked into place without replacing a concurrent winner;
readers see either no key or all 32 bytes.
readers see either no key or all 32 bytes. With `host.secret_key_path` the host
generates nothing: it reads the key file that the setting names and refuses to
start when it cannot.

Shutdown drains the management server (5 s; force-close on failure), stops
core workers, closes public then private networks, closes SQLite and releases
Expand Down Expand Up @@ -566,7 +568,10 @@ and separate browser-side CORS/CSP behavior. WASI has no outbound HTTP ABI.
## Secrets

Values are sealed with AES-256-GCM (random nonce, AAD = secret name) under
`<data>/secret.key`. Only the console and the local CLI (`flats secret set`,
`<data>/secret.key`, or under the file that `host.secret_key_path` names, which
keeps the key out of the data directory and of a copy of it (the host only reads
that file; see [Keeping the key outside the data directory](configuration.md#keeping-the-key-outside-the-data-directory)).
Only the console and the local CLI (`flats secret set`,
loopback only) can set or delete values; APIs and MCP return names and update
times only. The CLI is recognized by its `X-Flats-Client: cli` header on the
loopback listener, so this stops MCP and remote API clients, not a process
Expand Down
1 change: 1 addition & 0 deletions docs/spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ list.
| [Autopilot](autopilot.md) | implemented | An opt-in mode in the new Settings › Experiments tab where agents approve or reject pending approvals for the operations the operator allows, with an operation guide for agents |
| [Windows support](windows-support.md) | partly implemented | Flats on Windows: a PowerShell installer, a per-user service, Windows file names and locks, and a release archive |
| [Secret key recovery](secret-key-recovery.md) | implemented | `flats secret backup-key`, `restore-key` and `reset`: a password-encrypted copy of `secret.key`, its restore against the sealed values, and a reset when both are lost |
| [Secret key path](secret-key-path.md) | implemented | `host.secret_key_path` keeps the host key outside the data directory, for example in a Docker secret or a systemd credential: the host reads it and never creates it, and the key commands and `flats backup` use it |
| [Public instance mode](public-instance.md) | implemented | An opt-in Experiments mode in which every new flat is created Public on the default public network and no private address is handed out or served |
| [Host backup](host-backup.md) | implemented | `flats backup --out` copies a running or stopped host into a directory with a manifest, and `flats backup --verify` checks such a copy: databases as snapshots, write-once blobs in two passes, coherence of the outbox databases of documents, boards and notebooks with `flats.db`, and a self-check that fails to be run again |

Expand Down
5 changes: 3 additions & 2 deletions docs/spec/config-reload.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ table; a key's bucket follows the table's Applies column.

8. **Restart-required keys.** These keys enter the adopted document and
apply at the next start, exactly as they do today: `host.console_addr`,
`host.local_addr`, `host.server_runtime`, `metrics.enabled`,
`metrics.expose_on`, `metrics.per_flat`, `network.private_backend`,
`host.local_addr`, `host.server_runtime`, `host.secret_key_path`,
`metrics.enabled`, `metrics.expose_on`, `metrics.per_flat`,
`network.private_backend`,
`portal.relays`, `portal.discovery`, `portal.max_active_relays`,
`zrok.environment`, `zrok.namespace`, `netbird.management_url`,
`proxy.addr`, and the deprecated `credentials.tailscale_authkey_file`.
Expand Down
49 changes: 35 additions & 14 deletions docs/spec/host-backup.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ Out of scope:
- Scheduling, upload to a remote store, compression, encryption of the copy and
incremental copies.
- A pause of the running host. The requester chose a copy that checks itself.
- The key path setting and key rotation (#224 items 1 and 2). The command reads
`secret.key` at its fixed path. When those items ship, the command must read
the key through the function that they add, and a rotation during a copy must
fail the copy (rule 22).
- Key rotation (#224 item 2). A rotation during a copy must fail the copy (rule
22). The key path setting (#224 item 1) is implemented, and the command reads
the key through the location that it adds. [Secret key path](secret-key-path.md)
says how rules 12, 22, 24, 28, 30, 31 and 35 apply to it.

## The copy

Expand Down Expand Up @@ -167,7 +167,12 @@ these steps and no other:
database of a host of this release, which means a file that is no Flats
database or a `user_version` other than the one this binary expects (rule 25).
A host of an older release migrates its `flats.db` when it starts, and the
refusal says so and names the copy by hand. This check comes last, because
refusal says so and names the copy by hand. With `host.secret_key_path`, it
also refuses, before it opens any database, when the checks of rule 3 of
[secret key path](secret-key-path.md) fail or the key cannot be read. Only the
refusal for a key that cannot be read names `--exclude-secret-key`, which skips
the read and not the checks. The database
check comes last, because
it is the only refusal that opens a database, and opening the database of a
stopped host that left a `-wal` file and no `-shm` creates an empty `-shm`
(rule 11). It creates a new
Expand All @@ -178,7 +183,8 @@ these steps and no other:
Each has the file identity that the system gives (the inode on Unix, the file
index on Windows) and its creation time where the system has one. It lists
the entry names of `flats/.lifecycle/` and each `restore-journal.json` with
its size and modification time. It reads `secret.key`. A journal that exists
its size and modification time. It reads `secret.key`, or the key at the
configured path. A journal that exists
now is data and is copied. It is not a failure.
14. **Write-once files, first pass.** It copies the write-once class. A snapshot
is the file `<kind>-<ms>.sqlite` and, in the current format, the directory
Expand Down Expand Up @@ -271,7 +277,10 @@ these steps and no other:
directory.
22. `secret.key` is read at step 13 and again at step 18. When the two reads
differ, the command fails the backup, so a key change during the copy, such as
a rotation, never pairs one key with the other key's `flats.db`.
a rotation, never pairs one key with the other key's `flats.db`. With
`host.secret_key_path`, both reads use the configured file, the second one
without the refusal of a file at `<data>/secret.key`, and a file that is
gone at the second read fails the backup the same way.
23. On any failure, and on an interrupt (Ctrl-C or SIGTERM) before step 21, the
command removes the partial directory and exits with a failure. `PATH` never
holds an incomplete copy. After a crash a `PATH.partial-<random>` can stay. It
Expand All @@ -288,7 +297,10 @@ flats backup --out PATH [--exclude-secret-key] [--config PATH | --data DIR] [--j
With `--exclude-secret-key`, `data/secret.key` is not copied and the manifest
records `secret_key: "excluded"`. The recovery copy `secret.key.recovery` and
its `.id` file stay, so a copy without the key can still be restored with
`flats secret restore-key`. A holder of the copy can guess the recovery
`flats secret restore-key`. With `host.secret_key_path`, the walk leaves out
`<data>/secret.key` as well and lists it in `excluded`, the manifest records
`secret_key: "external"`, and the flag means that the command does not read
the key ([secret key path](secret-key-path.md), rules 16 to 18). A holder of the copy can guess the recovery
password offline, as
[secret key recovery](secret-key-recovery.md#the-recovery-copy) says. The
output names the folder, the number of files and bytes, whether a secret key
Expand Down Expand Up @@ -333,7 +345,10 @@ flats backup --verify PATH [--json]
every flat secret and every operator key record with a stored value opens with
that key. The code is the one of `flats secret restore-key` (rule 22 of its
spec), reading the immutable connection of rule 25. When the copy has no
`secret.key`, the command says so and skips this check.
`secret.key`, the command says so and skips this check. With
`secret_key: "external"`, `flats backup --out` checks the copy against the key
that it read, and the standalone `--verify` has no key and reports a note
([secret key path](secret-key-path.md), rules 17 and 19).
29. The output lists each failure with its path and reason, and ends with the
count. It prints `ok` and the Flats version that made the copy when nothing
failed.
Expand All @@ -344,13 +359,17 @@ flats backup --verify PATH [--json]
status 2. A refusal exits with status 78, like `flats key`: an existing
`PATH`, a missing or unusable folder, a `PATH` inside the data directory, a
data directory with no `flats.db`, a `flats.db` that is no Flats database, and
a `user_version` that rule 25 refuses.
a `user_version` that rule 25 refuses, and, with `host.secret_key_path`, a
key that the checks of rule 3 of [secret key path](secret-key-path.md) refuse
or that cannot be read.
Any other failure exits with status 1: an unstable host file after 3 copies, a
database that stays incoherent after 3 attempts, a change of the layout during
the copy (rule 19), a changed key (rule 22), a failed verification, and an
input or output error such as a full disk. A failure that a second run can
clear says so: "run the backup again".
31. A refusal writes nothing at `PATH`, and the only file that it can create
input or output error such as a full disk, and, with `host.secret_key_path`,
a key that does not open the sealed values of the copy and a copy that holds
the bytes of the key. The last two say that a second run does not clear them.
A failure that a second run can clear says so: "run the backup again".
31. A refusal writes nothing at `PATH`, including a refusal of the key, and the only file that it can create
anywhere is the empty `-shm` that the schema refusal of rule 12 can leave
beside the database of a stopped host. A failure leaves nothing at `PATH`.

Expand Down Expand Up @@ -381,7 +400,9 @@ flats backup --verify PATH [--json]
[configuration](../configuration.md#what-to-back-up) says. A copy made with
`--exclude-secret-key` needs `secret.key` put back from where the operator
kept it, or `flats secret restore-key` with the recovery password, before the
start.
start. A copy with `secret_key: "external"` brings back `config.json` with
`host.secret_key_path`, and the operator puts the key at that path before
the start.

## Compatibility

Expand Down
Loading
Loading