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
40 changes: 24 additions & 16 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,11 @@ written; `flats config unset KEY` removes it.
| `metrics.enabled` | `true` | restart |
| `metrics.expose_on` | `["localhost"]`; unique entries from `localhost`, `console`, `external`; `[]` exposes nowhere | restart |
| `metrics.per_flat` | `true`; set `false` to omit per-flat Prometheus series | restart |
| `network.permitted` | `[]` | immediately from the console; otherwise restart |
| `network.permitted` | `[]` | immediately from the console; a SIGHUP reload or restart from the file |
| `network.private_backend` | `local` | restart |
| `network.default_private` | `""` (follow `private_backend`); `local`, `tailscale`, `netbird` or `proxy` | immediately, for flats created afterwards |
| `network.default_public` | `""` (no public provider); `tailscale-funnel`, `portal`, `zrok`, `cloudflare-quick`, `netbird-expose` or `proxy-public` | immediately, for flats created afterwards |
| `tailscale.console_hosts` | `[]` (console on loopback only) | immediately from the console; otherwise restart |
| `tailscale.console_hosts` | `[]` (console on loopback only) | immediately from the console; a SIGHUP reload or restart from the file |
| `system.upload_max_bytes` | `20971520` (20 MiB) | immediately |
| `system.version_quota` | `20` per flat, `0` = unlimited | immediately; existing versions are kept |
| `system.revision_quota` | `100` unpinned revisions per document or design, `0` = keep all | immediately; lowering it deletes the oldest unpinned revisions at once |
Expand All @@ -172,11 +172,11 @@ written; `flats config unset KEY` removes it.
| `zrok.environment` | none (Flats manages `zrok-environment/` in the data directory) | restart, or when zrok is turned on from the console |
| `zrok.namespace` | `public` | restart, or when zrok is turned on from the console |
| `netbird.management_url` | `https://api.netbird.io:443` (NetBird Cloud); an https URL | restart, or when NetBird is turned on from the console; applies to peers started afterwards |
| `netbird.console_hosts` | `[]`; lowercase DNS labels, each its own NetBird peer serving the console over HTTP; needs `netbird` in `network.permitted` | immediately when changed from the console (Settings → Console); otherwise restart |
| `proxy.url_template` | `""` (none); the reverse proxy's address pattern, such as `https://{host}.site.example.org` | immediately from Settings → Networks → Reverse proxy; otherwise restart |
| `netbird.console_hosts` | `[]`; lowercase DNS labels, each its own NetBird peer serving the console over HTTP; needs `netbird` in `network.permitted` | immediately when changed from the console (Settings → Console); a SIGHUP reload or restart from the file |
| `proxy.url_template` | `""` (none); the reverse proxy's address pattern, such as `https://{host}.site.example.org` | immediately from Settings → Networks → Reverse proxy; a SIGHUP reload or restart from the file |
| `proxy.addr` | `127.0.0.1:7880`; the listener the reverse proxy forwards to | offline `flats config set` only; when the listener next binds (at start, or when a reverse proxy kind is turned on) |
| `console.external_origins` | `[]`; explicit HTTP/HTTPS origins with lowercase DNS names and optional ports | immediately from Settings → Console; otherwise restart |
| `console.preferred_host` | `""` (no preference); `local`, `tailscale:NAME` or `netbird:NAME` with NAME in that list | immediately when changed from the console (Settings → Console); otherwise restart |
| `console.external_origins` | `[]`; explicit HTTP/HTTPS origins with lowercase DNS names and optional ports | immediately from Settings → Console; a SIGHUP reload or restart from the file |
| `console.preferred_host` | `""` (no preference); `local`, `tailscale:NAME` or `netbird:NAME` with NAME in that list | immediately when changed from the console (Settings → Console); a SIGHUP reload or restart from the file |
| `credentials.operator_file` | none; ignored (the operator credential is no longer used) | — |
| `credentials.tailscale_authkey_file` | deprecated: the next start stores the key in `flats.db` and removes this key; `flats config set` refuses it | next start |

Expand Down Expand Up @@ -207,13 +207,15 @@ a new flat is allowed on when it is created, besides Local. Each must be in
`default_private` follows `private_backend`, and an empty `default_public`
grants no public provider. A new flat is always Private: a public default is a
permission only and opens nothing until the operator approves making the flat
public. Turning a provider off in the console clears a default that names it. Removing a provider from
`permitted` in the file takes its routes down at the next start but keeps each
flat's provider permission and visibility in `flats.db`; it never publishes a
flat or changes visibility. The console's Settings → Networks turns a
public. Turning a provider off in the console clears a default that names it.
The console's Settings → Networks turns a
provider on or off while the host runs: turning one on saves the file and
starts its backend at once (a backend contacts nothing until a flat allows the
provider), and turning one off is refused while a flat still allows it.
Removing a provider from `permitted` in the file takes its routes down at a
SIGHUP reload or the next start, without the console's refusal, but keeps each
flat's provider permission and visibility in `flats.db`; it never publishes a
flat or changes visibility.

`portal.hide: true` keeps public flats out of the Portal relays' lists of
sites. It is not access control: anyone with a flat's URL can still open it.
Expand Down Expand Up @@ -323,9 +325,10 @@ old one.
is stopped. Deleting the name the page is open on needs another console
domain that is ready, and moves the page there.
- `flats.db` records every console node (`console_nodes`), so a name removed
with `flats config set` or a text editor is retired at the next start. A
with `flats config set` or a text editor is retired at a SIGHUP reload or
the next start. A
retirement that fails keeps the name reserved; the console lists it and
retries on request, and the next start retries it.
retries on request, and the next reload or start retries it.
- A listed name that a flat, preview or redirect already uses is not served;
the console shows the conflict, and deleting it leaves that owner's node
alone. A name listed again while its old node is still being retired is
Expand Down Expand Up @@ -579,16 +582,21 @@ approvals.
`netbird.console_hosts` through Console.
A save
rereads the file under a lock and fails if it changed on disk since the
host read it; restart to pick up an outside edit. Host,
host read it; SIGHUP reloads an outside edit (a restart also picks it up). Host,
`network.private_backend` and `proxy.addr` keys are read-only there. A service that still
passes `--relays` pins the relays, one that passes `--portal`, `--permit` or
`--network tailscale` pins those providers, and one that passes
`--console-host` pins the console domains: the console refuses to change
them until the service is reinstalled.
them until the service is reinstalled, and a reload logs each of their
differing file values as held by the flag without applying it.
- **`flats config set KEY VALUE` / `unset KEY`**: offline; they take the host
lock and refuse while a host runs.
- **A text editor**: allowed. A running host does not reload the file; the
change applies at the next start, which fails if the file is invalid.
- **A text editor**: allowed. Sending SIGHUP to `flats serve` reloads the
file: the keys whose Applies cell names a live path take effect at once with
the same side effects as a console save, the others apply at the next start,
and an invalid file is refused and logged, changing nothing. Without a
SIGHUP the change applies at the next start, which fails if the file is
invalid.

Every Flats write keeps the previous file in `config-history/`, writes a
synced temporary file, renames it into place and syncs the directory.
Expand Down
1 change: 1 addition & 0 deletions docs/spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ list.
| Spec | Status | Covers |
| --- | --- | --- |
| [Development workflow](dev-workflow.md) | implemented | Instruction-based design, implementation and testing stages with cross-model adversarial review, and a review evidence helper |
| [Config reload on SIGHUP](config-reload.md) | implemented | SIGHUP reloads config.json on a running host: live-applied keys take effect, restart-required keys wait for the next start |
| [Idle document workers](docs-worker-idle.md) | implemented | Automatic idle sleep and demand-driven recovery of built-in document workers |
| [Content data models](content-models.md) | implemented | The Version model (websites, pages) and the Revision model (documents, designs) |
| [Design content type](design-type.md) | implemented | `type: "design"`: a canvas of HTML artboards people and agents edit live, with revisions |
Expand Down
143 changes: 143 additions & 0 deletions docs/spec/config-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Config reload on SIGHUP

Status: implemented

A running host reads `config.json` once at start. An operator who edits the
file directly must restart the host for the change to apply, and until then
the console refuses settings saves because the file's hash no longer matches
the settings ETag. This spec adds an explicit reload: SIGHUP tells a running
host to adopt the file without interrupting serving.

The rule for every key is: a reload applies what a restart would apply,
immediately, for every key with a defined live path. Keys without one wait
for the next start, as they do today. Where configuration.md says a key
applies "immediately from the console; otherwise restart", the reload is the
live path for file edits: it runs the same side effects as the console save.
Requirements 4 to 9 below partition every key of the configuration.md key
table; a key's bucket follows the table's Applies column.

## Requirements

1. **Trigger.** SIGHUP to the `flats serve` process requests one reload.
SIGHUP never terminates or interrupts the host. There is no file watcher.
Reloads happen only on the signal. Reloads are serialized by the settings
lock (requirement 10); SIGHUPs that arrive while one runs coalesce into at
most one further reload. A SIGHUP that arrives before the settings source
exists is ignored.

2. **Validation before adoption.** A reload parses and validates the whole
file with the same rules a start applies: strict parsing, per-key checks,
the documented cross-key checks (proxy address uniqueness,
external-origins versus proxy-template overlap, default providers within
the permitted set, at most one of `proxy` and `proxy-public`), and the
host binding (`host.instance_id` and `host.data_dir` must match the
running host; they never change on a live host). The host refuses an
unreadable, missing or invalid file, and one whose `schema_version`
differs from the running host's schema in either direction, because a
reload cannot migrate a running host. A refusal keeps the current
settings, logs the reason, and leaves the settings ETag naming the file
the host last read or wrote.

3. **Adoption.** A successful reload makes the file the host's current
truth. The settings ETag advances to the file's hash, so the
changed-on-disk condition clears and console saves work against the new
ETag. The adoption is all-or-nothing: a reload that fails validation
changes nothing.

4. **Keys with immediate effect.** These keys take effect on reload with
the same side effects as a console save:

- `system.rate_limit_rps` pushes the new rate to every live flat's
request limiter.
- `system.upload_max_bytes` governs new uploads.
- `system.version_quota` governs new versions; existing versions are
kept.
- `system.revision_quota` prunes when lowered.
- `system.pin_quota` keeps existing pins and refuses new pins while
full.
- `system.disk_quota_bytes` pushes document budgets and re-measures
boards.
- `portal.hide` re-applies host listings.
- `proxy.url_template` and `console.external_origins` affect new
requests.
- `console.preferred_host` enters the adopted document; a preference
whose name is not listed is not in effect, as the console save path
already defines.
- `network.default_private` and `network.default_public` govern flats
created afterwards.

If a side effect fails after the file is adopted, the reload keeps the
adopted document and the new ETag, and logs the failed side effect; the
effect retries at its next occasion, such as a later settings save or a
restart.

5. **Keys with periodic effect.** These keys enter the adopted document
and govern their next occasion: `system.preview_ttl_seconds` at the next
sweep, `system.events_keep` at the next pruning, `system.redirect_days`
for new renames. They log as applied; no side effect runs.

6. **Network providers.** A reload applies `network.permitted` the way the
next start would, without waiting for it. A newly permitted provider's
host grant is recorded and its backend starts, as when the console turns
it on; a backend contacts nothing until a flat allows the provider. A
removed provider's grant is removed and its routes come down, as they
would at the next start; each flat's provider permission and visibility
in `flats.db` stay, and nothing is published or unpublished. Unlike the
console, a reload does not refuse a removal while a flat still allows the
provider: the file is the operator's explicit instruction, and a restart
would take the same routes down. The resulting state is the one a restart
with the same file would produce: a flat keeps its permission for the
removed provider, the provider serves no routes while it is not
permitted, and the console shows it as not permitted, exactly as after a
restart. A grant or backend failure is logged and leaves that provider's
state as it was; the rest of the reload stands.

7. **Console domains.** A reload reconciles both console domain lists,
`tailscale.console_hosts` and `netbird.console_hosts`, the way the next
start would. A listed name that is not served starts being served, as
when the console adds it. A served name that is no longer listed is
retired the way a start retires it: the Tailscale node logs out, the
NetBird peer stops, and the identity is deleted. A failed retirement
keeps the name reserved, is logged, and retries at the next reload or
start. A conflict or a failed retirement leaves that name's state as it
was; the rest of the reload stands. A conflicting name enters the adopted
list but is not served, and the conflict is logged. The conflict and
reservation rules of the console flows apply unchanged: a name taken by a
flat, preview, redirect or a still-retiring node is not served.

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`,
`portal.relays`, `portal.discovery`, `portal.max_active_relays`,
`zrok.environment`, `zrok.namespace`, `netbird.management_url`,
`proxy.addr`, and the deprecated `credentials.tailscale_authkey_file`.
`host.instance_id` and `host.data_dir` never change on a live host
(requirement 2), and `credentials.operator_file` stays ignored. The
running host keeps its current values and logs each changed key that
awaits a restart.

9. **Run overrides and pins.** Values pinned for this run by service flags
survive a reload: the config-mode run overrides `--listen`
(`host.console_addr`), `--local-addr` (`host.local_addr`),
`--console-host` (`tailscale.console_hosts`) and `--runtime`
(`host.server_runtime`); the legacy `--relays` flag pins
`portal.relays`; and the legacy `--portal`, `--network` and `--permit`
flags pin their provider grants in `network.permitted`. The running value
stays, a differing file value enters the adopted document without
changing this run, and the reload log names each such key and what pins
it.

10. **Atomicity.** A reload holds the same settings lock as a console save,
so the two cannot interleave, and each observes the other's result.

11. **Observability.** Every reload attempt logs its outcome. A successful
reload logs the applied keys, the keys awaiting restart, and the keys
held by run overrides. A refusal logs the reason. Keys are logged by
name only, never by value.

## Open questions

- Whether the console or CLI should offer a reload trigger, such as a button
or `flats config reload`, in addition to SIGHUP. Not part of this spec's
target. SIGHUP covers service managers and operators on the host.
12 changes: 6 additions & 6 deletions internal/app/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ func (h *Host) SetNetworkProvider(ctx context.Context, raw string, enabled bool,
}
return "", err
}
return h.providerChangedLocked(id, enabled, etag), nil
return h.providerChangedLocked(id, enabled, etag, "from the console"), nil
}
// Turning off: stop new routes and release the proxy backend before
// saving, so a refusal on the way saves nothing.
Expand Down Expand Up @@ -293,20 +293,20 @@ func (h *Host) SetNetworkProvider(ctx context.Context, raw string, enabled bool,
}
h.proxyNet = nil
}
return h.providerChangedLocked(id, enabled, etag), nil
return h.providerChangedLocked(id, enabled, etag, "from the console"), nil
}

// providerChangedLocked finishes a saved provider change: it refreshes the
// backend parts of the approval policy and logs the change. It returns etag.
// h.pmu is held.
func (h *Host) providerChangedLocked(id provider.ID, enabled bool, etag string) string {
// backend parts of the approval policy and logs the change, naming where it
// came from. It returns etag. h.pmu is held.
func (h *Host) providerChangedLocked(id provider.ID, enabled bool, etag, source string) string {
if id == provider.Zrok {
h.syncZrokConfigurationLocked()
}
if id == provider.Netbird || id == provider.NetbirdExpose {
h.syncNetbirdConfigurationLocked()
}
h.logf("provider %s enabled=%t from the console", id, enabled)
h.logf("provider %s enabled=%t %s", id, enabled, source)
return etag
}

Expand Down
Loading
Loading