Compare commits

...

64 commits

Author SHA1 Message Date
9813812dfc Move insecure-pnpm/broadcom-sta allowance to common.nix (vesktop on all hosts)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:00:32 +01:00
7d0c729e91 Update skills.md 2026-06-30 19:26:52 +01:00
6cc3fb6419 hardware-health: drop fwupd; no P700 BIOS published on LVFS
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:42:08 +01:00
ad1ceba28e macbook: pin to 6.12 LTS kernel so broadcom_sta + facetimehd build (7.x breaks them)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:42:08 +01:00
34d44a619e hardware-health: enable fwupd to check LVFS for P700 BIOS update
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:37:07 +01:00
ebef93f618 macbook: allow insecure pnpm (CVE-flagged build dep in closure)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:26:49 +01:00
forgejo-actions[bot]
7a9cf0e1f0 Update flake inputs 2026-06-30 04:00:57 +00:00
forgejo-actions[bot]
0678a43a89 Update flake inputs 2026-06-29 04:00:53 +00:00
forgejo-actions[bot]
f21f7ac6f8 Update flake inputs 2026-06-28 04:00:54 +00:00
forgejo-actions[bot]
8bf1d03dd2 Update flake inputs 2026-06-27 04:00:53 +00:00
c7b3f8a306 hypridle: don't lock/dpms/suspend while MPRIS media is playing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:09:13 +01:00
ee630bac30 hyprland: idle_inhibit fullscreen — no lock during fullscreen apps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:02:49 +01:00
forgejo-actions[bot]
6622ed6864 Update flake inputs 2026-06-26 04:00:53 +00:00
d69c9f624f hardware-health: rasdaemon MCE attribution + watchdog auto-reboot on mediaserver
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:37:35 +01:00
707f78c9d1 selkies: GPU-accelerate 32-bit GW via mounted 32-bit nvidia GL + vglrun launcher
Mount config.hardware.nvidia.package.lib32 into the container (CDI only carries
64-bit driver libs) and add a `gw` launcher that runs Guild Wars through
VirtualGL on the M2000. Drops GW from ~18 software-rendered CPU cores to <1.
Also bump stream to 60fps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:37:17 +01:00
21b0fa15ae selkies: enable internal TURN relay (LAN) so WebRTC media works behind nginx
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:52:28 +01:00
d31a4501f1 selkies: browser game streaming for GW (pointer-lock relative mouse), retire neko
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:42:45 +01:00
38901eee27 neko: add Mesa GL (i386) so Wine/Guild Wars gets an OpenGL context (llvmpipe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:56:33 +01:00
370b69bd5a Document: verify Nix options via nixos MCP before writing 2026-06-25 10:42:22 +01:00
c0ed58bcc2 neko: own /var/lib/neko/home as uid 1000 so the container desktop can start
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:42:13 +01:00
cb9a03cbf4 Add mcp-nixos MCP server (nix run) 2026-06-25 10:40:40 +01:00
b00dee9dc6 neko: drop winetricks (not in Debian trixie main; GW needs only bare wine)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:39:20 +01:00
e5589907a3 neko: use real xfce image (software render), drop nonexistent nvidia-xfce + GPU
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:35:51 +01:00
e199933dce neko: build image from stdin Dockerfile (fix symlinked-context build failure)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:31:31 +01:00
fe0cb4663e neko: add Authelia access rule for neko.nordhammer.it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:27:49 +01:00
448e44753f neko: Guild Wars in a browser (Xfce+Wine+NVIDIA), Authelia-gated
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:07:36 +01:00
forgejo-actions[bot]
cdf5184a52 Update flake inputs 2026-06-25 04:00:55 +00:00
5e870d0e8b arr-interconnect: auto-add Jellyfin library-refresh notification to Sonarr/Radarr
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:35:41 +01:00
forgejo-actions[bot]
1ed7cda25c Update flake inputs 2026-06-24 04:00:54 +00:00
forgejo-actions[bot]
00e02c28ff Update flake inputs 2026-06-23 04:00:51 +00:00
forgejo-actions[bot]
4807be6cb0 Update flake inputs 2026-06-22 04:00:53 +00:00
forgejo-actions[bot]
4d328af16b Update flake inputs 2026-06-21 04:01:00 +00:00
forgejo-actions[bot]
d300b9d30d Update flake inputs 2026-06-20 04:00:52 +00:00
forgejo-actions[bot]
3396401e92 Update flake inputs 2026-06-19 04:00:54 +00:00
0f92b3fbf5 Disable frigate for now
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:11:06 +01:00
85c230457b hyprland: suppress battle.net activation events that close the launcher
Battle.net (non-Steam shortcut, class steam_app_0) spams window-activation
events that clear quickshell's HyprlandFocusGrab, instantly closing the
launcher / power menu. suppress_event activate activatefocus drops them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:24:21 +01:00
forgejo-actions[bot]
128143bc74 Update flake inputs 2026-06-18 04:00:59 +00:00
23a5ad2914 quickshell: trim hyprshot body, add image preview to toast (shared helper)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:39:11 +01:00
7cd7a0e3dc screenshots: save file for notification previews, fall back to appIcon path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:20:53 +01:00
f0193eedd3 quickshell: round album art, full-width notifications with app icon + image preview
- album art uses ClippingRectangle so the image follows the radius
- NotifContent gains an app icon + name header
- notifications move to a full-width card spanning both panes, each item
  showing the notification image preview when present

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:56:49 +01:00
c901b9b56d quickshell: media card — title/artist right of art, controls+volume below
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:48:31 +01:00
af35c81514 quickshell: bump media album art to 128x128
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:47:02 +01:00
150f362998 quickshell: restack media card — art+controls row, then title/artist/volume
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:43:09 +01:00
a772034220 quickshell: drop session menu auto-close timer too
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:25:54 +01:00
83b4c5ef09 quickshell: click-outside dropdown dismissal, drop auto-close timers
Extend the launcher/session HyprlandFocusGrab to the bar dropdowns and
remove the per-dropdown inactivity timers. Shift+Super+S brackets hyprshot
with a screenshot pin so open menus survive slurp's input grab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:19:10 +01:00
215239e7aa quickshell: extract HoverRow component, dedupe 6 hover targets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:59:26 +01:00
6846f38b9a quickshell: wrap tray context menu in shared Card segment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:52:15 +01:00
700d3f7de1 quickshell: clickable mute icon on all volume sliders via VolIcon
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:22:28 +01:00
2697614e1b quickshell: fade hover highlights via transparent base02, no black flash
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:52:52 +01:00
2f51d2b4f1 quickshell: split media cards per MPRIS source, add per-stream volume
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:43:45 +01:00
6977568bf2 quickshell: drop fill-on-select for session menu icons
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:38:11 +01:00
98699b5346 quickshell: fix hover/colour bugs, translucent card surfaces
- calendar month chevrons: fade from transparent base02, no black flash
- power menu: all four buttons use the logout base05 setup
- runner results: base01 card segment matching other dropdowns
- cards: translucent cardBg so the bar-layer blur shows through

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:30:17 +01:00
forgejo-actions[bot]
7fc29c82bf Update flake inputs 2026-06-17 04:01:05 +00:00
792ecb80bb quickshell: drop unused Theme tokens (base06, base07, toastBg)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:55:34 +01:00
4e3aa498e0 hyprland: force Tiny Terraces to tile instead of float
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:10:33 +01:00
forgejo-actions[bot]
cc9ef378f6 Update flake inputs 2026-06-16 04:00:57 +00:00
forgejo-actions[bot]
bf7d24d740 Update flake inputs 2026-06-15 04:01:11 +00:00
forgejo-actions[bot]
0397a5391b Update flake inputs 2026-06-14 04:01:01 +00:00
ad70441589 quickshell: toast notification sits in a base02 card to match the calendar
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 19:36:11 +01:00
faa345d016 quickshell: match calendar notification styling to the toast (shared defaults)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 19:25:06 +01:00
ddbc8929e4 alerting: silence per-ban crowdsec pushes; ntfy alert on service down/recovery
- crowdsec.nix: drop the ntfy notifications (one push per ban was constant
  noise on the WAN-exposed box); bans still happen silently
- service-health.nix: OnFailure=notify-failure@%n on 16 core units sends an
  ntfy 'down' push when a unit truly fails (after exhausting Restart=), then
  a 'recovered' push when it comes back. Shares /var/secrets/ntfy-url.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 17:54:37 +01:00
3047ea547c quickshell: profile selector — selected bright, unselected grey
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 17:45:16 +01:00
f1b8d5d57d quickshell: refactor — Theme tokens, reusable components, dedup
Reduces the shell from 2898 → 2712 lines (~316 lines of duplication removed,
~130 of reusable scaffolding added). Rollback point: tag quickshell-pre-refactor.

- Theme tokens: barHeight, radius/radiusSmall/radiusTiny, cardPad, animMorph/
  animContent/animFade — bar height and the shader cutout math now derive from
  one constant instead of ~10 scattered 30/26 literals
- SText/SIcon base components default the two fonts (removed 58 font.family lines)
- Card component: the rounded base01 section surface, now one definition driving
  7 cards (volume ×2, network ×2, battery ×2, calendar)
- PillSlider component: master + per-app volume sliders share one slider
- NotifContent component: calendar list + toast notification bodies deduped
- dead code: dropped unused QtQuick.Shapes import, collapsed redundant
  weatherGlyph branches, converted the lone RowLayout to drop QtQuick.Layouts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 13:05:01 +01:00
forgejo-actions[bot]
479c6c6906 Update flake inputs 2026-06-13 04:01:00 +00:00
17 changed files with 1630 additions and 1175 deletions

View file

@ -0,0 +1,100 @@
---
name: ponytail
description: >
Forces the laziest solution that actually works, simplest, shortest, most
minimal. Channels a senior dev who has seen everything: question whether the
task needs to exist at all (YAGNI), reach for the standard library before
custom code, native platform features before dependencies, one line before
fifty. Supports intensity levels: lite, full (default), ultra. Use whenever
the user says "ponytail", "be lazy", "lazy mode", "simplest solution",
"minimal solution", "yagni", "do less", or "shortest path", and whenever
they complain about over-engineering, bloat, boilerplate, or unnecessary
dependencies.
license: MIT
---
# Ponytail
You are a lazy senior developer. Lazy means efficient, not careless. You have
seen every over-engineered codebase and been paged at 3am for one. The best
code is the code never written.
## Persistence
ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if
unsure. Off only: "stop ponytail" / "normal mode". Default: **full**.
Switch: `/ponytail lite|full|ultra`.
## The ladder
Stop at the first rung that holds:
1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI)
2. **Stdlib does it?** Use it.
3. **Native platform feature covers it?** `<input type="date">` over a picker lib, CSS over JS, DB constraint over app code.
4. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do.
5. **Can it be one line?** One line.
6. **Only then:** the minimum code that works.
The ladder is a reflex, not a research project. Two rungs work → take the
higher one and move on. The first lazy solution that works is the right one.
## Rules
- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
- No boilerplate, no scaffolding "for later", later can scaffold for itself.
- Deletion over addition. Boring over clever, clever is what someone decodes at 3am.
- Fewest files possible. Shortest working diff wins.
- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
## Output
Code first. Then at most three short lines: what was skipped, when to add it.
No essays, no feature tours, no design notes. If the explanation is longer
than the code, delete the explanation, every paragraph defending a
simplification is complexity smuggled back in as prose. Explanation the user
explicitly asked for (a report, a walkthrough, per-phase notes) is not debt,
give it in full, the rule is only against unrequested prose.
Pattern: `[code] → skipped: [X], add when [Y].`
## Intensity
| Level | What change |
|-------|------------|
| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. |
| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. |
| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. |
Example: "Add a cache for these API responses."
- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class."
- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short."
- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate."
## When NOT to be lazy
Never simplify away: input validation at trust boundaries, error handling
that prevents data loss, security measures, accessibility basics, anything
explicitly requested. User insists on the full version → build it, no
re-arguing.
Hardware is never the ideal on paper: a real clock drifts, a real sensor
reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not
just less code, the physical world needs tuning a minimal model can't see.
Lazy code without its check is unfinished. Non-trivial logic (a branch, a
loop, a parser, a money/security path) leaves ONE runnable check behind, the
smallest thing that fails if the logic breaks: an `assert`-based
`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no
fixtures, no per-function suites unless asked. Trivial one-liners need no
test, YAGNI applies to tests too.
## Boundaries
Ponytail governs what you build, not how you talk (pair with Caveman for
terse prose). "stop ponytail" / "normal mode": revert. Level persists until
changed or session end.
The shortest path to done is the right path.

14
.mcp.json Normal file
View file

@ -0,0 +1,14 @@
{
"mcpServers": {
"nixos": {
"type": "stdio",
"command": "nix",
"args": [
"run",
"github:utensils/mcp-nixos",
"--"
],
"env": {}
}
}
}

View file

@ -27,6 +27,11 @@ That means evaluation is **pure**: config can never read files outside the repo
## Code Evaluation ## Code Evaluation
Before writing or changing any NixOS / Home Manager option, verify it exists and
has the expected name and type using the `nixos` MCP server tools (`nix` /
`nix_versions`, configured in `.mcp.json`). Don't rely on memory for option or
package names — look them up first to avoid invented attributes that fail at eval.
Always validate Nix expressions with `nix eval` before committing. For example: Always validate Nix expressions with `nix eval` before committing. For example:
```bash ```bash

View file

@ -22,7 +22,7 @@
./services/qbittorrent-nox.nix ./services/qbittorrent-nox.nix
./services/nginx.nix ./services/nginx.nix
./services/go2rtc.nix ./services/go2rtc.nix
./services/frigate.nix # ./services/frigate.nix
./services/sonarr.nix ./services/sonarr.nix
./services/radarr.nix ./services/radarr.nix
./services/prowlarr.nix ./services/prowlarr.nix
@ -37,10 +37,14 @@
./services/adguard.nix ./services/adguard.nix
./services/router.nix ./services/router.nix
./services/crowdsec.nix ./services/crowdsec.nix
./services/service-health.nix
./services/sabnzbd.nix ./services/sabnzbd.nix
./services/forgejo-runner.nix ./services/forgejo-runner.nix
./services/code-server.nix ./services/code-server.nix
./services/memos.nix ./services/memos.nix
# ./services/neko.nix # superseded by selkies.nix (Neko can't handle GW's mouse grab)
./services/selkies.nix
./services/hardware-health.nix
]; ];
### Make build time quicker ### Make build time quicker
@ -87,6 +91,12 @@
# Allow unfree packages # Allow unfree packages
nixpkgs.config.allowUnfree = true; nixpkgs.config.allowUnfree = true;
# vesktop (multiple hosts) builds with pnpm via fetchPnpmDeps, which nixpkgs
# marks insecure (build-time only, hash-pinned FOD — not in PATH). broadcom-sta
# is Macbook-only Wi-Fi but allowing it everywhere is harmless (absent on others).
nixpkgs.config.allowInsecurePredicate = pkg:
lib.any (p: lib.hasPrefix p (lib.getName pkg)) [ "broadcom-sta" "pnpm" ];
# Flakes — nixos-rebuild self-enables these, but plain `nix eval` / # Flakes — nixos-rebuild self-enables these, but plain `nix eval` /
# `nix flake check` on the hosts need them too. # `nix flake check` on the hosts need them too.
nix.settings.experimental-features = [ "nix-command" "flakes" ]; nix.settings.experimental-features = [ "nix-command" "flakes" ];

60
flake.lock generated
View file

@ -71,11 +71,11 @@
"cachyos-kernel": { "cachyos-kernel": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1780413908, "lastModified": 1781883168,
"narHash": "sha256-T15bnskj20rdc4vJ55bFF2lVCVR8edilWn0hiYR7vVs=", "narHash": "sha256-raAojJGk0aWdscfFn/9ikZ6V5oUuAZcAz5kjAZ2QN3E=",
"owner": "CachyOS", "owner": "CachyOS",
"repo": "linux-cachyos", "repo": "linux-cachyos",
"rev": "a61f943f5e94b75c5600a2968cb699d0e37945b3", "rev": "daed450e9b1a4fadfef68fb4fa5e2f3391fedb34",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -87,11 +87,11 @@
"cachyos-kernel-patches": { "cachyos-kernel-patches": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1780462466, "lastModified": 1782242233,
"narHash": "sha256-t6c7FTqMB0skEz+4tei5v8GEyL4fRDgx24oW3LrnYiE=", "narHash": "sha256-AUwTZq++PBq0qjDVFKqD0AZNNwa0b1RK41bM9XMbkW8=",
"owner": "CachyOS", "owner": "CachyOS",
"repo": "kernel-patches", "repo": "kernel-patches",
"rev": "bb41330bd4372672f552beda66712fb70b17f0fa", "rev": "19250dcc39862169961756c733b8a6ba77754c22",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -211,11 +211,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1781184346, "lastModified": 1782704057,
"narHash": "sha256-cZRlW47U6A2nWvAmnZeeO6Xvq23gxYbVLel4KxqOrcQ=", "narHash": "sha256-G1I1gd32F7mp9LAe1DaZ4ZL7NX5gyiKwdCMwro1Vrck=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "ea6d221d7aa85652d014b6f719dddf036037515b", "rev": "868d0a692de703c2de98fab61968e4e310b7c28e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -234,11 +234,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1780771919, "lastModified": 1782415778,
"narHash": "sha256-cbace1ZTWYFG0luPL7OFlUxDh/t9lmPj+Isvg9hLN0k=", "narHash": "sha256-Qts73QQA+lADfxWjonL3Q1JcZssVZPsQI38L3qZyS0o=",
"owner": "xddxdd", "owner": "xddxdd",
"repo": "nix-cachyos-kernel", "repo": "nix-cachyos-kernel",
"rev": "3d940a534da0ba6bce60e345ff2c9c7b062087fb", "rev": "1740ec90e7b07730c212a3a1ff5e71af08a5270b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -250,11 +250,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1780751787, "lastModified": 1782378976,
"narHash": "sha256-nWR7F46SyrLvN8Ot39XJDpVCswekGakXlOD4KsTYKW0=", "narHash": "sha256-UqQgBlQATXM3aBvzTRE/1wxHrCdKg5/ePlXfG/7Eqd8=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "00fa9a692bafc08a86061886f888b843bf7fbdb0", "rev": "5df71f3d167f0aad71658608361c1301147b9eb6",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -281,11 +281,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1780902259, "lastModified": 1782535326,
"narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=", "narHash": "sha256-ZeRxu4yn6shd3SNF5ZUQb4r7BaVo1zBKMjRhfoNSBmw=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca", "rev": "714a5f8c4ead6b31148d829288440ed033ccc041",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -297,11 +297,11 @@
}, },
"nixpkgs_3": { "nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1780749050, "lastModified": 1782467914,
"narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=", "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a799d3e3886da994fa307f817a6bc705ae538eeb", "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -357,11 +357,11 @@
"nixpkgs": "nixpkgs_3" "nixpkgs": "nixpkgs_3"
}, },
"locked": { "locked": {
"lastModified": 1781218261, "lastModified": 1782481477,
"narHash": "sha256-09ZzpMMfszYPp+SV+P48smeTG2cqttf9oO5BrgTRrzk=", "narHash": "sha256-CgRxFjimm1aw9sXkN+Bhx458a1/MH760dfgRhULLdxU=",
"owner": "powerofthe69", "owner": "powerofthe69",
"repo": "proton-cachyos-nix", "repo": "proton-cachyos-nix",
"rev": "8c0236830281dffa4ae4236e2ea8b6361d63407f", "rev": "7509cbb68c200f203af3b1b25d4699fa061371c3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -398,11 +398,11 @@
"tinted-zed": "tinted-zed" "tinted-zed": "tinted-zed"
}, },
"locked": { "locked": {
"lastModified": 1780702455, "lastModified": 1782770679,
"narHash": "sha256-+srjPGNy67nKytYwdlepycL51IG6S34sS4MKRZXK8G0=", "narHash": "sha256-+8RpmHKn5n2tYmoRCwiKJ6PeU85q15qnXzGQ2WGMn9Q=",
"owner": "nix-community", "owner": "nix-community",
"repo": "stylix", "repo": "stylix",
"rev": "54fa19702f4f2c7f6a981a92850678933588af9a", "rev": "3ed763829fc06d32cab3c1f31672379a1f53450e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -501,11 +501,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1781173532, "lastModified": 1782623843,
"narHash": "sha256-MwnZpL82aQO1I15JH525vz6REI/OULEAmXDp6cIcgNg=", "narHash": "sha256-zQdTvI8jcVfblsrWafw1ykTnCVoV94ttxb5e6drwVaI=",
"owner": "0xc000022070", "owner": "0xc000022070",
"repo": "zen-browser-flake", "repo": "zen-browser-flake",
"rev": "f13e82162fae68af7716147207fa5f868f5ca381", "rev": "c59e57b9c6ea4c86f9f3b7efc92db3cbd305d078",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -34,10 +34,17 @@
hardware.enableRedistributableFirmware = true; hardware.enableRedistributableFirmware = true;
hardware.facetimehd.enable = true; hardware.facetimehd.enable = true;
# Pin to the 6.12 LTS kernel: the out-of-tree broadcom_sta (Wi-Fi) and
# facetimehd (iSight) modules don't build against linuxPackages_latest
# (7.x) — broadcom-sta's wl_cfg80211 ops no longer match the cfg80211 API.
# 6.12 LTS is the newest kernel both modules compile against. Overrides
# common.nix's linuxPackages_latest.
boot.kernelPackages = lib.mkForce pkgs.linuxPackages_6_12;
# wait_prepare/wait_finish were removed from struct vb2_ops in Linux 6.8 # wait_prepare/wait_finish were removed from struct vb2_ops in Linux 6.8
nixpkgs.overlays = [ nixpkgs.overlays = [
(final: prev: { (final: prev: {
linuxPackages_latest = prev.linuxPackages_latest.extend (lpFinal: lpPrev: { linuxPackages_6_12 = prev.linuxPackages_6_12.extend (lpFinal: lpPrev: {
facetimehd = lpPrev.facetimehd.overrideAttrs (old: { facetimehd = lpPrev.facetimehd.overrideAttrs (old: {
postPatch = (old.postPatch or "") + '' postPatch = (old.postPatch or "") + ''
sed -i '/\.wait_prepare[[:space:]]*=.*vb2_ops_wait_prepare/d' fthd_v4l2.c sed -i '/\.wait_prepare[[:space:]]*=.*vb2_ops_wait_prepare/d' fthd_v4l2.c
@ -48,8 +55,7 @@
}) })
]; ];
nixpkgs.config.allowInsecurePredicate = pkg: # allowInsecurePredicate (broadcom-sta + pnpm) lives in common.nix now.
(lib.hasPrefix "broadcom-sta" (lib.getName pkg));
services.xserver.deviceSection = lib.mkDefault '' services.xserver.deviceSection = lib.mkDefault ''
Option "TearFree" "true" Option "TearFree" "true"

View file

@ -45,5 +45,10 @@ name = "7DTD-coop voice/dynamic"
ports = "26911-26912" ports = "26911-26912"
protocol = "udp" protocol = "udp"
[[forward]]
name = "Neko WebRTC"
port = 59000
protocol = "udp"
# DR (Dungeon Runners) forwards removed — services/dr-server.nix is disabled. # DR (Dungeon Runners) forwards removed — services/dr-server.nix is disabled.
# Re-add 2110 tcp, 2603 both, 2604-2605 udp, 2606 tcp if it comes back. # Re-add 2110 tcp, 2603 both, 2604-2605 udp, 2606 tcp if it comes back.

View file

@ -2,7 +2,7 @@
let let
interconnectScript = pkgs.writeShellScript "arr-interconnect" '' interconnectScript = pkgs.writeShellScript "arr-interconnect" ''
set -euo pipefail set -euo pipefail
PATH="${lib.makeBinPath [ pkgs.curl pkgs.jq pkgs.gnused pkgs.gnugrep pkgs.coreutils pkgs.systemd ]}:$PATH" PATH="${lib.makeBinPath [ pkgs.curl pkgs.jq pkgs.gnused pkgs.gnugrep pkgs.coreutils pkgs.systemd pkgs.sqlite ]}:$PATH"
BASE="http://127.0.0.1" BASE="http://127.0.0.1"
@ -30,6 +30,14 @@ let
SABNZBD_KEY=$(grep -oP '^api_key\s*=\s*\K\S+' /var/lib/sabnzbd/sabnzbd.ini | head -n1 || true) SABNZBD_KEY=$(grep -oP '^api_key\s*=\s*\K\S+' /var/lib/sabnzbd/sabnzbd.ini | head -n1 || true)
fi fi
# Jellyfin has no config.xml api key; any AccessToken in its db works as
# an API key. Reuse the first one (create one in the Jellyfin UI once if
# the table is empty — same first-run caveat as SAB above).
JELLYFIN_KEY=""
if [ -f "/var/lib/jellyfin/data/jellyfin.db" ]; then
JELLYFIN_KEY=$(sqlite3 /var/lib/jellyfin/data/jellyfin.db "SELECT AccessToken FROM ApiKeys LIMIT 1;" 2>/dev/null || true)
fi
# --- Helpers --- # --- Helpers ---
wait_for() { wait_for() {
local name="$1" url="$2" key="$3" local name="$1" url="$2" key="$3"
@ -341,6 +349,79 @@ let
fi fi
fi fi
##########################################################################
# Sonarr → Jellyfin (refresh library on import so new shows appear
# without waiting for Jellyfin's flaky filesystem watcher / full scan)
##########################################################################
if [ -n "$SONARR_KEY" ] && [ -n "$JELLYFIN_KEY" ]; then
if ! exists_by_name "$BASE:8989/api/v3/notification" "$SONARR_KEY" "Jellyfin"; then
echo "Adding Jellyfin notification to Sonarr..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $SONARR_KEY" \
"$BASE:8989/api/v3/notification" \
-d "$(jq -n --arg key "$JELLYFIN_KEY" '{
name: "Jellyfin",
implementation: "MediaBrowser",
configContract: "MediaBrowserSettings",
implementationName: "Emby / Jellyfin",
onDownload: true,
onUpgrade: true,
onRename: true,
onSeriesDelete: true,
onEpisodeFileDelete: true,
onEpisodeFileDeleteForUpgrade: true,
fields: [
{name: "host", value: "localhost"},
{name: "port", value: 8096},
{name: "useSsl", value: false},
{name: "apiKey", value: $key},
{name: "notify", value: false},
{name: "updateLibrary", value: true}
],
tags: []
}')" > /dev/null && echo " done" || echo " failed"
else
echo "Sonarr Jellyfin already configured"
fi
fi
##########################################################################
# Radarr → Jellyfin (refresh library on import)
##########################################################################
if [ -n "$RADARR_KEY" ] && [ -n "$JELLYFIN_KEY" ]; then
if ! exists_by_name "$BASE:7878/api/v3/notification" "$RADARR_KEY" "Jellyfin"; then
echo "Adding Jellyfin notification to Radarr..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $RADARR_KEY" \
"$BASE:7878/api/v3/notification" \
-d "$(jq -n --arg key "$JELLYFIN_KEY" '{
name: "Jellyfin",
implementation: "MediaBrowser",
configContract: "MediaBrowserSettings",
implementationName: "Emby / Jellyfin",
onDownload: true,
onUpgrade: true,
onRename: true,
onMovieDelete: true,
onMovieFileDelete: true,
onMovieFileDeleteForUpgrade: true,
fields: [
{name: "host", value: "localhost"},
{name: "port", value: 8096},
{name: "useSsl", value: false},
{name: "apiKey", value: $key},
{name: "notify", value: false},
{name: "updateLibrary", value: true}
],
tags: []
}')" > /dev/null && echo " done" || echo " failed"
else
echo "Radarr Jellyfin already configured"
fi
fi
########################################################################## ##########################################################################
# Prowlarr auth — trust localhost so Authelia is the only gate. Other # Prowlarr auth — trust localhost so Authelia is the only gate. Other
# *arr apps default to this; Prowlarr does not. # *arr apps default to this; Prowlarr does not.

View file

@ -41,6 +41,7 @@
{ domain = "sabnzbd.nordhammer.it"; policy = "one_factor"; } { domain = "sabnzbd.nordhammer.it"; policy = "one_factor"; }
{ domain = "code.nordhammer.it"; policy = "one_factor"; } { domain = "code.nordhammer.it"; policy = "one_factor"; }
{ domain = "notes.nordhammer.it"; policy = "one_factor"; } { domain = "notes.nordhammer.it"; policy = "one_factor"; }
{ domain = "selkies.nordhammer.it"; policy = "one_factor"; }
]; ];
}; };

View file

@ -9,37 +9,10 @@
# 2. Delete ../modules/crowdsec/ and the disabledModules + imports lines below # 2. Delete ../modules/crowdsec/ and the disabledModules + imports lines below
# 3. The settings/option API is the same as the PR's, so config below is forward-compatible # 3. The settings/option API is the same as the PR's, so config below is forward-compatible
# #
# Before first deploy, create /var/secrets/ntfy-url with your topic URL: # CrowdSec bans silently — no ntfy pushes (they were constant noise).
# echo 'https://ntfy.sh/nordhammer-<random>' | sudo tee /var/secrets/ntfy-url # The /var/secrets/ntfy-url topic is used by services/service-health.nix instead.
# sudo chmod 600 /var/secrets/ntfy-url
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
# The real URL is injected at service start (see ExecStartPre below) —
# eval-time builtins.readFile can't see /var/secrets under pure flake
# evaluation, which is how the `update` alias builds.
ntfyUrlPlaceholder = "@NTFY_URL@";
# The module renders settings.notifications into /etc/crowdsec/notifications/
# as a symlink into /etc/static (the store). Re-render it from the static
# source with the secret substituted on every service start; nixos-rebuild
# restores the symlink on activation, so this never goes stale.
injectNtfyUrl = pkgs.writeShellScript "crowdsec-inject-ntfy-url" ''
set -euo pipefail
src=/etc/static/crowdsec/notifications/0-nixos-generated.yaml
dst=/etc/crowdsec/notifications/0-nixos-generated.yaml
secret=/var/secrets/ntfy-url
if [ ! -f "$secret" ]; then
echo "WARNING: $secret not found; ntfy notifications will not work" >&2
exit 0
fi
url=$(${pkgs.coreutils}/bin/tr -d '\n' < "$secret")
tmp=$(${pkgs.coreutils}/bin/mktemp "$dst.XXXXXX")
${pkgs.gnused}/bin/sed "s|${ntfyUrlPlaceholder}|$url|g" "$src" > "$tmp"
${pkgs.coreutils}/bin/chmod 600 "$tmp"
${pkgs.coreutils}/bin/chown crowdsec:crowdsec "$tmp"
${pkgs.coreutils}/bin/mv "$tmp" "$dst"
'';
# nixpkgs only builds the agent + cscli; the new module also expects # nixpkgs only builds the agent + cscli; the new module also expects
# notification plugins at $out/libexec/crowdsec/plugins/. Compile them # notification plugins at $out/libexec/crowdsec/plugins/. Compile them
# from the same source tree (cmd/notification-*) and move them there. # from the same source tree (cmd/notification-*) and move them there.
@ -142,52 +115,27 @@ in
} }
]; ];
# Push notifications via ntfy.sh # Profiles set ban duration to 4h. No ntfy notifications: a push per
notifications = [ # ban was constant noise on a WAN-exposed box. ntfy is now reserved
{ # for service-down alerts (see services/service-health.nix); CrowdSec
name = "ntfy_http"; # still bans silently.
type = "http";
log_level = "info";
url = ntfyUrlPlaceholder;
method = "POST";
headers = {
Title = "CrowdSec alert";
Priority = "high";
Tags = "rotating_light";
};
format = ''
{{range . -}}
{{.Scenario}} from {{.Source.IP}} ({{.Source.Cn}}) {{len .Decisions}} decision(s) taken
{{end -}}
'';
}
];
# Override default profiles to attach the ntfy notifier
profiles = [ profiles = [
{ {
name = "default_ip_remediation"; name = "default_ip_remediation";
filters = [ "Alert.Remediation == true && Alert.GetScope() == 'Ip'" ]; filters = [ "Alert.Remediation == true && Alert.GetScope() == 'Ip'" ];
decisions = [{ type = "ban"; duration = "4h"; }]; decisions = [{ type = "ban"; duration = "4h"; }];
notifications = [ "ntfy_http" ];
on_success = "break"; on_success = "break";
} }
{ {
name = "default_range_remediation"; name = "default_range_remediation";
filters = [ "Alert.Remediation == true && Alert.GetScope() == 'Range'" ]; filters = [ "Alert.Remediation == true && Alert.GetScope() == 'Range'" ];
decisions = [{ type = "ban"; duration = "4h"; }]; decisions = [{ type = "ban"; duration = "4h"; }];
notifications = [ "ntfy_http" ];
on_success = "break"; on_success = "break";
} }
]; ];
}; };
}; };
# Inject the ntfy topic URL into the rendered notification config before
# every start. "+" runs the script with full privileges (it reads the
# root-owned secret and replaces a root-owned /etc symlink).
systemd.services.crowdsec.serviceConfig.ExecStartPre = [ "+${injectNtfyUrl}" ];
# Firewall bouncer enforces decisions via nftables; auto-registers with LAPI # Firewall bouncer enforces decisions via nftables; auto-registers with LAPI
services.crowdsec-firewall-bouncer = { services.crowdsec-firewall-bouncer = {
enable = true; enable = true;

View file

@ -0,0 +1,35 @@
# services/hardware-health.nix — RAS error attribution + watchdog auto-recovery
#
# Context: Jun 2026 the dual Xeon E5-2697 v3 began throwing a storm of
# *corrected* Machine Check Exceptions on both sockets (Bank 5 / Bank 20),
# ~18k events in 36h, eventually hanging the box. Since this host is the
# router, a hang takes the whole LAN offline until a manual power-cycle.
#
# This module:
# - rasdaemon: decodes every MCE to a specific DIMM/channel/socket and
# persists a per-component error DB, so a failing part can be named
# (needed for the seller's warranty claim). Query with `ras-mc-ctl
# --error-count` and `ras-mc-ctl --summary`.
# - hardware watchdog: if userspace hangs again, systemd stops petting
# /dev/watchdog0 and the chipset watchdog reboots the box (~30s),
# restoring the LAN without physical access.
{ config, lib, pkgs, ... }:
{
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
# Decode + log + persist machine-check / memory errors per component.
hardware.rasdaemon.enable = true;
# ras-mc-ctl on PATH for manual inspection.
environment.systemPackages = [ pkgs.rasdaemon ];
# Hardware watchdog: auto-reboot a hung box instead of a dead LAN.
# systemd pets /dev/watchdog0 at half the runtime interval; if it stops
# (hang), the chipset resets after RuntimeWatchdogSec.
systemd.settings.Manager = {
RuntimeWatchdogSec = "30s";
RebootWatchdogSec = "10min";
};
};
}

92
services/neko.nix Normal file
View file

@ -0,0 +1,92 @@
# services/neko.nix — Guild Wars (2005) in a browser via Neko
#
# Streams an Xfce desktop running the Windows Guild Wars client (under Wine)
# to a browser tab over WebRTC. Reach it at neko.nordhammer.it (Authelia-gated).
#
# Neko's stock images don't ship Wine, and apt installs land in /usr — which is
# wiped whenever the container is recreated. So we bake Wine into a locally-built
# image (FROM the upstream nvidia-xfce base) instead of relying on a volume.
# Guild Wars' own data installs into the persistent /home/neko volume on first run.
#
# Rendering is software-only (no GPU): neko doesn't ship a prebuilt NVIDIA Xfce
# desktop image, and building one from nvidia-base is a big detour for a 2005
# game. Wine renders via llvmpipe (software OpenGL) and neko encodes via x264 —
# both are heavily multithreaded and this box has 56 Xeon threads to spare, so
# Guild Wars is comfortably playable this way.
{ config, pkgs, lib, ... }:
let
# Wine-enabled image definition. Fed to `docker build` over stdin (see below)
# so there's no build context — we have no COPY/ADD, and a Nix-store symlinked
# context dir breaks BuildKit's Dockerfile resolution. Pinned to the v3.1
# series so the NEKO_MEMBER_*/NEKO_WEBRTC_* env schema below stays valid.
dockerfile = pkgs.writeText "neko-gw.Dockerfile" ''
FROM ghcr.io/m1k1o/neko/xfce:3.1
USER root
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
wine wine64 wine32 winbind ca-certificates wget \
libgl1 libgl1:i386 libglx-mesa0 libglx-mesa0:i386 \
libgl1-mesa-dri libgl1-mesa-dri:i386 \
&& rm -rf /var/lib/apt/lists/*
'';
in
{
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
virtualisation.docker.enable = true;
systemd.tmpfiles.rules = [
"d /var/lib/neko 0755 root root -"
# The container's neko user is uid/gid 1000 and must own its home, or the
# X server / Xfce can't create ~/.config, ~/.cache, etc. and the desktop
# never starts.
"d /var/lib/neko/home 0755 1000 1000 -"
];
systemd.services.neko = {
description = "Neko Guild Wars in a browser (Xfce + Wine + NVIDIA)";
after = [ "docker.service" "network-online.target" ];
requires = [ "docker.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
# First start pulls a multi-GB base image and runs apt — give it room.
# If it fails, back off but don't crash-loop (see 7dtd veth-flood note).
startLimitIntervalSec = 600;
startLimitBurst = 3;
serviceConfig = {
Restart = "on-failure";
RestartSec = "30s";
TimeoutStartSec = "3600";
ExecStartPre = [
"-${pkgs.docker}/bin/docker rm -f neko"
"${pkgs.writeShellScript "neko-build" ''
exec ${pkgs.docker}/bin/docker build -t neko-gw:local - < ${dockerfile}
''}"
];
# Wrapped in a shell script so the ICE-server JSON survives quoting
# (systemd's own ExecStart parser would strip the inner double quotes).
ExecStart = pkgs.writeShellScript "neko-run" ''
exec ${pkgs.docker}/bin/docker run --rm --name neko \
--shm-size=1g \
-e LIBGL_ALWAYS_SOFTWARE=1 \
-p 127.0.0.1:8092:8080 \
-p 59000:59000/udp \
-e NEKO_DESKTOP_SCREEN=1280x720@30 \
-e NEKO_MEMBER_PROVIDER=multiuser \
-e NEKO_MEMBER_MULTIUSER_USER_PASSWORD=neko \
-e NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=neko-admin \
-e NEKO_WEBRTC_UDPMUX=59000 \
-e NEKO_WEBRTC_NAT1TO1=10.0.0.1 \
-e 'NEKO_WEBRTC_ICESERVERS_FRONTEND=[{"urls":["stun:stun.l.google.com:19302"]}]' \
-e 'NEKO_WEBRTC_ICESERVERS_BACKEND=[{"urls":["stun:stun.l.google.com:19302"]}]' \
-v /var/lib/neko/home:/home/neko \
neko-gw:local
'';
ExecStop = "${pkgs.docker}/bin/docker stop neko";
};
};
};
}

View file

@ -123,6 +123,7 @@ in
''; '';
}; };
"notes.nordhammer.it" = protectedProxy 5230; "notes.nordhammer.it" = protectedProxy 5230;
"selkies.nordhammer.it" = protectedProxy 8093;
# --- Local-only: serves update history JSON to Homepage's customapi widget --- # --- Local-only: serves update history JSON to Homepage's customapi widget ---
"homepage-updates.local" = { "homepage-updates.local" = {

99
services/selkies.nix Normal file
View file

@ -0,0 +1,99 @@
# services/selkies.nix — Guild Wars in a browser via Selkies
#
# Replaces the Neko attempt (services/neko.nix, now unimported): Neko's
# absolute-pointer input model can't handle Guild Wars' exclusive mouse grab.
# Selkies captures the mouse client-side with the browser Pointer Lock API and
# sends *relative* movement, so the grab is a non-issue — and it uses the GPU
# (NVENC + EGL) instead of software rendering.
#
# Reach it at selkies.nordhammer.it (Authelia-gated). The Wine prefix with
# Guild Wars already installed is reused from the old Neko home, seeded into
# /var/lib/selkies/home/.wine (see the deploy note in the repo).
{ config, pkgs, lib, ... }:
let
# Selkies' NVIDIA EGL desktop (Ubuntu 24.04) plus Wine for the 32-bit GW
# client. Built from stdin (no build context); see neko.nix for the why.
dockerfile = pkgs.writeText "selkies-gw.Dockerfile" ''
FROM ghcr.io/selkies-project/nvidia-egl-desktop:24.04
USER root
RUN add-apt-repository -y multiverse \
&& dpkg --add-architecture i386 \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
wine wine32 wine64 winbind cabextract ca-certificates wget \
&& rm -rf /var/lib/apt/lists/*
# `gw` launcher: GW is 32-bit, so it needs the 32-bit NVIDIA GL libs (mounted
# at /usr/lib/i386-linux-gnu/nvidia by the service) on the loader path, and
# VirtualGL (EGL backend) to render on the M2000. Without this GW falls back
# to llvmpipe (software) and pegs ~18 CPU cores at <20 fps.
RUN printf '#!/bin/bash\nexport LD_LIBRARY_PATH=/usr/lib/i386-linux-gnu/nvidia\nexport VGL_DISPLAY=egl VGL_FPS=60\ncd "$HOME/.wine/drive_c/Program Files (x86)/Guild Wars/"\nexec vglrun wine Gw.exe "$@"\n' > /usr/local/bin/gw \
&& chmod +x /usr/local/bin/gw
USER 1000
'';
in
{
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
virtualisation.docker.enable = true;
# GPU into the container via CDI (nvidia.com/gpu=all). The CDI spec only
# carries the 64-bit driver libs, so the 32-bit set (for 32-bit Wine/GW) is
# bind-mounted separately below; enable32Bit makes them exist on the host.
hardware.nvidia-container-toolkit.enable = true;
hardware.graphics.enable32Bit = true;
systemd.tmpfiles.rules = [
"d /var/lib/selkies 0755 root root -"
# Container user is uid/gid 1000 and must own its home.
"d /var/lib/selkies/home 0755 1000 1000 -"
];
systemd.services.selkies = {
description = "Selkies Guild Wars in a browser (EGL desktop + Wine + NVENC)";
after = [ "docker.service" "network-online.target" ];
requires = [ "docker.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
startLimitIntervalSec = 600;
startLimitBurst = 3;
serviceConfig = {
Restart = "on-failure";
RestartSec = "30s";
TimeoutStartSec = "3600";
ExecStartPre = [
"-${pkgs.docker}/bin/docker rm -f selkies"
"${pkgs.writeShellScript "selkies-build" ''
exec ${pkgs.docker}/bin/docker build -t selkies-gw:local - < ${dockerfile}
''}"
];
ExecStart = pkgs.writeShellScript "selkies-run" ''
exec ${pkgs.docker}/bin/docker run --rm --name selkies \
--device=nvidia.com/gpu=all \
-e NVIDIA_VISIBLE_DEVICES=all \
-e NVIDIA_DRIVER_CAPABILITIES=all \
--shm-size=2g \
-p 127.0.0.1:8093:8080 \
-p 3478:3478 -p 3478:3478/udp \
-p 65532-65535:65532-65535/udp \
-e TZ=Europe/Stockholm \
-e DISPLAY_SIZEW=1280 -e DISPLAY_SIZEH=720 \
-e DISPLAY_REFRESH=60 -e DISPLAY_DPI=96 -e DISPLAY_CDEPTH=24 \
-e PASSWD=selkies \
-e SELKIES_ENCODER=nvh264enc \
-e SELKIES_VIDEO_BITRATE=8000 \
-e SELKIES_FRAMERATE=60 \
-e SELKIES_ENABLE_BASIC_AUTH=false \
-e SELKIES_TURN_HOST=10.0.0.1 \
-e SELKIES_TURN_PROTOCOL=udp \
-e SELKIES_TURN_PORT=3478 \
-e TURN_MIN_PORT=65532 -e TURN_MAX_PORT=65535 \
-v ${config.hardware.nvidia.package.lib32}/lib:/usr/lib/i386-linux-gnu/nvidia:ro \
-v /var/lib/selkies/home:/home/ubuntu \
selkies-gw:local
'';
ExecStop = "${pkgs.docker}/bin/docker stop selkies";
};
};
};
}

View file

@ -0,0 +1,77 @@
# services/service-health.nix — ntfy alert when a watched systemd unit fails,
# and again when it recovers. Replaces the noisy per-ban CrowdSec pushes
# (silenced in services/crowdsec.nix); both share the /var/secrets/ntfy-url topic.
#
# Detection is event-driven: each watched unit gets OnFailure=notify-failure@%n.
# OnFailure fires only once a unit truly enters "failed" state — i.e. after it
# has exhausted its Restart= attempts — so transient restarts stay silent and
# you're only paged when a service has genuinely given up. The handler sends a
# "down" push, then waits for the unit to come back and sends "recovered".
#
# Requires /var/secrets/ntfy-url (the same topic file CrowdSec used):
# echo 'https://ntfy.sh/your-topic' | sudo tee /var/secrets/ntfy-url
# sudo chmod 600 /var/secrets/ntfy-url
{ config, lib, pkgs, ... }:
let
# Core media + infra units to page on. All verified to exist on the box;
# adding a name that isn't a real unit would create a stray stub service.
watched = [
"jellyfin" "sonarr" "radarr" "prowlarr" "bazarr"
"qbittorrent-nox" "sabnzbd" "authelia-main" "nginx"
"adguardhome" "crowdsec" "go2rtc"
"homepage-dashboard" "cloudflare-dyndns" "gitea-runner-default"
];
# Reads the topic at runtime (pure flake eval can't see /var/secrets).
# $1 = the failed unit's full name, e.g. "jellyfin.service".
notify = pkgs.writeShellScript "service-health-notify" ''
set -uo pipefail
unit="$1"
name="''${unit%.service}"
host="${config.networking.hostName}"
secret=/var/secrets/ntfy-url
if [ ! -f "$secret" ]; then
echo "service-health: $secret missing; cannot notify" >&2
exit 0
fi
url=$(${pkgs.coreutils}/bin/tr -d '\n' < "$secret")
post() { # title priority tags body
${pkgs.curl}/bin/curl -fsS --max-time 10 \
-H "Title: $1" -H "Priority: $2" -H "Tags: $3" \
-d "$4" "$url" >/dev/null 2>&1 || true
}
post "Service down" high rotating_light "$name failed on $host"
# Wait for recovery: up to 2h, polling every 20s.
for _ in $(${pkgs.coreutils}/bin/seq 1 360); do
${pkgs.coreutils}/bin/sleep 20
if ${pkgs.systemd}/bin/systemctl is-active --quiet "$unit"; then
post "Service recovered" default white_check_mark "$name is running again on $host"
exit 0
fi
done
'';
in
{
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
systemd.services = lib.mkMerge [
# Templated handler: %i is the failed unit's full name (jellyfin.service).
{
"notify-failure@" = {
description = "ntfy alert: %i failed";
serviceConfig = {
Type = "simple";
ExecStart = "${notify} %i";
};
};
}
# Wire OnFailure onto each watched unit (merges with its existing config).
(lib.genAttrs watched (_: {
unitConfig.OnFailure = [ "notify-failure@%n.service" ];
}))
];
};
}

View file

@ -227,12 +227,32 @@ in
hl.animation({ leaf = "workspaces", enabled = true, speed = 1, bezier = "snap" }) hl.animation({ leaf = "workspaces", enabled = true, speed = 1, bezier = "snap" })
-- Window rules -- Window rules
-- Don't lock/idle while any window is fullscreen (video, games).
hl.window_rule({
match = { class = ".*" },
idle_inhibit = "fullscreen",
})
-- Battle.net tray icon leaks as a tiny floating XWayland window. -- Battle.net tray icon leaks as a tiny floating XWayland window.
hl.window_rule({ hl.window_rule({
match = { class = "steam_app_0", title = "^$", float = true }, match = { class = "steam_app_0", title = "^$", float = true },
workspace = "special silent", workspace = "special silent",
}) })
-- Tiny Terraces opens floating by default; force it to tile.
hl.window_rule({
match = { class = "steam_app_3136330" },
tile = true,
})
-- Battle.net (a non-Steam shortcut, so class steam_app_0) spams
-- window-activation events that clear quickshell's focus grab and
-- instantly close the launcher / power menu. Drop those events.
hl.window_rule({
match = { class = "steam_app_0" },
suppress_event = "activate activatefocus",
})
-- Binds -- Binds
local mod = "SUPER" local mod = "SUPER"
@ -275,8 +295,10 @@ in
end end
-- Screenshots Shift+Super+S matches GNOME binding -- Screenshots Shift+Super+S matches GNOME binding
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("hyprshot -m region --clipboard-only")) -- Pin/unpin quickshell's focus grab around the region select so an
hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output --clipboard-only")) -- open menu survives slurp's input grab (no-ops if qs isn't up).
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c 'qs ipc call screenshot pin; hyprshot -m region; qs ipc call screenshot unpin'"))
hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output"))
-- Settings shortcut Super+I matches GNOME binding -- Settings shortcut Super+I matches GNOME binding
hl.bind(mod .. " + I", hl.dsp.exec_cmd("pavucontrol")) hl.bind(mod .. " + I", hl.dsp.exec_cmd("pavucontrol"))
@ -356,31 +378,38 @@ in
}; };
}; };
services.hypridle = lib.mkIf isMacbook { services.hypridle = lib.mkIf isMacbook (
enable = true; let
settings = { # Skip the action if any MPRIS player is playing — covers windowed
general = { # video (Jellyfin in a browser, mpv, …) that the fullscreen
lock_cmd = "pidof hyprlock || hyprlock"; # idle_inhibit rule misses. Browsers expose MPRIS via playerctl.
before_sleep_cmd = "loginctl lock-session"; unlessPlaying = cmd: "playerctl -a status 2>/dev/null | grep -q Playing || ${cmd}";
after_sleep_cmd = "hyprctl dispatch dpms on"; in {
enable = true;
settings = {
general = {
lock_cmd = "pidof hyprlock || hyprlock";
before_sleep_cmd = "loginctl lock-session";
after_sleep_cmd = "hyprctl dispatch dpms on";
};
listener = [
{
timeout = 300; # 5 min — lock
on-timeout = unlessPlaying "loginctl lock-session";
}
{
timeout = 420; # 7 min — display off
on-timeout = unlessPlaying "hyprctl dispatch dpms off";
on-resume = "hyprctl dispatch dpms on";
}
{
timeout = 600; # 10 min — suspend
on-timeout = unlessPlaying "systemctl suspend";
}
];
}; };
listener = [ }
{ );
timeout = 300; # 5 min — lock
on-timeout = "loginctl lock-session";
}
{
timeout = 420; # 7 min — display off
on-timeout = "hyprctl dispatch dpms off";
on-resume = "hyprctl dispatch dpms on";
}
{
timeout = 600; # 10 min — suspend
on-timeout = "systemctl suspend";
}
];
};
};
# Scope all HM Wayland services (hyprpaper, etc.) to the # Scope all HM Wayland services (hyprpaper, etc.) to the
# Hyprland session so they don't crash-loop in a GNOME session. # Hyprland session so they don't crash-loop in a GNOME session.

File diff suppressed because it is too large Load diff