1 Commits

Author SHA1 Message Date
74cb5d8c9d feat(agents): make Pi a first-class agent 2026-07-29 12:00:42 -04:00
103 changed files with 1442 additions and 3783 deletions

72
.agents/CONTEXT.md Normal file
View File

@@ -0,0 +1,72 @@
# NixOS Dotfiles
A single flake that builds every machine the user owns — laptop, desktop, and three servers — from one shared, modular configuration.
## Language
**Host**:
One physical machine the flake builds a NixOS configuration for. Each Host has a directory under `hosts/` holding its machine-specific `hardware-configuration.nix` and its choice of enabled Modules.
_Avoid_: machine, node, system, box
**Module**:
A single `.nix` feature file under `modules/` that declares an `enable` option and the configuration it turns on. Every Module is always imported but stays inert until a Host enables it.
_Avoid_: component, package, plugin
**Guest**:
A reusable, machine-independent definition under `guests/` that bundles the Modules it runs inside an isolated NixOS instance, realized on a Host as a nested container.
Its `backend` defaults to `container` (systemd-nspawn).
`microvm` is a reserved backend value that is not yet built.
A Guest follows the Modules convention wholesale: the file-or-folder layout, the Namespace convention (`guests/media/jellyfin.nix` declares `guests.media.jellyfin`), and the Enable convention (imported always, inert until a Host sets its `enable`).
The Guest owns only its interior Modules.
The Host that enables it supplies the machine-specific placement, such as its VLAN, pool mounts, and resource caps.
_Avoid_: container, VM, instance, LXC, appliance
**Skeleton**:
The flake's plumbing — the Auto-loader, the helper lib, the flake inputs/overlays, and the shared base config — as distinct from the Modules that sit on top of it.
The shared base config splits in two: a host base (`system.nix`) and a slim guest-base that every nested Guest stands on.
The host base carries host-only machinery, such as the bootloader, hardware profile, host identity, and the boot and garbage-collection timers.
The guest-base carries only what a nested service needs and auto-enables the `toolkit` bundle and `modules.ssh`.
Both include the primary user, home-manager, and the shared overlays.
_Avoid_: framework, core, base, scaffolding
**toolkit**:
A deliberate bundle Module (`modules.toolkit`) that turns on the baseline interactive environment — fish, tmux, nvim, git, direnv — as one unit, so any Host or Guest shell feels identical.
The guest-base auto-enables it; a Host enables it explicitly like any other Module, keeping the Host a full checklist.
Distinct from the grouping-directory enables ADR 0004 rejected, since this is a bundle wanted as a unit.
_Avoid_: base, workstation, essentials
**Auto-loader**:
The lib code that recursively discovers and imports every Module under `modules/`, every Host under `hosts/`, and every Guest under `guests/`, so new files wire themselves in without manual `imports` edits.
_Avoid_: loader, importer, scanner
**Enable convention**:
The rule that every Module is imported unconditionally and guards its own body with `mkIf config.modules.<path>.enable`, so a Host reads as a checklist of `enable = true` flags.
_Avoid_: feature flag, toggle, opt-in
**Namespace convention**:
The rule that a Module's option path mirrors its directory path under `modules/`, so a file's location is its namespace.
A file whose name matches its enclosing directory is that directory's index node, declaring the directory's own segment rather than a doubled one.
A directory with no such file is a pure namespace prefix that carries no aggregate enable.
_Avoid_: option tree, module path, config key
**admin identity**:
The age identity held only in the operator's password manager, never committed, that is a recipient of every secrets file.
It is the recovery path for any wiped machine and the credential that authorizes registering a new host.
_Avoid_: master key, admin key, root key
**host identity**:
The dedicated age key on one machine's encrypted root, generated there and never transmitted, that decrypts that machine's own secrets and the shared file.
Deliberately distinct from the machine's SSH host key.
_Avoid_: machine key, node key, host key
**secrets file**:
One sops-encrypted file in the repo, encrypted to the admin identity plus whichever hosts may read it. Either shared across every host or specific to one.
_Avoid_: vault, secret store, keyring
**unstable overlay**:
The overlay exposing `nixpkgs-unstable` packages as `unstable.<name>`, used to pull an individual package fresher than the `nixos-unstable` base.
_Avoid_: bleeding-edge, latest
**stable overlay**:
The overlay exposing the latest stable release (`nixos-26.05`) as `stable.<name>`, used to pin an individual package to the rock-solid release from the `nixos-unstable` base.
_Avoid_: LTS, release channel

View File

@@ -0,0 +1,14 @@
---
status: superseded by ADR-0002
---
# Use sops-nix for secrets
The repo is public, so no secret — including password hashes and the WireGuard/ProtonVPN key — may be committed in plaintext. We manage all secrets with **sops-nix**: encrypted into the repo and decrypted per-host at activation via an age key derived from each machine's SSH host key.
We chose sops-nix over agenix for its multi-recipient encryption (one secret readable by both a host and the admin laptop) and its grouped-file editing workflow, which scale better across the planned five hosts with a mix of shared and per-host secrets. The cost is slightly more upfront machinery than agenix's one-file-per-secret model.
## Consequences
- User/root passwords use `hashedPasswordFile` backed by a sops secret, never a committed hash.
- Each new host must have its SSH host public key registered as a recipient before it can decrypt its secrets.

View File

@@ -0,0 +1,25 @@
---
status: accepted
---
# Two-tier age identities, secrets in the public repo
Secrets are encrypted with sops-nix into this public repo and decrypted by a two-tier set of age identities: one **admin identity**, stored only in Proton Pass and never committed, which is a recipient of every secrets file; and one **host identity** per machine, a dedicated age key generated on that machine's encrypted root, which reads only its own secrets plus the shared file.
The admin identity makes secrets recoverable after any machine is wiped and is the credential that authorizes registering a new host; the host identities keep a compromised server from decrypting the laptop.
Deliberately, a host identity is *not* derived from its SSH host key — that decoupling is what lets the SSH host keys themselves be secrets, so they survive a reimage instead of being regenerated.
This supersedes ADR 0001, whose choice of sops-nix over agenix still holds — the shared-plus-per-host file split with overlapping recipients is exactly the multi-recipient, grouped-file model that decided against agenix — but whose key-derivation mechanism is replaced.
## Considered Options
- **A separate private repository for secrets.** Rejected: cloning it needs credentials that would themselves be bootstrap material during an install, reintroducing a hand-carried secret to protect ciphertext that is already safe to publish.
- **A passphrase-encrypted admin identity committed to the repo.** Rejected: in a public repo it is offline-brute-forceable indefinitely, whereas a password manager provides the same protection with rate limiting.
- **Deriving host identities from SSH host keys**, as ADR 0001 specified. Rejected: it forces new host keys on every reimage, which means re-keying every secret, and it makes storing the host keys as secrets circular.
- **A single admin identity for all hosts, with no per-host identities.** Rejected: with three servers planned, it gives every machine the ability to decrypt every other machine's secrets.
## Consequences
- Every secrets file must include the admin identity as a recipient. A file readable only by its own host becomes permanently unrecoverable the moment that machine is wiped.
- The admin identity is the single point of recovery, and its durability is now a property of Proton Pass rather than of any machine or repository.
- A host must have its identity provisioned and registered *before* its first boot, because the login password now arrives only from a decrypted secret and there is no fallback credential.
- Registering a new host is a re-key of each file's data key, not a re-encryption of its values, so the cost stays constant as the fleet grows.

View File

@@ -0,0 +1,34 @@
---
status: accepted
---
# Hyprland as the keyboard-driven desktop compositor
The graphical desktop is built on **Hyprland**, a keyboard-driven Wayland tiling compositor, as a single choice serving both the laptop (neogaia) and the future desktop (zeus).
It matches how the operator already works: an i3 model of numbered workspaces and manual tiling, ported to bindings reachable entirely on a 60% keyboard.
Among true tilers it comes closest to "just works" through its cohesive first-party companion tools (lock, idle, wallpaper, portal) and a large ecosystem, which buys down the assembly-and-breakage cost that made past minimal tiling setups expensive for the operator.
The gaming and driver dimension does not constrain the choice, because both Hosts drive Wayland without caveats: neogaia is Intel and zeus is AMD.
Notably zeus is AMD, not Nvidia — Raichu is the only Nvidia machine, and it is a server with no desktop — so no Nvidia-on-Wayland pressure shapes the decision.
With gaming survival off the table, the choice rests on workflow and low-friction rather than on tolerating a hostile driver.
## Considered Options
- **Sway.** Rejected: its i3-faithful minimalism is precisely what historically cost the operator hours of assembly and breakage.
On AMD it games fine, but its only remaining edge over Hyprland was stability and purity — which the repo's pinning already provides, and which the operator's stated "just works" priority actively discounts.
- **KDE Plasma.** Rejected: the most integrated and lowest-friction option, the best AMD gaming desktop, and the operator's prior environment — but mouse-first at its core and only keyboard-navigable at the margins, which works against the primary keyboard-first requirement.
Its custom-tile-layout feature is an approximation of tiling on a floating desktop, not real automatic tiling.
- **niri.** Rejected: its scrollable-tiling paradigm abandons numbered workspaces, which breaks the operator's core muscle memory of switching by number.
It also has the smallest community of the candidates, a low-friction risk for a daily-driver desktop.
- **A different compositor per Host** (a keyboard-pure laptop plus a separate gaming desktop). Rejected: it doubles the configuration and maintenance and defeats the goal of one transferable setup.
It is unnecessary once AMD removes any gaming penalty from a keyboard-first compositor.
## Consequences
- One desktop Module set serves both Hosts.
zeus adopts the identical desktop by enabling a single flag, with battery-sensitive knobs such as blur flipped on for its AMD headroom.
- Hyprland's churn and occasional breakage are absorbed by the pinned, declarative, reversible configuration rather than by live fixing, so upgrades happen on the operator's schedule.
- The desktop's lock, idle, keybind syntax, and portal are Hyprland-specific, so a future move to another compositor would be a rewrite rather than a swap.
This is the accepted cost of the first-party-cohesion benefit.
- Hyprland is taken from nixpkgs, with no compositor plugins this pass.
Adopting the upstream Hyprland flake later, for a plugin or a bleeding-edge feature, is a contained change that mirrors the existing chaotic-nyx input pattern (an input that must not follow nixpkgs, carrying its own binary cache).

View File

@@ -0,0 +1,24 @@
---
status: accepted
---
# A Module's option namespace mirrors its directory
A Module's option path mirrors its directory path under `modules/`, so a file's location on disk is its namespace: `modules/agents/tools/gitea-axi.nix` declares `modules.agents.tools.gitea-axi`, and a subfolder like `agents/` or `tools/` is a real namespace segment, not a cosmetic grouping.
A file whose name matches its enclosing directory is that directory's index node, declaring the directory's own segment — its `enable` or aggregator — rather than a doubled segment, so `desktop/hyprland/hyprland.nix` owns `modules.desktop.hyprland` while `desktop/hyprland/hypridle.nix` nests under it as `modules.desktop.hyprland.hypridle`.
We chose this nested-mirrors-directory shape over the previous flat names (`modules.claude-code`, `modules.gitea-axi`) because the flat scheme let a Module sit anywhere on disk regardless of its option path, so the tree stopped predicting where a namespace lived.
Mirroring makes the two the single fact.
A pure grouping directory (`agents/`, `tools/`) contributes a namespace segment but declares no aggregate `enable`: agents are enabled à la carte, so there is deliberately no `modules.agents.enable` that would turn on a bundle nobody wants as a unit.
## Considered Options
- **Flat, location-independent names** (the prior state). Rejected: a Module's option path was unconstrained by its file's location, so the directory tree and the option tree drifted and neither could be read off the other.
- **A subfolder as cosmetic grouping only**, with the option path skipping the folder (`agents/pi.nix` → `modules.pi`). Rejected: it reintroduces the same drift for grouped Modules and makes the folder a lie the namespace does not tell.
- **An aggregator at every grouping level** (`modules.agents.enable`). Rejected: the agent Modules have no meaningful "all agents" bundle, and an aggregate enable there would invite turning on tools no Host wants together.
## Consequences
- The `agents/` group carries `claude-code`, `pi`, `skills`, and `tools/gitea-axi`, each enabled individually under `modules.agents.*`, with no `modules.agents.enable`.
- The index-file rule means adding a knob to an existing group (a new `desktop/hyprland/*.nix`) nests automatically without a naming decision, while a new top-level Module names its own segment.
- The `skills` Module remains the one deliberate exception to the Enable convention — it wires unconditionally — which the namespace convention does not change.

View File

@@ -0,0 +1,37 @@
---
status: accepted
---
# Stock Firefox with policy-installed extensions
The browser Module ships **stock mainline Firefox** (`pkgs.firefox`, the release train), and installs its three extensions — an ad and content blocker, the operator's password manager, and a video sponsor-skipper — through Mozilla's enterprise `ExtensionSettings` policy, keyed by add-on id with an install URL and `installation_mode = "force_installed"`.
Firefox fetches each signed add-on from Mozilla's add-on site at runtime and enables it automatically.
We chose this over an ESR, unbranded, or Developer Edition build carrying hash-pinned add-on packages from the Nix store.
Stock mainline Firefox refuses to load unsigned locally-built add-ons, so the pinned-package path forces the browser variant: it works only on a build that relaxes signature enforcement, which the mainline release does not.
Pairing the variant to the extension mechanism makes this the pivotal, hard-to-reverse decision — the choice of build dictates the whole extension story — so it is recorded here rather than left implicit in the Module.
The trade-off is deliberate.
The policy path gives up build-time reproducibility of the extension binaries, and needs network on first launch to populate them, in exchange for staying on current mainline Firefox with add-ons that are actually enabled and no new flake input.
## Considered Options
- **Stock mainline Firefox with policy-installed extensions** (chosen).
Current release train, no signature-enforcement caveat, no extra flake input.
The extension binaries are fetched signed at runtime rather than pinned, so their exact versions are not reproducible from the flake and first launch needs network.
- **ESR or unbranded Firefox with hash-pinned add-on packages** (e.g. via a NUR add-ons input).
Rejected: it buys reproducible extension binaries but drags in an older or unusual browser variant to satisfy the signature check the mainline build enforces, plus a new flake input to maintain, for a browser the operator wants on the mainline feature and security cadence.
- **Stock Firefox with extensions installed by hand.**
Rejected: the state would live outside the flake, would not survive a reimage, and defeats the point of declaring the browser at all.
## Consequences
- The Module needs no new flake input and no add-on package set.
The extension list is three id/URL pairs under the enterprise policy.
- Extension versions are whatever Mozilla currently serves, not a pinned hash, so the browser tracks upstream add-on updates automatically and the flake does not gate them.
- First launch after a fresh build requires network to fetch the add-ons.
An offline first boot comes up with the extensions not yet present, populating them once online.
- Moving to a pinned-package posture later would mean changing the browser variant as well, since the two are coupled — the reason this is captured as a decision rather than a detail.
- The no-pinned-package rule scopes to the three functional extensions, which is where the signature-enforcement conflict bites.
Nord chrome theming comes from the Stylix Firefox Color add-on, a signed add-on that Stylix pins and manages, so it loads on stock mainline Firefox and adds no flake input of ours.
That is a bounded, deliberate exception, not a reversal: it is what lets the browser be themed from the shared Stylix scheme without hand-written chrome CSS.

View File

@@ -0,0 +1,35 @@
---
status: accepted
---
# A Guest is a third concept beside Host and Module
The flake gains a third first-class concept, the Guest, beside the Host and the Module.
A Guest is a reusable, machine-independent definition under `guests/` that bundles the Modules it runs inside an isolated NixOS instance, realized on a Host as a nested container.
A Host composes both Modules and Guests: a Module turned on directly is a host-level install, and a Guest is a service running in its own isolated instance, so a Host still reads as one flat checklist of everything it carries.
A Guest follows the Modules convention wholesale.
The Auto-loader discovers `guests/` as a third kind, the Namespace convention and file-or-folder rule apply unchanged (`guests/media/jellyfin.nix` declares `guests.media.jellyfin`), and a Guest is Module-shaped: it declares its own `guests.<path>` option namespace with an `enable` and its placement fields and guards its body on that `enable` per the Enable convention.
The single difference from a Module is the payload — a Module's body merges settings into the Host, while a Guest's body realizes a nested container running the Guest's interior.
A Guest is backend-agnostic through a `backend` field but only the `container` backend (systemd-nspawn) is built now; `microvm` is a reserved value for a future hard-isolation backend.
A Guest is sealed and singleton: its interior Modules are fixed in the Guest file and not overridable by a Host, which supplies only placement, and a Guest is instantiated at most once per Host.
Every Guest stands on a slim guest-base, distinct from the host base carved out of the shared base config, and imports the full `modules/` tree so any Module is available inside it.
We chose this because the homelab this flake is growing to build runs services as isolated guests that are first-class citizens on a VLAN-tagged network, and the two-concept model had no way to say "run this service in its own instance, on this VLAN, with these pool mounts" declaratively or to reuse that definition across machines.
Making the Guest a peer of Host and Module — same Auto-loader, same conventions, same checklist — adds the capability without adding a second mental model, and keeps the imperative container lifecycle and mutable drift of the Proxmox setup it replaces out of the flake.
## Considered Options
- **Inline `containers.<name>` per Host.** The platform's native nested-container option, declared directly inside each Host. Rejected: a guest would be tied to one Host with no reuse, and the machine-independent identity of an appliance would be entangled with one machine's config, the same drift ADR 0004 removed for Modules.
- **Incus or another imperative container stack.** A maintained LXC-style manager on the Host. Rejected: its instance lifecycle is imperative and its state mutable, which is precisely the Proxmox property the migration exists to eliminate; guests would not be declared in the flake.
- **A microvm-first Guest.** Realize every Guest as a hard-isolated micro VM from the start. Deferred, not chosen for now: the operator's guests are the soft-isolation class the Proxmox LXCs already are, and a shared-kernel container backend is the like-for-like replacement; the backend field reserves `microvm` for when a guest genuinely needs a distinct kernel.
- **A Guest as a parameterized template with Host-declared instances.** Let one Guest file be stamped out as many named instances per Host. Rejected: it breaks "follow the Modules convention completely" by making a Guest a non-singleton template with its own instance sublevel, a fourth shape; multiplicity is instead expressed as separate Guests over shared, configurable service Modules.
## Consequences
- The Skeleton grows a Guest realization: the Auto-loader discovers `guests/`, a Guest's interior compiles into a nested container, and placement fields wire VLAN attachment, MAC pinning, bind mounts, secret mounts, unit caps, and the nesting prerequisites.
- The shared base config splits into a host base (`system.nix`) and a slim guest-base; both include the primary user, home-manager, and the shared overlays, and the guest-base auto-enables the `toolkit` bundle and `modules.ssh`.
- Guests attach to host-level foundations declared once per Host: `modules.network` for the trunk and per-VLAN bridges, and `modules.zfs` with a shared fixed-gid `storage` group for identity-mapped pool writes.
- OCI software has a declarative home without a new mechanism: a Guest with `nesting` runs Podman in its interior.
- The `microvm` backend, multi-instance Guests, and the concrete homelab Host with its real trunk, VLAN, pool, and MAC values remain future work.

0
.agents/spec/.gitkeep Normal file
View File

0
.agents/tasks/.gitkeep Normal file
View File

1
.claude/CONTEXT.md Symbolic link
View File

@@ -0,0 +1 @@
../.agents/CONTEXT.md

1
.claude/adr Symbolic link
View File

@@ -0,0 +1 @@
../.agents/adr

1
.claude/spec Symbolic link
View File

@@ -0,0 +1 @@
../.agents/spec

1
.claude/tasks Symbolic link
View File

@@ -0,0 +1 @@
../.agents/tasks

1
.envrc
View File

@@ -1 +0,0 @@
use flake

7
.gitignore vendored
View File

@@ -1,8 +1 @@
/reference/
/.direnv/
# BEGIN mkSkillsShellHook
# Generated by mkSkillsShellHook. Nix-delivered skill symlinks, kept out of git.
.claude/skills
.agents/skills/gitea-axi
# END mkSkillsShellHook

View File

@@ -7,7 +7,6 @@ keys:
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
# Generated on the machine it names.
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
- &pikachu age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
creation_rules:
# Material belonging to one machine.
@@ -19,16 +18,9 @@ creation_rules:
- *admin
- *neogaia
- path_regex: secrets/pikachu\.yaml$
key_groups:
- age:
- *admin
- *pikachu
# Material common to every machine, so it is stored once rather than per host.
- path_regex: secrets/shared\.yaml$
key_groups:
- age:
- *admin
- *neogaia
- *pikachu

165
AGENTS.md
View File

@@ -1,110 +1,69 @@
# dotfiles-nixos
One flake builds every machine I own.
One flake that builds every machine the user owns.
The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overlays) lives in `.agents/CONTEXT.md`.
## Repository Conventions
## Conventions
- Use `docs/technical-writing.md` and `docs/dictionary.md` while authoring any file.
Apply them to comments, user-facing descriptions, commit prose, pull-request prose, and Markdown documentation.
- Comments posted to the forge under the operator's account must end with `— Pi`.
The sign-off identifies the agent as the author instead of the operator.
- Commit messages follow Conventional Commits, defined in `docs/conventional-commits.md`.
Use a scope for the changed module or host, such as `fish`, `nvim`, or `neogaia`.
- Keep commit messages about the Git history, not the forge workflow.
Do not reference issue or pull-request numbers unless the commit itself requires a stable external reference.
- During pull-request development, add follow-up commits instead of amending existing commits.
Preserve review history until the pull request is ready to merge.
- Use `gitea-axi` for Gitea issues, pull requests, labels, reviews, comments, and milestones.
It resolves this repository from the `origin` remote inside the checkout.
## Dotfiles Conventions
### Desktop
- When adding a graphical application, add a `window-rewrite` icon mapping in `modules/desktop/session/waybar.nix`.
The workspace indicator uses this mapping to show an application icon instead of the generic default glyph.
Match the mapping on the window class.
Run `hyprctl clients -j | jq -r '.[].class' | sort -u` to list classes in the running session.
- Set Hyprland session environment variables in `modules/desktop/session/hyprland/default.nix` with `env = KEY,VALUE`.
`home.sessionVariables` do **not** reach the Hyprland session because UWSM does not source `hm-session-vars.sh`.
Source cursor values from `config.stylix.cursor`.
Set `XCURSOR_*` and `HYPRCURSOR_*` to the same theme for Bibata.
- Configure the Steam desktop client as an XWayland application.
SDL Wayland selectors do not make the visible client native Wayland.
Keep fractional scaling sharp with Hyprland's `xwayland.force_zero_scaling` and Steam's `STEAM_FORCE_DESKTOPUI_SCALING`.
### Firefox
- Manage Firefox search engines through `programs.firefox.search` with `force = true`.
Hide built-in engines with `<engine>.metaData.hidden = true` because omission does not remove them.
Use current engine ids, such as `default = "ddg"` instead of `"DuckDuckGo"`.
Decode the built `search.json.mozlz4` with `mozlz4a -d <search.json.mozlz4>` to check the result.
- Avoid Home Manager Firefox extension settings unless the whole profile can use the legacy extension-storage backend.
Any non-empty `profiles.<name>.extensions.settings.<id>.settings` sets `extensions.webextensions.ExtensionStorageIDB.enabled = false` for the whole profile.
### Neovim
- Nixvim's flake input follows the root nixpkgs source, but its Home Manager module does not reuse the host's `pkgs` instance.
Keep `programs.nixvim.nixpkgs.useGlobalPackages = true`.
That setting makes Nixvim use the shared package set without warning that its source default changed.
### AI Tooling
- Use produced literals for Herdr key names that include shifted punctuation.
For example, use `prefix+<` instead of `prefix+shift+comma`.
- Treat `~/.pi/agent/skills` as generated output from Home Manager.
Do not edit skills in that directory.
A new file created there stays outside the repo.
Shared global skills come from the `skills` flake through `modules/agents/skills.nix`.
Update the `skills` flake input, then rebuild.
- Keep generated `.agents/skills/.gitignore` files from ignoring symlinked skills.
Pi skill discovery honors `.gitignore`, `.ignore`, and `.fdignore` inside scanned skill directories.
An ignored symlinked skill does not load even when `.agents/skills/<name>/SKILL.md` exists.
- Write directly to flake-managed Pi extension, prompt, and skill directories only for throwaway development or local experiments.
Home Manager activation can overwrite or hide unmanaged files in those directories.
Promote finished work into the dotfiles module before treating it as deployed.
- Comments posted to Gitea (pull requests, issues, reviews) go out under the operator's account, so sign every one to make clear the author is the agent, not the operator.
End the comment with a `— Claude` sign-off.
(A dedicated bot account may replace this later.
Until then, the sign-off is the only marker.)
- Commit messages follow Conventional Commits, specified in `docs/conventional-commits.md`.
Scope is the module or host the change belongs to (`fish`, `nvim`, `neogaia`), omitted for repo-wide changes.
Keep messages free of Gitea-specific references: this repository is mirrored to GitHub, where issue and pull-request numbers resolve to unrelated things.
- When a graphical application is added, give it a `window-rewrite` icon mapping in `modules/desktop/waybar.nix`.
Without one its windows fall back to the generic default glyph on the workspace indicator instead of showing a recognisable per-application icon.
Match on the window class, which `hyprctl clients -j | jq -r '.[].class' | sort -u` lists for the running session.
## Gotchas
- Guest modules are host-agnostic wiring, while host files own deployment-specific service configuration such as served volumes and host paths.
For example, `guests/copyparty.nix` should expose options and translate them into guest mounts/service settings, while `hosts/pikachu/default.nix` chooses `/inbox`, `/media`, and their backing paths.
Guest addresses and routes are host-level configuration because a guest module can be used on multiple hosts.
- The registry collector reads only top-level host `config.registry` declarations.
Registry declarations inside guest interiors live under `config.containers.<guest>.config.registry`.
The collected registry does not include those guest-interior declarations.
Publish guest values from host-side code, or extend the collector to recurse into guests.
See http://git.alexion.dev/alexion/dotfiles/issues/65.
- A NixOS module function argument default such as `registry ? config.registry` does not safely handle an unprovided module argument.
The module system still queries `_module.args.registry`.
Evaluation fails when that argument is absent.
For an optional special-argument fallback, bind module arguments through `@args`.
Test `args ? <name>` inside the module body.
- `chaotic-nyx` must **not** follow this flake's `nixpkgs`.
Chaotic builds its packages against its own pinned `nixpkgs`.
Its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path.
That setup lets `nyx-cache.chaotic.cx` serve the CachyOS kernel instead of building it from source.
Chaotic packages do not see this flake's `unstable` or `stable` overlays.
- Remote deploy helpers that run target-side `sudo` over SSH need a TTY.
Use `ssh -t` unless the target command cannot prompt for sudo credentials.
- This host has no `python` or `python3` command on its ordinary `PATH`.
For one-off Python commands, use Nix explicitly, such as `nix shell nixpkgs#python3 -c python3 <script>`.
- `nixos-generate-config --show-hardware-config` needs root on this machine, even when it only prints.
Without root, it fails with `Failed to retrieve subvolume info for /`.
The root filesystem uses btrfs.
- Nix flake evaluation ignores untracked files in this checkout.
Stage or commit new auto-loaded modules before evaluating the flake.
Otherwise, `nix flake check` and `nixos-rebuild --flake` cannot see those modules.
They can then report missing options that exist only in the working tree.
- `sops.secrets.<name>.owner` must name a user known on the decrypting host.
Do not set the owner to a guest-only service user unless the host also declares that user.
Use a host-visible owner plus a shared group mode when the guest service can read the decrypted secret file through its group.
- Actual Budget's NixOS service runs `actual-server` with `WorkingDirectory = services.actual.settings.dataDir`.
The `actual-server` CLI wrapper sets `ACTUAL_CONFIG_PATH=./config.json` when the working directory contains `config.json`.
That environment variable overrides the config path generated by the NixOS module.
During migration from old community-script state, move or rewrite a copied `/data/config.json`.
Otherwise, the copied file shadows the declarative module settings.
- This repo pins no Nix formatter, and its committed `.nix` files are not clean under current `nixfmt-rfc-style`.
Running `nixfmt` across a file reflows untouched code (for example `lib.nix`'s `deriveMac` list and multi-line assertion messages) and injects churn unrelated to the change.
Format only the lines being written or changed, matching the surrounding style by hand.
- This repo is developed on `neogaia`, which now runs the NixOS it builds.
Flakes and the chaotic substituter come from this flake's own `nix.settings`, so no `NIX_CONFIG` export or per-command `--extra-experimental-features` is needed, and building a toplevel with `boot.kernelPackages = linuxPackages_cachyos` fetches the kernel from `nyx-cache` rather than compiling it.
Both were true only while the machine still ran CachyOS against a distro Nix daemon.
- Git identity is declared in the flake by `modules/git.nix`, which writes `alexion <contact@alexion.dev>` — the identity all history uses — on any host enabling `modules.git`.
Every new host has to enable it, so that a host reads as a full checklist of what it carries.
It is deployed on `neogaia` and verified: a commit in a repository outside this checkout is authored `alexion <contact@alexion.dev>` with no override.
Verify it that way rather than from this checkout, whose `.git/config` carries the same identity and would mask a broken module.
`~/.gitconfig` (a second global file that outranks the flake-managed `~/.config/git/config`) currently holds only a `tea` credential helper and no `user.*`, so it does not shadow the identity, but it is undeclared and will not survive a reimage.
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel).
Cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
- chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path).
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source.
The tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
- The remote is self-hosted Gitea (`git.alexion.dev`), and the forge CLI is `gitea-axi` rather than `tea`.
`gitea-axi` resolves the repository from the `origin` remote and discovers credentials from a `tea` login whose host matches the remote, so both are implicit inside a checkout.
It is installed on `neogaia` by `modules.agents.tools.gitea-axi`, and verified: `gitea-axi` run from this checkout renders the `alexion/dotfiles` dashboard authenticated, so the claude-code `SessionStart` hook that runs it now resolves to a real binary rather than a missing one.
The package wraps the binary so `git` and `tea` are reachable without being on `PATH`, while still preferring the operator's own where present.
Credentials: `~/.config/tea/config.yml` holds a token-bearing login named `alexion`, which `gitea-axi` uses and which also opens pull requests directly with `nix run nixpkgs#tea -- pr create --login alexion --repo alexion/dotfiles --base main --head <branch> ...`.
The `--repo` flag is required on that path, since `tea` resolves `origin` only for a login whose SSH host matches.
The same token reads PR discussion, which `tea` itself does poorly: `tea pr <n> --comments` prints only the body, and `-f comments` returns no comments field at all.
Use the API instead, taking the token from `.logins[] | select(.name=="alexion") | .token`.
Review comments are **not** at `/issues/<n>/comments` — that endpoint holds only top-level discussion and is usually empty.
Inline comments need two calls: `/pulls/<n>/reviews` for the review ids, then `/pulls/<n>/reviews/<id>/comments` for the bodies, whose `path` and `diff_hunk` fields say what each one is attached to.
A review row with an empty `body` is the normal shape when the operator left only inline comments.
- `~/.claude/skills` and `~/.pi/agent/skills` are home-manager-generated (`recursive = true`), so editing a skill in place fails and a new file created there silently escapes the repo.
Shared global skills come from the `skills` flake through `modules/agents/skills.nix`, applied by a rebuild.
Claude-specific legacy skills, when kept, live under `modules/agents/claude-code/skills/<name>/`.
- nixpkgs `vimPlugins.nord-nvim` is `shaunsingh/nord.nvim` (no `require("nord").setup()`).
The config wants `gbprod/nord.nvim`, which is packaged as `vimPlugins.gbprod-nord`.
- `nixos-generate-config --show-hardware-config` needs root on this machine even just to print: unprivileged it dies at `Failed to retrieve subvolume info for /`, because the root filesystem is btrfs.
- This repo's claude-code module sets sudo's credential cache to per-user (`timestamp_type=global`, 60-minute window), so an authentication made in one real terminal counts for the agent's commands.
A `PreToolUse` hook refuses privileged commands while the cache is cold, so a cold cache announces itself instead of stalling.
A privileged-command failure *without* that message is the sandbox, not the cache.
- Host GPUs: `neogaia` is Intel, `zeus` (the desktop) is **AMD**, and `raichu` (a headless server) is the only Nvidia machine.
The corrected fact also lives in ADR 0003.
- This repo's `programs.firefox` `search` (with `force = true`) writes `search.json.mozlz4`.
Omission alone does not prune a built-in engine, since Firefox reconciles its app-provided engines back in, so remove one by listing it with `<engine>.metaData.hidden = true`.
Engines are referenced by their current id, so the default is `default = "ddg"`, not `"DuckDuckGo"`.
Decode the built file with `mozlz4a -d <search.json.mozlz4>` to check the result.
- `home.sessionVariables` do **not** reach the Hyprland session, since UWSM does not source `hm-session-vars.sh`.
The cursor is therefore set through Hyprland's own `env = KEY,VALUE` in `modules/desktop/hyprland/hyprland.nix`, sourced from `config.stylix.cursor`.
Bibata ships XCursor format only (no `hyprcursor/` dir), rendered through Hyprland's XCursor fallback, so `XCURSOR_*` and `HYPRCURSOR_*` naming the same theme are both safe.
- `neogaia`, the repo's only host, is a wifi laptop with a btrfs root and no ZFS pools, so it cannot honestly carry `modules.network`, `modules.zfs`, or a networked/pool-mounted guest.
Enabling networkd takes over its DNS, its CachyOS `zfs-kernel` build is marked broken, and it has no bridge or pool to attach to.
Verify these against it ad hoc through `nixosConfigurations.neogaia.extendModules` (forcing a ZFS-capable `boot.kernelPackages` for the zfs case) plus `nix eval` of the derived values, never by committing the enablement.
A committed guest therefore leaves `vlan`, `mounts`, and `secrets` unset, and the standing enablement waits for the first wired server host with real storage.

88
base.nix Normal file
View File

@@ -0,0 +1,88 @@
{
config,
lib,
inputs,
...
}:
# The shared foundation both the host base and the guest-base build on: the
# primary user, home-manager, and the fresher/pinned package overlays.
let
inherit (lib) mkOption types;
user = config.user;
# Args to instantiate an extra nixpkgs source on the base platform.
pinArgs = prev: {
inherit (prev.stdenv.hostPlatform) system;
config.allowUnfree = true;
};
in
{
imports = [ inputs.home-manager.nixosModules.home-manager ];
options.user = {
name = mkOption {
type = types.str;
default = "alexion";
description = ''
The primary interactive user this system is built for. Drives both the
system account and the home-manager user in lockstep.
'';
};
description = mkOption {
type = types.str;
default = "Alexion";
description = "Human-readable description (GECOS field) for the primary user.";
};
};
config = {
# Reach fresher packages with `unstable.<name>` or pin with `stable.<name>`.
nixpkgs.overlays = [
(_final: prev: {
unstable = import inputs.nixpkgs-unstable (pinArgs prev);
stable = import inputs.nixpkgs-stable (pinArgs prev);
})
];
nixpkgs.config.allowUnfree = true;
# Flakes, so `nixos-rebuild switch` works from the console and a direnv
# `use flake` resolves inside a guest.
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
# Primary user.
# The wheel group is the way in, since root is locked.
# No password is set here, since that is host-only.
# A guest therefore has none and is reached by SSH key or `machinectl`.
users.users.${user.name} = {
isNormalUser = true;
description = user.description;
extraGroups = [ "wheel" ];
};
# The shared write group.
# Its gid is fixed, so a host and every guest carry the same number.
# An identity-mapped container write then lands on the pool as this group, sparing every service the permission juggling.
# 10000 clears the system-group ids assigned automatically and leaves headroom above the primary user, so nothing else claims it.
users.groups.storage.gid = 10000;
# home-manager as a NixOS module: one build produces the system and user
# environment together, sharing the system's pkgs and installing user
# packages into the system profile.
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
extraSpecialArgs = {
inherit inputs;
my = inputs.self.lib;
};
users.${user.name} = {
home.username = user.name;
home.homeDirectory = "/home/${user.name}";
home.stateVersion = "26.05";
};
};
};
}

View File

@@ -1,59 +0,0 @@
# Technical Writing and Domain Dictionary
This dictionary defines project-specific controlled vocabulary, prohibited terms, and domain allowlists.
It applies to comments, user-facing descriptions, commit prose, pull-request prose, and Markdown documentation in this repository.
ASD-STE100 Simplified Technical English partly informs these rules.
This repository uses those ideas as a local style discipline.
It does not claim formal ASD-STE100 compliance.
## Strictly Banned Fluff and Latin Terms
Do not use these Latin abbreviations, conversational filler words, and subjective qualifiers.
Remove them or use the approved replacements.
| Disapproved Term | Approved Replacement | Notes |
| :--- | :--- | :--- |
| `i.e.` | `that is` | Latin abbreviation. Unclear to non-native speakers. |
| `e.g.` | `for example` | Latin abbreviation. |
| `etc.` | `and so forth`, `and others` | Latin abbreviation. Vague and non-exhaustive. |
| `vs.` / `versus` | `compared to`, `against` | Latin abbreviation. |
| `via` | `by using`, `through`, `with` | Latin term. Prefer direct prepositions. |
| `ad hoc` | `one-off`, `for this case` | Latin term. Prefer direct wording. |
| `simply` | omit | Subjective filler. Code and concepts are not inherently simple. |
| `just` | omit | Minimizing filler word. |
| `obviously` | omit | Subjective qualifier. |
| `clearly` | omit | Subjective qualifier. |
| `easily` | omit | Subjective qualifier. |
| `properly` | `correctly`, or specify exact behavior | Vague and subjective. State the exact condition. |
| `as necessary` | specify exact condition | Ambiguous. Define the condition explicitly. |
| `very` / `quite` | omit | Empty intensifiers. |
| `basically` | omit | Conversational filler. |
| `actually` | omit | Conversational filler. |
| `really` | omit | Empty intensifier. |
| `furthermore` / `moreover` | `also`, `and` | Overly formal transitional filler. |
## Technical and Domain Allowed Language
Use only approved project-specific terms as controlled vocabulary in documentation and comments.
Add a term here when repeated project prose needs it.
Use the simplest accurate word when a term is not listed.
| Approved Domain Term | Category | Description and Permitted Context |
| :--- | :--- | :--- |
| agent | Agent tooling | An AI coding assistant acting in the operator's environment. |
| operator | Agent tooling | The human driving the development process. Use when distinguishing the human from the agent acting on their behalf. |
| guest | dotfiles | A systemd-nspawn container managed by this flake. Use for configured containers under `guests/`. |
| host | dotfiles | A machine with a NixOS configuration built by this flake. |
| module | dotfiles | In this repository, a self-contained configuration under `modules/`. Use for one program, service, or subsystem. |
| registry | dotfiles | A typed configuration namespace whose entries can be collected across hosts and reused by other configuration. |
| Conventional Commits | Git | A commit-message format defined in `docs/conventional-commits.md`. |
| forge | Git | A web service that hosts Git repositories, pull requests, issues, and code review comments. Use when the exact provider does not matter. |
| flake | Nix | A Nix project with pinned inputs and declared outputs. Use for the repository-level build unit defined by `flake.nix`. |
| assembly file | Nix | A Nix file under `flake/` that constructs flake outputs or arguments passed to NixOS modules. |
| assembly function | Nix | A project function in `flake/` that creates modules, systems, or output values. |
| library file | Nix | A Nix file that defines reusable project functions instead of a NixOS module or flake output. |
| library function | Nix | A pure project function exposed under `util`. |
| NixOS module | Nix | A Nix file evaluated by `nixosSystem` for a NixOS configuration. |
| overlay | Nix | A function that modifies a Nix package set by adding or replacing packages. |
| workspace | Window management | A named or numbered group of windows in a graphical session. Some desktop environments call this a virtual desktop. |

View File

@@ -171,7 +171,7 @@ Then populate that file, which needs no existing identity because encrypting onl
$ nix run nixpkgs#sops -- secrets/newhost.yaml
```
A host with `modules.network.ssh.enable` expects one entry per key type, named `ssh-host-<type>-key`, each holding a private key generated with `ssh-keygen -t <type> -N "" -f /tmp/<type>`.
A host with `modules.ssh.enable` expects one entry per key type, named `ssh-host-<type>-key`, each holding a private key generated with `ssh-keygen -t <type> -N "" -f /tmp/<type>`.
The build fails at evaluation if a declared secret is absent from the file, so a host that enables the daemon without these will not install.
Commit the matching public halves beside the host's configuration in plaintext, since publishing them is their purpose.

View File

@@ -1,270 +0,0 @@
# Nix Conventions
This document records local Nix style and design conventions.
Use these conventions when changing this flake.
Read this document with `docs/dictionary.md`.
## Names
### Reserve `lib` for `nixpkgs.lib`
Use `lib` only for the base `nixpkgs.lib` value.
Do not use `lib` for project functions.
Use `util` for library functions passed through module arguments.
Use `flake.mk<Name>` for assembly functions used by flake outputs.
Avoid:
```nix
specialArgs = {
lib = util;
};
```
Use:
```nix
specialArgs = {
inherit util;
};
```
Then call library functions through `util` inside modules:
```nix
{
lib,
util,
...
}:
{
networking.networkmanager.unmanaged = [ "interface-name:${util.bridgeName 10}" ];
}
```
### Name project function namespaces by role
Put pure project functions under `util`.
Put functions that create systems or output values under `flake`.
This keeps data-only functions separate from functions that carry flake context.
Avoid:
```nix
util.mkHosts { root = self + "/hosts"; }
```
Use:
```nix
flake.mkHosts { root = self + "/hosts"; }
```
## File Placement
### Put files in the directory that owns their role
Choose the file location before writing a Nix file.
Put assembly files under `flake/`.
Put library files under `lib/`.
Put modules under `modules/`.
Put host configurations under `hosts/<name>/`.
Put host-side guest wiring under `guests/`.
Put registry definitions under `registry/`.
Keep `flake.nix` focused on inputs and output wiring.
Do not add root-level Nix files other than `flake.nix`.
A module is self-contained configuration under `modules/`.
A module configures one program, service, or subsystem.
Files under `hosts/`, `guests/`, and `modules/` return NixOS modules.
### Use `default.nix` for module directories with support files
Use `default.nix` as the primary module when a module directory owns support files.
Keep sibling files for data or code read by that primary module.
Avoid:
```text
modules/shell/fish/fish.nix
modules/shell/fish/config.fish
```
Use:
```text
modules/shell/fish/default.nix
modules/shell/fish/config.fish
```
### Use non-module `default.nix` files only for directory assembly
Outside `modules/`, use `default.nix` to connect files in its directory.
Do not define primary assembly functions in `default.nix`.
Put primary assembly functions in named files.
Avoid:
```text
flake/default.nix # defines mkHost
```
Use:
```text
flake/default.nix # imports ./host.nix
flake/host.nix # defines mkHosts
```
### Keep assembly files focused on one concept
Give each assembly file under `flake/` one top-level concept.
Keep helper functions private to that file when no other file needs them.
Export the constructor for the concept when another assembly file needs it.
Name that constructor `mk<Concept>`.
Avoid:
```nix
{
inherit collectNixFiles mkHost mkHosts;
}
```
Use:
```nix
{
inherit mkHosts;
}
```
## Function Arguments
### Use closed interfaces for assembly files and library files
Use exact function argument patterns for assembly files and library files.
The caller owns the argument set for these files.
The imported file names each accepted attribute.
The imported file rejects stale or accidental extra attributes.
Avoid:
```nix
{
inputs,
lib,
self,
...
}:
```
Use:
```nix
{
inputs,
lib,
self,
}:
```
### Pass function arguments explicitly
Pass only the values an assembly file or library file needs.
Bind `lib` to the base `nixpkgs.lib` value at the call site.
Use `inherit` only for values that keep the same name.
Avoid:
```nix
import ./flake inputs
```
Use:
```nix
let
inherit (nixpkgs) lib;
in
import ./flake { inherit lib inputs self; }
```
### Keep NixOS module interfaces open
Files under `hosts/`, `guests/`, and `modules/` return NixOS modules.
`nixosSystem` owns their argument shape.
Name each argument the module uses.
Keep `...` so the module accepts standard arguments and special arguments.
Avoid:
```nix
{
config,
lib,
pkgs,
registry,
}:
```
Use:
```nix
{
config,
lib,
pkgs,
registry,
...
}:
```
## `let` Bindings
### Use role-specific binding names
A `let` binding name must explain the value's role in the expression.
Avoid names that describe only the value's type.
Avoid:
```nix
let
names = lib.attrNames hosts;
in
lib.genAttrs names mkHost
```
Use:
```nix
let
hostNames = lib.attrNames hosts;
in
lib.genAttrs hostNames mkHost
```
## Standard Library Calls
### Qualify standard library calls in flake assembly
Use qualified standard library calls inside assembly files.
Use `lib.mapAttrs` instead of inheriting `mapAttrs` from `lib`.
This keeps the source of each function visible.
Avoid:
```nix
let
inherit (lib) mapAttrs;
in
mapAttrs f attrs
```
Use:
```nix
lib.mapAttrs f attrs
```

View File

@@ -1,109 +0,0 @@
# Technical Writing
This guide defines writing rules for comments, user-facing descriptions, commit prose, pull-request prose, and Markdown documentation.
These rules apply throughout this repository.
ASD-STE100 Simplified Technical English partly informs these rules.
This repository uses those ideas as a local style discipline.
It does not claim formal ASD-STE100 compliance.
Read the local dictionary with this guide.
In this guide, “the dictionary” means `docs/dictionary.md`.
The dictionary defines banned terms and the strict domain allowlist.
## Structural Conciseness and Grammar Rules
- **Clear and concise style:** Use direct, concise writing.
Use simple present tense for factual descriptions.
Do not use uncertainty words such as `could`, `might`, and `should` unless uncertainty is the subject.
Use each domain word with the meaning assigned in the dictionary.
Avoid: `The tool should usually handle the input correctly.`
Use: `The tool validates the input.`
- **Avoid jargon and inflated language:** Use common technical words.
Avoid unnecessary jargon in three categories:
1. Linguistic and grammatical meta-jargon.
Avoid: `modal auxiliaries`
Use: `uncertain words`
2. Inflated corporate phrasing.
Avoid: `utilize`
Use: `use`
Avoid: `commence`
Use: `start`
3. Unapproved domain inventions.
Do not use project-specific terms unless the dictionary defines them.
General technical words do not need dictionary entries.
Add a common word to the dictionary when it has a project-specific meaning.
If a term is vague or overloaded, choose one precise term.
List avoided alternatives in the dictionary.
Choose the simplest word that accurately communicates the meaning.
- **Sentence length limits:** Code comments, user-facing descriptions, and procedural instructions have a 20-word limit.
Descriptive Markdown paragraphs have a 25-word limit.
Do not put multiple sentences on one physical line.
Wrap a sentence across multiple physical lines when readability requires it.
Split long sentences into multiple concise sentences.
Avoid: `The command configures the service with the generated settings and restarts it after the file changes.`
Use: `The command configures the service with the generated settings.`
Use: `It restarts the service after the file changes.`
- **Active voice:** Always use active voice.
The subject must perform the action.
Do not use passive voice.
Avoid: `Stale entries are invalidated by the cache.`
Use: `The cache invalidates stale entries.`
- **Imperative mood:** Procedural instructions and task steps must start with an imperative verb.
Summary fragments for declarations use third-person singular.
Avoid: `The token should be validated before use.`
Use for a task step: `Validate the token before use.`
Use for a declaration summary: `Validates the token before use.`
- **Noun clusters:** Do not write more than three consecutive nouns without a preposition or hyphen.
Avoid: `session cache entry lookup`
Use: `lookup of session cache entries`
- **One idea per sentence:** Express only one instruction or core concept per sentence.
Do not combine multiple operations in one compound sentence.
Avoid: `Validate the input and restart the service when validation succeeds.`
Use: `Validate the input.`
Use: `Restart the service when validation succeeds.`
- **Code token exemption:** Text in Markdown backticks, URLs, and code blocks counts as one word.
Sentence-length limits and noun-cluster limits do not apply to these tokens.
Example: `curl --fail --silent https://example.com/health` counts as one word.
- **Cause and time precision:** Use `because` for causality.
Use `since` only for passage of time.
Use `as` only for simultaneous action.
Avoid: `Since the token is expired, validation fails.`
Use: `Because the token is expired, validation fails.`
## In-Code Documentation Conventions
- **Declaration coverage and exemptions:** Document public interfaces, configuration options, fields, and non-trivial public methods.
Omit comments on obvious declarations when the name and context are self-explanatory.
Avoid: `Enable this option.`
Use: `Enables automatic cleanup after a successful run.`
- **Declaration non-redundancy:** Comments must explain purpose, preconditions, invariants, or non-obvious consequences.
Do not repeat information that the name, type, path, or parameter list already conveys.
Avoid: `The timeout option sets the timeout.`
Use: `Controls how long the command waits before it reports failure.`
- **Multi-line declaration comments:** Start multi-line declaration comments with one summary sentence.
Put a blank line between the summary and detail paragraphs.
Use detail paragraphs for preconditions, invariants, examples, and non-obvious consequences.
Avoid:
```text
The caller must load the signing key before calling this method.
Validates the token and returns false for expired tokens.
```
Use:
```text
Validates the token.
The caller must load the signing key before calling this method.
Expired tokens return false instead of an error.
```
- **Code symbol references:** Enclose identifiers, paths, commands, option names, and file names in Markdown backticks.
Avoid: Run npm test before editing package.json.
Use: Run `npm test` before editing `package.json`.
- **Boolean descriptions:** Start boolean descriptions with the behavior verb.
Use `True if` only when the value reports state instead of enabling behavior.
Avoid: `Flag that specifies whether retries are enabled.`
Use: `Enables retry behavior.`
Use for state: `True if the token is expired.`

153
flake.lock generated
View File

@@ -75,11 +75,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1785327209,
"narHash": "sha256-heXGjUBU1UsTHFzedDzYct9Cblr6FGzQcYyjCykywh8=",
"lastModified": 1784318604,
"narHash": "sha256-P/N5ZbGWITiTfmiWpE/1uyXdOCagpgw/YAZLZJSzx/I=",
"owner": "chaotic-cx",
"repo": "nyx",
"rev": "90cfa9864fa08c923dddeca965103ad44663dd64",
"rev": "21a8ef816f34558a438d778057a8809322ea2415",
"type": "github"
},
"original": {
@@ -109,28 +109,6 @@
"type": "github"
}
},
"firefox-addons": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"dir": "pkgs/firefox-addons",
"lastModified": 1785384175,
"narHash": "sha256-sWSJPXpQwKJstL4rdhpAQYCYlHK5wOkEHR8/lNHBVb4=",
"owner": "rycee",
"repo": "nur-expressions",
"rev": "db607f3d0afe811bcb3b16266f28b2fc5af4e74f",
"type": "gitlab"
},
"original": {
"dir": "pkgs/firefox-addons",
"owner": "rycee",
"repo": "nur-expressions",
"type": "gitlab"
}
},
"firefox-gnome-theme": {
"flake": false,
"locked": {
@@ -258,28 +236,6 @@
"url": "https://git.alexion.dev/alexion/gitea-axi"
}
},
"gitea-axi_2": {
"inputs": {
"home-manager": "home-manager_4",
"nixpkgs": [
"skills",
"nixpkgs"
]
},
"locked": {
"lastModified": 1785340481,
"narHash": "sha256-GSxdQ7w8yYfZnfkXUuZ2fYIKibe9ZU8xDGyqpeTb2tE=",
"ref": "refs/heads/main",
"rev": "627fc9a32bb80d97923540fc0d3e9661961462ba",
"revCount": 83,
"type": "git",
"url": "https://git.alexion.dev/alexion/gitea-axi"
},
"original": {
"type": "git",
"url": "https://git.alexion.dev/alexion/gitea-axi"
}
},
"gnome-shell": {
"flake": false,
"locked": {
@@ -307,11 +263,11 @@
]
},
"locked": {
"lastModified": 1785288465,
"narHash": "sha256-nCkxaGRtyNheNTxoc527gjOG0BN2zovsWDQVBeKDMW8=",
"lastModified": 1784129366,
"narHash": "sha256-N5JiyICSeQF14x+OQebNyPpYowOT9Rs1iKyeCylSzOA=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "36662afed2fa1c9b69bdd03edb92ad572202ca20",
"rev": "165228b0efefc3e635e5174020c40ea64271dc25",
"type": "github"
},
"original": {
@@ -348,11 +304,11 @@
]
},
"locked": {
"lastModified": 1785306346,
"narHash": "sha256-DScBkW0fOgpGPK2trNoX3ryLTlaC14+gglFo/BhGJ4g=",
"lastModified": 1784351324,
"narHash": "sha256-By+kuRJZRqs2TuXgtR8vJ8cTKWXw33YG/Yollu5cO1U=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "e705714e918c3b11affcdd15db2cbe3a070420a0",
"rev": "460108009ca1ff69ca2ff19079ca2c838d6e3080",
"type": "github"
},
"original": {
@@ -362,28 +318,6 @@
}
},
"home-manager_4": {
"inputs": {
"nixpkgs": [
"skills",
"gitea-axi",
"nixpkgs"
]
},
"locked": {
"lastModified": 1784588016,
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"home-manager_5": {
"inputs": {
"nixpkgs": [
"skills",
@@ -404,21 +338,6 @@
"type": "github"
}
},
"monorepo": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1788059616,
"narHash": "sha256-HBYT3h0vXRsvmgPOhUW3kgPTkgdl73PEwRTJQ5X48aM=",
"path": "/home/alexion/dev",
"type": "path"
},
"original": {
"path": "/home/alexion/dev",
"type": "path"
}
},
"nixos-hardware": {
"inputs": {
"nixpkgs": [
@@ -426,11 +345,11 @@
]
},
"locked": {
"lastModified": 1785232496,
"narHash": "sha256-65EQYIRRpTdpH8lUiB6Mvo5uBkG60aBIzAJuALfx+O0=",
"lastModified": 1784310968,
"narHash": "sha256-rkSPTePrKqs4dg+i7ZFCq93+HrClac6oSwXX927SVjA=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "2e790b0a6be8ec2b76174ac0931b8ff11919ec98",
"rev": "779c32a00155994c86cde8213a8dd4df139d4355",
"type": "github"
},
"original": {
@@ -441,11 +360,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1785090369,
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=",
"lastModified": 1784120854,
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "624af665418d3c65d544145b4d34ad696439570e",
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
"type": "github"
},
"original": {
@@ -457,11 +376,11 @@
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1785133411,
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=",
"lastModified": 1784280462,
"narHash": "sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3",
"rev": "293d6abedf0478e681a4dfcfcb35b30fc796a32f",
"type": "github"
},
"original": {
@@ -473,11 +392,11 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1785301185,
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
"lastModified": 1784347607,
"narHash": "sha256-VI5cdo27nEZ3m1SlgB8RvBbrqFUO2/dUgrrLWe407oA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
"rev": "31cd72fdba8fa052e437ce7e6879c4fe62def10f",
"type": "github"
},
"original": {
@@ -489,11 +408,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1785318670,
"narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
"lastModified": 1784120854,
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
"type": "github"
},
"original": {
@@ -512,11 +431,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1785364321,
"narHash": "sha256-BLuHl+nZKb+FDq3GAM6L+UBEiyVepXANA31fT1F56pw=",
"lastModified": 1784057377,
"narHash": "sha256-yycNej5//EsRbV10moBoh+/63vXEwZD1ZFEiRm6C9rQ=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "acd69cc15d57004e8cb4495034320263a3d362ea",
"rev": "07180a087e4a00720dc0731cbcd8dec796974381",
"type": "github"
},
"original": {
@@ -554,15 +473,10 @@
"inputs": {
"chaotic": "chaotic",
"disko": "disko",
"firefox-addons": "firefox-addons",
"gitea-axi": "gitea-axi",
"home-manager": "home-manager_3",
"monorepo": "monorepo",
"nixos-hardware": "nixos-hardware",
"nixpkgs": [
"monorepo",
"nixpkgs"
],
"nixpkgs": "nixpkgs_2",
"nixpkgs-stable": "nixpkgs-stable",
"nixpkgs-unstable": "nixpkgs-unstable",
"nixvim": "nixvim",
@@ -574,18 +488,17 @@
"skills": {
"inputs": {
"flake-utils": "flake-utils",
"gitea-axi": "gitea-axi_2",
"home-manager": "home-manager_5",
"home-manager": "home-manager_4",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1787455371,
"narHash": "sha256-MJ0bX4/cksSbGl7v9o0l+Wy1xAEa3BgzG7Hs8EEGde0=",
"lastModified": 1785340476,
"narHash": "sha256-+uVjxpCBKtwsFBhkHTtCfnCZIZa8xLzwpe1k71DM4hM=",
"ref": "refs/heads/main",
"rev": "9a1d21da4d3e70165ff772967d639df244dcc2ea",
"revCount": 55,
"rev": "02f2017b4026cb51f04070e62bc4775a2e5d2edd",
"revCount": 21,
"type": "git",
"url": "https://git.alexion.dev/alexion/skills"
},

View File

@@ -2,10 +2,8 @@
description = "Alexion's NixOS configuration — one flake for every host";
inputs = {
monorepo.url = "path:/home/alexion/dev";
# Base package set shared with the monorepo.
nixpkgs.follows = "monorepo/nixpkgs";
# Base channel.
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
# Fresher packages, reachable per-package as `unstable.<name>`.
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable";
@@ -18,12 +16,6 @@
inputs.nixpkgs.follows = "nixpkgs";
};
# Signed AMO extensions, pinned by version and hash.
firefox-addons = {
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
inputs.nixpkgs.follows = "nixpkgs";
};
# Follows our nixpkgs so its plugins build against the same package set.
nixvim = {
url = "github:nix-community/nixvim";
@@ -78,9 +70,14 @@
{ self, nixpkgs, ... }@inputs:
let
inherit (nixpkgs) lib;
my = import ./lib.nix { inherit lib inputs self; };
in
{
inherit (import ./flake { inherit lib inputs self; }) nixosConfigurations;
# Helper functions for discovering and building hosts.
lib = my;
# Every host under hosts/ is discovered and built.
nixosConfigurations = my.mkHosts (self + "/hosts");
# A project shell for agent-local resources that should travel with this
# checkout rather than the operator's global profile.
@@ -95,13 +92,9 @@
];
};
# `nix flake check` builds every discovered host toplevel.
# `nix flake check` builds each host's toplevel.
checks.x86_64-linux = lib.mapAttrs (
name: host:
if host.config.warnings == [ ] then
host.config.system.build.toplevel
else
throw "Host ${name} has evaluation warnings:\n${lib.concatStringsSep "\n" host.config.warnings}"
_name: host: host.config.system.build.toplevel
) self.nixosConfigurations;
};
}

View File

@@ -1,53 +0,0 @@
{
lib,
inputs,
self,
}:
let
util = import ../lib { inherit lib; };
registry = import ./registry.nix {
inherit lib;
hosts = self.nixosConfigurations;
root = self + "/registry";
};
modules = import ./module.nix {
inherit util;
root = self + "/modules";
};
guestModules = import ./guest.nix {
inherit
inputs
lib
registry
specialArgs
util
;
repositoryModules = modules;
root = self + "/guests";
};
specialArgs = {
inherit
inputs
self
util
;
registry = registry.collected;
};
in
{
nixosConfigurations = import ./host.nix {
inherit
inputs
lib
specialArgs
guestModules
;
registryModule = registry.module;
repositoryModules = modules;
root = self + "/hosts";
};
}

View File

@@ -1,279 +0,0 @@
{
inputs,
lib,
root,
specialArgs,
repositoryModules,
registry,
util,
}:
let
mkGuest =
name:
{ config, lib, ... }:
let
cfg = config.guests.${name};
directoryMounts = lib.filterAttrs (_: mount: mount.directory != null) cfg.mounts;
directoryScript = lib.concatStringsSep "\n" (
lib.mapAttrsToList (
_: mount:
''
${config.system.path}/bin/install -d \
${lib.optionalString (mount.owner != null) "-o ${lib.escapeShellArg (toString mount.owner)}"} \
${lib.optionalString (mount.group != null) "-g ${lib.escapeShellArg mount.group}"} \
${lib.optionalString (mount.mode != null) "-m ${lib.escapeShellArg mount.mode}"} \
${lib.escapeShellArg mount.directory}
''
) directoryMounts
);
in
{
options.guests.${name} = {
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
vlan = lib.mkOption {
type = lib.types.ints.between 1 4094;
example = 10;
description = ''
The tagged VLAN this guest lives on. The guest attaches to its host's
`br-vlan<id>` bridge for that VLAN. The id must be one of the host's
`modules.network.vlans.ids`.
'';
};
mac = lib.mkOption {
type = lib.types.str;
default = util.deriveMac "${config.networking.hostName}.${name}";
defaultText = lib.literalMD "a stable address derived from the host name and guest option path";
example = "bc:24:11:00:00:01";
description = ''
The guest's MAC address on its VLAN, pinned inside the guest by its
own networkd. Set it to reuse an existing address so a router's DHCP
reservation keeps working. Left unset, a stable address is derived
from the host name and guest option path in the locally-administered range.
'';
};
address = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.23.20.126";
description = ''
Expected IP address assigned outside NixOS for this guest.
Left null, this guest contributes no address to the address registry.
'';
};
reverseProxy = {
enable = lib.mkEnableOption "a reverse-proxy route for this guest";
route = lib.mkOption {
type = registry.types.route;
description = "Route registry entry published for this guest.";
};
};
mounts = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
directory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/media";
description = "Host directory bind-mounted into the guest.";
};
file = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/run/secrets/service-password";
description = "Host file bind-mounted into the guest.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Mount the host path read-only inside the guest.";
};
owner = lib.mkOption {
type = lib.types.nullOr (
lib.types.oneOf [
lib.types.str
lib.types.ints.positive
]
);
default = null;
description = "Owner assigned to the host directory before the guest starts.";
};
group = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Group assigned to the host directory before the guest starts.";
};
mode = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "0770";
description = "Mode assigned to the host directory before the guest starts.";
};
};
}
);
default = { };
example = lib.literalExpression ''
{
"/data/media" = { directory = "/srv/media"; };
"/data/config" = { directory = "/srv/config/jellyfin"; };
}
'';
description = ''
Host paths bind-mounted into the guest, keyed by the path they appear
at inside the guest, so a guest sees exactly the data it should at any
granularity — a single folder, a whole pool, or a decrypted secret.
Mounts are read-write unless `readOnly` is set.
'';
};
interior = lib.mkOption {
type = lib.types.deferredModule;
default = { };
description = "NixOS module imported into the guest interior.";
};
limits = {
memory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "2G";
description = ''
Cap on the guest's memory, applied to its unit as `MemoryMax`.
Accepts systemd size suffixes such as `512M` or `2G`. Left null,
the guest's memory is uncapped.
'';
};
cpu = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "150%";
description = ''
Cap on the guest's CPU, applied to its unit as `CPUQuota`, where
`100%` is one full core. Left null, the guest's CPU is uncapped.
'';
};
tasksMax = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive;
default = null;
example = 512;
description = ''
Cap on the number of processes and threads the guest may spawn,
applied to its unit as `TasksMax`. Left null, the task count is
uncapped.
'';
};
};
};
config = lib.mkIf cfg.enable {
assertions =
let
hasDirectorySettings = mount: mount.owner != null || mount.group != null || mount.mode != null;
hasCompleteDirectorySettings = mount: mount.owner != null && mount.group != null && mount.mode != null;
hasOneSource = mount: (mount.directory != null) != (mount.file != null);
mountsWithInvalidSource = lib.attrNames (lib.filterAttrs (_: mount: !hasOneSource mount) cfg.mounts);
fileMountsWithHostSettings = lib.attrNames (
lib.filterAttrs (_: mount: mount.file != null && hasDirectorySettings mount) cfg.mounts
);
directoryMountsWithPartialHostSettings = lib.attrNames (
lib.filterAttrs (_: mount: mount.directory != null && hasDirectorySettings mount && !hasCompleteDirectorySettings mount) cfg.mounts
);
in
[
{
assertion = mountsWithInvalidSource == [ ];
message = ''
guests.${name} mounts ${lib.concatStringsSep ", " mountsWithInvalidSource} must set exactly one of `directory` or `file`.
'';
}
{
assertion = fileMountsWithHostSettings == [ ];
message = ''
guests.${name} file mounts ${lib.concatStringsSep ", " fileMountsWithHostSettings} must not set `owner`, `group`, or `mode`.
'';
}
{
assertion = directoryMountsWithPartialHostSettings == [ ];
message = ''
guests.${name} directory mounts ${lib.concatStringsSep ", " directoryMountsWithPartialHostSettings} must set `owner`, `group`, and `mode` together.
'';
}
{
assertion = lib.elem cfg.vlan config.modules.network.vlans.ids;
message = ''
guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans.ids (${lib.concatMapStringsSep ", " toString config.modules.network.vlans.ids}). Declare the VLAN on the host or correct the guest's placement.
'';
}
];
registry.address.${config.networking.hostName}.${name} =
lib.mkIf (cfg.address != null) cfg.address;
registry.route.${config.networking.hostName}.${name} =
lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route;
systemd.services."container@${name}" =
let
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (util.bridgeName cfg.vlan)}.device";
in
{
after = [ bridgeDevice ];
wants = [ bridgeDevice ];
serviceConfig = lib.filterAttrs (_: v: v != null) {
MemoryMax = cfg.limits.memory;
CPUQuota = cfg.limits.cpu;
TasksMax = cfg.limits.tasksMax;
};
preStart = lib.mkIf (directoryMounts != { }) directoryScript;
};
containers.${name} = {
autoStart = true;
privateNetwork = lib.mkDefault true;
hostBridge = util.bridgeName cfg.vlan;
privateUsers = lib.mkDefault "no";
bindMounts = lib.mapAttrs (_: mount: {
hostPath = if mount.directory != null then mount.directory else mount.file;
isReadOnly = mount.readOnly;
}) cfg.mounts;
inherit specialArgs;
config = {
imports = [
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
repositoryModules
registry.module
cfg.interior
{
modules.guest.enable = true;
networking.useNetworkd = true;
networking.useHostResolvConf = false;
systemd.network.networks."20-eth0" = {
matchConfig.Name = "eth0";
linkConfig.MACAddress = cfg.mac;
networkConfig.DHCP = "yes";
};
}
];
};
};
};
};
in
let
files = util.collectNixFiles root;
guestFiles = lib.filterAttrs (file: type: type == "regular" && lib.hasSuffix ".nix" file) (builtins.readDir root);
names = map (file: lib.removeSuffix ".nix" file) (lib.attrNames guestFiles);
in
{
imports = files ++ map mkGuest names;
}

View File

@@ -1,45 +0,0 @@
{
inputs,
lib,
root,
specialArgs,
repositoryModules,
guestModules,
registryModule,
}:
let
mkHost =
{
root,
name,
}:
lib.nixosSystem {
inherit specialArgs;
modules = [
inputs.chaotic.nixosModules.default
inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
repositoryModules
guestModules
# TODO: refactor
registryModule (root + "/${name}")
{
networking.hostName = name;
modules.host.enable = true;
}
];
};
in
let
directories = lib.filterAttrs (_name: type: type == "directory") (builtins.readDir root);
names = lib.attrNames directories;
in
lib.genAttrs names (
name:
mkHost { inherit root name; }
)

View File

@@ -1,7 +0,0 @@
{
root,
util,
}:
{
imports = util.collectNixFiles root;
}

View File

@@ -1,197 +0,0 @@
{ hosts, lib, root }:
let
checkType = lib.types.submodule {
options = {
assertion = lib.mkOption {
type = lib.types.functionTo lib.types.bool;
description = ''
Predicate run against this registry's flattened collected entries.
'';
};
message = lib.mkOption {
type = lib.types.str;
description = ''
Error message shown when this registry validation check fails.
'';
};
};
};
predicates = {
uniqueValue = value: entries: builtins.length (lib.unique (map value entries)) == builtins.length entries;
};
registryLib = lib // {
registry = (lib.registry or { }) // {
inherit predicates;
};
};
registryDefinitionModule = {
options = {
type = lib.mkOption {
type = lib.types.raw;
description = ''
NixOS option type for one registry entry.
'';
};
replicate = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether collected entries are merged and published under every host key.
'';
};
checks = lib.mkOption {
type = lib.types.listOf checkType;
default = [ ];
description = ''
Validation checks for this registry's flattened collected entries.
'';
};
};
};
registryName = fileName: lib.removeSuffix ".nix" fileName;
registryOption = registry: lib.mkOption {
type = lib.types.attrsOf (lib.types.attrsOf registry.type);
default = { };
description = ''
Lazily collected fleet-visible registry entries, nested by host and entry name.
'';
};
mkRegistry = path:
let
evaluated = lib.evalModules {
specialArgs.lib = registryLib;
modules = [
registryDefinitionModule
path
];
};
in
{
inherit (evaluated.config) type replicate checks;
};
definitions = lib.mapAttrs' (
name: type:
let
path = root + "/${name}";
in
lib.nameValuePair (registryName name) (mkRegistry path)
) (lib.filterAttrs (name: type: type == "regular" && lib.hasSuffix ".nix" name) (builtins.readDir root));
flattenRegistryEntries = registryName: data:
lib.concatLists (
lib.mapAttrsToList (
host: entries:
lib.mapAttrsToList (entry: value: {
path = "registry.${registryName}.${host}.${entry}";
inherit host entry value;
}) entries
) data
);
rawDeclarations = registryName: hosts:
lib.mapAttrs (_hostName: host: host.config.registry.${registryName} or { }) hosts;
replicatedEntries = registryName: raw:
let
entries = lib.concatLists (
lib.concatLists (
lib.mapAttrsToList (
_evalHost: hostDeclarations:
lib.mapAttrsToList (
hostName: declarations:
lib.mapAttrsToList (entryName: value: {
path = "registry.${registryName}.${hostName}.${entryName}";
inherit entryName value;
}) declarations
) hostDeclarations
) raw
)
);
in
lib.foldl' (
acc: entry:
if lib.hasAttr entry.entryName acc then
if acc.${entry.entryName}.value == entry.value then
acc
else
throw "conflicting replicated registry entry ${entry.path} does not match ${acc.${entry.entryName}.path}"
else
acc // {
${entry.entryName} = entry;
}
) { } entries;
replicatedData = registryName: hosts: raw:
let
entries = lib.mapAttrs (_entryName: entry: entry.value) (replicatedEntries registryName raw);
in
lib.mapAttrs (_hostName: _host: entries) hosts;
validationAssertions = data:
lib.concatLists (
lib.mapAttrsToList (
registryName: registry:
let
entries = flattenRegistryEntries registryName data.${registryName};
in
map (check: {
assertion = check.assertion entries;
inherit (check) message;
}) registry.checks
) definitions
);
assertValid = data:
let
assertions = validationAssertions data;
failures = lib.filter (assertion: !assertion.assertion) assertions;
in
assert lib.assertMsg (lib.all (assertion: assertion.assertion) assertions) (
lib.concatMapStringsSep "\n" (assertion: assertion.message) failures
);
data;
join = registry: host: entry:
lib.filterAttrs (_name: value: value != null) (
lib.mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions
);
collectData =
{ hosts }:
let
raw = lib.mapAttrs (registryName: _registry: rawDeclarations registryName hosts) definitions;
data = lib.mapAttrs (
registryName: registry:
if registry.replicate or false then
replicatedData registryName hosts raw.${registryName}
else
lib.mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts
) definitions;
in
assertValid data;
withOperations = registry:
registry
// {
join = host: entry: join registry host entry;
};
in
{
types = lib.mapAttrs (_name: definition: definition.type) definitions;
module = {
options.registry = lib.mapAttrs (_name: registry: registryOption registry) definitions;
};
collected = withOperations (collectData { inherit hosts; });
}

31
guest.nix Normal file
View File

@@ -0,0 +1,31 @@
{
my,
inputs,
lib,
...
}:
# The guest-base: the slim foundation every nested guest's interior stands on.
# It imports the full modules tree so any module is available to enable inside a
# guest, and stands on the same shared base a host does.
{
imports = my.collectNixFiles (inputs.self + "/modules") ++ [
(inputs.self + "/base.nix")
# The modules tree reaches for these option namespaces, so they must be
# declared for the tree to evaluate even where a guest leaves them off.
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
];
# A nested container has no per-host `default.nix` to pin its release.
system.stateVersion = "26.05";
# The baseline toolset and SSH access, so any guest shelled into is a workable
# environment without per-guest wiring.
modules.toolkit.enable = lib.mkDefault true;
modules.ssh.enable = lib.mkDefault true;
# A guest carries no host identity, so it presents a self-generated host key
# rather than restoring one from secrets.
modules.ssh.hostKeys.restore = lib.mkDefault false;
}

View File

@@ -1,43 +0,0 @@
{
config,
lib,
...
}:
let
cfg = config.guests.actualbudget;
dataDir = "/data";
in
{
options.guests.actualbudget = {
dataPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/actualbudget";
description = "Host path bind-mounted as Actual Budget's data directory.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.dataPath != null;
message = "guests.actualbudget.dataPath must point at the host directory that stores Actual Budget data.";
}
];
guests.actualbudget = {
interior = {
modules.services.actualbudget = {
enable = true;
inherit dataDir;
};
};
mounts.${dataDir}.directory = cfg.dataPath;
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";
tasksMax = lib.mkDefault 512;
};
};
};
}

View File

@@ -1,128 +0,0 @@
{
config,
lib,
...
}:
let
cfg = config.guests.copyparty;
volumeType = lib.types.submodule {
options = {
hostPath = lib.mkOption {
type = lib.types.str;
description = "Host path bind-mounted into the Copyparty guest for this volume.";
};
path = lib.mkOption {
type = lib.types.str;
description = "Guest path served by this Copyparty volume.";
};
access = lib.mkOption {
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
default = { };
description = "Copyparty access map from permission string to account names.";
};
flags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Copyparty volume flags.";
};
};
};
in
{
options.guests.copyparty = {
accountName = lib.mkOption {
type = lib.types.str;
default = "alexion";
description = "Copyparty account name configured for password authentication.";
};
passwordSecret = lib.mkOption {
type = lib.types.str;
default = "services/copyparty/users/alexion/password";
description = "SOPS secret name containing the Copyparty account password.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/copyparty";
description = "Guest path where Copyparty stores service state.";
};
statePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/copyparty";
description = "Host path bind-mounted as Copyparty's state directory.";
};
reverseProxy.trustedSources = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "IP ranges that may supply Copyparty's forwarded client IP header.";
};
volumes = lib.mkOption {
type = lib.types.attrsOf volumeType;
default = { };
description = "Copyparty volumes keyed by their URL path.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.statePath != null;
message = "guests.copyparty.statePath must point at the host directory that stores Copyparty state.";
}
{
assertion = cfg.volumes != { };
message = "guests.copyparty.volumes must declare the host's served Copyparty volumes.";
}
];
guests.copyparty = {
interior.modules.services.copyparty = {
enable = true;
inherit (cfg) accountName stateDir;
passwordFile = "/run/secrets/${cfg.passwordSecret}";
reverseProxy = {
enable = cfg.reverseProxy.enable;
inherit (cfg.reverseProxy) trustedSources;
};
volumes = lib.mapAttrs (_urlPath: volume: {
inherit (volume) path access flags;
}) cfg.volumes;
};
mounts =
{
${cfg.stateDir} = {
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = "storage";
mode = "0770";
};
${"/run/secrets/${cfg.passwordSecret}"} = {
file = config.sops.secrets.${cfg.passwordSecret}.path;
readOnly = true;
};
}
// lib.mapAttrs' (_urlPath: volume: lib.nameValuePair volume.path { directory = volume.hostPath; }) cfg.volumes;
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";
tasksMax = lib.mkDefault 512;
};
};
sops.secrets.${cfg.passwordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
};
}

11
guests/nesting-sample.nix Normal file
View File

@@ -0,0 +1,11 @@
args@{ my, ... }:
# A sample guest whose interior runs an OCI container on Podman.
# The image is pulled at runtime, so the guest builds with no build-time fetch.
my.guest {
name = "nesting-sample";
interior = {
virtualisation.oci-containers.containers.hello = {
image = "docker.io/library/hello-world";
};
};
} args

View File

@@ -1,75 +0,0 @@
{
config,
lib,
...
}:
let
giteaSshBackend = "10.23.20.45:2022";
in
{
config = lib.mkIf config.guests.reverse-proxy.enable {
guests.reverse-proxy = {
interior = {
modules.services.reverse-proxy.enable = true;
services.nginx.virtualHosts = {
"games.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"git.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"music.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"photos.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"plex.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"servers.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"todo.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"wings.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
};
networking.firewall.allowedTCPPorts = [ 2022 ];
services.nginx.streamConfig = ''
server {
listen 2022;
proxy_pass ${giteaSshBackend};
}
'';
};
limits = {
memory = lib.mkDefault "1G";
cpu = lib.mkDefault "100%";
tasksMax = lib.mkDefault 256;
};
};
};
}

5
guests/sample.nix Normal file
View File

@@ -0,0 +1,5 @@
args@{ my, ... }:
# The tracer-bullet guest: the thinnest complete path from discovery to a
# running nested container. Its interior is just the guest-base — the baseline
# toolset and SSH access — so it proves the concept without carrying a service.
my.guest { name = "sample"; } args

View File

@@ -1,4 +1,5 @@
{
config,
inputs,
pkgs,
...
@@ -31,22 +32,44 @@
# So wifi can be joined from the console.
networking.networkmanager.enable = true;
registry.address.neogaia.self = "10.23.50.146";
# So setup can be driven over the network.
# The matching host public keys sit beside this file in plaintext, since
# publishing them is their purpose.
modules.network.ssh.enable = true;
modules.network.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
modules.network.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
modules.ssh.enable = true;
modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
modules.shell.herdr.enable = true;
# A machine the operator works from, so it admits the workstation keys alone.
modules.ssh.authorizedKeys = config.modules.ssh.workstationKeys;
modules.toolkit.enable = true;
# The walking-skeleton guest, enabled like any module: proves the guest path
# end to end through this host's `nix flake check`.
# Modest caps keep the skeleton guest from starving the laptop.
guests.sample.enable = true;
guests.sample.limits = {
memory = "1G";
cpu = "100%";
tasksMax = 512;
};
# The nesting guest, run with `nesting` on: proves an interior OCI container
# on Podman builds end to end through this host's `nix flake check`.
guests.nesting-sample.enable = true;
guests.nesting-sample.nesting = true;
guests.nesting-sample.limits = {
memory = "1G";
cpu = "100%";
tasksMax = 512;
};
modules.agents.claude-code.enable = true;
modules.agents.tools.gitea-axi.enable = true;
modules.agents.pi.enable = true;
modules.desktop.enable = true;
modules.desktop.apps.obsidian.enable = true;
modules.desktop.apps.steam.enable = true;
modules.desktop.obsidian.enable = true;
time.timeZone = "America/New_York";
i18n.defaultLocale = "en_GB.UTF-8";

View File

@@ -1,149 +0,0 @@
{
config,
pkgs,
registry,
...
}:
# pikachu — AZW ME Pro server.
# Disk layout is in ./disk.nix.
# `fileSystems` for the root disk are derived from it.
{
imports = [
./hardware-configuration.nix
./disk.nix
];
system.stateVersion = "26.05";
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
hardware.cpu.intel.updateMicrocode = true;
hardware.enableRedistributableFirmware = true;
zramSwap.enable = true;
systemd.network = {
enable = true;
netdevs = {
"40-br-vlan20".netdevConfig = {
Name = "br-vlan20";
Kind = "bridge";
};
"40-vlan20" = {
netdevConfig = {
Name = "vlan20";
Kind = "vlan";
};
vlanConfig.Id = 20;
};
};
networks = {
"10-uplink" = {
matchConfig = {
Name = "enp2s0";
MACAddress = "78:55:36:07:af:49";
};
networkConfig.DHCP = "yes";
vlan = [ "vlan20" ];
linkConfig.RequiredForOnline = "routable";
};
"40-vlan20" = {
matchConfig.Name = "vlan20";
networkConfig.Bridge = "br-vlan20";
linkConfig.RequiredForOnline = "no";
};
"40-br-vlan20" = {
matchConfig.Name = "br-vlan20";
linkConfig.RequiredForOnline = "no";
};
};
};
networking.useDHCP = false;
modules.network.vlans.ids = [ 20 ];
boot.zfs.forceImportRoot = false;
modules.zfs = {
enable = true;
hostId = "2346edbd";
pools.pikachu = { };
};
registry.address.pikachu.self = "10.23.10.102";
modules.network.ssh.enable = true;
modules.network.ssh.hostKeys.sopsFile = ../../secrets/pikachu.yaml;
modules.network.ssh.userKey.sopsFile = ../../secrets/pikachu.yaml;
guests.actualbudget = {
enable = true;
vlan = 20;
mac = "BC:24:11:C1:CD:28";
dataPath = "/pikachu/data/actualbudget";
address = "10.23.20.42";
reverseProxy = {
enable = true;
route = {
domain = "budget.alexion.dev";
port = 5006;
};
};
};
guests.reverse-proxy = {
enable = true;
vlan = 20;
mac = "02:98:64:5a:07:4f";
address = "10.23.20.109";
};
guests.copyparty = {
enable = true;
vlan = 20;
mac = "02:23:20:39:23:01";
statePath = "/pikachu/data/copyparty";
address = "10.23.20.126";
reverseProxy = {
enable = true;
route = {
domain = "files.alexion.dev";
port = 3923;
};
trustedSources = [ "${registry.address.${config.networking.hostName}."reverse-proxy"}/32" ];
};
volumes = {
"/inbox" = {
hostPath = "/pikachu/__inbox";
path = "/srv/inbox";
access.A = [ "alexion" ];
flags = [
"d2d"
"d2t"
"dthumb"
];
};
"/media" = {
hostPath = "/pikachu/media";
path = "/srv/media";
access.A = [ "alexion" ];
flags = [
"d2d"
"d2t"
"dthumb"
];
};
};
};
environment.systemPackages = with pkgs; [
pciutils
smartmontools
usbutils
];
time.timeZone = "America/New_York";
i18n.defaultLocale = "en_GB.UTF-8";
}

View File

@@ -1,32 +0,0 @@
{ ... }:
# pikachu's install layout for disko: one NVMe boot disk with an EFI system partition and ext4 root.
# The existing 8 TB ZFS mirror is imported by name and is never declared here.
{
disko.devices.disk.main = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
ESP = {
size = "2G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}

View File

@@ -1,18 +0,0 @@
{ lib, modulesPath, ... }:
# Hardware detected from the Proxmox inventory for this machine.
# disko derives the root disk filesystems, none declared here.
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
boot.initrd.availableKernelModules = [
"ahci"
"nvme"
"sd_mod"
"xhci_pci"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu

View File

@@ -1 +0,0 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu

421
lib.nix Normal file
View File

@@ -0,0 +1,421 @@
{
lib,
inputs,
self,
}:
let
inherit (lib)
attrNames
filterAttrs
genAttrs
flatten
hasSuffix
mapAttrsToList
;
# Recursively collect every `.nix` file under `dir` as a flat list, for a
# module's `imports`.
collectNixFiles =
dir:
flatten (
mapAttrsToList (
name: type:
let
path = dir + "/${name}";
in
if type == "directory" then
collectNixFiles path
else if type == "regular" && hasSuffix ".nix" name then
[ path ]
else
[ ]
) (builtins.readDir dir)
);
# The special arguments every configuration is evaluated with, host and guest
# interior alike.
specialArgs = {
inherit inputs;
my = self.lib;
};
# The name of a tagged VLAN's bridge, kept here as the one definition of a
# convention shared across the flake.
bridgeName = id: "br-vlan${toString id}";
# A guest with no operator-set MAC derives a stable one from its namespace path.
# The first octet 02 marks the address locally-administered and unicast.
# The rest is a slice of the path's hash.
# The same guest therefore always lands on the same address, which the operator can reserve at the router.
deriveMac =
name:
let
hash = builtins.hashString "sha256" name;
octet = i: builtins.substring (i * 2) 2 hash;
in
lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]);
# Build one host: every module and every guest is imported unconditionally
# (inert until its `enable` flag is set), alongside chaotic, the host base,
# and the host's own directory.
mkHost =
{
hostName,
system ? "x86_64-linux",
}:
inputs.nixpkgs.lib.nixosSystem {
inherit system specialArgs;
modules =
(collectNixFiles (self + "/modules"))
++ (collectNixFiles (self + "/guests"))
++ [
inputs.chaotic.nixosModules.default
inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
(self + "/system.nix")
(self + "/hosts/${hostName}")
{ networking.hostName = hostName; }
];
};
# Build a guest: a module-shaped definition whose body realizes its interior
# as a nested container standing on the guest-base, keyed by its namespace path.
# `name` is the dotted namespace under `guests.` and `interior` is an extra
# module merged into the container alongside the guest-base.
guest =
{
name,
interior ? { },
}:
{ config, lib, ... }:
let
optionPath = [ "guests" ] ++ lib.splitString "." name;
cfg = lib.getAttrFromPath optionPath config;
machineName = lib.replaceStrings [ "." ] [ "-" ] name;
networked = cfg.vlan != null;
# Host paths the operator maps into the guest, keyed by their in-guest path.
userMounts = lib.mapAttrs (_guestPath: m: {
inherit (m) hostPath;
isReadOnly = m.readOnly;
}) cfg.mounts;
# Each named secret bind-mounted read-only at the same `/run/secrets/<name>`
# path it holds on the host.
# No ownership is set here, since the container's one-to-one identity map
# carries the host file's owner through unchanged.
secretMounts = lib.listToAttrs (
map (
name:
let
path = config.sops.secrets.${name}.path;
in
lib.nameValuePair path {
hostPath = path;
isReadOnly = true;
}
) cfg.secrets
);
# An in-guest path claimed by both a mount and a secret, which the merge
# below would otherwise resolve silently in the secret's favour.
mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts);
# The resource caps the operator places on the guest's unit, dropping any
# left unset so systemd keeps its uncapped default for those.
limitConfig = lib.filterAttrs (_: v: v != null) {
MemoryMax = cfg.limits.memory;
CPUQuota = cfg.limits.cpu;
TasksMax = cfg.limits.tasksMax;
};
# A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container.
# The interface is eth0, the name a nested container gives its bridged veth.
# It takes the placement MAC, and the static address or DHCP when that is unset.
guestNet =
{ lib, ... }:
{
config = lib.mkIf networked {
networking.useNetworkd = true;
# networkd default-enables resolved, which owns the guest's resolv.conf.
# The nested-container default of inheriting the host's file conflicts with that, so the guest keeps its own.
networking.useHostResolvConf = false;
systemd.network.networks."20-eth0" = {
matchConfig.Name = "eth0";
linkConfig.MACAddress = cfg.mac;
networkConfig = lib.mkIf (cfg.address == null) { DHCP = "yes"; };
address = lib.mkIf (cfg.address != null) [ cfg.address ];
};
};
};
in
{
options = lib.setAttrByPath optionPath {
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
backend = lib.mkOption {
type = lib.types.enum [
"container"
"microvm"
];
default = "container";
description = ''
How the guest is realized. `container` runs the guest as a
systemd-nspawn nested container. `microvm` is reserved for a future
hard-isolation backend and is not built yet.
'';
};
vlan = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 1 4094);
default = null;
example = 10;
description = ''
The tagged VLAN this guest lives on. The guest attaches to its host's
`br-vlan<id>` bridge for that VLAN. Left null, the guest keeps a
private network with no bridge attachment. The id must be one of the
host's `modules.network.vlans`.
'';
};
mac = lib.mkOption {
type = lib.types.str;
default = deriveMac name;
defaultText = lib.literalMD "a stable address derived from the guest's namespace path";
example = "bc:24:11:00:00:01";
description = ''
The guest's MAC address on its VLAN, pinned inside the guest by its
own networkd. Set it to reuse an existing address so a router's DHCP
reservation keeps working. Left unset, a stable address is derived
from the guest's namespace path in the locally-administered range.
'';
};
address = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.0.10.5/24";
description = ''
The guest's static address, in CIDR form, on its VLAN. Left null, the
guest takes its address by DHCP, keeping IP management at the router.
'';
};
mounts = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
hostPath = lib.mkOption {
type = lib.types.str;
example = "/srv/media";
description = "The path on the host bind-mounted into the guest.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Mount the path read-only. Read-write by default, since a
service must write to the pool data it owns.
'';
};
};
}
);
default = { };
example = lib.literalExpression ''
{
"/data/media" = { hostPath = "/srv/media"; };
"/data/config" = {
hostPath = "/srv/config/jellyfin";
readOnly = true;
};
}
'';
description = ''
Host paths bind-mounted into the guest, keyed by the path they appear
at inside the guest, so a guest sees exactly the data it should at any
granularity — a single folder or a whole pool. Each mount is
read-write unless `readOnly` is set.
'';
};
secrets = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "jellyfin-api-key" ];
description = ''
Names of the secrets this guest needs. The host is the sole
decryptor: it decrypts each named secret from its own sops files and
bind-mounts the plaintext file into the guest read-only at
`/run/secrets/<name>`, the same path it would occupy on a host, so a
service reads its credentials at a predictable location. The guest
names the files it wants and receives exactly those. It holds no age
key and decrypts nothing itself. Ownership carries across unchanged,
since the container maps ids one to one, so a secret owned by a uid on
the host is owned by that same uid inside the guest.
'';
};
limits = {
memory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "2G";
description = ''
Cap on the guest's memory, applied to its unit as `MemoryMax`.
Accepts systemd size suffixes such as `512M` or `2G`. Left null,
the guest's memory is uncapped.
'';
};
cpu = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "150%";
description = ''
Cap on the guest's CPU, applied to its unit as `CPUQuota`, where
`100%` is one full core. Left null, the guest's CPU is uncapped.
'';
};
tasksMax = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive;
default = null;
example = 512;
description = ''
Cap on the number of processes and threads the guest may spawn,
applied to its unit as `TasksMax`. Left null, the task count is
uncapped.
'';
};
};
nesting = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Grant the guest's interior the prerequisites to run Podman or other
OCI containers of its own. Off by default, so a guest cannot nest
containers. On, the guest's container gains the network-administration
capability its container runtime uses to build bridges and firewall
rules, along with the tun and fuse device nodes such a runtime reaches
for, so the interior's `virtualisation.oci-containers` works with
Podman as its default runtime.
'';
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Start the guest at boot. On by default. Disabled, the guest stays
defined and can be started on demand, but does not come up at boot.
'';
};
};
config = lib.mkIf cfg.enable {
# Declared here so the host is the one that decrypts each named secret.
# The guest carries no age key and decrypts nothing of its own.
sops.secrets = lib.genAttrs cfg.secrets (_: { });
assertions = [
{
assertion = mountCollisions == [ ];
message = ''
guests.${name} maps a mount at ${lib.concatStringsSep ", " mountCollisions}, colliding with a secret bind-mounted at the same path. Rename the mount or the secret so each in-guest path is used once.
'';
}
{
assertion = cfg.backend == "container";
message = ''
guests.${name}.backend = "${cfg.backend}" is not implemented. Only the "container" backend is built; "microvm" is reserved for future work.
'';
}
{
assertion = !networked || lib.elem cfg.vlan config.modules.network.vlans;
message = ''
guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans (${lib.concatMapStringsSep ", " toString config.modules.network.vlans}). Declare the VLAN on the host or correct the guest's placement.
'';
}
];
# The operator's resource caps land on the guest's own unit, which a
# networked guest also orders after the bridge its veth enslaves to at
# start, since the container backend orders the unit after the network
# is up but not after that specific bridge existing.
systemd.services."container@${machineName}" = lib.mkIf (cfg.backend == "container") (
lib.mkMerge [
{ serviceConfig = limitConfig; }
(lib.mkIf networked (
let
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (bridgeName cfg.vlan)}.device";
in
{
after = [ bridgeDevice ];
wants = [ bridgeDevice ];
}
))
]
);
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
autoStart = cfg.autoStart;
# The guest gets its own network namespace, so its services — its own
# sshd included — never contend with the host's.
privateNetwork = lib.mkDefault true;
# A networked guest's veth is enslaved to the VLAN's bridge, making it
# a first-class L2 citizen on that segment.
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
# The container shares the host's uid and gid space one to one.
# A guest process writing as the shared storage group then lands on a bind-mounted pool as that same group, with no permission juggling.
# A private-user mapping would shift the ids and reintroduce those errors, so it stays off.
privateUsers = lib.mkDefault "no";
# A nesting guest runs Podman or other OCI containers in its interior.
# The network-administration capability lets that runtime build its
# bridges and firewall rules.
# The tun and fuse device nodes are what it reaches for to network
# those containers and back their overlay storage.
# The remaining prerequisite, a delegated cgroup subtree for the
# runtime to manage, the container backend already grants every guest.
additionalCapabilities = lib.optionals cfg.nesting [ "CAP_NET_ADMIN" ];
allowedDevices = lib.optionals cfg.nesting [
{
node = "/dev/net/tun";
modifier = "rwm";
}
{
node = "/dev/fuse";
modifier = "rwm";
}
];
bindMounts = userMounts // secretMounts;
inherit specialArgs;
config = {
imports = [
(self + "/guest.nix")
guestNet
interior
];
};
};
};
};
# Discover every host (a subdirectory of `hostsDir`) and build each one.
mkHosts =
hostsDir:
let
hostNames = attrNames (filterAttrs (_name: type: type == "directory") (builtins.readDir hostsDir));
in
genAttrs hostNames (hostName: mkHost { inherit hostName; });
in
{
inherit
collectNixFiles
mkHost
mkHosts
guest
bridgeName
;
}

View File

@@ -1,9 +0,0 @@
{ lib }:
let
files = import ./files.nix { inherit lib; };
network = import ./network.nix { inherit lib; };
in
{
inherit (files) collectNixFiles;
inherit (network) bridgeName deriveMac;
}

View File

@@ -1,22 +0,0 @@
{ lib }:
let
collectNixFiles =
dir:
lib.flatten (
lib.mapAttrsToList (
name: type:
let
path = dir + "/${name}";
in
if type == "directory" then
collectNixFiles path
else if type == "regular" && lib.hasSuffix ".nix" name then
[ path ]
else
[ ]
) (builtins.readDir dir)
);
in
{
inherit collectNixFiles;
}

View File

@@ -1,12 +0,0 @@
{ lib }:
{
bridgeName = id: "br-vlan${toString id}";
deriveMac =
name:
let
hash = builtins.hashString "sha256" name;
octet = i: builtins.substring (i * 2) 2 hash;
in
lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]);
}

View File

@@ -13,73 +13,48 @@ These are common instructions for Alexion's agents across all scenarios.
- NEVER ask the user a question using the `AskUserQuestion` tool.
Ask in plain prose, in your own message, instead, with no exceptions.
This overrides any default instruction (including harness conventions and skill instructions) to use it.
- Never manually modify `CHANGELOG.md` files or files marked as auto-generated.
Detect auto-generated files with a layered check.
Trust an explicit in-file marker first, for example `AUTO-GENERATED, DO NOT EDIT`.
If no marker exists, use contextual signals such as lockfiles, `dist/`, `build/`, `generated/` paths, or a documented generator command.
If the answer stays ambiguous, ask before editing.
- In authored prose, do not put multiple sentences on one physical line.
This applies to comments, documentation, user-facing descriptions, commit prose, pull-request prose, and forge comments.
A sentence can wrap across multiple physical lines when needed.
- Keep Markdown formatting changes local.
Only format prose you write or change.
Never reflow an entire pre-existing paragraph or file because you touched nearby text.
- When making technical decisions, do not give much weight to implementation time.
Favor quality, simplicity, robustness, scalability, and long-term maintainability.
Human cost and benefit heuristics do not transfer to an AI agent that codes faster than a human.
Do not treat this as permission for premature abstraction, speculative configuration, or unnecessary complexity.
Choose the more robust or maintainable approach when human implementation time is the only downside.
- Use lowercase file names unless a valid reason requires uppercase.
Established ecosystem or tool conventions count as valid reasons.
Examples include `README.md`, `LICENSE`, `CHANGELOG.md`, `Makefile`, `Dockerfile`, and `.github/` files.
- Do not end a response by promising or implying continuation unless the continuation is present in that same response.
Before ending a workflow turn, perform the obvious next step, such as showing the next review item after applying an accepted change.
If the workflow is paused, say that plainly instead of using a dangling transition like "continuing" or "next".
- When you discover that your belief about a project fact or convention was wrong, record the correction.
Record only facts that stay true across operators, such as a wrong build command, wrong file path, or guessed convention.
Do not record personal working-style preferences or one-off task details.
Record the correction in the nearest project `AGENTS.md`, not this global file.
Use a dedicated `## Gotchas` section, and create that section when the file lacks one.
If no project `AGENTS.md` exists, ask before creating one.
Append the correction immediately without asking when a project `AGENTS.md` exists.
Briefly mention the edit in your response.
If an existing entry is wrong or stale, correct or remove it the same way.
- Never manually modify CHANGELOG.md files or any files that are marked as auto-generated.
Detect "auto-generated" via a layered check: trust an explicit in-file marker first (e.g. `AUTO-GENERATED, DO NOT EDIT`).
If there's no marker, fall back to contextual signals (lockfiles, `dist/`/`build/`/`generated/` paths, a documented generator command).
If it's still ambiguous, ask before editing rather than guessing.
- When writing or substantially editing long Markdown files, put each full sentence in its own line.
Preserve normal Markdown structure, but avoid wrapping multiple sentences onto one physical line.
Apply this to any prose you author, regardless of file length; "long" is not a real threshold.
Only format what you're actually writing or changing.
Never reflow an entire pre-existing paragraph or file just because you touched something nearby.
- When making technical decisions, do not give much weight to development cost.
Instead, prefer quality, simplicity, robustness, scalability and long term maintainability.
This is specifically about implementation time.
Human cost/benefit heuristics ("not worth N extra days of engineering") don't transfer to an AI agent that codes far faster than a human.
This is not a license to override standard anti-overengineering guardrails (avoid premature abstraction, no speculative config, etc.); those still apply to unnecessary complexity.
It means: don't discount a more robust or maintainable approach just because it would take a human a long time to build.
- File names should always be lower case, unless there's a valid reason.
Established ecosystem or tool conventions count as a valid reason automatically (e.g. `README.md`, `LICENSE`, `CHANGELOG.md`, `Makefile`, `Dockerfile`, `.github/` files), without needing to ask each time.
- When you discover that a belief you held about an objective fact or convention of the current project was wrong, write it down so it isn't relearned next time.
This applies whether the user corrected you or you caught the mistake yourself, and only to things that are true regardless of who is operating the project (a wrong build command, a wrong file path, a convention you guessed at instead of checking) — not personal working-style preferences or one-off task details.
Record it in that project's own AGENTS.md, not this global file, under a dedicated `## Gotchas` section (create the section if the file doesn't have one yet).
If the project has nested AGENTS.md files, use the one nearest to where the mistake occurred, falling back to the project's top-level AGENTS.md.
Append to an existing AGENTS.md immediately, without asking; if no AGENTS.md exists yet for the project, ask before creating one.
Briefly mention the edit in your response rather than making it silently.
If an existing entry is later found to be wrong or stale, correct or remove it the same way.
## Comments
- Write comments only where they earn their place.
Keep comments concise.
Assume the reader can read code.
Explain why, not what.
Explain what only when it is genuinely non-obvious.
- Keep comments self-contained to their file.
Write comments for a reader who sees only that file.
Do not explain how another file or tool consumes a value.
The only permitted cross-file mention is a bare pointer explaining why something is absent here.
- Do not narrate history or future state in comments.
Avoid phrases such as "used to be", "now moved here", and "will be".
State the present reason a thing exists.
- Do not justify a choice against alternatives in comments.
State the positive reason the choice exists.
Include present consequences when they matter.
- Do not reference agent-facing state in comments.
This includes `.agents/`, `.claude/`, `AGENTS.md`, and `CLAUDE.md`.
- Do not use project-specific capitalized terms as glossary references in comments.
Use plain lowercase nouns unless the code requires the exact term.
- Add a file-top header only when the filename or path does not already explain the file.
The header must be one concise purpose line.
Do not write a feature inventory.
- Write placeholders plainly.
Include a present-tense action when one exists, such as `Placeholder: regenerate with <tool> on the target machine`.
Do not write `placeholder for <missing feature>`.
- Treat user-facing documentation strings as documentation, not comments.
They can describe behavior more fully than code comments.
They still must stay self-contained and avoid agent-state references.
- Start each sentence of a comment on its own physical line.
A comment sentence can wrap when needed.
Prefer splitting long comment sentences before wrapping them.
Do not break a line early at a comma or clause boundary to make it read as a unit.
Do not use semicolons in comments or authored prose.
Recast semicolon clauses as separate sentences.
Only reformat comments you write or change.
- Write comments only where they earn their place, and keep them concise.
Assume the reader can read code: comment the "why", not the "what", and explain "what" only when it is genuinely non-obvious.
A comment must be self-contained to its file — accurate to a reader looking at that file alone.
Do not write about history ("used to be X", "now moved here") or future state, about how a value is consumed elsewhere, or to justify the choice against alternatives; state the positive reason a thing exists, keeping any real stakes as a present-tense consequence.
The only permitted cross-file mention is a bare pointer explaining why something is *absent* here (e.g. a value another tool derives, which this file therefore does not declare), never narrating what the other file or tool does.
Do not use a project's domain-model or ubiquitous-language capitalized terms as glossary references; describe things in plain language, using ordinary lowercase nouns.
Never reference agent-facing state (anything under `.agents/`, `.claude/`, `AGENTS.md`, or `CLAUDE.md`).
A file-top header is one concise purpose line, added only where the filename or path does not already say it — never a feature inventory of the code below.
For a placeholder, say so plainly plus any actionable present-tense directive ("Placeholder: regenerate with <tool> on the target machine"), never "placeholder for <missing feature>".
User-facing documentation strings (an option's `description`, a generated help string) are documentation rather than comments, so they may describe behaviour more fully — but the self-contained rule and the bans on glossary terms and agent-state references still apply.
- Start each sentence of a comment on its own line, as with Markdown prose.
A sentence needing more than one line is first a prompt to ask whether it should be two sentences.
Only when it genuinely cannot be split does it wrap, and then it wraps normally at the right margin.
Never break a line early at a comma or clause boundary to make it read as a unit.
Never use a semicolon, in a comment or in authored prose.
Recast as two sentences instead.
Only reformat comments you are actually writing or changing.

View File

@@ -1,254 +0,0 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
type QuotaState =
| { status: "idle" | "loading" }
| { status: "ok"; detail: string; refreshedAt: number; weeklyRemaining?: number; shortRemaining?: number }
| { status: "missing" | "error"; detail: string; refreshedAt?: number };
const CODEX_USAGE_ENDPOINTS = [
"https://chatgpt.com/backend-api/wham/usage",
"https://chatgpt.com/backend-api/codex/usage",
];
const QUOTA_REFRESH_MS = 5 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 10_000;
let quotaState: QuotaState = { status: "idle" };
let quotaRefreshPromise: Promise<void> | null = null;
function shortCwd(cwd: string): string {
const home = process.env.HOME;
if (home && cwd.startsWith(`${home}/`)) return `~/${basename(cwd)}`;
return basename(cwd) || cwd;
}
function gitBranch(cwd: string): string | null {
try {
const out = execFileSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return out || null;
} catch {
return null;
}
}
function authPath(): string {
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json");
}
function readCodexCredentials(): { access: string; accountId?: string } | null {
const file = authPath();
if (!existsSync(file)) return null;
try {
const auth = JSON.parse(readFileSync(file, "utf8"));
const credential = auth?.["openai-codex"];
if (credential?.type !== "oauth" || typeof credential.access !== "string") return null;
if (typeof credential.expires === "number" && credential.expires <= Date.now() + 30_000) return null;
return {
access: credential.access,
accountId: typeof credential.accountId === "string" ? credential.accountId : undefined,
};
} catch {
return null;
}
}
function numberValue(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function objectValue(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function windowSeconds(raw: Record<string, unknown>): number | undefined {
const seconds = numberValue(raw.limit_window_seconds ?? raw.windowSeconds);
if (seconds !== undefined) return seconds;
const mins = numberValue(raw.windowDurationMins ?? raw.window_duration_mins);
return mins === undefined ? undefined : mins * 60;
}
function usedPercent(raw: Record<string, unknown>): number | undefined {
const value = numberValue(raw.used_percent ?? raw.usedPercent);
if (value === undefined) return undefined;
return Math.max(0, Math.min(100, value));
}
function collectWindows(raw: unknown, out: Array<{ seconds?: number; used: number; key: string }> = [], key = "root") {
if (Array.isArray(raw)) {
raw.forEach((item, index) => collectWindows(item, out, `${key}.${index}`));
return out;
}
const obj = objectValue(raw);
if (!obj) return out;
const used = usedPercent(obj);
if (used !== undefined) out.push({ seconds: windowSeconds(obj), used, key });
for (const [childKey, value] of Object.entries(obj)) {
if (value && typeof value === "object") collectWindows(value, out, `${key}.${childKey}`);
}
return out;
}
function pickQuotaWindows(raw: unknown): { weeklyRemaining?: number; shortRemaining?: number } | null {
const windows = collectWindows(raw);
if (windows.length === 0) return null;
const weekly = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 604_800) <= 60 * 60)
?? windows.find((window) => /week|weekly|secondary/i.test(window.key));
const short = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 18_000) <= 60 * 30)
?? windows.find((window) => /five|session|primary|short/i.test(window.key));
return {
weeklyRemaining: weekly ? Math.max(0, Math.min(100, 100 - weekly.used)) : undefined,
shortRemaining: short ? Math.max(0, Math.min(100, 100 - short.used)) : undefined,
};
}
function safeFg(theme: any, color: string, text: string): string {
try {
return theme.fg(color, text);
} catch {
return theme.fg("accent", text);
}
}
function contextColor(percent: number): string {
if (percent >= 90) return "error";
if (percent >= 70) return "warning";
return "success";
}
function quotaColor(percent: number): string {
if (percent >= 80) return "error";
if (percent >= 50) return "warning";
return "border";
}
function bar(theme: any, width: number, percent: number | null, glyph: string, colorForPercent: (percent: number) => string): string {
const barWidth = Math.max(12, width);
if (percent === null) return theme.fg("muted", glyph.repeat(barWidth));
const clamped = Math.max(0, Math.min(100, percent));
const filled = Math.max(0, Math.min(barWidth, Math.round((clamped / 100) * barWidth)));
const empty = Math.max(0, barWidth - filled);
return safeFg(theme, colorForPercent(clamped), glyph.repeat(filled)) + theme.fg("dim", glyph.repeat(empty));
}
async function fetchCodexQuota(force = false): Promise<void> {
const fresh = quotaState.status === "ok" && Date.now() - quotaState.refreshedAt < QUOTA_REFRESH_MS;
if (!force && fresh) return;
if (quotaRefreshPromise) return quotaRefreshPromise;
quotaState = { status: "loading" };
quotaRefreshPromise = (async () => {
const credentials = readCodexCredentials();
if (!credentials) {
quotaState = { status: "missing", detail: "OpenAI Codex OAuth credentials were not found or are expired" };
return;
}
let lastError = "quota unavailable";
for (const endpoint of CODEX_USAGE_ENDPOINTS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const headers: Record<string, string> = { Authorization: `Bearer ${credentials.access}` };
if (credentials.accountId) headers["ChatGPT-Account-Id"] = credentials.accountId;
const response = await fetch(endpoint, { headers, signal: controller.signal });
if (!response.ok) {
lastError = `${response.status} ${response.statusText}`;
continue;
}
const windows = pickQuotaWindows(await response.json());
if (!windows || (windows.weeklyRemaining === undefined && windows.shortRemaining === undefined)) {
lastError = "response had no recognized quota windows";
continue;
}
const details = [];
if (windows.weeklyRemaining !== undefined) details.push(`weekly ${Math.round(windows.weeklyRemaining)}%`);
if (windows.shortRemaining !== undefined) details.push(`short ${Math.round(windows.shortRemaining)}%`);
quotaState = {
status: "ok",
detail: `Codex quota remaining: ${details.join(", ")}`,
weeklyRemaining: windows.weeklyRemaining,
shortRemaining: windows.shortRemaining,
refreshedAt: Date.now(),
};
return;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
} finally {
clearTimeout(timeout);
}
}
quotaState = { status: "error", detail: `Codex quota failed: ${lastError}`, refreshedAt: Date.now() };
})().finally(() => {
quotaRefreshPromise = null;
});
return quotaRefreshPromise;
}
function statusLines(ctx: any, theme: any, width: number): string[] {
const cwd = ctx.sessionManager?.getCwd?.() ?? ctx.cwd ?? process.cwd();
const branch = gitBranch(cwd);
const where = branch ? `󰉋 ${shortCwd(cwd)} 󰘬 ${branch}` : `󰉋 ${shortCwd(cwd)}`;
const model = ctx.model?.id ?? process.env.PI_MODEL ?? "no-model";
const thinking = ctx.thinkingLevel ?? process.env.PI_REASONING_LEVEL ?? "off";
const left = theme.fg("accent", where);
const right = theme.fg("dim", `${model} • ${thinking}`);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
const contextPercentRaw = ctx.getContextUsage?.()?.percent;
const contextPercent = typeof contextPercentRaw === "number" && Number.isFinite(contextPercentRaw) ? contextPercentRaw : null;
const quotaConsumed = quotaState.status === "ok" && quotaState.weeklyRemaining !== undefined
? 100 - quotaState.weeklyRemaining
: null;
return [
truncateToWidth(left + pad + right, width),
bar(theme, width, contextPercent, "▃", contextColor),
bar(theme, width, quotaConsumed, "▔", quotaColor),
];
}
function setCompactStatusUi(ctx: any) {
if (!ctx.hasUI) return;
ctx.ui.setWidget("compact-status", (_tui: any, theme: any) => ({
invalidate() {},
render(width: number) {
return statusLines(ctx, theme, width);
},
}));
ctx.ui.setFooter(() => ({ invalidate() {}, render: () => [] }));
}
export default function compactStatus(pi: ExtensionAPI) {
function refreshUi(ctx: any) {
setCompactStatusUi(ctx);
}
pi.on("session_start", (_event, ctx) => {
refreshUi(ctx);
void fetchCodexQuota(false).then(() => refreshUi(ctx));
});
pi.on("model_select", (_event, ctx) => refreshUi(ctx));
pi.on("agent_settled", (_event, ctx) => refreshUi(ctx));
pi.registerCommand("codex-quota", {
description: "Refresh and show ChatGPT Codex quota",
handler: async (_args, ctx) => {
refreshUi(ctx);
await fetchCodexQuota(true);
refreshUi(ctx);
const level = quotaState.status === "ok" ? "info" : quotaState.status === "missing" ? "warning" : "error";
ctx.ui.notify(quotaState.status === "idle" || quotaState.status === "loading" ? "Codex quota refresh in progress" : quotaState.detail, level);
},
});
}

View File

@@ -1,171 +0,0 @@
diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts
--- a/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
+++ b/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
@@ -310,7 +310,7 @@ export class TUI extends Container {
private cursorRow = 0; // Logical cursor row (end of rendered content)
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
- private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
+ private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK !== "0"; // Clear empty rows when content shrinks (default: on)
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
private fullRedrawCount = 0;
diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts
--- a/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
+++ b/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
@@ -1093,11 +1093,11 @@ export class SettingsManager {
}
getClearOnShrink(): boolean {
- // Settings takes precedence, then env var, then default false
+ // Settings takes precedence, then env var, then default true
if (this.settings.terminal?.clearOnShrink !== undefined) {
return this.settings.terminal.clearOnShrink;
}
- return process.env.PI_CLEAR_ON_SHRINK === "1";
+ return process.env.PI_CLEAR_ON_SHRINK !== "0";
}
setClearOnShrink(enabled: boolean): void {
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:41:36.963495957 -0400
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:43:04.876341236 -0400
@@ -210,6 +210,47 @@
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
}
+class FlexSpacerBottomLayout implements Component {
+ private readonly ui: TUI;
+ private readonly flowChildren: Component[];
+ private readonly pinnedChildren: Component[];
+
+ constructor(ui: TUI, flowChildren: Component[], pinnedChildren: Component[]) {
+ this.ui = ui;
+ this.flowChildren = flowChildren;
+ this.pinnedChildren = pinnedChildren;
+ }
+
+ invalidate(): void {
+ for (const child of [...this.flowChildren, ...this.pinnedChildren]) {
+ child.invalidate();
+ }
+ }
+
+ private renderGroup(children: Component[], width: number): string[] {
+ const lines: string[] = [];
+ for (const child of children) {
+ for (const line of child.render(width)) {
+ lines.push(line);
+ }
+ }
+ return lines;
+ }
+
+ render(width: number): string[] {
+ const flowLines = this.renderGroup(this.flowChildren, width);
+ const pinnedLines = this.renderGroup(this.pinnedChildren, width);
+ const terminalRows = this.ui.terminal.rows;
+ const spacerRows = Math.max(0, terminalRows - flowLines.length - pinnedLines.length);
+
+ return [
+ ...flowLines,
+ ...Array.from({ length: spacerRows }, () => ""),
+ ...pinnedLines,
+ ];
+ }
+}
+
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
@@ -335,6 +376,7 @@
private fdPath: string | undefined;
private editorContainer: Container;
private footer: FooterComponent;
+ private footerContainer: Container;
private footerDataProvider: FooterDataProvider;
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
private keybindings: KeybindingsManager;
@@ -477,7 +519,9 @@
this.editorContainer = new Container();
this.editorContainer.addChild(this.editor as Component);
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
+ this.footerContainer = new Container();
this.footer = new FooterComponent(this.session, this.footerDataProvider);
+ this.footerContainer.addChild(this.footer);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
// Load hide thinking block setting
@@ -704,19 +748,25 @@
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
}
- // Add header container as first child. Populate it after applying theme settings.
- // Keep loaded resources before chat so restored session messages never precede them.
- this.ui.addChild(this.headerContainer);
- this.ui.addChild(this.loadedResourcesContainer);
-
- this.ui.addChild(this.chatContainer);
- this.ui.addChild(this.pendingMessagesContainer);
- this.ui.addChild(this.statusContainer);
this.renderWidgets(); // Initialize with default spacer
- this.ui.addChild(this.widgetContainerAbove);
- this.ui.addChild(this.editorContainer);
- this.ui.addChild(this.widgetContainerBelow);
- this.ui.addChild(this.footer);
+ this.ui.addChild(
+ new FlexSpacerBottomLayout(
+ this.ui,
+ [
+ this.headerContainer,
+ this.loadedResourcesContainer,
+ this.chatContainer,
+ ],
+ [
+ this.pendingMessagesContainer,
+ this.statusContainer,
+ this.widgetContainerAbove,
+ this.editorContainer,
+ this.widgetContainerBelow,
+ this.footerContainer,
+ ],
+ ),
+ );
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
@@ -2033,25 +2083,25 @@
| ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void })
| undefined,
): void {
- // Dispose existing custom footer
+ // Dispose existing custom footer
if (this.customFooter?.dispose) {
this.customFooter.dispose();
}
- // Remove current footer from UI
+ // Remove current footer from its pinned layout slot.
if (this.customFooter) {
- this.ui.removeChild(this.customFooter);
+ this.footerContainer.removeChild(this.customFooter);
} else {
- this.ui.removeChild(this.footer);
+ this.footerContainer.removeChild(this.footer);
}
if (factory) {
// Create and add custom footer, passing the data provider
this.customFooter = factory(this.ui, theme, this.footerDataProvider);
- this.ui.addChild(this.customFooter);
+ this.footerContainer.addChild(this.customFooter);
} else {
// Restore built-in footer
this.customFooter = undefined;
- this.ui.addChild(this.footer);
+ this.footerContainer.addChild(this.footer);
}
this.ui.requestRender();

View File

@@ -1,23 +0,0 @@
diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts
--- a/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:36.970496010 -0400
+++ b/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:37.028186009 -0400
@@ -74,8 +74,8 @@
function commandExists(cmd: string): boolean {
try {
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
- // Check for ENOENT error (command not found)
- return result.error === undefined || result.error === null;
+ // Downloaded generic Linux binaries can exist but fail on NixOS before they print a version.
+ return (result.error === undefined || result.error === null) && result.status === 0;
} catch {
return false;
}
@@ -88,7 +87,7 @@
// Check our tools directory first
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
- if (existsSync(localPath)) {
+ if (existsSync(localPath) && commandExists(localPath)) {
return localPath;
}

View File

@@ -1,7 +1,6 @@
{
config,
lib,
pkgs,
...
}:
# Pi, a terminal coding agent, for the primary user, configured through
@@ -11,44 +10,15 @@ let
cfg = config.modules.agents.pi;
user = config.user.name;
piDir = "${config.users.users.${user}.home}/.pi/agent";
patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [
./patches/pi-flex-spacer.patch
./patches/pi-tool-lookup-validation.patch
];
});
herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation {
name = "herdr-pi-integration";
nativeBuildInputs = [ pkgs.herdr ];
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $TMPDIR/home/.pi/agent/extensions
HOME=$TMPDIR/home herdr integration install pi
mkdir -p $out
cp $TMPDIR/home/.pi/agent/extensions/herdr-agent-state.ts $out/herdr-agent-state.ts
'';
};
piExtensions = pkgs.stdenvNoCC.mkDerivation {
name = "pi-extensions";
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out
cp -R ${./extensions}/. $out/
cp ${herdrPiIntegration}/herdr-agent-state.ts $out/herdr-agent-state.ts
'';
};
in
{
options.modules.agents.pi = {
enable = lib.mkEnableOption ''
Pi, a terminal coding agent, configured via home-manager'';
};
options.modules.agents.pi.enable = lib.mkEnableOption ''
Pi, a terminal coding agent, configured via home-manager'';
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
programs.pi-coding-agent = {
enable = true;
package = patchedPi;
settings = {
defaultProvider = "openai-codex";
@@ -60,22 +30,21 @@ in
};
};
home.file =
{
# The first declarative rollout replaces the interactive settings file.
# Login state stays in auth.json, which this module does not manage.
"${piDir}/settings.json".force = true;
home.file = {
# The first declarative rollout replaces the interactive settings file.
# Login state stays in auth.json, which this module does not manage.
"${piDir}/settings.json".force = true;
"${piDir}/extensions" = {
source = piExtensions;
recursive = true;
};
"${piDir}/prompts" = {
source = ./prompts;
recursive = true;
};
"${piDir}/extensions" = {
source = ./extensions;
recursive = true;
};
"${piDir}/prompts" = {
source = ./prompts;
recursive = true;
};
};
};
};
}

View File

@@ -10,7 +10,13 @@ let
user = config.user.name;
# The skills installed globally, as derivations from the skills flake.
skills = with inputs.skills.packages.${pkgs.stdenv.hostPlatform.system}; [ ];
# wiki reads the personal Obsidian vault, and consume mines a source into it.
# grill interviews the operator relentlessly to resolve a plan before building.
skills = with inputs.skills.packages.${pkgs.stdenv.hostPlatform.system}; [
wiki
consume
grill
];
in
{
home-manager.sharedModules = [ inputs.skills.homeModules.default ];

View File

@@ -1,126 +0,0 @@
{
config,
lib,
pkgs,
inputs,
...
}:
# The shared foundation for every host and guest.
let
inherit (lib) mkOption types;
user = config.user;
pinArgs = prev: {
inherit (prev.stdenv.hostPlatform) system;
config.allowUnfree = true;
};
in
{
imports = [ inputs.home-manager.nixosModules.home-manager ];
options.user = {
name = mkOption {
type = types.str;
default = "alexion";
description = ''
Primary interactive user for this system.
This drives the system account and Home Manager user together.
'';
};
description = mkOption {
type = types.str;
default = "Alexion";
description = "Human-readable description for the primary user.";
};
};
config = {
nixpkgs.overlays = [
(_final: prev: {
unstable = import inputs.nixpkgs-unstable (pinArgs prev);
stable = import inputs.nixpkgs-stable (pinArgs prev);
})
];
nixpkgs.config.allowUnfree = true;
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
users.users.${user.name} = {
isNormalUser = true;
description = user.description;
extraGroups = [
"wheel"
"storage"
];
};
users.groups.storage.gid = 10000;
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
extraSpecialArgs = {
inherit inputs;
};
users.${user.name} = {
home.username = user.name;
home.homeDirectory = "/home/${user.name}";
home.stateVersion = "26.05";
home.packages = with pkgs; [
eza
wget
];
};
};
registry.shell-shortcut.${config.networking.hostName} = {
ls = {
command = "eza -alg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
la = {
command = "eza -a --color=always --group-directories-first --icons=always";
abbreviation = false;
};
ll = {
command = "eza -lg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
lt = {
command = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
abbreviation = false;
};
"l." = {
command = "eza -a | grep -e '^\\.'";
abbreviation = false;
};
".." = "cd ..";
"..." = "cd ../..";
"...." = "cd ../../..";
cp = "cp -v";
tarnow = "tar -acf ";
untar = "tar -zxvf ";
wget = "wget -c ";
grep = "grep --color=auto";
fgrep = "fgrep --color=auto";
egrep = "egrep --color=auto";
jctl = "journalctl -p 3 -xb";
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
cleanup = "sudo nix-collect-garbage -d";
};
modules.shell.fish.enable = lib.mkDefault true;
modules.shell.fish.defaultShell = lib.mkDefault true;
modules.shell.tmux.enable = lib.mkDefault true;
modules.shell.nvim.enable = lib.mkDefault true;
modules.shell.git.enable = lib.mkDefault true;
modules.shell.direnv.enable = lib.mkDefault true;
modules.shell.yazi.enable = lib.mkDefault true;
};
}

View File

@@ -1,18 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# Ghostty as the desktop terminal.
let
cfg = config.modules.desktop.apps.ghostty;
user = config.user.name;
in
{
options.modules.desktop.apps.ghostty.enable = lib.mkEnableOption "Ghostty as the desktop terminal";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.ghostty ];
};
}

View File

@@ -1,24 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# Steam game launcher and runtime integration.
let
cfg = config.modules.desktop.apps.steam;
in
{
options.modules.desktop.apps.steam.enable = lib.mkEnableOption "Steam game launcher";
config = lib.mkIf cfg.enable {
programs.steam = {
enable = true;
package = pkgs.steam.override {
extraEnv.STEAM_FORCE_DESKTOPUI_SCALING = "1.5";
};
};
hardware.steam-hardware.enable = true;
};
}

View File

@@ -1,10 +1,10 @@
{ config, lib, ... }:
# PipeWire as the desktop audio server.
let
cfg = config.modules.desktop.utilities.audio;
cfg = config.modules.desktop.audio;
in
{
options.modules.desktop.utilities.audio.enable = lib.mkEnableOption "the PipeWire audio server";
options.modules.desktop.audio.enable = lib.mkEnableOption "the PipeWire audio server";
config = lib.mkIf cfg.enable {
# Realtime scheduling for the audio threads, so playback survives load.

View File

@@ -6,7 +6,7 @@
}:
# Clipboard history: cliphist records every copy, picked back through rofi.
let
cfg = config.modules.desktop.utilities.clipboard;
cfg = config.modules.desktop.clipboard;
user = config.user.name;
cliphist = "${pkgs.cliphist}/bin/cliphist";
@@ -25,7 +25,7 @@ let
'';
in
{
options.modules.desktop.utilities.clipboard.enable = lib.mkEnableOption "cliphist clipboard history";
options.modules.desktop.clipboard.enable = lib.mkEnableOption "cliphist clipboard history";
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {

View File

@@ -1,30 +0,0 @@
{ config, lib, ... }:
# The desktop aggregator: one flag brings up the whole graphical session.
let
cfg = config.modules.desktop;
in
{
options.modules.desktop.enable = lib.mkEnableOption "the keyboard-driven Hyprland desktop";
# Each piece is turned on at default priority, so a host can still override
# any one of them while the single flag above enables the whole desktop.
config = lib.mkIf cfg.enable {
modules.desktop.apps.firefox.enable = lib.mkDefault true;
modules.desktop.apps.ghostty.enable = lib.mkDefault true;
modules.desktop.session.hyprland.enable = lib.mkDefault true;
modules.desktop.session.hyprland.hypridle.enable = lib.mkDefault true;
modules.desktop.session.hyprland.hyprlock.enable = lib.mkDefault true;
modules.desktop.session.login.enable = lib.mkDefault true;
modules.desktop.session.mako.enable = lib.mkDefault true;
modules.desktop.session.portals.enable = lib.mkDefault true;
modules.desktop.session.rofi.enable = lib.mkDefault true;
modules.desktop.session.waybar.enable = lib.mkDefault true;
modules.desktop.utilities.audio.enable = lib.mkDefault true;
modules.desktop.utilities.clipboard.enable = lib.mkDefault true;
modules.desktop.utilities.osd.enable = lib.mkDefault true;
modules.desktop.utilities.recording.enable = lib.mkDefault true;
modules.desktop.utilities.screenshot.enable = lib.mkDefault true;
modules.desktop.utilities.theming.enable = lib.mkDefault true;
modules.desktop.utilities.userdirs.enable = lib.mkDefault true;
};
}

View File

@@ -0,0 +1,30 @@
{ config, lib, ... }:
# The desktop aggregator: one flag brings up the whole graphical session.
let
cfg = config.modules.desktop;
in
{
options.modules.desktop.enable = lib.mkEnableOption "the keyboard-driven Hyprland desktop";
# Each piece is turned on at default priority, so a host can still override
# any one of them while the single flag above enables the whole desktop.
config = lib.mkIf cfg.enable {
modules.desktop.clipboard.enable = lib.mkDefault true;
modules.desktop.firefox.enable = lib.mkDefault true;
modules.desktop.hyprland.enable = lib.mkDefault true;
modules.desktop.hyprland.hyprlock.enable = lib.mkDefault true;
modules.desktop.hyprland.hypridle.enable = lib.mkDefault true;
modules.desktop.login.enable = lib.mkDefault true;
modules.desktop.mako.enable = lib.mkDefault true;
modules.desktop.portals.enable = lib.mkDefault true;
modules.desktop.recording.enable = lib.mkDefault true;
modules.desktop.rofi.enable = lib.mkDefault true;
modules.desktop.screenshot.enable = lib.mkDefault true;
modules.desktop.terminal.enable = lib.mkDefault true;
modules.desktop.userdirs.enable = lib.mkDefault true;
modules.desktop.theming.enable = lib.mkDefault true;
modules.desktop.waybar.enable = lib.mkDefault true;
modules.desktop.audio.enable = lib.mkDefault true;
modules.desktop.osd.enable = lib.mkDefault true;
};
}

View File

@@ -1,18 +1,18 @@
{
config,
inputs,
lib,
pkgs,
...
}:
{ config, lib, ... }:
# Firefox as the desktop browser: stock mainline, hardened and de-monetized by policy.
let
cfg = config.modules.desktop.apps.firefox;
cfg = config.modules.desktop.firefox;
user = config.user.name;
firefoxAddons = inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system};
# A force-installed extension, keyed at the call site by the add-on's own id.
# Firefox fetches the signed add-on from Mozilla's site and enables it automatically.
forceInstalled = slug: {
install_url = "https://addons.mozilla.org/firefox/downloads/latest/${slug}/latest.xpi";
installation_mode = "force_installed";
};
in
{
options.modules.desktop.apps.firefox.enable = lib.mkEnableOption "Firefox as the desktop browser";
options.modules.desktop.firefox.enable = lib.mkEnableOption "Firefox as the desktop browser";
config = lib.mkIf cfg.enable {
home-manager.users.${user} = hm: {
@@ -34,22 +34,21 @@ in
SponsoredPocket = false;
Snippets = false;
};
# An ad blocker, a password manager, and a video sponsor-skipper.
ExtensionSettings = {
"uBlock0@raymondhill.net" = forceInstalled "ublock-origin";
"78272b6fa58f4a1abaac99321d503a20@proton.me" = forceInstalled "proton-pass";
"sponsorBlocker@ajay.app" = forceInstalled "sponsorblock";
};
};
profiles.default = {
isDefault = true;
extensions = {
packages = with firefoxAddons; [
ublock-origin
proton-pass
sponsorblock
];
# The Nord chrome theme is a declared extension setting, so home-manager
# owns the extension-settings store, overwriting runtime changes to it.
force = true;
};
# The Nord chrome theme is a declared extension setting, so home-manager
# owns the extension-settings store, overwriting runtime changes to it.
extensions.force = true;
# Stylix's Nord mapping paints the selected address-bar result a
# near-white grey, leaving its light text unreadable. Darken that one

View File

@@ -6,7 +6,7 @@
}:
# hypridle: idle-triggered locking and display power management.
let
cfg = config.modules.desktop.session.hyprland.hypridle;
cfg = config.modules.desktop.hyprland.hypridle;
user = config.user.name;
hyprctl = "${config.programs.hyprland.package}/bin/hyprctl";
@@ -17,7 +17,7 @@ let
lockCmd = "${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}";
in
{
options.modules.desktop.session.hyprland.hypridle = {
options.modules.desktop.hyprland.hypridle = {
enable = lib.mkEnableOption "hypridle idle management";
lockTimeout = lib.mkOption {

View File

@@ -6,7 +6,7 @@
}:
# The Hyprland compositor.
let
cfg = config.modules.desktop.session.hyprland;
cfg = config.modules.desktop.hyprland;
user = config.user.name;
cursor = config.stylix.cursor;
@@ -27,7 +27,7 @@ let
'';
in
{
options.modules.desktop.session.hyprland = {
options.modules.desktop.hyprland = {
enable = lib.mkEnableOption "the Hyprland compositor";
blur = lib.mkEnableOption ''
@@ -61,7 +61,7 @@ in
settings = {
"$mod" = "SUPER";
"$terminal" = "ghostty";
"$terminal" = "alacritty";
# Session variables handed to the compositor directly.
# UWSM launches the session without the shell profile that would carry
@@ -112,10 +112,6 @@ in
blur.enabled = cfg.blur;
};
# XWayland clients render at the panel's native resolution instead of
# being raster-scaled by the compositor at the fractional monitor scale.
xwayland.force_zero_scaling = true;
animations = {
enabled = true;
bezier = [ "ease, 0.25, 0.1, 0.25, 1.0" ];

View File

@@ -6,46 +6,14 @@
}:
# The lock screen: a hyprlock session-lock surface the compositor owns.
let
cfg = config.modules.desktop.session.hyprland.hyprlock;
cfg = config.modules.desktop.hyprland.hyprlock;
user = config.user.name;
# The hyprlock this module installs, used by the lock keybind below.
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
sleepIcon = builtins.fromJSON ''"\udb81\udcb2"'';
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" ''
unit=lockscreen-idle-inhibit.service
refresh_waybar() {
${pkgs.procps}/bin/pkill -RTMIN+9 waybar >/dev/null 2>&1 || true
}
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet "$unit"; then
${pkgs.systemd}/bin/systemctl --user stop "$unit"
refresh_waybar
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepIcon}"
exit 0
fi
if ${pkgs.systemd}/bin/systemd-run \
--user \
--collect \
--unit=lockscreen-idle-inhibit \
--description="Disable idle-triggered screen locking" \
${pkgs.systemd}/bin/systemd-inhibit \
--what=idle \
--who=lockscreen-hotkey \
--why="lock screen disabled by hotkey" \
${pkgs.coreutils}/bin/sleep infinity >/dev/null; then
refresh_waybar
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepOffIcon}"
fi
'';
in
{
options.modules.desktop.session.hyprland.hyprlock.enable = lib.mkEnableOption "the hyprlock lock screen";
options.modules.desktop.hyprland.hyprlock.enable = lib.mkEnableOption "the hyprlock lock screen";
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
@@ -87,7 +55,6 @@ in
# hyprlock never stacks over the first.
wayland.windowManager.hyprland.settings.bind = [
"$mod, X, exec, ${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}"
"$mod CTRL, X, exec, ${idleLockToggle}"
];
};
};

View File

@@ -7,10 +7,10 @@
# Text login: greetd running the tuigreet greeter, which starts the session
# through the universal Wayland session manager.
let
cfg = config.modules.desktop.session.login;
cfg = config.modules.desktop.login;
in
{
options.modules.desktop.session.login.enable = lib.mkEnableOption "greetd with the tuigreet text greeter";
options.modules.desktop.login.enable = lib.mkEnableOption "greetd with the tuigreet text greeter";
config = lib.mkIf cfg.enable {
services.greetd = {

View File

@@ -6,13 +6,13 @@
}:
# mako as the notification daemon: toasts, a do-not-disturb mode, and history recall.
let
cfg = config.modules.desktop.session.mako;
cfg = config.modules.desktop.mako;
user = config.user.name;
makoctl = "${pkgs.mako}/bin/makoctl";
in
{
options.modules.desktop.session.mako.enable = lib.mkEnableOption "the mako notification daemon";
options.modules.desktop.mako.enable = lib.mkEnableOption "the mako notification daemon";
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {

View File

@@ -6,11 +6,11 @@
}:
# Obsidian as the desktop note-taking vault.
let
cfg = config.modules.desktop.apps.obsidian;
cfg = config.modules.desktop.obsidian;
user = config.user.name;
in
{
options.modules.desktop.apps.obsidian.enable = lib.mkEnableOption "Obsidian as the desktop note-taking vault";
options.modules.desktop.obsidian.enable = lib.mkEnableOption "Obsidian as the desktop note-taking vault";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.obsidian ];

View File

@@ -6,11 +6,11 @@
}:
# SwayOSD: a transient on-screen popup for volume and brightness changes.
let
cfg = config.modules.desktop.utilities.osd;
cfg = config.modules.desktop.osd;
user = config.user.name;
in
{
options.modules.desktop.utilities.osd.enable = lib.mkEnableOption "the SwayOSD volume and brightness popup";
options.modules.desktop.osd.enable = lib.mkEnableOption "the SwayOSD volume and brightness popup";
config = lib.mkIf cfg.enable {
environment.systemPackages = [ pkgs.swayosd ];

View File

@@ -1,10 +1,10 @@
{ config, lib, ... }:
# XDG desktop portal routing for in-app screen sharing and file dialogs.
let
cfg = config.modules.desktop.session.portals;
cfg = config.modules.desktop.portals;
in
{
options.modules.desktop.session.portals.enable =
options.modules.desktop.portals.enable =
lib.mkEnableOption "XDG desktop portals for in-app screen sharing and file dialogs";
config = lib.mkIf cfg.enable {

View File

@@ -6,7 +6,7 @@
}:
# Keybound screen recording: pick a region, then toggle a video-only capture.
let
cfg = config.modules.desktop.utilities.recording;
cfg = config.modules.desktop.recording;
user = config.user.name;
slurp = "${pkgs.slurp}/bin/slurp";
@@ -41,7 +41,7 @@ let
'';
in
{
options.modules.desktop.utilities.recording.enable = lib.mkEnableOption "wf-recorder screen recording";
options.modules.desktop.recording.enable = lib.mkEnableOption "wf-recorder screen recording";
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {

View File

@@ -6,7 +6,7 @@
}:
# rofi as the desktop launcher and dmenu-style menu frontend.
let
cfg = config.modules.desktop.session.rofi;
cfg = config.modules.desktop.rofi;
user = config.user.name;
# A session menu built on the same themed rofi, so the power actions look
@@ -24,7 +24,7 @@ let
'';
in
{
options.modules.desktop.session.rofi.enable = lib.mkEnableOption "the rofi launcher";
options.modules.desktop.rofi.enable = lib.mkEnableOption "the rofi launcher";
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {

View File

@@ -7,7 +7,7 @@
# Keyboard-driven screenshots that open in satty for annotation, then land in
# both the clipboard and a dated file.
let
cfg = config.modules.desktop.utilities.screenshot;
cfg = config.modules.desktop.screenshot;
user = config.user.name;
grimblast = "${pkgs.grimblast}/bin/grimblast";
@@ -32,7 +32,7 @@ let
'';
in
{
options.modules.desktop.utilities.screenshot.enable = lib.mkEnableOption "screenshots via grimblast and satty";
options.modules.desktop.screenshot.enable = lib.mkEnableOption "screenshots via grimblast and satty";
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {

View File

@@ -6,11 +6,11 @@
}:
# Alacritty as the desktop terminal.
let
cfg = config.modules.desktop.apps.alacritty;
cfg = config.modules.desktop.terminal;
user = config.user.name;
in
{
options.modules.desktop.apps.alacritty.enable = lib.mkEnableOption "Alacritty as the desktop terminal";
options.modules.desktop.terminal.enable = lib.mkEnableOption "Alacritty as the desktop terminal";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.alacritty ];

View File

@@ -6,7 +6,7 @@
}:
# Nord theming for the graphical layer, driven from one base16 scheme by Stylix.
let
cfg = config.modules.desktop.utilities.theming;
cfg = config.modules.desktop.theming;
user = config.user.name;
# A single static Nord wallpaper, drawn as a vertical gradient across the
@@ -18,7 +18,7 @@ let
'';
in
{
options.modules.desktop.utilities.theming.enable =
options.modules.desktop.theming.enable =
lib.mkEnableOption "Nord theming of the graphical layer via Stylix";
config = lib.mkIf cfg.enable {
@@ -32,9 +32,6 @@ in
image = wallpaper;
# Regreet is not enabled, so its styling target stays off.
targets.regreet.enable = false;
cursor = {
package = pkgs.bibata-cursors;
# Solid white with a dark outline, so it stays easy to spot against the

View File

@@ -2,11 +2,11 @@
# Declares the XDG user directories, so a tool that queries them lands in a
# well-known folder a host can relocate from one place.
let
cfg = config.modules.desktop.utilities.userdirs;
cfg = config.modules.desktop.userdirs;
user = config.user.name;
in
{
options.modules.desktop.utilities.userdirs.enable = lib.mkEnableOption "XDG user directories";
options.modules.desktop.userdirs.enable = lib.mkEnableOption "XDG user directories";
config = lib.mkIf cfg.enable {
# Write ~/.config/user-dirs.dirs from the option defaults.

View File

@@ -6,13 +6,12 @@
}:
# Waybar: a top status bar reading system state at a glance.
let
cfg = config.modules.desktop.session.waybar;
cfg = config.modules.desktop.waybar;
user = config.user.name;
# A Nerd Font Private-Use-Area codepoint as a literal character, decoded
# through JSON so the glyph survives as bytes rather than an editor paste.
g = code: builtins.fromJSON ''"\u${code}"'';
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
# Renders and toggles mako's do-not-disturb mode.
# With no mako daemon answering, the status reads "notifications on", keeping
@@ -39,17 +38,9 @@ let
printf '{"text":"","class":"idle"}\n'
fi
'';
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet lockscreen-idle-inhibit.service; then
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
in
{
options.modules.desktop.session.waybar.enable = lib.mkEnableOption "the Waybar status bar";
options.modules.desktop.waybar.enable = lib.mkEnableOption "the Waybar status bar";
config = lib.mkIf cfg.enable {
# A symbols font backs the module glyphs, so pango falls back to it for the
@@ -72,7 +63,6 @@ in
modules-center = [ "clock" ];
modules-right = [
"custom/recording"
"custom/idle-lock"
"mpris"
"wireplumber"
"network"
@@ -89,12 +79,10 @@ in
window-rewrite-default = g "f2d0";
window-rewrite = {
"class<Alacritty>" = g "f120";
"class<com.mitchellh.ghostty>" = g "f120";
"class<.*[Ff]irefox.*>" = g "f269";
"class<chromium.*>" = g "f268";
"class<[Cc]ode>" = g "f121";
"class<obsidian>" = g "f02d";
"class<[Ss]team>" = g "f1b6";
};
};
@@ -166,13 +154,6 @@ in
exec = "${recStatus}";
interval = 1;
};
"custom/idle-lock" = {
return-type = "json";
exec = "${idleLockStatus}";
interval = "once";
signal = 9;
};
};
};
};

View File

@@ -5,22 +5,20 @@
}:
# direnv for the primary user, configured through home-manager.
let
cfg = config.modules.shell.direnv;
cfg = config.modules.direnv;
user = config.user.name;
in
{
options.modules.shell.direnv.enable =
options.modules.direnv.enable =
lib.mkEnableOption "direnv for the primary user, with nix-direnv shell caching";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.direnv = {
enable = true;
silent = true;
# nix-direnv caches the evaluated shell so re-entering a directory is instant
# instead of re-running `nix develop`, and adds the `use flake` stdlib helper.
nix-direnv.enable = true;
stdlib = builtins.readFile ./repo-flake.sh;
};
};
}

View File

@@ -6,13 +6,11 @@
}:
# fish for the primary user, configured through home-manager.
let
cfg = config.modules.shell.fish;
cfg = config.modules.fish;
user = config.user.name;
shortcuts = config.registry.shell-shortcut.${config.networking.hostName};
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
in
{
options.modules.shell.fish = {
options.modules.fish = {
enable = lib.mkEnableOption "fish as the user's shell, configured via home-manager";
defaultShell = lib.mkOption {
@@ -29,8 +27,10 @@ in
home-manager.users.${user} = {
home.packages = with pkgs; [
eza # backs the ls/la/ll aliases
bat # backs the manpager
fastfetch # the shell greeting
wget # backs the wget abbreviation
];
programs.fish = {
@@ -51,8 +51,37 @@ in
}
];
shellAliases = commandsOf (lib.filterAttrs (_name: shortcut: !shortcut.abbreviation) shortcuts);
shellAbbrs = commandsOf (lib.filterAttrs (_name: shortcut: shortcut.abbreviation) shortcuts);
shellAliases = {
ls = "eza -al --color=always --group-directories-first --icons=always";
la = "eza -a --color=always --group-directories-first --icons=always";
ll = "eza -l --color=always --group-directories-first --icons=always";
lt = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
"l." = "eza -a | grep -e '^\\.'";
};
shellAbbrs = {
# Walk up the tree.
".." = "cd ..";
"..." = "cd ../..";
"...." = "cd ../../..";
vi = "nvim";
vim = "nvim";
cp = "cp -v";
tmx = "tmux new-session -A -s";
tarnow = "tar -acf ";
untar = "tar -zxvf ";
wget = "wget -c ";
grep = "grep --color=auto";
fgrep = "fgrep --color=auto";
egrep = "egrep --color=auto";
jctl = "journalctl -p 3 -xb";
# Rebuild the system, and reclaim disk from old generations.
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
cleanup = "sudo nix-collect-garbage -d";
};
functions = {
# Pick the logo that fits the terminal so the greeting never wraps.
@@ -91,11 +120,6 @@ in
description = "Copy a file, or recursively copy a source directory into a destination";
body = builtins.readFile ./functions/copy.fish;
};
deploy-to = {
description = "Deploy the current dotfiles branch to a host through target-local nixos-rebuild";
body = builtins.readFile ./functions/deploy-to.fish;
};
};
# Rendered by home-manager into ~/.config/fish/config.fish.

View File

@@ -5,11 +5,11 @@
}:
# git for the primary user, configured through home-manager.
let
cfg = config.modules.shell.git;
cfg = config.modules.git;
user = config.user.name;
in
{
options.modules.shell.git.enable =
options.modules.git.enable =
lib.mkEnableOption "git for the primary user, carrying the operator's commit identity";
config = lib.mkIf cfg.enable {

View File

@@ -1,14 +0,0 @@
{ config, lib, ... }:
let
cfg = config.modules.guest;
in
{
options.modules.guest.enable = lib.mkEnableOption "nested-guest base configuration";
config = lib.mkIf cfg.enable {
system.stateVersion = "26.05";
modules.network.ssh.enable = lib.mkDefault true;
modules.network.ssh.hostKeys.restore = lib.mkDefault false;
};
}

View File

@@ -1,42 +0,0 @@
{
config,
lib,
...
}:
let
cfg = config.modules.host;
user = config.user;
passwordSecret = "${user.name}-password";
in
{
options.modules.host.enable = lib.mkEnableOption "physical-host base configuration";
config = lib.mkIf cfg.enable {
nix.settings.extra-substituters = [ "https://nyx-cache.chaotic.cx/" ];
nix.settings.extra-trusted-public-keys = [
"nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk="
];
nix.gc.automatic = true;
nix.gc.dates = "Mon 03:15";
nix.gc.options = "--delete-older-than 30d";
nix.optimise.automatic = true;
nix.optimise.dates = [ "Thu 03:45" ];
boot.loader.systemd-boot.configurationLimit = 15;
services.xserver.xkb.layout = "us";
services.xserver.xkb.options = "caps:escape_shifted_capslock";
console.useXkbConfig = true;
sops.defaultSopsFile = ../secrets/shared.yaml;
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
sops.age.sshKeyPaths = [ ];
sops.gnupg.sshKeyPaths = [ ];
users.mutableUsers = false;
sops.secrets.${passwordSecret}.neededForUsers = true;
users.users.${user.name}.hashedPasswordFile = config.sops.secrets.${passwordSecret}.path;
};
}

View File

@@ -1,31 +1,35 @@
{
config,
lib,
util,
my,
...
}:
# The host networking foundation: per-VLAN bridges over a tagged trunk.
let
cfg = config.modules.network.vlans;
cfg = config.modules.network;
inherit (util) bridgeName;
# Each tagged VLAN materializes as a bridge named for its id, by the shared
# convention.
inherit (my) bridgeName;
# The tagged sub-interface stacked on the trunk that feeds one bridge.
vlanName = id: "${cfg.trunk}.${toString id}";
in
{
options.modules.network.vlans = {
enable = lib.mkEnableOption "host VLAN bridges over a tagged trunk";
options.modules.network = {
enable =
lib.mkEnableOption "the host trunk, its tagged-VLAN bridges, and the host management address";
trunk = lib.mkOption {
type = lib.types.str;
example = "enp1s0";
description = ''
Physical interface that carries 802.1Q-tagged traffic for every VLAN.
Each declared VLAN attaches to its own bridge.
The physical interface carrying 802.1Q-tagged traffic for every VLAN.
Each declared VLAN is stacked on it and enslaved to its own bridge.
'';
};
ids = lib.mkOption {
vlans = lib.mkOption {
type = lib.types.listOf (lib.types.ints.between 1 4094);
default = [ ];
example = [
@@ -34,8 +38,8 @@ in
30
];
description = ''
Tagged VLAN ids to materialize.
Each id N creates one bridge named `br-vlanN`.
The tagged VLAN ids to materialize. Each id N emits exactly one bridge
named `br-vlanN`, and guests on VLAN N attach to it.
'';
};
@@ -44,9 +48,9 @@ in
type = lib.types.nullOr (lib.types.ints.between 1 4094);
default = null;
description = ''
VLAN whose bridge carries the host's own management address.
The value must be one of `ids`.
Left null, the host takes no address on any bridge.
The VLAN whose bridge carries the host's own management address; must
be one of `vlans`. Left null, the host takes no address on any bridge
and the bridges serve guests alone.
'';
};
@@ -55,8 +59,8 @@ in
default = null;
example = "10.0.10.2/24";
description = ''
Host static management address in CIDR form.
Left null, the host takes its management address by DHCP.
The host's static management address, in CIDR form, on the management
VLAN's bridge. Left null, the host takes its address there by DHCP.
'';
};
};
@@ -65,20 +69,21 @@ in
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.management.vlan == null || lib.elem cfg.management.vlan cfg.ids;
message = "modules.network.vlans.management.vlan (${toString cfg.management.vlan}) must name one of modules.network.vlans.ids.";
assertion = cfg.management.vlan == null || lib.elem cfg.management.vlan cfg.vlans;
message = "modules.network.management.vlan (${toString cfg.management.vlan}) must name one of modules.network.vlans.";
}
{
assertion = cfg.management.address == null || cfg.management.vlan != null;
message = "modules.network.vlans.management.address needs modules.network.vlans.management.vlan to say which bridge carries it.";
message = "modules.network.management.address needs modules.network.management.vlan to say which bridge carries it.";
}
];
# networkd owns these interfaces so NetworkManager does not contend for them.
# networkd owns the trunk, its VLAN sub-interfaces, and the bridges, so a host
# also running NetworkManager leaves them to networkd rather than contending.
networking.networkmanager.unmanaged =
[ "interface-name:${cfg.trunk}" ]
++ map (id: "interface-name:${bridgeName id}") cfg.ids
++ map (id: "interface-name:${vlanName id}") cfg.ids;
++ map (id: "interface-name:${bridgeName id}") cfg.vlans
++ map (id: "interface-name:${vlanName id}") cfg.vlans;
systemd.network = {
enable = true;
@@ -98,18 +103,19 @@ in
};
vlanConfig.Id = id;
};
}) cfg.ids
}) cfg.vlans
);
networks = lib.mkMerge (
[
# The trunk carries only tagged frames and takes no address.
# The trunk carries only tagged frames up to the sub-interfaces and
# takes no address of its own.
{
"30-${cfg.trunk}" = {
matchConfig.Name = cfg.trunk;
networkConfig.LinkLocalAddressing = "no";
linkConfig.RequiredForOnline = "no";
vlan = map vlanName cfg.ids;
vlan = map vlanName cfg.vlans;
};
}
]
@@ -120,8 +126,8 @@ in
networkConfig.Bridge = bridgeName id;
linkConfig.RequiredForOnline = "no";
};
# The management bridge carries the host address.
# Other bridges provide L2 segments for guests.
# The management VLAN's bridge carries the host address; every other
# bridge is a plain L2 segment for guests.
"40-${bridgeName id}" = lib.mkMerge [
{
matchConfig.Name = bridgeName id;
@@ -134,7 +140,7 @@ in
{ address = [ cfg.management.address ]; }
))
];
}) cfg.ids
}) cfg.vlans
);
};
};

View File

@@ -1,335 +0,0 @@
{
config,
lib,
registry,
...
}:
# SSH on this machine, in both directions.
let
cfg = config.modules.network.ssh;
user = config.user.name;
inherit (lib)
concatLists
concatStringsSep
elem
filter
genAttrs
hasAttr
imap0
listToAttrs
mapAttrs
mapAttrsToList
mkIf
mkMerge
mkOption
nameValuePair
optional
optionalAttrs
types
unique
;
hostKeySecret = type: "ssh-host-${type}-key";
userKeySecret = "ssh-user-ed25519-key";
targetType = types.submodule (
{ name, ... }:
{
options = {
hostName = mkOption {
type = types.str;
default = name;
description = ''
The network address OpenSSH connects to for this target.
'';
};
user = mkOption {
type = types.str;
default = config.user.name;
description = ''
The remote login name OpenSSH uses for this target.
'';
};
port = mkOption {
type = types.port;
default = 22;
description = ''
The TCP port OpenSSH uses for this target.
'';
};
aliases = mkOption {
type = types.listOf types.str;
default = [ name ];
description = ''
Host patterns written into the generated OpenSSH client block.
'';
};
clientKey = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The public key this target offers when it connects outward.
Other machines admit this key according to the host groups below.
'';
};
hostKeys = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
The public keys this target presents when it accepts inbound SSH.
These keys generate system-wide known-host entries.
'';
};
};
}
);
keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key));
targetNamesIn = names: filter (name: hasAttr name cfg.targets) names;
workstationNames = targetNamesIn cfg.hosts.workstations;
serverNames = targetNamesIn cfg.hosts.servers;
clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names);
currentHost = config.networking.hostName;
isServer = elem currentHost cfg.hosts.servers;
isWorkstation = elem currentHost cfg.hosts.workstations;
defaultAuthorizedKeys = lib.flatten (
clientKeysFor workstationNames
++ optional isServer (clientKeysFor serverNames)
);
outboundTargetNames =
let
groupTargets =
if isServer then
[ "gitea" ] ++ cfg.hosts.servers
else if isWorkstation then
[ "gitea" ] ++ cfg.hosts.servers ++ cfg.hosts.workstations
else
[ "gitea" ];
in
filter (name: name != currentHost) (targetNamesIn groupTargets);
sshSettingsFor = name:
let
target = cfg.targets.${name};
in
{
header = "Host ${concatStringsSep " " target.aliases}";
HostName = target.hostName;
User = target.user;
}
// optionalAttrs (target.port != 22) { Port = target.port; };
knownHostNamesFor = target:
let
names = unique (target.aliases ++ [ target.hostName ]);
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
in
map withPort names;
knownHosts = listToAttrs (
concatLists (
mapAttrsToList (
targetName: target:
imap0 (i: key:
nameValuePair "${targetName}-${toString i}" {
hostNames = knownHostNamesFor target;
publicKey = keyWithoutComment key;
}
) target.hostKeys
) cfg.targets
)
);
in
{
options.modules.network.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
hosts = {
servers = mkOption {
type = types.listOf types.str;
default = [ "pikachu" ];
description = ''
Hosts that serve durable services.
They admit workstation keys and server keys, and they receive aliases for other servers and the forge.
'';
};
workstations = mkOption {
type = types.listOf types.str;
default = [ "neogaia" ];
description = ''
Hosts the operator works from.
Their keys are admitted by every host, and they receive aliases for the whole fleet and the forge.
'';
};
};
targets = mkOption {
type = types.attrsOf targetType;
default = {
gitea = {
hostName = "git.alexion.dev";
port = 2022;
user = "gitea";
aliases = [
"gitea"
"git.alexion.dev"
];
};
neogaia = {
hostName = registry.address.neogaia.self;
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia"
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia"
];
};
pikachu = {
hostName = registry.address.pikachu.self;
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu"
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu"
];
};
};
description = ''
SSH targets known to the fleet.
The inventory holds connection details plus public keys used for authorization and host verification.
'';
};
extraAuthorizedKeys = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Additional client public keys admitted by this host.
'';
};
authorizedKeys = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
description = ''
Complete override for client public keys admitted by this host.
Leave null to derive access from `modules.network.ssh.hosts` and `modules.network.ssh.targets`.
'';
};
extraSettings = mkOption {
type = types.attrsOf types.anything;
default = { };
description = ''
Additional OpenSSH client settings merged into the generated Home Manager configuration.
'';
};
hostKeys.restore = mkOption {
type = types.bool;
default = true;
description = ''
Restore the host keys from secrets rather than letting the daemon generate its own.
A machine with its own identity keeps its fingerprint across a reimage by restoring committed keys.
A guest carries no host identity, so it turns this off and presents a self-generated key instead.
'';
};
hostKeys.sopsFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Encrypted file holding this host's SSH host private keys, one entry per key type, named `ssh-host-<type>-key`.
Required when `restore` is on.
'';
};
hostKeys.types = mkOption {
type = types.listOf types.str;
default = [
"ed25519"
"rsa"
];
description = ''
Key types to restore, naming both the entries read from the encrypted file and the algorithms the daemon offers.
'';
};
userKey.sopsFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Encrypted file holding this machine's SSH client private key, under the entry `ssh-user-ed25519-key`.
Left unset on a machine that authenticates to no remote server, such as a guest.
'';
};
};
config = mkIf cfg.enable (
mkMerge [
{
services.openssh.enable = true;
users.users.${user}.openssh.authorizedKeys.keys =
if cfg.authorizedKeys != null then
cfg.authorizedKeys
else
defaultAuthorizedKeys ++ cfg.extraAuthorizedKeys;
programs.ssh.knownHosts = knownHosts;
home-manager.users.${user}.programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings =
mapAttrs (name: _: sshSettingsFor name) (genAttrs outboundTargetNames (name: name))
// cfg.extraSettings;
};
}
(mkIf cfg.hostKeys.restore {
assertions = [
{
assertion = cfg.hostKeys.sopsFile != null;
message = "modules.network.ssh.hostKeys.restore requires modules.network.ssh.hostKeys.sopsFile to name the encrypted host keys.";
}
];
sops.secrets = genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
inherit (cfg.hostKeys) sopsFile;
mode = "0400";
restartUnits = [ "sshd.service" ];
});
services.openssh.hostKeys = [ ];
services.openssh.extraConfig = concatStringsSep "" (
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") cfg.hostKeys.types
);
})
(mkIf (cfg.userKey.sopsFile != null) {
sops.secrets.${userKeySecret} = {
inherit (cfg.userKey) sopsFile;
mode = "0400";
owner = user;
};
home-manager.users.${user}.programs.ssh.settings."*".IdentityFile =
config.sops.secrets.${userKeySecret}.path;
})
]
);
}

View File

@@ -16,16 +16,6 @@ vim.api.nvim_create_autocmd("FileType", {
end,
})
-- Soft-wrap prose buffers without inserting line breaks.
vim.api.nvim_create_autocmd("FileType", {
pattern = { "gitcommit", "markdown", "text" },
callback = function()
vim.opt_local.wrap = true
vim.opt_local.linebreak = true
vim.opt_local.breakindent = true
end,
})
-- Turn line blame off again once the Neogit status buffer is closed.
vim.api.nvim_create_autocmd("BufUnload", {
callback = function(args)

View File

@@ -7,24 +7,18 @@
}:
# Neovim for the primary user, configured declaratively through nixvim.
let
cfg = config.modules.shell.nvim;
cfg = config.modules.nvim;
user = config.user.name;
in
{
options.modules.shell.nvim.enable = lib.mkEnableOption "Neovim, configured declaratively via nixvim";
options.modules.nvim.enable = lib.mkEnableOption "Neovim, configured declaratively via nixvim";
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName} = {
vi = "nvim";
vim = "nvim";
};
home-manager.sharedModules = [ inputs.nixvim.homeModules.nixvim ];
home-manager.users.${user} = hm: {
programs.nixvim = {
enable = true;
nixpkgs.useGlobalPackages = true;
extraPackages = with pkgs; [
git # neogit and gitsigns shell out to git
@@ -92,18 +86,6 @@ in
action = "<cmd>nohlsearch<CR>";
options.desc = "Clear search highlight";
}
{
mode = "n";
key = "j";
action = "gj";
options.desc = "Move down by display line";
}
{
mode = "n";
key = "k";
action = "gk";
options.desc = "Move up by display line";
}
{
mode = "n";
key = "<leader>e";
@@ -193,8 +175,8 @@ in
};
};
# `gbprod-nord` provides the `require("nord").setup()` API used in `./config.lua`.
# nixpkgs packages `shaunsingh/nord.nvim` as `nord-nvim`, which is a different plugin.
# gbprod/nord.nvim, set up in ./config.lua.
# nixvim's colorschemes.nord is a different plugin.
extraPlugins = [ pkgs.vimPlugins.gbprod-nord ];
extraConfigLua = builtins.readFile ./config.lua;

View File

@@ -1,62 +0,0 @@
{
config,
lib,
...
}:
let
cfg = config.modules.services.actualbudget;
in
{
options.modules.services.actualbudget = {
enable = lib.mkEnableOption "Actual Budget";
port = lib.mkOption {
type = lib.types.port;
default = 5006;
description = "Port Actual Budget listens on.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/data";
description = "Directory where Actual Budget stores server and user files.";
};
user = lib.mkOption {
type = lib.types.str;
default = "actual";
description = "User account that runs Actual Budget.";
};
group = lib.mkOption {
type = lib.types.str;
default = "storage";
description = "Group account that owns Actual Budget's writable state.";
};
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.actualbudget = 20001;
}
(lib.mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.actualbudget;
group = cfg.group;
};
services.actual = {
enable = true;
openFirewall = true;
user = cfg.user;
group = cfg.group;
settings = {
inherit (cfg) port dataDir;
};
};
})
];
}

View File

@@ -1,200 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.modules.services.copyparty;
volumeType = lib.types.submodule {
options = {
path = lib.mkOption {
type = lib.types.str;
description = "Filesystem path served by this volume.";
};
access = lib.mkOption {
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
default = { };
example = {
A = [ "alexion" ];
};
description = "Copyparty access map from permission string to account names.";
};
flags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "dthumb" ];
description = "Copyparty volume flags.";
};
};
};
renderAccess = permission: accounts: " ${permission}: ${lib.concatStringsSep ", " accounts}";
renderFlags = flags: lib.concatMapStringsSep "\n" (flag: " ${flag}") flags;
renderVolume = urlPath: volume: ''
[${urlPath}]
${volume.path}
accs:
${lib.concatStringsSep "\n" (lib.mapAttrsToList renderAccess volume.access)}
${lib.optionalString (volume.flags != [ ]) ''
flags:
${renderFlags volume.flags}
''}
'';
renderTrustedSource = source: " xff-src: ${source}";
renderedReverseProxy = lib.optionalString cfg.reverseProxy.enable ''
rproxy: 1
xff-hdr: x-forwarded-for
${lib.concatStringsSep "\n" (map renderTrustedSource cfg.reverseProxy.trustedSources)}
'';
renderedVolumes = lib.concatStringsSep "\n" (lib.mapAttrsToList renderVolume cfg.volumes);
configScript = pkgs.writeShellScript "copyparty-config" ''
set -eu
umask 077
password="$(${pkgs.coreutils}/bin/tr -d '\n' < "$CREDENTIALS_DIRECTORY/password")"
cat > /run/copyparty/copyparty.conf <<EOF
[global]
p: ${toString cfg.port}
usernames
http-only
no-crt
no-thumb
no-snap
no-rescan
ses-db: ${cfg.stateDir}/sessions.db
chpw-db: ${cfg.stateDir}/chpw.json
shr-db: ${cfg.stateDir}/shares.db
${renderedReverseProxy}
[accounts]
${cfg.accountName}: $password
${renderedVolumes}
EOF
'';
in
{
options.modules.services.copyparty = {
enable = lib.mkEnableOption "Copyparty";
package = lib.mkPackageOption pkgs "copyparty-min" { };
port = lib.mkOption {
type = lib.types.port;
default = 3923;
description = "Port Copyparty listens on.";
};
user = lib.mkOption {
type = lib.types.str;
default = "copyparty";
description = "User account that runs Copyparty.";
};
group = lib.mkOption {
type = lib.types.str;
default = "storage";
description = "Group account that owns Copyparty's writable state and served files.";
};
accountName = lib.mkOption {
type = lib.types.str;
default = "alexion";
description = "Copyparty account name configured for password authentication.";
};
passwordFile = lib.mkOption {
type = lib.types.path;
description = "Runtime file containing the Copyparty account password.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/copyparty";
description = "Directory where Copyparty stores service state.";
};
volumes = lib.mkOption {
type = lib.types.attrsOf volumeType;
default = { };
description = "Copyparty volumes keyed by their URL path.";
};
reverseProxy = {
enable = lib.mkEnableOption "reverse-proxy trust settings for Copyparty";
trustedSources = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "IP ranges that may supply Copyparty's forwarded client IP header.";
};
};
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.copyparty = 20002;
}
(lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.volumes != { };
message = "modules.services.copyparty.volumes must declare at least one served volume.";
}
{
assertion = !cfg.reverseProxy.enable || cfg.reverseProxy.trustedSources != [ ];
message = "modules.services.copyparty.reverseProxy.trustedSources must name at least one trusted proxy source when reverse-proxy support is enabled.";
}
];
users.users.${cfg.user} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = cfg.group;
home = cfg.stateDir;
};
systemd.services.copyparty = {
description = "Copyparty file server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ReloadSignal = "SIGUSR1";
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.stateDir;
Environment = [
"HOME=${cfg.stateDir}"
"XDG_CONFIG_HOME=${cfg.stateDir}"
];
StateDirectory = "copyparty";
RuntimeDirectory = "copyparty";
RuntimeDirectoryMode = "0700";
LoadCredential = [ "password:${cfg.passwordFile}" ];
ExecStartPre = configScript;
ExecStart = "${lib.getExe cfg.package} -c /run/copyparty/copyparty.conf";
Restart = "on-failure";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.stateDir ] ++ map (volume: volume.path) (lib.attrValues cfg.volumes);
};
};
networking.firewall.allowedTCPPorts = [ cfg.port ];
})
];
}

View File

@@ -1,95 +0,0 @@
{
config,
lib,
registry,
...
}:
let
cfg = config.modules.services.reverse-proxy;
mkRegistryPath = registryName: host: entry: "registry.${registryName}.${host}.${entry}";
mkBackend = address: route: "http://${address}:${toString route.port}";
mkVirtualHost = host: entry:
let
compositeEntry = registry.join host entry;
route = compositeEntry.route;
address = compositeEntry.address or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
in
{
inherit host entry;
name = route.domain;
value = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = mkBackend address route;
};
};
generatedVirtualHostList = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry) routes
) registry.route
);
virtualHostsByDomain = lib.foldl' (
acc: virtualHost:
acc
// {
${virtualHost.name} = (acc.${virtualHost.name} or [ ]) ++ [ virtualHost ];
}
) { } generatedVirtualHostList;
duplicateDomainMessages = lib.concatLists (
lib.mapAttrsToList (
domain: virtualHosts:
if lib.length virtualHosts > 1 then
[
"duplicate route domain ${domain} in ${lib.concatMapStringsSep " and " (virtualHost: mkRegistryPath "route" virtualHost.host virtualHost.entry) virtualHosts}"
]
else
[ ]
) virtualHostsByDomain
);
generatedVirtualHosts =
if duplicateDomainMessages != [ ] then
throw (builtins.head duplicateDomainMessages)
else
lib.listToAttrs generatedVirtualHostList;
in
{
options.modules.services.reverse-proxy = {
enable = lib.mkEnableOption "the public edge reverse proxy";
};
config = lib.mkIf cfg.enable {
networking.firewall.allowedTCPPorts = [
80
443
];
security.acme = {
acceptTerms = true;
defaults.email = "contact@alexion.dev";
};
services.nginx = {
enable = true;
commonHttpConfig = ''
server {
listen 0.0.0.0:443 ssl default_server;
listen [::0]:443 ssl default_server;
ssl_reject_handshake on;
}
'';
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
virtualHosts = generatedVirtualHosts;
};
};
}

View File

@@ -1,33 +0,0 @@
if declare -f use_flake >/dev/null; then
__dotfiles_use_flake_definition="$(declare -f use_flake)"
eval "${__dotfiles_use_flake_definition/use_flake/__dotfiles_nix_direnv_use_flake}"
unset __dotfiles_use_flake_definition
use_flake() {
local flake_expr="${1:-.}"
if [[ "$flake_expr" == //* ]]; then
local git_root
if ! git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
log_error 'use flake // requires a Git repository'
return 1
fi
local flake_suffix="${flake_expr#//}"
if [[ -n "$flake_suffix" && "$flake_suffix" != \#* && "$flake_suffix" != /* ]]; then
flake_expr="path:${git_root}/${flake_suffix}"
else
flake_expr="path:${git_root}${flake_suffix}"
fi
shift
__dotfiles_nix_direnv_use_flake "$flake_expr" "$@"
return
fi
__dotfiles_nix_direnv_use_flake "$@"
}
else
log_error 'nix-direnv must define use_flake before the // wrapper loads'
return 1
fi

View File

@@ -1,53 +0,0 @@
if test (count $argv) -lt 1; or test (count $argv) -gt 2
echo "usage: deploy-to <host> [branch]" >&2
return 2
end
set host $argv[1]
set branch $argv[2]
if test -z "$branch"
set branch (git branch --show-current)
end
if test -z "$branch"
echo "deploy-to: could not determine the current branch" >&2
return 2
end
git rev-parse --show-toplevel >/dev/null 2>&1
or begin
echo "deploy-to: run this from a git checkout" >&2
return 2
end
set dirty_lines (git status --porcelain)
if test (count $dirty_lines) -gt 0
echo "deploy-to: refusing to deploy with a dirty local worktree" >&2
return 1
end
git fetch origin
or return $status
set remote_ref refs/remotes/origin/$branch
git rev-parse --verify --quiet $remote_ref >/dev/null
or begin
echo "deploy-to: origin/$branch does not exist" >&2
return 1
end
set local_head (git rev-parse HEAD)
set remote_head (git rev-parse $remote_ref)
if test "$local_head" != "$remote_head"
echo "deploy-to: local HEAD differs from origin/$branch" >&2
echo "deploy-to: push or reset the branch before deploying" >&2
return 1
end
set escaped_branch (string escape -- $branch)
set escaped_host (string escape -- $host)
set remote_command "cd ~/wrk/dotfiles && git fetch origin && git switch -- $escaped_branch && git reset --hard origin/$escaped_branch && sudo nixos-rebuild switch --flake .#$escaped_host --print-build-logs"
ssh -t $host $remote_command

View File

@@ -1,47 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# Herdr, a terminal multiplexer for coding agents.
let
cfg = config.modules.shell.herdr;
user = config.user.name;
in
{
options.modules.shell.herdr.enable = lib.mkEnableOption "Herdr, a terminal multiplexer for coding agents";
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName}.hs = "herdr --session";
home-manager.users.${user} = {
home.packages = [ pkgs.herdr ];
xdg.configFile."herdr/config.toml".text = ''
[keys]
prefix = "ctrl+space"
detach = "prefix+d"
reload_config = "prefix+r"
new_workspace = "prefix+c"
new_tab = "prefix+shift+c"
rename_workspace = "prefix+comma"
rename_tab = "prefix+<"
split_vertical = "prefix+backslash"
split_horizontal = "prefix+minus"
switch_workspace = "prefix+1..9"
switch_tab = "prefix+shift+1..9"
focus_pane_left = "prefix+h"
focus_pane_down = "prefix+j"
focus_pane_up = "prefix+k"
focus_pane_right = "prefix+l"
[ui]
prompt_new_tab_name = false
[experimental]
kitty_graphics = true
'';
};
};
}

View File

@@ -1,32 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# Yazi, a terminal file manager.
let
cfg = config.modules.shell.yazi;
user = config.user.name;
in
{
options.modules.shell.yazi.enable = lib.mkEnableOption "Yazi, a terminal file manager";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.yazi = {
enable = true;
enableFishIntegration = true;
extraPackages = with pkgs; [
fd
ffmpegthumbnailer
fzf
imagemagick
jq
poppler
ripgrep
unar
zoxide
];
};
};
}

170
modules/ssh.nix Normal file
View File

@@ -0,0 +1,170 @@
{
config,
lib,
...
}:
# SSH on this machine, in both directions.
let
cfg = config.modules.ssh;
user = config.user.name;
hostKeySecret = type: "ssh-host-${type}-key";
userKeySecret = "ssh-user-ed25519-key";
in
{
options.modules.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
workstationKeys = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia"
];
description = ''
Client public keys of the machines the operator works from.
Every machine admits these, so any of them reaches the whole fleet.
'';
};
serverKeys = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Client public keys of the machines that serve.
Only other servers admit these, so one that is compromised reaches no
machine the operator works from.
'';
};
authorizedKeys = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = cfg.workstationKeys;
defaultText = lib.literalExpression "config.modules.ssh.workstationKeys";
description = ''
Client public keys this machine admits for the primary user, drawn from
the lists above.
A machine the operator works from takes the workstation keys. One that
serves takes both, so servers reach each other. The default admits the
workstation keys, since a machine admitting none is unreachable.
'';
};
hostKeys.restore = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Restore the host keys from secrets rather than letting the daemon
generate its own.
A machine with its own identity keeps its fingerprint across a reimage
by restoring committed keys. A guest carries no host identity, so it
turns this off and presents a self-generated key instead.
'';
};
hostKeys.sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Encrypted file holding this host's SSH host private keys, one entry per
key type, named `ssh-host-<type>-key`. Required when `restore` is on.
These are the keys the daemon presents to identify itself to connecting
clients, not keys used to authenticate anyone to a remote server.
Restoring them from secrets rather than generating them keeps the host's
fingerprint across a reimage, so every client's `known_hosts` entry
stays valid.
'';
};
hostKeys.types = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"ed25519"
"rsa"
];
description = ''
Key types to restore, naming both the entries read from the encrypted
file and the algorithms the daemon offers. Dropping a type a client has
already pinned makes the host unrecognisable to it.
'';
};
userKey.sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Encrypted file holding this machine's SSH client private key, under the
entry `ssh-user-ed25519-key`. Left unset on a machine that authenticates
to no remote server, such as a guest.
This is the key the primary user offers to authenticate to a remote
server, not a key the daemon presents to identify this machine.
It belongs to this machine alone, so withdrawing its access does not
re-key any other.
'';
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
services.openssh.enable = true;
# The primary user is the only account reachable over SSH.
users.users.${user}.openssh.authorizedKeys.keys = cfg.authorizedKeys;
}
# A machine with its own identity restores its host keys from secrets.
(lib.mkIf cfg.hostKeys.restore {
assertions = [
{
assertion = cfg.hostKeys.sopsFile != null;
message = "modules.ssh.hostKeys.restore requires modules.ssh.hostKeys.sopsFile to name the encrypted host keys.";
}
];
# The daemon reads its host keys once at startup, so a re-key has to
# restart it to take effect.
sops.secrets = lib.genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
inherit (cfg.hostKeys) sopsFile;
mode = "0400";
restartUnits = [ "sshd.service" ];
});
# An empty list is what stops the daemon generating keys of its own.
services.openssh.hostKeys = [ ];
services.openssh.extraConfig = lib.concatMapStrings (
type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n"
) cfg.hostKeys.types;
})
# The client key the primary user offers to remote servers, present only on
# a machine that has one.
(lib.mkIf (cfg.userKey.sopsFile != null) {
# The primary user is the only account that authenticates with this key,
# and the mode admits no other.
# The client rereads it per connection, so no unit restarts on a re-key.
sops.secrets.${userKeySecret} = {
inherit (cfg.userKey) sopsFile;
mode = "0400";
owner = user;
};
# The client reads the decrypted key where it is written, so no copy of it
# lives in the user's home to drift from the secret.
# Declaring no defaults of home-manager's own leaves every other directive
# at the one OpenSSH itself ships.
home-manager.users.${user}.programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings."*".IdentityFile = config.sops.secrets.${userKeySecret}.path;
};
})
]
);
}

View File

@@ -6,15 +6,13 @@
# tmux for the primary user, configured through home-manager.
# Settings without a home-manager option are read from ./extra.conf.
let
cfg = config.modules.shell.tmux;
cfg = config.modules.tmux;
user = config.user.name;
in
{
options.modules.shell.tmux.enable = lib.mkEnableOption "tmux, configured via home-manager";
options.modules.tmux.enable = lib.mkEnableOption "tmux, configured via home-manager";
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName}.tmx = "tmux new-session -A -s";
home-manager.users.${user}.programs.tmux = {
enable = true;

20
modules/toolkit.nix Normal file
View File

@@ -0,0 +1,20 @@
{ config, lib, ... }:
# The baseline interactive toolset as one deliberate bundle.
let
cfg = config.modules.toolkit;
in
{
options.modules.toolkit.enable =
lib.mkEnableOption "the baseline interactive environment — fish, tmux, nvim, git, and direnv — as one unit";
# Each piece is turned on at default priority, so a host can still override any one of them.
config = lib.mkIf cfg.enable {
modules.fish.enable = lib.mkDefault true;
# fish is also the login shell, so the bundled environment is what a shell drops into.
modules.fish.defaultShell = lib.mkDefault true;
modules.tmux.enable = lib.mkDefault true;
modules.nvim.enable = lib.mkDefault true;
modules.git.enable = lib.mkDefault true;
modules.direnv.enable = lib.mkDefault true;
};
}

View File

@@ -1,11 +0,0 @@
{ lib, ... }:
{
type = lib.types.str;
checks = [
{
assertion = lib.registry.predicates.uniqueValue (entry: entry.value);
message = "Address registry values must be unique.";
}
];
}

View File

@@ -1,25 +0,0 @@
{ lib, ... }:
{
checks = [
{
assertion = lib.registry.predicates.uniqueValue (entry: entry.value.domain);
message = "Route domains must be unique.";
}
];
type = lib.types.submodule {
options = {
domain = lib.mkOption {
type = lib.types.str;
example = "files.alexion.dev";
description = "Public domain served by this reverse-proxy route entry.";
};
port = lib.mkOption {
type = lib.types.ints.between 1 65535;
example = 3923;
description = "Backend TCP port served by the matching address registry entry.";
};
};
};
}

View File

@@ -1,21 +0,0 @@
{ lib, ... }:
let
entryType = lib.types.submodule {
options = {
command = lib.mkOption {
type = lib.types.str;
example = "nvim";
description = "Command expanded by this shell shortcut.";
};
abbreviation = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether fish realizes this shell shortcut as an abbreviation instead of an alias.";
};
};
};
in
{
type = lib.types.coercedTo lib.types.str (command: { inherit command; }) entryType;
}

View File

@@ -1,31 +0,0 @@
{ lib, ... }:
let
inherit (lib)
filterAttrs
groupBy
mapAttrs
unique
;
serviceNamesByUid = entries:
mapAttrs (_uid: uidEntries: unique (map (entry: entry.entry) uidEntries)) (
groupBy (entry: toString entry.value) entries
);
duplicateUids = entries:
filterAttrs (_uid: serviceNames: builtins.length serviceNames > 1) (serviceNamesByUid entries);
in
{
# Next stable UID: 20003
replicate = true;
type = lib.types.ints.positive;
checks = [
{
assertion = entries: duplicateUids entries == { };
message = "Stable UID allocations must be unique.";
}
];
}

Some files were not shown because too many files have changed in this diff Show More