Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b9358069 | ||
|
|
41ada3fa60 | ||
|
|
dcf79577ca | ||
|
|
cea32c5f16 | ||
|
|
cbc48e456d | ||
|
|
05918ec2ce | ||
|
|
2ca4c3d5b1 | ||
|
|
82cbe67010 | ||
|
|
0c6928f7ae | ||
|
|
135d52d3de | ||
|
|
f8a89b9c87 | ||
|
|
5e3b196ee0 | ||
|
|
b8f31e23f8 | ||
|
|
7fe3c8186c | ||
|
|
1ca978baf4 |
@@ -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).
|
||||
@@ -1,2 +1,4 @@
|
||||
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,17 +10,22 @@ jobs:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- uses: DeterminateSystems/determinate-nix-action@v3
|
||||
with:
|
||||
# Gitea will supply a token in GITHUB_TOKEN, which this action will
|
||||
# try to pass to Nix when downloading from GitHub
|
||||
github_access_token: ${{ secrets.GH_PULL_TOKEN }}
|
||||
extra_nix_config: |
|
||||
# Gitea will supply a token in GITHUB_TOKEN, which this action passes to
|
||||
# Nix (as access-tokens) when downloading from GitHub
|
||||
github-token: ${{ secrets.GH_PULL_TOKEN }}
|
||||
extra-conf: |
|
||||
# Make sure we're using sandbox
|
||||
sandbox-fallback = false
|
||||
# Big C++ projects fill up memory...
|
||||
cores = 6
|
||||
|
||||
# Determinate performance features
|
||||
lazy-trees = true
|
||||
eval-cores = 0
|
||||
|
||||
accept-flake-config = true
|
||||
extra-substituters = https://nix-cache.nul.ie
|
||||
extra-trusted-public-keys = nix-cache.nul.ie-1:BzH5yMfF4HbzY1C977XzOxoPhEc9Zbu39ftPkUbH+m4=
|
||||
|
||||
|
||||
@@ -7,20 +7,22 @@ on:
|
||||
jobs:
|
||||
installer:
|
||||
name: Build installer
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: https://github.com/actions/setup-go@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: DeterminateSystems/determinate-nix-action@v3
|
||||
with:
|
||||
go-version: '>=1.20.1'
|
||||
- uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
github_access_token: ${{ secrets.GH_PULL_TOKEN }}
|
||||
extra_nix_config: |
|
||||
# Gitea will supply a token in GITHUB_TOKEN, which this action passes to
|
||||
# Nix (as access-tokens) when downloading from GitHub
|
||||
github-token: ${{ secrets.GH_PULL_TOKEN }}
|
||||
extra-conf: |
|
||||
# Make sure we're using sandbox
|
||||
sandbox-fallback = false
|
||||
|
||||
# Determinate performance features
|
||||
lazy-trees = true
|
||||
eval-cores = 0
|
||||
|
||||
extra-substituters = https://nix-cache.nul.ie
|
||||
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
|
||||
|
||||
- name: Create release
|
||||
uses: https://gitea.com/actions/release-action@main
|
||||
uses: https://gitea.com/actions/gitea-release-action@main
|
||||
with:
|
||||
title: Latest installer
|
||||
api_key: '${{ secrets.RELEASE_TOKEN }}'
|
||||
name: Latest installer
|
||||
token: '${{ secrets.RELEASE_TOKEN }}'
|
||||
files: |
|
||||
jackos-installer-${{ steps.setup.outputs.short_rev }}.iso
|
||||
jackos-installer-netboot-${{ steps.setup.outputs.short_rev }}.tar.zst
|
||||
|
||||
@@ -61,7 +61,9 @@ Common ones:
|
||||
`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).
|
||||
- `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,
|
||||
`nix eval .#nixfiles.config.nixos.allAssignments --json` for assignment generation, or
|
||||
|
||||
@@ -12,6 +12,9 @@ in
|
||||
NIX_USER_CONF_FILES = toString (pkgs.writeText "nix.conf"
|
||||
''
|
||||
experimental-features = nix-command flakes ca-derivations
|
||||
lazy-trees = true
|
||||
eval-cores = 0
|
||||
accept-flake-config = true
|
||||
connect-timeout = 5
|
||||
fallback = true
|
||||
${lib.my.c.nix.cache.conf}
|
||||
@@ -22,7 +25,7 @@ in
|
||||
|
||||
packages = with pkgs; [
|
||||
coreutils
|
||||
nixVersions.stable
|
||||
determinate-nix
|
||||
rage
|
||||
wireguard-tools
|
||||
(pkgs.writeShellScriptBin "deploy" ''
|
||||
|
||||
@@ -29,7 +29,7 @@ let
|
||||
coreutils
|
||||
gnugrep
|
||||
openssh
|
||||
nixVersions.stable
|
||||
determinate-nix
|
||||
jq
|
||||
];
|
||||
text =
|
||||
|
||||
+5
-1
@@ -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,
|
||||
the AS211024 L2 mesh, BGP, WireGuard, Tailscale.
|
||||
- [`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/nixos-options.md`](reference/nixos-options.md) — generated per-option reference for
|
||||
the custom `my.*` NixOS modules.
|
||||
@@ -57,7 +59,9 @@ colony (physical VM host, ams1)
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
+29
-2
@@ -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-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 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`
|
||||
|
||||
On pushes to `master`, this 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
|
||||
On pushes to `master`, this installs Determinate Nix on the runner (via
|
||||
`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
|
||||
Harmonia cache with [`ci/push-to-cache.sh`](../ci/push-to-cache.sh).
|
||||
|
||||
|
||||
@@ -24,8 +24,11 @@ The custom NixOS installer image used to bootstrap new boxes.
|
||||
`installer-<hex>` hostname is set at boot.
|
||||
- `INSTALL_ROOT=/mnt` in the session environment, plus a `show-hw-config` alias wrapping
|
||||
`nixos-generate-config --show-hardware-config --root $INSTALL_ROOT`.
|
||||
- NixOS documentation enabled, `wpa_supplicant` available but not started, GC and memory-overcommit
|
||||
tuning for low-memory targets, LVM thin and NFS support.
|
||||
- NixOS documentation enabled, NetworkManager available but not started at boot (run
|
||||
`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
|
||||
deploy target (`my.deploy.enable = false`).
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
**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).
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -33,6 +33,10 @@ See the consolidated [network assignments](../../networking.md#box-assignments)
|
||||
|
||||
## 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
|
||||
|
||||
`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)
|
||||
|
||||
`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)
|
||||
runs a patched `mstpd` and forces RSTP on `lan` once it's routable, so exactly one uplink carries
|
||||
are enslaved to the `lan` bridge with `STP=true`. The explicit bridge-port costs prefer
|
||||
`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.)
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -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
@@ -163,6 +163,29 @@
|
||||
"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": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
@@ -256,6 +279,42 @@
|
||||
"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": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
@@ -460,6 +519,30 @@
|
||||
"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": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
@@ -655,6 +738,22 @@
|
||||
"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": {
|
||||
"locked": {
|
||||
"lastModified": 1781356656,
|
||||
@@ -687,6 +786,22 @@
|
||||
"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": {
|
||||
"locked": {
|
||||
"lastModified": 1780902259,
|
||||
@@ -847,6 +962,7 @@
|
||||
"borgthin": "borgthin",
|
||||
"copyparty": "copyparty",
|
||||
"deploy-rs": "deploy-rs",
|
||||
"determinate-nix": "determinate-nix",
|
||||
"devshell": "devshell_3",
|
||||
"flake-utils": "flake-utils_6",
|
||||
"harmonia": "harmonia",
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
{
|
||||
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 = {
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
# libnet.url = "github:reo101/nix-lib-net";
|
||||
@@ -21,6 +34,12 @@
|
||||
home-manager-stable.url = "home-manager/release-26.05";
|
||||
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
|
||||
# ragenix.url = "github:yaxitech/ragenix";
|
||||
ragenix.url = "github:devplayer0/ragenix/add-rekey-one-flag";
|
||||
@@ -83,6 +102,22 @@
|
||||
};
|
||||
pkgsLibOverlay = final: prev: { lib = prev.lib.extend libOverlay; };
|
||||
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
|
||||
pkgsFlakes = mapAttrs (_: pkgsFlake: pkgsFlake // { lib = pkgsFlake.lib.extend libOverlay; }) {
|
||||
@@ -111,6 +146,7 @@
|
||||
pkgsLibOverlay
|
||||
|
||||
myPkgsOverlay
|
||||
determinateOverlay
|
||||
inputs.devshell.overlays.default
|
||||
inputs.ragenix.overlays.default
|
||||
inputs.deploy-rs.overlays.default
|
||||
@@ -126,6 +162,7 @@
|
||||
pkgsLibOverlay
|
||||
|
||||
myPkgsOverlay
|
||||
determinateOverlay
|
||||
];
|
||||
|
||||
config = {
|
||||
@@ -187,7 +224,13 @@
|
||||
nixosModules = nixfiles.config.nixos.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;
|
||||
|
||||
deploy = nixfiles.config.deploy-rs.rendered;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
let
|
||||
inherit (builtins) listToAttrs mapAttrs readFile;
|
||||
inherit (lib)
|
||||
optionalString nameValuePair concatMapStrings concatStringsSep optionalAttrs versionAtLeast
|
||||
optionalString nameValuePair concatMapStrings concatStringsSep optionalAttrs
|
||||
mapAttrsToList mkMerge mkIf mkDefault mkOption;
|
||||
inherit (lib.hm) dag;
|
||||
inherit (lib.my) mkOpt' dummyOption;
|
||||
@@ -50,8 +50,7 @@ in
|
||||
};
|
||||
|
||||
nix = {
|
||||
package = mkIf (!(versionAtLeast config.home.stateVersion "22.11")) pkgs.nix;
|
||||
settings = with lib.my.c.nix; {
|
||||
settings = with lib.my.c.nix; determinateSettings // {
|
||||
experimental-features = [ "nix-command" "flakes" "ca-derivations" ];
|
||||
max-jobs = mkDefault "auto";
|
||||
|
||||
@@ -257,13 +256,13 @@ in
|
||||
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;
|
||||
|
||||
home = {
|
||||
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...
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{ lib, pkgs', pkgs, config, ... }:
|
||||
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.c) pubDomain;
|
||||
|
||||
@@ -45,6 +45,9 @@ let
|
||||
chmod +x "$out"/bin/doomsaver
|
||||
'';
|
||||
doomsaver = doomsaver' cfg.screensaver.brainrotTextCommand;
|
||||
firefoxMemoryControl = pkgs.firefox-memory-control.override {
|
||||
inherit (cfg.firefoxMemoryControl) lowAvailableMiB highAvailableMiB pollIntervalMs minInactiveMs;
|
||||
};
|
||||
in
|
||||
{
|
||||
options.my.gui = with lib.types; {
|
||||
@@ -52,12 +55,26 @@ in
|
||||
manageGraphical = mkBoolOpt' false "Configure the graphical session";
|
||||
standalone = mkBoolOpt' false "Enable settings for fully Nix managed systems";
|
||||
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 [
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.firefoxMemoryControl.highAvailableMiB > cfg.firefoxMemoryControl.lowAvailableMiB;
|
||||
message = "`my.gui.firefoxMemoryControl.highAvailableMiB` must exceed `lowAvailableMiB`.";
|
||||
}
|
||||
];
|
||||
|
||||
home = {
|
||||
packages = with pkgs; [
|
||||
packages = (with pkgs; [
|
||||
xdg-utils
|
||||
|
||||
font.package
|
||||
@@ -99,7 +116,7 @@ in
|
||||
# --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.nodejs_latest ]}
|
||||
# '';
|
||||
# })
|
||||
];
|
||||
]) ++ optional cfg.firefoxMemoryControl.enable firefoxMemoryControl;
|
||||
};
|
||||
|
||||
programs = {
|
||||
@@ -378,8 +395,9 @@ in
|
||||
|
||||
"XF86AudioRaiseVolume" = "exec ${pkgs.pamixer}/bin/pamixer -i 5";
|
||||
"XF86AudioLowerVolume" = "exec ${pkgs.pamixer}/bin/pamixer -d 5";
|
||||
"XF86AudioPlay" = "exec ${pkgs.playerctl}/bin/playerctl play";
|
||||
"XF86AudioPause" = "exec ${pkgs.playerctl}/bin/playerctl pause";
|
||||
# Some AVRCP devices alternate play and pause events independently of player state.
|
||||
"XF86AudioPlay" = "exec ${pkgs.playerctl}/bin/playerctl play-pause";
|
||||
"XF86AudioPause" = "exec ${pkgs.playerctl}/bin/playerctl play-pause";
|
||||
"XF86AudioNext" = "exec ${pkgs.playerctl}/bin/playerctl next";
|
||||
"XF86AudioPrev" = "exec ${pkgs.playerctl}/bin/playerctl previous";
|
||||
};
|
||||
|
||||
@@ -111,6 +111,13 @@ rec {
|
||||
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";
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ rec {
|
||||
then throw "\nFailed assertions:\n${concatStringsSep "\n" (map (x: "- ${x}") failedAssertions)}"
|
||||
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: {
|
||||
# The flake passes a default setting, but we don't care about that
|
||||
home.stateVersion = mkForce (homeStateVersion' hmBranch);
|
||||
|
||||
@@ -38,6 +38,8 @@ in
|
||||
let
|
||||
inherit (lib) mkMerge;
|
||||
inherit (lib.my) networkdAssignment;
|
||||
|
||||
podmanSubnet = "10.88.0.0/16";
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
@@ -158,7 +160,7 @@ in
|
||||
oci-containers = {
|
||||
backend = "podman";
|
||||
};
|
||||
containers.containersConf.settings.network.default_subnet = "10.88.0.0/16";
|
||||
containers.containersConf.settings.network.default_subnet = podmanSubnet;
|
||||
};
|
||||
|
||||
systemd.network = {
|
||||
@@ -195,7 +197,7 @@ in
|
||||
extraRules = ''
|
||||
table inet filter {
|
||||
chain forward {
|
||||
ip saddr 10.88.0.0/16 accept
|
||||
ip saddr ${podmanSubnet} accept
|
||||
}
|
||||
}
|
||||
'';
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
{ lib, pkgs, config, ... }:
|
||||
let
|
||||
inherit (lib) mkForce;
|
||||
inherit (lib.my) net;
|
||||
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
|
||||
{
|
||||
config = {
|
||||
@@ -34,6 +41,11 @@ in
|
||||
cache = {
|
||||
enabled = true;
|
||||
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";
|
||||
};
|
||||
};
|
||||
|
||||
# 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
|
||||
}
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,9 +43,19 @@ in
|
||||
|
||||
(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 = {
|
||||
|
||||
@@ -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 = {
|
||||
netdevs = mkMerge [
|
||||
(mkVLAN "wan-pon-ont" vlans.wan-pon-ont)
|
||||
|
||||
@@ -24,7 +24,7 @@ in
|
||||
services = {
|
||||
networkd-dispatcher.rules = {
|
||||
configure-mstpd = {
|
||||
onState = [ "routable" ];
|
||||
onState = [ "configured" ];
|
||||
script = ''
|
||||
#!${pkgs.runtimeShell}
|
||||
if [ "$IFACE" = "lan" ]; then
|
||||
|
||||
@@ -115,10 +115,7 @@
|
||||
Name = "lan";
|
||||
Kind = "bridge";
|
||||
};
|
||||
extraConfig = ''
|
||||
[Bridge]
|
||||
STP=true
|
||||
'';
|
||||
bridgeConfig.STP = true;
|
||||
};
|
||||
};
|
||||
links = {
|
||||
@@ -175,10 +172,12 @@
|
||||
"50-lan-jim" = {
|
||||
matchConfig.Name = "lan-jim";
|
||||
networkConfig.Bridge = "lan";
|
||||
bridgeConfig.Cost = 100;
|
||||
};
|
||||
"50-lan-dave" = {
|
||||
matchConfig.Name = "lan-dave";
|
||||
networkConfig.Bridge = "lan";
|
||||
bridgeConfig.Cost = 10;
|
||||
};
|
||||
|
||||
"50-wan-ifb" = {
|
||||
|
||||
+24
-5
@@ -32,7 +32,7 @@
|
||||
};
|
||||
|
||||
image = {
|
||||
baseName = "jackos-installer";
|
||||
baseName = mkForce "jackos-installer";
|
||||
};
|
||||
isoImage = {
|
||||
volumeID = "jackos-${config.system.nixos.release}-${pkgs.stdenv.hostPlatform.uname.processor}";
|
||||
@@ -97,10 +97,17 @@
|
||||
documentation.enable = mkForce true;
|
||||
documentation.nixos.enable = mkForce true;
|
||||
|
||||
# Enable wpa_supplicant, but don't start it by default.
|
||||
networking.wireless.enable = mkDefault true;
|
||||
networking.wireless.userControlled = true;
|
||||
systemd.services.wpa_supplicant.wantedBy = mkForce [];
|
||||
system.nixos.variant_id = mkDefault "installer";
|
||||
|
||||
# Enable NetworkManager, but don't start it by default.
|
||||
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.
|
||||
# This is desirable in memory-constrained environments that don't
|
||||
@@ -113,6 +120,18 @@
|
||||
# download-using-manifests.pl from forking even if there is
|
||||
# plenty of free memory.
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,9 +18,6 @@ let
|
||||
"${modulesPath}/installer/cd-dvd/iso-image.nix"
|
||||
allHardware
|
||||
{
|
||||
# Doesn't work right now... (missing /dev/root)
|
||||
boot.initrd.systemd.enable = false;
|
||||
|
||||
isoImage = {
|
||||
makeEfiBootable = true;
|
||||
makeUsbBootable = true;
|
||||
@@ -67,10 +64,10 @@ let
|
||||
ip = "${iproute2}/bin/ip";
|
||||
nbd-client = "${nbd}/bin/nbd-client";
|
||||
};
|
||||
extraConfig = ''
|
||||
DefaultTimeoutStartSec=20
|
||||
DefaultDeviceTimeoutSec=20
|
||||
'';
|
||||
settings.Manager = {
|
||||
DefaultTimeoutStartSec = "20s";
|
||||
DefaultDeviceTimeoutSec = "20s";
|
||||
};
|
||||
|
||||
network = {
|
||||
enable = true;
|
||||
|
||||
@@ -50,9 +50,9 @@ in
|
||||
};
|
||||
|
||||
nix = {
|
||||
package = pkgs'.mine.nix;
|
||||
package = pkgs'.mine.determinate-nix;
|
||||
channel.enable = false;
|
||||
settings = with lib.my.c.nix; {
|
||||
settings = with lib.my.c.nix; determinateSettings // {
|
||||
trusted-users = [ "@wheel" ];
|
||||
experimental-features = [ "nix-command" "flakes" "ca-derivations" ];
|
||||
extra-substituters = cache.substituters;
|
||||
|
||||
@@ -129,7 +129,8 @@ in
|
||||
services = {
|
||||
netboot-update = {
|
||||
description = "Update netboot images";
|
||||
after = [ "systemd-networkd-wait-online.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
serviceConfig.Type = "oneshot";
|
||||
path = with pkgs; [
|
||||
coreutils curl jq zstd gnutar
|
||||
@@ -138,6 +139,10 @@ in
|
||||
update_nixos() {
|
||||
latestShort="$(curl -s https://git.nul.ie/api/v1/repos/dev/nixfiles/tags/installer \
|
||||
| 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
|
||||
echo "NixOS installer is up to date"
|
||||
return
|
||||
@@ -148,6 +153,10 @@ in
|
||||
fname="jackos-installer-netboot-$latestShort.tar.zst"
|
||||
downloadUrl="$(curl -s https://git.nul.ie/api/v1/repos/dev/nixfiles/releases/tags/installer | \
|
||||
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"
|
||||
tar -C nixos-installer --zstd -xf /tmp/nixos-installer-netboot.tar.zst
|
||||
truncate -s "${cfg.server.installer.storeSize}" nixos-installer/rootfs.ext4
|
||||
@@ -163,7 +172,7 @@ in
|
||||
update_nixos
|
||||
'';
|
||||
startAt = "06:00";
|
||||
wantedBy = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
};
|
||||
|
||||
nbd-server = {
|
||||
|
||||
@@ -11,6 +11,7 @@ in
|
||||
chocolate-doom2xx = callPackage ./chocolate-doom2xx { };
|
||||
windowtolayer = callPackage ./windowtolayer.nix { };
|
||||
swaylock-plugin = callPackage ./swaylock-plugin.nix { };
|
||||
firefox-memory-control = callPackage ./firefox-memory-control { };
|
||||
|
||||
update-docs-assignments = pkgs.writeShellScriptBin "update-docs-assignments" ''
|
||||
exec ${pkgs.python3}/bin/python3 ${../ci/update-docs-assignments.py} "$@"
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user