Files
2026-09-30 20:30:56 +03:00

254 lines
14 KiB
Markdown

# Production edge deployment
This opt-in deployment keeps PostgreSQL, supervisor control, the standalone dashboard,
the Worker API, and its typed admin backend on the runtime container's loopback. The
root-owned deployment profile selects one of two exact Caddy topologies. Both keep the
private Caddy admin API on an unpublished Unix socket and preserve the same Worker API,
admin authentication, operator attribution, denylist, and header contract.
## Deployment profiles
If `/etc/truf/deployment-profile` is absent, `standalone-edge-v1` is selected. The
standalone profile publishes runtime TCP 443 and gives the managed edge only
`NET_BIND_SERVICE`.
For a host whose existing root-owned Caddy must remain the sole owner of ports 80/443,
install the shared profile before running the host-agent installer:
```sh
printf '%s\n' shared-host-edge-v1 | sudo install -m 0444 -o root -g root /dev/stdin /etc/truf/deployment-profile
```
`shared-host-edge-v1` runs the runtime in the host network namespace with no Docker
published ports. The managed Truf edge shares that namespace, has no capabilities, and
binds plain HTTP only at `127.0.0.1:18766`. The existing host Caddy imports the fixed
route-only `deploy/edge/host-caddy-shared.caddy` snippet inside the reviewed public site.
Install that import before any catch-all handler. It handles only `/api/v1/worker/*` and
the exact random admin prefix; it does not define a listener, TLS policy, global option,
or route for another application. The host agent never restarts or reconfigures host
Caddy or X-UI.
Profile changes are maintenance operations: stop the host agent first, require no active
apply or failed hold, install the exact root-owned mode-0444 value, validate the selected
Compose projection and host-Caddy configuration, then restart the agent. Never expose
profile selection through the admin or host-agent request.
### Choosing a topology
Use `standalone-edge-v1` on a dedicated host where the managed edge can own public TCP
443. The request path is:
```text
Internet -> managed Caddy :443 -> private runtime :8766
```
Use `shared-host-edge-v1` only when an existing root-owned Caddy must remain the sole
owner of public ports and TLS. The request path is:
```text
Internet -> host Caddy :443 -> 127.0.0.1:18766 -> managed Caddy -> private runtime :8766
```
Shared-host mode adds a one-time integration boundary, not a second public edge. The
operator installs the fixed route snippet, places its import before every catch-all,
supplies the independent ingress marker, and validates the complete host Caddy
configuration. After that bootstrap, runtime restart and document apply use the same
host-agent lifecycle as standalone mode. The host agent never owns the host Caddy
configuration or service lifecycle.
These are the only supported production topologies. Nginx, Traefik, an arbitrary Caddy
layout, or an ad-hoc Compose override is not equivalent to either profile. Add and test a
new exact deployment profile instead of translating private headers approximately. The
host agent validates the selected fixed Compose projection and rejects metadata drift.
The shared-host projection currently carries the constrained-host runtime limits declared
in `compose.shared-host.yaml`. A materially different CPU or memory envelope also requires
a reviewed profile change; do not hide it in an unvalidated local override.
### End-to-end host bootstrap
The repository provides fixed deployment components, not a universal VPS installer,
Ansible role, public image registry, or infrastructure module. Bootstrap a new host from
one reviewed release checkout as follows:
1. Install the reviewed Linux, Docker Engine and Compose plugin, systemd, Python 3, and
fail2ban prerequisites; provision DNS and the selected TLS ownership boundary.
2. Install the release checkout root-owned at `/opt/truf` and choose exactly one deployment
profile before installing the host agent.
3. In shared-host mode, install the fixed host-Caddy import, validate the complete host
configuration, and prove an unavailable loopback edge cannot fall through to another
application.
4. Create the protected edge directories, denylist state, and mode-0600 edge environment
described below. Generate independent admin, edge, and shared-ingress values rather
than copying values from another host.
5. Install and validate the fixed host agent. Its first install seeds an absent active
config from `app/config.linux.yaml` and an absent secrets document as an empty mapping;
repeat installation never replaces active documents.
6. Install every trusted worker-package manifest referenced by the runtime config beneath
`/etc/truf/worker-packages` with the exact ownership and mode described below.
7. Review the private active config and secrets, then build `runtime` and `edge` from the
same checkout with the exact base and selected profile Compose files.
8. Start the stack, explicitly enable Worker API and admin only after their private
configuration is complete, and verify PostgreSQL, runtime, edge, HTTPS, admin, Worker
API, host-agent, and unrelated host applications.
Initial host bootstrap is therefore intentionally more manual than later operation.
Normal config apply, restart, rollback, status, and audit are performed through the typed
control plane and fixed host agent after this trust boundary is established.
## Host agent and fixed runtime paths
Install the root-owned checkout at `/opt/truf`. Before invoking the host-agent installer,
prepare the edge state below and create the complete protected environment file that its
fixed combined-Compose validation consumes.
UID/GID 10001 is the numeric edge identity. The denylist directory is mounted, rather than
its file, so atomic replacement remains visible in the container.
```sh
sudo install -d -o 10001 -g 10001 -m 0700 /var/log/truf-edge
sudo install -d -o root -g 10001 -m 2750 /etc/truf-edge/denylist
sudo install -m 0640 -o root -g 10001 deploy/edge/admin-denylist.caddy /etc/truf-edge/denylist/admin-denylist.caddy
sudo install -d -o root -g root -m 0700 /var/lib/truf-edge
sudo install -m 0750 -o root -g root deploy/fail2ban/truf_caddy_admin_denylist.py /usr/local/sbin/truf-caddy-admin-denylist
```
Create `/etc/truf-edge/edge.env` as root with mode 0600. Generate a new admin segment
with `openssl rand -hex 32`. It must be exactly 64 lowercase hex characters (256 random
bits). Generate the bcrypt value interactively with the pinned edge image's
`caddy hash-password` command; never put the plaintext password in a command, file, or
Compose variable.
```dotenv
TRUF_EDGE_HOST=edge.example.net
TRUF_ADMIN_PREFIX=replace_with_64_lowercase_hex_characters
TRUF_ADMIN_USER=operator
TRUF_ADMIN_PASSWORD_HASH='$2a$14$replace_with_a_real_caddy_bcrypt_hash'
TRUF_ADMIN_EDGE_MARKER=replace_with_a_second_independent_64_character_hex_secret
TRUF_SHARED_INGRESS_MARKER=replace_with_a_third_independent_64_character_hex_secret
TRUF_EDGE_AUTH_LOG_DIR=/var/log/truf-edge
TRUF_EDGE_DENYLIST_DIR=/etc/truf-edge/denylist
```
`TRUF_SHARED_INGRESS_MARKER` is required only by `shared-host-edge-v1`. Host Caddy strips
any inbound transit/private headers, injects this marker and its observed client address,
and proxies to loopback. The managed edge rejects a missing marker before trusting that
address and removes the marker before proxying to the application.
Now install and validate the fixed host agent. The installer creates the fixed candidate,
result, PostgreSQL socket, and active-document paths and enables
`/run/truf/host-agent.sock`; Compose refuses to create missing bind sources.
```sh
sudo /usr/bin/python3 -I -S -B /opt/truf/deploy/host-agent/truf_host_agent_install.py install
sudo /usr/bin/python3 -I -S -B /opt/truf/deploy/host-agent/truf_host_agent_install.py validate
```
The active `/etc/truf/runtime/config.yaml` and `secrets.yaml` are UID/GID 10001 mode 0600
documents and are never overwritten by repeat installation. Any package manifest referenced
by the config must be installed beneath `/etc/truf/worker-packages` as a root-owned,
root:root mode 0644 regular file before validation. The runtime maps that immutable authority
read-only at `/data/worker-packages`; do not place manifests in the private active-document
directory.
Automatic TLS remains the default. A deployment that must use operator-provided
certificates can mount a root-owned, non-link `*.caddy` file under `/etc/caddy/tls` and
set `TRUF_EDGE_TLS_INCLUDE` to that absolute container path in a reviewed Compose
override. The include should contain only the site's `tls CERT KEY` directive. Never use
the repository's localhost test certificate or key in a deployment.
The normal `compose.yaml` remains private and unchanged. Confirm the host-agent socket is
active and rerun installer validation immediately before starting production edge. Always
supply the base file, the exact selected profile file, and the protected environment file.
For standalone:
```sh
docker compose --env-file /etc/truf-edge/edge.env -f compose.yaml -f compose.edge.yaml build runtime edge
docker compose --env-file /etc/truf-edge/edge.env -f compose.yaml -f compose.edge.yaml up -d
```
For shared host:
```sh
docker compose --env-file /etc/truf-edge/edge.env -f compose.yaml -f compose.shared-host.yaml build runtime edge
docker compose --env-file /etc/truf-edge/edge.env -f compose.yaml -f compose.shared-host.yaml up -d
```
Before starting shared host, validate the complete existing host Caddy configuration with
the snippet import in place. A matching request must fail at that Truf route if the
loopback edge is unavailable; it must never fall through to X-UI or another upstream.
Provisioning creates the private `/data/managed-files` namespace in the named data volume.
Each configured writable root must be a reviewed immediate child such as
`/data/managed-files/exports`, created with UID/GID 10001 and mode 0700 while the runtime is
stopped. Arbitrary host bind paths are not managed-file roots.
Worker admission remains disabled by `app/config.linux.yaml`. Configure the private
runtime config's worker sources, compatibility profiles, and hashed device credentials
before explicitly enabling `supervisor.worker_api.enabled`. The edge does not enable it.
The typed admin backend is disabled independently under `supervisor.worker_api.admin`.
Set its exact `origin` to `https://TRUF_EDGE_HOST`, set `edge_marker` to the same independent
256-bit value as `TRUF_ADMIN_EDGE_MARKER`, then explicitly enable it. Both authenticated
surfaces share the private runtime loopback port 8766. Caddy strips any inbound
`X-Truf-Admin-Edge` and `X-Truf-Admin-Operator`, sets the configured marker and the
authenticated Basic-auth username only after authentication, and rewrites the public
random prefix to the private `/admin-internal` backend path. The backend accepts the
operator identity only together with the private marker.
Worker API requests receive neither private admin header.
Build and client bootstrap instructions for Windows and Linux remote workers are in
`docs/remote-worker-operations.md`. Worker executables and images must be produced from a
reviewed release checkout; operators must not assemble Python, Git, TruffleHog, detector
policy, or dependencies manually on each worker.
## Fail2ban
Install the host files under their conventional names and enable fail2ban plus the expiry
timer. The jail counts only redacted `admin_auth_failure` JSON records. An initial Basic
challenge without credentials, Worker API authentication failures, and unrelated 404s do
not enter that log. The action changes only the matcher imported inside the secret admin
route; it does not create firewall rules and therefore does not block workers sharing an IP.
```sh
sudo install -m 0644 deploy/fail2ban/filter.d-truf-admin-auth.conf /etc/fail2ban/filter.d/truf-admin-auth.conf
sudo install -m 0644 deploy/fail2ban/jail.d-truf-admin-auth.local /etc/fail2ban/jail.d/truf-admin-auth.local
sudo install -m 0644 deploy/fail2ban/action.d-truf-caddy-admin-denylist.conf /etc/fail2ban/action.d/truf-caddy-admin-denylist.conf
sudo install -m 0644 deploy/fail2ban/fail2ban.d-truf-persistence.local /etc/fail2ban/fail2ban.d/truf-persistence.local
sudo install -m 0644 deploy/systemd/truf-caddy-admin-denylist-expire.service /etc/systemd/system/
sudo install -m 0644 deploy/systemd/truf-caddy-admin-denylist-expire.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now fail2ban truf-caddy-admin-denylist-expire.timer
sudo fail2ban-client status truf-admin-auth
```
Fail2ban persists jail state in `/var/lib/fail2ban/fail2ban.sqlite3`. The updater persists
canonical IPs and expiry timestamps in private
`/var/lib/truf-edge/admin-denylist.json`. It validates the complete Caddyfile in the running
edge container, reloads it through the private admin endpoint, and restores/reloads the
previous state if a command fails. Reload uses Caddy's private `/run/caddy-admin.sock` inside the edge
container. The socket is not mounted or published. Shared mode renders denylist matchers
against the marker-authenticated client address; standalone mode uses the direct peer.
## SSH recovery
Use fail2ban's normal unban first so its database and Caddy agree:
```sh
sudo fail2ban-client set truf-admin-auth unbanip 203.0.113.10
sudo /usr/local/sbin/truf-caddy-admin-denylist status
sudo /usr/local/sbin/truf-caddy-admin-denylist expire
```
If fail2ban is unavailable, run the updater's explicit unban over SSH:
```sh
sudo /usr/local/sbin/truf-caddy-admin-denylist unban 203.0.113.10
```
For recovery from a damaged generated snippet, stop the expiry timer and fail2ban, restore
`deploy/edge/admin-denylist.caddy` to `/etc/truf-edge/denylist/admin-denylist.caddy`, then
run Caddy validation and reload through the private Unix admin socket only after validation
succeeds. Reconcile each
remaining address with the updater before re-enabling the services. Do not use a global
firewall ban as a shortcut.