docs(generic): add Fedora deployment sections for sysusers, firewalld, and SELinux

Expand generic.md with detailed guidance on service account creation via
systemd-sysusers, named firewalld service definitions, and SELinux policy
management. Update deploy.sh responsibilities, asset layout, and conventions
summary to reflect the new requirements.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 11:50:00 +03:00
parent bec0ba92c4
commit ecfefa6433

View File

@@ -22,7 +22,7 @@ Projects are Rust cargo workspaces. The repository root contains:
│ ├── <app>-api/ # binary: REST / JSON / WebSocket daemon │ ├── <app>-api/ # binary: REST / JSON / WebSocket daemon
│ ├── <app>-worker/ # binary: long-running processor / queue consumer │ ├── <app>-worker/ # binary: long-running processor / queue consumer
│ └── <app>-cli/ # binary: operator / admin CLI │ └── <app>-cli/ # binary: operator / admin CLI
├── <web/dashboard/ui>/ # Vite + React + SWC + TS frontend (when applicable) ├── web/ # Vite + React + SWC + TS frontend (when applicable)
├── asset/ # deployment artifacts (see §6) ├── asset/ # deployment artifacts (see §6)
├── script/ # deploy.sh and related operational scripts ├── script/ # deploy.sh and related operational scripts
└── README.md └── README.md
@@ -193,7 +193,14 @@ asset/
│ ├── <app>-api.socket # if socket-activated │ ├── <app>-api.socket # if socket-activated
│ ├── <app>-worker.service │ ├── <app>-worker.service
│ ├── <app>-indexer.timer │ ├── <app>-indexer.timer
── <app>-indexer.service ── <app>-indexer.service
│ └── <app>.sysusers.conf # systemd-sysusers drop-in
├── firewalld/
│ ├── <app>-api.xml # named firewalld service per §9
│ └── <app>-worker.xml
├── selinux/ # only if custom policy is required
│ ├── <app>.te
│ └── <app>.fc
├── nginx/ ├── nginx/
│ └── <app>.<site>.conf # per server_name configs │ └── <app>.<site>.conf # per server_name configs
├── config/ ├── config/
@@ -213,7 +220,7 @@ environments:
api: api:
hosts: [oolon.hanzalova.internal] hosts: [oolon.hanzalova.internal]
config: config:
bind: 0.0.0.0:8080 bind: 127.0.0.1:8080
log_level: info log_level: info
worker: worker:
hosts: [gramathea.kosherinata.internal, oolon.hanzalova.internal] hosts: [gramathea.kosherinata.internal, oolon.hanzalova.internal]
@@ -227,7 +234,7 @@ environments:
api: api:
hosts: [quadbrat.hanzalova.internal] hosts: [quadbrat.hanzalova.internal]
config: config:
bind: 0.0.0.0:8080 bind: 127.0.0.1:8080
log_level: debug log_level: debug
# ... # ...
``` ```
@@ -258,8 +265,13 @@ A bash script with a stable CLI:
- For each `(component, host)` pair: - For each `(component, host)` pair:
1. Build the artifact locally (cargo build release, vite build, etc.) if not already built for the current commit. 1. Build the artifact locally (cargo build release, vite build, etc.) if not already built for the current commit.
2. Resolve secrets from `pass` (or the project's configured secret backend) and render config templates. 2. Resolve secrets from `pass` (or the project's configured secret backend) and render config templates.
3. `rsync` binary + rendered config + systemd units to the target host over ssh (quantum-safe key exchange). 3. `rsync` binary + rendered config + systemd units + sysusers drop-in + firewalld XML + any SELinux assets to the target host over ssh (quantum-safe key exchange).
4. `ssh` to the target to `systemctl daemon-reload` and restart the unit(s). 4. On the target, in this order:
a. `systemd-sysusers` to create the service account if missing (§8).
b. Create/chown `/etc/<app>`, `/var/lib/<app>` with correct modes.
c. `restorecon -R` on installed paths; apply `semanage` changes and load any policy module (§10).
d. Install firewalld service XML, `firewall-cmd --reload`, ensure the service is enabled in the appropriate zone, persistently and at runtime (§9).
e. `systemctl daemon-reload` and restart the unit(s).
5. Verify health (HTTP probe for api, `systemctl is-active` for all). 5. Verify health (HTTP probe for api, `systemctl is-active` for all).
- Exit non-zero on any failure. Report per-host status at the end. - Exit non-zero on any failure. Report per-host status at the end.
@@ -271,9 +283,162 @@ A bash script with a stable CLI:
--- ---
## 8. Infrastructure Context ## 8. Deployment: Service Accounts
This is the environment these apps deploy into. Implementation should assume it. All targets run **Fedora Server (current stable, 43 at time of writing)**. Claude Code should assume this and target the idiomatic Fedora way of doing things.
Services run as **dedicated non-root system users** by default. Root is an exception requiring explicit justification (e.g., a service that genuinely needs to bind < 1024 without `CAP_NET_BIND_SERVICE`, manage kernel modules, or similar).
### Creating the service account
Ship a `sysusers.d` drop-in with the app and let systemd-sysusers create the user at deploy time. Place it at `asset/systemd/<app>.sysusers.conf`:
```
#Type Name ID GECOS Home directory Shell
u <app> - "<App> service account" /var/lib/<app> /usr/sbin/nologin
```
`deploy.sh` installs this to `/etc/sysusers.d/<app>.conf` and runs `systemd-sysusers` on the target. This is idempotent and survives package reinstalls.
### Directory ownership
Services typically need:
- `/etc/<app>/` — config (root:<app>, 0750; files 0640) so the daemon can read but not write.
- `/var/lib/<app>/` — mutable state (<app>:<app>, 0750).
- `/var/log/<app>/` — only if the service logs somewhere other than journald (rare; prefer journald).
`deploy.sh` must create these with correct ownership and modes, not rely on the service creating them at runtime.
### systemd unit hardening
Unit files in `asset/systemd/` should use the user, not run as root, and include the standard hardening knobs unless a specific feature prevents it:
```ini
[Service]
Type=notify
User=<app>
Group=<app>
ExecStart=/usr/local/bin/<app>-api --config /etc/<app>/config.toml
# Hardening — enable unless the service genuinely needs otherwise
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
# Writable paths (minimum necessary)
ReadWritePaths=/var/lib/<app>
# Network
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
```
If a setting breaks the service, relax only that one — don't disable hardening wholesale.
### Privilege exceptions
If the service must run as root or with extra capabilities, document the reason in a comment at the top of the unit file. Prefer narrow `AmbientCapabilities=` (e.g., `CAP_NET_BIND_SERVICE`) over full root.
---
## 9. Deployment: Firewall (firewalld)
All hosts run `firewalld`. Every service that listens on a port must ship a **named firewalld service definition** rather than opening bare ports in a zone. The service name matches the systemd unit name (minus `.service`).
### Why named services
Named services are self-documenting (`firewall-cmd --list-services` tells you what's actually running), removable atomically on app decommission, and survive zone reassignment without reconfiguration.
### Shipping the definition
Place the XML in `asset/firewalld/<app>-<component>.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<service>
<short><app>-api</short>
<description>REST/WebSocket API for <app></description>
<port protocol="tcp" port="8443"/>
<!-- multiple ports fine if the app needs them -->
<port protocol="tcp" port="8444"/>
</service>
```
### What `deploy.sh` must do
For each component with a firewalld service definition:
1. `rsync` the XML to `/etc/firewalld/services/<app>-<component>.xml` on the target.
2. `firewall-cmd --reload` to pick up the new definition.
3. Check if the service is already enabled in the target zone (default zone unless the manifest specifies otherwise):
```
firewall-cmd --zone=<zone> --query-service=<app>-<component>
```
4. If not, enable it persistently **and** in the runtime config:
```
firewall-cmd --permanent --zone=<zone> --add-service=<app>-<component>
firewall-cmd --zone=<zone> --add-service=<app>-<component>
```
5. On component removal (future concern), the reverse: `--remove-service` then delete the XML.
Steps must be idempotent — re-running a deploy is a no-op on the firewall layer if the service is already installed and enabled.
### Zone selection
Most services bind to internal WireGuard interfaces. Put the WireGuard interface in a dedicated `internal` or `wg` zone and open services there. Public-facing services (rare — nginx is usually the only one) go in the default `public`/`FedoraServer` zone. The manifest may optionally specify a `zone:` per component; default to `internal` if unset.
### Port ranges, ICMP, sources
If a service needs port ranges, ICMP types, or source-IP restrictions, put them in the same XML using firewalld's standard elements (`<port port="x-y" />`, `<source address="..."/>`). Don't split these across multiple named services.
---
## 10. Deployment: SELinux
All hosts run **SELinux in enforcing mode**. Deployments must either operate cleanly within the default targeted policy or ship the labels and policy modules they need. Running `setenforce 0` to "just get it working" is never acceptable, and Claude Code should flag any suggestion to do so.
### Order of preference
Try these in order. Go no further down the list than necessary:
1. **Fit the default policy.** Install binaries to `/usr/local/bin/` (or `/usr/bin/` for packaged apps), state under `/var/lib/<app>/`, config under `/etc/<app>/`, logs to journald. These paths already have sensible default labels and most Rust daemons will run unmodified under `unconfined_service_t` or `init_t`.
2. **Apply existing contexts with `semanage fcontext`.** When files land in non-standard paths, map them to an appropriate existing type:
```
semanage fcontext -a -t bin_t '/opt/<app>/bin(/.*)?'
semanage fcontext -a -t etc_t '/opt/<app>/etc(/.*)?'
semanage fcontext -a -t var_lib_t '/opt/<app>/var(/.*)?'
restorecon -Rv /opt/<app>
```
3. **Use booleans** for common permissions the service needs (e.g., `setsebool -P httpd_can_network_connect on` if nginx needs to reach the API). Document every boolean flipped in the deployment.
4. **Register non-standard ports.** If the API binds to a port not already known to SELinux, label it:
```
semanage port -a -t http_port_t -p tcp 8443 # if not already labelled
```
Check first with `semanage port -l | grep <port>` and skip if the label is correct.
5. **Ship a custom policy module** only when the above don't cover it. Place sources in `asset/selinux/<app>.te` (and `.fc`, `.if` as needed). Build and install at deploy time:
```
checkmodule -M -m -o <app>.mod <app>.te
semodule_package -o <app>.pp -m <app>.mod -f <app>.fc
semodule -i <app>.pp
```
Custom modules should be as narrow as possible. If the policy ends up allowing everything, it's wrong — generate rules from `audit2allow` only after confirming the denial is actually legitimate, never as a blanket suppression.
### What `deploy.sh` must do
- After installing files, always run `restorecon -R` on their installation paths so filesystem labels match the policy.
- Apply `semanage fcontext` / `semanage port` / `setsebool` changes **permanently** (no runtime-only hacks).
- Load or reload any shipped policy module with `semodule -i`.
- Keep these operations idempotent. `semanage fcontext -a` on an already-registered path errors; deploy scripts should check with `semanage fcontext -l` first, or use `-m` (modify) with a guard.
### Dev loop
During development on a test host, `ausearch -m AVC -ts recent` and `audit2why` are the primary tools for diagnosing denials. Capture the clean set of rules once stable, fold into `asset/selinux/<app>.te`, and commit. Never leave a host with `permissive` mode set — if you set it during debugging, put it back before ending the session.
### Podman quadlets
For containerised workloads: quadlets run confined under `container_t` by default. Bind mounts of host paths need `:Z` (private relabel) or `:z` (shared relabel) depending on whether the volume is shared across containers. Default to `:Z` unless sharing is required.
---
## 11. Infrastructure Context
This is the environment these apps deploy into. Claude Code should assume it.
### Network ### Network
- Multi-site WireGuard mesh. Sites are numbered; host IPs follow `10.<site>.0.0/16` (currently `10.3.0.0/16` and `10.6.0.0/16`, but the second octet encodes the site and is the stable part). - Multi-site WireGuard mesh. Sites are numbered; host IPs follow `10.<site>.0.0/16` (currently `10.3.0.0/16` and `10.6.0.0/16`, but the second octet encodes the site and is the stable part).
@@ -292,14 +457,15 @@ This is the environment these apps deploy into. Implementation should assume it.
- nginx serves static frontends directly from `/var/www/<app>` and reverse-proxies API traffic to the internal host:port from `manifest.yml`. - nginx serves static frontends directly from `/var/www/<app>` and reverse-proxies API traffic to the internal host:port from `manifest.yml`.
### Hosts ### Hosts
- Workstations and servers run the latest Fedora. - Fedora Server, current stable (43). Workstations run the same release.
- Services run as dedicated non-root users created by RPM scaffolding or `sysusers.d` drop-ins shipped in `asset/systemd/`. - Services run as dedicated non-root users per §8.
- SELinux enforcing. Any non-standard file paths or ports require a policy module shipped with the app. - firewalld with named services per §9.
- SELinux enforcing per §10.
- Podman quadlets for containerised workloads; bare-metal systemd units for native Rust binaries (preferred where feasible). - Podman quadlets for containerised workloads; bare-metal systemd units for native Rust binaries (preferred where feasible).
--- ---
## 9. Code Quality and Tooling ## 12. Code Quality and Tooling
### Formatting and linting ### Formatting and linting
- `cargo fmt` on commit (pre-commit hook or CI gate). - `cargo fmt` on commit (pre-commit hook or CI gate).
@@ -329,7 +495,7 @@ This is the environment these apps deploy into. Implementation should assume it.
--- ---
## 10. Conventions Summary for Scaffolding and Implementation ## 13. Conventions Summary for Claude Code
When scaffolding or extending a project: When scaffolding or extending a project:
@@ -337,9 +503,12 @@ When scaffolding or extending a project:
2. Put new types in `entities`, new logic in `core`, new I/O in `data`. Binaries stay thin. 2. Put new types in `entities`, new logic in `core`, new I/O in `data`. Binaries stay thin.
3. Add dependencies to the workspace root first, then reference with `dep.workspace = true`. 3. Add dependencies to the workspace root first, then reference with `dep.workspace = true`.
4. Version strings live in exactly one place — the workspace root. 4. Version strings live in exactly one place — the workspace root.
5. Any new deployable component gets an entry in `asset/manifest.yml` and a systemd unit in `asset/systemd/` in the same change. 5. Any new deployable component gets an entry in `asset/manifest.yml`, a systemd unit in `asset/systemd/`, a sysusers drop-in, a firewalld service XML, and any required SELinux assets — in the same change.
6. Config templates go in `asset/config/` with `{{PLACEHOLDER}}` secrets. Never commit a rendered config. 6. Config templates go in `asset/config/` with `{{PLACEHOLDER}}` secrets. Never commit a rendered config.
7. Postgres connections are mTLS, passwordless. If writing connection code that accepts a password, stop and ask. 7. Postgres connections are mTLS, passwordless. If writing connection code that accepts a password, stop and ask.
8. Frontend is Vite + React + SWC + TS, served as static assets from nginx. Rust web frameworks require a stated reason. 8. Frontend is Vite + React + SWC + TS, served as static assets from nginx. Rust web frameworks require a stated reason.
9. Prefer fewer dependencies. Prefer bare-metal systemd over containers unless there's a reason. 9. Services run as dedicated non-root users with hardened systemd units per §8. Root requires explicit justification.
10. When unsure, ask — these preferences are defaults, not mandates, but deviations should be deliberate. 10. Every listening port gets a named firewalld service per §9. No bare `--add-port` calls.
11. SELinux stays enforcing. Work with the default policy first; ship a custom module only when necessary (§10). Never suggest `setenforce 0`.
12. Prefer fewer dependencies. Prefer bare-metal systemd over containers unless there's a reason.
13. When unsure, ask — these preferences are defaults, not mandates, but deviations should be deliberate.