24 Commits
Author SHA1 Message Date
jackos1998andClaude Opus 5 d1b9358069 nixos/netboot: Fix installer update failures
CI / Check, build and cache nixfiles (push) Has been cancelled
Update docs / update (push) Has been cancelled
Installer / Build installer (push) Successful in 4m56s
`netboot-update` failed with an opaque curl usage error whenever the
`installer` tag advanced past a build that had not published assets:
the `jq` select found no matching asset, and the empty result was
passed straight to `curl` as the URL. Report the missing asset (and
an unresolvable tag) instead.

The unit also had its network dependency inverted, being `wantedBy`
network-online.target rather than wanting and ordering after it. Fix
the idiom and keep it in the boot transaction via multi-user.target.

On river that is not enough on its own, because the WAN is a pppd
interface that networkd's wait-online knows nothing about, so
network-online.target is reached well before there is a route
off-site. Gate the service on wan-online.target there, following the
same wantedBy + partOf idiom as ipsec, which also re-runs the fetch
whenever the link returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:21:31 +01:00
jackos1998andClaude Opus 5 41ada3fa60 ci/installer: Switch to gitea-release-action
`release-action` is archived; its repository points at
`gitea-release-action` as the replacement. The inputs were renamed
(`api_key` -> `token`, `title` -> `name`).

The new action is a Node one rather than Go, so the Go setup step
kept in the previous commit is no longer needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:17:25 +01:00
jackos1998andClaude Opus 5 dcf79577ca nixos/git: Buffer uploads on the state volume
Gitea writes uploaded release assets to a temp file before storing
them. That landed in `/tmp`, which is on the 2G tmpfs root, so
uploading the installer ISO failed with:

  ParseMultipartForm [E] ... write /tmp/multipart-...: no space
  left on device

Point the service's `TMPDIR` at the state volume, which has room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:17:21 +01:00
jackos1998andClaude Opus 5 cea32c5f16 ci/installer: Refresh workflow infrastructure
CI / Check, build and cache nixfiles (push) Has been cancelled
Update docs / update (push) Has been cancelled
Installer / Build installer (push) Failing after 4m30s
The installer workflow had not been touched since 2024 and missed
both the Ubuntu 26.04 runner bump and the move to Determinate Nix as
the common Nix. Bring it in line with `ci.yaml`.

The Go setup step stays: it supports the Gitea release action rather
than the Nix build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:52:35 +01:00
jackos1998andClaude Opus 5 cbc48e456d nixos/build: Fix netboot initrd systemd config
`boot.initrd.systemd.extraConfig` was removed upstream and now fails
an assertion, which broke the `netbootArchive` target and with it the
second build step of the installer release workflow. Move the two
timeout settings to `settings.Manager`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:52:30 +01:00
jackos1998andClaude Opus 5 05918ec2ce nixos/installer: Refresh against upstream
Installer / Build installer (push) Has been cancelled
CI / Check, build and cache nixfiles (push) Has been cancelled
Update docs / update (push) Has been cancelled
The ISO target had drifted from nixpkgs and no longer evaluated:
`iso-image.nix` now defines `image.baseName` itself, which conflicts
with ours, so force it.

Drop the `boot.initrd.systemd.enable = false` override from the
`asISO` build target. The missing `/dev/root` it worked around is no
longer an issue, and scripted initrd is deprecated for removal in
26.11.

Replace the wpa_supplicant stanza, which upstream's
`installation-device.nix` no longer carries, with NetworkManager.
Keep it out of `multi-user.target` so nothing network-related starts
until asked; NetworkManager enables wpa_supplicant as its backend,
which is D-Bus activated on demand.

Pick up three more bits from that profile: the installer
`variant_id`, the pstore drop-in that stops an install evacuating the
target's persistent entries, and the mdadm `PROGRAM` stub that
silences the unset-mail warning.

Built and booted as an ISO to confirm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:45:28 +01:00
jackos1998 2ca4c3d5b1 home-manager/gui: Toggle AVRCP media keys
CI / Check, build and cache nixfiles (push) Successful in 58m6s
Update docs / update (push) Successful in 1m23s
Some headsets alternate play and pause events independently of the
actual player state. Treat either event as a toggle so an out-of-sync
event is not a no-op.
2026-08-18 16:05:49 +01:00
jackos1998 82cbe67010 home-manager/firefox: Add memory control
CI / Check, build and cache nixfiles (push) Successful in 57m45s
Update docs / update (push) Successful in 1m17s
Wrap Firefox with a privileged AutoConfig controller that unloads tabs under configurable MemAvailable hysteresis and exposes an on-demand reclaim command. Enable it by default for Linux GUI homes.
2026-08-13 12:29:35 +01:00
jackos1998andClaude Opus 5 0c6928f7ae docs/home: Add 5G WWAN modem reference
Update docs / update (push) Successful in 1m14s
CI / Check, build and cache nixfiles (push) Successful in 56m6s
Notes from bench-testing the Quectel RM500U-EA on tower, bought to
eventually replace stream's Virgin Media WAN. Like the switches and
APs, the module is configured out-of-band and is not referenced by
the flake.

Records what it takes to connect at all: the module must be switched
from its stock NCM composition to MBIM, since under NCM the PDP
context activates but the cdc_ncm link never raises carrier; and the
connect only succeeds with the network-expanded APN read off the
default-attach bearer, as IPv4.

Also flags the two consequences for stream: the SIM is CGNAT so it
cannot carry the public lease stream's WAN currently publishes, and
the bearer reports a /8 that would cover Tailscale's 100.64.0.0/10
if configured literally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:57:42 +01:00
jackos1998andClaude Opus 4.8 135d52d3de nix: Skip readLinkAt unit test on XFS builder
Update docs / update (push) Successful in 1m6s
CI / Check, build and cache nixfiles (push) Successful in 1h7m38s
CI builds Determinate Nix from source, running its unit-test suite. The
`nix-util` `readLinkAt.works` test creates symlinks with PATH_MAX-length
targets, but our CI runner's build filesystem is XFS, which hard-caps
symlink targets at 1024 bytes (XFS_SYMLINK_MAXLEN). Creation fails with
ENAMETOOLONG, so the test — and the whole determinate-nix build — fails
on the runner while passing on non-XFS filesystems.

Filter out just that test via gtest's GTEST_FILTER on the
`nix-util-tests-run` check input, leaving the rest of the unit and
functional tests gating the build (they still matter, since we build
against several nixpkgs channels).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 00:26:37 +01:00
jackos1998andClaude Opus 4.8 f8a89b9c87 docs: Add guided nixpkgs upgrade procedure
CI / Check, build and cache nixfiles (push) Has been cancelled
Update docs / update (push) Successful in 1m10s
Capture the periodic upgrade of the four nixpkgs channels and
home-manager as a repo doc: check for a NixOS stable bump first, rebase
the devplayer0 fork against upstream (re-verifying the patch stack
against freshly fetched refs), run the update commands, sweep
version-gated TODOs, and review the remaining flake inputs.

Keep the canonical, agent-agnostic procedure in docs/ and point both
AGENTS.md and a thin Claude Code skill at it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 23:31:26 +01:00
jackos1998andClaude Opus 4.8 5e3b196ee0 nixos/git: Fix Actions runner cache timeout
Job containers using actions/cache hung and timed out reaching the
runner's built-in artifact cache server. With cache.host unset,
act_runner announced the box's autodetected outbound address, which
containers on podman0 can't route back to; even to the right address
the host input chain (policy drop) dropped the connection, as only the
forward chain was opened for the podman subnet.

Pin cache.host to the podman bridge gateway and cache.port to a fixed
value, and open that one port on podman0 in the input chain. Declare
the podman subnet once in the box file and derive the gateway, the
default_subnet and both firewall rules from it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 22:56:30 +01:00
jackos1998 b8f31e23f8 nixos/home: Prefer dave for stream RSTP
CI / Check, build and cache nixfiles (push) Failing after 24m39s
Update docs / update (push) Successful in 1m9s
Set explicit port costs so `stream` prefers `dave` over `jim`.

Trigger `mstpd` when the bridge is configured, before it is routable.
2026-08-03 22:31:43 +01:00
jackos1998andClaude Opus 4.8 7fe3c8186c nix: Adopt Determinate Nix as the common Nix
Use Determinate Nix as `nix.package` for systems, homes and the
devshell, for its parallel evaluation and lazy trees. We only take the
package, not `determinate-nixd`: the daemon and `nix.conf` model are
unchanged and the Determinate NixOS module is not imported.

- Add the `determinate-nix` (`nix-src`) input, following our
  `nixpkgs-unstable`. FlakeHub's cache needs auth, so we build it
  ourselves and let it flow through Harmonia like everything else.
- `determinateOverlay` exposes it as `pkgs'.mine.determinate-nix`;
  `lib.my.c.nix.determinateSettings` (`lazy-trees`, `eval-cores = 0`)
  is merged into `nix.settings` and the devshell `nix.conf`.
- Switch CI to `DeterminateSystems/determinate-nix-action` so the
  runner itself evaluates with Determinate.
- Advertise the Harmonia cache via the flake's `nixConfig`, trusted
  without a prompt via `accept-flake-config` in the devshell, `.envrc`
  and CI only (boxes already trust it through `nix.settings`).
- Re-attach `pkgs`/`lib` to container and installer
  `nixosConfigurations` so Determinate's flake schemas can evaluate
  them (`nix flake check` otherwise fails with `attribute 'pkgs'
  missing`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 22:31:43 +01:00
jackos1998andClaude Opus 4.8 1ca978baf4 home: Raise stateVersion and drop dead nix.package guards
Every managed home already had its stateVersion force-set per
home-manager branch (22.11 for stable/mine-stable, 23.05 otherwise);
pin them all to 23.05.

With the floor at 23.05 the `versionAtLeast config.home.stateVersion`
guards on `nix.package` are always taken, so drop them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 22:31:43 +01:00
jackos1998andClaude Opus 4.8 b9bcb1eaeb nixos/home: Anchor static hi clients' DNS on VIPs
Update docs / update (push) Successful in 1m10s
CI / Check, build and cache nixfiles (push) Has been cancelled
Statically-addressed home servers on hi run no DHCP, so they learned a
resolver only from the v6 RA RDNSS and lost DNS whenever v6 (and thus
the RA) was absent. Factor the fix castle/palace applied inline into a
shared lib.my.c.home.vlanDns helper that points resolved at the VLAN's
VRRP VIPs (always-present static v4, plus v6 when up) and sets the
advertised search domains, then apply it to every statically-addressed
hi client: castle, palace, cellar, sfh and the sfh hass/unifi
containers. Document it under the router client DNS section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 22:27:47 +01:00
jackos1998andClaude Opus 4.8 f9f61e19ad docs/home: Document switches/AP must not route
CI / Check, build and cache nixfiles (push) Successful in 47m37s
Update docs / update (push) Successful in 1m7s
jim, dave and the vibe AP are pure L2, but RouterOS ships ip-forward and
IPv6 forward on, and with IPv6 forwarding enabled it also advertises
itself as a default router. After the 7.18 -> 7.23 upgrade clients began
picking up the switches as IPv6 default routers alongside river.

Replace the earlier advertise-dns framing (which only strips RA options,
not the router lifetime) with the actual requirement: ip-forward=no,
IPv6 forward=no, accept-router-advertisements=no, ra-lifetime=0, and a
re-check after every RouterOS upgrade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 14:15:40 +01:00
jackos1998andClaude Opus 4.8 14af217880 nixos/castle: Pin static DNS on lan-hi
CI / Check, build and cache nixfiles (push) Successful in 47m5s
Update docs / update (push) Successful in 1m16s
castle took its resolver solely from the IPv6 RA (radvd RDNSS) on
lan-hi, so DNS broke whenever RA was absent. Since castle's own v6 is
RA/SLAAC-derived it has no usable v6 address in that case, so anchor
DNS on the always-present static v4 via the VRRP VIP, with the v6 VIP
as a bonus when v6 is up. Search domains reuse the centralised
lib.my.c.home.searchDomains list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 22:04:34 +01:00
jackos1998andClaude Opus 4.8 26e6870337 lib/home: Centralise advertised search domains
radvd's DNSSL and kea's domain-search hardcoded the same list of
search domains in two places. Hoist it to lib.my.c.home.searchDomains
so there is a single source of truth (and so other consumers, such as
statically-configured boxes, can reuse it). No change to what is
advertised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 22:04:34 +01:00
jackos1998andClaude Opus 4.8 000c03d3c1 ci/docs: Fall back to discovered servers for AXFR
CI / Check, build and cache nixfiles (push) Successful in 47m47s
Update docs / update (push) Successful in 1m9s
The DNS reference generator transfers each zone from the nameservers
public NS discovery returns. The home IPv6 reverse zone
(0.d.4.0.0.c.7.9.e.0.a.2.ip6.arpa) is delegated only to Hurricane
Electric, which refuses AXFR, so generation aborted before committing
any zone. Our own authoritative servers (ns1/ns2.h.nul.ie) serve that
zone and permit transfers but aren't in its public delegation, as their
addresses are dynamic.

When a zone's delegated servers all refuse, retry against the union of
nameservers discovered for every other zone. That pool includes
ns1/ns2.h.nul.ie via the h.nul.ie NS records, resolved to their current
addresses at query time, so the reverse zone transfers from the same
reachable servers h.nul.ie already uses. Zones whose own servers work
are unaffected; the fallback only runs after their transfers fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:49:40 +01:00
jackos1998 4f6ea35ee4 nixos/toot: Fix broke deploy due to mastodon secrets 2026-08-02 21:31:07 +01:00
jackos1998andClaude Opus 4.8 de681f33da docs: Note container deploy targets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:20:53 +01:00
jackos1998andClaude Opus 4.8 ab2308e765 lib: Disable IPv6 privacy addresses on assignments
IPv6 temporary (privacy) addresses rotate an interface's stable source
address out from under long-lived connected sockets. nginx's resolver
on middleman binds a UDP socket to the preferred temporary address at
worker start; once that address expires and is removed, the socket can
no longer send, so every upstream lookup fails with "could not be
resolved (timed out)" until nginx is restarted (which then binds the
next temporary address, so it recurs).

These are servers with no need for privacy addresses, so disable them
in networkdAssignment for every RA-accepting interface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:20:53 +01:00
jackos1998 539a76a94f docs: Correct sfh acronym 2026-08-02 11:42:09 +01:00
48 changed files with 1220 additions and 92 deletions
+25
View File
@@ -0,0 +1,25 @@
---
name: upgrade-nixpkgs
description: >-
Upgrade all four nixpkgs channels (unstable, stable, mine, mine-stable) and home-manager for this
flake: check for a NixOS stable bump, rebase the devplayer0 nixpkgs fork against upstream, run the
update commands, sweep version-gated TODOs, and review flake inputs. Use when the user wants to
update/bump nixpkgs, refresh the pins, or do the periodic nixpkgs/home-manager upgrade.
---
# Upgrade nixpkgs
The canonical, agent-agnostic procedure lives in the repo at
[`docs/nixpkgs-upgrade.md`](../../../docs/nixpkgs-upgrade.md). Read it and follow the phases in
order.
Key reminders (see the doc for the full steps):
- It is **guided, not automated** — do the mechanical/investigative work but stop at the ⏸ points:
pushing the fork, resolving rebase conflicts, editing the `flake.nix` stable pins, and deleting
version guards. Report and let the user decide.
- **Check the current NixOS stable first** (Phase 1) — the fork's `devplayer0-stable` rebase target
and the `flake.nix` stable pins must agree on one release.
- **Re-verify the patch stack against freshly fetched upstream**, not stale refs — enumerate it with
`git log`, don't assume a remembered list (stale `upstream/*` refs make already-upstreamed commits
masquerade as fork-only patches).
+3 -1
View File
@@ -1,2 +1,4 @@
watch_file devshell/{default,commands,install,vm-tasks}.nix watch_file devshell/{default,commands,install,vm-tasks}.nix
use flake # --accept-flake-config trusts the flake's nixConfig (our Harmonia cache) non-interactively, so
# direnv doesn't stall on the trust prompt.
use flake . --accept-flake-config
+10 -5
View File
@@ -10,17 +10,22 @@ jobs:
runs-on: ubuntu-26.04 runs-on: ubuntu-26.04
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: cachix/install-nix-action@v31 - uses: DeterminateSystems/determinate-nix-action@v3
with: with:
# Gitea will supply a token in GITHUB_TOKEN, which this action will # Gitea will supply a token in GITHUB_TOKEN, which this action passes to
# try to pass to Nix when downloading from GitHub # Nix (as access-tokens) when downloading from GitHub
github_access_token: ${{ secrets.GH_PULL_TOKEN }} github-token: ${{ secrets.GH_PULL_TOKEN }}
extra_nix_config: | extra-conf: |
# Make sure we're using sandbox # Make sure we're using sandbox
sandbox-fallback = false sandbox-fallback = false
# Big C++ projects fill up memory... # Big C++ projects fill up memory...
cores = 6 cores = 6
# Determinate performance features
lazy-trees = true
eval-cores = 0
accept-flake-config = true
extra-substituters = https://nix-cache.nul.ie extra-substituters = https://nix-cache.nul.ie
extra-trusted-public-keys = nix-cache.nul.ie-1:BzH5yMfF4HbzY1C977XzOxoPhEc9Zbu39ftPkUbH+m4= extra-trusted-public-keys = nix-cache.nul.ie-1:BzH5yMfF4HbzY1C977XzOxoPhEc9Zbu39ftPkUbH+m4=
+14 -12
View File
@@ -7,20 +7,22 @@ on:
jobs: jobs:
installer: installer:
name: Build installer name: Build installer
runs-on: ubuntu-22.04 runs-on: ubuntu-26.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Set up Go - uses: DeterminateSystems/determinate-nix-action@v3
uses: https://github.com/actions/setup-go@v4
with: with:
go-version: '>=1.20.1' # Gitea will supply a token in GITHUB_TOKEN, which this action passes to
- uses: cachix/install-nix-action@v27 # Nix (as access-tokens) when downloading from GitHub
with: github-token: ${{ secrets.GH_PULL_TOKEN }}
github_access_token: ${{ secrets.GH_PULL_TOKEN }} extra-conf: |
extra_nix_config: |
# Make sure we're using sandbox # Make sure we're using sandbox
sandbox-fallback = false sandbox-fallback = false
# Determinate performance features
lazy-trees = true
eval-cores = 0
extra-substituters = https://nix-cache.nul.ie extra-substituters = https://nix-cache.nul.ie
extra-trusted-public-keys = nix-cache.nul.ie-1:BzH5yMfF4HbzY1C977XzOxoPhEc9Zbu39ftPkUbH+m4= extra-trusted-public-keys = nix-cache.nul.ie-1:BzH5yMfF4HbzY1C977XzOxoPhEc9Zbu39ftPkUbH+m4=
@@ -40,10 +42,10 @@ jobs:
jackos-installer-netboot-${{ steps.setup.outputs.short_rev }}.tar.zst jackos-installer-netboot-${{ steps.setup.outputs.short_rev }}.tar.zst
- name: Create release - name: Create release
uses: https://gitea.com/actions/release-action@main uses: https://gitea.com/actions/gitea-release-action@main
with: with:
title: Latest installer name: Latest installer
api_key: '${{ secrets.RELEASE_TOKEN }}' token: '${{ secrets.RELEASE_TOKEN }}'
files: | files: |
jackos-installer-${{ steps.setup.outputs.short_rev }}.iso jackos-installer-${{ steps.setup.outputs.short_rev }}.iso
jackos-installer-netboot-${{ steps.setup.outputs.short_rev }}.tar.zst jackos-installer-netboot-${{ steps.setup.outputs.short_rev }}.tar.zst
+7 -2
View File
@@ -46,7 +46,10 @@ Common ones:
Pass the flake-qualified node, e.g. `deploy .#git`. The deploy node name is **always** the system Pass the flake-qualified node, e.g. `deploy .#git`. The deploy node name is **always** the system
name (`deploy-rs.nix` keys nodes directly off `nixos.systems` / `home-manager.homes`); a system is name (`deploy-rs.nix` keys nodes directly off `nixos.systems` / `home-manager.homes`); a system is
only a deploy target when `config.my.deploy.enable` is true (defaults true; auto-disabled for dev only a deploy target when `config.my.deploy.enable` is true (defaults true; auto-disabled for dev
VMs and containers). Pass `--boot` to stage a config as the boot default **without** live-switching VMs and containers). A container is **not** its own deploy node — it is generated as a
`container-<name>` profile on its **host** node. So `deploy .#<host>` deploys the host's `system`
profile and every one of its containers, whereas `deploy .#<host>.container-<name>` targets a
single container (e.g. `deploy .#shill.container-middleman`). Pass `--boot` to stage a config as the boot default **without** live-switching
(`deploy --boot .#<host>`) — the box keeps running its current generation until it reboots. Use this (`deploy --boot .#<host>`) — the box keeps running its current generation until it reboots. Use this
when a live `switch` would break connectivity mid-change (e.g. a router's WAN VLAN rework), then when a live `switch` would break connectivity mid-change (e.g. a router's WAN VLAN rework), then
reboot to cut over. reboot to cut over.
@@ -58,7 +61,9 @@ Common ones:
`SSH_AUTH_SOCK= ssh-machine …` (or add `-o IdentityAgent=none` to a raw `ssh`). `SSH_AUTH_SOCK= ssh-machine …` (or add `-o IdentityAgent=none` to a raw `ssh`).
- `ragenix` — edit age secrets using `.keys/dev.key` as identity (see Secrets). - `ragenix` — edit age secrets using `.keys/dev.key` as identity (see Secrets).
- `repl` — `nix repl .#`. - `repl` — `nix repl .#`.
- `update-nixpkgs` / `update-home-manager` — bump pinned inputs. - `update-nixpkgs` / `update-home-manager` — bump pinned inputs. For the full periodic upgrade
(rebasing the `devplayer0` nixpkgs fork, stable-release bumps, version-gate sweep, input review)
follow the guided procedure in [`docs/nixpkgs-upgrade.md`](docs/nixpkgs-upgrade.md).
Use the narrowest relevant evaluation while iterating: `check-system <host>` for a box config, Use the narrowest relevant evaluation while iterating: `check-system <host>` for a box config,
`nix eval .#nixfiles.config.nixos.allAssignments --json` for assignment generation, or `nix eval .#nixfiles.config.nixos.allAssignments --json` for assignment generation, or
+18 -3
View File
@@ -144,9 +144,19 @@ def discover_nameservers(domains: list[str], port: int) -> dict[str, list[str]]:
return discovered return discovered
def transfer_domain(port: int, domain: str, servers: list[str]) -> list[Record]: def transfer_domain(
port: int, domain: str, servers: list[str], fallback: list[str] = ()
) -> list[Record]:
# A zone may be delegated publicly to servers that refuse AXFR (e.g. HE serving
# reverse DNS) while our own authoritative servers, discovered for other zones,
# will transfer it. Try the delegated servers first, then fall back to those.
ordered = list(servers)
for server in fallback:
if server not in ordered:
ordered.append(server)
errors = [] errors = []
for server in servers: for server in ordered:
try: try:
return transfer(server, port, domain) return transfer(server, port, domain)
except RuntimeError as error: except RuntimeError as error:
@@ -325,8 +335,13 @@ def main() -> int:
try: try:
nameservers = discover_nameservers(args.domain, args.port) nameservers = discover_nameservers(args.domain, args.port)
fallback = []
for servers in nameservers.values():
for server in servers:
if server not in fallback:
fallback.append(server)
transferred = [ transferred = [
(domain, transfer_domain(args.port, domain, nameservers[domain])) (domain, transfer_domain(args.port, domain, nameservers[domain], fallback))
for domain in args.domain for domain in args.domain
] ]
except RuntimeError as error: except RuntimeError as error:
+4 -1
View File
@@ -12,6 +12,9 @@ in
NIX_USER_CONF_FILES = toString (pkgs.writeText "nix.conf" NIX_USER_CONF_FILES = toString (pkgs.writeText "nix.conf"
'' ''
experimental-features = nix-command flakes ca-derivations experimental-features = nix-command flakes ca-derivations
lazy-trees = true
eval-cores = 0
accept-flake-config = true
connect-timeout = 5 connect-timeout = 5
fallback = true fallback = true
${lib.my.c.nix.cache.conf} ${lib.my.c.nix.cache.conf}
@@ -22,7 +25,7 @@ in
packages = with pkgs; [ packages = with pkgs; [
coreutils coreutils
nixVersions.stable determinate-nix
rage rage
wireguard-tools wireguard-tools
(pkgs.writeShellScriptBin "deploy" '' (pkgs.writeShellScriptBin "deploy" ''
+1 -1
View File
@@ -29,7 +29,7 @@ let
coreutils coreutils
gnugrep gnugrep
openssh openssh
nixVersions.stable determinate-nix
jq jq
]; ];
text = text =
+6 -2
View File
@@ -25,6 +25,8 @@ Not every box fits this pattern, but **colony** and **home** are organised this
- [`networking.md`](networking.md) — network assignments, domains, site topologies, router HA, - [`networking.md`](networking.md) — network assignments, domains, site topologies, router HA,
the AS211024 L2 mesh, BGP, WireGuard, Tailscale. the AS211024 L2 mesh, BGP, WireGuard, Tailscale.
- [`deployment.md`](deployment.md) — deploy-rs, devshell commands, secrets workflow, CI. - [`deployment.md`](deployment.md) — deploy-rs, devshell commands, secrets workflow, CI.
- [`nixpkgs-upgrade.md`](nixpkgs-upgrade.md) — guided procedure for the periodic upgrade of the four
nixpkgs channels and home-manager (fork rebase, stable bumps, input review).
- [`reference/dns.md`](reference/dns.md) — generated forward and reverse DNS record reference. - [`reference/dns.md`](reference/dns.md) — generated forward and reverse DNS record reference.
- [`reference/nixos-options.md`](reference/nixos-options.md) — generated per-option reference for - [`reference/nixos-options.md`](reference/nixos-options.md) — generated per-option reference for
the custom `my.*` NixOS modules. the custom `my.*` NixOS modules.
@@ -57,14 +59,16 @@ colony (physical VM host, ams1)
Redundant routers, VM host, storage, IoT containers and the workstation — see Redundant routers, VM host, storage, IoT containers and the workstation — see
[`sites/home/README.md`](sites/home/README.md). The hand-configured switch fabric (jim/dave/brian) [`sites/home/README.md`](sites/home/README.md). The hand-configured switch fabric (jim/dave/brian)
and the Digiweb WAN path are documented in [`sites/home/switches.md`](sites/home/switches.md). and the Digiweb WAN path are documented in [`sites/home/switches.md`](sites/home/switches.md); the
5G modem being evaluated as a replacement for `stream`'s WAN is in
[`sites/home/wwan.md`](sites/home/wwan.md).
``` ```
h.nul.ie h.nul.ie
├── palace (physical VM host — AMD, 100G, SR-IOV) ├── palace (physical VM host — AMD, 100G, SR-IOV)
│ ├── river ── primary router VM (PPPoE / Digiweb WAN) │ ├── river ── primary router VM (PPPoE / Digiweb WAN)
│ ├── cellar ─ NVMe-oF / SPDK storage target VM │ ├── cellar ─ NVMe-oF / SPDK storage target VM
│ └── sfh ──── container host VM ("services for home") │ └── sfh ──── container host VM ("shill from home")
│ ├── hass ── Home Assistant + Frigate + MQTT (container) │ ├── hass ── Home Assistant + Frigate + MQTT (container)
│ └── unifi ─ UniFi controller (container) │ └── unifi ─ UniFi controller (container)
├── stream (physical secondary router — Virgin Media WAN) ├── stream (physical secondary router — Virgin Media WAN)
+29 -2
View File
@@ -110,6 +110,31 @@ VM's unix sockets from `/run/vms/<vm>/` on `<host>` over SSH):
| `vm-monitor <host> <vm>` | QEMU monitor socket in `minicom`. | | `vm-monitor <host> <vm>` | QEMU monitor socket in `minicom`. |
| `vm-viewer <host> <vm>` | SPICE display in `virt-viewer` (not on Darwin). | | `vm-viewer <host> <vm>` | SPICE display in `virt-viewer` (not on Darwin). |
## Nix implementation
Every context uses **Determinate Nix** as its `nix.package`, for its performance features
(parallel evaluation and lazy trees) — not `determinate-nixd`; the daemon and `nix.conf` model
are unchanged, and the Determinate NixOS module is deliberately not imported.
- **Input and package.** The [`determinate-nix`](../flake.nix) input is the `nix-src` flake
(`flakehub.com/f/DeterminateSystems/nix-src`), with `nixpkgs.follows = "nixpkgs-unstable"`. We
build it ourselves against our pinned nixpkgs — FlakeHub's own cache needs authentication, so
there is nothing to gain from leaving it unpinned — and it then flows through the Harmonia cache
like everything else. `determinateOverlay` exposes it under the stable attr `determinate-nix`,
added to both the devshell `pkgs'` and the config `configPkgs'` overlay lists, so systems, homes
and the devshell all resolve the same package (`pkgs'.mine.determinate-nix`).
- **Settings.** `lib.my.c.nix.determinateSettings` (`lazy-trees`, `eval-cores = 0`) is merged into
`nix.settings` for systems and homes and into the devshell's `nix.conf`. These keys are only
understood by the Determinate binary.
- **Consumers follow automatically.** Everything that shells out to Nix references
`config.nix.package` (deploy-rs, containers, `build`, netboot, Harmonia), so they inherit
Determinate without further change.
- **`accept-flake-config`.** Set true only in the devshell `nix.conf`, `.envrc` (as
`--accept-flake-config`, for direnv) and CI — the contexts that build this flake — so its
`nixConfig` (the Harmonia cache) is trusted without an interactive prompt. It is deliberately not
set system-wide: boxes already trust that cache via `nix.settings`, so a global setting would only
blanket-trust every flake's `nixConfig` for no gain.
## Secrets ## Secrets
Secrets are age-encrypted files in [`secrets/`](../secrets), managed with **ragenix** (a fork Secrets are age-encrypted files in [`secrets/`](../secrets), managed with **ragenix** (a fork
@@ -152,8 +177,10 @@ GitHub/Gitea Actions workflows live in [`.gitea/workflows/`](../.gitea/workflows
### `ci.yaml` ### `ci.yaml`
On pushes to `master`, this runs `nix flake check --no-build`, then builds every attribute of On pushes to `master`, this installs Determinate Nix on the runner (via
`.#ci.x86_64-linux`: systems as `system-<name>`, homes as `home-<name>` (with `@` changed to `DeterminateSystems/determinate-nix-action`, configured with the same performance settings and
Harmonia substituter as the boxes), runs `nix flake check --no-build`, then builds every attribute
of `.#ci.x86_64-linux`: systems as `system-<name>`, homes as `home-<name>` (with `@` changed to
`-at-`), packages as `package-<name>`, and the development `shell`. Each result is pushed to the `-at-`), packages as `package-<name>`, and the development `shell`. Each result is pushed to the
Harmonia cache with [`ci/push-to-cache.sh`](../ci/push-to-cache.sh). Harmonia cache with [`ci/push-to-cache.sh`](../ci/push-to-cache.sh).
+5 -2
View File
@@ -24,8 +24,11 @@ The custom NixOS installer image used to bootstrap new boxes.
`installer-<hex>` hostname is set at boot. `installer-<hex>` hostname is set at boot.
- `INSTALL_ROOT=/mnt` in the session environment, plus a `show-hw-config` alias wrapping - `INSTALL_ROOT=/mnt` in the session environment, plus a `show-hw-config` alias wrapping
`nixos-generate-config --show-hardware-config --root $INSTALL_ROOT`. `nixos-generate-config --show-hardware-config --root $INSTALL_ROOT`.
- NixOS documentation enabled, `wpa_supplicant` available but not started, GC and memory-overcommit - NixOS documentation enabled, NetworkManager available but not started at boot (run
tuning for low-memory targets, LVM thin and NFS support. `systemctl start NetworkManager`, then `nmtui`), GC and memory-overcommit tuning for low-memory
targets, LVM thin and NFS support.
- Identifies itself as `VARIANT_ID=installer` in `/etc/os-release`, and leaves the target's
persistent pstore entries alone (`Unlink=no`) so an install doesn't evacuate them.
- No regular user (`my.user.enable = false`), no tmpfs-root management, no NAT, and not a - No regular user (`my.user.enable = false`), no tmpfs-root management, no NAT, and not a
deploy target (`my.deploy.enable = false`). deploy target (`my.deploy.enable = false`).
+6
View File
@@ -321,6 +321,12 @@ family move together.
serving disjoint pool halves. `radvd` advertises the v6 VIP as RDNSS (`untrusted` gets Cloudflare) serving disjoint pool halves. `radvd` advertises the v6 VIP as RDNSS (`untrusted` gets Cloudflare)
and keepalived's `notify_master`/`notify_backup` hooks ensure that only the master sends RAs. and keepalived's `notify_master`/`notify_backup` hooks ensure that only the master sends RAs.
Statically-addressed boxes (the servers on `hi`) don't run DHCP, so they'd otherwise learn a
resolver only from the v6 RA RDNSS — which vanishes when v6 is disabled, taking DNS with it. They
instead anchor DNS on the VIPs via the shared `lib.my.c.home.vlanDns "<vlan>"` fragment, which sets
`DNS` to `vips.<vlan>.{v4,v6}` and `Domains` to the advertised search list; the always-present
static v4 VIP keeps resolution working even with v6 down.
#### DNS binding #### DNS binding
`pdns-recursor` binds the VIPs directly; see `pdns-recursor` binds the VIPs directly; see
+125
View File
@@ -0,0 +1,125 @@
# Upgrading nixpkgs
Procedure for the periodic upgrade of all four nixpkgs channels (`unstable`, `stable`, `mine`,
`mine-stable`) and home-manager. Written to be followed by a person or any coding agent; a
Claude Code entry point exists at `.claude/skills/upgrade-nixpkgs/` but the steps below are the
canonical source.
The upgrade is **guided, not automated**: do the mechanical and investigative steps, but stop at
the judgment points (marked ⏸) — pushing the fork, resolving rebase conflicts, editing the
`flake.nix` stable pins, and deleting version guards. Report findings and let the maintainer
decide. Keep a running summary and present it before any push or commit.
Work the phases in order; skip one only if explicitly scoped to a subset.
## Setup facts
- **Fork checkout:** `~/documents/projects/nixpkgs` — remotes `origin` (`devplayer0/nixpkgs`) and
`upstream` (`NixOS/nixpkgs`). Confirm the path exists; if not, ask.
- **Fork branches:** `devplayer0` (tracks `nixos-unstable`) and `devplayer0-stable` (tracks the
current NixOS stable). Each is a small stack of local patches rebased onto upstream.
- **Flake pins** in `flake.nix` (`inputs`):
- `nixpkgs-unstable.url = "nixpkgs/nixos-unstable"`
- `nixpkgs-stable.url = "nixpkgs/nixos-<STABLE>"` (e.g. `nixos-26.05`)
- `nixpkgs-mine.url = "github:devplayer0/nixpkgs/devplayer0"`
- `nixpkgs-mine-stable.url = "github:devplayer0/nixpkgs/devplayer0-stable"`
- `home-manager-unstable.url = "home-manager"`
- `home-manager-stable.url = "home-manager/release-<STABLE>"`
- **Devshell commands:** `update-nixpkgs` = `nix flake update nixpkgs-{unstable,stable,mine,mine-stable}`;
`update-home-manager` = `nix flake update home-manager-{unstable,stable}`.
- **Validation:** prefer `check-system <host>` and `nix flake check --no-build` over full builds.
## Phase 1 — Determine the current NixOS stable
Do this first: everything downstream (the fork's `devplayer0-stable` rebase target, the `flake.nix`
stable pins) has to agree on one NixOS stable release, so establish it up front.
1. Find the latest NixOS stable release branch — check `git branch -r` on `upstream` for the newest
`release-YY.NN`, or the NixOS release schedule.
2. Compare it to `<STABLE>` in the `flake.nix` `nixpkgs-stable` / `home-manager-stable` URLs.
3. **If they already match** (no new stable): note "stable is current" and carry `<STABLE>` into the
later phases.
4. ⏸ **If a newer stable has cut:** stop and report the coordinated change set before proceeding —
the pieces must all move to the same release together:
- Rebase `devplayer0-stable` onto the new `upstream/release-YY.NN` (Phase 2 uses this target).
- Edit `flake.nix`: `nixpkgs-stable.url` and `home-manager-stable.url` → the new release.
- Bump each system's `stateVersion` / `home.stateVersion` only if the maintainer explicitly
wants to — that is a separate, deliberate decision; never auto-bump.
Don't edit `flake.nix` here without confirmation.
## Phase 2 — Rebase the nixpkgs fork
For **both** branches — `devplayer0` onto `upstream/nixos-unstable`, and `devplayer0-stable` onto
`upstream/release-<STABLE>` (the release established in Phase 1):
1. In `~/documents/projects/nixpkgs`, confirm a clean working tree (`git status`). If dirty, stop
and report — don't stash silently.
2. `git fetch upstream --prune` and `git fetch origin --prune`. If the checkout has been idle a
long time this fetch can be large and slow; let it finish.
3. Enumerate the patch stack before rebasing:
`git log --oneline upstream/nixos-unstable..origin/devplayer0` (and the stable equivalent
against `upstream/release-<STABLE>`). For each commit, check whether it has landed upstream or
been superseded — e.g. `git log --oneline upstream/nixos-unstable -- <path>` or grep the
upstream tree for the package/option. Note any patch that now looks redundant.
4. Rebase: `git switch devplayer0 && git rebase upstream/nixos-unstable` (and the stable branch
onto `upstream/release-<STABLE>`).
- ⏸ **Conflicts:** stop. Report which patch conflicts and against what upstream change; let the
maintainer resolve, or drop the patch if it has been upstreamed.
- Clean rebase: continue.
5. Summarize: which patches still apply, which are now redundant (candidate to drop), which
conflicted.
6. ⏸ **Push:** only after confirmation. `git push --force-with-lease origin devplayer0
devplayer0-stable` (force needed — rebase rewrites history).
## Phase 3 — Update the pinned inputs
Run together (they move as a set):
```
update-nixpkgs
update-home-manager
```
Then show the `flake.lock` diff for the nixpkgs/home-manager entries so the old→new revisions are
visible.
## Phase 4 — Sweep version-gated behavior
The repo carries branch-conditional logic and TODOs keyed to specific nixpkgs versions; some become
removable after an upgrade, especially after a stable bump. Surface them:
```
grep -rn "versionAtLeast\|versionOlder\|when 2[0-9]\.[0-9][0-9]\|TODO.*2[0-9]\.[0-9][0-9]" \
--include=*.nix nixos home-manager lib pkgs flake.nix
```
Known example: `nixos/modules/common.nix` carries a `# TODO: Remove if-else when 26.11 releases`
guard. For each hit, evaluate whether the now-current versions make the guard removable and list
candidates. ⏸ Don't delete guards without confirmation — some protect the still-supported stable.
## Phase 5 — Review remaining flake inputs
Don't blanket-update. Walk the other inputs deliberately:
1. List inputs and locked revisions from `flake.lock` (or `nix flake metadata`).
2. For each meaningful input (`libnetRepo`, `devshell`, `determinate-nix`, `ragenix`, `deploy-rs`,
`impermanence`, and the packaged apps like `boardie`, `harmonia`, `copyparty`, `sharry`, …),
compare the locked revision to upstream and summarize notable changes (breaking changes,
relevant fixes). Many inputs `follows` `nixpkgs-unstable` and already moved in Phase 3.
3. Propose a per-input update list with reasons; update the approved ones with targeted
`nix flake update <input>`, not a global update.
## Phase 6 — Validate
1. `nix flake check --no-build` (broad eval; reproduces CI's cheap checks).
2. `check-system <host>` on a representative box, and one exercising the stable channel if the
boxes mix channels.
3. Report eval/build results honestly. On failure, surface the error and stop rather than papering
over it.
## Wrap-up
Present a final summary: fork rebase outcome (patches kept/dropped/conflicted), whether a stable
bump is pending or was applied, the lock diff, version-gate cleanup candidates, inputs updated, and
validation results. Leave committing to the maintainer unless asked; if committing, follow the
repo's `area/scope: Capitalized summary` convention.
+6
View File
@@ -43,3 +43,9 @@ documented in [switches.md](switches.md).
The Wi-Fi APs — `vibe` (MikroTik cAP ax) and `wave` (Cudy AX3000 on OpenWrt) — are dumb APs, also The Wi-Fi APs — `vibe` (MikroTik cAP ax) and `wave` (Cudy AX3000 on OpenWrt) — are dumb APs, also
**not** managed by this flake. The shared VLAN-trunk design, SSIDs, per-AP management addressing, **not** managed by this flake. The shared VLAN-trunk design, SSIDs, per-AP management addressing,
and the OpenWrt flash/config for `wave` are in [aps.md](aps.md). and the OpenWrt flash/config for `wave` are in [aps.md](aps.md).
## 5G WWAN
A Quectel RM500U-EA USB modem with a GoMo SIM is being evaluated as a replacement for `stream`'s
Virgin Media WAN. It is bench-tested only and not yet referenced by the flake; the module settings
it needs, the APN gotcha and the CGNAT consequences are in [wwan.md](wwan.md).
+6
View File
@@ -77,6 +77,12 @@ Management uses host `.15`: `192.168.64.15` on native/core as a backup,
route through its VIP; `untrusted` has no address. With `l2mtu 9214`, `vibe` can use the jumbo route through its VIP; `untrusted` has no address. With `l2mtu 9214`, `vibe` can use the jumbo
`hi` network unlike `wave`. `hi` network unlike `wave`.
Like the switches, `vibe` is **pure L2 and must not route** — `ip-forward=no`, IPv6 `forward=no`,
`accept-router-advertisements=no`, `ra-lifetime=0`. RouterOS ships these forwarding-on and then
advertises itself as an IPv6 router; re-check after any upgrade. See
[switches.md#switches-must-not-route](switches.md#switches-must-not-route) for the rationale and
commands.
## wave (Cudy AX3000, OpenWrt) ## wave (Cudy AX3000, OpenWrt)
Single-port AP, so the port is a VLAN **trunk** carrying management + both SSIDs. Single-port AP, so the port is a VLAN **trunk** carrying management + both SSIDs.
+1 -1
View File
@@ -1,6 +1,6 @@
# sfh # sfh
"Services for home" — the NixOS container host for the home site. A VM on `palace` that netboots "Shill from home" — the NixOS container host for the home site. A VM on `palace` that netboots
from `river` and runs its root off NVMe-oF from `cellar`. from `river` and runs its root off NVMe-oF from `cellar`.
- **Source:** [`nixos/boxes/home/palace/vms/sfh/`](../../../../nixos/boxes/home/palace/vms/sfh) - **Source:** [`nixos/boxes/home/palace/vms/sfh/`](../../../../nixos/boxes/home/palace/vms/sfh)
+7 -2
View File
@@ -33,6 +33,10 @@ See the consolidated [network assignments](../../networking.md#box-assignments)
## WAN (Virgin Media DHCP) ## WAN (Virgin Media DHCP)
A Quectel RM500U-EA 5G modem is being evaluated as a replacement for this WAN; it is bench-tested
only and nothing here depends on it yet. Note that its SIM is CGNAT, so it cannot carry the public
lease this section assumes — see [wwan.md](wwan.md).
### Link and addressing ### Link and addressing
`wan` is a renamed igc NIC (`00:f0:cb:ee:ca:dd`) towards the cable modem. The modem segment is `wan` is a renamed igc NIC (`00:f0:cb:ee:ca:dd`) towards the cable modem. The modem segment is
@@ -76,8 +80,9 @@ box sets:
## Switching (RSTP) ## Switching (RSTP)
`stream` is dual-homed to both switches: `lan-jim` (igc) and `lan-dave` (mlx4_en), both MTU 9000, `stream` is dual-homed to both switches: `lan-jim` (igc) and `lan-dave` (mlx4_en), both MTU 9000,
are enslaved to the `lan` bridge with `STP=true`. [`routing-common/mstpd.nix`](../../../nixos/boxes/home/routing-common/mstpd.nix) are enslaved to the `lan` bridge with `STP=true`. The explicit bridge-port costs prefer
runs a patched `mstpd` and forces RSTP on `lan` once it's routable, so exactly one uplink carries `lan-dave` at 10 over `lan-jim` at 100. [`routing-common/mstpd.nix`](../../../nixos/boxes/home/routing-common/mstpd.nix)
runs a patched `mstpd` and forces RSTP on `lan` once it is configured, so exactly one uplink carries
traffic at a time. (The remaining NICs are renamed `et2`/`et5` and left unconfigured.) traffic at a time. (The remaining NICs are renamed `et2`/`et5` and left unconfigured.)
## Deployment ## Deployment
+30 -1
View File
@@ -86,7 +86,9 @@ from a box that does not depend on it, or power `castle` off cleanly first.
Switch L3 presence (`/interface vlan` on `main`) exists **only** for VLANs the switch is managed Switch L3 presence (`/interface vlan` on `main`) exists **only** for VLANs the switch is managed
from — `hi` (100) and `lo` (110), plus native core. WAN and guest VLANs deliberately have no switch from — `hi` (100) and `lo` (110), plus native core. WAN and guest VLANs deliberately have no switch
L3 interface. L3 interface. jim and dave carry a static IPv4 and **global IPv6** address on `hi`/`lo` (plus a
static default route on each stack) purely for management — they are **pure L2, never routers**. See
[Switches must not route](#switches-must-not-route).
## The Digiweb WAN path (trunked VLAN 10 + PVID 140) ## The Digiweb WAN path (trunked VLAN 10 + PVID 140)
@@ -147,6 +149,33 @@ this is plain tagged bridging.
10/140/141 rows. `wan-pon-in` (`sfp-sfpplus2`) sits at `pvid=1` as a spare port. jim only handles 10/140/141 rows. `wan-pon-in` (`sfp-sfpplus2`) sits at `pvid=1` as a spare port. jim only handles
stream's VLAN-130 WAN and the LAN VLANs. stream's VLAN-130 WAN and the LAN VLANs.
## Switches must not route
jim and dave (and the `vibe` AP) are **pure L2** — river/stream do all routing. Their per-stack
management addresses and static default routes exist only so the boxes themselves can be reached and
reach out; they must **never** forward traffic or advertise themselves as routers. RouterOS defaults
work against this: `ip-forward` and IPv6 `forward` ship **on**, and with IPv6 forwarding on RouterOS
also emits Router Advertisements (`ra-lifetime=30m`) on every L3 interface — so a switch silently
becomes a competing IPv6 default router. This surfaced after the 7.18 → 7.23 upgrade, when clients
picked up dave/jim as default routers alongside river.
The required config on each RouterOS box:
```
/ip settings set ip-forward=no
/ipv6 settings set forward=no accept-router-advertisements=no
/ipv6 nd set [find] ra-lifetime=0
```
- `ip-forward=no` / `forward=no` — no L3 forwarding on either stack; IPv6 `forward=no` also stops RA
emission at the source.
- `accept-router-advertisements=no` — with forwarding off RouterOS would otherwise start *accepting*
RAs; this keeps the box on its deterministic **static** default route.
- `ra-lifetime=0` — belt-and-suspenders: even if forwarding is ever re-enabled the box advertises
router-lifetime 0 (i.e. "not a default router"). Setting it also emits a withdrawal RA that
actively clears the rogue default from clients (they otherwise cache it for up to ~30 min).
**After any RouterOS upgrade, re-check `/ip settings` and `/ipv6 settings`** — an upgrade can reset
these to the forwarding-on defaults. brian (UniFi) is not a RouterOS box and was not affected.
## Future: multiple ONTs (per-port VLAN translation) ## Future: multiple ONTs (per-port VLAN translation)
If a second ONT arrives (e.g. a Digiweb line for stream, or a second river), trunking breaks: both If a second ONT arrives (e.g. a Digiweb line for stream, or a second river), trunking breaks: both
+139
View File
@@ -0,0 +1,139 @@
# Home 5G WWAN modem
Reference for the Quectel **RM500U-EA** USB 5G modem, bought to eventually replace `stream`'s
Virgin Media cable WAN ([stream.md](stream.md)). Like the switches ([switches.md](switches.md)) and
the APs ([aps.md](aps.md)), it is **not** managed by this flake: the module's own settings live in
its NVRAM and are applied out-of-band over AT, and nothing in the repo references it yet.
As of 2026-08-05 it has only been bench-tested on `tower`; `stream` is untouched. The notes below
are the working knowledge from that session — what the module needs in order to connect at all, and
what is still unresolved.
## The hardware
| | |
|---|---|
| Model | Quectel **RM500U-EA** |
| Platform | UNISOC-based (not Qualcomm) — its AT set is the `AT+QCFG`/`AT+QNETDEVCTL` router-firmware family, not the QMI one |
| USB | `2c7c:0900`, SuperSpeed (USB 3.1) |
| Firmware | `RM500UEAAAR03A13M2G` |
| SIM | **GoMo**, which rides eir's network (MCC/MNC **272-03**, AS**15751** Meteor Mobile) |
The SIM's **PIN lock has been disabled** on the SIM itself, so no PIN needs to be entered at boot
and no age secret is required for it. Card identifiers and the PIN are deliberately not recorded
here; read them from the modem with `mmcli` if needed.
## The module must be in MBIM mode
This is the single most important setting. The module ships in **NCM** mode (`AT+QCFG="usbnet",5`),
and in that mode it does not work:
- The PDP context comes up correctly — `AT+CGPADDR` reports a real address and `AT+CGCONTRDP`
reports the APN, DNS servers and prefix — but the `cdc_ncm` interface **never raises carrier**, so
no traffic can leave the box. No combination of `AT+QNETDEVCTL` modes (the `(0-3)` operations,
per profile) changed that.
- ModemManager also mis-reports the module's capability as `gsm-umts` only, and cannot read signal
quality (it sits at 0% while the radio is registered and attached).
Switching to **MBIM** fixes both:
```
AT+QCFG="usbnet",2
AT+CFUN=1,1 # reset so the new USB composition takes effect
```
It then enumerates as `cdc_mbim` with `/dev/cdc-wdm0` and a `wwp*` interface, ModemManager reports
`gsm-umts, lte, 5gnr`, signal quality works, and carrier follows the bearer. The other `usbnet`
values the module advertises are `(1,2,3,5,11,13,15)` — `1` ECM, `2` MBIM, `3` RNDIS, `5` NCM.
To reach the AT ports (`ttyUSB2` and `ttyUSB3` are the AT ones; ModemManager claims them, so stop it
first), any serial terminal works — `minicom -D /dev/ttyUSB2`, or a raw `stty`/`exec` pair on the
device node.
### Router-mode features are not in use
The firmware is the router variant: it can do its own NAT (`AT+QCFG="nat"`) and hand the host a
lease off a private LAN prefix (`AT+QCFG="lanip"`, default `192.168.42.0/24`). It is currently in
bridge mode (`nat=0`) so the host gets the real WAN address. NAT mode was not needed once MBIM
worked, and would mean double NAT.
## Connecting: the APN must be the network-expanded form
The documented consumer APNs (`gomo.ie`, `data.myeirmobile.ie`) **fail**. The connect only succeeds
with the fully expanded name the network itself uses, and only as **IPv4**:
```
mmcli -m <n> --simple-connect="apn=data.myeirmobile.ie.mnc003.mcc272.gprs,ip-type=ipv4"
```
The module has `AT+QCFG="autoapn",1`, so it selects an APN by itself during attach and brings up an
initial EPS bearer regardless. That bearer is where the expanded name comes from: list the modem's
bearers and read the one whose type is `default-attach`.
```
mmcli -m <n> # note the bearer paths and the initial bearer path
mmcli -b <n> # the default-attach bearer carries the real APN
```
Failure modes are worth distinguishing, since they look similar from `mmcli`:
| Symptom | Meaning |
|---|---|
| `MBIM status error: Failure`, immediate | The APN reached the network and was rejected — usually the wrong APN string |
| `Network timeout`, after a long wait | The APN never resolved to anything; wrong name entirely |
| `No valid data port found` | Already connected — the single data port is in use by an existing bearer |
## Bench result on tower
Measured 2026-08-05 on `tower`, indoors, with **no external antennas** and a weak signal
(RSSI around −85 dBm):
| | |
|---|---|
| Access technology | `lte, 5gnr` — 5G **NSA** |
| Throughput | ~64 Mbit/s down, ~26 Mbit/s up |
| Latency | ~76 ms to `1.1.1.1` |
| Bearer-negotiated rates | 150 Mbit/s down, 50 Mbit/s up |
Treat the throughput as a floor, not a characterisation — antennas and siting were both worst-case.
## The address is CGNAT, and the prefix length is a trap
Two separate consequences of how the bearer addresses the host.
### No public IP
The bearer address is in `100.64.0.0/10` and egress is carrier-NAT'd (`*.cgn1.srl.meteor.ie`,
AS15751). There is **no inbound reachability and no public address**. `stream` currently takes a
*public* DHCP lease on `wan` and publishes it — see [stream.md](stream.md#wan-virgin-media-dhcp),
which also drives `my.homeRouter.dns.wanSkipBroadcasts`. Replacing that WAN with this SIM therefore
drops port forwards, inbound WireGuard and anything resolving to `stream`'s WAN address. Making
this a real WAN needs either a public/static IP from the carrier, or `stream`'s inbound
reachability moved onto the AS211024 mesh or a tunnel from `britway`
([networking.md](../../networking.md)).
### The bearer reports a /8
ModemManager reports the address with a **`/8` prefix**, i.e. `100.0.0.0/8`. Configuring that
literally would install a route covering **Tailscale's `100.64.0.0/10`** and break it. Any
configuration for this modem must add the address as a `/32` with an explicit on-link route to the
gateway, and never use the bearer's own prefix length.
For a throwaway test that cannot disturb the box, put the address and default route in their own
routing table behind an `ip rule` matching the source address, and drive traffic onto it with
`ping -I <addr>` / `curl --interface <addr>`.
## Still open
- **IPv6.** GoMo is expected to provide it, but `ip-type=ipv4v6` fails to connect and only
`ip-type=ipv4` works. In NCM mode the module *did* report an IPv6 address and IPv6 DNS servers
(`2001:bb0::11`/`::12`) on the context, so the network clearly offers it — this looks like an APN
or MBIM-session problem rather than a carrier one. Worth retrying with a separate IPv6-only
context, or with the initial EPS bearer settings pinned via
`mmcli --3gpp-set-initial-eps-bearer-settings`.
- **A public or static IP** from GoMo/eir, without which this cannot replace `stream`'s WAN
unchanged (see above).
- **Antenna siting**, and whether 5G **SA** is reachable rather than the NSA seen so far.
- **Flake integration** — nothing exists yet. It would need the MBIM interface configured under
`stream`'s networkd, a `wan-online.target` mechanism equivalent to the current DHCP-route gate,
and a decision on whether ModemManager or a plain `mbimcli` connect script drives the bearer.
Generated
+116
View File
@@ -163,6 +163,29 @@
"type": "github" "type": "github"
} }
}, },
"determinate-nix": {
"inputs": {
"flake-parts": "flake-parts",
"git-hooks-nix": "git-hooks-nix",
"nixpkgs": [
"nixpkgs-unstable"
],
"nixpkgs-23-11": "nixpkgs-23-11",
"nixpkgs-regression": "nixpkgs-regression"
},
"locked": {
"lastModified": 1785428605,
"narHash": "sha256-wfaiSRLM1wDb4MV+NEzbyheK9Y03/oe56NR2I84UF7E=",
"rev": "0ff46631f69584c9f76792cae595ea253bd482c3",
"revCount": 26288,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/DeterminateSystems/nix-src/3.21.9/019fb409-4d6e-7243-8a88-23ceee2520e9/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/DeterminateSystems/nix-src/%2A"
}
},
"devshell": { "devshell": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
@@ -256,6 +279,42 @@
"type": "github" "type": "github"
} }
}, },
"flake-compat_2": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"determinate-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1748821116,
"narHash": "sha256-F82+gS044J1APL0n4hH50GYdPRv/5JWm34oCJYmVKdE=",
"rev": "49f0870db23e8c1ca0b5259734a02cd9e1e371a1",
"revCount": 377,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/hercules-ci/flake-parts/0.1.377%2Brev-49f0870db23e8c1ca0b5259734a02cd9e1e371a1/01972f28-554a-73f8-91f4-d488cc502f08/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/hercules-ci/flake-parts/0.1"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@@ -460,6 +519,30 @@
"type": "github" "type": "github"
} }
}, },
"git-hooks-nix": {
"inputs": {
"flake-compat": "flake-compat_2",
"gitignore": [
"determinate-nix"
],
"nixpkgs": [
"determinate-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1747372754,
"narHash": "sha256-2Y53NGIX2vxfie1rOW0Qb86vjRZ7ngizoo+bnXU9D9k=",
"rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46",
"revCount": 1026,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/cachix/git-hooks.nix/0.1.1026%2Brev-80479b6ec16fefd9c1db3ea13aeb038c60530f46/0196d79a-1b35-7b8e-a021-c894fb62163d/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/cachix/git-hooks.nix/0.1.941"
}
},
"harmonia": { "harmonia": {
"inputs": { "inputs": {
"crane": "crane", "crane": "crane",
@@ -655,6 +738,22 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs-23-11": {
"locked": {
"lastModified": 1717159533,
"narHash": "sha256-oamiKNfr2MS6yH64rUn99mIZjc45nGJlj9eGth/3Xuw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446",
"type": "github"
}
},
"nixpkgs-mine": { "nixpkgs-mine": {
"locked": { "locked": {
"lastModified": 1781356656, "lastModified": 1781356656,
@@ -687,6 +786,22 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs-regression": {
"locked": {
"lastModified": 1643052045,
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
}
},
"nixpkgs-stable": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1780902259, "lastModified": 1780902259,
@@ -847,6 +962,7 @@
"borgthin": "borgthin", "borgthin": "borgthin",
"copyparty": "copyparty", "copyparty": "copyparty",
"deploy-rs": "deploy-rs", "deploy-rs": "deploy-rs",
"determinate-nix": "determinate-nix",
"devshell": "devshell_3", "devshell": "devshell_3",
"flake-utils": "flake-utils_6", "flake-utils": "flake-utils_6",
"harmonia": "harmonia", "harmonia": "harmonia",
+44 -1
View File
@@ -1,6 +1,19 @@
{ {
description = "System configs"; description = "System configs";
# Offer our Harmonia cache when building the flake itself, so `nix develop` / `nix build` don't
# rebuild from source. Nix reads `nixConfig` before the flake evaluates and rejects any computed
# value (imports/thunks), so these must stay literal — keep them in sync with `lib.my.c.nix.cache`.
# Consumers must trust these (accept-flake-config / a trusted user) for them to take effect.
nixConfig = {
extra-substituters = [
"https://nix-cache.nul.ie"
];
extra-trusted-public-keys = [
"nix-cache.nul.ie-1:BzH5yMfF4HbzY1C977XzOxoPhEc9Zbu39ftPkUbH+m4="
];
};
inputs = { inputs = {
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
# libnet.url = "github:reo101/nix-lib-net"; # libnet.url = "github:reo101/nix-lib-net";
@@ -21,6 +34,12 @@
home-manager-stable.url = "home-manager/release-26.05"; home-manager-stable.url = "home-manager/release-26.05";
home-manager-stable.inputs.nixpkgs.follows = "nixpkgs-stable"; home-manager-stable.inputs.nixpkgs.follows = "nixpkgs-stable";
# Determinate Nix, used as the common Nix implementation across systems, homes, the devshell and
# CI (see lib.my.c.nix). We build it ourselves against our pinned nixpkgs (FlakeHub's cache needs
# auth), so it flows through our own Harmonia cache like everything else.
determinate-nix.url = "https://flakehub.com/f/DeterminateSystems/nix-src/*";
determinate-nix.inputs.nixpkgs.follows = "nixpkgs-unstable";
# Stuff used by the flake for build / deployment # Stuff used by the flake for build / deployment
# ragenix.url = "github:yaxitech/ragenix"; # ragenix.url = "github:yaxitech/ragenix";
ragenix.url = "github:devplayer0/ragenix/add-rekey-one-flag"; ragenix.url = "github:devplayer0/ragenix/add-rekey-one-flag";
@@ -83,6 +102,22 @@
}; };
pkgsLibOverlay = final: prev: { lib = prev.lib.extend libOverlay; }; pkgsLibOverlay = final: prev: { lib = prev.lib.extend libOverlay; };
myPkgsOverlay = final: prev: import ./pkgs { lib = final.lib; pkgs = prev; }; myPkgsOverlay = final: prev: import ./pkgs { lib = final.lib; pkgs = prev; };
# Exposes Determinate Nix under a stable attr name so systems, homes and the devshell all
# resolve the exact same package (referenced as `pkgs'.mine.determinate-nix` in configs).
# `nix-util`'s `readLinkAt.works` unit test creates PATH_MAX-length symlinks, which our CI
# runner's XFS-backed build filesystem rejects (XFS hard-caps symlink targets at 1024 bytes).
# Skip just that test via gtest's GTEST_FILTER so the rest of the suite still gates the build.
determinateOverlay = final: prev: {
determinate-nix =
(inputs.determinate-nix.packages.${prev.stdenv.hostPlatform.system}.default).overrideAttrs (o: {
checkInputs = map
(drv:
if (drv.name or "") == "nix-util-tests-run"
then drv.overrideAttrs (_: { GTEST_FILTER = "-readLinkAt.*"; })
else drv)
o.checkInputs;
});
};
# Override the flake-level lib since we're going to use it for non-config specific stuff # Override the flake-level lib since we're going to use it for non-config specific stuff
pkgsFlakes = mapAttrs (_: pkgsFlake: pkgsFlake // { lib = pkgsFlake.lib.extend libOverlay; }) { pkgsFlakes = mapAttrs (_: pkgsFlake: pkgsFlake // { lib = pkgsFlake.lib.extend libOverlay; }) {
@@ -111,6 +146,7 @@
pkgsLibOverlay pkgsLibOverlay
myPkgsOverlay myPkgsOverlay
determinateOverlay
inputs.devshell.overlays.default inputs.devshell.overlays.default
inputs.ragenix.overlays.default inputs.ragenix.overlays.default
inputs.deploy-rs.overlays.default inputs.deploy-rs.overlays.default
@@ -126,6 +162,7 @@
pkgsLibOverlay pkgsLibOverlay
myPkgsOverlay myPkgsOverlay
determinateOverlay
]; ];
config = { config = {
@@ -187,7 +224,13 @@
nixosModules = nixfiles.config.nixos.modules; nixosModules = nixfiles.config.nixos.modules;
homeModules = nixfiles.config.home-manager.modules; homeModules = nixfiles.config.home-manager.modules;
nixosConfigurations = mapAttrs (_: s: s.rendered) nixfiles.config.nixos.systems; # Containers and the installer override `rendered` with a bare `extendModules` config
# (`my.asContainer` / `my.asISO`) that lacks the `pkgs`/`lib` attrs `eval-config` exposes on a
# normal system. Determinate Nix's flake schemas read `machine.pkgs.stdenv.system` for every
# `nixosConfigurations` entry, so re-attach them from the full system eval (`configuration`).
nixosConfigurations = mapAttrs
(_: s: s.rendered // { inherit (s.configuration) pkgs lib; })
nixfiles.config.nixos.systems;
homeConfigurations = mapAttrs (_: s: s.configuration) nixfiles.config.home-manager.homes; homeConfigurations = mapAttrs (_: s: s.configuration) nixfiles.config.home-manager.homes;
deploy = nixfiles.config.deploy-rs.rendered; deploy = nixfiles.config.deploy-rs.rendered;
+4 -5
View File
@@ -2,7 +2,7 @@
let let
inherit (builtins) listToAttrs mapAttrs readFile; inherit (builtins) listToAttrs mapAttrs readFile;
inherit (lib) inherit (lib)
optionalString nameValuePair concatMapStrings concatStringsSep optionalAttrs versionAtLeast optionalString nameValuePair concatMapStrings concatStringsSep optionalAttrs
mapAttrsToList mkMerge mkIf mkDefault mkOption; mapAttrsToList mkMerge mkIf mkDefault mkOption;
inherit (lib.hm) dag; inherit (lib.hm) dag;
inherit (lib.my) mkOpt' dummyOption; inherit (lib.my) mkOpt' dummyOption;
@@ -50,8 +50,7 @@ in
}; };
nix = { nix = {
package = mkIf (!(versionAtLeast config.home.stateVersion "22.11")) pkgs.nix; settings = with lib.my.c.nix; determinateSettings // {
settings = with lib.my.c.nix; {
experimental-features = [ "nix-command" "flakes" "ca-derivations" ]; experimental-features = [ "nix-command" "flakes" "ca-derivations" ];
max-jobs = mkDefault "auto"; max-jobs = mkDefault "auto";
@@ -257,13 +256,13 @@ in
ssh.authKeys.files = [ lib.my.c.sshKeyFiles.me ]; ssh.authKeys.files = [ lib.my.c.sshKeyFiles.me ];
}; };
nix.package = mkIf (versionAtLeast config.home.stateVersion "22.05") pkgs.nix; nix.package = pkgs'.mine.determinate-nix;
fonts.fontconfig.enable = true; fonts.fontconfig.enable = true;
home = { home = {
packages = with pkgs; [ packages = with pkgs; [
pkgs'.mine.nix pkgs'.mine.determinate-nix
]; ];
# Without this, we are at the mercy of whatever version of nix is in $PATH... # Without this, we are at the mercy of whatever version of nix is in $PATH...
+23 -5
View File
@@ -1,6 +1,6 @@
{ lib, pkgs', pkgs, config, ... }: { lib, pkgs', pkgs, config, ... }:
let let
inherit (lib) genAttrs mkIf mkMerge mkForce mapAttrs mkOptionDefault mkDefault; inherit (lib) genAttrs mkIf mkMerge mkForce mapAttrs mkOptionDefault mkDefault optional;
inherit (lib.my) mkOpt' mkBoolOpt'; inherit (lib.my) mkOpt' mkBoolOpt';
inherit (lib.my.c) pubDomain; inherit (lib.my.c) pubDomain;
@@ -45,6 +45,9 @@ let
chmod +x "$out"/bin/doomsaver chmod +x "$out"/bin/doomsaver
''; '';
doomsaver = doomsaver' cfg.screensaver.brainrotTextCommand; doomsaver = doomsaver' cfg.screensaver.brainrotTextCommand;
firefoxMemoryControl = pkgs.firefox-memory-control.override {
inherit (cfg.firefoxMemoryControl) lowAvailableMiB highAvailableMiB pollIntervalMs minInactiveMs;
};
in in
{ {
options.my.gui = with lib.types; { options.my.gui = with lib.types; {
@@ -52,12 +55,26 @@ in
manageGraphical = mkBoolOpt' false "Configure the graphical session"; manageGraphical = mkBoolOpt' false "Configure the graphical session";
standalone = mkBoolOpt' false "Enable settings for fully Nix managed systems"; standalone = mkBoolOpt' false "Enable settings for fully Nix managed systems";
screensaver.brainrotTextCommand = mkOpt' (either path str) genLipsum "Command to generate brainrot text."; screensaver.brainrotTextCommand = mkOpt' (either path str) genLipsum "Command to generate brainrot text.";
firefoxMemoryControl = {
enable = mkBoolOpt' pkgs.stdenv.isLinux "Enable memory-pressure tab unloading in Firefox";
lowAvailableMiB = mkOpt' ints.positive 2048 "Available memory threshold at which Firefox starts unloading tabs.";
highAvailableMiB = mkOpt' ints.positive 3072 "Available memory threshold at which Firefox stops unloading tabs.";
pollIntervalMs = mkOpt' ints.positive 1000 "Memory-pressure polling interval in milliseconds.";
minInactiveMs = mkOpt' ints.unsigned 300000 "Minimum tab inactivity before automatic unloading, in milliseconds.";
};
}; };
config = mkIf cfg.enable (mkMerge [ config = mkIf cfg.enable (mkMerge [
{ {
assertions = [
{
assertion = cfg.firefoxMemoryControl.highAvailableMiB > cfg.firefoxMemoryControl.lowAvailableMiB;
message = "`my.gui.firefoxMemoryControl.highAvailableMiB` must exceed `lowAvailableMiB`.";
}
];
home = { home = {
packages = with pkgs; [ packages = (with pkgs; [
xdg-utils xdg-utils
font.package font.package
@@ -99,7 +116,7 @@ in
# --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.nodejs_latest ]} # --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.nodejs_latest ]}
# ''; # '';
# }) # })
]; ]) ++ optional cfg.firefoxMemoryControl.enable firefoxMemoryControl;
}; };
programs = { programs = {
@@ -378,8 +395,9 @@ in
"XF86AudioRaiseVolume" = "exec ${pkgs.pamixer}/bin/pamixer -i 5"; "XF86AudioRaiseVolume" = "exec ${pkgs.pamixer}/bin/pamixer -i 5";
"XF86AudioLowerVolume" = "exec ${pkgs.pamixer}/bin/pamixer -d 5"; "XF86AudioLowerVolume" = "exec ${pkgs.pamixer}/bin/pamixer -d 5";
"XF86AudioPlay" = "exec ${pkgs.playerctl}/bin/playerctl play"; # Some AVRCP devices alternate play and pause events independently of player state.
"XF86AudioPause" = "exec ${pkgs.playerctl}/bin/playerctl pause"; "XF86AudioPlay" = "exec ${pkgs.playerctl}/bin/playerctl play-pause";
"XF86AudioPause" = "exec ${pkgs.playerctl}/bin/playerctl play-pause";
"XF86AudioNext" = "exec ${pkgs.playerctl}/bin/playerctl next"; "XF86AudioNext" = "exec ${pkgs.playerctl}/bin/playerctl next";
"XF86AudioPrev" = "exec ${pkgs.playerctl}/bin/playerctl previous"; "XF86AudioPrev" = "exec ${pkgs.playerctl}/bin/playerctl previous";
}; };
+19
View File
@@ -111,6 +111,13 @@ rec {
extra-trusted-public-keys = ${concatStringsSep " " keys} extra-trusted-public-keys = ${concatStringsSep " " keys}
''; '';
}; };
# Determinate-specific settings enabling its performance features. Only understood by the
# Determinate Nix binary, so they must not be emitted for a base-Nix package.
determinateSettings = {
lazy-trees = true;
eval-cores = 0;
};
}; };
pubDomain = "nul.ie"; pubDomain = "nul.ie";
@@ -307,6 +314,8 @@ rec {
home = rec { home = rec {
domain = "h.${pubDomain}"; domain = "h.${pubDomain}";
# Search domains advertised to clients (radvd DNSSL / kea domain-search)
searchDomains = [ domain "dyn.${domain}" colony.domain britway.domain ];
vlans = { vlans = {
hi = 100; hi = 100;
lo = 110; lo = 110;
@@ -383,6 +392,16 @@ rec {
}; };
}; };
# networkConfig fragment anchoring a VLAN client's DNS on the router pair's
# VRRP VIPs rather than the RA RDNSS. v6 addresses here are RA/token-derived,
# so when RA is absent (e.g. v6 disabled) there is no v6 and no RDNSS at all;
# the always-present static v4 VIP keeps name resolution working, with the v6
# VIP as a bonus when v6 is up. Merge into the VLAN network's networkConfig.
vlanDns = vlan: {
DNS = [ vips.${vlan}.v4 vips.${vlan}.v6 ];
Domains = searchDomains;
};
roceBootModules = [ "ib_core" "ib_uverbs" "mlx5_core" "mlx5_ib" ]; roceBootModules = [ "ib_core" "ib_uverbs" "mlx5_core" "mlx5_ib" ];
}; };
+5 -1
View File
@@ -107,7 +107,7 @@ rec {
then throw "\nFailed assertions:\n${concatStringsSep "\n" (map (x: "- ${x}") failedAssertions)}" then throw "\nFailed assertions:\n${concatStringsSep "\n" (map (x: "- ${x}") failedAssertions)}"
else showWarnings config.warnings res; else showWarnings config.warnings res;
homeStateVersion' = hmBranch: (if (hmBranch == "stable" || hmBranch == "mine-stable") then "22.11" else "23.05"); homeStateVersion' = hmBranch: "23.05";
homeStateVersion = hmBranch: { homeStateVersion = hmBranch: {
# The flake passes a default setting, but we don't care about that # The flake passes a default setting, but we don't care about that
home.stateVersion = mkForce (homeStateVersion' hmBranch); home.stateVersion = mkForce (homeStateVersion' hmBranch);
@@ -149,6 +149,10 @@ rec {
(optional (a.ipv6.gateway != null) a.ipv6.gateway); (optional (a.ipv6.gateway != null) a.ipv6.gateway);
networkConfig = { networkConfig = {
IPv6AcceptRA = a.ipv6.gateway == null || a.ipv6.iid != null; IPv6AcceptRA = a.ipv6.gateway == null || a.ipv6.iid != null;
# These are servers: temporary (privacy) addresses only rotate our stable source
# address out from under long-lived connected sockets (e.g. nginx's resolver, which
# wedges permanently when the address it bound to expires).
IPv6PrivacyExtensions = "no";
# NOTE: LLDP emission / reception is ignored on bridge interfaces # NOTE: LLDP emission / reception is ignored on bridge interfaces
LLDP = true; LLDP = true;
EmitLLDP = "customer-bridge"; EmitLLDP = "customer-bridge";
+4 -2
View File
@@ -38,6 +38,8 @@ in
let let
inherit (lib) mkMerge; inherit (lib) mkMerge;
inherit (lib.my) networkdAssignment; inherit (lib.my) networkdAssignment;
podmanSubnet = "10.88.0.0/16";
in in
{ {
imports = [ imports = [
@@ -158,7 +160,7 @@ in
oci-containers = { oci-containers = {
backend = "podman"; backend = "podman";
}; };
containers.containersConf.settings.network.default_subnet = "10.88.0.0/16"; containers.containersConf.settings.network.default_subnet = podmanSubnet;
}; };
systemd.network = { systemd.network = {
@@ -195,7 +197,7 @@ in
extraRules = '' extraRules = ''
table inet filter { table inet filter {
chain forward { chain forward {
ip saddr 10.88.0.0/16 accept ip saddr ${podmanSubnet} accept
} }
} }
''; '';
@@ -1,7 +1,14 @@
{ lib, pkgs, config, ... }: { lib, pkgs, config, ... }:
let let
inherit (lib) mkForce; inherit (lib) mkForce;
inherit (lib.my) net;
inherit (lib.my.c) pubDomain; inherit (lib.my.c) pubDomain;
# The podman bridge gateway (first host of the default subnet); job
# containers reach the runner's artifact cache server here, through a single
# fixed port opened in the firewall below.
podmanGateway = net.cidr.host 1 config.virtualisation.containers.containersConf.settings.network.default_subnet;
cachePort = 34567;
in in
{ {
config = { config = {
@@ -34,6 +41,11 @@ in
cache = { cache = {
enabled = true; enabled = true;
dir = "/var/cache/gitea-runner"; dir = "/var/cache/gitea-runner";
# Announce the podman bridge gateway rather than let act_runner
# autodetect the box's outbound address, which containers can't
# route back to.
host = podmanGateway;
port = cachePort;
}; };
}; };
}; };
@@ -73,6 +85,15 @@ in
group = "gitea-runner"; group = "gitea-runner";
}; };
}; };
# Let job containers reach the runner's artifact cache server on the host.
firewall.extraRules = ''
table inet filter {
chain input {
iifname "podman0" tcp dport ${toString cachePort} accept
}
}
'';
}; };
}; };
} }
+10
View File
@@ -43,9 +43,19 @@ in
(umask 027; gitea_extra_setup) (umask 027; gitea_extra_setup)
''; '';
# Uploaded release assets are buffered through a temp file before being stored.
# The default /tmp is on the small tmpfs root, so keep them on the state volume.
environment.TMPDIR = "${config.services.gitea.stateDir}/tmp";
} }
]; ];
}; };
tmpfiles.settings."10-gitea-tmp"."${config.services.gitea.stateDir}/tmp".d = {
user = config.services.gitea.user;
group = config.services.gitea.group;
mode = "0700";
};
}; };
services = { services = {
@@ -48,8 +48,9 @@ in
"s3-secret-key.txt" "s3-secret-key.txt"
]) ])
(_: with config.services.mastodon; { (_: with config.services.mastodon; {
owner = user; # user doesn't exist any more, so this breaks on deploy
inherit group; # owner = user;
# inherit group;
})) // { })) // {
"toot/pds.env" = { "toot/pds.env" = {
owner = "pds"; owner = "pds";
+6 -2
View File
@@ -2,7 +2,7 @@
let let
inherit (lib.my) net; inherit (lib.my) net;
inherit (lib.my.c) networkd; inherit (lib.my.c) networkd;
inherit (lib.my.c.home) domain vlans prefixes vips roceBootModules; inherit (lib.my.c.home) domain vlans prefixes vips vlanDns roceBootModules;
in in
{ {
nixos.systems.castle = { nixos.systems.castle = {
@@ -189,8 +189,12 @@ in
}; };
"40-lan-hi" = mkMerge [ "40-lan-hi" = mkMerge [
(networkdAssignment "lan-hi" assignments.hi) (networkdAssignment "lan-hi" assignments.hi)
{
networkConfig = vlanDns "hi" // {
# So we don't drop the IP we use to connect to NVMe-oF! # So we don't drop the IP we use to connect to NVMe-oF!
{ networkConfig.KeepConfiguration = "static"; } KeepConfiguration = "static";
};
}
]; ];
"45-lan-lo" = { "45-lan-lo" = {
matchConfig.Name = "lan-lo"; matchConfig.Name = "lan-lo";
+5 -2
View File
@@ -2,7 +2,7 @@
let let
inherit (lib.my) net mkVLAN; inherit (lib.my) net mkVLAN;
inherit (lib.my.c) pubDomain; inherit (lib.my.c) pubDomain;
inherit (lib.my.c.home) domain vlans prefixes vips hiMTU; inherit (lib.my.c.home) domain vlans prefixes vips vlanDns hiMTU;
in in
{ {
imports = [ ./vms ]; imports = [ ./vms ];
@@ -203,7 +203,10 @@ in
MACAddress=52:54:00:90:34:95 MACAddress=52:54:00:90:34:95
''; '';
}; };
"60-lan-hi" = networkdAssignment "lan-hi" assignments.hi; "60-lan-hi" = mkMerge [
(networkdAssignment "lan-hi" assignments.hi)
{ networkConfig = vlanDns "hi"; }
];
"50-lan-core-phy" = { "50-lan-core-phy" = {
matchConfig.Name = "lan-core-phy"; matchConfig.Name = "lan-core-phy";
@@ -2,7 +2,7 @@
let let
inherit (lib.my) net; inherit (lib.my) net;
inherit (lib.my.c) pubDomain; inherit (lib.my.c) pubDomain;
inherit (lib.my.c.home) domain prefixes vips hiMTU; inherit (lib.my.c.home) domain prefixes vips vlanDns hiMTU;
in in
{ {
nixos.systems.cellar = { nixos.systems.cellar = {
@@ -79,7 +79,10 @@ in
}; };
networks = { networks = {
"80-lan-hi" = networkdAssignment "lan-hi" assignments.hi; "80-lan-hi" = mkMerge [
(networkdAssignment "lan-hi" assignments.hi)
{ networkConfig = vlanDns "hi"; }
];
}; };
}; };
+9
View File
@@ -145,6 +145,15 @@
}; };
}; };
# networkd's wait-online knows nothing about the pppd-owned `wan` interface, so
# network-online.target is reached long before there's a route off-site. Gate the
# installer fetch on the WAN instead, and retry it whenever the link returns.
systemd.services.netboot-update = {
after = [ "wan-online.target" ];
wantedBy = mkForce [ "wan-online.target" ];
partOf = [ "wan-online.target" ];
};
systemd.network = { systemd.network = {
netdevs = mkMerge [ netdevs = mkMerge [
(mkVLAN "wan-pon-ont" vlans.wan-pon-ont) (mkVLAN "wan-pon-ont" vlans.wan-pon-ont)
@@ -2,7 +2,7 @@
let let
inherit (lib.my) net; inherit (lib.my) net;
inherit (lib.my.c) pubDomain; inherit (lib.my.c) pubDomain;
inherit (lib.my.c.home) domain prefixes vips hiMTU; inherit (lib.my.c.home) domain prefixes vips vlanDns hiMTU;
in in
{ {
nixos.systems.hass = { config, ... }: { nixos.systems.hass = { config, ... }: {
@@ -82,7 +82,10 @@ in
systemd = { systemd = {
network.networks = { network.networks = {
"80-container-host0" = networkdAssignment "host0" assignments.hi; "80-container-host0" = mkMerge [
(networkdAssignment "host0" assignments.hi)
{ networkConfig = vlanDns "hi"; }
];
"80-container-lan-lo" = networkdAssignment "lan-lo" assignments.lo; "80-container-lan-lo" = networkdAssignment "lan-lo" assignments.lo;
}; };
}; };
@@ -1,7 +1,7 @@
{ lib, ... }: { lib, ... }:
let let
inherit (lib.my) net; inherit (lib.my) net;
inherit (lib.my.c.home) domain prefixes vips hiMTU; inherit (lib.my.c.home) domain prefixes vips vlanDns hiMTU;
in in
{ {
nixos.systems.unifi = { config, ... }: { nixos.systems.unifi = { config, ... }: {
@@ -58,7 +58,10 @@ in
systemd = { systemd = {
network.networks = { network.networks = {
"80-container-host0" = networkdAssignment "host0" assignments.hi; "80-container-host0" = mkMerge [
(networkdAssignment "host0" assignments.hi)
{ networkConfig = vlanDns "hi"; }
];
"80-lan-core" = networkdAssignment "lan-core" assignments.core; "80-lan-core" = networkdAssignment "lan-core" assignments.core;
}; };
}; };
+4 -2
View File
@@ -1,7 +1,7 @@
{ lib, ... }: { lib, ... }:
let let
inherit (lib.my) net; inherit (lib.my) net;
inherit (lib.my.c.home) domain prefixes vips hiMTU roceBootModules; inherit (lib.my.c.home) domain prefixes vips vlanDns hiMTU roceBootModules;
in in
{ {
imports = [ ./containers ]; imports = [ ./containers ];
@@ -134,8 +134,10 @@ in
networks = { networks = {
"30-lan-hi" = mkMerge [ "30-lan-hi" = mkMerge [
(networkdAssignment "lan-hi" assignments.hi) (networkdAssignment "lan-hi" assignments.hi)
{
# So we don't drop the IP we use to connect to NVMe-oF! # So we don't drop the IP we use to connect to NVMe-oF!
{ networkConfig.KeepConfiguration = "static"; } networkConfig = vlanDns "hi" // { KeepConfiguration = "static"; };
}
]; ];
"30-lan-hi-ctrs" = { "30-lan-hi-ctrs" = {
matchConfig.Name = "lan-hi-ctrs"; matchConfig.Name = "lan-hi-ctrs";
+3 -3
View File
@@ -1,8 +1,8 @@
index: { lib, pkgs, config, assignments, allAssignments, ... }: index: { lib, pkgs, config, assignments, allAssignments, ... }:
let let
inherit (lib) mkForce; inherit (lib) mkForce concatStringsSep;
inherit (lib.my) net netbootKeaClientClasses; inherit (lib.my) net netbootKeaClientClasses;
inherit (lib.my.c.home) domain prefixes vips hiMTU; inherit (lib.my.c.home) domain searchDomains prefixes vips hiMTU;
dns-servers = [ dns-servers = [
{ {
@@ -59,7 +59,7 @@ in
} }
{ {
name = "domain-search"; name = "domain-search";
data = "${domain}, dyn.${domain}, ${lib.my.c.colony.domain}, ${lib.my.c.britway.domain}"; data = concatStringsSep ", " searchDomains;
always-send = true; always-send = true;
} }
]; ];
+1 -1
View File
@@ -24,7 +24,7 @@ in
services = { services = {
networkd-dispatcher.rules = { networkd-dispatcher.rules = {
configure-mstpd = { configure-mstpd = {
onState = [ "routable" ]; onState = [ "configured" ];
script = '' script = ''
#!${pkgs.runtimeShell} #!${pkgs.runtimeShell}
if [ "$IFACE" = "lan" ]; then if [ "$IFACE" = "lan" ]; then
+3 -3
View File
@@ -1,8 +1,8 @@
index: { lib, pkgs, ... }: index: { lib, pkgs, ... }:
let let
inherit (lib) mkForce concatMapStringsSep; inherit (lib) mkForce concatMapStringsSep concatStringsSep;
inherit (lib.my) net; inherit (lib.my) net;
inherit (lib.my.c.home) domain prefixes vips; inherit (lib.my.c.home) domain searchDomains prefixes vips;
# untrusted uses external (Cloudflare) resolvers, matching the v4 kea config; # untrusted uses external (Cloudflare) resolvers, matching the v4 kea config;
# trusted VLANs use the internal recursor via its floating VRRP VIP # trusted VLANs use the internal recursor via its floating VRRP VIP
@@ -18,7 +18,7 @@ let
AdvLinkMTU ${toString prefixes."${name}".mtu}; AdvLinkMTU ${toString prefixes."${name}".mtu};
prefix ${prefixes."${name}".v6} {}; prefix ${prefixes."${name}".v6} {};
RDNSS ${rdnss name} {}; RDNSS ${rdnss name} {};
DNSSL ${domain} dyn.${domain} ${lib.my.c.colony.domain} ${lib.my.c.britway.domain} {}; DNSSL ${concatStringsSep " " searchDomains} {};
}; };
''; '';
in in
+3 -4
View File
@@ -115,10 +115,7 @@
Name = "lan"; Name = "lan";
Kind = "bridge"; Kind = "bridge";
}; };
extraConfig = '' bridgeConfig.STP = true;
[Bridge]
STP=true
'';
}; };
}; };
links = { links = {
@@ -175,10 +172,12 @@
"50-lan-jim" = { "50-lan-jim" = {
matchConfig.Name = "lan-jim"; matchConfig.Name = "lan-jim";
networkConfig.Bridge = "lan"; networkConfig.Bridge = "lan";
bridgeConfig.Cost = 100;
}; };
"50-lan-dave" = { "50-lan-dave" = {
matchConfig.Name = "lan-dave"; matchConfig.Name = "lan-dave";
networkConfig.Bridge = "lan"; networkConfig.Bridge = "lan";
bridgeConfig.Cost = 10;
}; };
"50-wan-ifb" = { "50-wan-ifb" = {
+24 -5
View File
@@ -32,7 +32,7 @@
}; };
image = { image = {
baseName = "jackos-installer"; baseName = mkForce "jackos-installer";
}; };
isoImage = { isoImage = {
volumeID = "jackos-${config.system.nixos.release}-${pkgs.stdenv.hostPlatform.uname.processor}"; volumeID = "jackos-${config.system.nixos.release}-${pkgs.stdenv.hostPlatform.uname.processor}";
@@ -97,10 +97,17 @@
documentation.enable = mkForce true; documentation.enable = mkForce true;
documentation.nixos.enable = mkForce true; documentation.nixos.enable = mkForce true;
# Enable wpa_supplicant, but don't start it by default. system.nixos.variant_id = mkDefault "installer";
networking.wireless.enable = mkDefault true;
networking.wireless.userControlled = true; # Enable NetworkManager, but don't start it by default.
systemd.services.wpa_supplicant.wantedBy = mkForce []; networking.networkmanager.enable = true;
systemd.services = {
NetworkManager.wantedBy = mkForce [];
NetworkManager-wait-online.wantedBy = mkForce [];
NetworkManager-dispatcher.wantedBy = mkForce [];
# NetworkManager's wireless backend, D-Bus activated on demand
wpa_supplicant.wantedBy = mkForce [];
};
# Tell the Nix evaluator to garbage collect more aggressively. # Tell the Nix evaluator to garbage collect more aggressively.
# This is desirable in memory-constrained environments that don't # This is desirable in memory-constrained environments that don't
@@ -113,6 +120,18 @@
# download-using-manifests.pl from forking even if there is # download-using-manifests.pl from forking even if there is
# plenty of free memory. # plenty of free memory.
boot.kernel.sysctl."vm.overcommit_memory" = "1"; boot.kernel.sysctl."vm.overcommit_memory" = "1";
# Prevent installation media from evacuating persistent storage, as their
# var directory is not persistent and it would thus result in deletion of
# those entries.
environment.etc."systemd/pstore.conf".text = ''
[PStore]
Unlink=no
'';
# Remove warning about unset mail
boot.swraid.mdadmConf = "PROGRAM ${pkgs.coreutils}/bin/true";
services.lvm.boot.thin.enable = true; services.lvm.boot.thin.enable = true;
}; };
}; };
+4 -7
View File
@@ -18,9 +18,6 @@ let
"${modulesPath}/installer/cd-dvd/iso-image.nix" "${modulesPath}/installer/cd-dvd/iso-image.nix"
allHardware allHardware
{ {
# Doesn't work right now... (missing /dev/root)
boot.initrd.systemd.enable = false;
isoImage = { isoImage = {
makeEfiBootable = true; makeEfiBootable = true;
makeUsbBootable = true; makeUsbBootable = true;
@@ -67,10 +64,10 @@ let
ip = "${iproute2}/bin/ip"; ip = "${iproute2}/bin/ip";
nbd-client = "${nbd}/bin/nbd-client"; nbd-client = "${nbd}/bin/nbd-client";
}; };
extraConfig = '' settings.Manager = {
DefaultTimeoutStartSec=20 DefaultTimeoutStartSec = "20s";
DefaultDeviceTimeoutSec=20 DefaultDeviceTimeoutSec = "20s";
''; };
network = { network = {
enable = true; enable = true;
+2 -2
View File
@@ -50,9 +50,9 @@ in
}; };
nix = { nix = {
package = pkgs'.mine.nix; package = pkgs'.mine.determinate-nix;
channel.enable = false; channel.enable = false;
settings = with lib.my.c.nix; { settings = with lib.my.c.nix; determinateSettings // {
trusted-users = [ "@wheel" ]; trusted-users = [ "@wheel" ];
experimental-features = [ "nix-command" "flakes" "ca-derivations" ]; experimental-features = [ "nix-command" "flakes" "ca-derivations" ];
extra-substituters = cache.substituters; extra-substituters = cache.substituters;
+11 -2
View File
@@ -129,7 +129,8 @@ in
services = { services = {
netboot-update = { netboot-update = {
description = "Update netboot images"; description = "Update netboot images";
after = [ "systemd-networkd-wait-online.service" ]; wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig.Type = "oneshot"; serviceConfig.Type = "oneshot";
path = with pkgs; [ path = with pkgs; [
coreutils curl jq zstd gnutar coreutils curl jq zstd gnutar
@@ -138,6 +139,10 @@ in
update_nixos() { update_nixos() {
latestShort="$(curl -s https://git.nul.ie/api/v1/repos/dev/nixfiles/tags/installer \ latestShort="$(curl -s https://git.nul.ie/api/v1/repos/dev/nixfiles/tags/installer \
| jq -r .commit.sha | cut -c -7)" | jq -r .commit.sha | cut -c -7)"
if [ -z "$latestShort" ] || [ "$latestShort" = "null" ]; then
echo "Couldn't resolve the installer tag to a commit" >&2
return 1
fi
if [ -f nixos-installer/tag.txt ] && [ "$(< nixos-installer/tag.txt)" = "$latestShort" ]; then if [ -f nixos-installer/tag.txt ] && [ "$(< nixos-installer/tag.txt)" = "$latestShort" ]; then
echo "NixOS installer is up to date" echo "NixOS installer is up to date"
return return
@@ -148,6 +153,10 @@ in
fname="jackos-installer-netboot-$latestShort.tar.zst" fname="jackos-installer-netboot-$latestShort.tar.zst"
downloadUrl="$(curl -s https://git.nul.ie/api/v1/repos/dev/nixfiles/releases/tags/installer | \ downloadUrl="$(curl -s https://git.nul.ie/api/v1/repos/dev/nixfiles/releases/tags/installer | \
jq -r ".assets[] | select(.name == \"$fname\").browser_download_url")" jq -r ".assets[] | select(.name == \"$fname\").browser_download_url")"
if [ -z "$downloadUrl" ]; then
echo "No release asset $fname; did the installer build succeed?" >&2
return 1
fi
curl -Lo /tmp/nixos-installer-netboot.tar.zst "$downloadUrl" curl -Lo /tmp/nixos-installer-netboot.tar.zst "$downloadUrl"
tar -C nixos-installer --zstd -xf /tmp/nixos-installer-netboot.tar.zst tar -C nixos-installer --zstd -xf /tmp/nixos-installer-netboot.tar.zst
truncate -s "${cfg.server.installer.storeSize}" nixos-installer/rootfs.ext4 truncate -s "${cfg.server.installer.storeSize}" nixos-installer/rootfs.ext4
@@ -163,7 +172,7 @@ in
update_nixos update_nixos
''; '';
startAt = "06:00"; startAt = "06:00";
wantedBy = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ];
}; };
nbd-server = { nbd-server = {
+1
View File
@@ -11,6 +11,7 @@ in
chocolate-doom2xx = callPackage ./chocolate-doom2xx { }; chocolate-doom2xx = callPackage ./chocolate-doom2xx { };
windowtolayer = callPackage ./windowtolayer.nix { }; windowtolayer = callPackage ./windowtolayer.nix { };
swaylock-plugin = callPackage ./swaylock-plugin.nix { }; swaylock-plugin = callPackage ./swaylock-plugin.nix { };
firefox-memory-control = callPackage ./firefox-memory-control { };
update-docs-assignments = pkgs.writeShellScriptBin "update-docs-assignments" '' update-docs-assignments = pkgs.writeShellScriptBin "update-docs-assignments" ''
exec ${pkgs.python3}/bin/python3 ${../ci/update-docs-assignments.py} "$@" exec ${pkgs.python3}/bin/python3 ${../ci/update-docs-assignments.py} "$@"
+251
View File
@@ -0,0 +1,251 @@
// This file runs as privileged Firefox AutoConfig code. Keep it in the Nix store.
(() => {
"use strict";
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
Cu.importGlobalProperties(["IOUtils"]);
const Services = {
appinfo: Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime),
console: Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService),
env: Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment),
prefs: Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch),
};
const { TabUnloader } = ChromeUtils.importESModule(
"moz-src:///browser/components/tabbrowser/TabUnloader.sys.mjs"
);
const PREFIX = "firefox.memoryControl.";
const MiB = 1024 * 1024;
const log = message => {
const line = `[firefox-memory-control] ${message}`;
Services.console.logStringMessage(line);
if (typeof dump === "function") {
dump(`${line}\n`);
}
};
const sleep = milliseconds =>
new Promise(resolve => {
const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
timer.initWithCallback(resolve, milliseconds, Ci.nsITimer.TYPE_ONE_SHOT);
});
const prefInt = name => Services.prefs.getIntPref(PREFIX + name);
const prefBool = name => Services.prefs.getBoolPref(PREFIX + name);
// procfs files report a size of zero, so IOUtils reads /proc/meminfo as empty.
// nsIScriptableInputStream also rejects reads larger than that reported size;
// nsIConverterInputStream reads until EOF without relying on it.
function availableMemory() {
const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
file.initWithPath("/proc/meminfo");
const fileStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
Ci.nsIFileInputStream
);
fileStream.init(file, 0x01, 0, 0);
const input = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(
Ci.nsIConverterInputStream
);
input.init(fileStream, "UTF-8", 0, 0);
const chunk = {};
let meminfo = "";
while (input.readString(4096, chunk)) {
meminfo += chunk.value;
}
input.close();
const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo);
if (!match) {
throw new Error("MemAvailable is absent from /proc/meminfo");
}
return Number(match[1]) * 1024;
}
async function unloadOne(minInactiveMs) {
const sorted = await TabUnloader.getSortedTabs(minInactiveMs);
const candidate = sorted.find(tab => TabUnloader.isDiscardable(tab));
if (!candidate) {
return null;
}
const estimatedBytes = candidate.memory || 0;
const unloaded = await TabUnloader.unloadLeastRecentlyUsedTab(minInactiveMs);
return unloaded ? { estimatedBytes } : null;
}
function runtimePaths() {
const runtimeDir = Services.env.get("XDG_RUNTIME_DIR");
if (!runtimeDir || !runtimeDir.startsWith("/")) {
throw new Error("XDG_RUNTIME_DIR is not an absolute path");
}
const root = `${runtimeDir}/firefox-memory-control`;
return {
root,
requests: `${root}/requests`,
processing: `${root}/processing`,
responses: `${root}/responses`,
};
}
async function ensureRuntimeDirectories(paths) {
for (const path of Object.values(paths)) {
await IOUtils.makeDirectory(path, { ignoreExisting: true, permissions: 0o700 });
}
}
async function claimRequest(paths) {
const children = await IOUtils.getChildren(paths.requests);
for (const requestPath of children.sort()) {
if (!requestPath.endsWith(".json")) {
continue;
}
const leaf = requestPath.slice(requestPath.lastIndexOf("/") + 1);
const claimed = `${paths.processing}/${leaf}.${Services.appinfo.processID}`;
try {
await IOUtils.move(requestPath, claimed, { noOverwrite: true });
return claimed;
} catch (error) {
// Another Firefox instance can win the atomic move.
}
}
return null;
}
async function writeResponse(paths, id, response) {
const finalPath = `${paths.responses}/${id}.json`;
const temporaryPath = `${finalPath}.${Services.appinfo.processID}.tmp`;
await IOUtils.writeUTF8(temporaryPath, JSON.stringify(response));
await IOUtils.move(temporaryPath, finalPath, { noOverwrite: true });
}
async function serviceRequest(paths, requestPath) {
let request;
try {
request = JSON.parse(await IOUtils.readUTF8(requestPath));
if (!/^[0-9a-f-]{36}$/.test(request.id)) {
throw new Error("invalid request id");
}
if (!Number.isSafeInteger(request.targetBytes) || request.targetBytes <= 0) {
throw new Error("targetBytes must be a positive integer");
}
const minInactiveMs = Number.isSafeInteger(request.minInactiveMs)
? Math.max(0, request.minInactiveMs)
: 0;
const baseline = await availableMemory();
let estimatedBytes = 0;
let observedBytes = 0;
let unloadedTabs = 0;
while (
estimatedBytes < request.targetBytes &&
observedBytes < request.targetBytes &&
unloadedTabs < 100
) {
const result = await unloadOne(minInactiveMs);
if (!result) {
break;
}
unloadedTabs += 1;
estimatedBytes += result.estimatedBytes;
await sleep(400);
observedBytes = Math.max(0, (await availableMemory()) - baseline);
}
const reachedTarget =
estimatedBytes >= request.targetBytes || observedBytes >= request.targetBytes;
await writeResponse(paths, request.id, {
id: request.id,
reachedTarget,
targetBytes: request.targetBytes,
unloadedTabs,
estimatedBytes,
observedBytes,
});
} catch (error) {
log(`request failed: ${error}`);
if (request && /^[0-9a-f-]{36}$/.test(request.id)) {
await writeResponse(paths, request.id, {
id: request.id,
reachedTarget: false,
error: String(error),
});
}
} finally {
await IOUtils.remove(requestPath, { ignoreAbsent: true });
}
}
const controller = {
busy: false,
underPressure: false,
timer: null,
paths: null,
async tick() {
if (this.busy) {
return;
}
this.busy = true;
try {
const request = await claimRequest(this.paths);
if (request) {
await serviceRequest(this.paths, request);
return;
}
if (!prefBool("enabled")) {
this.underPressure = false;
return;
}
const available = await availableMemory();
const low = prefInt("lowAvailableMiB") * MiB;
const high = prefInt("highAvailableMiB") * MiB;
if (high <= low) {
throw new Error("highAvailableMiB must be greater than lowAvailableMiB");
}
if (!this.underPressure && available <= low) {
this.underPressure = true;
log(`memory pressure entered at ${Math.round(available / MiB)} MiB available`);
} else if (this.underPressure && available >= high) {
this.underPressure = false;
log(`memory pressure cleared at ${Math.round(available / MiB)} MiB available`);
}
if (this.underPressure) {
await unloadOne(prefInt("minInactiveMs"));
}
} catch (error) {
log(`poll failed: ${error}`);
} finally {
this.busy = false;
}
},
async start() {
try {
this.paths = runtimePaths();
await ensureRuntimeDirectories(this.paths);
const interval = Math.max(250, prefInt("pollIntervalMs"));
this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
this.timer.initWithCallback(
() => this.tick(),
interval,
Ci.nsITimer.TYPE_REPEATING_SLACK
);
log(`started; polling every ${interval} ms`);
await this.tick();
} catch (error) {
log(`startup failed: ${error}`);
}
},
};
controller.start();
})();
+46
View File
@@ -0,0 +1,46 @@
{
lib,
firefox-unwrapped,
wrapFirefox,
writeText,
writeScriptBin,
symlinkJoin,
python3,
lowAvailableMiB ? 2048,
highAvailableMiB ? 3072,
pollIntervalMs ? 1000,
minInactiveMs ? 300000,
}:
let
autoConfig = writeText "firefox-memory-control.js" ''
defaultPref("firefox.memoryControl.enabled", true);
defaultPref("firefox.memoryControl.lowAvailableMiB", ${toString lowAvailableMiB});
defaultPref("firefox.memoryControl.highAvailableMiB", ${toString highAvailableMiB});
defaultPref("firefox.memoryControl.pollIntervalMs", ${toString pollIntervalMs});
defaultPref("firefox.memoryControl.minInactiveMs", ${toString minInactiveMs});
${builtins.readFile ./autoconfig.js}
'';
firefox = wrapFirefox firefox-unwrapped {
extraAutoConfig = ''
pref("general.config.sandbox_enabled", false);
'';
extraPrefsFiles = [ autoConfig ];
};
freeMemory = writeScriptBin "firefox-free-memory" ''
#!${python3}/bin/python3
${builtins.readFile ./firefox-free-memory.py}
'';
in
symlinkJoin {
name = "firefox-memory-control-${firefox.version}";
paths = [ firefox freeMemory ];
meta = firefox.meta // {
description = "Firefox with memory-pressure tab unloading and an on-demand reclaim utility";
mainProgram = "firefox";
platforms = lib.platforms.linux;
};
}
@@ -0,0 +1,139 @@
import argparse
import json
import os
import re
import sys
import time
import uuid
from pathlib import Path
SIZE_RE = re.compile(r'^([0-9]+(?:\.[0-9]+)?)\s*([kmgt]?i?b?)?$', re.I)
DURATION_RE = re.compile(r'^([0-9]+(?:\.[0-9]+)?)\s*(ms|s|m|h)?$', re.I)
def parse_size(value):
match = SIZE_RE.match(value)
if not match:
raise argparse.ArgumentTypeError(f'invalid size: {value!r}')
number = float(match.group(1))
suffix = (match.group(2) or 'b').lower().removesuffix('b').removesuffix('i')
powers = {'': 0, 'k': 1, 'm': 2, 'g': 3, 't': 4}
size = round(number * 1024 ** powers[suffix])
if size <= 0:
raise argparse.ArgumentTypeError('size must be greater than zero')
return size
def parse_duration(value):
match = DURATION_RE.match(value)
if not match:
raise argparse.ArgumentTypeError(f'invalid duration: {value!r}')
number = float(match.group(1))
suffix = (match.group(2) or 's').lower()
factors = {'ms': 1, 's': 1000, 'm': 60_000, 'h': 3_600_000}
return round(number * factors[suffix])
def format_size(size):
for suffix in ('TiB', 'GiB', 'MiB', 'KiB'):
unit = 1024 ** {'KiB': 1, 'MiB': 2, 'GiB': 3, 'TiB': 4}[suffix]
if size >= unit:
return f'{size / unit:.2f} {suffix}'
return f'{size} B'
def runtime_root():
runtime_dir = os.environ.get('XDG_RUNTIME_DIR')
if not runtime_dir or not os.path.isabs(runtime_dir):
raise RuntimeError('XDG_RUNTIME_DIR is not set to an absolute path')
return Path(runtime_dir) / 'firefox-memory-control'
def main():
parser = argparse.ArgumentParser(
description='Ask a running memory-controlled Firefox to unload tabs'
)
parser.add_argument('size', type=parse_size, help='desired reclaim amount, for example 2G')
parser.add_argument(
'--min-inactive',
type=parse_duration,
default=0,
metavar='DURATION',
help='only unload tabs inactive for this long (default: 0s)',
)
parser.add_argument(
'--timeout',
type=float,
default=60,
metavar='SECONDS',
help='maximum time to wait for Firefox (default: 60)',
)
args = parser.parse_args()
root = runtime_root()
requests = root / 'requests'
responses = root / 'responses'
requests.mkdir(mode=0o700, parents=True, exist_ok=True)
responses.mkdir(mode=0o700, parents=True, exist_ok=True)
request_id = str(uuid.uuid4())
request_path = requests / f'{request_id}.json'
temporary_path = requests / f'.{request_id}.{os.getpid()}.tmp'
response_path = responses / f'{request_id}.json'
request = {
'id': request_id,
'targetBytes': args.size,
'minInactiveMs': args.min_inactive,
}
temporary_path.write_text(json.dumps(request), encoding='utf-8')
os.chmod(temporary_path, 0o600)
temporary_path.replace(request_path)
deadline = time.monotonic() + args.timeout
try:
while time.monotonic() < deadline:
try:
response = json.loads(response_path.read_text(encoding='utf-8'))
break
except FileNotFoundError:
time.sleep(0.1)
else:
raise RuntimeError(
'timed out waiting for Firefox; start Firefox from the '
'firefox-memory-control package'
)
finally:
request_path.unlink(missing_ok=True)
response_path.unlink(missing_ok=True)
if 'error' in response:
raise RuntimeError(response['error'])
unloaded_tabs = response['unloadedTabs']
estimated_bytes = response['estimatedBytes']
observed_bytes = response['observedBytes']
target_bytes = response['targetBytes']
print(
f'unloaded {unloaded_tabs} tab(s); '
f'Firefox estimated {format_size(estimated_bytes)} reclaimable; '
f'MemAvailable increased by {format_size(observed_bytes)}'
)
if not response['reachedTarget']:
print(
f'could not reach the requested {format_size(target_bytes)}',
file=sys.stderr,
)
return 2
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except (OSError, RuntimeError) as error:
print(f'firefox-free-memory: {error}', file=sys.stderr)
sys.exit(1)