Compare commits

...

24 Commits

Author SHA1 Message Date
2ab1c855ec Revert "muffin: test, move to 7.0"
All checks were successful
Build and Deploy / mreow (push) Successful in 1m45s
Build and Deploy / yarn (push) Successful in 47s
Build and Deploy / muffin (push) Successful in 1m31s
This reverts commit f67ec5bde6.
2026-04-25 10:50:00 -04:00
f67ec5bde6 muffin: test, move to 7.0
Some checks failed
Build and Deploy / mreow (push) Successful in 1h43m17s
Build and Deploy / yarn (push) Successful in 22m1s
Build and Deploy / muffin (push) Failing after 33s
2026-04-25 02:12:21 -04:00
112b85f3fb update
Some checks failed
Build and Deploy / yarn (push) Has been cancelled
Build and Deploy / muffin (push) Has been cancelled
Build and Deploy / mreow (push) Has been cancelled
2026-04-25 01:45:47 -04:00
86cf624027 Revert "muffin: test, move to 6.18"
All checks were successful
Build and Deploy / mreow (push) Successful in 50s
Build and Deploy / yarn (push) Successful in 44s
Build and Deploy / muffin (push) Successful in 1m2s
This reverts commit 1df3a303f5.
2026-04-24 14:21:40 -04:00
1df3a303f5 muffin: test, move to 6.18
All checks were successful
Build and Deploy / mreow (push) Successful in 1m15s
Build and Deploy / yarn (push) Successful in 43s
Build and Deploy / muffin (push) Successful in 1m29s
2026-04-24 14:08:26 -04:00
07a5276e40 patiodeck: fix disko partition order (fixed-size before 100%) 2026-04-24 01:47:25 -04:00
f3d21f16fb desktop-jovian: unify steam/jovian config across yarn + patiodeck
- modules/desktop-jovian.nix: shared Jovian deck-mode config (unfree
  predicate, jovian.steam, sddm, gamescope override, imports
  desktop-steam-update.nix)
- home/progs/steam-shortcuts.nix: declarative non-Steam shortcuts
  (Prism Launcher); add new entries here for all Jovian hosts
- hosts/yarn/default.nix: reduced to host-specific config only
- hosts/patiodeck/default.nix: same
2026-04-23 22:42:25 -04:00
5b2a1a652a patiodeck: add prism launcher to steam shortcuts 2026-04-23 22:34:58 -04:00
665793668d patiodeck: add steam deck LCD host 2026-04-23 22:34:47 -04:00
5ccd84c77e yarn: fix steamos-update exit code — 7 means no update, not 0
Some checks failed
Build and Deploy / mreow (push) Successful in 1m48s
Build and Deploy / yarn (push) Successful in 4m39s
Build and Deploy / muffin (push) Failing after 31s
Steam interprets exit 0 from 'steamos-update check' as 'update applied
successfully' and shows a persistent 'update available' notification.
The SteamOS convention is exit 7 = no update available.
2026-04-23 20:47:33 -04:00
7721c9d3a2 ssh: remove desktop key
Some checks failed
Build and Deploy / mreow (push) Successful in 1m58s
Build and Deploy / yarn (push) Successful in 47s
Build and Deploy / muffin (push) Failing after 30s
2026-04-23 20:23:37 -04:00
b41a547589 yarn: persist root fish history
Some checks failed
Build and Deploy / mreow (push) Successful in 46s
Build and Deploy / yarn (push) Successful in 51s
Build and Deploy / muffin (push) Failing after 28s
2026-04-23 20:17:02 -04:00
d122842995 secrets: update yarn TPM recipient after tmpfs wipe
Some checks failed
Build and Deploy / mreow (push) Successful in 2m8s
Build and Deploy / yarn (push) Successful in 48s
Build and Deploy / muffin (push) Failing after 29s
2026-04-23 19:56:54 -04:00
d65d991118 secrets: add mreow + yarn TPM recipients, re-encrypt desktop secrets
Some checks failed
Build and Deploy / mreow (push) Successful in 2m56s
Build and Deploy / yarn (push) Successful in 1m49s
Build and Deploy / muffin (push) Failing after 31s
2026-04-23 19:45:57 -04:00
06ccc337c1 secrets: proper agenix for desktop hosts via TPM identity
- modules/desktop-age-secrets.nix: agenix + rage wrapped with age-plugin-tpm,
  TPM identity primary, admin SSH key fallback for recovery/pre-bootstrap
- modules/desktop-lanzaboote-agenix.nix: extract secureboot.tar at activation
- modules/desktop-networkmanager.nix: revert to simple import of git-crypt file
- modules/server-age-secrets.nix: renamed from age-secrets.nix
- modules/desktop-common.nix: wire netrc + password-hash to agenix paths
- hosts/yarn/impermanence.nix: persist /var/lib/agenix across tmpfs wipes
- secrets/secrets.nix: recipient declarations (admin + tpm + muffin USB)
- secrets/desktop/*.age: secureboot.tar, nix-cache-netrc, password-hash
- scripts/bootstrap-desktop-tpm.sh: generate TPM identity + print recipient
2026-04-23 19:24:34 -04:00
a3f7a19cc2 update
All checks were successful
Build and Deploy / mreow (push) Successful in 3m39s
Build and Deploy / yarn (push) Successful in 1m3s
Build and Deploy / muffin (push) Successful in 2m26s
2026-04-23 14:23:17 -04:00
e019f2d4fb secrets overhaul: use tpm for laptop (need to migrate desktop later) 2026-04-23 14:22:37 -04:00
22282691e7 grafana: add minecraft server stats 2026-04-23 01:17:10 -04:00
bc3652c782 kernel: cleanup + add back intel gpu (for future server unification)
All checks were successful
Build and Deploy / mreow (push) Successful in 1h25m37s
Build and Deploy / yarn (push) Successful in 1m3s
Build and Deploy / muffin (push) Successful in 1m6s
2026-04-23 00:23:21 -04:00
0a8b863e4b gitea: fix actions visibility
All checks were successful
Build and Deploy / mreow (push) Successful in 2m39s
Build and Deploy / yarn (push) Successful in 1m48s
Build and Deploy / muffin (push) Successful in 1m14s
2026-04-22 23:02:53 -04:00
0901f5edf0 deploy: potentially fix self-deploy issue? 2026-04-22 23:02:38 -04:00
a1924849d6 pi: edit AGENTS.md
Some checks failed
Build and Deploy / mreow (push) Successful in 51s
Build and Deploy / yarn (push) Successful in 54s
Build and Deploy / muffin (push) Failing after 27s
2026-04-22 21:28:20 -04:00
fdd5c5fba0 gitea: hide actions when not logged in
All checks were successful
Build and Deploy / mreow (push) Successful in 56s
Build and Deploy / yarn (push) Successful in 52s
Build and Deploy / muffin (push) Successful in 1m1s
2026-04-22 21:23:47 -04:00
d00ff42e8e site-config: dedupe cross-host values, fix stale dark-reader urls, drop desktop 1g hugepages
new site-config.nix holds values previously duplicated across hosts:
  domain, old_domain, contact_email, timezone, binary_cache (url + pubkey),
  dns_servers, lan (cidr + gateway), hosts.{muffin,yarn} (ip/alias/ssh_host_key),
  ssh_keys.{laptop,desktop,ci_deploy}.

threaded through specialArgs on all three hosts + home-manager extraSpecialArgs +
homeConfigurations.primary + serverLib. service-configs.nix now takes
{ site_config } as a function arg and drops its https namespace; per-service
domains (gitea/matrix/ntfy/mollysocket/livekit/firefox-sync/grafana) are
derived from site_config.domain. ~15 service files and 6 vm tests migrated.

breakage fixes rolled in:
 - home/progs/zen/dark-reader.nix: 5 stale *.gardling.com entries in
   disabledFor rewritten to *.sigkill.computer (caddy 301s the old names so
   these never fired and the new sigkill urls were getting dark-reader applied)
 - modules/desktop-common.nix: drop unused hugepagesz=1G/hugepages=3
   kernelParams (no consumer on mreow or yarn; xmrig on muffin still reserves
   its own via services/monero/xmrig.nix)

verification: muffin toplevel is bit-identical to pre-refactor baseline.
mreow/yarn toplevels differ only in boot.json kernelParams + darkreader
storage.js (nix-diff verified). deployGuardTest and fail2banVaultwardenTest
(latter exercises site_config.domain via bitwarden.nix) pass.
2026-04-22 20:48:29 -04:00
60 changed files with 2216 additions and 349 deletions

View File

@@ -36,10 +36,11 @@ lib/
overlays.nix # jellyfin-exporter, igpu-exporter, reflac, ensureZfsMounts
patches/nixpkgs/ # applied to nixpkgs-stable for muffin builds
secrets/
desktop/ # git-crypt: mreow + yarn share these (wifi, nix-cache-netrc, secureboot.tar, password-hash, disk-password)
secrets.nix # agenix recipients (who can decrypt each .age)
desktop/ # agenix *.age (mreow + yarn) + disk-password (install-time only, git-crypt)
home/ # git-crypt: per-user HM secrets (api keys, steam id)
server/ # agenix *.age + git-crypt *.nix/*.tar/livekit_keys
usb-secrets/ # USB-resident agenix identity key (git-crypt inside the repo)
server/ # agenix *.age + git-crypt *.nix/*.tar/livekit_keys (muffin)
usb-secrets/ # USB-resident agenix identity for muffin (git-crypt inside the repo)
```
**Never read or write files under `secrets/`.** They are encrypted at rest (git-crypt for plaintext, agenix for `.age`). The git-crypt key is delivered to `muffin` at runtime as `/run/agenix/git-crypt-key-nixos.age`.
@@ -89,7 +90,7 @@ If Nix complains about a missing file, `git add` it first — flakes only see tr
| `common-` | imported by ALL hosts | `common-doas.nix`, `common-nix.nix`, `common-shell-fish.nix` |
| `desktop-` | imported by mreow + yarn only | `desktop-common.nix`, `desktop-steam.nix`, `desktop-networkmanager.nix` |
| `server-` | imported by muffin only | `server-security.nix`, `server-power.nix`, `server-impermanence.nix`, `server-lanzaboote-agenix.nix` |
| *(none)* | host-specific filename-scoped; see file contents | `age-secrets.nix`, `zfs.nix`, `no-rgb.nix` (yarn + muffin) |
| *(none)* | host-specific filename-scoped; see file contents | `zfs.nix`, `no-rgb.nix` (yarn + muffin) |
New modules: pick the narrowest prefix that's true, then add the import explicitly in the host's `default.nix` (there is no auto-discovery).
@@ -117,14 +118,18 @@ New modules: pick the narrowest prefix that's true, then add the import explicit
## Secrets
- **git-crypt** covers `secrets/**` per the root `.gitattributes`. Initialized with a single symmetric key checked into `secrets/server/git-crypt-key-nixos.age` (agenix-encrypted to the USB SSH identity).
- **agenix** decrypts `secrets/server/*.age` at activation into `/run/agenix/` on muffin.
- **USB identity**: `/mnt/usb-secrets/usb-secrets-key` on muffin; the age identity path is wired in `modules/usb-secrets.nix`.
- **Encrypting a new agenix secret** uses the SSH public key directly with `age -R`:
- **agenix** decrypts `*.age` into `/run/agenix/` at activation on every host:
- **muffin**: identity is `/mnt/usb-secrets/usb-secrets-key` (ssh-ed25519 on a physical USB). Wired in `modules/usb-secrets.nix`.
- **mreow + yarn**: identity is `/var/lib/agenix/tpm-identity` (an `age-plugin-tpm` handle sealed by the host's TPM 2.0). Wired in `modules/desktop-age-secrets.nix`; yarn persists `/var/lib/agenix` through impermanence.
- **Recipients** are declared in `secrets/secrets.nix`. Desktop secrets are encrypted to the admin SSH key + each host's TPM recipient; server secrets stay encrypted to the muffin USB key.
- **Bootstrap a new desktop**: run `doas scripts/bootstrap-desktop-tpm.sh` on the host. It generates a TPM-sealed identity at `/var/lib/agenix/tpm-identity` and prints an `age1tpm1…` recipient. Append it to the `tpm` list in `secrets/secrets.nix`, run `agenix -r` to re-encrypt, commit, `./deploy.sh switch`.
- **Encrypting a new server secret** uses the SSH public key directly with `age -R`:
```sh
age -R <(ssh-keygen -y -f secrets/usb-secrets/usb-secrets-key) \
-o secrets/server/<name>.age \
/path/to/plaintext
```
For desktop secrets, prefer `agenix -e secrets/desktop/<name>.age` from a shell with `age-plugin-tpm` on PATH — it reads `secrets/secrets.nix` and encrypts to every recipient listed there.
- **DO NOT use `ssh-to-age`**. It produces `X25519` recipient stanzas, which the SSH private key on muffin cannot decrypt (it only decrypts `ssh-ed25519` stanzas produced by `age -R` against the SSH pubkey). Mismatched stanzas show up as `age: error: no identity matched any of the recipients` at deploy time.
- Never read or commit plaintext secrets. Never log secret values.
@@ -191,11 +196,26 @@ lib.mkIf config.services.<service>.enable {
Existing registrations live in `services/jellyfin/jellyfin-deploy-guard.nix` (REST `/Sessions` via curl+jq) and `services/minecraft-deploy-guard.nix` (Server List Ping via `mcstatus`). Prefer soft-fail on unreachable — a service that's already down has no users to disrupt.
## Deploy finalize (muffin)
`modules/server-deploy-finalize.nix` solves the self-deploy problem: the gitea-actions runner driving CI deploys lives on muffin itself, so a direct `switch-to-configuration switch` restarts the runner mid-activation, killing the SSH session, the CI job, and deploy-rs's magic-rollback handshake. The failure mode is visible as "deploy appears to fail even though the new config landed" (or worse, a rollback storm).
The fix is a two-phase activation wired into `deploy.nodes.muffin.profiles.system.path` in `flake.nix`:
1. `switch-to-configuration boot` — bootloader-only, no service restarts. The runner, SSH session, and magic-rollback survive.
2. `deploy-finalize` — schedules a detached `systemd-run --on-active=N` transient unit (default 60s). The unit is owned by pid1, so it survives the eventual runner restart. If `/run/booted-system/{kernel,initrd,kernel-modules}` differs from the new profile's, the unit runs `systemctl reboot`; otherwise it runs `switch-to-configuration switch`.
That is, reboot is dynamically gated on kernel/initrd/kernel-modules change. The 60s delay is tuned so the CI job (or manual `./deploy.sh muffin`) has time to emit status/notification steps before the runner is recycled.
Back-to-back deploys supersede each other: each invocation cancels any still-pending `deploy-finalize-*.timer` before scheduling its own. `deploy-finalize --dry-run` prints the decision without scheduling anything — useful when debugging.
Prior art: the 3-path `{kernel,initrd,kernel-modules}` diff is lifted from nixpkgs's `system.autoUpgrade` module (the `allowReboot = true` branch) and was packaged the same way in [obsidiansystems/obelisk#957](https://github.com/obsidiansystems/obelisk/pull/957). nixpkgs#185030 tracks lifting it into `switch-to-configuration` proper but has been stale since 2025-07. The self-deploy `systemd-run` detachment is the proposed fix from [deploy-rs#153](https://github.com/serokell/deploy-rs/issues/153), also unmerged upstream.
## Technical details
- **Privilege escalation**: `doas` everywhere; `sudo` is disabled on every host.
- **Shell**: fish. `bash` login shells re-exec into fish via `programs.bash.interactiveShellInit` (see `modules/common-shell-fish.nix`).
- **Secure boot**: lanzaboote. Desktops extract keys from `secrets/desktop/secureboot.tar`; muffin extracts from an agenix-decrypted tar (see `modules/server-lanzaboote-agenix.nix`).
- **Secure boot**: lanzaboote. Every host extracts keys from an agenix-decrypted tar at activation — desktops via `modules/desktop-lanzaboote-agenix.nix`, muffin via `modules/server-lanzaboote-agenix.nix`.
- **Impermanence**: muffin is tmpfs-root with `/persistent` surviving reboots (`modules/server-impermanence.nix`); yarn binds `/home/primary` from `/persistent` (`hosts/yarn/impermanence.nix`).
- **Disks**: disko.
- **Binary cache**: muffin runs harmonia; desktops consume it at `https://nix-cache.sigkill.computer`.

132
flake.lock generated
View File

@@ -92,16 +92,16 @@
]
},
"locked": {
"lastModified": 1776182890,
"narHash": "sha256-+/VOe8XGq5klpU+I19D+3TcaR7o+Cwbq67KNF7mcFak=",
"owner": "Mic92",
"lastModified": 1776192490,
"narHash": "sha256-5gYQNEs0/vDkHhg63aHS5g0IwG/8HNvU1Vr00cElofk=",
"owner": "nix-community",
"repo": "bun2nix",
"rev": "648d293c51e981aec9cb07ba4268bc19e7a8c575",
"rev": "6ef9f144616eedea90b364bb408ef2e1de7b310a",
"type": "github"
},
"original": {
"owner": "Mic92",
"ref": "catalog-support",
"owner": "nix-community",
"ref": "staging-2.1.0",
"repo": "bun2nix",
"type": "github"
}
@@ -109,11 +109,11 @@
"cachyos-kernel": {
"flake": false,
"locked": {
"lastModified": 1776608760,
"narHash": "sha256-ehDv8bF7k/2Kf4b8CCoSm51U/MOoFuLsRXqe5wZ57sE=",
"lastModified": 1776881435,
"narHash": "sha256-j8AobLjMzeKJugseObrVC4O5k7/aZCWoft2sCS3jWYs=",
"owner": "CachyOS",
"repo": "linux-cachyos",
"rev": "7e06e29005853bbaaa3b1c1067f915d6e0db728a",
"rev": "1c61dfd1c3ad7762faa0db8b06c6af6c59cc4340",
"type": "github"
},
"original": {
@@ -125,11 +125,11 @@
"cachyos-kernel-patches": {
"flake": false,
"locked": {
"lastModified": 1776792814,
"narHash": "sha256-39dlIhz9KxUNQFxGpE9SvCviaOWAivdW0XJM8RnPNmg=",
"lastModified": 1777002108,
"narHash": "sha256-PIZCIf6xUTOUqLFbEGH0mSwu2O/YfeAmYlgdAbP4dhs=",
"owner": "CachyOS",
"repo": "kernel-patches",
"rev": "d7d558d0b2e239e27b40bcf1af6fe12e323aa391",
"rev": "46476ae2538db486462aef8a9de37d19030cdaf2",
"type": "github"
},
"original": {
@@ -222,11 +222,11 @@
]
},
"locked": {
"lastModified": 1776849698,
"narHash": "sha256-t2I9ZhBuAcaLV1Z65aVd/5BmDFGvyzLY5kpiSedx2uY=",
"lastModified": 1777083982,
"narHash": "sha256-O44P8qcFEv0PYQd+9vFAgCu/e9RclHIAyAmRDJ8qR5s=",
"owner": "nix-community",
"repo": "emacs-overlay",
"rev": "87dff52c245cba0c5103cf89b964e508ed9bb720",
"rev": "42711d50137a45b8065c3e329946e2d4525235d0",
"type": "github"
},
"original": {
@@ -266,11 +266,11 @@
},
"locked": {
"dir": "pkgs/firefox-addons",
"lastModified": 1776830588,
"narHash": "sha256-1X4L6+F7DgYTUDah+PDs7IYJiQrb7MwYfateq2fBxGY=",
"lastModified": 1777089773,
"narHash": "sha256-ZIlNuebeWTncyl7mcV9VbceSLAaZki+UeXLPQG959xI=",
"owner": "rycee",
"repo": "nur-expressions",
"rev": "f3db83bc13aee22474fab41fa838e50a691dfbc5",
"rev": "402ba229617a12d918c2a887a4c83a9a24f9a36c",
"type": "gitlab"
},
"original": {
@@ -484,11 +484,11 @@
]
},
"locked": {
"lastModified": 1776891022,
"narHash": "sha256-vEe2f4NEhMvaNDpM1pla4hteaIIGQyAMKUfIBPLasr0=",
"lastModified": 1777086106,
"narHash": "sha256-hlNpIN18pw3xo34Lsrp6vAMUPn0aB/zFBqL0QXI1Pmk=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "508daf831ab8d1b143d908239c39a7d8d39561b2",
"rev": "5826802354a74af18540aef0b01bc1320f82cc17",
"type": "github"
},
"original": {
@@ -564,11 +564,11 @@
]
},
"locked": {
"lastModified": 1776874528,
"narHash": "sha256-X4Y2vMbVBuyUQzbZnl72BzpZMYUsWdE78JuDg2ySDxE=",
"lastModified": 1776962372,
"narHash": "sha256-Y2imW4kyIhupx8myNSeNCzDbEx2X+h+AmhNjWXA/7Yw=",
"owner": "Jovian-Experiments",
"repo": "Jovian-NixOS",
"rev": "4c8ccc482a3665fb4a3b2cadbbe7772fb7cc2629",
"rev": "ee3a1184a978e311194a2d3d352c5e6aba67a4b5",
"type": "github"
},
"original": {
@@ -631,11 +631,11 @@
]
},
"locked": {
"lastModified": 1776862155,
"narHash": "sha256-EDvbwsGNE/N5ul+9ul1dJP3Ouf72+Ub2C0UMbDWcxyQ=",
"lastModified": 1777066729,
"narHash": "sha256-f+a+ikbq0VS6RQFf+A6EuVnsWYn2RR3ggRJNkzZgMto=",
"owner": "TheTom",
"repo": "llama-cpp-turboquant",
"rev": "9e3fb40e8bc0f873ad4d3d8329b17dacff28e4ca",
"rev": "11a241d0db78a68e0a5b99fe6f36de6683100f6a",
"type": "github"
},
"original": {
@@ -657,11 +657,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1776883427,
"narHash": "sha256-prHCm++hniRcoqzvWTEFyAiLKT6m+EUVCRaDLrsuEgM=",
"lastModified": 1777093284,
"narHash": "sha256-tBvsFPJy0/2gocc6QGYFXJF44TvJ8PC726NsdTpFJ44=",
"owner": "numtide",
"repo": "llm-agents.nix",
"rev": "6fd26c9cb50d9549f3791b3d35e4f72f97677103",
"rev": "6b4673fddbbe1f2656b3fa8d2a32666570aafbfa",
"type": "github"
},
"original": {
@@ -704,11 +704,11 @@
"xwayland-satellite-unstable": "xwayland-satellite-unstable"
},
"locked": {
"lastModified": 1776879043,
"narHash": "sha256-M9RjuowtoqQbFRdQAm2P6GjFwgHjRcnWYcB7ChSjDms=",
"lastModified": 1777068473,
"narHash": "sha256-atEzEdMgJMRPm/yxOiBvOSEcjSUgU20ieXYQeDfxhTo=",
"owner": "sodiboo",
"repo": "niri-flake",
"rev": "535ebbe038039215a5d1c6c0c67f833409a5be96",
"rev": "d543523b5cd4c1f10e41ad8801c49808198b9ca5",
"type": "github"
},
"original": {
@@ -737,11 +737,11 @@
"niri-unstable": {
"flake": false,
"locked": {
"lastModified": 1776853441,
"narHash": "sha256-mSxfoEs7DiDhMCBzprI/1K7UXzMISuGq0b7T06LVJXE=",
"lastModified": 1777045529,
"narHash": "sha256-EeAwmrvONsovL2qPwKGXF2xGhbo7MySesY3fW2pNLpM=",
"owner": "YaLTeR",
"repo": "niri",
"rev": "74d2b18603366b98ec9045ecf4a632422f472365",
"rev": "9438f59e2b9d8deb6fcec5922f8aca18162b673c",
"type": "github"
},
"original": {
@@ -761,11 +761,11 @@
]
},
"locked": {
"lastModified": 1776796985,
"narHash": "sha256-cNFg3H09sBZl1v9ds6PDHfLCUTDJbefGMSv+WxFs+9c=",
"lastModified": 1777054238,
"narHash": "sha256-qaqHPZO3oQJiIZgD6sp5HKwvYAVyMtHVJiXVwPSEkx0=",
"owner": "xddxdd",
"repo": "nix-cachyos-kernel",
"rev": "ac5956bbceb022998fc1dd0001322f10ef1e6dda",
"rev": "acb94409639d6d6d64bea140f939ac34938560b1",
"type": "github"
},
"original": {
@@ -787,11 +787,11 @@
"systems": "systems_6"
},
"locked": {
"lastModified": 1776851701,
"narHash": "sha256-tdtOcU2Hz/eLqAhkzUcEocgX0WpjKSbl2SkVjOZGZw0=",
"lastModified": 1776938345,
"narHash": "sha256-3/BFiytDNoIXMUQHcJLoxa7JK0Q1/49M0ffOR9pbzvw=",
"owner": "marienz",
"repo": "nix-doom-emacs-unstraightened",
"rev": "7ac65a49eec5e3f87d27396e645eddbf9dc626de",
"rev": "eb25c754986165e509ad2ab8c6b6729f4a861f0c",
"type": "github"
},
"original": {
@@ -846,11 +846,11 @@
"systems": "systems_7"
},
"locked": {
"lastModified": 1776828595,
"narHash": "sha256-LkFpFnPTK6H0gwyfYezN3kEKHVxjSdPp/tBnrQRFP3E=",
"lastModified": 1777001712,
"narHash": "sha256-9JX9msZU1NvHzjKM24PRorP76Ge8GBy6LAkJKA21mlY=",
"owner": "Infinidoge",
"repo": "nix-minecraft",
"rev": "28f0f2369655a5910e810c35c698dfaa9ccec692",
"rev": "394d3bfd943458baf29e4798bc9b256d824a3bb9",
"type": "github"
},
"original": {
@@ -861,11 +861,11 @@
},
"nixos-hardware": {
"locked": {
"lastModified": 1776830795,
"narHash": "sha256-PAfvLwuHc1VOvsLcpk6+HDKgMEibvZjCNvbM1BJOA7o=",
"lastModified": 1776983936,
"narHash": "sha256-ZOQyNqSvJ8UdrrqU1p7vaFcdL53idK+LOM8oRWEWh6o=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "72674a6b5599e844c045ae7449ba91f803d44ebc",
"rev": "2096f3f411ce46e88a79ae4eafcfc9df8ed41c61",
"type": "github"
},
"original": {
@@ -877,11 +877,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1776548001,
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
"lastModified": 1776877367,
"narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
"rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
"type": "github"
},
"original": {
@@ -991,11 +991,11 @@
"noctalia-qs": "noctalia-qs"
},
"locked": {
"lastModified": 1776888984,
"narHash": "sha256-Up2F/eoMuPUsZnPVYdH5TMHe1TBP2Ue1QuWd0vWZoxY=",
"lastModified": 1777079905,
"narHash": "sha256-TvYEXwkZnRFQRuFyyqTNSfPnU2tMdhtiBOXSk2AWLJA=",
"owner": "noctalia-dev",
"repo": "noctalia-shell",
"rev": "2c1808f9f8937fc0b82c54af513f7620fec56d71",
"rev": "a50c92167c8d438000270f7eca36f6eea74f388e",
"type": "github"
},
"original": {
@@ -1133,11 +1133,11 @@
]
},
"locked": {
"lastModified": 1776827647,
"narHash": "sha256-sYixYhp5V8jCajO8TRorE4fzs7IkL4MZdfLTKgkPQBk=",
"lastModified": 1777086717,
"narHash": "sha256-vEl3cGHRxEFdVNuP9PbrhAWnmU98aPOLGy9/1JXzSuM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "40e6ccc06e1245a4837cbbd6bdda64e21cc67379",
"rev": "3be56bd430bfd65d3c468a50626c3a601c7dee03",
"type": "github"
},
"original": {
@@ -1190,11 +1190,11 @@
]
},
"locked": {
"lastModified": 1776653059,
"narHash": "sha256-K3tWnUj6FXaK95sBUajedutJrFVrOzYhvrQwQjJ0FbU=",
"lastModified": 1777000965,
"narHash": "sha256-xcrhVgfI13s1WH4hg5MLL83zAp6/htfF8Pjw4RPiKM8=",
"owner": "nix-community",
"repo": "srvos",
"rev": "4968d2a44c84edfc9a38a2494cc7f85ad2c7122b",
"rev": "7ae6f096b2ffbd25d17da8a4d0fe299a164c4eac",
"type": "github"
},
"original": {
@@ -1356,11 +1356,11 @@
"trackerlist": {
"flake": false,
"locked": {
"lastModified": 1776809383,
"narHash": "sha256-r4V5l+Yk3jxVfZNQk2Ddu8Vlyshd9FWcnGGFyaL4UCw=",
"lastModified": 1777068584,
"narHash": "sha256-UZr6mQfauhIUo8n3SDYnBWeq11xs5lTAoc9onh2MHBc=",
"owner": "ngosang",
"repo": "trackerslist",
"rev": "37d5c0552c25abf50f05cc6b377345e65a588dc2",
"rev": "747c048c604c8d12b9d20cfccea4800a32382a66",
"type": "github"
},
"original": {
@@ -1524,11 +1524,11 @@
]
},
"locked": {
"lastModified": 1776844129,
"narHash": "sha256-DaYSEBVzTvUhTuoVe70NHphoq5JKUHqUhlNlN5XnTuU=",
"lastModified": 1777084302,
"narHash": "sha256-qHE5XpgtRedzND5xzaqzbSOw4amse0aA4/BaVI4ONcU=",
"owner": "0xc000022070",
"repo": "zen-browser-flake",
"rev": "90706e6ab801e4fb7bc53343db67583631936192",
"rev": "f6bab88f8566ddc13fb5e5500bd6c720b61d5321",
"type": "github"
},
"original": {

View File

@@ -180,17 +180,20 @@
targetPlatform = system;
buildPlatform = builtins.currentSystem;
};
serviceConfigs = import ./hosts/muffin/service-configs.nix;
siteConfig = import ./site-config.nix;
serviceConfigs = import ./hosts/muffin/service-configs.nix { site_config = siteConfig; };
serverLib = import ./lib {
inherit inputs;
lib = nixpkgs-stable.lib;
pkgs = serverPkgs;
service_configs = serviceConfigs;
site_config = siteConfig;
};
testSuite = import ./tests/tests.nix {
pkgs = serverPkgs;
lib = serverLib;
inherit inputs;
site_config = siteConfig;
config = self.nixosConfigurations.muffin.config;
};
@@ -203,6 +206,7 @@
specialArgs = {
inherit inputs username hostname;
niri-package = niriPackage;
site_config = siteConfig;
};
modules = [
home-manager.nixosModules.home-manager
@@ -222,6 +226,7 @@
niri-package = niriPackage;
homeDirectory = "/home/${username}";
stateVersion = config.system.stateVersion;
site_config = siteConfig;
};
home-manager.users.${username} = import ./hosts/${hostname}/home.nix;
}
@@ -241,6 +246,7 @@
hostname = "muffin";
eth_interface = "enp4s0";
service_configs = serviceConfigs;
site_config = siteConfig;
lib = serverLib;
};
modules = [
@@ -349,6 +355,9 @@
(
{ ... }:
{
home-manager.extraSpecialArgs = {
site_config = siteConfig;
};
home-manager.users.${username} = import ./hosts/muffin/home.nix;
}
)
@@ -367,6 +376,7 @@
nixosConfigurations = {
mreow = mkDesktopHost "mreow";
yarn = mkDesktopHost "yarn";
patiodeck = mkDesktopHost "patiodeck";
muffin = muffinHost;
};
@@ -376,6 +386,9 @@
# Ships the shared terminal profile (fish, helix, modern CLI, git).
homeConfigurations.primary = home-manager.lib.homeManagerConfiguration {
pkgs = desktopPkgs;
extraSpecialArgs = {
site_config = siteConfig;
};
modules = [
./home/profiles/terminal.nix
{
@@ -389,7 +402,7 @@
};
deploy.nodes.muffin = {
hostname = "server-public";
hostname = siteConfig.hosts.muffin.alias;
profiles.system = {
sshUser = "root";
user = "root";
@@ -403,7 +416,27 @@
# want to avoid when the deploy is supposed to be a no-op blocked by
# the guard. Blocking before the deploy-rs invocation is the only
# clean way to leave the running system untouched.
path = deploy-rs.lib.${system}.activate.nixos self.nixosConfigurations.muffin;
#
# Activation uses `switch-to-configuration boot` + a detached finalize
# (modules/server-deploy-finalize.nix) rather than the default
# `switch`. The gitea-actions runner driving CI deploys lives on
# muffin itself; a direct `switch` restarts gitea-runner-muffin mid-
# activation, killing the SSH session, the CI job, and deploy-rs's
# magic-rollback handshake. `boot` only touches the bootloader — no
# service restarts — and deploy-finalize schedules a pid1-owned
# transient unit that runs the real `switch` (or `systemctl reboot`
# when kernel/initrd/kernel-modules changed) ~60s later, surviving
# runner restart because it's decoupled from the SSH session.
path =
deploy-rs.lib.${system}.activate.custom self.nixosConfigurations.muffin.config.system.build.toplevel
''
# matches activate.nixos's workaround for NixOS/nixpkgs#73404
cd /tmp
$PROFILE/bin/switch-to-configuration boot
${nixpkgs-stable.lib.getExe self.nixosConfigurations.muffin.config.system.build.deployFinalize}
'';
};
};

View File

@@ -10,6 +10,7 @@
# tools, no GUI-adjacent utilities — those belong in profiles layered on top.
{
lib,
site_config,
pkgs,
...
}:
@@ -83,7 +84,7 @@
push.autoSetupRemote = true;
user = {
name = "Simon Gardling";
email = "titaniumtown@proton.me";
email = site_config.contact_email;
};
};

View File

@@ -69,6 +69,9 @@ in
## Nix
For using `nix build` append `-L` to get better visibility into the logs.
If you get an error that a file can't be found, always try to `git add` the file before trying other troubleshooting steps.
## Implementation
When sketching out an implementation of something, always look for tools that already exist in the space first before implementing something custom. This is also the case when it comes to submodules and sections of code, I don't want you to implement things in-house when it isn't needed.
'';
home.file.".omp/agent/skills/android-ui/SKILL.md".text = ''

View File

@@ -0,0 +1,29 @@
# Declarative non-Steam game shortcuts for the Steam library.
# Add entries to the `shortcuts` list to have them appear in Steam's UI.
{
pkgs,
inputs,
lib,
...
}:
{
imports = [
inputs.json2steamshortcut.homeModules.default
];
services.steam-shortcuts = {
enable = true;
overwriteExisting = true;
steamUserId = lib.strings.toInt (
lib.strings.trim (builtins.readFile ../../secrets/home/steam-user-id)
);
shortcuts = [
{
AppName = "Prism Launcher";
Exe = "${pkgs.prismlauncher}/bin/prismlauncher";
Icon = "${pkgs.prismlauncher}/share/icons/hicolor/scalable/apps/org.prismlauncher.PrismLauncher.svg";
Tags = [ "Game" ];
}
];
};
}

View File

@@ -68,19 +68,19 @@ in
"element.envs.net"
"mail.proton.me"
"mail.google.com"
"www.gardling.com"
"www.sigkill.computer"
"projects.fivethirtyeight.com"
"secure.bankofamerica.com"
"billpay-ui.bankofamerica.com"
"plus.pearson.com"
"immich.gardling.com"
"immich.sigkill.computer"
"huggingface.co"
"session.masteringphysics.com"
"brainly.com"
"www.270towin.com"
"phet.colorado.edu"
"8042-1.portal.athenahealth.com"
"torrent.gardling.com"
"torrent.sigkill.computer"
"nssb-p.adm.fit.edu"
"mail.openbenchmarking.org"
"moneroocean.stream"
@@ -89,11 +89,11 @@ in
"chat.deepseek.com"
"n21.ultipro.com"
"www.egaroucid.nyanyan.dev"
"bitmagnet.gardling.com"
"bitmagnet.sigkill.computer"
"frame.work"
"www.altcancer.net"
"jenkins.jpenilla.xyz"
"soulseek.gardling.com"
"soulseek.sigkill.computer"
"discord.com"
"www.lufthansa.com"
"surveys.hyundaicx.com"

View File

@@ -5,6 +5,7 @@
hostname,
username,
eth_interface,
site_config,
service_configs,
options,
...
@@ -18,13 +19,14 @@
../../modules/zfs.nix
../../modules/server-impermanence.nix
../../modules/usb-secrets.nix
../../modules/age-secrets.nix
../../modules/server-age-secrets.nix
../../modules/server-lanzaboote-agenix.nix
../../modules/no-rgb.nix
../../modules/server-security.nix
../../modules/ntfy-alerts.nix
../../modules/server-power.nix
../../modules/server-deploy-guard.nix
../../modules/server-deploy-finalize.nix
../../services/postgresql.nix
../../services/jellyfin
@@ -79,19 +81,32 @@
];
# Hosts entries for CI/CD deploy targets
networking.hosts."192.168.1.50" = [ "server-public" ];
networking.hosts."192.168.1.223" = [ "desktop" ];
networking.hosts.${site_config.hosts.muffin.ip} = [ site_config.hosts.muffin.alias ];
networking.hosts.${site_config.hosts.yarn.ip} = [ site_config.hosts.yarn.alias ];
# SSH known_hosts for CI runner (pinned host keys)
environment.etc."ci-known-hosts".text = ''
server-public ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFMjgaMnE+zS7tL+m5E7gh9Q9U1zurLdmU0qcmEmaucu
192.168.1.50 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFMjgaMnE+zS7tL+m5E7gh9Q9U1zurLdmU0qcmEmaucu
git.sigkill.computer ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFMjgaMnE+zS7tL+m5E7gh9Q9U1zurLdmU0qcmEmaucu
git.gardling.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFMjgaMnE+zS7tL+m5E7gh9Q9U1zurLdmU0qcmEmaucu
'';
# SSH known_hosts for CI runner (pinned host keys). All four names resolve to
# the same muffin host and therefore serve the same host key.
environment.etc."ci-known-hosts".text =
let
key = site_config.hosts.muffin.ssh_host_key;
names = [
site_config.hosts.muffin.alias
site_config.hosts.muffin.ip
"git.${site_config.domain}"
"git.${site_config.old_domain}"
];
in
lib.concatMapStrings (n: "${n} ${key}\n") names;
services.deployGuard.enable = true;
# Detached deploy finalize: see modules/server-deploy-finalize.nix. deploy-rs
# activates in `boot` mode and invokes deploy-finalize to schedule the real
# `switch` (or reboot, when kernel/initrd/kernel-modules changed) 60s later
# as a pid1-owned transient unit. Prevents the self-hosted gitea runner from
# being restarted mid-CI-deploy.
services.deployFinalize.enable = true;
# Disable serial getty on ttyS0 to prevent dmesg warnings
systemd.services."serial-getty@ttyS0".enable = false;
@@ -149,9 +164,6 @@
};
};
# Set your time zone.
time.timeZone = "America/New_York";
hardware.graphics = {
enable = true;
extraPackages = with pkgs; [
@@ -183,10 +195,7 @@
];
networking = {
nameservers = [
"1.1.1.1"
"9.9.9.9"
];
nameservers = site_config.dns_servers;
hostName = hostname;
hostId = "0f712d56";
@@ -200,8 +209,7 @@
interfaces.${eth_interface} = {
ipv4.addresses = [
{
address = "192.168.1.50";
# address = "10.1.1.102";
address = site_config.hosts.muffin.ip;
prefixLength = 24;
}
];
@@ -213,8 +221,7 @@
];
};
defaultGateway = {
#address = "10.1.1.1";
address = "192.168.1.1";
address = site_config.lan.gateway;
interface = eth_interface;
};
# TODO! fix this

View File

@@ -1,3 +1,4 @@
{ site_config }:
rec {
zpool_ssds = "tank";
zpool_hdds = "hdds";
@@ -195,6 +196,10 @@ rec {
port = 9563;
proto = "tcp";
};
minecraft_exporter = {
port = 9567;
proto = "tcp";
};
prometheus_zfs = {
port = 9134;
proto = "tcp";
@@ -206,15 +211,9 @@ rec {
};
};
https = {
certs = services_dir + "/http_certs";
domain = "sigkill.computer";
old_domain = "gardling.com"; # Redirect traffic from old domain
};
gitea = {
dir = services_dir + "/gitea";
domain = "git.${https.domain}";
domain = "git.${site_config.domain}";
};
postgres = {
@@ -278,19 +277,19 @@ rec {
matrix = {
dataDir = "/var/lib/continuwuity";
domain = "matrix.${https.domain}";
domain = "matrix.${site_config.domain}";
};
ntfy = {
domain = "ntfy.${https.domain}";
domain = "ntfy.${site_config.domain}";
};
mollysocket = {
domain = "mollysocket.${https.domain}";
domain = "mollysocket.${site_config.domain}";
};
livekit = {
domain = "livekit.${https.domain}";
domain = "livekit.${site_config.domain}";
};
syncthing = {
@@ -324,12 +323,12 @@ rec {
};
firefox_syncserver = {
domain = "firefox-sync.${https.domain}";
domain = "firefox-sync.${site_config.domain}";
};
grafana = {
dir = services_dir + "/grafana";
domain = "grafana.${https.domain}";
domain = "grafana.${site_config.domain}";
};
trilium = {

View File

@@ -0,0 +1,38 @@
{
username,
inputs,
site_config,
...
}:
{
imports = [
../../modules/desktop-common.nix
../../modules/desktop-jovian.nix
./disk.nix
./impermanence.nix
inputs.impermanence.nixosModules.impermanence
];
networking.hostId = "a1b2c3d4";
# SSH for remote management from laptop
services.openssh = {
enable = true;
ports = [ 22 ];
settings = {
PasswordAuthentication = false;
PermitRootLogin = "yes";
};
};
users.users.${username}.openssh.authorizedKeys.keys = [
site_config.ssh_keys.laptop
];
users.users.root.openssh.authorizedKeys.keys = [
site_config.ssh_keys.laptop
];
jovian.devices.steamdeck.enable = true;
}

52
hosts/patiodeck/disk.nix Normal file
View File

@@ -0,0 +1,52 @@
{
disko.devices = {
disk = {
main = {
type = "disk";
content = {
type = "gpt";
partitions = {
ESP = {
type = "EF00";
size = "500M";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
nix = {
size = "200G";
content = {
type = "filesystem";
format = "f2fs";
mountpoint = "/nix";
};
};
persistent = {
size = "100%";
content = {
type = "filesystem";
format = "f2fs";
mountpoint = "/persistent";
};
};
};
};
};
};
nodev = {
"/" = {
fsType = "tmpfs";
mountOptions = [
"defaults"
"size=2G"
"mode=755"
];
};
};
};
fileSystems."/persistent".neededForBoot = true;
fileSystems."/nix".neededForBoot = true;
}

8
hosts/patiodeck/home.nix Normal file
View File

@@ -0,0 +1,8 @@
{ ... }:
{
imports = [
../../home/profiles/gui.nix
../../home/profiles/desktop.nix
../../home/progs/steam-shortcuts.nix
];
}

View File

@@ -0,0 +1,48 @@
{
username,
...
}:
{
environment.persistence."/persistent" = {
hideMounts = true;
directories = [
"/var/log"
"/var/lib/systemd/coredump"
"/var/lib/nixos"
"/var/lib/systemd/timers"
# agenix identity sealed by the TPM
{
directory = "/var/lib/agenix";
mode = "0700";
user = "root";
group = "root";
}
];
files = [
"/etc/ssh/ssh_host_ed25519_key"
"/etc/ssh/ssh_host_ed25519_key.pub"
"/etc/ssh/ssh_host_rsa_key"
"/etc/ssh/ssh_host_rsa_key.pub"
"/etc/machine-id"
];
users.root = {
files = [
".local/share/fish/fish_history"
];
};
};
# bind mount home directory from persistent storage
fileSystems."/home/${username}" = {
device = "/persistent/home/${username}";
fsType = "none";
options = [ "bind" ];
neededForBoot = true;
};
systemd.tmpfiles.rules = [
"d /etc 755 root"
];
}

View File

@@ -1,21 +1,21 @@
{
config,
pkgs,
lib,
username,
inputs,
site_config,
...
}:
{
imports = [
../../modules/desktop-common.nix
../../modules/desktop-jovian.nix
../../modules/no-rgb.nix
./disk.nix
./impermanence.nix
./vr.nix
inputs.impermanence.nixosModules.impermanence
inputs.jovian-nixos.nixosModules.default
];
fileSystems."/media/games" = {
@@ -43,8 +43,8 @@
};
ipv4 = {
method = "manual";
address1 = "192.168.1.223/24,192.168.1.1";
dns = "1.1.1.1;9.9.9.9;";
address1 = "${site_config.hosts.yarn.ip}/24,${site_config.lan.gateway}";
dns = lib.concatMapStrings (n: "${n};") site_config.dns_servers;
};
ipv6.method = "disabled";
};
@@ -59,12 +59,12 @@
};
users.users.${username}.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO4jL6gYOunUlUtPvGdML0cpbKSsPNqQ1jit4E7U1RyH" # laptop
site_config.ssh_keys.laptop
];
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO4jL6gYOunUlUtPvGdML0cpbKSsPNqQ1jit4E7U1RyH" # laptop
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC5ZYN6idL/w/mUIfPOH1i+Q/SQXuzAMQUEuWpipx1Pc ci-deploy@muffin"
site_config.ssh_keys.laptop
site_config.ssh_keys.ci_deploy
];
programs.steam = {
@@ -82,145 +82,6 @@
systemd.services.lactd.serviceConfig.ExecStartPre = "${lib.getExe pkgs.bash} -c \"sleep 3s\"";
# root-level service that applies a pending update. Triggered by
# steamos-update (via systemctl start) when the user accepts an update.
# Runs as root so it can write the system profile and boot entry.
systemd.services.pull-update-apply = {
description = "Apply pending NixOS update pulled from binary cache";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "pull-update-apply" ''
set -uo pipefail
export PATH=${
pkgs.lib.makeBinPath [
pkgs.curl
pkgs.coreutils
pkgs.nix
]
}
STORE_PATH=$(curl -sf --max-time 30 "https://nix-cache.sigkill.computer/deploy/yarn" || true)
if [ -z "$STORE_PATH" ]; then
echo "server unreachable"
exit 1
fi
CURRENT=$(readlink -f /nix/var/nix/profiles/system)
if [ "$CURRENT" = "$STORE_PATH" ]; then
echo "already up to date: $STORE_PATH"
exit 0
fi
echo "applying $STORE_PATH (was $CURRENT)"
nix-store -r --add-root /nix/var/nix/gcroots/pull-update-apply-latest --indirect "$STORE_PATH" \
|| { echo "fetch failed"; exit 1; }
nix-env -p /nix/var/nix/profiles/system --set "$STORE_PATH" \
|| { echo "profile set failed"; exit 1; }
"$STORE_PATH/bin/switch-to-configuration" boot \
|| { echo "boot entry failed"; exit 1; }
echo "update applied; reboot required"
'';
};
};
# Allow primary user to start pull-update-apply.service without a password
security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "pull-update-apply.service" &&
subject.user == "${username}") {
return polkit.Result.YES;
}
});
'';
nixpkgs.config.allowUnfreePredicate =
pkg:
builtins.elem (lib.getName pkg) [
"steamdeck-hw-theme"
"steam-jupiter-unwrapped"
"steam"
"steam-original"
"steam-unwrapped"
"steam-run"
];
# Override jovian-stubs to disable steamos-update kernel check
# This prevents Steam from requesting reboots for "system updates"
# Steam client updates will still work normally
nixpkgs.overlays = [
(
final: prev:
let
deploy-url = "https://nix-cache.sigkill.computer/deploy/yarn";
steamos-update-script = final.writeShellScript "steamos-update" ''
export PATH=${
final.lib.makeBinPath [
final.curl
final.coreutils
final.systemd
]
}
STORE_PATH=$(curl -sf --max-time 30 "${deploy-url}" || true)
if [ -z "$STORE_PATH" ]; then
>&2 echo "[steamos-update] server unreachable"
exit 7
fi
CURRENT=$(readlink -f /nix/var/nix/profiles/system)
if [ "$CURRENT" = "$STORE_PATH" ]; then
>&2 echo "[steamos-update] no update available"
exit 0
fi
# check-only mode: just report that an update exists
if [ "''${1:-}" = "check" ] || [ "''${1:-}" = "--check-only" ]; then
>&2 echo "[steamos-update] update available"
exit 0
fi
# apply: trigger the root-running systemd service to install the update
>&2 echo "[steamos-update] applying update..."
if systemctl start --wait pull-update-apply.service; then
>&2 echo "[steamos-update] update installed, reboot to apply"
exit 0
else
>&2 echo "[steamos-update] apply failed; see 'journalctl -u pull-update-apply'"
exit 1
fi
'';
in
{
# Only replace holo-update (and its steamos-update alias) with our
# binary-cache pull script. All other stubs (pkexec, sudo,
# holo-reboot, holo-select-branch, …) come from upstream unchanged.
jovian-stubs = prev.jovian-stubs.overrideAttrs (old: {
buildCommand = (old.buildCommand or "") + ''
install -D -m 755 ${steamos-update-script} $out/bin/holo-update
install -D -m 755 ${steamos-update-script} $out/bin/steamos-update
'';
});
}
)
];
jovian = {
devices.steamdeck.enable = false;
steam = {
enable = true;
autoStart = true;
desktopSession = "niri";
user = username;
};
};
# Jovian-NixOS requires sddm
# https://github.com/Jovian-Experiments/Jovian-NixOS/commit/52f140c07493f8bb6cd0773c7e1afe3e1fd1d1fa
services.displayManager.sddm.wayland.enable = true;
# Disable gamescope from common.nix to avoid conflict with jovian-nixos
programs.gamescope.enable = lib.mkForce false;
# yarn is not a Steam Deck
jovian.devices.steamdeck.enable = false;
}

View File

@@ -1,15 +1,12 @@
{
pkgs,
inputs,
lib,
config,
...
}:
{
imports = [
../../home/profiles/gui.nix
../../home/profiles/desktop.nix
inputs.json2steamshortcut.homeModules.default
../../home/progs/steam-shortcuts.nix
];
home.packages = with pkgs; [
@@ -27,20 +24,4 @@
obs-pipewire-audio-capture
];
};
services.steam-shortcuts = {
enable = true;
overwriteExisting = true;
steamUserId = lib.strings.toInt (
lib.strings.trim (builtins.readFile ../../secrets/home/steam-user-id)
);
shortcuts = [
{
AppName = "Prism Launcher";
Exe = "${pkgs.prismlauncher}/bin/prismlauncher";
Icon = "${pkgs.prismlauncher}/share/icons/hicolor/scalable/apps/org.prismlauncher.PrismLauncher.svg";
Tags = [ "Game" ];
}
];
};
}

View File

@@ -12,6 +12,14 @@
"/var/lib/systemd/coredump"
"/var/lib/nixos"
"/var/lib/systemd/timers"
# agenix identity sealed by the TPM. Must survive the tmpfs root
# wipe so decryption at activation finds the right handle.
{
directory = "/var/lib/agenix";
mode = "0700";
user = "root";
group = "root";
}
];
files = [
@@ -21,6 +29,12 @@
"/etc/ssh/ssh_host_rsa_key.pub"
"/etc/machine-id"
];
users.root = {
files = [
".local/share/fish/fish_history"
];
};
};
# Bind mount entire home directory from persistent storage

View File

@@ -2,6 +2,7 @@
inputs,
pkgs,
service_configs,
site_config,
lib ? inputs.nixpkgs-stable.lib,
...
}:
@@ -195,7 +196,7 @@ lib.extend (
assert (subdomain != null) != (domain != null);
{ config, ... }:
let
vhostDomain = if domain != null then domain else "${subdomain}.${service_configs.https.domain}";
vhostDomain = if domain != null then domain else "${subdomain}.${site_config.domain}";
upstream =
if vpn then
"${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString port}"

View File

@@ -75,4 +75,19 @@ final: prev: {
'';
meta.mainProgram = "igpu-exporter";
};
mc-monitor = prev.buildGoModule rec {
pname = "mc-monitor";
version = "0.16.1";
src = prev.fetchFromGitHub {
owner = "itzg";
repo = "mc-monitor";
rev = version;
hash = "sha256-/94+Z9FTFOzQHynHiJuaGFiidkOxmM0g/FIpHn+xvJM=";
};
vendorHash = "sha256-qq7rIpvGRi3AMnBbi8uAhiPcfSF4McIuqozdtxB5CeQ=";
# upstream tests probe live Minecraft servers
doCheck = false;
meta.mainProgram = "mc-monitor";
};
}

View File

@@ -2,10 +2,15 @@
config,
lib,
pkgs,
site_config,
username,
...
}:
{
# Shared timezone. Plain priority so it wins against srvos's mkDefault "UTC";
# mreow overrides via lib.mkForce when travelling.
time.timeZone = site_config.timezone;
# Common Nix daemon settings. Host-specific overrides (binary cache substituters,
# gc retention) live in the host's default.nix.
nix = {

View File

@@ -0,0 +1,70 @@
{
pkgs,
inputs,
...
}:
let
# Wrap rage so age-plugin-tpm is on PATH at activation time.
# Both mreow and yarn use age1tpm1… recipients (legacy P-256 encoding),
# which age-plugin-tpm handles under its own name.
rageWithTpm = pkgs.writeShellScriptBin "rage" ''
export PATH="${pkgs.age-plugin-tpm}/bin:$PATH"
exec ${pkgs.rage}/bin/rage "$@"
'';
in
{
imports = [
inputs.agenix.nixosModules.default
];
# Expose the plugin + agenix CLI for interactive edits (`agenix -e …`).
environment.systemPackages = [
inputs.agenix.packages.${pkgs.system}.default
pkgs.age-plugin-tpm
];
age.ageBin = "${rageWithTpm}/bin/rage";
# Primary identity: TPM-sealed key, generated by scripts/bootstrap-desktop-tpm.sh.
# Fallback identity: admin SSH key. age tries paths in order, so if the TPM
# is wiped or the board is replaced the SSH key keeps secrets accessible until
# the TPM is re-bootstrapped. Both are encrypted recipients on every .age file.
age.identityPaths = [
"/var/lib/agenix/tpm-identity"
"/home/primary/.ssh/id_ed25519"
];
# Ensure the identity directory exists before agenix activation so a fresh
# bootstrap doesn't race the directory creation.
systemd.tmpfiles.rules = [
"d /var/lib/agenix 0700 root root -"
];
age.secrets = {
# Secureboot PKI bundle (db/KEK/PK keys + certs) consumed by lanzaboote
# via desktop-lanzaboote-agenix.nix at activation time.
secureboot-tar = {
file = ../secrets/desktop/secureboot.tar.age;
mode = "0400";
owner = "root";
group = "root";
};
# netrc for the private nix binary cache.
nix-cache-netrc = {
file = ../secrets/desktop/nix-cache-netrc.age;
mode = "0400";
owner = "root";
group = "root";
};
# yescrypt hash for the primary user.
password-hash = {
file = ../secrets/desktop/password-hash.age;
mode = "0400";
owner = "root";
group = "root";
};
};
}

View File

@@ -5,6 +5,7 @@
lib,
username,
inputs,
site_config,
niri-package,
...
}:
@@ -16,9 +17,10 @@
./desktop-vm.nix
./desktop-steam.nix
./desktop-networkmanager.nix
./desktop-age-secrets.nix
./desktop-lanzaboote-agenix.nix
inputs.disko.nixosModules.disko
inputs.lanzaboote.nixosModules.lanzaboote
inputs.nixos-hardware.nixosModules.common-cpu-amd-pstate
inputs.nixos-hardware.nixosModules.common-cpu-amd-zenpower
@@ -49,28 +51,18 @@
mkdir -p /nix/var/nix/profiles/per-user/root/channels
'';
# extract all my secureboot keys
# TODO! proper secrets management
"secureboot-keys".text = ''
#!/usr/bin/env sh
rm -fr ${config.boot.lanzaboote.pkiBundle} || true
mkdir -p ${config.boot.lanzaboote.pkiBundle}
${lib.getExe pkgs.gnutar} xf ${../secrets/desktop/secureboot.tar} -C ${config.boot.lanzaboote.pkiBundle}
chown -R root:wheel ${config.boot.lanzaboote.pkiBundle}
chmod -R 500 ${config.boot.lanzaboote.pkiBundle}
'';
};
swapDevices = [ ];
# Desktop-specific Nix cache — muffin serves it, desktops consume.
# Base nix settings (optimise, gc, experimental-features) come from common-nix.nix.
# Base nix settings (optimise, gc, experimental-features) come from common.nix.
nix.settings = {
substituters = [ "https://nix-cache.sigkill.computer" ];
substituters = [ site_config.binary_cache.url ];
trusted-public-keys = [
"nix-cache.sigkill.computer-1:ONtQC9gUjL+2yNgMWB68NudPySXhyzJ7I3ra56/NPgk="
site_config.binary_cache.public_key
];
netrc-file = "${../secrets/desktop/nix-cache-netrc}";
netrc-file = config.age.secrets.nix-cache-netrc.path;
};
# cachyos kernel overlay
@@ -181,9 +173,14 @@
DRM_HISI_HIBMC = lib.mkForce no;
DRM_APPLETBDRM = lib.mkForce no;
# intel gpu
DRM_I915 = lib.mkForce no;
DRM_XE = lib.mkForce no;
# legacy AMD IP blocks. hosts are Navi 32 RDNA3 dGPU (7800 XT, yarn,
# 2023, gfx1101, DCN 3.2) and Krackan Point RDNA 3.5 iGPU (mreow,
# 2024, gfx1150, DCN 3.5). everything below pre-dates those by a
# decade. upstream only exposes per-generation toggles for SI and
# CIK — no switch for VI/Polaris/Vega/Navi1x, those stay in amdgpu.
DRM_AMDGPU_SI = lib.mkForce no; # Southern Islands / GCN 1 (2012): HD 7950/7970, R9 280/280X, R7 260X
DRM_AMDGPU_CIK = lib.mkForce no; # Sea Islands / GCN 2 (2013): R9 290/290X/390, Kaveri APUs (A10-7850K), Steam Machine Bonaire
DRM_AMD_SECURE_DISPLAY = lib.mkForce no; # HDCP region-CRC debugfs helper, needs custom DMCU firmware
# early-boot framebuffer chain: drop every alternative to amdgpu so
# the console never transitions simpledrm -> dummy -> amdgpu (visible
@@ -285,6 +282,486 @@
XZ_DEC_ARM64 = lib.mkForce no;
XZ_DEC_SPARC = lib.mkForce no;
XZ_DEC_RISCV = lib.mkForce no;
# ==== no hardware for any of these on either host ====
# laptop vendor platform drivers (only FRAMEWORK_LAPTOP is used)
ACER_WMI = lib.mkForce no;
ACER_WIRELESS = lib.mkForce no;
ACERHDF = lib.mkForce no;
APPLE_GMUX = lib.mkForce no;
ASUS_LAPTOP = lib.mkForce no;
ASUS_WMI = lib.mkForce no;
ASUS_NB_WMI = lib.mkForce no;
ASUS_ARMOURY = lib.mkForce no;
ASUS_TF103C_DOCK = lib.mkForce no;
ASUS_WIRELESS = lib.mkForce no;
COMPAL_LAPTOP = lib.mkForce no;
DELL_LAPTOP = lib.mkForce no;
DELL_RBTN = lib.mkForce no;
DELL_PC = lib.mkForce no;
DELL_SMBIOS = lib.mkForce no;
DELL_SMO8800 = lib.mkForce no;
DELL_UART_BACKLIGHT = lib.mkForce no;
DELL_WMI = lib.mkForce no;
DELL_WMI_AIO = lib.mkForce no;
DELL_WMI_DDV = lib.mkForce no;
DELL_WMI_DESCRIPTOR = lib.mkForce no;
DELL_WMI_LED = lib.mkForce no;
DELL_WMI_SYSMAN = lib.mkForce no;
EEEPC_LAPTOP = lib.mkForce no;
EEEPC_WMI = lib.mkForce no;
FUJITSU_LAPTOP = lib.mkForce no;
FUJITSU_ES = lib.mkForce no;
FUJITSU_TABLET = lib.mkForce no;
HUAWEI_WMI = lib.mkForce no;
IBM_ASM = lib.mkForce no;
IBM_RTL = lib.mkForce no;
IDEAPAD_LAPTOP = lib.mkForce no;
LG_LAPTOP = lib.mkForce no;
MSI_LAPTOP = lib.mkForce no;
MSI_WMI = lib.mkForce no;
MSI_EC = lib.mkForce no;
PANASONIC_LAPTOP = lib.mkForce no;
SONY_LAPTOP = lib.mkForce no;
SAMSUNG_LAPTOP = lib.mkForce no;
TOPSTAR_LAPTOP = lib.mkForce no;
THINKPAD_ACPI = lib.mkForce no;
THINKPAD_LMI = lib.mkForce no;
LENOVO_SE10_WDT = lib.mkForce no;
LENOVO_SE30_WDT = lib.mkForce no;
LENOVO_WMI_HOTKEY_UTILITIES = lib.mkForce no;
LENOVO_WMI_CAMERA = lib.mkForce no;
LENOVO_YMC = lib.mkForce no;
LENOVO_WMI_CAPDATA = lib.mkForce no;
LENOVO_WMI_EVENTS = lib.mkForce no;
LENOVO_WMI_HELPERS = lib.mkForce no;
LENOVO_WMI_GAMEZONE = lib.mkForce no;
LENOVO_WMI_TUNING = lib.mkForce no;
YOGABOOK = lib.mkForce no;
YT2_1380 = lib.mkForce no;
XIAOMI_WMI = lib.mkForce no;
BARCO_P50_GPIO = lib.mkForce no;
PC_ENGINES_APU = lib.mkForce no;
SILICOM_PLATFORM = lib.mkForce no;
SIEMENS_SIMATIC_IPC_WDT = lib.mkForce no;
SYSTEM76_ACPI = lib.mkForce no;
INSPUR_PLATFORM_PROFILE = lib.mkForce no;
NVIDIA_WMI_EC_BACKLIGHT = lib.mkForce no;
# legacy filesystems (hosts use vfat/f2fs/tmpfs/fuse; exfat/ntfs3 kept for externals)
JFS_FS = lib.mkForce no;
GFS2_FS = lib.mkForce no;
OCFS2_FS = lib.mkForce no;
NILFS2_FS = lib.mkForce no;
AFFS_FS = lib.mkForce no;
HFS_FS = lib.mkForce no;
HFSPLUS_FS = lib.mkForce no;
BEFS_FS = lib.mkForce no;
JFFS2_FS = lib.mkForce no;
UBIFS_FS = lib.mkForce no;
MINIX_FS = lib.mkForce no;
OMFS_FS = lib.mkForce no;
ROMFS_FS = lib.mkForce no;
UFS_FS = lib.mkForce no;
EROFS_FS = lib.mkForce no;
ORANGEFS_FS = lib.mkForce no;
CODA_FS = lib.mkForce no;
AFS_FS = lib.mkForce no;
CEPH_FS = lib.mkForce no;
ZONEFS_FS = lib.mkForce no;
BCACHE = lib.mkForce no;
BCACHEFS_FS = lib.mkForce no;
ECRYPT_FS = lib.mkForce no;
NFSD = lib.mkForce no;
# legacy partition tables (only GPT+MBR in use)
AIX_PARTITION = lib.mkForce no;
MAC_PARTITION = lib.mkForce no;
LDM_PARTITION = lib.mkForce no;
KARMA_PARTITION = lib.mkForce no;
MINIX_SUBPARTITION = lib.mkForce no;
SOLARIS_X86_PARTITION = lib.mkForce no;
BSD_DISKLABEL = lib.mkForce no;
UNIXWARE_DISKLABEL = lib.mkForce no;
SYSV68_PARTITION = lib.mkForce no;
ULTRIX_PARTITION = lib.mkForce no;
OSF_PARTITION = lib.mkForce no;
SGI_PARTITION = lib.mkForce no;
SUN_PARTITION = lib.mkForce no;
ATARI_PARTITION = lib.mkForce no;
AMIGA_PARTITION = lib.mkForce no;
ACORN_PARTITION = lib.mkForce no;
# legacy net protocols (nothing uses SCTP/RDS/TIPC/SMC or GRE tunnels)
IP_SCTP = lib.mkForce no;
RDS = lib.mkForce no;
TIPC = lib.mkForce no;
SMC = lib.mkForce no;
NET_IPIP = lib.mkForce no;
NET_IPGRE = lib.mkForce no;
NET_IPGRE_DEMUX = lib.mkForce no;
NET_IPVTI = lib.mkForce no;
# legacy PCI sound cards (kept: SND_HDA_* for AMD HDA, SND_SOC_SOF_AMD for ACP)
SND_ALI5451 = lib.mkForce no;
SND_ATIIXP = lib.mkForce no;
SND_ATIIXP_MODEM = lib.mkForce no;
SND_AU8810 = lib.mkForce no;
SND_AU8820 = lib.mkForce no;
SND_AU8830 = lib.mkForce no;
SND_AW2 = lib.mkForce no;
SND_AZT3328 = lib.mkForce no;
SND_BT87X = lib.mkForce no;
SND_CA0106 = lib.mkForce no;
SND_CMIPCI = lib.mkForce no;
SND_OXYGEN = lib.mkForce no;
SND_CS46XX = lib.mkForce no;
SND_CTXFI = lib.mkForce no;
SND_DARLA20 = lib.mkForce no;
SND_GINA20 = lib.mkForce no;
SND_LAYLA20 = lib.mkForce no;
SND_DARLA24 = lib.mkForce no;
SND_GINA24 = lib.mkForce no;
SND_LAYLA24 = lib.mkForce no;
SND_MONA = lib.mkForce no;
SND_MIA = lib.mkForce no;
SND_ECHO3G = lib.mkForce no;
SND_INDIGO = lib.mkForce no;
SND_INDIGOIO = lib.mkForce no;
SND_INDIGODJ = lib.mkForce no;
SND_INDIGOIOX = lib.mkForce no;
SND_INDIGODJX = lib.mkForce no;
SND_EMU10K1 = lib.mkForce no;
SND_EMU10K1X = lib.mkForce no;
SND_ENS1370 = lib.mkForce no;
SND_ENS1371 = lib.mkForce no;
SND_ES1938 = lib.mkForce no;
SND_ES1968 = lib.mkForce no;
SND_FM801 = lib.mkForce no;
SND_HDSP = lib.mkForce no;
SND_HDSPM = lib.mkForce no;
SND_ICE1712 = lib.mkForce no;
SND_ICE1724 = lib.mkForce no;
SND_INTEL8X0 = lib.mkForce no;
SND_INTEL8X0M = lib.mkForce no;
SND_KORG1212 = lib.mkForce no;
SND_LOLA = lib.mkForce no;
SND_LX6464ES = lib.mkForce no;
SND_MAESTRO3 = lib.mkForce no;
SND_MIXART = lib.mkForce no;
SND_MPU401 = lib.mkForce no;
SND_MTS64 = lib.mkForce no;
SND_NM256 = lib.mkForce no;
SND_PCXHR = lib.mkForce no;
SND_PORTMAN2X4 = lib.mkForce no;
SND_RIPTIDE = lib.mkForce no;
SND_RME32 = lib.mkForce no;
SND_RME96 = lib.mkForce no;
SND_RME9652 = lib.mkForce no;
SND_SE6X = lib.mkForce no;
SND_TRIDENT = lib.mkForce no;
SND_VIA82XX = lib.mkForce no;
SND_VIRTUOSO = lib.mkForce no;
SND_VX222 = lib.mkForce no;
SND_YMFPCI = lib.mkForce no;
# legacy HDA codecs (kept: REALTEK for ALC269 on Framework + HDMI for amdhdmi)
SND_HDA_CODEC_ANALOG = lib.mkForce no;
SND_HDA_CODEC_SIGMATEL = lib.mkForce no;
SND_HDA_CODEC_VIA = lib.mkForce no;
SND_HDA_CODEC_CONEXANT = lib.mkForce no;
SND_HDA_CODEC_CA0110 = lib.mkForce no;
SND_HDA_CODEC_CA0132 = lib.mkForce no;
SND_HDA_CODEC_SI3054 = lib.mkForce no;
SND_HDA_CODEC_CIRRUS = lib.mkForce no;
SND_HDA_CODEC_CS420X = lib.mkForce no;
SND_HDA_CODEC_CS421X = lib.mkForce no;
SND_HDA_CODEC_CS8409 = lib.mkForce no;
# OSS compat (deprecated)
SOUND_OSS_CORE = lib.mkForce no;
# legacy USB HCDs (Zen APUs only have xHCI)
USB_OHCI_HCD = lib.mkForce no;
USB_UHCI_HCD = lib.mkForce no;
USB_C67X00_HCD = lib.mkForce no;
USB_OXU210HP_HCD = lib.mkForce no;
USB_ISP116X_HCD = lib.mkForce no;
USB_ISP1760 = lib.mkForce no;
USB_MAX3421_HCD = lib.mkForce no;
USB_SL811_HCD = lib.mkForce no;
USB_R8A66597 = lib.mkForce no;
USB_XEN_HCD = lib.mkForce no;
# USB gadget + exotic device drivers
USB_GADGET = lib.mkForce no;
USB_MICROTEK = lib.mkForce no;
USB_USS720 = lib.mkForce no;
USB_EMI26 = lib.mkForce no;
USB_EMI62 = lib.mkForce no;
USB_ADUTUX = lib.mkForce no;
USB_SEVSEG = lib.mkForce no;
USB_LEGOTOWER = lib.mkForce no;
USB_CYPRESS_CY7C63 = lib.mkForce no;
USB_CYTHERM = lib.mkForce no;
USB_IDMOUSE = lib.mkForce no;
USB_APPLEDISPLAY = lib.mkForce no;
USB_TRANCEVIBRATOR = lib.mkForce no;
USB_CHAOSKEY = lib.mkForce no;
USB_TEST = lib.mkForce no;
# USB mass-storage sub-drivers for legacy flash/camera readers
USB_STORAGE_REALTEK = lib.mkForce no;
USB_STORAGE_DATAFAB = lib.mkForce no;
USB_STORAGE_FREECOM = lib.mkForce no;
USB_STORAGE_ISD200 = lib.mkForce no;
USB_STORAGE_USBAT = lib.mkForce no;
USB_STORAGE_SDDR09 = lib.mkForce no;
USB_STORAGE_SDDR55 = lib.mkForce no;
USB_STORAGE_JUMPSHOT = lib.mkForce no;
USB_STORAGE_ALAUDA = lib.mkForce no;
USB_STORAGE_ONETOUCH = lib.mkForce no;
USB_STORAGE_KARMA = lib.mkForce no;
USB_STORAGE_CYPRESS_ATACB = lib.mkForce no;
USB_STORAGE_ENE_UB6250 = lib.mkForce no;
# wlan vendors (kept: MEDIATEK/INTEL/REALTEK/BROADCOM for mreow+yarn)
WLAN_VENDOR_ADMTEK = lib.mkForce no;
WLAN_VENDOR_ATMEL = lib.mkForce no;
WLAN_VENDOR_CISCO = lib.mkForce no;
WLAN_VENDOR_INTERSIL = lib.mkForce no;
WLAN_VENDOR_MARVELL = lib.mkForce no;
WLAN_VENDOR_MICROCHIP = lib.mkForce no;
WLAN_VENDOR_PURELIFI = lib.mkForce no;
WLAN_VENDOR_QUANTENNA = lib.mkForce no;
WLAN_VENDOR_RALINK = lib.mkForce no;
WLAN_VENDOR_RSI = lib.mkForce no;
WLAN_VENDOR_SILABS = lib.mkForce no;
WLAN_VENDOR_ST = lib.mkForce no;
WLAN_VENDOR_TI = lib.mkForce no;
WLAN_VENDOR_ZYDAS = lib.mkForce no;
# ethernet vendors (kept: AMD/INTEL/REALTEK/AQUANTIA/ATHEROS)
NET_VENDOR_3COM = lib.mkForce no;
NET_VENDOR_ADAPTEC = lib.mkForce no;
NET_VENDOR_AGERE = lib.mkForce no;
NET_VENDOR_ALACRITECH = lib.mkForce no;
NET_VENDOR_ALTEON = lib.mkForce no;
NET_VENDOR_AMAZON = lib.mkForce no;
NET_VENDOR_ARC = lib.mkForce no;
NET_VENDOR_BROADCOM = lib.mkForce no;
NET_VENDOR_BROCADE = lib.mkForce no;
NET_VENDOR_CADENCE = lib.mkForce no;
NET_VENDOR_CAVIUM = lib.mkForce no;
NET_VENDOR_CHELSIO = lib.mkForce no;
NET_VENDOR_CISCO = lib.mkForce no;
NET_VENDOR_CORTINA = lib.mkForce no;
NET_VENDOR_DAVICOM = lib.mkForce no;
NET_VENDOR_DEC = lib.mkForce no;
NET_VENDOR_DLINK = lib.mkForce no;
NET_VENDOR_EMULEX = lib.mkForce no;
NET_VENDOR_ENGLEDER = lib.mkForce no;
NET_VENDOR_EZCHIP = lib.mkForce no;
NET_VENDOR_FUJITSU = lib.mkForce no;
NET_VENDOR_FUNGIBLE = lib.mkForce no;
NET_VENDOR_GOOGLE = lib.mkForce no;
NET_VENDOR_HISILICON = lib.mkForce no;
NET_VENDOR_HUAWEI = lib.mkForce no;
NET_VENDOR_I825XX = lib.mkForce no;
NET_VENDOR_ADI = lib.mkForce no;
NET_VENDOR_LITEX = lib.mkForce no;
NET_VENDOR_MARVELL = lib.mkForce no;
NET_VENDOR_META = lib.mkForce no;
NET_VENDOR_MICREL = lib.mkForce no;
NET_VENDOR_MICROCHIP = lib.mkForce no;
NET_VENDOR_MICROSEMI = lib.mkForce no;
NET_VENDOR_MICROSOFT = lib.mkForce no;
NET_VENDOR_MUCSE = lib.mkForce no;
NET_VENDOR_MYRI = lib.mkForce no;
NET_VENDOR_NI = lib.mkForce no;
NET_VENDOR_NATSEMI = lib.mkForce no;
NET_VENDOR_NETRONOME = lib.mkForce no;
NET_VENDOR_8390 = lib.mkForce no;
NET_VENDOR_NVIDIA = lib.mkForce no;
NET_VENDOR_OKI = lib.mkForce no;
NET_VENDOR_PACKET_ENGINES = lib.mkForce no;
NET_VENDOR_PENSANDO = lib.mkForce no;
NET_VENDOR_QLOGIC = lib.mkForce no;
NET_VENDOR_QUALCOMM = lib.mkForce no;
NET_VENDOR_RDC = lib.mkForce no;
NET_VENDOR_RENESAS = lib.mkForce no;
NET_VENDOR_ROCKER = lib.mkForce no;
NET_VENDOR_SAMSUNG = lib.mkForce no;
NET_VENDOR_SEEQ = lib.mkForce no;
NET_VENDOR_SILAN = lib.mkForce no;
NET_VENDOR_SIS = lib.mkForce no;
NET_VENDOR_SOLARFLARE = lib.mkForce no;
NET_VENDOR_SMSC = lib.mkForce no;
NET_VENDOR_SOCIONEXT = lib.mkForce no;
NET_VENDOR_STMICRO = lib.mkForce no;
NET_VENDOR_SUN = lib.mkForce no;
NET_VENDOR_SYNOPSYS = lib.mkForce no;
NET_VENDOR_TEHUTI = lib.mkForce no;
NET_VENDOR_TI = lib.mkForce no;
NET_VENDOR_VERTEXCOM = lib.mkForce no;
NET_VENDOR_VIA = lib.mkForce no;
NET_VENDOR_WANGXUN = lib.mkForce no;
NET_VENDOR_WIZNET = lib.mkForce no;
NET_VENDOR_XILINX = lib.mkForce no;
NET_VENDOR_XIRCOM = lib.mkForce no;
# watchdogs (kept: SP5100_TCO for AMD chipset, WDAT_WDT for ACPI)
ACQUIRE_WDT = lib.mkForce no;
ADVANTECH_WDT = lib.mkForce no;
ADVANTECH_EC_WDT = lib.mkForce no;
ALIM1535_WDT = lib.mkForce no;
ALIM7101_WDT = lib.mkForce no;
CGBC_WDT = lib.mkForce no;
EBC_C384_WDT = lib.mkForce no;
EXAR_WDT = lib.mkForce no;
F71808E_WDT = lib.mkForce no;
EUROTECH_WDT = lib.mkForce no;
IB700_WDT = lib.mkForce no;
WAFER_WDT = lib.mkForce no;
I6300ESB_WDT = lib.mkForce no;
IE6XX_WDT = lib.mkForce no;
ITCO_WDT = lib.mkForce no;
IT8712F_WDT = lib.mkForce no;
IT87_WDT = lib.mkForce no;
HP_WATCHDOG = lib.mkForce no;
HPWDT_NMI_DECODE = lib.mkForce no;
KEMPLD_WDT = lib.mkForce no;
MLX_WDT = lib.mkForce no;
NI903X_WDT = lib.mkForce no;
NIC7018_WDT = lib.mkForce no;
SMSC37B787_WDT = lib.mkForce no;
TQMX86_WDT = lib.mkForce no;
VIA_WDT = lib.mkForce no;
W83627HF_WDT = lib.mkForce no;
W83877F_WDT = lib.mkForce no;
W83977F_WDT = lib.mkForce no;
MACHZ_WDT = lib.mkForce no;
SBC_EPX_C3_WATCHDOG = lib.mkForce no;
MEN_A21_WDT = lib.mkForce no;
DW_WATCHDOG = lib.mkForce no;
SOFT_WATCHDOG = lib.mkForce no;
XILINX_WATCHDOG = lib.mkForce no;
# misc dead weight
BLK_DEV_DRBD = lib.mkForce no;
GREYBUS = lib.mkForce no;
SOUNDWIRE_QCOM = lib.mkForce no;
SOUNDWIRE_INTEL = lib.mkForce no;
MEDIA_RADIO_SUPPORT = lib.mkForce no;
# net queue disciplines not used on desktop (kept: htb/prio/fifo/fq/fq_codel/cake/bpf/ingress/netem/tbf/mqprio for basic shaping + testing)
NET_SCH_CBS = lib.mkForce no;
NET_SCH_CHOKE = lib.mkForce no;
NET_SCH_CODEL = lib.mkForce no;
NET_SCH_DRR = lib.mkForce no;
NET_SCH_DUALPI2 = lib.mkForce no;
NET_SCH_ETF = lib.mkForce no;
NET_SCH_ETS = lib.mkForce no;
NET_SCH_FQ_PIE = lib.mkForce no;
NET_SCH_GRED = lib.mkForce no;
NET_SCH_HFSC = lib.mkForce no;
NET_SCH_HHF = lib.mkForce no;
NET_SCH_MULTIQ = lib.mkForce no;
NET_SCH_PIE = lib.mkForce no;
NET_SCH_PLUG = lib.mkForce no;
NET_SCH_QFQ = lib.mkForce no;
NET_SCH_RED = lib.mkForce no;
NET_SCH_SFB = lib.mkForce no;
NET_SCH_SFQ = lib.mkForce no;
NET_SCH_SKBPRIO = lib.mkForce no;
NET_SCH_TAPRIO = lib.mkForce no;
NET_SCH_TEQL = lib.mkForce no;
# battery charger PMIC drivers — all mobile/embedded SoCs, none of these
# exist on x86 laptops/desktops (which use ACPI battery + USB-PD via ucsi).
# CROS_* are Chromebook-specific; Framework has CrOS EC but not CrOS charging.
CHARGER_88PM860X = lib.mkForce no;
CHARGER_ADP5061 = lib.mkForce no;
CHARGER_AXP20X = lib.mkForce no;
CHARGER_BD71828 = lib.mkForce no;
CHARGER_BD99954 = lib.mkForce no;
CHARGER_BQ2415X = lib.mkForce no;
CHARGER_BQ24190 = lib.mkForce no;
CHARGER_BQ24257 = lib.mkForce no;
CHARGER_BQ24735 = lib.mkForce no;
CHARGER_BQ2515X = lib.mkForce no;
CHARGER_BQ256XX = lib.mkForce no;
CHARGER_BQ257XX = lib.mkForce no;
CHARGER_BQ25890 = lib.mkForce no;
CHARGER_BQ25980 = lib.mkForce no;
CHARGER_CROS_CONTROL = lib.mkForce no;
CHARGER_CROS_PCHG = lib.mkForce no;
CHARGER_CROS_USBPD = lib.mkForce no;
CHARGER_DA9150 = lib.mkForce no;
CHARGER_DETECTOR_MAX14656 = lib.mkForce no;
CHARGER_GPIO = lib.mkForce no;
CHARGER_ISP1704 = lib.mkForce no;
CHARGER_LP8727 = lib.mkForce no;
CHARGER_LP8788 = lib.mkForce no;
CHARGER_LT3651 = lib.mkForce no;
CHARGER_LTC4162L = lib.mkForce no;
CHARGER_MANAGER = lib.mkForce no;
CHARGER_MAX14577 = lib.mkForce no;
CHARGER_MAX77650 = lib.mkForce no;
CHARGER_MAX77693 = lib.mkForce no;
CHARGER_MAX77705 = lib.mkForce no;
CHARGER_MAX77976 = lib.mkForce no;
CHARGER_MAX8903 = lib.mkForce no;
CHARGER_MAX8971 = lib.mkForce no;
CHARGER_MAX8997 = lib.mkForce no;
CHARGER_MAX8998 = lib.mkForce no;
CHARGER_MP2629 = lib.mkForce no;
CHARGER_MT6360 = lib.mkForce no;
CHARGER_MT6370 = lib.mkForce no;
CHARGER_PF1550 = lib.mkForce no;
CHARGER_RK817 = lib.mkForce no;
CHARGER_RT5033 = lib.mkForce no;
CHARGER_RT9455 = lib.mkForce no;
CHARGER_RT9467 = lib.mkForce no;
CHARGER_RT9471 = lib.mkForce no;
CHARGER_RT9756 = lib.mkForce no;
CHARGER_SBS = lib.mkForce no;
CHARGER_SMB347 = lib.mkForce no;
CHARGER_TPS65090 = lib.mkForce no;
CHARGER_TPS65217 = lib.mkForce no;
CHARGER_TWL4030 = lib.mkForce no;
CHARGER_TWL6030 = lib.mkForce no;
CHARGER_UCS1002 = lib.mkForce no;
CHARGER_WILCO = lib.mkForce no;
# enterprise storage stack (kept: DM_CRYPT for LUKS, DM_SNAPSHOT/INTEGRITY/VERITY, MD_RAID0/1/10/456 in case)
DM_MULTIPATH = lib.mkForce no;
DM_MULTIPATH_QL = lib.mkForce no;
DM_MULTIPATH_ST = lib.mkForce no;
DM_MULTIPATH_HST = lib.mkForce no;
DM_MULTIPATH_IOA = lib.mkForce no;
DM_VDO = lib.mkForce no;
DM_PCACHE = lib.mkForce no;
DM_ZONED = lib.mkForce no;
DM_LOG_USERSPACE = lib.mkForce no;
DM_EBS = lib.mkForce no;
DM_ERA = lib.mkForce no;
DM_DUST = lib.mkForce no;
DM_DELAY = lib.mkForce no;
DM_FLAKEY = lib.mkForce no;
DM_SWITCH = lib.mkForce no;
DM_LOG_WRITES = lib.mkForce no;
DM_CLONE = lib.mkForce no;
DM_UNSTRIPED = lib.mkForce no;
DM_CACHE = lib.mkForce no;
DM_WRITECACHE = lib.mkForce no;
DM_THIN_PROVISIONING = lib.mkForce no;
MD_CLUSTER = lib.mkForce no;
MD_LINEAR = lib.mkForce no;
SCSI_DH_RDAC = lib.mkForce no;
SCSI_DH_HP_SW = lib.mkForce no;
SCSI_ENCLOSURE = lib.mkForce no;
};
}
];
@@ -337,12 +814,6 @@
"msr"
"btusb"
];
kernelParams = [
# 1gb huge pages
"hugepagesz=1G"
"hugepages=3"
];
};
services = {
@@ -381,9 +852,6 @@
};
};
# EST
time.timeZone = "America/New_York";
# Select internationalisation properties.
i18n.defaultLocale = "en_US.UTF-8";
@@ -419,8 +887,7 @@
"camera"
"adbusers"
];
# TODO! this is really bad :( I should really figure out how to do proper secrets management
hashedPasswordFile = "${../secrets/desktop/password-hash}";
hashedPasswordFile = config.age.secrets.password-hash.path;
};
services.gvfs.enable = true;

View File

@@ -0,0 +1,40 @@
# Jovian-NixOS deck-mode configuration shared by all hosts running Steam
# in gamescope (yarn, patiodeck). Host-specific settings (like
# jovian.devices.steamdeck.enable) stay in the host's default.nix.
{
lib,
username,
inputs,
...
}:
{
imports = [
./desktop-steam-update.nix
inputs.jovian-nixos.nixosModules.default
];
nixpkgs.config.allowUnfreePredicate =
pkg:
builtins.elem (lib.getName pkg) [
"steamdeck-hw-theme"
"steam-jupiter-unwrapped"
"steam"
"steam-original"
"steam-unwrapped"
"steam-run"
];
jovian.steam = {
enable = true;
autoStart = true;
desktopSession = "niri";
user = username;
};
# jovian overrides the display manager; sddm is required
services.displayManager.sddm.wayland.enable = true;
# desktop-common.nix enables programs.gamescope which conflicts with
# jovian's own gamescope wrapper
programs.gamescope.enable = lib.mkForce false;
}

View File

@@ -0,0 +1,49 @@
{
config,
lib,
pkgs,
inputs,
...
}:
{
imports = [
inputs.lanzaboote.nixosModules.lanzaboote
];
boot = {
loader.systemd-boot.enable = lib.mkForce false;
lanzaboote = {
enable = true;
# sbctl expects the bundle at /var/lib/sbctl; muffin uses /etc/secureboot
# because it is wiped on every activation there (impermanence) — desktops
# extract to the historical sbctl path so existing tooling keeps working.
pkiBundle = "/var/lib/sbctl";
};
};
system.activationScripts = {
# Extract the secureboot PKI bundle from the agenix-decrypted tar. Mirrors
# modules/server-lanzaboote-agenix.nix; skip when keys are already present
# (e.g., disko-install staged them via --extra-files).
"secureboot-keys" = {
deps = [ "agenix" ];
text = ''
#!/bin/sh
(
umask 077
if [[ -d ${config.boot.lanzaboote.pkiBundle} && -f ${config.boot.lanzaboote.pkiBundle}/db.key ]]; then
echo "secureboot keys already present, skipping extraction"
else
echo "extracting secureboot keys from agenix"
rm -fr ${config.boot.lanzaboote.pkiBundle} || true
install -d -o root -g wheel -m 0500 ${config.boot.lanzaboote.pkiBundle}
${pkgs.gnutar}/bin/tar xf ${config.age.secrets.secureboot-tar.path} -C ${config.boot.lanzaboote.pkiBundle}
fi
chown -R root:wheel ${config.boot.lanzaboote.pkiBundle}
chmod -R 500 ${config.boot.lanzaboote.pkiBundle}
)
'';
};
};
}

View File

@@ -0,0 +1,122 @@
# Binary-cache update mechanism for Jovian-NixOS desktops.
#
# Replaces the upstream holo-update/steamos-update stubs with a script that
# checks the private binary cache for a newer system closure, and provides a
# root-level systemd service to apply it. Steam's deck UI calls
# `steamos-update check` periodically; exit 7 = no update, exit 0 = update
# applied or available.
#
# The deploy endpoint is ${binary_cache_url}/deploy/${hostname} — a plain
# text file containing the /nix/store path of the latest closure, published
# by CI after a successful build.
{
pkgs,
lib,
hostname,
username,
site_config,
...
}:
let
deploy-url = "${site_config.binary_cache.url}/deploy/${hostname}";
steamos-update-script = pkgs.writeShellScript "steamos-update" ''
export PATH=${
lib.makeBinPath [
pkgs.curl
pkgs.coreutils
pkgs.systemd
]
}
STORE_PATH=$(curl -sf --max-time 30 "${deploy-url}" || true)
if [ -z "$STORE_PATH" ]; then
>&2 echo "[steamos-update] server unreachable"
exit 7
fi
CURRENT=$(readlink -f /nix/var/nix/profiles/system)
if [ "$CURRENT" = "$STORE_PATH" ]; then
>&2 echo "[steamos-update] no update available"
exit 7
fi
# check-only mode: just report that an update exists
if [ "''${1:-}" = "check" ] || [ "''${1:-}" = "--check-only" ]; then
>&2 echo "[steamos-update] update available"
exit 0
fi
# apply: trigger the root-running systemd service to install the update
>&2 echo "[steamos-update] applying update..."
if systemctl start --wait pull-update-apply.service; then
>&2 echo "[steamos-update] update installed, reboot to apply"
exit 0
else
>&2 echo "[steamos-update] apply failed; see 'journalctl -u pull-update-apply'"
exit 1
fi
'';
in
{
nixpkgs.overlays = [
(_final: prev: {
jovian-stubs = prev.jovian-stubs.overrideAttrs (old: {
buildCommand = (old.buildCommand or "") + ''
install -D -m 755 ${steamos-update-script} $out/bin/holo-update
install -D -m 755 ${steamos-update-script} $out/bin/steamos-update
'';
});
})
];
systemd.services.pull-update-apply = {
description = "Apply pending NixOS update pulled from binary cache";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "pull-update-apply" ''
set -uo pipefail
export PATH=${
lib.makeBinPath [
pkgs.curl
pkgs.coreutils
pkgs.nix
]
}
STORE_PATH=$(curl -sf --max-time 30 "${deploy-url}" || true)
if [ -z "$STORE_PATH" ]; then
echo "server unreachable"
exit 1
fi
CURRENT=$(readlink -f /nix/var/nix/profiles/system)
if [ "$CURRENT" = "$STORE_PATH" ]; then
echo "already up to date: $STORE_PATH"
exit 0
fi
echo "applying $STORE_PATH (was $CURRENT)"
nix-store -r --add-root /nix/var/nix/gcroots/pull-update-apply-latest --indirect "$STORE_PATH" \
|| { echo "fetch failed"; exit 1; }
nix-env -p /nix/var/nix/profiles/system --set "$STORE_PATH" \
|| { echo "profile set failed"; exit 1; }
"$STORE_PATH/bin/switch-to-configuration" boot \
|| { echo "boot entry failed"; exit 1; }
echo "update applied; reboot required"
'';
};
};
# allow the primary user to trigger pull-update-apply without a password
security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "pull-update-apply.service" &&
subject.user == "${username}") {
return polkit.Result.YES;
}
});
'';
}

View File

@@ -0,0 +1,187 @@
# Deferred deploy finalize for deploy-rs-driven hosts.
#
# When deploy-rs activates via `switch-to-configuration switch` and the gitea-
# actions runner driving the deploy lives on the same host, the runner unit
# gets restarted mid-activation — its definition changes between builds. That
# restart kills the SSH session, the CI job, and deploy-rs's magic-rollback
# handshake, so CI reports failure even when the deploy itself completed.
# This is deploy-rs#153, open since 2022.
#
# This module breaks the dependency: activation does `switch-to-configuration
# boot` (bootloader only, no service restarts), then invokes deploy-finalize
# which schedules a detached systemd transient unit that fires `delay` seconds
# later with the real `switch` (or `systemctl reboot` when the kernel, initrd,
# or kernel-modules changed since boot). The transient unit is owned by pid1,
# so it survives the runner's eventual restart — by which time the CI job has
# finished reporting.
#
# Prior art (reboot-or-switch logic, not the self-deploy detachment):
# - nixpkgs `system.autoUpgrade` (allowReboot = true branch) is the canonical
# source of the 3-path {initrd,kernel,kernel-modules} comparison.
# - obsidiansystems/obelisk#957 merged the same snippet into `ob deploy` for
# push-based remote deploys — but doesn't need detachment since its deployer
# lives on a different machine from the target.
# - nixpkgs#185030 tracks lifting this into switch-to-configuration proper.
# Stale since 2025-07; until it lands, every downstream reimplements it.
#
# Bootstrap note: the activation snippet resolves deploy-finalize via
# lib.getExe (store path), not via `/run/current-system/sw/bin` — `boot` mode
# does not update `/run/current-system`, so the old binary would be resolved.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.deployFinalize;
finalize = pkgs.writeShellApplication {
name = "deploy-finalize";
runtimeInputs = [
pkgs.coreutils
pkgs.systemd
];
text = ''
delay=${toString cfg.delay}
profile=/nix/var/nix/profiles/system
dry_run=0
usage() {
cat <<EOF
Usage: deploy-finalize [--dry-run] [--delay N] [--profile PATH]
Compares /run/booted-system against PATH (default /nix/var/nix/profiles/system)
and schedules either \`systemctl reboot\` (kernel or initrd changed) or
\`switch-to-configuration switch\` (services only) via a detached systemd-run
timer firing N seconds later.
Options:
--dry-run Print the decision and would-be command without scheduling.
--delay N Override the delay in seconds. Default: ${toString cfg.delay}.
--profile PATH Override the profile path used for comparison.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) dry_run=1; shift ;;
--delay) delay="$2"; shift 2 ;;
--profile) profile="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*)
echo "deploy-finalize: unknown option $1" >&2
usage >&2
exit 2
;;
esac
done
# Comparing {kernel,initrd,kernel-modules} matches nixpkgs's canonical
# `system.autoUpgrade` allowReboot logic. -e (not -f) so a dangling
# symlink counts as missing: on a real NixOS profile all three exist,
# but defensive: if a profile has bad symlinks we refuse to schedule
# rather than scheduling against ghost paths.
booted_kernel="$(readlink -e /run/booted-system/kernel 2>/dev/null || true)"
booted_initrd="$(readlink -e /run/booted-system/initrd 2>/dev/null || true)"
booted_modules="$(readlink -e /run/booted-system/kernel-modules 2>/dev/null || true)"
new_kernel="$(readlink -e "$profile/kernel" 2>/dev/null || true)"
new_initrd="$(readlink -e "$profile/initrd" 2>/dev/null || true)"
new_modules="$(readlink -e "$profile/kernel-modules" 2>/dev/null || true)"
if [[ -z "$new_kernel" || -z "$new_initrd" || -z "$new_modules" ]]; then
echo "deploy-finalize: refusing to schedule $profile is missing kernel, initrd, or kernel-modules" >&2
exit 1
fi
changed=()
if [[ -z "$booted_kernel" || -z "$booted_initrd" || -z "$booted_modules" ]]; then
# Unreachable on a booted NixOS, but fail closed on reboot.
changed+=("/run/booted-system incomplete")
fi
[[ "$booted_kernel" != "$new_kernel" ]] && changed+=("kernel")
[[ "$booted_initrd" != "$new_initrd" ]] && changed+=("initrd")
[[ "$booted_modules" != "$new_modules" ]] && changed+=("kernel-modules")
reboot_needed=0
reason=""
if [[ ''${#changed[@]} -gt 0 ]]; then
reboot_needed=1
# Join with commas so the reason reads as e.g. `kernel,initrd changed`.
reason="$(IFS=, ; echo "''${changed[*]}") changed"
fi
if [[ "$reboot_needed" == 1 ]]; then
action=reboot
cmd="systemctl reboot"
else
action=switch
reason="services only"
cmd="$profile/bin/switch-to-configuration switch"
fi
# Nanosecond suffix so back-to-back deploys don't collide on unit names.
unit="deploy-finalize-$(date +%s%N)"
printf 'deploy-finalize: booted_kernel=%s\n' "$booted_kernel"
printf 'deploy-finalize: new_kernel=%s\n' "$new_kernel"
printf 'deploy-finalize: booted_initrd=%s\n' "$booted_initrd"
printf 'deploy-finalize: new_initrd=%s\n' "$new_initrd"
printf 'deploy-finalize: booted_kernel-modules=%s\n' "$booted_modules"
printf 'deploy-finalize: new_kernel-modules=%s\n' "$new_modules"
printf 'deploy-finalize: action=%s reason=%s delay=%ss unit=%s\n' \
"$action" "$reason" "$delay" "$unit"
if [[ "$dry_run" == 1 ]]; then
printf 'deploy-finalize: dry-run not scheduling\n'
printf 'deploy-finalize: would run: %s\n' "$cmd"
printf 'deploy-finalize: would schedule: systemd-run --collect --unit=%s --on-active=%s\n' \
"$unit" "$delay"
exit 0
fi
# Cancel any still-pending finalize timers from an earlier deploy so this
# invocation is authoritative. Without this a stale timer could fire with
# the old profile's action (reboot/switch) against the new profile and
# briefly run new userspace under the old kernel.
systemctl stop 'deploy-finalize-*.timer' 2>/dev/null || true
# --on-active arms a transient timer owned by pid1. systemd-run returns
# once the timer is armed; the SSH session that called us can exit and
# the gitea-runner can be restarted (by the switch the timer fires)
# without affecting whether the finalize runs.
systemd-run \
--collect \
--unit="$unit" \
--description="Finalize NixOS deploy ($action after boot-mode activation)" \
--on-active="$delay" \
/bin/sh -c "$cmd"
'';
};
in
{
options.services.deployFinalize = {
enable = lib.mkEnableOption "deferred deploy finalize (switch or reboot) after boot-mode activation";
delay = lib.mkOption {
type = lib.types.ints.positive;
default = 60;
description = ''
Seconds between the deploy-rs activation completing and the scheduled
finalize firing. Tuned so the CI job (or manual SSH session) has time
to complete status reporting before the runner is restarted by the
eventual switch-to-configuration.
'';
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ finalize ];
# Exposed for the deploy-rs activation snippet to reference by /nix/store
# path via lib.getExe — `boot` mode does not update /run/current-system,
# so reading through /run/current-system/sw/bin would resolve to the OLD
# binary on a new-feature rollout or immediately after a rollback.
system.build.deployFinalize = finalize;
};
}

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Bootstrap the age-plugin-tpm identity for a desktop host (mreow / yarn).
#
# Produces a TPM-sealed age identity at /var/lib/agenix/tpm-identity and
# prints the recipient string to add to secrets/secrets.nix.
#
# Usage:
# doas scripts/bootstrap-desktop-tpm.sh
#
# After running:
# 1. Append the printed recipient to the `tpm` list in secrets/secrets.nix.
# 2. Re-encrypt: nix-shell -p age-plugin-tpm rage --run \
# 'agenix -r -i ~/.ssh/id_ed25519'
# 3. Commit + ./deploy.sh switch.
set -euo pipefail
if [[ $EUID -ne 0 ]]; then
echo "this script must run as root (access to /dev/tpmrm0 + /var/lib/agenix)" >&2
exit 1
fi
host=$(hostname -s)
id_file=/var/lib/agenix/tpm-identity
install -d -m 0700 -o root -g root /var/lib/agenix
if [[ -f "$id_file" ]]; then
echo "existing identity found at $id_file — preserving"
else
echo "generating TPM-sealed age identity..."
nix-shell -p age-plugin-tpm --run "age-plugin-tpm --generate -o $id_file"
chmod 0400 "$id_file"
chown root:root "$id_file"
fi
# Read the recipient directly from the identity file header — no TPM
# round-trip needed, no nix run, no set -e hazards.
recipient=$(grep '^# Recipient:' "$id_file" | awk '{print $3}')
if [[ -z "$recipient" ]]; then
echo "failed to read recipient from $id_file" >&2
exit 1
fi
cat <<EOF
recipient for $host:
"$recipient $host"
next steps (run on a workstation with git-crypt unlocked):
1. edit secrets/secrets.nix and add the line above to the \`tpm\` list.
2. re-encrypt: nix-shell -p age-plugin-tpm rage --run 'agenix -r -i ~/.ssh/id_ed25519'
3. git commit + ./deploy.sh switch
EOF

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
secrets/secrets.nix Normal file

Binary file not shown.

View File

@@ -2,6 +2,7 @@
config,
lib,
pkgs,
site_config,
service_configs,
...
}:
@@ -25,7 +26,7 @@
configurePostgres = true;
config = {
# Refer to https://github.com/dani-garcia/vaultwarden/blob/main/.env.template
DOMAIN = "https://bitwarden.${service_configs.https.domain}";
DOMAIN = "https://bitwarden.${site_config.domain}";
SIGNUPS_ALLOWED = false;
ROCKET_ADDRESS = "127.0.0.1";
@@ -34,7 +35,7 @@
};
};
services.caddy.virtualHosts."bitwarden.${service_configs.https.domain}".extraConfig = ''
services.caddy.virtualHosts."bitwarden.${site_config.domain}".extraConfig = ''
encode zstd gzip
reverse_proxy :${toString config.services.vaultwarden.config.ROCKET_PORT} {

View File

@@ -1,5 +1,6 @@
{
config,
site_config,
service_configs,
pkgs,
lib,
@@ -42,8 +43,8 @@ let
'';
};
newDomain = service_configs.https.domain;
oldDomain = service_configs.https.old_domain;
newDomain = site_config.domain;
oldDomain = site_config.old_domain;
in
{
imports = [
@@ -54,7 +55,7 @@ in
services.caddy = {
enable = true;
email = "titaniumtown@proton.me";
email = site_config.contact_email;
# Build with Njalla DNS provider for DNS-01 ACME challenges (wildcard certs)
package = pkgs.caddy.withPlugins {
@@ -146,8 +147,9 @@ in
# defaults: maxretry=5, findtime=10m, bantime=10m
# Ignore local network IPs - NAT hairpinning causes all LAN traffic to
# appear from the router IP (192.168.1.1). Banning it blocks all internal access.
ignoreip = "127.0.0.1/8 ::1 192.168.1.0/24";
# appear from the router IP (site_config.lan.gateway). Banning it
# blocks all internal access.
ignoreip = "127.0.0.1/8 ::1 ${site_config.lan.cidr}";
};
filter.Definition = {
# Only match 401s where an Authorization header was actually sent.

View File

@@ -2,6 +2,7 @@
config,
lib,
pkgs,
site_config,
service_configs,
inputs,
...
@@ -32,7 +33,7 @@ let
};
in
{
services.caddy.virtualHosts."senior-project.${service_configs.https.domain}".extraConfig = ''
services.caddy.virtualHosts."senior-project.${site_config.domain}".extraConfig = ''
root * ${hugoWebsite}
file_server browse
'';

View File

@@ -49,6 +49,32 @@
};
};
# Hide repo Actions/workflow details from anonymous visitors. Gitea's own
# REQUIRE_SIGNIN_VIEW=expensive does not cover /{user}/{repo}/actions, and
# the API auth chain (routers/api/v1/api.go buildAuthGroup) deliberately
# omits `auth_service.Session`, so an /api/v1/user probe would 401 even
# for logged-in browser sessions. We gate at Caddy instead: forward_auth
# probes a lightweight *web-UI* endpoint that does accept session cookies,
# and Gitea's own reqSignIn middleware answers 303 to /user/login for
# anonymous callers which we rewrite to preserve the original URL.
# Workflow status badges stay public so README links keep rendering.
services.caddy.virtualHosts.${service_configs.gitea.domain}.extraConfig = ''
@repoActionsNotBadge {
path_regexp ^/[^/]+/[^/]+/actions(/.*)?$
not path_regexp ^/[^/]+/[^/]+/actions/workflows/[^/]+/badge\.svg$
}
handle @repoActionsNotBadge {
forward_auth :${toString service_configs.ports.private.gitea.port} {
uri /user/stopwatches
@unauthorized status 302 303
handle_response @unauthorized {
redir * /user/login?redirect_to={uri} 302
}
}
}
'';
services.postgresql = {
ensureDatabases = [ config.services.gitea.user ];
ensureUsers = [

View File

@@ -687,6 +687,188 @@ let
overrides = [ ];
};
}
# -- Row 6: Minecraft --
{
id = 14;
type = "stat";
title = "Minecraft Players";
gridPos = {
h = 8;
w = 6;
x = 0;
y = 40;
};
datasource = promDs;
targets = [
{
datasource = promDs;
expr = "sum(minecraft_status_players_online_count) or vector(0)";
refId = "A";
}
];
fieldConfig = {
defaults = {
thresholds = {
mode = "absolute";
steps = [
{
color = "green";
value = null;
}
{
color = "yellow";
value = 3;
}
{
color = "red";
value = 6;
}
];
};
};
overrides = [ ];
};
options = {
reduceOptions = {
calcs = [ "lastNotNull" ];
fields = "";
values = false;
};
colorMode = "value";
graphMode = "area";
};
}
{
id = 15;
type = "stat";
title = "Minecraft Server";
gridPos = {
h = 8;
w = 6;
x = 6;
y = 40;
};
datasource = promDs;
targets = [
{
datasource = promDs;
expr = "max(minecraft_status_healthy) or vector(0)";
refId = "A";
}
];
fieldConfig = {
defaults = {
mappings = [
{
type = "value";
options = {
"0" = {
text = "Offline";
color = "red";
index = 0;
};
"1" = {
text = "Online";
color = "green";
index = 1;
};
};
}
];
thresholds = {
mode = "absolute";
steps = [
{
color = "red";
value = null;
}
{
color = "green";
value = 1;
}
];
};
};
overrides = [ ];
};
options = {
reduceOptions = {
calcs = [ "lastNotNull" ];
fields = "";
values = false;
};
colorMode = "value";
graphMode = "none";
};
}
{
id = 16;
type = "timeseries";
title = "Minecraft Player Activity";
gridPos = {
h = 8;
w = 12;
x = 12;
y = 40;
};
datasource = promDs;
targets = [
{
datasource = promDs;
expr = "sum(minecraft_status_players_online_count) or vector(0)";
legendFormat = "Online players";
refId = "A";
}
{
datasource = promDs;
expr = "max(minecraft_status_players_max_count) or vector(0)";
legendFormat = "Max players";
refId = "B";
}
];
fieldConfig = {
defaults = {
unit = "short";
min = 0;
decimals = 0;
color.mode = "palette-classic";
custom = {
lineWidth = 2;
fillOpacity = 15;
spanNulls = true;
};
};
overrides = [
{
matcher = {
id = "byFrameRefID";
options = "B";
};
properties = [
{
id = "custom.lineStyle";
value = {
fill = "dash";
dash = [
8
4
];
};
}
{
id = "custom.fillOpacity";
value = 0;
}
{
id = "custom.lineWidth";
value = 1;
}
];
}
];
};
}
];
};
in

View File

@@ -10,6 +10,9 @@ let
jellyfinExporterPort = service_configs.ports.private.jellyfin_exporter.port;
qbitExporterPort = service_configs.ports.private.qbittorrent_exporter.port;
igpuExporterPort = service_configs.ports.private.igpu_exporter.port;
minecraftExporterPort = service_configs.ports.private.minecraft_exporter.port;
minecraftServerName = service_configs.minecraft.server_name;
minecraftServerPort = service_configs.ports.public.minecraft.port;
in
{
# -- Jellyfin Prometheus Exporter --
@@ -109,4 +112,45 @@ in
REFRESH_PERIOD_MS = "30000";
};
};
# -- Minecraft Prometheus Exporter --
# itzg/mc-monitor queries the local server via SLP on each scrape and exposes
# minecraft_status_{healthy,response_time_seconds,players_online_count,players_max_count}.
# mc-monitor binds to 0.0.0.0 (no listen-address flag); the firewall keeps
# 9567 internal and IPAddressAllow pins the socket to loopback as defense-in-depth.
systemd.services.minecraft-exporter =
lib.mkIf (config.services.grafana.enable && config.services.minecraft-servers.enable)
{
description = "Prometheus exporter for Minecraft (mc-monitor SLP)";
after = [
"network.target"
"minecraft-server-${minecraftServerName}.service"
];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${lib.getExe pkgs.mc-monitor} export-for-prometheus";
Restart = "on-failure";
RestartSec = "10s";
DynamicUser = true;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
MemoryDenyWriteExecute = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
IPAddressAllow = [
"127.0.0.0/8"
"::1/128"
];
IPAddressDeny = "any";
};
environment = {
EXPORT_SERVERS = "127.0.0.1:${toString minecraftServerPort}";
EXPORT_PORT = toString minecraftExporterPort;
TIMEOUT = "5s";
};
};
}

View File

@@ -95,6 +95,12 @@ in
{ targets = [ "127.0.0.1:${toString service_configs.ports.private.igpu_exporter.port}" ]; }
];
}
{
job_name = "minecraft";
static_configs = [
{ targets = [ "127.0.0.1:${toString service_configs.ports.private.minecraft_exporter.port}" ]; }
];
}
{
job_name = "zfs";
static_configs = [

View File

@@ -1,4 +1,5 @@
{
site_config,
service_configs,
inputs,
pkgs,
@@ -9,7 +10,7 @@ let
inputs.ytbn-graphing-software.packages.${pkgs.stdenv.targetPlatform.system}.web;
in
{
services.caddy.virtualHosts."graphing.${service_configs.https.domain}".extraConfig = ''
services.caddy.virtualHosts."graphing.${site_config.domain}".extraConfig = ''
root * ${graphing-calculator}
file_server browse
'';

View File

@@ -1,6 +1,7 @@
{
config,
lib,
site_config,
service_configs,
...
}:
@@ -19,7 +20,7 @@
# serve latest deploy store paths (unauthenticated — just a path string)
# CI writes to /var/lib/nix-deploy/<hostname> after building
services.caddy.virtualHosts."nix-cache.${service_configs.https.domain}".extraConfig = ''
services.caddy.virtualHosts."nix-cache.${site_config.domain}".extraConfig = ''
handle_path /deploy/* {
root * /var/lib/nix-deploy
file_server

View File

@@ -1,6 +1,7 @@
{
pkgs,
config,
site_config,
service_configs,
lib,
...
@@ -24,7 +25,7 @@
inherit (service_configs.jellyfin) dataDir cacheDir;
};
services.caddy.virtualHosts."jellyfin.${service_configs.https.domain}".extraConfig = ''
services.caddy.virtualHosts."jellyfin.${site_config.domain}".extraConfig = ''
reverse_proxy :${builtins.toString service_configs.ports.private.jellyfin.port} {
# Disable response buffering for streaming. Caddy's default partial
# buffering delays fMP4-HLS segments and direct-play responses where

View File

@@ -1,5 +1,6 @@
{
pkgs,
site_config,
service_configs,
config,
inputs,
@@ -24,7 +25,7 @@ in
# "Invalid API Key" warning has no client IP, and behind Caddy the
# llama-server access log only sees 127.0.0.1. Caddy's JSON log has
# the real client IP via request.remote_ip.
services.caddy.virtualHosts."llm.${service_configs.https.domain}".extraConfig = ''
services.caddy.virtualHosts."llm.${site_config.domain}".extraConfig = ''
log {
output file /var/log/caddy/access-llama-cpp.log
format json
@@ -52,8 +53,8 @@ in
# defaults: maxretry=5, findtime=10m, bantime=10m
# NAT hairpinning sends LAN traffic via the router IP. Don't ban
# 192.168.1.0/24 or we lock ourselves out.
ignoreip = "127.0.0.1/8 ::1 192.168.1.0/24";
# our LAN or we lock ourselves out.
ignoreip = "127.0.0.1/8 ::1 ${site_config.lan.cidr}";
};
filter.Definition = {
failregex = ''^.*"remote_ip":"<HOST>".*"status":401.*$'';

View File

@@ -1,13 +1,14 @@
{
config,
lib,
site_config,
service_configs,
...
}:
{
services.coturn = {
enable = true;
realm = service_configs.https.domain;
realm = site_config.domain;
use-auth-secret = true;
static-auth-secret-file = config.age.secrets.coturn-auth-secret.path;
listening-port = service_configs.ports.public.coturn.port;

View File

@@ -1,5 +1,6 @@
{
config,
site_config,
service_configs,
lib,
...
@@ -23,7 +24,7 @@
settings.global = {
port = [ service_configs.ports.private.matrix.port ];
server_name = service_configs.https.domain;
server_name = site_config.domain;
allow_registration = true;
registration_token_file = config.age.secrets.matrix-reg-token.path;
@@ -43,14 +44,14 @@
# TURN server config (coturn)
turn_secret_file = config.age.secrets.matrix-turn-secret.path;
turn_uris = [
"turn:${service_configs.https.domain}?transport=udp"
"turn:${service_configs.https.domain}?transport=tcp"
"turn:${site_config.domain}?transport=udp"
"turn:${site_config.domain}?transport=tcp"
];
turn_ttl = 86400;
};
};
services.caddy.virtualHosts.${service_configs.https.domain}.extraConfig = lib.mkBefore ''
services.caddy.virtualHosts.${site_config.domain}.extraConfig = lib.mkBefore ''
header /.well-known/matrix/* Content-Type application/json
header /.well-known/matrix/* Access-Control-Allow-Origin *
respond /.well-known/matrix/server `{"m.server": "${service_configs.matrix.domain}:${builtins.toString service_configs.ports.public.https.port}"}`

View File

@@ -1,5 +1,6 @@
{
pkgs,
site_config,
service_configs,
lib,
config,
@@ -177,7 +178,7 @@
};
services.caddy.virtualHosts = lib.mkIf (config.services.caddy.enable) {
"map.${service_configs.https.domain}".extraConfig = ''
"map.${site_config.domain}".extraConfig = ''
root * ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap/web
file_server browse
'';

View File

@@ -2,6 +2,7 @@
config,
lib,
pkgs,
site_config,
username,
...
}:
@@ -25,14 +26,13 @@
];
users.users.${username}.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO4jL6gYOunUlUtPvGdML0cpbKSsPNqQ1jit4E7U1RyH" # laptop
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBJjT5QZ3zRDb+V6Em20EYpSEgPW5e/U+06uQGJdraxi" # desktop
site_config.ssh_keys.laptop
];
# used for deploying configs to server
users.users.root.openssh.authorizedKeys.keys =
config.users.users.${username}.openssh.authorizedKeys.keys
++ [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC5ZYN6idL/w/mUIfPOH1i+Q/SQXuzAMQUEuWpipx1Pc ci-deploy@muffin"
site_config.ssh_keys.ci_deploy
];
}

62
site-config.nix Normal file
View File

@@ -0,0 +1,62 @@
# Site-wide constants shared across all three hosts and home-manager profiles.
#
# This file is pure data — no package refs, no module config. Import it from
# flake.nix and pass it as the `site_config` specialArg (and extraSpecialArg for
# home-manager). Callers read values; they do not set them.
#
# Adding a value: only add if it's used by ≥2 hosts/modules. Host-specific
# single-use values stay in the host's default.nix. Muffin-only service
# infrastructure (ports, zpool names, hugepage budgets) stays in
# hosts/muffin/service-configs.nix.
rec {
# --- Identity ---
domain = "sigkill.computer";
old_domain = "gardling.com"; # served by muffin via permanent redirect (services/caddy/caddy.nix)
contact_email = "titaniumtown@proton.me";
# All three hosts run on the same timezone. Override per-host via
# lib.mkForce when travelling (see hosts/mreow/default.nix for the pattern).
timezone = "America/New_York";
# --- Binary cache (muffin serves via harmonia, desktops consume) ---
binary_cache = {
url = "https://nix-cache.${domain}";
public_key = "nix-cache.${domain}-1:ONtQC9gUjL+2yNgMWB68NudPySXhyzJ7I3ra56/NPgk=";
};
# --- LAN topology ---
dns_servers = [
"1.1.1.1"
"9.9.9.9"
];
lan = {
cidr = "192.168.1.0/24";
gateway = "192.168.1.1";
};
# Per-host network info. mreow is laptop-on-DHCP so it has no entry.
hosts = {
muffin = {
ip = "192.168.1.50";
# Canonical alias used by deploy.sh, CI workflows, and borg backup target.
# Resolves via /etc/hosts on muffin and the desktops' NetworkManager DNS.
alias = "server-public";
# SSH host key — same key is served for every alias muffin answers to
# (server-public, the IP, git.${domain}, git.${old_domain}).
ssh_host_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFMjgaMnE+zS7tL+m5E7gh9Q9U1zurLdmU0qcmEmaucu";
};
yarn = {
ip = "192.168.1.223";
alias = "desktop";
};
};
# --- SSH pubkeys ---
# One line per key, referenced by name from services/ssh.nix (muffin) and
# hosts/yarn/default.nix. Rotating a key means changing it here, nowhere else.
ssh_keys = {
laptop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO4jL6gYOunUlUtPvGdML0cpbKSsPNqQ1jit4E7U1RyH";
ci_deploy = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC5ZYN6idL/w/mUIfPOH1i+Q/SQXuzAMQUEuWpipx1Pc ci-deploy@muffin";
};
}

196
tests/deploy-finalize.nix Normal file
View File

@@ -0,0 +1,196 @@
# Test for modules/server-deploy-finalize.nix.
#
# Covers the decision and scheduling logic with fabricated profile directories,
# since spawning a second booted NixOS toplevel to diff kernels is too heavy for
# a runNixOSTest. We rely on the shellcheck pass baked into writeShellApplication
# to catch syntax regressions in the script itself.
{
lib,
pkgs,
inputs,
...
}:
pkgs.testers.runNixOSTest {
name = "deploy-finalize";
node.specialArgs = {
inherit inputs lib;
username = "testuser";
};
nodes.machine =
{ ... }:
{
imports = [
../modules/server-deploy-finalize.nix
];
services.deployFinalize = {
enable = true;
# Shorter default in the test to make expected-substring assertions
# stable and reinforce that the option is wired through.
delay = 15;
};
};
testScript = ''
start_all()
machine.wait_for_unit("multi-user.target")
# Test fixtures: fabricated profile trees whose kernel/initrd/kernel-modules
# symlinks are under test control. `readlink -e` requires the targets to
# exist, so we point at real files in /tmp rather than non-existent paths.
machine.succeed(
"mkdir -p /tmp/profile-same /tmp/profile-changed-kernel "
"/tmp/profile-changed-initrd /tmp/profile-changed-modules "
"/tmp/profile-missing /tmp/fake-targets"
)
machine.succeed(
"touch /tmp/fake-targets/alt-kernel /tmp/fake-targets/alt-initrd "
"/tmp/fake-targets/alt-modules"
)
booted_kernel = machine.succeed("readlink -e /run/booted-system/kernel").strip()
booted_initrd = machine.succeed("readlink -e /run/booted-system/initrd").strip()
booted_modules = machine.succeed("readlink -e /run/booted-system/kernel-modules").strip()
def link_profile(path, kernel, initrd, modules):
machine.succeed(f"ln -sf {kernel} {path}/kernel")
machine.succeed(f"ln -sf {initrd} {path}/initrd")
machine.succeed(f"ln -sf {modules} {path}/kernel-modules")
# profile-same: matches booted exactly should choose `switch`.
link_profile("/tmp/profile-same", booted_kernel, booted_initrd, booted_modules)
machine.succeed("mkdir -p /tmp/profile-same/bin")
machine.succeed(
"ln -sf /run/current-system/bin/switch-to-configuration "
"/tmp/profile-same/bin/switch-to-configuration"
)
# profile-changed-kernel: kernel differs only should choose `reboot`.
link_profile(
"/tmp/profile-changed-kernel",
"/tmp/fake-targets/alt-kernel",
booted_initrd,
booted_modules,
)
# profile-changed-initrd: initrd differs only should choose `reboot`.
link_profile(
"/tmp/profile-changed-initrd",
booted_kernel,
"/tmp/fake-targets/alt-initrd",
booted_modules,
)
# profile-changed-modules: kernel-modules differs only should choose `reboot`.
# Catches the obelisk PR / nixpkgs auto-upgrade case where modules rebuild
# against the same kernel but ABI-incompatible.
link_profile(
"/tmp/profile-changed-modules",
booted_kernel,
booted_initrd,
"/tmp/fake-targets/alt-modules",
)
# profile-missing: no kernel/initrd/kernel-modules should fail closed.
with subtest("dry-run against identical profile selects switch"):
rc, out = machine.execute(
"deploy-finalize --dry-run --profile /tmp/profile-same 2>&1"
)
assert rc == 0, f"rc={rc}\n{out}"
assert "action=switch" in out, out
assert "services only" in out, out
assert "dry-run not scheduling" in out, out
assert "would run: /tmp/profile-same/bin/switch-to-configuration switch" in out, out
assert "would schedule: systemd-run" in out, out
with subtest("dry-run against changed-kernel profile selects reboot"):
rc, out = machine.execute(
"deploy-finalize --dry-run --profile /tmp/profile-changed-kernel 2>&1"
)
assert rc == 0, f"rc={rc}\n{out}"
assert "action=reboot" in out, out
assert "reason=kernel changed" in out, out
assert "systemctl reboot" in out, out
with subtest("dry-run against changed-initrd profile selects reboot"):
rc, out = machine.execute(
"deploy-finalize --dry-run --profile /tmp/profile-changed-initrd 2>&1"
)
assert rc == 0, f"rc={rc}\n{out}"
assert "action=reboot" in out, out
assert "reason=initrd changed" in out, out
with subtest("dry-run against changed-modules profile selects reboot"):
rc, out = machine.execute(
"deploy-finalize --dry-run --profile /tmp/profile-changed-modules 2>&1"
)
assert rc == 0, f"rc={rc}\n{out}"
assert "action=reboot" in out, out
assert "reason=kernel-modules changed" in out, out
with subtest("dry-run against empty profile fails closed with rc=1"):
rc, out = machine.execute(
"deploy-finalize --dry-run --profile /tmp/profile-missing 2>&1"
)
assert rc == 1, f"rc={rc}\n{out}"
assert "missing kernel, initrd, or kernel-modules" in out, out
with subtest("--delay override is reflected in output"):
rc, out = machine.execute(
"deploy-finalize --dry-run --delay 7 --profile /tmp/profile-same 2>&1"
)
assert rc == 0, f"rc={rc}\n{out}"
assert "delay=7s" in out, out
with subtest("configured default delay from module option is used"):
rc, out = machine.execute(
"deploy-finalize --dry-run --profile /tmp/profile-same 2>&1"
)
assert rc == 0, f"rc={rc}\n{out}"
# module option delay=15 in nodes.machine above.
assert "delay=15s" in out, out
with subtest("unknown option rejected with rc=2"):
rc, out = machine.execute("deploy-finalize --bogus 2>&1")
assert rc == 2, f"rc={rc}\n{out}"
assert "unknown option --bogus" in out, out
with subtest("non-dry run arms a transient systemd timer"):
# Long delay so the timer doesn't fire during the test. We stop it
# explicitly afterwards.
rc, out = machine.execute(
"deploy-finalize --delay 3600 --profile /tmp/profile-same 2>&1"
)
assert rc == 0, f"scheduling rc={rc}\n{out}"
# Confirm exactly one transient timer is active.
timers = machine.succeed(
"systemctl list-units --type=timer --no-legend 'deploy-finalize-*.timer' "
"--state=waiting | awk 'NF{print $1}'"
).strip().splitlines()
assert len(timers) == 1, f"expected exactly one pending timer, got {timers}"
assert timers[0].startswith("deploy-finalize-"), timers
with subtest("back-to-back scheduling cancels the previous timer"):
# The previous subtest left one timer armed. Schedule again; the old
# one should be stopped before the new unit name is created.
machine.succeed("sleep 1") # ensure a distinct unit-name timestamp
rc, out = machine.execute(
"deploy-finalize --delay 3600 --profile /tmp/profile-same 2>&1"
)
assert rc == 0, f"second-schedule rc={rc}\n{out}"
timers = machine.succeed(
"systemctl list-units --type=timer --no-legend 'deploy-finalize-*.timer' "
"--state=waiting | awk 'NF{print $1}'"
).strip().splitlines()
assert len(timers) == 1, f"expected only the new timer, got {timers}"
# Clean up so the test's shutdown path is quiet.
machine.succeed(
"systemctl stop 'deploy-finalize-*.timer' 'deploy-finalize-*.service' "
"2>/dev/null || true"
)
'';
}

View File

@@ -12,10 +12,10 @@
...
}:
let
baseServiceConfigs = import ../hosts/muffin/service-configs.nix;
baseSiteConfig = import ../site-config.nix;
baseServiceConfigs = import ../hosts/muffin/service-configs.nix { site_config = baseSiteConfig; };
testServiceConfigs = lib.recursiveUpdate baseServiceConfigs {
zpool_ssds = "";
https.domain = "test.local";
};
alwaysOk = pkgs.writeShellApplication {

View File

@@ -5,7 +5,10 @@
...
}:
let
baseServiceConfigs = import ../../hosts/muffin/service-configs.nix;
baseSiteConfig = import ../../site-config.nix;
baseServiceConfigs = import ../../hosts/muffin/service-configs.nix {
site_config = baseSiteConfig;
};
testServiceConfigs = lib.recursiveUpdate baseServiceConfigs {
zpool_ssds = "";
gitea = {

View File

@@ -5,10 +5,12 @@
...
}:
let
baseServiceConfigs = import ../../hosts/muffin/service-configs.nix;
baseSiteConfig = import ../../site-config.nix;
baseServiceConfigs = import ../../hosts/muffin/service-configs.nix {
site_config = baseSiteConfig;
};
testServiceConfigs = lib.recursiveUpdate baseServiceConfigs {
zpool_ssds = "";
https.domain = "test.local";
ports.private.immich = {
port = 2283;
proto = "tcp";

View File

@@ -5,10 +5,12 @@
...
}:
let
baseServiceConfigs = import ../../hosts/muffin/service-configs.nix;
baseSiteConfig = import ../../site-config.nix;
baseServiceConfigs = import ../../hosts/muffin/service-configs.nix {
site_config = baseSiteConfig;
};
testServiceConfigs = lib.recursiveUpdate baseServiceConfigs {
zpool_ssds = "";
https.domain = "test.local";
jellyfin = {
dataDir = "/var/lib/jellyfin";
cacheDir = "/var/cache/jellyfin";
@@ -33,6 +35,7 @@ let
(import ../../services/jellyfin/jellyfin.nix {
inherit config pkgs;
lib = testLib;
site_config = baseSiteConfig;
service_configs = testServiceConfigs;
})
];

View File

@@ -5,10 +5,12 @@
...
}:
let
baseServiceConfigs = import ../../hosts/muffin/service-configs.nix;
baseSiteConfig = import ../../site-config.nix;
baseServiceConfigs = import ../../hosts/muffin/service-configs.nix {
site_config = baseSiteConfig;
};
testServiceConfigs = lib.recursiveUpdate baseServiceConfigs {
zpool_ssds = "";
https.domain = "test.local";
};
testLib = lib.extend (
@@ -28,6 +30,7 @@ let
(import ../../services/bitwarden.nix {
inherit config pkgs;
lib = testLib;
site_config = baseSiteConfig;
service_configs = testServiceConfigs;
})
];

View File

@@ -0,0 +1,220 @@
{
config,
lib,
pkgs,
...
}:
let
baseSiteConfig = import ../site-config.nix;
baseServiceConfigs = import ../hosts/muffin/service-configs.nix {
site_config = baseSiteConfig;
};
testServiceConfigs = lib.recursiveUpdate baseServiceConfigs {
zpool_ssds = "";
gitea = {
dir = "/var/lib/gitea";
# `:80` makes Caddy bind all hosts on HTTP port 80 with no Host-header
# matching — simplest path to a reachable vhost inside the test VM
# where there is no ACME / DNS and no TLS terminator.
domain = ":80";
};
ports.private.gitea = {
port = 3000;
proto = "tcp";
};
};
testLib = lib.extend (
final: prev: {
serviceMountWithZpool =
serviceName: zpool: dirs:
{ ... }:
{ };
serviceFilePerms = serviceName: tmpfilesRules: { ... }: { };
}
);
giteaModule =
{ config, pkgs, ... }:
{
imports = [
(import ../services/gitea/gitea.nix {
inherit config pkgs;
lib = testLib;
service_configs = testServiceConfigs;
})
];
};
in
pkgs.testers.runNixOSTest {
name = "gitea-hide-actions";
nodes = {
server =
{
config,
lib,
pkgs,
...
}:
{
imports = [
../modules/server-security.nix
giteaModule
];
# The shared gitea.nix module derives DOMAIN/ROOT_URL from the
# `service_configs.gitea.domain` string, which here is the full URL
# `http://server`. Override to valid bare values so Gitea doesn't
# get a malformed ROOT_URL like `https://http://server`.
services.gitea.settings = {
server = {
DOMAIN = lib.mkForce "server";
ROOT_URL = lib.mkForce "http://server/";
};
# Tests talk HTTP, so drop the Secure flag — otherwise curl's cookie
# jar holds the session cookie but never sends it back.
session.COOKIE_SECURE = lib.mkForce false;
};
services.caddy = {
enable = true;
# No DNS / ACME in the VM test network — serve plain HTTP.
globalConfig = ''
auto_https off
'';
};
services.postgresql.enable = true;
# Stub out zfs/mount ordering added by the real serviceMountWithZpool.
systemd.services."gitea-mounts".enable = lib.mkForce false;
systemd.services.gitea = {
wants = lib.mkForce [ ];
after = lib.mkForce [ "postgresql.service" ];
requires = lib.mkForce [ ];
};
networking.firewall.allowedTCPPorts = [
80
3000
];
};
client =
{ pkgs, ... }:
{
environment.systemPackages = [ pkgs.curl ];
};
};
testScript = ''
import re
start_all()
server.wait_for_unit("postgresql.service")
server.wait_for_unit("gitea.service")
server.wait_for_unit("caddy.service")
server.wait_for_open_port(3000)
server.wait_for_open_port(80)
server.succeed(
"su -l gitea -s /bin/sh -c '${pkgs.gitea}/bin/gitea admin user create "
"--username testuser --password testpassword "
"--email test@test.local --must-change-password=false "
"--work-path /var/lib/gitea'"
)
def curl(args, cookies=None):
cookie_args = f"-b {cookies} " if cookies else ""
cmd = (
"curl -4 -s -o /dev/null "
f"-w '%{{http_code}}|%{{redirect_url}}' {cookie_args}{args}"
)
return client.succeed(cmd).strip()
def login():
# Gitea's POST /user/login requires a _csrf token and expects the
# matching session cookie already set. Fetch the login form first
# to harvest both, then submit credentials with the same cookie jar.
client.succeed("rm -f /tmp/cookies.txt")
html = client.succeed(
"curl -4 -s -c /tmp/cookies.txt http://server/user/login"
)
match = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
assert match, f"CSRF token not found in login form: {html[:500]!r}"
csrf = match.group(1)
# -L so we follow the post-login redirect; the session cookie is
# rewritten by Gitea on successful login to carry uid.
client.succeed(
"curl -4 -s -L -o /dev/null "
"-b /tmp/cookies.txt -c /tmp/cookies.txt "
f"--data-urlencode '_csrf={csrf}' "
"--data-urlencode 'user_name=testuser' "
"--data-urlencode 'password=testpassword' "
"http://server/user/login"
)
# Sanity-check the session by hitting the gated probe directly
# the post-login cookie jar MUST drive /user/stopwatches to 200.
probe = client.succeed(
"curl -4 -s -o /dev/null -w '%{http_code}' "
"-b /tmp/cookies.txt http://server/user/stopwatches"
).strip()
assert probe == "200", f"session auth probe expected 200, got {probe!r}"
return "/tmp/cookies.txt"
with subtest("Anonymous /{user}/{repo}/actions redirects to login"):
result = curl("http://server/foo/bar/actions")
code, _, redir = result.partition("|")
print(f"anon /foo/bar/actions -> {result!r}")
assert code == "302", f"expected 302, got {code!r} (full: {result!r})"
assert "/user/login" in redir, f"expected login redirect, got {redir!r}"
assert "redirect_to=" in redir, f"expected redirect_to param, got {redir!r}"
assert "/foo/bar/actions" in redir, (
f"expected original URL preserved in redirect_to, got {redir!r}"
)
with subtest("Anonymous deep /actions paths also redirect"):
for path in ["/foo/bar/actions/", "/foo/bar/actions/runs/1", "/foo/bar/actions/workflows/build.yaml"]:
result = curl(f"http://server{path}")
code, _, redir = result.partition("|")
print(f"anon {path} -> {result!r}")
assert code == "302", f"{path}: expected 302, got {code!r}"
assert "/user/login" in redir, f"{path}: expected login redirect, got {redir!r}"
with subtest("Anonymous workflow badge stays public"):
result = curl("http://server/foo/bar/actions/workflows/ci.yaml/badge.svg")
code, _, redir = result.partition("|")
print(f"anon badge -> {result!r}")
assert code != "302" or "/user/login" not in redir, (
f"badge path should not redirect to login, got {result!r}"
)
cookies = login()
with subtest("Session-authenticated /{user}/{repo}/actions reaches Gitea"):
result = curl(
"http://server/testuser/nonexistent/actions", cookies=cookies
)
code, _, redir = result.partition("|")
print(f"auth /testuser/nonexistent/actions -> {result!r}")
# Gitea returns 404 for the missing repo the key assertion is that
# Caddy's gate forwarded the request instead of redirecting to login.
assert not (code == "302" and "/user/login" in redir), (
f"session-authed actions request was intercepted by login gate: {result!r}"
)
with subtest("Anonymous /explore/repos is served without gating"):
result = curl("http://server/explore/repos")
code, _, _ = result.partition("|")
print(f"anon /explore/repos -> {result!r}")
assert code == "200", f"expected 200 for public explore page, got {result!r}"
with subtest("Anonymous /{user}/{repo} (non-actions) is not login-gated"):
result = curl("http://server/foo/bar")
code, _, redir = result.partition("|")
print(f"anon /foo/bar -> {result!r}")
assert not (code == "302" and "/user/login" in redir), (
f"non-actions repo path should not redirect to login: {result!r}"
)
'';
}

View File

@@ -6,10 +6,10 @@
...
}:
let
baseServiceConfigs = import ../hosts/muffin/service-configs.nix;
baseSiteConfig = import ../site-config.nix;
baseServiceConfigs = import ../hosts/muffin/service-configs.nix { site_config = baseSiteConfig; };
testServiceConfigs = lib.recursiveUpdate baseServiceConfigs {
zpool_ssds = "";
https.domain = "test.local";
minecraft.parent_dir = "/var/lib/minecraft";
minecraft.memory = rec {
heap_size_m = 1000;
@@ -31,6 +31,7 @@ testPkgs.testers.runNixOSTest {
node.specialArgs = {
inherit inputs lib;
site_config = baseSiteConfig;
service_configs = testServiceConfigs;
username = "testuser";
};

View File

@@ -13,6 +13,7 @@ in
minecraftTest = handleTest ./minecraft.nix;
jellyfinQbittorrentMonitorTest = handleTest ./jellyfin-qbittorrent-monitor.nix;
deployGuardTest = handleTest ./deploy-guard.nix;
deployFinalizeTest = handleTest ./deploy-finalize.nix;
filePermsTest = handleTest ./file-perms.nix;
# fail2ban tests
@@ -40,4 +41,7 @@ in
# gitea runner test
giteaRunnerTest = handleTest ./gitea-runner.nix;
# gitea actions visibility gate test
giteaHideActionsTest = handleTest ./gitea-hide-actions.nix;
}