Harden deployment, authentication, firewall and media storage failure paths
This commit is contained in:
parent
64ede7bd05
commit
7145a35927
35 changed files with 2529 additions and 499 deletions
|
|
@ -20,8 +20,10 @@ This is a NixOS flake-based configuration for multiple hosts:
|
|||
|
||||
## Deployment
|
||||
|
||||
Hosts never pull this repo locally — they rebuild from the Forgejo remote via the
|
||||
`update` alias (`nixos-rebuild switch --refresh --flake git+https://forg.gregersen.it/rope/nixos`).
|
||||
Hosts normally build from the Forgejo remote. On the mediaserver, `update` and
|
||||
`update2` call `fred-deploy`, sharing a guarded deployment transaction with the
|
||||
router UI and nightly update. Clients build on the server, copy the closure,
|
||||
then switch locally. See `docs/deployment.md` for server deployment and recovery.
|
||||
That means evaluation is **pure**: config can never read files outside the repo
|
||||
(e.g. `/var/secrets`) at eval time. Secrets must be injected at service runtime
|
||||
(see `services/crowdsec.nix` and `services/go2rtc.nix` for the pattern).
|
||||
|
|
|
|||
148
REPO-REVIEW.md
Normal file
148
REPO-REVIEW.md
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
Repository review — 13 September 2026
|
||||
|
||||
Reviewed revision: `6a461267a2121216a0ecb586b4e4b655085038ab`.
|
||||
|
||||
Second-pass scope clarification: camera isolation is excluded from the action list. The vendor's cloud app must continue working. `devices.toml` already leaves isolation disabled and documents why; the original description of the camera as currently internet-blocked was incorrect. Resource findings below identify work and contention worth measuring, not measured CPU, RAM or energy savings.
|
||||
|
||||
The architecture is worth keeping: one flake, host-specific hardware, mostly native services, a central builder, and a shared desktop. The main problems are incomplete failure boundaries and duplicated operational policy. More abstraction alone will not fix those. The largest likely resource savings are in scheduled work and storage maintenance, with smaller opportunities in the desktop shell.
|
||||
|
||||
This is a configuration and code review, not a live penetration test or performance measurement. Findings describe what this revision declares; manually configured service state and the active firewall may differ. I did not switch generations, restart services, update inputs, access runtime secrets, or modify configuration. Runtime wattage, RAM consumption and externally reachable ports still need measurement.
|
||||
|
||||
Validation: all four physical-host system derivations evaluated successfully offline (Gaming, Mediaserver, Macbook and Kayla). The existing router UI self-check passed, and the standalone shell scripts passed Bash syntax checking. Two additional in-memory validation probes confirmed the destination-validation issues below. `nix flake check --offline --no-write-lock-file --no-build` failed on `mediaserver-vm`, after checking the physical hosts. No complete system builds or GUI/VM boot tests were run. Mediaserver evaluation also warned that the custom Redlib package lacks `meta.mainProgram`.
|
||||
|
||||
**1. High priority: restore desktop firewall startup.**
|
||||
|
||||
[common.nix:155](common.nix#L155) forces `systemd.services.firewall.wantedBy = []` and orders it after `multi-user.target`. Evaluation confirms the empty dependency list on Gaming, Macbook and Kayla. The pinned iptables firewall module normally starts from `sysinit.target`, before networking. This override removes that normal boot activation; ordering a unit does not start it. It also conflicts with the intended early-network ordering if another dependency pulls it in.
|
||||
|
||||
Remove the common override. The server already explicitly disables the scripted firewall and supplies nftables. Keep that exception on the router. Verify the client firewall after a cold boot, especially on the Macbook away from home; a currently running firewall after a switch does not prove it starts on boot.
|
||||
|
||||
**2. High priority: Docker bypasses the intended WAN port inventory.**
|
||||
|
||||
[router.nix:225](services/router.nix#L225) accepts every WAN-forwarded packet with `ct status dnat`. That includes DNAT created by Docker, not only the rules generated from `ports.toml`. [pelican.nix:126](services/pelican.nix#L126) publishes `2022:2022` on all host addresses while claiming that its absence from `ports.toml` makes it LAN-only. The declared rules permit that Docker-published SFTP port from WAN, assuming the container is running and no independent upstream filter blocks it.
|
||||
|
||||
Bind private container ports explicitly and enforce the WAN allowlist on forwarded traffic too, accounting for original destination/port before translation. Test both an allowed port and an unlisted Docker-published port from a separate WAN-side test node. Merely removing entries from `ports.toml` is insufficient. This follows Docker's documented [port publishing behavior](https://docs.docker.com/engine/network/port-publishing/) and the repository's blanket DNAT acceptance.
|
||||
|
||||
**3. Withdrawn from the action list: camera isolation.**
|
||||
|
||||
[router.nix:40](services/router.nix#L40) describes an optional forward-chain MAC drop as providing VLAN-like isolation. That description is technically inaccurate: devices in the same `10.0.0.0/24` LAN can communicate directly through the switch/access point. However, [devices.toml](devices.toml) does not enable this rule and explicitly records its removal because it broke the vendor cloud tunnel. The earlier review incorrectly described the optional rule as active.
|
||||
|
||||
Preserve the working camera configuration. A separate subnet that permits required internet access while restricting local access is a possible future design, but compatibility with this app and the available network hardware has not been established. No camera networking change is recommended in this review. The loopback-only go2rtc configuration remains useful.
|
||||
|
||||
**4. High priority: router deployment is not reliably reversible.**
|
||||
|
||||
[router-ui.nix:50](services/router-ui.nix#L50) captures the old running system, then invokes `nixos-rebuild test`. Any nonzero result is described as a build failure with nothing activated. Activation can itself fail after partial changes, so this branch can leave a partially changed machine without rollback.
|
||||
|
||||
The subsequent health test checks WAN ping, dnsmasq process state, the LAN address and existence of a NAT table. It does not verify DNS resolution, actual LAN forwarding, nginx/Authelia access, or management reachability. Its rollback uses the mutable system profile rather than the captured `$old`. The pinned NixOS activation implementation already locks individual activations, but there is no lock covering the complete test → health-check → commit/rollback transaction. Another deployment can intervene between those steps and invalidate the assumed rollback target. A failed final switch is also not proof that the boot default is unchanged.
|
||||
|
||||
Build an exact revision first, capture and root the old closure, serialize all activation paths, and arm an independent rollback timer before activation. Activate the already-built closure and disarm rollback only after meaningful probes pass. Include a LAN-side test client where possible. Use the captured old closure for recovery and verify rollback success. The nightly server upgrade should use the same safeguards; currently it bypasses this workflow entirely.
|
||||
|
||||
**5. High priority: lockscreen startup relies on an unreliable session marker.**
|
||||
|
||||
[desktop.nix:8](settings/desktop.nix#L8) logs straight into fred's session; [quickshell.nix:754](settings/quickshell.nix#L754) locks only when creating `$XDG_RUNTIME_DIR/qs-boot-lock` succeeds. That runtime directory belongs to the user, not exclusively to one graphical session. Another login can keep it alive across compositor logout/restart, leaving the marker present and the new autologin session unlocked. A shell error before requesting the lock is another uncovered startup path.
|
||||
|
||||
[hyprland.nix:74](settings/hyprland.nix#L74) sends a lock request and sleeps for one second before suspend. It does not await compositor confirmation. Use a genuine greeter authentication boundary, or an independently supervised lock flow that verifies acquisition; separate it from the frequently edited bar. Quickshell exposes `WlSessionLock.secure` for compositor-confirmed coverage. An already-acquired lock failing is a different case: conformant compositors keep it locked, as the [Quickshell documentation](https://quickshell.org/docs/v0.2.1/types/Quickshell.Wayland/WlSessionLock/) explains. This finding concerns startup and acquisition, not a claim that killing an acquired lock exposes the desktop.
|
||||
|
||||
The suspend failure path is concrete: the generated script has no `set -e` or explicit IPC-error handling, so a failed `qs ipc` followed by successful `sleep 1` reports success. The unit is also only wanted by `sleep.target`; ordering alone is not a requirement that locking succeed. Correct failure propagation and require confirmed coverage before allowing suspend.
|
||||
|
||||
**6. High priority if this is the recovery method: backup coverage and failure handling lag behind the services.**
|
||||
|
||||
[backup-server.sh](backup-server.sh) backs up only seven application directories and secrets. It omits active state such as Pelican panel/worlds and `/etc/pelican`, Flatnotes, Shelfarr, Profilarr, SABnzbd and Seerr. Its `/mnt/disk4` destination is not declared in the hardware configuration and is not checked to be mounted. A missing backup disk can therefore turn the backup directory into a directory on the root filesystem.
|
||||
|
||||
If rsync fails under `set -e`, services remain stopped because restart is not in a trap. Conversely, stop failures are ignored, allowing an inconsistent live database copy. The script also starts services that might have been intentionally stopped beforehand.
|
||||
|
||||
Create an explicit state inventory, automate backups to a verified independent destination, and test restoration. Preserve prior service state and always restore it on failure, or use application-consistent database backups/snapshots. Include secret recovery. No scheduled backup or restore test is declared here; that does not establish whether you run one externally. Mergerfs pooling itself provides neither redundancy nor backup.
|
||||
|
||||
**7. Medium/high priority: storage failures should not disable the household router.**
|
||||
|
||||
[FredOS-Mediaserver hardware:28](hosts/hardware/FredOS-Mediaserver.nix#L28) makes the three media disks required boot mounts. A failed media disk can obstruct normal boot of the router. The mergerfs mount also lacks explicit dependencies on its three branch mounts: paths embedded in its device string are not a reliable substitute for mount dependencies.
|
||||
|
||||
Declare branch dependencies and require the pool for services that write media. Decide deliberately whether a missing branch should prevent the pool from mounting or permit degraded service. If disks become optional for boot, also prevent media services and tmpfiles rules from writing into bare mountpoint directories on the root disk. Make router/DNS startup independent of the media pool. Do not simply add `nofail` everywhere without those guards.
|
||||
|
||||
**8. Medium priority: turn nightly prebuilding into release publication.**
|
||||
|
||||
[prebuild.nix](services/prebuild.nix) and [common.nix:76](common.nix#L76) both build a moving remote branch. Clients do not actually read the nightly `/var/lib/prebuild/<host>` result: they run a fresh server-side `nix build --refresh`, with a separate output link in fred's home. It is a cache hit only if the current revision's closure is already available. An intervening push turns a supposedly copy-only update into another evaluation/build.
|
||||
|
||||
This does not mean every update recompiles all packages: unchanged derivations are reused, and an unchanged revision can be cheap. Publishing a fixed result improves the guarantee of fast, validated updates; it is not evidence that the existing system always does substantial duplicate compilation.
|
||||
|
||||
The schedules are also misaligned. The lock updater is scheduled for 04:00 UTC; the host's timers use Europe/London. During British Summer Time, the Macbook prebuild at 04:00 local precedes that day's lock update, while the Gaming job at 05:00 races it. The server upgrades at 05:15. One-hour staggering does not guarantee non-overlap, and persistent timers can bunch together after downtime.
|
||||
|
||||
Use one pipeline: update candidate inputs, evaluate/test, build the required host closures at the exact same revision, then atomically publish a manifest containing revision and per-host store paths. Clients copy their published path. Keep the previous successful manifest if any required build fails. For three machines, SSH plus a small manifest and GC roots is enough; a large deployment framework is optional. Remove `git commit --allow-empty` in the updater so unchanged inputs do not produce meaningless revisions. Separate candidate updates from promotion to the server's deployable branch/ref.
|
||||
|
||||
**9. Medium priority: resource budgets belong on the actual builders.**
|
||||
|
||||
Evaluated server settings are `max-jobs = "auto"` and `cores = 0`, with no explicit build CPU or memory budget in the repository. Many jobs and multithreaded compilers can contend with DNS, media and game servers. Prebuilds, the 05:15 upgrade, weekly subtitle syncing and the runner may overlap. Zram helps absorb pressure but does not reserve responsiveness for essential services.
|
||||
|
||||
Choose conservative concurrent-job and per-job core limits based on measured RAM and build peaks; prioritize router/DNS service cgroups and lower background build/transcode priorities. Apply limits to the daemon/build execution cgroups, not just the `nix build` client unit, because the daemon starts the compilers. Account separately for the runner's container-local Nix installation. Start with pressure and duration measurements rather than arbitrary hard memory caps that can kill valid builds.
|
||||
|
||||
The CI container installs Nix afresh just to update the lock, with no persistent Nix-store mount declared here. A maintained runner image containing Nix plus a suitable cache would remove repeated setup. A host runner is simpler but gives workflow code host-level authority; choose that tradeoff explicitly.
|
||||
|
||||
**10. Medium priority: remove routine recursive permission repair.**
|
||||
|
||||
The qBittorrent, SABnzbd, Sonarr, Radarr and server-permissions modules use recursive `Z` tmpfiles rules on whole library/download trees. They walk every descendant and enforce one ownership/mode policy, including regular files. This is recurring metadata I/O across mergerfs/HDDs, and the numeric modes can also add unnecessary executable bits to data files or broaden access to app-state files.
|
||||
|
||||
Keep directory creation, setgid directories, shared-group access and sane umasks. Perform recursive repairs explicitly as migrations, using separate directory/file modes where needed. Measure `systemd-tmpfiles` duration and disk activity before and after. This is a stronger initial optimization target than shaving a minute-scale UI timer.
|
||||
|
||||
**11. Medium priority: signed closures can replace globally disabled verification.**
|
||||
|
||||
[common.nix:257](common.nix#L257) disables signature requirements on every non-server host to accept copied results. That broadens trust for all such imports, not just your server. Sign server-built closures and configure its public signing key on clients while retaining normal signature requirements. SSH remains useful for transport. The [Nix configuration reference](https://nix.dev/manual/nix/2.33/command-ref/conf-file) documents signature checking and trusted keys.
|
||||
|
||||
Separate administrative SSH access from build/copy access where practical. Copying fred's private key into root's SSH directory couples credential rotation and gives the builder connection the authority of the personal account. Also, `max-jobs = 0` on Macbook means `update2` is not a complete server-down local-compilation fallback. Document a deliberate emergency override, and verify that root's builder SSH host verification/key setup actually works.
|
||||
|
||||
**12. Medium priority: container versions escape the flake lock.**
|
||||
|
||||
Pelican, Shelfarr, Profilarr, Flatnotes and bazarr-sync use mutable `latest` tags. The pinned NixOS OCI module defaults to pull policy `missing`, so existing images can stay old while a fresh machine fetches newer code from the same Nix revision. Neither a nightly Nix build nor reverting `flake.lock` captures those image contents or reverses database migrations.
|
||||
|
||||
Pin image digests and update them through a tested workflow. Back up state before migrations. Preserve explicit image retention alongside system generation retention. Running Docker for Pelican and Podman for subtitle jobs is not itself a major waste—Podman is daemonless—but using one engine would simplify network, image and cleanup policy if there is no isolation reason for both.
|
||||
|
||||
**13. Medium priority: router UI validation does not enforce its advertised constraints.**
|
||||
|
||||
[router-ui.py:378](scripts/router-ui.py#L378) validates port syntax but not destination addresses. Names are interpolated into nftables quoted comments without suitable restrictions/escaping; destinations are inserted as raw nftables text. It also considers SSH/HTTPS protected merely because their port numbers occur, even if redirected to another host. In-memory probes accepted both `not-an-ip` as a destination and moving TCP/22 to `10.0.0.99`.
|
||||
|
||||
Validate names, IP addresses, permitted destination ranges, duplicate/overlapping forwards and the actual management destination. Apply validation at the Nix/TOML boundary as well as in the UI, because direct git edits bypass Python. Add tests for these cases. For the HTTP API, validate request content type/origin and use explicit CSRF protection; its current handler accepts JSON bodies without those checks. Authentication at nginx is useful but should not be treated as input validation.
|
||||
|
||||
**14. Medium priority: separate public service convenience from admin authority.**
|
||||
|
||||
All Authelia access rules are `one_factor`, despite documentation describing an SSO/2FA gateway. Code-server runs as fred with its own auth disabled, and fred has unrestricted passwordless sudo; compromise of that web session therefore reaches full server/router administration. Prefer stronger authentication and tighter access for code, router and game administration. Restore meaningful backend authentication or restrict listeners wherever LAN access currently bypasses the proxy.
|
||||
|
||||
Owncast reuses the guest viewing password as the administrator password and stream key, while nginx exempts admin endpoints from its outer gate. Sharing viewing access therefore also shares administration. Give administration and ingest separate runtime secrets. The deliberate lack of chat-socket authentication should also be an explicit product decision, not protection by an obscure hostname. I have not reproduced credentials in this review.
|
||||
|
||||
**15. Medium priority: the transcoder can overwrite unrelated files and increase disk use.**
|
||||
|
||||
[transcode-hevc.sh:235](scripts/transcode-hevc.sh#L235) converts `name.mp4` to `name.mkv` and moves it over any existing file at that destination without a collision check. Successful output replaces the source even if it is larger. Duration-only validation does not verify retained streams, attachments or successful full decoding; the mapping excludes attachments, which can include subtitle fonts. Replacing the inode can change ownership/mode from the original file.
|
||||
|
||||
Use unique temporary files, refuse destination collisions, retain original metadata, require meaningful size savings, and verify streams before replacement. Lock concurrent runs: the shared `/tmp` size list and blanket cleanup of `.transcode-*` can conflict with another encoder. qBittorrent lookup failure currently disables seeded-file protection rather than stopping; decide whether that is acceptable before destructive processing. File size alone is only a heuristic for matching torrent data.
|
||||
|
||||
Software x265 can consume substantial CPU, and HEVC may make playback harder on the Haswell laptop than H.264. Measure real playback and total encoding energy before batch conversion; the presence of an NVIDIA transcoding card does not accelerate this script's `libx265` encoding.
|
||||
|
||||
**Quickshell: keep the design, reduce duplication and measure wakeups.**
|
||||
|
||||
The shell is not simply a collection of aggressive polling loops. It uses UPower/PipeWire/MPRIS integration, NetworkManager monitoring with a slow fallback, visible-only ANC polling, bounded notification history, and releases the unused wallpaper texture after transitions. Those are good decisions.
|
||||
|
||||
The clearest remaining targets are:
|
||||
|
||||
- Cava continues capturing during any media playback even with the calendar closed. Its output is parsed into JavaScript arrays before the visibility check, although publication is correctly gated to avoid hidden repainting. On Macbook, stop it while hidden or at least return before parsing hidden frames. Keeping it warm on Gaming can remain an explicit visual preference.
|
||||
- Each screen instantiates a complete Bar, including weather retrieval, network monitoring and cava. Move shared data/processes into singleton services; keep per-screen rendering separate. Otherwise connecting an external monitor duplicates background work.
|
||||
- Wallpaper and lockscreen images lack explicit decode-size caps. Generate sensible display-sized assets or bound `sourceSize` while preserving aspect ratio. Full-resolution source decoding and blur textures can matter on the laptop's shared memory.
|
||||
- The full-screen bar shader makes frequent invalidation worth profiling. Compare idle, music with popup closed, popup open, and multiple displays. Do not assume the expensive hidden repaint issue remains: this revision already gates publication.
|
||||
- `apps/zen.nix` checks the theme file every two seconds. This is small but continuous work for an infrequent event. Consider a supported file-change mechanism or a slower interval, and measure before making the integration more complex.
|
||||
|
||||
For maintainability, extract the approximately 7,300 lines of embedded QML into ordinary component files, leaving Nix to generate a small settings/command module. Separate lockscreen, launcher, bar components and shared service models. Move sizeable shell/Python helpers out of Nix strings so their syntax, formatting and tests are easier to run. This reduces change risk and improves tooling; it does not inherently reduce runtime memory.
|
||||
|
||||
**Other maintenance changes worth making.**
|
||||
|
||||
- Fix the VM output shape. `mediaserver-vm` currently fails ordinary `system.build.toplevel` evaluation because root filesystem/bootloader configuration exists only in the VM variant. Export runnable VM derivations under suitable package/check outputs, or make the configurations valid in their exposed form. The current failure is not proof that `build-vm` itself fails. The VM also disables the router rules, so it cannot test WAN firewall behavior; add a separate router test topology.
|
||||
- Build tests around the actual failure boundaries: denied WAN Docker ports, invalid TOML, unsuccessful activation, pool unavailability and fresh media-service setup. Existing Python tests are useful but currently cover only a subset, and fixture counts are tied to today's personal device inventory.
|
||||
- Use explicit roles/profiles instead of repeated hostname membership lists. For three hosts, simple imports such as base, desktop, media and router are sufficient. Move Docker enablement out of the nominally disabled game-server module. Keep hardware separate as it is now.
|
||||
- The exported reusable gaming module disables distributed builds and removes SSH keys, but still inherits the `update` alias contacting your server and disabled signature checks. Parameterize deployment independently of the desktop profile.
|
||||
- Replace `clean = nix-collect-garbage -d` as the routine cleanup path with a retention policy. It deletes old system generations and sacrifices rollback history. Keep a bounded number/age of known-good server and client release roots; boot-menu limits alone do not implement store retention.
|
||||
- Scope the global ten-second stop timeout. Stateful services and game saves need enough shutdown time; inspect per-service overrides rather than assuming they all inherit ten seconds. A faster reboot is not worth interrupted persistence.
|
||||
- Extend service monitoring to failed prebuilds, upgrades, backups, DHCP and reconciliation, and add application probes. A process being active is not proof that DNS or HTTP works. An external monitor is needed to report when the router itself cannot send ntfy notifications.
|
||||
- Review subtitle scheduling: the hourly Radarr selection uses movie-added time rather than download/import time, so a movie downloaded long after being added is missed until the weekly sweep. The weekly and hourly jobs can overlap, and errors frequently become successful service exits. Use import events/history or durable incremental tracking, a common lock and meaningful failure status.
|
||||
- Treat reconciliation failures as failures. `arr-interconnect` extracts keys before readiness checks, skips many unavailable components and can exit successfully with `RemainAfterExit`; its restart policy will not repair skipped work. It needs explicit convergence reporting and retries for incomplete first-run state. It also overwrites existing non-primary Seerr users' permissions wholesale; separate defaults from deliberate management of existing users.
|
||||
- Refresh documentation: client deployment commands, service inventory, CrowdSec patch status, backup destinations, first-run secrets, and Kayla's placeholder hardware are not consistently described. Record local patches with pinned upstream versions, a reason and a condition for removal.
|
||||
|
||||
**Suggested order of work.**
|
||||
|
||||
First restore firewall startup, close the Docker allowlist gap, address lock acquisition and protect router activation/backup recovery. Then consolidate nightly work around exact published revisions, add resource budgets and replace recursive permission repair. Next pin containers and add targeted VM tests. Finally extract the QML/services and profile desktop optimizations.
|
||||
|
||||
Keeping routing on the server is workable, but it couples household connectivity to kernel updates, hardware faults, storage boot failures and development activity. A separate small router is the strongest way to remove that coupling if outages are disruptive. A router VM on the same server does not protect against host reboot or hardware failure. I would resolve the concrete software faults and measure idle power before deciding whether hardware separation is worth the cost.
|
||||
12
common.nix
12
common.nix
|
|
@ -61,7 +61,9 @@ let
|
|||
post = "nvd diff $OLD_SYSTEM /run/current-system && (command -v record-update &>/dev/null && record-update $OLD_SYSTEM /run/current-system || true)";
|
||||
|
||||
# The server evaluates and builds for itself.
|
||||
localUpdate = "bash -c 'set -o pipefail && OLD_SYSTEM=$(readlink /run/current-system) && sudo nixos-rebuild switch $@ --refresh --flake ${flake} -L${buildLog} && ${post}' --";
|
||||
localUpdate = if config.networking.hostName == "FredOS-Mediaserver"
|
||||
then "sudo fred-deploy"
|
||||
else "bash -c 'set -o pipefail && OLD_SYSTEM=$(readlink /run/current-system) && sudo nixos-rebuild switch $@ --refresh --flake ${flake} -L${buildLog} && ${post}' --";
|
||||
|
||||
# The clients offload the *evaluation* too, not just the build. nixos-rebuild
|
||||
# always evaluates on the machine it runs on (--build-host ships the
|
||||
|
|
@ -81,6 +83,7 @@ in
|
|||
|
||||
# Generic settings #
|
||||
./settings/desktop.nix
|
||||
./settings/auth.nix
|
||||
./settings/hyprland.nix
|
||||
./settings/quickshell.nix
|
||||
./settings/theming.nix
|
||||
|
|
@ -125,6 +128,7 @@ in
|
|||
./services/redlib.nix
|
||||
./services/hardware-health.nix
|
||||
./services/wan-watchdog.nix
|
||||
./services/deployment.nix
|
||||
./services/valheim-autoupdate.nix
|
||||
./services/owncast.nix
|
||||
./services/prebuild.nix
|
||||
|
|
@ -152,10 +156,8 @@ in
|
|||
boot.loader.timeout = lib.mkDefault 5;
|
||||
systemd.services.NetworkManager-wait-online.enable = false;
|
||||
systemd.services.systemd-udev-settle.enable = false;
|
||||
systemd.services.firewall = {
|
||||
wantedBy = lib.mkForce [ ];
|
||||
after = [ "multi-user.target" ];
|
||||
};
|
||||
# Keep NixOS's early firewall startup. The router disables this firewall
|
||||
# explicitly in services/router.nix and owns its nftables rules separately.
|
||||
|
||||
boot.initrd.verbose = false;
|
||||
#############################################################################
|
||||
|
|
|
|||
71
docs/deployment.md
Normal file
71
docs/deployment.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Server deployment and recovery
|
||||
|
||||
The mediaserver's `update` and `update2` aliases, the router UI Apply action, and the 05:15 nightly update now use `fred-deploy`. The clients' build/copy/switch workflow and the migration backup script are unchanged. This change has not been activated on any machine.
|
||||
|
||||
## Transaction
|
||||
|
||||
Each deployment runs in its own transient systemd service. Losing the terminal or router UI apply process does not terminate that service. Logs go to the journal; the calling command prints the unit to follow and collects its journal after completion.
|
||||
|
||||
One lock, `/run/lock/fred-deploy.lock`, covers the entire server build, activation, verification, commit and recovery. Other deployments queue behind it. The WAN watchdog takes the same lock nonblockingly and skips its cycle when the lock is held, including conntrack flushes and network restarts.
|
||||
|
||||
The transaction captures the **running system** and **system-profile boot default** separately and retains both, the candidate and its own recovery code as GC roots. It builds exactly one candidate; committing never re-fetches a moving branch. A candidate must contain the boot recovery service.
|
||||
|
||||
After the build succeeds, a separate ten-minute systemd timer is armed. The candidate is activated with `switch-to-configuration test`. Essential router checks must pass before the system profile is changed and `switch-to-configuration boot` installs the new boot default. A final health check precedes the durable `committed` result and timer cancellation.
|
||||
|
||||
Activation, health-check or boot-commit errors restore the captured boot profile, reinstall its boot configuration, and reactivate the captured running system. Recovery attempts runtime restoration even if restoring the bootloader fails. Failed recovery retains the roots and records `rollback-failed`; it is never reported as a successful rollback. New deployments refuse to overwrite an unresolved transaction.
|
||||
|
||||
The independent timer stops a stuck deployment worker before taking the lock, so it does not deadlock behind that worker. Stale timers cannot undo a committed or newer transaction. Subprocess timeouts kill their process groups. Durable state and GC-root directory changes are fsynced.
|
||||
|
||||
Transient timers disappear on reboot. The boot recovery service checks for an unfinished transaction from a previous boot and runs the pinned recovery code. It uses `Type=exec` so its activation does not block completion of the boot targets that the switch script starts. A deployment arriving before boot recovery takes the lock recovers the old transaction first. Same-boot activation of this service does not cancel the deployment installing it.
|
||||
|
||||
## Health checks and limits
|
||||
|
||||
Checks cover networkd, dnsmasq, AdGuard Home, nftables, SSH, nginx and the router UI; the LAN address and WAN default route; IPv4 forwarding; default-drop input/forward chains; DNAT and masquerade rules; WAN ping through either of two targets; an actual LAN DNS lookup and SSH banner; the local UI HTTP endpoint and its TLS management proxy.
|
||||
|
||||
Media applications are not health probes. A nonzero Nix activation exit still causes rollback, including activation errors originating outside the router services. An ISP, public DNS or certificate outage can also fail verification and cause a conservative rollback. The local probes do not prove reachability from every external client.
|
||||
|
||||
A build is limited to 90 minutes; individual activation and bootloader commands to two minutes. Health checks retry for 90 seconds, with each probe also bounded. The timer's ten-minute deadline starts after building. The supervised deployment has a 100-minute runtime limit, including lock waits. Recovery has its own runtime limit.
|
||||
|
||||
The nightly job retains the existing conditional reboot behavior: after a successful transaction, it requests a reboot if the booted kernel, initrd or kernel modules differ from the candidate. This is **not** an automatic post-reboot kernel validation/boot-fallback system. Recovery cannot undo database migrations, change the currently running kernel, restore arbitrary application data, or fix a failed disk or unresponsive systemd/kernel.
|
||||
|
||||
## Commands
|
||||
|
||||
Normal server update:
|
||||
|
||||
```sh
|
||||
update
|
||||
```
|
||||
|
||||
Deploy a particular source or commit:
|
||||
|
||||
```sh
|
||||
sudo fred-deploy --flake 'git+https://forg.gregersen.it/rope/nixos?rev=FULL_COMMIT_SHA'
|
||||
```
|
||||
|
||||
Use `--reboot` to request the nightly job's conditional reboot behavior. The server aliases now accept `fred-deploy` options, rather than arbitrary `nixos-rebuild` flags. Calling `nixos-rebuild switch` or `boot` directly bypasses this transaction.
|
||||
|
||||
Inspect the latest transaction or retry interrupted/failed recovery:
|
||||
|
||||
```sh
|
||||
sudo cat /var/lib/fred-deploy/last-result.json
|
||||
sudo fred-deploy recover
|
||||
journalctl -u fred-deploy-recover
|
||||
```
|
||||
|
||||
`recover` handles an unfinished transaction; it is not a general rollback command after a successful commit. Manual recovery also runs in an independent systemd service. Keep the saved roots and state until recovery succeeds.
|
||||
|
||||
For the first installation, the live `update` alias still points to the old updater. Build the new command from a checkout on the server first:
|
||||
|
||||
Supervise this first rollout: older generations predate the shared watchdog lock and boot recovery service. In particular, booting an older generation can restore connectivity without automatically clearing the saved unfinished transaction; use the built command's `recover` action before retrying.
|
||||
|
||||
```sh
|
||||
deploy_tool=$(nix build --no-link --print-out-paths path:.#nixosConfigurations.FredOS-Mediaserver.config.system.build.deploy)
|
||||
```
|
||||
|
||||
When ready to activate that checkout, invoke the built command explicitly with `sudo "$deploy_tool/bin/fred-deploy" --flake "path:$PWD"`. Alternatively, pass the exact Forgejo commit containing these changes. Build and review the command before invoking it; none of these activation commands were run during implementation.
|
||||
|
||||
## Verification performed
|
||||
|
||||
`python3 scripts/deploy-check.py` exercises failure after partial runtime activation, health failure, partial profile/boot commit, failed boot restoration, failed recovery health, timer setup/cancellation failures, stale timers, boot recovery, a full state filesystem, and missing candidate recovery support. It also tests actual process-level lock exclusion, independent recovery stopping a hung fixture worker before taking its lock, and subprocess timeout cleanup of descendants. Health-probe failures use mocked services, networking and sockets.
|
||||
|
||||
The deployment wrapper is built with Nix's shell checks, and physical host toplevels are evaluated. The existing `mediaserver-vm` root-filesystem/bootloader issue still prevents a clean whole-flake check. These tests use temporary filesystem/system fixtures; they do not create live deployment timers, reboot machines, or activate a real generation. A controlled server rollout remains the final operational validation.
|
||||
66
docs/failure-path-fixes.md
Normal file
66
docs/failure-path-fixes.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Approved failure-path fixes
|
||||
|
||||
This note covers proposal items 1, 2, 4 and 5. Item 3 is now implemented and documented in [deployment.md](deployment.md); the migration backup script remains last. Camera configuration is unchanged.
|
||||
|
||||
- **Firewall startup:** shared configuration now uses NixOS's early firewall startup. The mediaserver continues to own its separate nftables firewall.
|
||||
- **WAN forwarding:** forwarded Docker traffic must match the original protocol/port in `ports.toml`. TCP and UDP 25565–25600 remain available automatically for Pelican allocations. SFTP binds to 10.0.0.1:2022. Forward names, LAN destinations, overlaps and SSH/HTTPS destinations are validated both by the UI and during Nix evaluation.
|
||||
- **Login and locking:** greetd runs the Quickshell greeter as `greeter` inside Cage and authenticates before creating Fred's session. The existing visual layout is shared with an independent Quickshell lock process. The greeter uses the default wallpaper and the desktop's GB/Norwegian keyboard layouts; the lock reads the current wallpaper. Greetd unlocks the login keyring before Spotify starts. The old boot marker and IPC unlock method are removed. `fred-lock` waits for compositor-confirmed screen coverage; failure or timeout fails the required pre-sleep service and prevents sleep.
|
||||
- **Media storage:** missing data disks no longer make the mounts mandatory for router boot. Device waits are bounded at 15 seconds. The pool requires every branch, and media consumers require a readiness service bound to all four mounts. Directory creation and the existing recursive permission rules run only after mount checks. These consumers stop when a required mount disappears. Routing and services outside the media pool remain independent.
|
||||
|
||||
## Validation
|
||||
|
||||
All four physical hosts were evaluated. The authentication bundle and media-readiness unit were built without activating a system. Tests cover:
|
||||
|
||||
- Existing router UI round trips plus malformed destinations/names, overlaps and redirected management ports; equivalent Nix checks reject 13 invalid inventories.
|
||||
- Evaluated nftables rules in private user/network namespaces, with a second NAT table modeling Docker publications: WAN TCP/UDP at both Pelican range endpoints succeeds; unlisted ports fail; LAN SFTP succeeds. This is a packet-level test of the rules, not a running Pelican instance.
|
||||
- The actual greeter QML against a fake greetd socket: a rejected password never requests a session launch; successful retry requests the session with Wayland metadata. The pinned Quickshell logs an unexpected-response message for the cancellation acknowledgement; retry and launch still succeed.
|
||||
- The actual lock QML on a private headless Sway compositor: coverage is confirmed; the helper fails when IPC disappears or reports that coverage never became secure.
|
||||
- The generated media-readiness script in a private mount namespace: missing pool, missing branch and subsequently lost branch all fail; a complete mounted fixture receives its directories; the underlying root directory stays empty.
|
||||
|
||||
The broader `nix flake check --no-build` still fails on the existing `mediaserver-vm` output's root-filesystem/bootloader assertions. The configured nixos MCP server did not complete initialization; options were checked against the pinned module sources and host evaluations instead.
|
||||
|
||||
These checks do not substitute for a physical login, keyring unlock, lid-close/resume or disk-disconnection test. No configuration has been activated, no production service has been restarted, and no production firewall or mount has been changed.
|
||||
|
||||
## Repeating the focused tests
|
||||
|
||||
The Python tests document their required tools and arguments in their opening docstrings. Use packages from the flake's pinned package set. New files must be included in the source; `path:.` includes the working tree before they are committed.
|
||||
|
||||
Build the authentication fixture:
|
||||
|
||||
```sh
|
||||
nix build --no-link --print-out-paths path:.#nixosConfigurations.FredOS-Gaming.config.system.build.auth
|
||||
```
|
||||
|
||||
Run `scripts/greeter-check.py` and `scripts/session-lock-check.py` with that output path. Put `qs` and `sway` on PATH and set `QML2_IMPORT_PATH` to the pinned `qt6.qt5compat` package's `lib/qt-6/qml` directory.
|
||||
|
||||
Build the media-readiness fixture:
|
||||
|
||||
```sh
|
||||
nix build --no-link --print-out-paths --impure --expr \
|
||||
'(builtins.getFlake ("path:" + toString ./.)).nixosConfigurations.FredOS-Mediaserver.config.systemd.units."media-storage-ready.service".unit'
|
||||
```
|
||||
|
||||
Pass its output path to `sudo python3 scripts/media-storage-check.py`. The script creates a private mount namespace before making any mount changes.
|
||||
|
||||
Run the Nix forwarding checks:
|
||||
|
||||
```sh
|
||||
nix eval --impure --expr \
|
||||
'import ./scripts/router-forwards-check.nix { lib = (builtins.getFlake ("path:" + toString ./.)).inputs.nixpkgs.lib; }'
|
||||
```
|
||||
|
||||
For packet tests, export the evaluated tables and pass the file to `python3 scripts/router-firewall-check.py`; that script creates its own private user/network namespace:
|
||||
|
||||
```sh
|
||||
nix eval --raw path:.#nixosConfigurations.FredOS-Mediaserver.config.networking.nftables.tables \
|
||||
--apply 'tables: builtins.concatStringsSep "\n" (builtins.attrValues (builtins.mapAttrs (name: table: "table ${table.family} ${name} {\n${table.content}\n}") tables))' \
|
||||
> /tmp/router-rules.nft
|
||||
```
|
||||
|
||||
## Operational behavior
|
||||
|
||||
The new greeter takes effect on the next login or boot. It supports the configured password authentication; unusual PAM conversations such as forced password changes require a text console. The independent locker starts on the first lock request. Restarting that locker reacquires the lock, while ordinary bar reloads leave it alone.
|
||||
|
||||
If media storage is unavailable, restore and verify the missing disks first, then start the affected media services. Their dependencies retry mounts and directory preparation. Services deliberately stay stopped after losing storage; they do not automatically resume merely because a disk returns. Inspect `journalctl -u media-storage-ready` and `systemctl status mnt-storage.mount` when diagnosing this.
|
||||
|
||||
The shared deployment transaction is documented separately in [deployment.md](deployment.md).
|
||||
|
|
@ -186,13 +186,8 @@
|
|||
# unsigned store paths sent by the build client).
|
||||
nix.settings.trusted-users = [ "root" "fred" ];
|
||||
|
||||
# Automatic daily system updates
|
||||
system.autoUpgrade = {
|
||||
enable = true;
|
||||
flake = "git+https://forg.gregersen.it/rope/nixos";
|
||||
dates = "05:15";
|
||||
allowReboot = true;
|
||||
};
|
||||
# services/deployment.nix owns the 05:15 update and conditional reboot,
|
||||
# using the same health-checked transaction as manual and router UI applies.
|
||||
|
||||
# WAN exposure is controlled by nftables in services/router.nix +
|
||||
# ports.toml (networking.firewall is disabled on this host).
|
||||
|
|
|
|||
|
|
@ -28,24 +28,31 @@
|
|||
fileSystems."/mnt/disk1" = {
|
||||
device = "/dev/disk/by-uuid/90ae3493-38c1-4473-b409-e9d99c3b315e";
|
||||
fsType = "ext4";
|
||||
options = [ "nofail" "x-systemd.device-timeout=15s" ];
|
||||
};
|
||||
|
||||
fileSystems."/mnt/disk2" = {
|
||||
device = "/dev/disk/by-uuid/58cecfd5-2fd7-4c4b-b3a1-0bf5e9d0beab";
|
||||
fsType = "ext4";
|
||||
options = [ "nofail" "x-systemd.device-timeout=15s" ];
|
||||
};
|
||||
|
||||
fileSystems."/mnt/disk3" = {
|
||||
device = "/dev/disk/by-uuid/317660ef-bd75-4fa4-bd20-f96a3926bf7b";
|
||||
fsType = "ext4";
|
||||
options = [ "nofail" "x-systemd.device-timeout=15s" ];
|
||||
};
|
||||
|
||||
# Combined MergerFS pool
|
||||
fileSystems."/mnt/storage" = {
|
||||
device = "/mnt/disk1:/mnt/disk2:/mnt/disk3";
|
||||
fsType = "fuse.mergerfs";
|
||||
# The router may boot without media, but the pool must never start on
|
||||
# bare branch directories or present a silently incomplete library.
|
||||
depends = [ "/mnt/disk1" "/mnt/disk2" "/mnt/disk3" ];
|
||||
options = [
|
||||
"defaults"
|
||||
"nofail"
|
||||
"allow_other"
|
||||
"use_ino"
|
||||
"cache.files=partial"
|
||||
|
|
|
|||
486
scripts/deploy-check.py
Normal file
486
scripts/deploy-check.py
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
"""Fault-injection checks: python3 scripts/deploy-check.py.
|
||||
|
||||
All system/profile/bootloader operations are replaced with filesystem fixtures.
|
||||
No Nix build, systemd unit, network request or production activation is performed.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"deploy", Path(__file__).with_name("deploy.py")
|
||||
)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
|
||||
class Fixture(m.Deployment):
|
||||
def __init__(self, root):
|
||||
self.calls = []
|
||||
self.fail = set()
|
||||
self.health_calls = 0
|
||||
self.root = root
|
||||
self.candidate = root / "candidate"
|
||||
self.old_running = root / "running"
|
||||
self.old_boot = root / "boot"
|
||||
recovery = self.candidate / "etc/systemd/system/fred-deploy-recover.service"
|
||||
recovery.parent.mkdir(parents=True, exist_ok=True)
|
||||
recovery.write_text("fixture")
|
||||
for system in (self.candidate, self.old_running, self.old_boot):
|
||||
(system / "bin").mkdir(parents=True, exist_ok=True)
|
||||
(system / "bin/switch-to-configuration").write_text("fixture")
|
||||
runner = root / "tools/bin/worker"
|
||||
runner.parent.mkdir(parents=True, exist_ok=True)
|
||||
runner.write_text("fixture")
|
||||
super().__init__(
|
||||
runner, root / "state", root / "roots", root / "lock", self.fake
|
||||
)
|
||||
self.current = root / "current"
|
||||
self.profile = root / "profile"
|
||||
if not self.current.is_symlink():
|
||||
self.current.symlink_to(self.old_running)
|
||||
if not self.profile.is_symlink():
|
||||
self.profile.symlink_to(self.old_boot)
|
||||
self.boot_default = self.old_boot
|
||||
|
||||
def fake(self, args, timeout=120):
|
||||
args = list(map(str, args))
|
||||
self.calls.append(args)
|
||||
step = args[0]
|
||||
if args[:2] == ["nix", "build"]:
|
||||
step = "build"
|
||||
self.pin("candidate", self.candidate)
|
||||
elif args[0] == "systemd-run":
|
||||
step = "arm"
|
||||
elif args[0] == "nix-env":
|
||||
target = Path(args[-1])
|
||||
self.profile.unlink()
|
||||
self.profile.symlink_to(target)
|
||||
step = "commit-profile" if target == self.candidate else "restore-profile"
|
||||
elif args[0].endswith("switch-to-configuration"):
|
||||
target = Path(args[0]).parents[1]
|
||||
if args[1] == "test":
|
||||
self.current.unlink()
|
||||
self.current.symlink_to(target)
|
||||
else:
|
||||
self.boot_default = target
|
||||
step = ("candidate-" if target == self.candidate else "restore-") + args[1]
|
||||
elif args[:2] == ["systemctl", "stop"]:
|
||||
step = "disarm" if args[-1].endswith(".timer") else "stop-worker"
|
||||
elif args[:2] == ["systemctl", "show"]:
|
||||
return "not-found"
|
||||
if step in self.fail:
|
||||
raise RuntimeError("Injected failure after side effect: " + step)
|
||||
return ""
|
||||
|
||||
def healthy(self):
|
||||
self.health_calls += 1
|
||||
if self.current.resolve() == self.candidate:
|
||||
if "health" in self.fail or (
|
||||
"final-health" in self.fail and self.health_calls == 2
|
||||
):
|
||||
raise RuntimeError("Injected unhealthy router")
|
||||
elif "restore-health" in self.fail:
|
||||
raise RuntimeError("Restored configuration is unhealthy")
|
||||
|
||||
def wait_healthy(self, duration=90):
|
||||
self.healthy()
|
||||
|
||||
def deploy(self, ident="a" * 32, flake="fixture", reboot=False):
|
||||
with patch.object(
|
||||
Path,
|
||||
"exists",
|
||||
autospec=True,
|
||||
side_effect=lambda p: (
|
||||
False
|
||||
if str(p) == "/run/current-system/sw/bin/record-update"
|
||||
else original_exists(p)
|
||||
),
|
||||
):
|
||||
super().deploy(ident, flake, reboot)
|
||||
|
||||
|
||||
original_exists = Path.exists
|
||||
|
||||
|
||||
class Transactions(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="fred-deploy-check-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.f = Fixture(Path(self.temp.name))
|
||||
self.output = contextlib.redirect_stdout(io.StringIO())
|
||||
self.output.__enter__()
|
||||
self.addCleanup(self.output.__exit__, None, None, None)
|
||||
|
||||
def assert_restored(self):
|
||||
self.assertEqual(self.f.current.resolve(), self.f.old_running)
|
||||
self.assertEqual(self.f.profile.resolve(), self.f.old_boot)
|
||||
self.assertEqual(self.f.boot_default, self.f.old_boot)
|
||||
|
||||
def pending(self, phase="activating", boot_id=None):
|
||||
self.f.pin("old-running", self.f.old_running)
|
||||
self.f.pin("old-boot", self.f.old_boot)
|
||||
record = {
|
||||
"id": "a" * 32,
|
||||
"unit": "fred-deploy-" + "a" * 32 + ".service",
|
||||
"old_running": str(self.f.old_running),
|
||||
"old_boot": str(self.f.old_boot),
|
||||
"candidate": str(self.f.candidate),
|
||||
"boot_id": boot_id or self.f.boot_id(),
|
||||
}
|
||||
self.f.save(record, phase)
|
||||
self.f.current.unlink()
|
||||
self.f.current.symlink_to(self.f.candidate)
|
||||
return record
|
||||
|
||||
def test_success_orders_test_health_and_boot_commit(self):
|
||||
self.f.deploy()
|
||||
self.assertEqual(self.f.read()["phase"], "committed")
|
||||
self.assertEqual(self.f.profile.resolve(), self.f.candidate)
|
||||
commands = self.f.calls
|
||||
arm = next(i for i, c in enumerate(commands) if c[0] == "systemd-run")
|
||||
test = commands.index(
|
||||
[str(self.f.candidate / "bin/switch-to-configuration"), "test"]
|
||||
)
|
||||
boot = commands.index(
|
||||
[str(self.f.candidate / "bin/switch-to-configuration"), "boot"]
|
||||
)
|
||||
self.assertLess(arm, test)
|
||||
self.assertLess(test, boot)
|
||||
self.assertEqual(self.f.health_calls, 2)
|
||||
self.assertFalse(list(self.f.roots.iterdir()))
|
||||
|
||||
def test_build_failure_never_activates_or_arms(self):
|
||||
self.f.fail.add("build")
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.f.deploy()
|
||||
self.assertEqual(self.f.read()["phase"], "build-failed")
|
||||
self.assert_restored()
|
||||
self.assertFalse(
|
||||
any(
|
||||
c[0] == "systemd-run" or c[0].endswith("switch-to-configuration")
|
||||
for c in self.f.calls
|
||||
)
|
||||
)
|
||||
|
||||
def test_candidate_must_retain_boot_recovery(self):
|
||||
(self.f.candidate / "etc/systemd/system/fred-deploy-recover.service").unlink()
|
||||
with self.assertRaisesRegex(RuntimeError, "missing the boot recovery"):
|
||||
self.f.deploy()
|
||||
self.assert_restored()
|
||||
self.assertEqual(self.f.read()["phase"], "build-failed")
|
||||
|
||||
def test_activation_and_commit_failure_paths_restore_both_systems(self):
|
||||
for failure in [
|
||||
"arm",
|
||||
"candidate-test",
|
||||
"health",
|
||||
"commit-profile",
|
||||
"candidate-boot",
|
||||
"final-health",
|
||||
]:
|
||||
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temp:
|
||||
f = Fixture(Path(temp))
|
||||
f.fail.add(failure)
|
||||
with self.assertRaises(RuntimeError):
|
||||
f.deploy()
|
||||
self.assertEqual(f.read()["phase"], "rolled-back")
|
||||
self.assertEqual(f.current.resolve(), f.old_running)
|
||||
self.assertEqual(f.profile.resolve(), f.old_boot)
|
||||
self.assertEqual(f.boot_default, f.old_boot)
|
||||
|
||||
def test_failed_boot_restore_still_restores_runtime_and_retains_roots(self):
|
||||
self.f.fail.update({"candidate-test", "restore-boot"})
|
||||
with self.assertRaisesRegex(RuntimeError, "needs attention"):
|
||||
self.f.deploy()
|
||||
self.assertEqual(self.f.current.resolve(), self.f.old_running)
|
||||
self.assertEqual(self.f.read()["phase"], "rollback-failed")
|
||||
self.assertTrue((self.f.roots / "old-boot").exists())
|
||||
with self.assertRaisesRegex(RuntimeError, "needs recovery"):
|
||||
self.f.deploy("b" * 32)
|
||||
self.f.fail.clear()
|
||||
self.f.recover()
|
||||
self.assertEqual(self.f.read()["phase"], "rolled-back")
|
||||
|
||||
def test_unhealthy_restored_router_is_not_reported_as_success(self):
|
||||
self.f.fail.update({"candidate-test", "restore-health"})
|
||||
with self.assertRaisesRegex(RuntimeError, "needs attention"):
|
||||
self.f.deploy()
|
||||
self.assertEqual(self.f.read()["phase"], "rollback-failed")
|
||||
|
||||
def test_late_timer_cannot_undo_commit(self):
|
||||
self.f.fail.add("disarm")
|
||||
self.f.deploy()
|
||||
self.assertEqual(self.f.read()["phase"], "committed")
|
||||
before = len(self.f.calls)
|
||||
self.f.recover("a" * 32)
|
||||
self.assertEqual(len(self.f.calls), before)
|
||||
|
||||
def test_stale_timer_cannot_touch_new_transaction(self):
|
||||
self.pending()
|
||||
self.f.recover("b" * 32)
|
||||
self.assertFalse(self.f.calls)
|
||||
self.assertEqual(self.f.read()["phase"], "activating")
|
||||
|
||||
def test_boot_service_does_not_cancel_its_own_installation(self):
|
||||
self.pending()
|
||||
self.f.recover(boot=True)
|
||||
self.assertFalse(self.f.calls)
|
||||
self.assertEqual(self.f.read()["phase"], "activating")
|
||||
|
||||
def test_next_boot_recovers_even_if_worker_unit_was_collected(self):
|
||||
self.pending(boot_id="previous-boot")
|
||||
self.f.fail.add("stop-worker")
|
||||
self.f.recover(boot=True)
|
||||
self.assert_restored()
|
||||
self.assertEqual(self.f.read()["phase"], "rolled-back")
|
||||
|
||||
def test_interrupted_build_can_be_retried_without_activation(self):
|
||||
self.pending(phase="building")
|
||||
# A building record never altered the running system.
|
||||
self.f.current.unlink()
|
||||
self.f.current.symlink_to(self.f.old_running)
|
||||
self.f.deploy("b" * 32)
|
||||
self.assertEqual(self.f.read()["phase"], "committed")
|
||||
|
||||
def test_boot_time_deployment_recovers_previous_boot_first(self):
|
||||
self.pending(boot_id="previous-boot")
|
||||
self.f.deploy("b" * 32)
|
||||
restore = self.f.calls.index(
|
||||
[str(self.f.old_running / "bin/switch-to-configuration"), "test"]
|
||||
)
|
||||
build = next(
|
||||
i for i, args in enumerate(self.f.calls) if args[:2] == ["nix", "build"]
|
||||
)
|
||||
self.assertLess(restore, build)
|
||||
self.assertEqual(self.f.read()["phase"], "committed")
|
||||
|
||||
def test_failed_state_write_does_not_prevent_restoring_system(self):
|
||||
record = self.pending()
|
||||
save = self.f.save
|
||||
|
||||
def fail_start(record, phase, **fields):
|
||||
if phase == "rolling-back":
|
||||
raise OSError("disk full")
|
||||
return save(record, phase, **fields)
|
||||
|
||||
self.f.save = fail_start
|
||||
self.f.rollback_locked(record)
|
||||
self.assert_restored()
|
||||
|
||||
def test_recovery_rereads_state_after_stopping_worker(self):
|
||||
record = self.pending()
|
||||
command = self.f.command
|
||||
|
||||
def race(args, **kw):
|
||||
result = command(args, **kw)
|
||||
if args[:2] == ["systemctl", "stop"]:
|
||||
self.f.save({**record, "id": "b" * 32}, "armed")
|
||||
return result
|
||||
|
||||
self.f.command = race
|
||||
self.f.recover("a" * 32)
|
||||
self.assertEqual(self.f.read()["id"], "b" * 32)
|
||||
self.assertFalse(
|
||||
any(c[0].endswith("switch-to-configuration") for c in self.f.calls)
|
||||
)
|
||||
|
||||
def test_transaction_lock_excludes_another_process(self):
|
||||
marker = self.f.root / "entered"
|
||||
code = "import fcntl,pathlib,sys; f=open(sys.argv[1],'a'); fcntl.flock(f,fcntl.LOCK_EX); pathlib.Path(sys.argv[2]).touch()"
|
||||
with self.f.exclusive():
|
||||
child = subprocess.Popen(
|
||||
[sys.executable, "-c", code, str(self.f.lock), str(marker)]
|
||||
)
|
||||
time.sleep(0.2)
|
||||
self.assertFalse(marker.exists())
|
||||
child.wait(timeout=3)
|
||||
self.assertEqual(child.returncode, 0)
|
||||
self.assertTrue(marker.exists())
|
||||
|
||||
def test_independent_recovery_stops_hung_worker_before_taking_lock(self):
|
||||
self.pending()
|
||||
marker = self.f.root / "holding-lock"
|
||||
code = "import fcntl,pathlib,sys,time; f=open(sys.argv[1],'a'); fcntl.flock(f,fcntl.LOCK_EX); pathlib.Path(sys.argv[2]).touch(); time.sleep(30)"
|
||||
worker = subprocess.Popen(
|
||||
[sys.executable, "-c", code, str(self.f.lock), str(marker)]
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 3
|
||||
while not marker.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
self.assertTrue(marker.exists())
|
||||
recovery = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-B",
|
||||
__file__,
|
||||
"recover-fixture",
|
||||
str(self.f.root),
|
||||
str(worker.pid),
|
||||
],
|
||||
timeout=5,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(recovery.returncode, 0, recovery.stdout + recovery.stderr)
|
||||
worker.wait(timeout=2)
|
||||
self.assertEqual(worker.returncode, -signal.SIGKILL)
|
||||
self.assert_restored()
|
||||
self.assertEqual(self.f.read()["phase"], "rolled-back")
|
||||
finally:
|
||||
if worker.poll() is None:
|
||||
worker.kill()
|
||||
worker.wait()
|
||||
|
||||
def test_command_timeout_kills_descendants(self):
|
||||
marker = self.f.root / "unexpected-child-write"
|
||||
child = (
|
||||
"import pathlib,time,sys; time.sleep(.5); pathlib.Path(sys.argv[1]).touch()"
|
||||
)
|
||||
parent = "import subprocess,sys,time; subprocess.Popen([sys.executable,'-c',sys.argv[1],sys.argv[2]]); time.sleep(30)"
|
||||
with self.assertRaisesRegex(RuntimeError, "timed out"):
|
||||
m.run([sys.executable, "-c", parent, child, str(marker)], timeout=0.1)
|
||||
time.sleep(0.6)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_reboot_is_requested_only_after_commit(self):
|
||||
self.f.deploy(reboot=True)
|
||||
self.assertEqual(self.f.calls[-1], ["systemctl", "reboot", "--no-block"])
|
||||
self.assertEqual(self.f.read()["phase"], "committed")
|
||||
|
||||
def test_router_health_rejects_missing_essential_functions(self):
|
||||
for failure in [
|
||||
None,
|
||||
"service",
|
||||
"address",
|
||||
"route",
|
||||
"firewall",
|
||||
"nat",
|
||||
"wan",
|
||||
"dns",
|
||||
"ssh",
|
||||
"proxy",
|
||||
]:
|
||||
with self.subTest(failure=failure):
|
||||
|
||||
def health_command(args, timeout=120):
|
||||
if args[0] == "systemctl":
|
||||
self.assertNotIn("jellyfin", args)
|
||||
if failure == "service":
|
||||
raise RuntimeError("inactive")
|
||||
return ""
|
||||
if args[:4] == ["ip", "-j", "-4", "address"]:
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"addr_info": []
|
||||
if failure == "address"
|
||||
else [{"local": "10.0.0.1", "prefixlen": 24}]
|
||||
}
|
||||
]
|
||||
)
|
||||
if args[0] == "ip":
|
||||
return json.dumps(
|
||||
[]
|
||||
if failure == "route"
|
||||
else [{"dst": "default", "dev": "eno1"}]
|
||||
)
|
||||
if args[0] == "nft":
|
||||
if args[-1] == "filter":
|
||||
return json.dumps(
|
||||
{
|
||||
"nftables": [
|
||||
{
|
||||
"chain": {
|
||||
"hook": hook,
|
||||
"policy": "accept"
|
||||
if failure == "firewall"
|
||||
else "drop",
|
||||
}
|
||||
}
|
||||
for hook in ["input", "forward"]
|
||||
]
|
||||
}
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"nftables": [
|
||||
{"chain": {"hook": hook}}
|
||||
for hook in ["prerouting", "postrouting"]
|
||||
]
|
||||
+ (
|
||||
[]
|
||||
if failure == "nat"
|
||||
else [
|
||||
{
|
||||
"rule": {
|
||||
"expr": [
|
||||
{"dnat": {}},
|
||||
{"masquerade": None},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
)
|
||||
if args[0] == "ping":
|
||||
if failure == "wan":
|
||||
raise RuntimeError("unreachable")
|
||||
return ""
|
||||
if args[0] == "dig":
|
||||
return "" if failure == "dns" else "93.184.215.14"
|
||||
if args[0] == "curl":
|
||||
return "503" if failure == "proxy" else "302"
|
||||
raise AssertionError(args)
|
||||
|
||||
self.f.command = health_command
|
||||
ssh = MagicMock()
|
||||
ssh.__enter__.return_value.recv.return_value = (
|
||||
b"HTTP/1.1" if failure == "ssh" else b"SSH-2.0-fixture"
|
||||
)
|
||||
read_text = Path.read_text
|
||||
|
||||
def read(path, *args, **kw):
|
||||
if str(path) == "/proc/sys/net/ipv4/ip_forward":
|
||||
return "1"
|
||||
return read_text(path, *args, **kw)
|
||||
|
||||
with (
|
||||
patch.object(m.socket, "create_connection", return_value=ssh),
|
||||
patch.object(Path, "read_text", read),
|
||||
):
|
||||
if failure:
|
||||
with self.assertRaises(RuntimeError):
|
||||
m.Deployment.healthy(self.f)
|
||||
else:
|
||||
m.Deployment.healthy(self.f)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "recover-fixture":
|
||||
fixture = Fixture(Path(sys.argv[2]))
|
||||
worker_pid = int(sys.argv[3])
|
||||
fake = fixture.command
|
||||
|
||||
def stop_fixture_worker(args, **kwargs):
|
||||
if args[:2] == ["systemctl", "stop"] and args[-1].endswith(".service"):
|
||||
os.kill(worker_pid, signal.SIGKILL)
|
||||
return fake(args, **kwargs)
|
||||
|
||||
fixture.command = stop_fixture_worker
|
||||
fixture.recover("a" * 32)
|
||||
else:
|
||||
unittest.main()
|
||||
540
scripts/deploy.py
Normal file
540
scripts/deploy.py
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
"""Root-only server deployment transactions. Commands are supplied by the Nix wrapper.
|
||||
|
||||
The deployment lock excludes other deployments and the WAN watchdog. A separate
|
||||
systemd timer can stop a stuck worker before taking that lock and rolling back.
|
||||
State and GC roots survive the invoking terminal, service and machine reboot.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import fcntl
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
TERMINAL = {"committed", "rolled-back", "build-failed"}
|
||||
|
||||
|
||||
def run(args, timeout=120):
|
||||
# A timeout must kill descendants too, not leave an activation writing in
|
||||
# parallel with recovery. systemd also kills the entire worker cgroup.
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
output, _ = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.communicate()
|
||||
raise RuntimeError(f"Command timed out: {args[0]}") from None
|
||||
if proc.returncode:
|
||||
raise RuntimeError(
|
||||
f"Command failed ({proc.returncode}): {' '.join(map(str, args))}\n{output}"
|
||||
)
|
||||
return output.strip()
|
||||
|
||||
|
||||
def sync_directory(path):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def atomic_json(path, value):
|
||||
temp = path.with_suffix(".tmp")
|
||||
with temp.open("w") as out:
|
||||
json.dump(value, out)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(temp, path)
|
||||
sync_directory(path.parent)
|
||||
|
||||
|
||||
class Deployment:
|
||||
def __init__(
|
||||
self,
|
||||
runner,
|
||||
state="/var/lib/fred-deploy",
|
||||
roots="/nix/var/nix/gcroots/fred-deploy",
|
||||
lock="/run/lock/fred-deploy.lock",
|
||||
command=run,
|
||||
):
|
||||
self.runner = str(Path(runner).resolve())
|
||||
self.state, self.roots, self.lock = Path(state), Path(roots), Path(lock)
|
||||
self.command = command
|
||||
self.profile = Path("/nix/var/nix/profiles/system")
|
||||
self.current = Path("/run/current-system")
|
||||
self.state.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
self.roots.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
sync_directory(self.state.parent)
|
||||
sync_directory(self.roots.parent)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def exclusive(self):
|
||||
with self.lock.open("a") as lock:
|
||||
# Queue deployments; watchdog uses a nonblocking acquisition.
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
def read(self):
|
||||
file = self.state / "last-result.json"
|
||||
return json.loads(file.read_text()) if file.exists() else None
|
||||
|
||||
def save(self, record, phase, **fields):
|
||||
record.update(phase=phase, **fields)
|
||||
atomic_json(self.state / "last-result.json", record)
|
||||
print(f"Deployment {record['id']}: {phase}", flush=True)
|
||||
|
||||
def pin(self, name, target):
|
||||
link = self.roots / name
|
||||
temporary = self.roots / (name + ".new")
|
||||
temporary.unlink(missing_ok=True)
|
||||
temporary.symlink_to(target)
|
||||
os.replace(temporary, link)
|
||||
sync_directory(self.roots)
|
||||
|
||||
def cleanup(self):
|
||||
for name in ("candidate", "old-running", "old-boot", "runner"):
|
||||
(self.roots / name).unlink(missing_ok=True)
|
||||
|
||||
def healthy(self):
|
||||
c = self.command
|
||||
c(
|
||||
[
|
||||
"systemctl",
|
||||
"is-active",
|
||||
"--quiet",
|
||||
"systemd-networkd",
|
||||
"dnsmasq",
|
||||
"adguardhome",
|
||||
"nginx",
|
||||
"sshd",
|
||||
"nftables",
|
||||
"router-ui",
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
addresses = json.loads(
|
||||
c(["ip", "-j", "-4", "address", "show", "dev", "eth0"], timeout=5)
|
||||
)
|
||||
if not any(
|
||||
a["local"] == "10.0.0.1" and a["prefixlen"] == 24
|
||||
for interface in addresses
|
||||
for a in interface["addr_info"]
|
||||
):
|
||||
raise RuntimeError("LAN address 10.0.0.1/24 missing")
|
||||
routes = json.loads(
|
||||
c(["ip", "-j", "-4", "route", "show", "default", "dev", "eno1"], timeout=5)
|
||||
)
|
||||
if (
|
||||
not routes
|
||||
or Path("/proc/sys/net/ipv4/ip_forward").read_text().strip() != "1"
|
||||
):
|
||||
raise RuntimeError("WAN route or IPv4 forwarding missing")
|
||||
rules = json.loads(
|
||||
c(["nft", "-j", "list", "table", "inet", "filter"], timeout=5)
|
||||
)
|
||||
chains = [r["chain"] for r in rules["nftables"] if "chain" in r]
|
||||
for hook in ("input", "forward"):
|
||||
if not any(
|
||||
ch.get("hook") == hook and ch.get("policy") == "drop" for ch in chains
|
||||
):
|
||||
raise RuntimeError(f"Default-drop {hook} firewall missing")
|
||||
nat = json.loads(
|
||||
c(["nft", "-j", "list", "table", "ip", "router-nat"], timeout=5)
|
||||
)
|
||||
hooks = {r["chain"].get("hook") for r in nat["nftables"] if "chain" in r}
|
||||
if not {"prerouting", "postrouting"} <= hooks:
|
||||
raise RuntimeError("Router NAT hooks missing")
|
||||
expressions = [
|
||||
expr
|
||||
for item in nat["nftables"]
|
||||
if "rule" in item
|
||||
for expr in item["rule"].get("expr", [])
|
||||
]
|
||||
if not all(
|
||||
any(action in expr for expr in expressions)
|
||||
for action in ("dnat", "masquerade")
|
||||
):
|
||||
raise RuntimeError("Router DNAT or masquerade rules missing")
|
||||
try:
|
||||
c(["ping", "-c1", "-W3", "-I", "eno1", "1.1.1.1"], timeout=5)
|
||||
except RuntimeError:
|
||||
c(["ping", "-c1", "-W3", "-I", "eno1", "8.8.8.8"], timeout=5)
|
||||
answer = c(
|
||||
["dig", "@10.0.0.1", "example.com", "A", "+short", "+time=2", "+tries=1"],
|
||||
timeout=5,
|
||||
)
|
||||
if not any(self.is_ip(line) for line in answer.splitlines()):
|
||||
raise RuntimeError("LAN DNS query failed")
|
||||
with socket.create_connection(("10.0.0.1", 22), timeout=3) as ssh:
|
||||
if not ssh.recv(128).startswith(b"SSH-2.0-"):
|
||||
raise RuntimeError("SSH did not return a protocol banner")
|
||||
c(
|
||||
[
|
||||
"curl",
|
||||
"--fail",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--max-time",
|
||||
"5",
|
||||
"http://127.0.0.1:8086/",
|
||||
],
|
||||
timeout=7,
|
||||
)
|
||||
code = c(
|
||||
[
|
||||
"curl",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--max-time",
|
||||
"5",
|
||||
"--output",
|
||||
"/dev/null",
|
||||
"--write-out",
|
||||
"%{http_code}",
|
||||
"--resolve",
|
||||
"router.nordhammer.it:443:10.0.0.1",
|
||||
"https://router.nordhammer.it/",
|
||||
],
|
||||
timeout=7,
|
||||
)
|
||||
if code not in {"200", "301", "302", "303", "307", "308", "401", "403"}:
|
||||
raise RuntimeError(f"Management proxy returned HTTP {code}")
|
||||
|
||||
@staticmethod
|
||||
def is_ip(value):
|
||||
try:
|
||||
ipaddress.IPv4Address(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def wait_healthy(self, duration=90):
|
||||
deadline = time.monotonic() + duration
|
||||
while True:
|
||||
try:
|
||||
self.healthy()
|
||||
return
|
||||
except Exception as error:
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError(
|
||||
f"Router health checks failed: {error}"
|
||||
) from error
|
||||
print(f"Waiting for router health: {error}", flush=True)
|
||||
time.sleep(5)
|
||||
|
||||
def disarm(self, record):
|
||||
# Do not stop the recovery service: it may already be checking state.
|
||||
self.command(
|
||||
["systemctl", "stop", f"fred-rollback-{record['id']}.timer"], timeout=15
|
||||
)
|
||||
|
||||
def rollback_locked(self, record):
|
||||
try:
|
||||
self.save(record, "rolling-back")
|
||||
except OSError as error:
|
||||
# A full/read-only state filesystem must not prevent restoration.
|
||||
print(f"Cannot record recovery start: {error}", flush=True)
|
||||
errors = []
|
||||
# Always attempt runtime recovery, even if bootloader restoration fails.
|
||||
for args in (
|
||||
["nix-env", "--profile", str(self.profile), "--set", record["old_boot"]],
|
||||
[record["old_boot"] + "/bin/switch-to-configuration", "boot"],
|
||||
[record["old_running"] + "/bin/switch-to-configuration", "test"],
|
||||
):
|
||||
try:
|
||||
self.command(args, timeout=120)
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
try:
|
||||
self.wait_healthy()
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
if errors:
|
||||
self.save(record, "rollback-failed", errors=errors)
|
||||
raise RuntimeError(
|
||||
"Recovery needs attention; GC roots retained: " + "; ".join(errors)
|
||||
)
|
||||
self.save(record, "rolled-back")
|
||||
# Cancellation failure cannot turn a completed rollback into success
|
||||
# for the deployment. A late timer sees terminal state and does nothing.
|
||||
try:
|
||||
self.disarm(record)
|
||||
except Exception as error:
|
||||
print(f"Could not cancel completed recovery timer: {error}", flush=True)
|
||||
self.cleanup()
|
||||
|
||||
def recover(self, expected=None, boot=False):
|
||||
record = self.read()
|
||||
if (
|
||||
not record
|
||||
or record["phase"] in TERMINAL
|
||||
or (expected and expected != record["id"])
|
||||
):
|
||||
return
|
||||
if boot and record.get("boot_id") == self.boot_id():
|
||||
return
|
||||
expected = record["id"]
|
||||
# Never wait behind a hung activation holding the transaction lock.
|
||||
# Timer and boot recovery execute in their own cgroups.
|
||||
try:
|
||||
self.command(["systemctl", "stop", record["unit"]], timeout=30)
|
||||
except RuntimeError:
|
||||
if (
|
||||
self.command(
|
||||
[
|
||||
"systemctl",
|
||||
"show",
|
||||
"--property=LoadState",
|
||||
"--value",
|
||||
record["unit"],
|
||||
]
|
||||
)
|
||||
!= "not-found"
|
||||
):
|
||||
raise
|
||||
with self.exclusive():
|
||||
record = self.read()
|
||||
if (
|
||||
record
|
||||
and record["phase"] not in TERMINAL
|
||||
and (not expected or expected == record["id"])
|
||||
):
|
||||
if record["phase"] == "building":
|
||||
self.save(
|
||||
record,
|
||||
"build-failed",
|
||||
error="Builder interrupted; no activation attempted",
|
||||
)
|
||||
self.cleanup()
|
||||
else:
|
||||
self.rollback_locked(record)
|
||||
|
||||
@staticmethod
|
||||
def boot_id():
|
||||
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
||||
|
||||
def deploy(self, ident, flake, reboot=False):
|
||||
with self.exclusive():
|
||||
previous = self.read()
|
||||
if (
|
||||
previous
|
||||
and previous["phase"] not in TERMINAL
|
||||
and previous["phase"] != "building"
|
||||
and previous.get("boot_id") != self.boot_id()
|
||||
):
|
||||
# A boot-time update may win the lock before boot recovery.
|
||||
# The former worker cannot exist in this boot: restore first.
|
||||
self.rollback_locked(previous)
|
||||
if previous and previous["phase"] == "building":
|
||||
# Owning the lock proves the previous builder is gone. No
|
||||
# activation was attempted, so a fresh build is safe.
|
||||
self.save(
|
||||
previous, "build-failed", error="Previous builder interrupted"
|
||||
)
|
||||
self.cleanup()
|
||||
if previous and previous["phase"] not in TERMINAL:
|
||||
raise RuntimeError(
|
||||
"An interrupted deployment needs recovery first: sudo fred-deploy recover"
|
||||
)
|
||||
record = {
|
||||
"id": ident,
|
||||
"unit": f"fred-deploy-{ident}.service",
|
||||
"flake": flake,
|
||||
"boot_id": self.boot_id(),
|
||||
"old_running": str(self.current.resolve(strict=True)),
|
||||
"old_boot": str(self.profile.resolve(strict=True)),
|
||||
}
|
||||
self.pin("old-running", record["old_running"])
|
||||
self.pin("old-boot", record["old_boot"])
|
||||
self.pin("runner", str(Path(self.runner).parents[1]))
|
||||
self.save(record, "building")
|
||||
try:
|
||||
self.command(
|
||||
[
|
||||
"nix",
|
||||
"build",
|
||||
"--refresh",
|
||||
"--out-link",
|
||||
str(self.roots / "candidate"),
|
||||
flake
|
||||
+ "#nixosConfigurations.FredOS-Mediaserver.config.system.build.toplevel",
|
||||
],
|
||||
timeout=5400,
|
||||
)
|
||||
candidate = str((self.roots / "candidate").resolve(strict=True))
|
||||
if not Path(candidate, "bin/switch-to-configuration").is_file():
|
||||
raise RuntimeError("Build did not produce a NixOS system")
|
||||
if not Path(
|
||||
candidate, "etc/systemd/system/fred-deploy-recover.service"
|
||||
).is_file():
|
||||
raise RuntimeError("Candidate is missing the boot recovery service")
|
||||
self.pin("candidate", candidate)
|
||||
except Exception as error:
|
||||
self.save(record, "build-failed", error=str(error))
|
||||
self.cleanup()
|
||||
raise
|
||||
# Durable intent precedes timer setup; boot recovery can handle a
|
||||
# crash even between starting the timer and invoking activation.
|
||||
self.save(record, "armed", candidate=candidate)
|
||||
try:
|
||||
self.command(
|
||||
[
|
||||
"systemd-run",
|
||||
"--collect",
|
||||
"--unit",
|
||||
f"fred-rollback-{ident}",
|
||||
"--on-active=10min",
|
||||
"--timer-property=AccuracySec=1s",
|
||||
"--timer-property=RemainAfterElapse=no",
|
||||
"--property=Type=exec",
|
||||
"--property=RuntimeMaxSec=12min",
|
||||
"--property=TimeoutStopSec=10s",
|
||||
self.runner,
|
||||
"recover",
|
||||
"--id",
|
||||
ident,
|
||||
],
|
||||
timeout=20,
|
||||
)
|
||||
self.save(record, "activating")
|
||||
self.command(
|
||||
[candidate + "/bin/switch-to-configuration", "test"], timeout=120
|
||||
)
|
||||
self.wait_healthy()
|
||||
self.save(record, "committing")
|
||||
self.command(
|
||||
["nix-env", "--profile", str(self.profile), "--set", candidate],
|
||||
timeout=30,
|
||||
)
|
||||
self.command(
|
||||
[candidate + "/bin/switch-to-configuration", "boot"], timeout=120
|
||||
)
|
||||
self.healthy()
|
||||
self.save(record, "committed")
|
||||
except Exception as error:
|
||||
# Timer owns recovery after SIGKILL; ordinary errors recover now.
|
||||
print(f"Deployment failed: {error}", flush=True)
|
||||
record["error"] = str(error)
|
||||
self.rollback_locked(record)
|
||||
raise RuntimeError(
|
||||
"Deployment failed; previous runtime and boot configuration restored"
|
||||
) from error
|
||||
try:
|
||||
self.disarm(record)
|
||||
except Exception as error:
|
||||
print(
|
||||
f"Timer cleanup failed (committed state is durable): {error}",
|
||||
flush=True,
|
||||
)
|
||||
self.cleanup()
|
||||
recorder = Path("/run/current-system/sw/bin/record-update")
|
||||
if recorder.exists():
|
||||
try:
|
||||
self.command(
|
||||
[str(recorder), record["old_running"], candidate], timeout=30
|
||||
)
|
||||
except Exception as error:
|
||||
print(
|
||||
f"Deployment committed; history recording failed: {error}",
|
||||
flush=True,
|
||||
)
|
||||
if reboot:
|
||||
components = ("kernel", "initrd", "kernel-modules")
|
||||
if any(
|
||||
Path("/run/booted-system", p).resolve()
|
||||
!= Path(candidate, p).resolve()
|
||||
for p in components
|
||||
):
|
||||
self.command(["systemctl", "reboot", "--no-block"])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="fred-deploy",
|
||||
description="Build and safely deploy the mediaserver configuration.",
|
||||
usage="%(prog)s [--flake SOURCE] [--reboot]\n %(prog)s recover",
|
||||
)
|
||||
parser.add_argument(
|
||||
"action",
|
||||
choices=["launch", "launch-recovery", "deploy", "recover"],
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--flake",
|
||||
default="git+https://forg.gregersen.it/rope/nixos",
|
||||
help="Flake source without a #fragment; defaults to the Forgejo repository",
|
||||
)
|
||||
parser.add_argument("--id", help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--reboot",
|
||||
action="store_true",
|
||||
help="Reboot after success if boot components changed",
|
||||
)
|
||||
parser.add_argument("--boot", action="store_true", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
if os.geteuid() != 0:
|
||||
parser.error("run this command with sudo")
|
||||
if "#" in args.flake:
|
||||
parser.error("pass a flake source without a #fragment")
|
||||
if args.id and not re.fullmatch(r"[0-9a-f]{32}", args.id):
|
||||
parser.error("invalid transaction ID")
|
||||
runner = os.environ["FRED_DEPLOY_RUNNER"]
|
||||
if args.action in {"launch", "launch-recovery"}:
|
||||
ident = uuid.uuid4().hex
|
||||
recovering = args.action == "launch-recovery"
|
||||
unit = f"fred-recover-{ident}" if recovering else f"fred-deploy-{ident}"
|
||||
print(f"Deployment logs: journalctl -fu {unit}", flush=True)
|
||||
command = [
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
"--collect",
|
||||
"--unit",
|
||||
unit,
|
||||
"--property=Type=exec",
|
||||
"--property=RuntimeMaxSec=100min",
|
||||
"--property=TimeoutStopSec=10s",
|
||||
"--property=WorkingDirectory=/",
|
||||
runner,
|
||||
]
|
||||
command += (
|
||||
["recover"]
|
||||
if recovering
|
||||
else ["deploy", "--id", ident, "--flake", args.flake]
|
||||
)
|
||||
if args.reboot:
|
||||
command.append("--reboot")
|
||||
result = subprocess.run(command)
|
||||
subprocess.run(["journalctl", "--no-pager", "-o", "cat", "-u", unit])
|
||||
return result.returncode
|
||||
deployment = Deployment(runner)
|
||||
if args.action == "recover":
|
||||
deployment.recover(args.id, args.boot)
|
||||
else:
|
||||
if not args.id:
|
||||
parser.error("deploy requires a transaction ID")
|
||||
deployment.deploy(args.id, args.flake, args.reboot)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as error:
|
||||
print(str(error), file=sys.stderr, flush=True)
|
||||
sys.exit(1)
|
||||
129
scripts/greeter-check.py
Normal file
129
scripts/greeter-check.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Test greeter failure/retry/launch against a fake greetd server.
|
||||
|
||||
Usage: QML2_IMPORT_PATH=<qt5compat>/lib/qt-6/qml python3 scripts/greeter-check.py <auth-bundle>
|
||||
Requires qs on PATH. No real authentication or desktop launch occurs.
|
||||
"""
|
||||
|
||||
import json, os, socket, struct, subprocess as sp, tempfile, time, shutil, sys
|
||||
from pathlib import Path
|
||||
|
||||
qs = shutil.which("qs")
|
||||
assert qs, "qs must be on PATH"
|
||||
root = Path(tempfile.mkdtemp(prefix="fred-greetd-check."))
|
||||
root.chmod(0o700)
|
||||
shutil.copytree(str(Path(sys.argv[1]) / "greeter"), root / "config")
|
||||
p = root / "config/shell.qml"
|
||||
p.chmod(0o600)
|
||||
s = (
|
||||
p.read_text()
|
||||
.replace("import Quickshell\n", "import Quickshell\nimport Quickshell.Io\n")
|
||||
.replace(" AuthView {", " AuthView {\n id: auth")
|
||||
)
|
||||
s = (
|
||||
s.rstrip()[:-1]
|
||||
+ """ IpcHandler { target: "test"; function submit(password: string): void { auth.submitted(password); } }
|
||||
}
|
||||
"""
|
||||
)
|
||||
p.write_text(s)
|
||||
server = socket.socket(socket.AF_UNIX)
|
||||
server.bind(str(root / "greetd.sock"))
|
||||
server.listen()
|
||||
server.settimeout(10)
|
||||
env = {
|
||||
**os.environ,
|
||||
"HOME": str(root),
|
||||
"XDG_RUNTIME_DIR": str(root),
|
||||
"GREETD_SOCK": str(root / "greetd.sock"),
|
||||
"FRED_GREETER": "1",
|
||||
"QT_QPA_PLATFORM": "offscreen",
|
||||
"QT_QUICK_BACKEND": "software",
|
||||
"FRED_SESSION_COMMAND": "/fixture/authenticated-session",
|
||||
}
|
||||
log = open(root / "log", "w")
|
||||
proc = sp.Popen([qs, "-p", str(root / "config")], env=env, stdout=log, stderr=log)
|
||||
try:
|
||||
conn, _ = server.accept()
|
||||
conn.settimeout(5)
|
||||
|
||||
def recv():
|
||||
data = b""
|
||||
while len(data) < 4:
|
||||
data += conn.recv(4 - len(data))
|
||||
size = struct.unpack("<I", data)[0]
|
||||
data = b""
|
||||
while len(data) < size:
|
||||
data += conn.recv(size - len(data))
|
||||
value = json.loads(data)
|
||||
print(value, flush=True)
|
||||
return value
|
||||
|
||||
def send(value):
|
||||
data = json.dumps(value).encode()
|
||||
conn.sendall(struct.pack("<I", len(data)) + data)
|
||||
|
||||
def submit():
|
||||
for _ in range(30):
|
||||
result = sp.run(
|
||||
[
|
||||
qs,
|
||||
"ipc",
|
||||
"--pid",
|
||||
str(proc.pid),
|
||||
"call",
|
||||
"test",
|
||||
"submit",
|
||||
"fixture-password",
|
||||
],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(result.stderr)
|
||||
|
||||
for success in [False, True]:
|
||||
submit()
|
||||
assert recv() == {"type": "create_session", "username": "fred"}
|
||||
send(
|
||||
{
|
||||
"type": "auth_message",
|
||||
"auth_message_type": "secret",
|
||||
"auth_message": "Password:",
|
||||
}
|
||||
)
|
||||
assert recv() == {
|
||||
"type": "post_auth_message_response",
|
||||
"response": "fixture-password",
|
||||
}
|
||||
if success:
|
||||
send({"type": "success"})
|
||||
launch = recv()
|
||||
assert (
|
||||
launch["type"] == "start_session"
|
||||
and launch["cmd"] == ["/fixture/authenticated-session"]
|
||||
and "XDG_SESSION_TYPE=wayland" in launch["env"]
|
||||
)
|
||||
send({"type": "success"})
|
||||
assert proc.wait(timeout=5) == 0
|
||||
else:
|
||||
send(
|
||||
{
|
||||
"type": "error",
|
||||
"error_type": "auth_error",
|
||||
"description": "fixture rejection",
|
||||
}
|
||||
)
|
||||
assert recv() == {"type": "cancel_session"}
|
||||
send({"type": "success"})
|
||||
time.sleep(0.3)
|
||||
print(
|
||||
"Greeter: failed password does not launch; retry and authenticated launch pass"
|
||||
)
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
log.close()
|
||||
print((root / "log").read_text())
|
||||
18
scripts/lock-session.sh
Normal file
18
scripts/lock-session.sh
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Invoked by the fred-lock wrapper, with coreutils, systemd and qs on PATH.
|
||||
set -euo pipefail
|
||||
|
||||
timeout 5s systemctl --user start quickshell-lock.service
|
||||
deadline=$((SECONDS + 10))
|
||||
while (( SECONDS < deadline )); do
|
||||
# Select the running service, including one from an older generation.
|
||||
locker_pid=$(timeout 1s systemctl --user show quickshell-lock.service --property=MainPID --value)
|
||||
if [[ "$locker_pid" =~ ^[1-9][0-9]*$ ]] &&
|
||||
timeout 1s qs ipc --pid "$locker_pid" call lock lock >/dev/null 2>&1 &&
|
||||
secure=$(timeout 1s qs ipc --pid "$locker_pid" call lock secure 2>/dev/null) &&
|
||||
[[ "$secure" == true ]]; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo "The compositor did not confirm a secure session lock" >&2
|
||||
exit 1
|
||||
61
scripts/media-storage-check.py
Normal file
61
scripts/media-storage-check.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Test the generated storage readiness script in a private mount namespace.
|
||||
|
||||
Usage: sudo python3 scripts/media-storage-check.py <media-storage-ready-unit>
|
||||
Uses temporary tmpfs mounts and NSS fixtures; host media and mount state are untouched.
|
||||
"""
|
||||
|
||||
import os, re, subprocess as sp, sys, tempfile
|
||||
from pathlib import Path
|
||||
|
||||
original = os.environ.get("FRED_TEST_ORIGINAL_MNTNS")
|
||||
if not original:
|
||||
os.execvpe(
|
||||
"unshare",
|
||||
["unshare", "-m", sys.executable, *sys.argv],
|
||||
{**os.environ, "FRED_TEST_ORIGINAL_MNTNS": os.readlink("/proc/self/ns/mnt")},
|
||||
)
|
||||
assert original != os.readlink("/proc/self/ns/mnt")
|
||||
sp.run(["mount", "--make-rprivate", "/"], check=True)
|
||||
sp.run(["mount", "-t", "tmpfs", "tmpfs", "/mnt"], check=True)
|
||||
for name in ["storage", "disk1", "disk2", "disk3"]:
|
||||
Path("/mnt", name).mkdir()
|
||||
unit = (Path(sys.argv[1]) / "media-storage-ready.service").read_text()
|
||||
script = re.search(r"^ExecStart=(\S+)", unit, re.M)[1]
|
||||
|
||||
|
||||
def ready(expected):
|
||||
r = sp.run([script], capture_output=True, text=True)
|
||||
assert (r.returncode == 0) == expected, r.stderr
|
||||
print(r.stderr.strip() or "Readiness succeeded", flush=True)
|
||||
|
||||
|
||||
ready(False)
|
||||
assert list(Path("/mnt/storage").iterdir()) == []
|
||||
for name in ["storage", "disk1", "disk2"]:
|
||||
sp.run(["mount", "-t", "tmpfs", "tmpfs", "/mnt/" + name], check=True)
|
||||
ready(False)
|
||||
assert list(Path("/mnt/storage").iterdir()) == []
|
||||
# Isolated NSS fixtures let tmpfiles resolve the real configured names.
|
||||
root = Path(tempfile.mkdtemp(prefix="fred-storage-users."))
|
||||
(root / "passwd").write_text(
|
||||
"root:x:0:0:root:/root:/bin/sh\n"
|
||||
+ "".join(
|
||||
f"{name}:x:0:0::{root}:/bin/sh\n"
|
||||
for name in ["fred", "qbittorrent", "sonarr", "radarr", "sabnzbd"]
|
||||
)
|
||||
)
|
||||
(root / "group").write_text("root:x:0:\nmedia:x:0:\n")
|
||||
for name in ["passwd", "group"]:
|
||||
sp.run(["mount", "--bind", str(root / name), "/etc/" + name], check=True)
|
||||
sp.run(["mount", "-t", "tmpfs", "tmpfs", "/mnt/disk3"], check=True)
|
||||
ready(True)
|
||||
assert Path("/mnt/storage/torrents/shows").is_dir()
|
||||
sp.run(["umount", "/mnt/disk2"], check=True)
|
||||
ready(False)
|
||||
sp.run(["umount", "/mnt/storage"], check=True)
|
||||
assert list(Path("/mnt/storage").iterdir()) == [], (
|
||||
"wrote into underlying root directory"
|
||||
)
|
||||
print(
|
||||
"Storage guard: missing pool/branch and lost branch refused; mounted pool prepared; underlying directory untouched"
|
||||
)
|
||||
142
scripts/router-firewall-check.py
Normal file
142
scripts/router-firewall-check.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""Exercise evaluated router rules in private user/network namespaces.
|
||||
|
||||
Run: python3 scripts/router-firewall-check.py /tmp/router-rules.nft
|
||||
Requires ip, nft, nsenter and unshare on PATH. Never runs against the host network.
|
||||
The extra NAT table models Docker publishing allowed and unlisted host ports.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess as sp
|
||||
import sys
|
||||
|
||||
original = os.environ.get("FRED_TEST_ORIGINAL_NETNS")
|
||||
if not original:
|
||||
os.execvpe(
|
||||
"unshare",
|
||||
["unshare", "-Urn", sys.executable, *sys.argv],
|
||||
{**os.environ, "FRED_TEST_ORIGINAL_NETNS": os.readlink("/proc/self/ns/net")},
|
||||
)
|
||||
if os.readlink("/proc/self/ns/net") == original:
|
||||
sys.exit("Refusing to run outside a private network namespace")
|
||||
|
||||
children = []
|
||||
|
||||
|
||||
def run(*args, **kw):
|
||||
return sp.run(args, check=True, text=True, **kw)
|
||||
|
||||
|
||||
def net(pid, *args, **kw):
|
||||
return run("nsenter", "-t", str(pid), "-n", *args, **kw)
|
||||
|
||||
|
||||
def peer(interface, address, peer_address):
|
||||
proc = sp.Popen(["unshare", "-n", "sleep", "300"])
|
||||
children.append(proc)
|
||||
# unshare must finish before moving the peer into its namespace.
|
||||
import time
|
||||
|
||||
for _ in range(100):
|
||||
if os.readlink(f"/proc/{proc.pid}/ns/net") != os.readlink("/proc/self/ns/net"):
|
||||
break
|
||||
time.sleep(0.01)
|
||||
run("ip", "link", "add", interface, "type", "veth", "peer", "name", "peer0")
|
||||
run("ip", "link", "set", "peer0", "netns", str(proc.pid))
|
||||
run("ip", "addr", "add", address + "/24", "dev", interface)
|
||||
run("ip", "link", "set", interface, "up")
|
||||
net(proc.pid, "ip", "addr", "add", peer_address + "/24", "dev", "peer0")
|
||||
net(proc.pid, "ip", "link", "set", "peer0", "up")
|
||||
net(proc.pid, "ip", "link", "set", "lo", "up")
|
||||
net(proc.pid, "ip", "route", "add", "default", "via", address)
|
||||
return proc.pid
|
||||
|
||||
|
||||
server = """
|
||||
import socket,threading,time
|
||||
ports=PORTS
|
||||
for port in ports:
|
||||
for kind in (socket.SOCK_STREAM,socket.SOCK_DGRAM):
|
||||
s=socket.socket(socket.AF_INET,kind);s.bind(('0.0.0.0',port))
|
||||
if kind==socket.SOCK_STREAM:s.listen()
|
||||
def serve(s=s,kind=kind):
|
||||
while True:
|
||||
if kind==socket.SOCK_STREAM:
|
||||
c,a=s.accept()
|
||||
with c:
|
||||
data=c.recv(16)
|
||||
if data:c.sendall(data)
|
||||
else:
|
||||
data,a=s.recvfrom(16);s.sendto(data,a)
|
||||
threading.Thread(target=serve,daemon=True).start()
|
||||
print('ready',flush=True)
|
||||
time.sleep(300)
|
||||
"""
|
||||
|
||||
|
||||
def serve(pid, ports):
|
||||
cmd = [sys.executable, "-u", "-c", server.replace("PORTS", repr(ports))]
|
||||
if pid:
|
||||
cmd = ["nsenter", "-t", str(pid), "-n"] + cmd
|
||||
proc = sp.Popen(cmd, stdout=sp.PIPE, text=True)
|
||||
children.append(proc)
|
||||
assert proc.stdout.readline().strip() == "ready"
|
||||
|
||||
|
||||
def check(pid, port, proto, expected):
|
||||
client = f"""
|
||||
import socket
|
||||
s=socket.socket(socket.AF_INET,socket.SOCK_{"STREAM" if proto == "tcp" else "DGRAM"})
|
||||
s.settimeout(.4)
|
||||
try:
|
||||
s.connect(('{"10.0.0.1" if pid == lan else "198.18.0.1"}',{port}))
|
||||
s.send(b'hello')
|
||||
ok=s.recv(16)==b'hello'
|
||||
except OSError:ok=False
|
||||
assert ok=={expected!r}, 'unexpected reachability: {proto}/{port}, expected {expected}'
|
||||
"""
|
||||
net(pid, sys.executable, "-c", client)
|
||||
print(
|
||||
f"{'WAN' if pid == wan else 'LAN'} {proto}/{port}: {'allowed' if expected else 'blocked'}"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
run("ip", "link", "set", "lo", "up")
|
||||
wan = peer("eno1", "198.18.0.1", "198.18.0.2")
|
||||
lan = peer("eth0", "10.0.0.1", "10.0.0.2")
|
||||
container = peer("docker0", "172.18.0.1", "172.18.0.2")
|
||||
Path("/proc/sys/net/ipv4/ip_forward").write_text("1")
|
||||
run("nft", "-f", sys.argv[1])
|
||||
run(
|
||||
"nft",
|
||||
"-f",
|
||||
"-",
|
||||
input="""
|
||||
table ip docker_test {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority -101; policy accept;
|
||||
iifname "eno1" tcp dport { 25565, 25600, 2022 } dnat to 172.18.0.2
|
||||
iifname "eno1" udp dport { 25565, 25600, 2022 } dnat to 172.18.0.2
|
||||
iifname "eno1" tcp dport { 25564, 25601, 9999 } dnat to 172.18.0.2:9000
|
||||
}
|
||||
chain forward { type filter hook forward priority 0; policy accept; }
|
||||
}
|
||||
""",
|
||||
)
|
||||
serve(None, [22, 443, 2022])
|
||||
serve(container, [25565, 25600, 2022, 9000])
|
||||
for port in [22, 443, 25565, 25600]:
|
||||
check(wan, port, "tcp", True)
|
||||
for port in [25565, 25600]:
|
||||
check(wan, port, "udp", True)
|
||||
for port in [2022, 25564, 25601, 9999]:
|
||||
check(wan, port, "tcp", False)
|
||||
check(wan, 2022, "udp", False)
|
||||
check(lan, 2022, "tcp", True)
|
||||
print("Router namespace checks passed")
|
||||
finally:
|
||||
for proc in reversed(children):
|
||||
proc.terminate()
|
||||
for proc in children:
|
||||
proc.wait()
|
||||
27
scripts/router-forwards-check.nix
Normal file
27
scripts/router-forwards-check.nix
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{ lib }:
|
||||
let
|
||||
validate = import ../services/validate-forwards.nix { inherit lib; };
|
||||
valid = builtins.fromTOML (builtins.readFile ../ports.toml);
|
||||
accepts = data: (builtins.tryEval (builtins.deepSeq (validate data) true)).success;
|
||||
extra = entry: valid // { forward = valid.forward ++ [ entry ]; };
|
||||
forward = { name = "test"; port = 9999; protocol = "tcp"; };
|
||||
invalid = [
|
||||
(valid // { dest_default = "10.0.0.2"; })
|
||||
(valid // { forward = builtins.filter (f: f.name != "SSH") valid.forward; })
|
||||
(valid // { forward = map (f: if f.name == "HTTPS" then f // { dest = "10.0.0.2"; } else f) valid.forward; })
|
||||
(extra (forward // { name = "bad\" label"; }))
|
||||
(extra (forward // { name = "SSH"; }))
|
||||
(extra (forward // { protocol = "sctp"; }))
|
||||
(extra (forward // { dest = "10.0.0.5; accept"; }))
|
||||
(extra (forward // { dest = "10.0.0.255"; }))
|
||||
(extra (forward // { port = 0; }))
|
||||
(extra (forward // { port = 65536; }))
|
||||
(extra (forward // { ports = "10000-10001"; }))
|
||||
(extra { name = "overlap"; ports = "25600-25601"; protocol = "udp"; })
|
||||
(extra { name = "backwards"; ports = "9001-9000"; protocol = "udp"; })
|
||||
];
|
||||
in
|
||||
assert accepts valid;
|
||||
assert lib.all (data: !accepts data) invalid;
|
||||
assert accepts (extra (forward // { port = 443; protocol = "udp"; dest = "10.0.0.2"; }));
|
||||
"Forward validation: valid inventory and separate protocols accepted; all ${toString (builtins.length invalid)} invalid cases rejected"
|
||||
|
|
@ -174,3 +174,22 @@ assert got[0] == {"ts": "2026-08-15 12:00:55", "down": 508.9, "up": 70.7,
|
|||
"ping": 18.8, "server": "Preston"}, got[0]
|
||||
|
||||
print("\nall checks passed", file=sys.stderr)
|
||||
|
||||
# A save must not inject nft syntax, introduce ambiguous forwards, or move
|
||||
# management ports to another machine while appearing to keep them open.
|
||||
for extra in [
|
||||
{"name": 'bad" rule', "port": "9999", "protocol": "tcp"},
|
||||
{"name": "bad destination", "port": "9999", "protocol": "tcp", "dest": "10.0.0.5; accept"},
|
||||
{"name": "outside LAN", "port": "9999", "protocol": "tcp", "dest": "192.168.1.2"},
|
||||
{"name": "broadcast", "port": "9999", "protocol": "tcp", "dest": "10.0.0.255"},
|
||||
{"name": "overlap", "port": "25600-25610", "protocol": "udp"},
|
||||
{"name": "SSH", "port": "9999", "protocol": "udp"},
|
||||
{"name": "ambiguous", "port": "9999", "ports": "10000-10001", "protocol": "udp"},
|
||||
]:
|
||||
refuses(r.check_forwards, mk() + [extra])
|
||||
for name in ("SSH", "HTTPS"):
|
||||
entries = mk()
|
||||
next(f for f in entries if f["name"] == name)["dest"] = "10.0.0.2"
|
||||
refuses(r.check_forwards, entries)
|
||||
r.check_forwards(mk() + [{"name": "different protocol", "port": "443", "protocol": "udp", "dest": "10.0.0.2"}])
|
||||
print("Forward failure-path checks passed")
|
||||
|
|
|
|||
|
|
@ -377,14 +377,20 @@ def check_devices(items, self_mac, self_ip):
|
|||
|
||||
def check_forwards(items):
|
||||
covered = set()
|
||||
names = set()
|
||||
for f in items:
|
||||
name = str(f.get("name", "")).strip()
|
||||
proto = str(f.get("protocol", "")).strip()
|
||||
if not name:
|
||||
raise Refused("every forward needs a name")
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9 _().-]{0,79}", name):
|
||||
raise Refused("forward names need 1–80 letters, digits, spaces or _().-")
|
||||
if name in names:
|
||||
raise Refused(f"duplicate forward name: {name}")
|
||||
names.add(name)
|
||||
if proto not in ("tcp", "udp", "both"):
|
||||
raise Refused(f"'{proto}' is not tcp, udp or both")
|
||||
# Accept either key so re-validating an already-normalised entry works.
|
||||
if f.get("port") and f.get("ports"):
|
||||
raise Refused("use either port or ports, not both")
|
||||
spec = str(f.get("port") or f.get("ports") or "").strip()
|
||||
if not PORT_RE.match(spec):
|
||||
raise Refused(f"'{spec}' is not a port or range (443, or 26901-26902)")
|
||||
|
|
@ -403,12 +409,18 @@ def check_forwards(items):
|
|||
else:
|
||||
f["port"] = bounds[0]
|
||||
dest = str(f.get("dest", "")).strip()
|
||||
if dest and not re.fullmatch(r"10\.0\.0\.([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])", dest):
|
||||
raise Refused("forward destination must be a LAN address in 10.0.0.1–254")
|
||||
if dest:
|
||||
f["dest"] = dest
|
||||
else:
|
||||
f.pop("dest", None)
|
||||
for p in range(bounds[0], bounds[-1] + 1):
|
||||
for pr in (("tcp", "udp") if proto == "both" else (proto,)):
|
||||
if (pr, str(p)) in covered:
|
||||
raise Refused(f"overlapping forward for {pr}/{p}")
|
||||
if (pr, str(p)) in REQUIRED_FORWARDS and dest not in ("", ROUTER_IP):
|
||||
raise Refused(f"{pr}/{p} must still reach the router ({ROUTER_IP})")
|
||||
covered.add((pr, str(p)))
|
||||
for need in REQUIRED_FORWARDS:
|
||||
if need not in covered:
|
||||
|
|
|
|||
121
scripts/session-lock-check.py
Normal file
121
scripts/session-lock-check.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Test compositor lock confirmation and helper failure when IPC disappears.
|
||||
|
||||
Usage: QML2_IMPORT_PATH=<qt5compat>/lib/qt-6/qml python3 scripts/session-lock-check.py <auth-bundle>
|
||||
Requires qs and sway on PATH. Starts only a private, headless compositor.
|
||||
"""
|
||||
|
||||
import os, subprocess as sp, tempfile, time, shutil, sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(tempfile.mkdtemp(prefix="fred-wayland-check."))
|
||||
root.chmod(0o700)
|
||||
(root / "sway.conf").write_text(
|
||||
"output HEADLESS-1 mode 1280x720\nseat seat0 fallback true\n"
|
||||
)
|
||||
env = {
|
||||
**os.environ,
|
||||
"HOME": str(root),
|
||||
"XDG_RUNTIME_DIR": str(root),
|
||||
"WLR_BACKENDS": "headless",
|
||||
"WLR_RENDERER": "pixman",
|
||||
"WLR_LIBINPUT_NO_DEVICES": "1",
|
||||
"QT_QUICK_BACKEND": "software",
|
||||
}
|
||||
env.pop("WAYLAND_DISPLAY", None)
|
||||
env.pop("DISPLAY", None)
|
||||
qs = shutil.which("qs")
|
||||
assert qs and shutil.which("sway"), "qs and sway must be on PATH"
|
||||
log = open(root / "sway.log", "w")
|
||||
sway = sp.Popen(
|
||||
[shutil.which("sway"), "-c", str(root / "sway.conf")],
|
||||
env=env,
|
||||
stdout=log,
|
||||
stderr=log,
|
||||
)
|
||||
lock = None
|
||||
try:
|
||||
for _ in range(100):
|
||||
sockets = [p for p in root.glob("wayland-*") if not str(p).endswith(".lock")]
|
||||
if sockets:
|
||||
break
|
||||
if sway.poll() is not None:
|
||||
raise RuntimeError("sway failed")
|
||||
time.sleep(0.1)
|
||||
assert sockets
|
||||
env["WAYLAND_DISPLAY"] = sockets[0].name
|
||||
env["QT_QPA_PLATFORM"] = "wayland"
|
||||
locklog = open(root / "lock.log", "w")
|
||||
lock = sp.Popen(
|
||||
[qs, "-p", str(Path(sys.argv[1]) / "lock")],
|
||||
env=env,
|
||||
stdout=locklog,
|
||||
stderr=locklog,
|
||||
)
|
||||
for _ in range(100):
|
||||
result = sp.run(
|
||||
[qs, "ipc", "--pid", str(lock.pid), "call", "lock", "secure"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip() == "true":
|
||||
break
|
||||
if lock.poll() is not None:
|
||||
raise RuntimeError("lock failed")
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
print("Wayland compositor confirms secure lock", flush=True)
|
||||
mock = root / "bin"
|
||||
mock.mkdir()
|
||||
(mock / "systemctl").write_text(
|
||||
'#!/bin/sh\ncase "$*" in *show*) echo ' + str(lock.pid) + ";; esac\n"
|
||||
)
|
||||
(mock / "systemctl").chmod(0o700)
|
||||
(mock / "qs").symlink_to(qs)
|
||||
testenv = {**env, "PATH": str(mock) + ":" + os.environ["PATH"]}
|
||||
result = sp.run(
|
||||
["bash", str(Path(__file__).with_name("lock-session.sh"))],
|
||||
env=testenv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
print("Lock helper returns success only with secure confirmation", flush=True)
|
||||
lock.terminate()
|
||||
lock.wait()
|
||||
result = sp.run(
|
||||
["bash", str(Path(__file__).with_name("lock-session.sh"))],
|
||||
env=testenv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
assert result.returncode != 0 and "did not confirm" in result.stderr, result
|
||||
print("Lock helper fails when lock IPC disappears", flush=True)
|
||||
(mock / "qs").unlink()
|
||||
(mock / "qs").write_text('#!/bin/sh\ncase "$*" in *secure*) echo false;; esac\n')
|
||||
(mock / "qs").chmod(0o700)
|
||||
result = sp.run(
|
||||
["bash", str(Path(__file__).with_name("lock-session.sh"))],
|
||||
env=testenv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
assert result.returncode != 0 and "did not confirm" in result.stderr, result
|
||||
print(
|
||||
"Lock helper fails when IPC works but the compositor never confirms coverage",
|
||||
flush=True,
|
||||
)
|
||||
finally:
|
||||
if lock and lock.poll() is None:
|
||||
lock.terminate()
|
||||
lock.wait()
|
||||
sway.terminate()
|
||||
sway.wait()
|
||||
log.close()
|
||||
for name in ["lock.log", "sway.log"]:
|
||||
if (root / name).exists():
|
||||
print(name, (root / name).read_text()[-4000:])
|
||||
75
services/deployment.nix
Normal file
75
services/deployment.nix
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# One protected activation path for manual, UI and nightly server deployments.
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
worker = pkgs.writeShellApplication {
|
||||
name = "fred-deploy-worker";
|
||||
runtimeInputs = [
|
||||
config.nix.package pkgs.systemd pkgs.iproute2 pkgs.iputils
|
||||
pkgs.nftables pkgs.dig pkgs.curl
|
||||
];
|
||||
text = ''
|
||||
export FRED_DEPLOY_RUNNER="$0"
|
||||
exec ${pkgs.python3}/bin/python3 ${../scripts/deploy.py} "$@"
|
||||
'';
|
||||
};
|
||||
command = pkgs.writeShellApplication {
|
||||
name = "fred-deploy";
|
||||
text = ''
|
||||
if [ "''${1-}" = recover ]; then
|
||||
shift
|
||||
if [ -x /nix/var/nix/gcroots/fred-deploy/runner/bin/fred-deploy-worker ]; then
|
||||
exec /nix/var/nix/gcroots/fred-deploy/runner/bin/fred-deploy-worker launch-recovery "$@"
|
||||
fi
|
||||
exec ${worker}/bin/fred-deploy-worker launch-recovery "$@"
|
||||
fi
|
||||
exec ${worker}/bin/fred-deploy-worker launch "$@"
|
||||
'';
|
||||
};
|
||||
in {
|
||||
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
|
||||
environment.systemPackages = [ command ];
|
||||
system.build.deploy = command;
|
||||
|
||||
systemd.services.nixos-upgrade = {
|
||||
description = "Build, verify and commit the nightly server deployment";
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
restartIfChanged = false;
|
||||
unitConfig.X-StopOnRemoval = false;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${command}/bin/fred-deploy --reboot";
|
||||
TimeoutStartSec = "105min";
|
||||
};
|
||||
};
|
||||
systemd.timers.nixos-upgrade = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = { OnCalendar = "05:15"; Persistent = true; };
|
||||
};
|
||||
|
||||
# Transient rollback timers do not survive reboot. Recover unfinished
|
||||
# transactions from durable state using the pinned transaction runner.
|
||||
systemd.services.fred-deploy-recover = {
|
||||
description = "Recover an interrupted server deployment";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
before = [ "wan-watchdog.service" "nixos-upgrade.service" ];
|
||||
restartIfChanged = false;
|
||||
unitConfig.X-StopOnRemoval = false;
|
||||
# A oneshot WantedBy=multi-user would hold up that target while the
|
||||
# activation script waits for target start jobs: use exec so boot can
|
||||
# finish while recovery holds the shared deployment lock.
|
||||
serviceConfig = {
|
||||
Type = "exec";
|
||||
RuntimeMaxSec = "15min";
|
||||
TimeoutStopSec = "10s";
|
||||
};
|
||||
script = ''
|
||||
if [ -x /nix/var/nix/gcroots/fred-deploy/runner/bin/fred-deploy-worker ]; then
|
||||
exec /nix/var/nix/gcroots/fred-deploy/runner/bin/fred-deploy-worker recover --boot
|
||||
fi
|
||||
exec ${worker}/bin/fred-deploy-worker recover --boot
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -122,8 +122,9 @@
|
|||
ports = [
|
||||
# Daemon API + console websocket — nginx fronts it as games.nordhammer.it.
|
||||
"127.0.0.1:8443:8080"
|
||||
# SFTP into server files. LAN-reachable; not in ports.toml, so no WAN.
|
||||
"2022:2022"
|
||||
# SFTP into server files. Bind LAN explicitly; router.nix also denies
|
||||
# new WAN connections outside the ports.toml allowlist after Docker NAT.
|
||||
"10.0.0.1:2022:2022"
|
||||
];
|
||||
environment.TZ = "Europe/Stockholm";
|
||||
# Image is distroless with no entrypoint script — needs a tty allocated.
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@
|
|||
"d /var/lib/qbittorrent 0755 qbittorrent media -"
|
||||
"Z /var/lib/qbittorrent 0755 qbittorrent media -"
|
||||
|
||||
# Storage - qbittorrent downloads here
|
||||
"d /mnt/storage/torrents/downloads 2775 qbittorrent media -"
|
||||
"Z /mnt/storage/torrents/downloads 2775 qbittorrent media -"
|
||||
];
|
||||
|
||||
systemd.services.qbittorrent-nox = {
|
||||
|
|
|
|||
|
|
@ -36,10 +36,6 @@
|
|||
};
|
||||
|
||||
# Set up directory structure with proper permissions
|
||||
systemd.tmpfiles.rules = [
|
||||
# Media folders - radarr writes here
|
||||
"d /mnt/storage/torrents/movies 2775 radarr media -"
|
||||
"Z /mnt/storage/torrents/movies 2775 radarr media -"
|
||||
];
|
||||
# Pool directories and mount dependencies live in server-permissions.nix.
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,8 @@
|
|||
# deploy that exact commit. services/router.nix reads those same files, so the
|
||||
# UI is a constrained editor for config that is still fully reviewable in git.
|
||||
#
|
||||
# Apply is deliberately paranoid, because a bad generation here takes the
|
||||
# household's internet with it:
|
||||
#
|
||||
# nixos-rebuild test -> eval/build failure aborts before anything activates
|
||||
# wait, then health-check WAN ping + dnsmasq + LAN address + NAT table
|
||||
# healthy -> nixos-rebuild switch (only now does it become the boot default)
|
||||
# unhealthy -> switch-to-configuration test on the old profile, + ntfy alert
|
||||
#
|
||||
# `test` never touches the boot default, so a reboot is always an escape hatch.
|
||||
# Apply delegates to the shared deployment transaction in deployment.nix.
|
||||
# Builds precede activation; an independent timer recovers partial failures.
|
||||
#
|
||||
# Requires a Forgejo token with write:repository on rope/nixos:
|
||||
# printf '%s' '<token>' | sudo tee /var/secrets/forgejo-router-token
|
||||
|
|
@ -34,7 +27,7 @@ let
|
|||
sudo = "/run/wrappers/bin/sudo";
|
||||
|
||||
applyScript = pkgs.writeShellScript "router-apply" ''
|
||||
set -uo pipefail
|
||||
set -euo pipefail
|
||||
rev="$1"
|
||||
flake="${flakeUrl}?rev=$rev"
|
||||
secret=/var/secrets/ntfy-url
|
||||
|
|
@ -47,45 +40,16 @@ let
|
|||
-d "$1" "$url" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
old=$(${pkgs.coreutils}/bin/readlink /run/current-system)
|
||||
|
||||
echo "==> building + activating $rev (test: boot default untouched)"
|
||||
if ! nixos-rebuild test --refresh --flake "$flake" -L; then
|
||||
echo "!! eval or build failed — nothing was activated, old config still running"
|
||||
notify "Router apply ''${rev:0:8}: build failed. Nothing changed."
|
||||
if [[ ! "$rev" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Invalid commit revision" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> activated, waiting 45s for the network to settle"
|
||||
sleep 45
|
||||
|
||||
healthy=1
|
||||
${pkgs.iputils}/bin/ping -c1 -W3 -I eno1 1.1.1.1 >/dev/null 2>&1 \
|
||||
|| ${pkgs.iputils}/bin/ping -c1 -W3 -I eno1 8.8.8.8 >/dev/null 2>&1 \
|
||||
|| { echo "!! no WAN"; healthy=0; }
|
||||
${systemctl} is-active --quiet dnsmasq || { echo "!! dnsmasq down"; healthy=0; }
|
||||
${pkgs.iproute2}/bin/ip -4 addr show eth0 \
|
||||
| ${pkgs.gnugrep}/bin/grep -q '10\.0\.0\.1' || { echo "!! LAN address gone"; healthy=0; }
|
||||
${pkgs.nftables}/bin/nft list table ip router-nat >/dev/null 2>&1 \
|
||||
|| { echo "!! NAT table missing"; healthy=0; }
|
||||
|
||||
if [ "$healthy" = 1 ]; then
|
||||
echo "==> healthy — making $rev the boot default"
|
||||
if nixos-rebuild switch --refresh --flake "$flake" -L; then
|
||||
${pkgs.coreutils}/bin/printf '%s' "$rev" > ${stateDir}/last-rev
|
||||
${pkgs.coreutils}/bin/chown router-ui ${stateDir}/last-rev || true
|
||||
record-update "$old" /run/current-system || true
|
||||
notify "Router applied ''${rev:0:8}."
|
||||
echo "==> done"
|
||||
else
|
||||
echo "!! switch failed after a healthy test — running config is fine, boot default is not"
|
||||
notify "Router apply ''${rev:0:8}: switch failed after a healthy test. Check the box."
|
||||
exit 1
|
||||
fi
|
||||
if ${config.system.build.deploy}/bin/fred-deploy --flake "$flake"; then
|
||||
${pkgs.coreutils}/bin/printf '%s' "$rev" > ${stateDir}/last-rev
|
||||
${pkgs.coreutils}/bin/chown router-ui ${stateDir}/last-rev
|
||||
notify "Router applied ''${rev:0:8}."
|
||||
else
|
||||
echo "!! health check failed — reverting to the previous generation"
|
||||
/nix/var/nix/profiles/system/bin/switch-to-configuration test
|
||||
notify "Router apply ''${rev:0:8} FAILED health check. Rolled back."
|
||||
notify "Router apply ''${rev:0:8} failed. Check the apply journal and /var/lib/fred-deploy/last-result.json for recovery status."
|
||||
exit 1
|
||||
fi
|
||||
'';
|
||||
|
|
@ -160,13 +124,15 @@ in
|
|||
|
||||
# Deploy one specific commit. Instance name is the git rev.
|
||||
systemd.services."router-apply@" = {
|
||||
description = "Deploy router config %i (test, health check, then switch)";
|
||||
path = [ pkgs.nixos-rebuild pkgs.nix pkgs.systemd "/run/current-system/sw" ];
|
||||
description = "Deploy router config %i through the shared transaction";
|
||||
restartIfChanged = false;
|
||||
unitConfig.X-StopOnRemoval = false;
|
||||
path = [ pkgs.systemd ];
|
||||
environment.HOME = "/root";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${applyScript} %i";
|
||||
TimeoutStartSec = "90min";
|
||||
TimeoutStartSec = "105min";
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
portsData = builtins.fromTOML (builtins.readFile ../ports.toml);
|
||||
portsData = (import ./validate-forwards.nix { inherit lib; })
|
||||
(builtins.fromTOML (builtins.readFile ../ports.toml));
|
||||
destDefault = portsData.dest_default;
|
||||
|
||||
devices = (builtins.fromTOML (builtins.readFile ../devices.toml)).device or [ ];
|
||||
|
|
@ -95,6 +96,13 @@ let
|
|||
(f: ''iifname "eno1" ${f.proto} dport ${f.port} accept comment "${f.name}"'')
|
||||
forwards;
|
||||
|
||||
# Docker also creates DNAT rules. A DNAT flag alone is not permission to
|
||||
# enter from WAN: check the published/original port, before translation.
|
||||
# The declared Pelican allocation range remains automatic for new servers.
|
||||
wanPortForwardRules = lib.concatMapStringsSep "\n "
|
||||
(f: ''iifname "eno1" ct status dnat meta l4proto ${f.proto} ct original proto-dst ${f.port} accept comment "${f.name}"'')
|
||||
forwards;
|
||||
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
|
||||
|
|
@ -221,8 +229,8 @@ in
|
|||
# etc.). br-* = Docker custom networks, podman* = Podman rootful.
|
||||
# pelican0 = wings' bridge; it does not match br-*.
|
||||
iifname { "docker0", "br-*", "podman*", "pelican0" } accept
|
||||
# WAN → any port-forward target (LAN host or docker container)
|
||||
iifname "eno1" ct status dnat accept
|
||||
# WAN → explicitly published ports only, including Docker DNAT.
|
||||
${wanPortForwardRules}
|
||||
}
|
||||
chain output {
|
||||
type filter hook output priority 0; policy accept;
|
||||
|
|
|
|||
|
|
@ -50,10 +50,6 @@ in
|
|||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/sabnzbd 0755 sabnzbd media -"
|
||||
"Z /var/lib/sabnzbd 0755 sabnzbd media -"
|
||||
"d /mnt/storage/usenet/downloads 2775 sabnzbd media -"
|
||||
"Z /mnt/storage/usenet/downloads 2775 sabnzbd media -"
|
||||
"d /mnt/storage/usenet/incomplete 2775 sabnzbd media -"
|
||||
"Z /mnt/storage/usenet/incomplete 2775 sabnzbd media -"
|
||||
];
|
||||
|
||||
systemd.services.sabnzbd = {
|
||||
|
|
@ -80,4 +76,4 @@ in
|
|||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,57 @@
|
|||
# services/server-permissions.nix
|
||||
{ config, pkgs, lib, ... }:
|
||||
# Media writes are allowed only after the pool and all its branches mount.
|
||||
# Keep these directory rules out of global tmpfiles: nofail mounts may be
|
||||
# absent when global tmpfiles runs, and creating paths then fills the root disk.
|
||||
{ config, pkgs, lib, utils, ... }:
|
||||
let
|
||||
pool = "/mnt/storage";
|
||||
branches = config.fileSystems.${pool}.depends or [ ];
|
||||
mounts = map (p: "${utils.escapeSystemdPath p}.mount") ([ pool ] ++ branches);
|
||||
consumers = [
|
||||
"jellyfin" "sonarr" "radarr" "bazarr" "qbittorrent-nox" "sabnzbd"
|
||||
"docker-shelfarr" "arr-interconnect" "bazarr-sync" "bazarr-sync-full"
|
||||
];
|
||||
directories = pkgs.writeText "media-directories.conf" ''
|
||||
d /mnt/storage/torrents/downloads 2775 qbittorrent media -
|
||||
Z /mnt/storage/torrents/downloads 2775 qbittorrent media -
|
||||
d /mnt/storage/torrents/shows 2775 sonarr media -
|
||||
Z /mnt/storage/torrents/shows 2775 sonarr media -
|
||||
d /mnt/storage/torrents/movies 2775 radarr media -
|
||||
Z /mnt/storage/torrents/movies 2775 radarr media -
|
||||
d /mnt/storage/torrents/audiobooks 2775 fred media -
|
||||
Z /mnt/storage/torrents/audiobooks 2775 fred media -
|
||||
d /mnt/storage/usenet/downloads 2775 sabnzbd media -
|
||||
Z /mnt/storage/usenet/downloads 2775 sabnzbd media -
|
||||
d /mnt/storage/usenet/incomplete 2775 sabnzbd media -
|
||||
Z /mnt/storage/usenet/incomplete 2775 sabnzbd media -
|
||||
'';
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
|
||||
systemd.tmpfiles.rules = [
|
||||
# Audiobooks - manually managed, no dedicated service yet
|
||||
"d /mnt/storage/torrents/audiobooks 2775 fred media -"
|
||||
"Z /mnt/storage/torrents/audiobooks 2775 fred media -"
|
||||
];
|
||||
systemd.services = {
|
||||
media-storage-ready = {
|
||||
description = "Prepare media directories on the mounted pool";
|
||||
requires = mounts;
|
||||
bindsTo = mounts;
|
||||
after = mounts;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
${lib.concatMapStringsSep "\n" (p: ''
|
||||
${pkgs.util-linux}/bin/mountpoint -q ${lib.escapeShellArg p} || {
|
||||
echo "Required media mount missing: ${p}" >&2
|
||||
exit 1
|
||||
}
|
||||
'') ([ pool ] ++ branches)}
|
||||
${pkgs.systemd}/bin/systemd-tmpfiles --create ${directories}
|
||||
'';
|
||||
};
|
||||
} // lib.genAttrs consumers (_: {
|
||||
requires = [ "media-storage-ready.service" ];
|
||||
after = [ "media-storage-ready.service" ];
|
||||
bindsTo = [ "media-storage-ready.service" ];
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,10 +35,6 @@
|
|||
extraGroups = [ "media" ];
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
# Shows - sonarr organises, bazarr writes subtitles
|
||||
"d /mnt/storage/torrents/shows 2775 sonarr media -"
|
||||
"Z /mnt/storage/torrents/shows 2775 sonarr media -"
|
||||
];
|
||||
# Pool directories and mount dependencies live in server-permissions.nix.
|
||||
};
|
||||
}
|
||||
|
|
|
|||
41
services/validate-forwards.nix
Normal file
41
services/validate-forwards.nix
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Validate hand-edited TOML as well as UI changes, before generating nft syntax.
|
||||
{ lib }:
|
||||
data:
|
||||
let
|
||||
fail = message: throw "ports.toml: ${message}";
|
||||
lanIP = value: builtins.isString value && builtins.match
|
||||
"10[.]0[.]0[.]([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])" value != null;
|
||||
normalize = entry:
|
||||
let
|
||||
name = entry.name or "";
|
||||
proto = entry.protocol or "";
|
||||
single = entry ? port;
|
||||
range = entry ? ports;
|
||||
spec = if single then toString entry.port else entry.ports or "";
|
||||
validSpec = builtins.isString spec && builtins.match "[0-9]+(-[0-9]+)?" spec != null;
|
||||
bounds = map lib.toInt (lib.splitString "-" spec);
|
||||
first = builtins.head bounds;
|
||||
last = lib.last bounds;
|
||||
dest = entry.dest or data.dest_default;
|
||||
in
|
||||
if !builtins.isString name || builtins.match "[A-Za-z0-9][A-Za-z0-9 _().-]{0,79}" name == null
|
||||
then fail "invalid forward name"
|
||||
else if !(lib.elem proto [ "tcp" "udp" "both" ]) then fail "${name}: invalid protocol"
|
||||
else if single == range || !validSpec then fail "${name}: use one port or one port range"
|
||||
else if (single && !builtins.isInt entry.port) || first < 1 || last > 65535 || (range && first >= last)
|
||||
then fail "${name}: invalid port bounds"
|
||||
else if !lanIP dest then fail "${name}: destination must be a LAN IPv4 address"
|
||||
else { inherit name first last dest; protos = if proto == "both" then [ "tcp" "udp" ] else [ proto ]; };
|
||||
entries = map normalize (data.forward or [ ]);
|
||||
names = map (e: e.name) entries;
|
||||
overlaps = a: b: a.first <= b.last && b.first <= a.last && lib.intersectLists a.protos b.protos != [ ];
|
||||
noOverlaps = list: list == [ ] || (
|
||||
lib.all (b: !overlaps (builtins.head list) b) (builtins.tail list)
|
||||
&& noOverlaps (builtins.tail list));
|
||||
reachable = port: lib.any (e: lib.elem "tcp" e.protos && e.first <= port && port <= e.last && e.dest == "10.0.0.1") entries;
|
||||
in
|
||||
if (data.dest_default or "") != "10.0.0.1" then fail "dest_default must remain 10.0.0.1"
|
||||
else if builtins.length names != builtins.length (lib.unique names) then fail "duplicate names"
|
||||
else if !noOverlaps entries then fail "overlapping protocol/port ranges"
|
||||
else if !lib.all reachable [ 22 443 ] then fail "TCP 22 and 443 must still reach the router"
|
||||
else builtins.deepSeq entries data
|
||||
|
|
@ -21,6 +21,10 @@ let
|
|||
# as "up". Two anycast targets so one being down isn't a false alarm.
|
||||
watchdog = pkgs.writeShellScript "wan-watchdog" ''
|
||||
set -uo pipefail
|
||||
# Hold this for the whole check/recovery operation. Never restart the
|
||||
# network or flush conntrack during activation, verification or rollback.
|
||||
exec 9>/run/lock/fred-deploy.lock
|
||||
${pkgs.util-linux}/bin/flock -n 9 || exit 0
|
||||
wan=eno1
|
||||
state=/var/lib/wan-watchdog
|
||||
ipfile=$state/wan-ip
|
||||
|
|
|
|||
92
settings/auth.nix
Normal file
92
settings/auth.nix
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Authenticate before creating the desktop; keep its lock separate from the bar.
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
enabled = lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ];
|
||||
theme = config.home-manager.users.fred.xdg.configFile."quickshell/Theme.qml".text;
|
||||
bundle = name: shell: pkgs.linkFarm name [
|
||||
{ name = "shell.qml"; path = shell; }
|
||||
{ name = "AuthView.qml"; path = ./quickshell/AuthView.qml; }
|
||||
{ name = "Theme.qml"; path = pkgs.writeText "Theme.qml" theme; }
|
||||
{ name = "qmldir"; path = pkgs.writeText "qmldir" "singleton Theme 1.0 Theme.qml\nAuthView 1.0 AuthView.qml\n"; }
|
||||
];
|
||||
greeter = bundle "fred-greeter" ./quickshell/Greeter.qml;
|
||||
locker = bundle "fred-lock" ./quickshell/SessionLock.qml;
|
||||
session = pkgs.writeShellScript "hyprland-session" ''
|
||||
exec ${config.programs.hyprland.package}/bin/start-hyprland >/dev/null 2>&1
|
||||
'';
|
||||
lockCommand = pkgs.writeShellApplication {
|
||||
name = "fred-lock";
|
||||
runtimeInputs = [ pkgs.systemd pkgs.coreutils pkgs.quickshell ];
|
||||
text = builtins.readFile ../scripts/lock-session.sh;
|
||||
};
|
||||
in {
|
||||
config = lib.mkIf enabled {
|
||||
system.build.auth = pkgs.linkFarm "fred-auth" [
|
||||
{ name = "greeter"; path = greeter; }
|
||||
{ name = "lock"; path = locker; }
|
||||
{ name = "bin"; path = "${lockCommand}/bin"; }
|
||||
];
|
||||
services.greetd = {
|
||||
enable = true;
|
||||
greeterManagesPlymouth = true;
|
||||
settings.default_session = {
|
||||
user = "greeter";
|
||||
command = toString (pkgs.writeShellScript "fred-greeter" ''
|
||||
export FRED_SESSION_COMMAND=${session}
|
||||
export FRED_GREETER=1
|
||||
export QML2_IMPORT_PATH=${pkgs.qt6.qt5compat}/lib/qt-6/qml
|
||||
export XKB_DEFAULT_LAYOUT=gb,no
|
||||
export XKB_DEFAULT_OPTIONS=${lib.escapeShellArg ("grp:win_space_toggle" + lib.optionalString (config.networking.hostName == "FredOS-Gaming") ",altwin:swap_lalt_lwin")}
|
||||
exec ${pkgs.dbus}/bin/dbus-run-session -- ${pkgs.cage}/bin/cage -s -- ${pkgs.quickshell}/bin/qs -p ${greeter}
|
||||
'');
|
||||
};
|
||||
};
|
||||
security.pam.services.greetd.allowNullPassword = lib.mkForce false;
|
||||
security.pam.services.fred-lock = { allowNullPassword = false; };
|
||||
environment.systemPackages = [ lockCommand ];
|
||||
|
||||
home-manager.users.fred.systemd.user.services.quickshell-lock = {
|
||||
Unit = {
|
||||
Description = "Independent Quickshell session lock";
|
||||
PartOf = [ "graphical-session.target" ];
|
||||
After = [ "graphical-session.target" ];
|
||||
StartLimitIntervalSec = 0;
|
||||
};
|
||||
Service = {
|
||||
ExecStart = "${pkgs.quickshell}/bin/qs -p ${locker}";
|
||||
Environment = [ "QML2_IMPORT_PATH=${pkgs.qt6.qt5compat}/lib/qt-6/qml" ];
|
||||
Restart = "always";
|
||||
RestartSec = 1;
|
||||
TimeoutStartSec = 5;
|
||||
};
|
||||
# No WantedBy: greetd already authenticated the new desktop session.
|
||||
};
|
||||
|
||||
systemd.services.lock-before-sleep = {
|
||||
description = "Require a confirmed session lock before sleep";
|
||||
before = [ "sleep.target" ];
|
||||
requiredBy = [ "sleep.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "fred";
|
||||
TimeoutStartSec = "20s";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
export HOME=/home/fred
|
||||
export XDG_RUNTIME_DIR=/run/user/$(${pkgs.coreutils}/bin/id -u)
|
||||
# A greeter has no desktop to lock. Query logind, not a potentially
|
||||
# stale user target, and fail closed if querying logind fails.
|
||||
sessions=$(${pkgs.systemd}/bin/loginctl list-sessions --no-legend --no-pager)
|
||||
fred_uid=$(${pkgs.coreutils}/bin/id -u)
|
||||
while read -r session uid _; do
|
||||
[ "$uid" = "$fred_uid" ] || continue
|
||||
type=$(${pkgs.systemd}/bin/loginctl show-session "$session" -p Type --value)
|
||||
if [ "$type" = wayland ] || [ "$type" = x11 ]; then
|
||||
exec ${lockCommand}/bin/fred-lock
|
||||
fi
|
||||
done <<< "$sessions"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -3,28 +3,9 @@
|
|||
{
|
||||
config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) {
|
||||
services.xserver.enable = true;
|
||||
# Autologin straight into Hyprland; the quickshell lockscreen engages
|
||||
# immediately (see Lock.qml), so the lock *is* the login prompt.
|
||||
services.greetd = {
|
||||
enable = true;
|
||||
# We handle the plymouth handoff ourselves below — don't wait for
|
||||
# plymouth-quit-wait (that wait is what dropped boot to a console
|
||||
# flash between splash and Hyprland).
|
||||
greeterManagesPlymouth = true;
|
||||
settings.default_session = {
|
||||
# start-hyprland prints an ASCII logo to the VT, which repaints the
|
||||
# console over the retained splash frame — silence it (Hyprland still
|
||||
# logs to $XDG_RUNTIME_DIR/hypr/*/hyprland.log).
|
||||
command = toString (pkgs.writeShellScript "hyprland-session" ''
|
||||
exec ${config.programs.hyprland.package}/bin/start-hyprland >/dev/null 2>&1
|
||||
'');
|
||||
user = "fred";
|
||||
};
|
||||
};
|
||||
|
||||
# GDM-style splash→session handoff: nothing else quits plymouth at boot;
|
||||
# greetd freezes the last splash frame (--retain-splash) right before it
|
||||
# spawns Hyprland, which then flips straight to the lockscreen.
|
||||
# spawns the isolated greeter compositor.
|
||||
systemd.services.plymouth-quit.wantedBy = lib.mkForce [ ];
|
||||
# ...and plymouth-quit-wait must go too: greetd is Type=idle, so it defers
|
||||
# its ExecStart while any job is pending — and the only pending job is
|
||||
|
|
@ -44,9 +25,7 @@
|
|||
services.flatpak.enable = true;
|
||||
|
||||
# Secret Service for Chromium/Electron credential encryption.
|
||||
# Autologin can't unlock the keyring (no password typed), so greetd only
|
||||
# *starts* the daemon; the quickshell lockscreen authenticates against the
|
||||
# "login" PAM service, where pam_gnome_keyring unlocks it on first unlock.
|
||||
# greetd passes the login password to PAM before starting the session.
|
||||
services.gnome.gnome-keyring.enable = true;
|
||||
security.pam.services.greetd.enableGnomeKeyring = true;
|
||||
security.pam.services.login.enableGnomeKeyring = true;
|
||||
|
|
|
|||
|
|
@ -62,40 +62,6 @@ in
|
|||
libcanberra-gtk3
|
||||
];
|
||||
|
||||
# Lock *before* the machine actually suspends. hypridle's
|
||||
# before_sleep_cmd cannot do this: it spawns the command and releases
|
||||
# its sleep inhibitor in the same breath (journal: "Executing …" then
|
||||
# "Releasing the sleep inhibitor!" on the same second), so the qs
|
||||
# process gets frozen mid-lock and only finishes on resume — the lid
|
||||
# opens showing the pre-suspend desktop for a beat before the lock
|
||||
# paints. A unit ordered Before=sleep.target genuinely blocks the
|
||||
# suspend until it exits. The sleep covers the gap between quickshell
|
||||
# accepting the IPC and the lock surface committing a frame.
|
||||
systemd.services.lock-before-sleep = {
|
||||
description = "Lock the session before suspend";
|
||||
before = [ "sleep.target" ];
|
||||
wantedBy = [ "sleep.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "fred";
|
||||
# XDG_RUNTIME_DIR must be derived at runtime: the %U specifier is the
|
||||
# UID of the *manager* running the unit (0 for system units), not the
|
||||
# one in User=, so it pointed qs at /run/user/0.
|
||||
ExecStart = pkgs.writeShellScript "lock-before-sleep" ''
|
||||
export XDG_RUNTIME_DIR=/run/user/$(${pkgs.coreutils}/bin/id -u)
|
||||
# HOME too: system units don't set it for User=, and qs resolves
|
||||
# the config (and thus the instance to talk to) via ~/.config —
|
||||
# without it: "Could not find "default" config directory".
|
||||
export HOME=/home/fred
|
||||
# --any-display: no WAYLAND_DISPLAY here, and qs skips instances
|
||||
# on other displays without it.
|
||||
${pkgs.quickshell}/bin/qs ipc --any-display call lock lock
|
||||
${pkgs.coreutils}/bin/sleep 1
|
||||
'';
|
||||
TimeoutStartSec = "5s";
|
||||
};
|
||||
};
|
||||
|
||||
home-manager.users.fred = { config, lib, pkgs, inputs, ... }:
|
||||
let
|
||||
c = config.lib.stylix.colors;
|
||||
|
|
@ -325,24 +291,14 @@ in
|
|||
-- Ensure hyprland-session.target starts even if HM's
|
||||
-- dbus-update-activation-environment chain fails upstream.
|
||||
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||
-- greetd's pam leaves gnome-keyring-daemon in half-initialized
|
||||
-- --login mode (autologin = no password, and nothing in a
|
||||
-- non-GNOME session runs the second stage). --start completes
|
||||
-- init so the lockscreen's pam unlock actually reaches it.
|
||||
hl.exec_cmd("${pkgs.gnome-keyring}/bin/gnome-keyring-daemon --start --components=secrets")
|
||||
-- Complete the keyring's PAM login initialization before Spotify
|
||||
-- requests credentials. greetd has already authenticated Fred.
|
||||
hl.exec_cmd("sh -c '${pkgs.gnome-keyring}/bin/gnome-keyring-daemon --start --components=secrets && exec spotify'")
|
||||
-- mako removed; notifications handled by quickshell
|
||||
|
||||
hl.exec_cmd("wl-paste --type text --watch cliphist store")
|
||||
hl.exec_cmd("wl-paste --type image --watch cliphist store")
|
||||
hl.exec_cmd("hyprctl setcursor Bibata-Modern-Ice 24")
|
||||
-- Spotify is NOT started here. It asks the Secret Service for its
|
||||
-- credentials the moment it launches, and at this point the
|
||||
-- lockscreen has not been dismissed yet, so the login keyring is
|
||||
-- still locked and it lands on gcr's unlock prompt instead of
|
||||
-- loading. Lock.qml starts it after pam accepts the password
|
||||
-- (pam_gnome_keyring unlocks the keyring in its auth phase). The
|
||||
-- window rule below still swallows it onto special:music, and the
|
||||
-- respawn-on-close rule above is unaffected.
|
||||
-- Scratchpad terminal: parked on special:scratch at
|
||||
-- login so SUPER+Return is always an instant toggle and the shell
|
||||
-- keeps its history/cwd between peeks.
|
||||
|
|
@ -608,8 +564,8 @@ in
|
|||
# (settings/quickshell.nix) via `hyprctl hyprsunset`.
|
||||
services.hyprsunset.enable = true;
|
||||
|
||||
# Lock screen is quickshell's WlSessionLock (settings/quickshell.nix),
|
||||
# triggered via `qs ipc call lock lock`.
|
||||
# The independent Quickshell lock is configured in settings/auth.nix,
|
||||
# triggered through the confirmed-lock helper.
|
||||
services.hypridle = lib.mkIf isMacbook (
|
||||
let
|
||||
# Skip the action if any MPRIS player is playing — covers windowed
|
||||
|
|
@ -620,7 +576,7 @@ in
|
|||
enable = true;
|
||||
settings = {
|
||||
general = {
|
||||
lock_cmd = "qs ipc call lock lock";
|
||||
lock_cmd = "fred-lock";
|
||||
# No before_sleep_cmd: hypridle does not wait for it before
|
||||
# dropping its sleep inhibitor, so the lock lands after resume.
|
||||
# systemd.services.lock-before-sleep handles suspend instead.
|
||||
|
|
|
|||
|
|
@ -476,7 +476,6 @@ in
|
|||
singleton Commands 1.0 Commands.qml
|
||||
singleton MonoIcons 1.0 MonoIcons.qml
|
||||
Bar 1.0 Bar.qml
|
||||
Lock 1.0 Lock.qml
|
||||
Cheatsheet 1.0 Cheatsheet.qml
|
||||
'';
|
||||
};
|
||||
|
|
@ -501,7 +500,10 @@ in
|
|||
// reboot; every colour below is a binding on `pal`, so
|
||||
// writing this file re-tints the entire shell in place.
|
||||
property FileView wallFile: FileView {
|
||||
path: Quickshell.env("HOME") + "/.local/state/quickshell-wallpaper.json"
|
||||
// The greeter uses the declared default wallpaper and
|
||||
// never reads or writes a user's runtime theme state.
|
||||
path: Quickshell.env("FRED_GREETER") === "1" ? ""
|
||||
: Quickshell.env("HOME") + "/.local/state/quickshell-wallpaper.json"
|
||||
printErrors: false
|
||||
// Synchronous: the palette load chains off this, and an
|
||||
// async hop here means one frame painted in the wrong
|
||||
|
|
@ -511,7 +513,7 @@ in
|
|||
id: wallState
|
||||
property string name: "${defaultWall}"
|
||||
}
|
||||
onLoadFailed: writeAdapter()
|
||||
onLoadFailed: if (path !== "") writeAdapter()
|
||||
}
|
||||
|
||||
// Unknown name (wallpaper deleted from the repo since it was
|
||||
|
|
@ -715,324 +717,6 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
"quickshell/Lock.qml" = {
|
||||
onChange = qsRestart;
|
||||
text = ''
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Services.Pam
|
||||
import Quickshell.Services.UPower
|
||||
import Quickshell.Services.Mpris
|
||||
import QtQuick
|
||||
import Qt5Compat.GraphicalEffects
|
||||
|
||||
// ext-session-lock screen, replacing hyprlock. Set `locked: true`
|
||||
// and the compositor hands every screen to a LockSurface below
|
||||
// until PAM says otherwise.
|
||||
//
|
||||
// PAM and state live in a Scope OUTSIDE WlSessionLock: its default
|
||||
// property is the surface *component*, so a PamContext declared
|
||||
// inside it is never instantiated — `pam` didn't resolve and
|
||||
// submit() threw, which is why enter/the arrow did nothing.
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
property alias locked: lock.locked
|
||||
property string password: ""
|
||||
property bool failed: false
|
||||
// True only for the once-per-session lock below, so a later
|
||||
// manual lock/unlock does not re-launch spotify.
|
||||
property bool bootLock: false
|
||||
|
||||
function submit(pw) {
|
||||
if (pam.active || pw === "") return;
|
||||
failed = false;
|
||||
password = pw;
|
||||
pam.start();
|
||||
}
|
||||
|
||||
// greetd autologs us in, so lock as soon as the shell comes up:
|
||||
// the lockscreen is the login prompt. mkdir is the atomic
|
||||
// once-per-session test — XDG_RUNTIME_DIR dies with the
|
||||
// session, so quickshell restarts (rebuilds) don't re-lock.
|
||||
Process {
|
||||
running: true
|
||||
command: ["mkdir", Quickshell.env("XDG_RUNTIME_DIR") + "/qs-boot-lock"]
|
||||
onExited: code => { if (code === 0) { root.bootLock = true; lock.locked = true; } }
|
||||
}
|
||||
|
||||
// Nothing here unlocks the keyring — pam_gnome_keyring already
|
||||
// did it during the *auth* phase. gkr-pam-module.c's
|
||||
// pam_sm_authenticate calls unlock_keyring() directly and logs
|
||||
// "gkr-pam: unlocked login keyring"; the session phase is only
|
||||
// its fallback for when that fails. So PamContext's auth-only
|
||||
// run has always been enough, and an explicit
|
||||
// gnome-keyring-daemon --replace --unlock here was both
|
||||
// redundant and a trap: the replacement daemon inherits
|
||||
// quickshell's stdout, holds the pipe open for the life of the
|
||||
// session, and the Process therefore never reports exiting.
|
||||
//
|
||||
// The only real problem was ordering. Spotify asks the Secret
|
||||
// Service the moment it launches, and from hyprland.start that
|
||||
// is before the lockscreen has been dismissed — keyring still
|
||||
// locked, so it lands on gcr's unlock prompt. Start it here,
|
||||
// after PAM has accepted the password, instead.
|
||||
Process {
|
||||
id: spotifyStart
|
||||
// Via hyprland, not as a child of quickshell: a shell
|
||||
// restart on rebuild must not take spotify down with it.
|
||||
command: ["hyprctl", "dispatch", "exec", "spotify"]
|
||||
}
|
||||
|
||||
PamContext {
|
||||
id: pam
|
||||
// NixOS ships a "login" pam service; pam_unix verifies the
|
||||
// user's own password via the unix_chkpwd setuid wrapper.
|
||||
config: "login"
|
||||
onPamMessage: {
|
||||
if (this.responseRequired) this.respond(root.password);
|
||||
}
|
||||
onCompleted: result => {
|
||||
if (result === PamResult.Success) {
|
||||
lock.locked = false;
|
||||
// Boot lock only: a later manual lock/unlock must
|
||||
// not spawn a second spotify.
|
||||
if (root.bootLock) {
|
||||
root.bootLock = false;
|
||||
spotifyStart.running = true;
|
||||
}
|
||||
} else root.failed = true;
|
||||
root.password = "";
|
||||
}
|
||||
}
|
||||
|
||||
WlSessionLock {
|
||||
id: lock
|
||||
|
||||
WlSessionLockSurface {
|
||||
id: surface
|
||||
color: "black"
|
||||
|
||||
Image {
|
||||
id: wall
|
||||
anchors.fill: parent
|
||||
source: "file://" + Theme.wallImage
|
||||
// cache:false — soft reload (qs ipc reload) keeps the process
|
||||
// alive, and the QML pixmap cache is keyed by URL, so a new
|
||||
// wallpaper behind the same path never shows without this.
|
||||
cache: false
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
visible: false
|
||||
}
|
||||
FastBlur {
|
||||
anchors.fill: wall
|
||||
source: wall
|
||||
radius: 20
|
||||
}
|
||||
// Lighter blur means less natural contrast, so the scrim
|
||||
// is a left-weighted gradient instead of a flat 0.4 wash:
|
||||
// dark under the text column, near-clear on the right.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: "#b0000000" }
|
||||
GradientStop { position: 0.55; color: "#59000000" }
|
||||
GradientStop { position: 1.0; color: "#33000000" }
|
||||
}
|
||||
}
|
||||
|
||||
SystemClock {
|
||||
id: lockClock
|
||||
precision: SystemClock.Seconds
|
||||
}
|
||||
|
||||
// Lower-left, not centred: clock/date/password stack left
|
||||
// aligned against a 10% margin, sitting below the middle.
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Math.round(parent.width * 0.10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.verticalCenterOffset: Math.round(parent.height * 0.14)
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: lockClock.date.toLocaleTimeString(Qt.locale(), "HH:mm")
|
||||
color: Theme.base05
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 104
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: lockClock.date.toLocaleDateString(Qt.locale(), "dddd, d MMMM")
|
||||
color: Theme.base04
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 18
|
||||
}
|
||||
|
||||
Item { width: 1; height: 14 }
|
||||
|
||||
${lib.optionalString isMacbook ''
|
||||
// Power. UPower.displayDevice is the same DBus source
|
||||
// the bar reads — no polling, no /sys parsing.
|
||||
Row {
|
||||
id: lockBatt
|
||||
spacing: 8
|
||||
property var dev: UPower.displayDevice
|
||||
property int level: dev && dev.ready ? Math.round(dev.percentage * 100) : 0
|
||||
property bool charging: dev ? dev.state === UPowerDeviceState.Charging : false
|
||||
property string remaining: {
|
||||
if (!dev) return "";
|
||||
let secs = charging ? dev.timeToFull : dev.timeToEmpty;
|
||||
if (!secs || secs <= 0) return "";
|
||||
return Math.floor(secs / 3600) + "h " + Math.round((secs % 3600) / 60) + "m";
|
||||
}
|
||||
visible: dev && dev.ready
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: lockBatt.charging ? "battery-charging"
|
||||
: lockBatt.level >= 75 ? "battery-full"
|
||||
: lockBatt.level >= 50 ? "battery-medium"
|
||||
: lockBatt.level >= 20 ? "battery-low"
|
||||
: "battery"
|
||||
font.family: Theme.iconFont
|
||||
font.pixelSize: 17
|
||||
color: lockBatt.level <= 15 ? Theme.err
|
||||
: lockBatt.level <= 30 ? Theme.warn : Theme.base04
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: lockBatt.level + "%" + (lockBatt.remaining === "" ? ""
|
||||
: " · " + lockBatt.remaining + (lockBatt.charging ? " to full" : " left"))
|
||||
color: Theme.base04
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
''}
|
||||
|
||||
// Now playing. Mpris is a global service, so this shows
|
||||
// whatever kept playing into the lock — nothing is woken.
|
||||
Row {
|
||||
id: lockMedia
|
||||
spacing: 8
|
||||
property var player: Mpris.players.values.filter(p => p.trackTitle)[0]
|
||||
visible: !!player
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "music"
|
||||
font.family: Theme.iconFont
|
||||
font.pixelSize: 17
|
||||
color: Theme.base04
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 380
|
||||
elide: Text.ElideRight
|
||||
text: lockMedia.player
|
||||
? lockMedia.player.trackTitle + (lockMedia.player.trackArtist
|
||||
? " · " + lockMedia.player.trackArtist : "")
|
||||
: ""
|
||||
color: Theme.base04
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 14 }
|
||||
|
||||
|
||||
// Password pill: dots for typed chars; border tracks
|
||||
// state (accent while checking, red on failure).
|
||||
Rectangle {
|
||||
width: 300
|
||||
height: 46
|
||||
radius: Theme.radiusCard
|
||||
color: Theme.cardBg
|
||||
border.width: Theme.borderWidth
|
||||
border.color: pam.active ? Theme.accent
|
||||
: root.failed ? Theme.err
|
||||
: Theme.base03
|
||||
Behavior on border.color { ColorAnimation { duration: Theme.animFade } }
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
Repeater {
|
||||
model: Math.min(pwInput.text.length, 24)
|
||||
Rectangle {
|
||||
width: 8; height: 8; radius: 4
|
||||
color: Theme.base05
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: pwInput.text === "" && root.failed
|
||||
text: "wrong password"
|
||||
color: Theme.err
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: pwInput
|
||||
anchors.fill: parent
|
||||
opacity: 0
|
||||
enabled: !pam.active
|
||||
echoMode: TextInput.Password
|
||||
focus: true
|
||||
function go() {
|
||||
root.submit(text);
|
||||
text = "";
|
||||
}
|
||||
// Explicit key handlers: accepted() doesn't
|
||||
// fire reliably on the lock surface.
|
||||
Keys.onReturnPressed: go()
|
||||
Keys.onEnterPressed: go()
|
||||
onAccepted: go()
|
||||
Keys.onEscapePressed: text = ""
|
||||
}
|
||||
|
||||
// Visible confirm button (after TextInput: on top)
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 32; height: 32; radius: 16
|
||||
color: confirmMa.containsMouse ? Theme.base02 : "transparent"
|
||||
Behavior on color { ColorAnimation { duration: Theme.animFade } }
|
||||
visible: pwInput.text.length > 0
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "arrow-right"
|
||||
font.family: Theme.iconFont
|
||||
font.pixelSize: 18
|
||||
color: Theme.accent
|
||||
}
|
||||
MouseArea {
|
||||
id: confirmMa
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: pwInput.go()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: pwInput.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
"quickshell/Cheatsheet.qml" = {
|
||||
onChange = qsRestart;
|
||||
text = ''
|
||||
|
|
@ -1265,16 +949,11 @@ in
|
|||
function reload(): void { Quickshell.reload(false); }
|
||||
}
|
||||
|
||||
// Session lock (replaces hyprlock). `qs ipc call lock lock`
|
||||
// from hypridle / loginctl lock-session; the session menu
|
||||
// calls root.lock() directly. Idempotent while locked.
|
||||
Lock { id: lockScreen }
|
||||
function lock() { lockScreen.locked = true; }
|
||||
IpcHandler {
|
||||
target: "lock"
|
||||
function lock(): void { root.lock(); }
|
||||
// Escape hatch from a VT: qs ipc call lock unlock
|
||||
function unlock(): void { lockScreen.locked = false; }
|
||||
// The lock runs independently of the bar and its reloads.
|
||||
function lock() { lockRequest.running = true; }
|
||||
Process {
|
||||
id: lockRequest
|
||||
command: ["fred-lock"]
|
||||
}
|
||||
|
||||
// Screenshot pin: the Shift+Super+S keybind brackets hyprshot
|
||||
|
|
|
|||
226
settings/quickshell/AuthView.qml
Normal file
226
settings/quickshell/AuthView.qml
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
import Quickshell.Services.Mpris
|
||||
import QtQuick
|
||||
import Qt5Compat.GraphicalEffects
|
||||
|
||||
// Shared appearance; authentication belongs to the greeter or session lock.
|
||||
Item {
|
||||
id: view
|
||||
required property var theme
|
||||
property bool busy: false
|
||||
property string errorMessage: ""
|
||||
signal submitted(string password)
|
||||
|
||||
Image {
|
||||
id: wall
|
||||
anchors.fill: parent
|
||||
source: "file://" + view.theme.wallImage
|
||||
// cache:false — soft reload (qs ipc reload) keeps the process
|
||||
// alive, and the QML pixmap cache is keyed by URL, so a new
|
||||
// wallpaper behind the same path never shows without this.
|
||||
cache: false
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
visible: false
|
||||
}
|
||||
FastBlur {
|
||||
anchors.fill: wall
|
||||
source: wall
|
||||
radius: 20
|
||||
}
|
||||
// Lighter blur means less natural contrast, so the scrim
|
||||
// is a left-weighted gradient instead of a flat 0.4 wash:
|
||||
// dark under the text column, near-clear on the right.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: "#b0000000" }
|
||||
GradientStop { position: 0.55; color: "#59000000" }
|
||||
GradientStop { position: 1.0; color: "#33000000" }
|
||||
}
|
||||
}
|
||||
|
||||
SystemClock {
|
||||
id: lockClock
|
||||
precision: SystemClock.Seconds
|
||||
}
|
||||
|
||||
// Lower-left, not centred: clock/date/password stack left
|
||||
// aligned against a 10% margin, sitting below the middle.
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Math.round(parent.width * 0.10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.verticalCenterOffset: Math.round(parent.height * 0.14)
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: lockClock.date.toLocaleTimeString(Qt.locale(), "HH:mm")
|
||||
color: view.theme.base05
|
||||
font.family: view.theme.fontFamily
|
||||
font.pixelSize: 104
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: lockClock.date.toLocaleDateString(Qt.locale(), "dddd, d MMMM")
|
||||
color: view.theme.base04
|
||||
font.family: view.theme.fontFamily
|
||||
font.pixelSize: 18
|
||||
}
|
||||
|
||||
Item { width: 1; height: 14 }
|
||||
|
||||
// Power. UPower.displayDevice is the same DBus source
|
||||
// the bar reads — no polling, no /sys parsing.
|
||||
Row {
|
||||
id: lockBatt
|
||||
spacing: 8
|
||||
property var dev: UPower.displayDevice
|
||||
property int level: dev && dev.ready ? Math.round(dev.percentage * 100) : 0
|
||||
property bool charging: dev ? dev.state === UPowerDeviceState.Charging : false
|
||||
property string remaining: {
|
||||
if (!dev) return "";
|
||||
let secs = charging ? dev.timeToFull : dev.timeToEmpty;
|
||||
if (!secs || secs <= 0) return "";
|
||||
return Math.floor(secs / 3600) + "h " + Math.round((secs % 3600) / 60) + "m";
|
||||
}
|
||||
visible: dev && dev.ready
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: lockBatt.charging ? "battery-charging"
|
||||
: lockBatt.level >= 75 ? "battery-full"
|
||||
: lockBatt.level >= 50 ? "battery-medium"
|
||||
: lockBatt.level >= 20 ? "battery-low"
|
||||
: "battery"
|
||||
font.family: view.theme.iconFont
|
||||
font.pixelSize: 17
|
||||
color: lockBatt.level <= 15 ? view.theme.err
|
||||
: lockBatt.level <= 30 ? view.theme.warn : view.theme.base04
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: lockBatt.level + "%" + (lockBatt.remaining === "" ? ""
|
||||
: " · " + lockBatt.remaining + (lockBatt.charging ? " to full" : " left"))
|
||||
color: view.theme.base04
|
||||
font.family: view.theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
|
||||
// Now playing. Mpris is a global service, so this shows
|
||||
// whatever kept playing into the lock — nothing is woken.
|
||||
Row {
|
||||
id: lockMedia
|
||||
spacing: 8
|
||||
property var player: Mpris.players.values.filter(p => p.trackTitle)[0]
|
||||
visible: !!player
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "music"
|
||||
font.family: view.theme.iconFont
|
||||
font.pixelSize: 17
|
||||
color: view.theme.base04
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 380
|
||||
elide: Text.ElideRight
|
||||
text: lockMedia.player
|
||||
? lockMedia.player.trackTitle + (lockMedia.player.trackArtist
|
||||
? " · " + lockMedia.player.trackArtist : "")
|
||||
: ""
|
||||
color: view.theme.base04
|
||||
font.family: view.theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 14 }
|
||||
|
||||
|
||||
// Password pill: dots for typed chars; border tracks
|
||||
// state (accent while checking, red on failure).
|
||||
Rectangle {
|
||||
width: 300
|
||||
height: 46
|
||||
radius: view.theme.radiusCard
|
||||
color: view.theme.cardBg
|
||||
border.width: view.theme.borderWidth
|
||||
border.color: view.busy ? view.theme.accent
|
||||
: (view.errorMessage !== "") ? view.theme.err
|
||||
: view.theme.base03
|
||||
Behavior on border.color { ColorAnimation { duration: view.theme.animFade } }
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
Repeater {
|
||||
model: Math.min(pwInput.text.length, 24)
|
||||
Rectangle {
|
||||
width: 8; height: 8; radius: 4
|
||||
color: view.theme.base05
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: pwInput.text === "" && (view.errorMessage !== "")
|
||||
text: view.errorMessage
|
||||
color: view.theme.err
|
||||
font.family: view.theme.fontFamily
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: pwInput
|
||||
anchors.fill: parent
|
||||
opacity: 0
|
||||
enabled: !view.busy
|
||||
echoMode: TextInput.Password
|
||||
focus: true
|
||||
function go() {
|
||||
view.submitted(text);
|
||||
text = "";
|
||||
}
|
||||
// Explicit key handlers: accepted() doesn't
|
||||
// fire reliably on the lock surface.
|
||||
Keys.onReturnPressed: go()
|
||||
Keys.onEnterPressed: go()
|
||||
onAccepted: go()
|
||||
Keys.onEscapePressed: text = ""
|
||||
}
|
||||
|
||||
// Visible confirm button (after TextInput: on top)
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 32; height: 32; radius: 16
|
||||
color: confirmMa.containsMouse ? view.theme.base02 : "transparent"
|
||||
Behavior on color { ColorAnimation { duration: view.theme.animFade } }
|
||||
visible: pwInput.text.length > 0
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "arrow-right"
|
||||
font.family: view.theme.iconFont
|
||||
font.pixelSize: 18
|
||||
color: view.theme.accent
|
||||
}
|
||||
MouseArea {
|
||||
id: confirmMa
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: pwInput.go()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: pwInput.forceActiveFocus()
|
||||
}
|
||||
65
settings/quickshell/Greeter.qml
Normal file
65
settings/quickshell/Greeter.qml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import Quickshell
|
||||
import Quickshell.Services.Greetd
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
|
||||
Window {
|
||||
id: root
|
||||
visible: true
|
||||
visibility: Window.FullScreen
|
||||
color: "black"
|
||||
property string password: ""
|
||||
property string errorMessage: ""
|
||||
property bool busy: false
|
||||
Component.onCompleted: Quickshell.watchFiles = false
|
||||
|
||||
function failed(message) {
|
||||
password = "";
|
||||
busy = false;
|
||||
errorMessage = message;
|
||||
}
|
||||
Connections {
|
||||
target: Greetd
|
||||
function onAuthMessage(message, error, responseRequired, echoResponse) {
|
||||
if (responseRequired) {
|
||||
// This greeter supports the configured password-only PAM flow.
|
||||
if (echoResponse || root.password === "") {
|
||||
Greetd.cancelSession();
|
||||
root.failed("Use a text console for this prompt");
|
||||
return;
|
||||
}
|
||||
Greetd.respond(root.password);
|
||||
root.password = "";
|
||||
} else if (error) root.errorMessage = message;
|
||||
}
|
||||
function onAuthFailure(message) { root.failed("Authentication failed"); }
|
||||
function onError(error) { root.failed("Login unavailable; try again"); }
|
||||
function onReadyToLaunch() {
|
||||
root.password = "";
|
||||
// PAM/logind must know this is a graphical session before the
|
||||
// compositor starts, including for the pre-suspend lock check.
|
||||
Greetd.launch([Quickshell.env("FRED_SESSION_COMMAND")], [
|
||||
"XDG_SESSION_TYPE=wayland",
|
||||
"XDG_CURRENT_DESKTOP=Hyprland",
|
||||
"XDG_SESSION_DESKTOP=Hyprland"
|
||||
]);
|
||||
}
|
||||
}
|
||||
AuthView {
|
||||
anchors.fill: parent
|
||||
theme: Theme
|
||||
busy: root.busy
|
||||
errorMessage: root.errorMessage
|
||||
onSubmitted: password => {
|
||||
if (root.busy || password === "") return;
|
||||
if (!Greetd.available) {
|
||||
root.failed("Login service unavailable");
|
||||
return;
|
||||
}
|
||||
root.errorMessage = "";
|
||||
root.password = password;
|
||||
root.busy = true;
|
||||
Greetd.createSession("fred");
|
||||
}
|
||||
}
|
||||
}
|
||||
61
settings/quickshell/SessionLock.qml
Normal file
61
settings/quickshell/SessionLock.qml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Services.Pam
|
||||
import QtQuick
|
||||
|
||||
Scope {
|
||||
id: root
|
||||
property string password: ""
|
||||
property string errorMessage: ""
|
||||
|
||||
// Immutable configuration, independent of bar edits and reloads.
|
||||
Component.onCompleted: Quickshell.watchFiles = false
|
||||
function requestLock() {
|
||||
Theme.wallFile.reload();
|
||||
sessionLock.locked = true;
|
||||
}
|
||||
IpcHandler {
|
||||
target: "lock"
|
||||
function lock(): void { root.requestLock(); }
|
||||
function secure(): bool { return sessionLock.secure; }
|
||||
}
|
||||
PamContext {
|
||||
id: pam
|
||||
config: "fred-lock"
|
||||
onPamMessage: {
|
||||
if (responseRequired) {
|
||||
respond(root.password);
|
||||
root.password = "";
|
||||
}
|
||||
}
|
||||
onCompleted: result => {
|
||||
root.password = "";
|
||||
if (result === PamResult.Success) sessionLock.locked = false;
|
||||
else root.errorMessage = "Authentication failed";
|
||||
}
|
||||
}
|
||||
WlSessionLock {
|
||||
id: sessionLock
|
||||
// Start only on demand, but always relock after a process crash.
|
||||
locked: true
|
||||
WlSessionLockSurface {
|
||||
color: "black"
|
||||
AuthView {
|
||||
anchors.fill: parent
|
||||
theme: Theme
|
||||
busy: pam.active
|
||||
errorMessage: root.errorMessage
|
||||
onSubmitted: password => {
|
||||
if (pam.active || password === "") return;
|
||||
root.errorMessage = "";
|
||||
root.password = password;
|
||||
if (!pam.start()) {
|
||||
root.password = "";
|
||||
root.errorMessage = "Authentication unavailable";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue