216 Commits

Author SHA1 Message Date
facaa261bc Add missing services to Gatus monitoring and Dashy dashboard
All checks were successful
Check Flake / check-flake (push) Successful in 3m50s
Gatus: Add Roundcube, Collabora, and all s0 services (Jellyfin,
servarr stack, Home Assistant, ESPHome, Zigbee2MQTT, Frigate,
Valetudo, Sandman, Vikunja, Actual Budget, Linkwarden, Memos,
Outline, LanguageTool, Unifi) in a new "s0" group.

Dashy: Add missing public services (ntfy, Librechat, Owncast,
Navidrome, Collabora, Gatus) to Services section. Add new Home
Automation and Productivity sections. Add Unifi to Network.
Remove disabled Jitsi Meet.
2026-02-26 23:41:06 -08:00
1d915f9524 Add update flake and skill creator skills
All checks were successful
Check Flake / check-flake (push) Successful in 3m14s
2026-02-26 23:09:32 -08:00
73633eaddc non-nix managed nextcloud apps isn't worth the headache
All checks were successful
Check Flake / check-flake (push) Successful in 3m25s
2026-02-26 22:51:42 -08:00
6a0540dddd Update attic-netrc
Some checks failed
Check Flake / check-flake (push) Has been cancelled
2026-02-26 22:47:28 -08:00
ce9bda8a0b Verify RSA-SHA256 signature on PIA server list response
All checks were successful
Check Flake / check-flake (push) Successful in 3m20s
The server list endpoint returns JSON on line 1 with a base64-encoded
RSA-SHA256 signature on lines 3+. This was previously ignored. Add
verifyServerList() that checks the signature against PIA's public
signing key before trusting the data. On failure the service aborts
and systemd restarts it.

Also bump RestartSec to 5m to avoid hammering PIA servers on repeated
failures, and add openssl to container dependencies.
2026-02-26 22:32:23 -08:00
1dd1b420d5 Add ntfy ssh login alerts. Include systemd service logs with service errors
All checks were successful
Check Flake / check-flake (push) Successful in 3m34s
2026-02-26 21:40:51 -08:00
59623c8a3b fix nextcloud auto-update crashing on nix-managed apps
Some checks failed
Check Flake / check-flake (push) Has been cancelled
2026-02-26 21:39:11 -08:00
f2f5761c83 Rewrite PIA VPN as multi-container bridge architecture
All checks were successful
Check Flake / check-flake (push) Successful in 3m15s
Replace the single VPN container (veth pair, host-side auth scripts) with a
multi-container setup on a shared bridge network:

- Dedicated VPN container handles all PIA auth, WireGuard config, NAT, and
  optional port forwarding DNAT
- Service containers default-route through VPN container (leak-proof by topology)
- Host runs tinyproxy on bridge for PIA API bootstrap before WG is up
- WG interface is still created in host netns and moved into VPN container
  namespace
- Monthly renewal to ensure that connection stays up (PIA allows connections to
  last up to 2 months)
- Drop OpenVPN support entirely
2026-02-26 19:51:35 -08:00
gitea-runner
412dd12b5a flake.lock: update inputs
Some checks failed
Check Flake / check-flake (push) Successful in 2m22s
Auto Update Flake / auto-update (push) Failing after 2m40s
2026-02-22 22:01:06 -08:00
684851d641 Prevent containers from running non-container services
All checks were successful
Check Flake / check-flake (push) Successful in 2m21s
Auto Update Flake / auto-update (push) Successful in 3m29s
2026-02-22 18:18:05 -08:00
4cf50b5fb1 Restart atticd whenever PostgreSQL restarts
All checks were successful
Check Flake / check-flake (push) Successful in 3m7s
2026-02-22 17:53:46 -08:00
288a2841aa Replace Uptime Kuma with Gatus for declarative uptime monitoring
All checks were successful
Check Flake / check-flake (push) Successful in 2m4s
Gatus is configured entirely via YAML (mapped from Nix attrsets),
making nix-config the single source of truth for all monitoring
config instead of Uptime Kuma's web UI/SQLite database.
2026-02-22 17:30:03 -08:00
0589ca5748 Add attic binary cache to sandboxed workspaces
Update the attic cache URL from s0.koi-bebop.ts.net to s0.neet.dev
and configure sandboxed workspaces to inherit the host's binary cache
settings (substituters, trusted keys, netrc auth via agenix).
2026-02-22 17:22:44 -08:00
a4c5cb589a Claude workspaces 2026-02-22 17:19:48 -08:00
a697ea10ad Add daily ZFS health check with ntfy alerts and introduce ntfy role
Add a zfs-alerts module that runs a daily health check on ZFS machines,
sending detailed ntfy notifications for degraded pools, data errors, or
drive errors. Introduce an "ntfy" system role to decouple ntfy alerting
from the server/personal roles, and assign it to all machines.
2026-02-22 17:17:40 -08:00
200d5a5d22 Add ntfy failure alerts for all systemd services
All checks were successful
Check Flake / check-flake (push) Successful in 3m18s
2026-02-22 16:19:43 -08:00
339eac52c6 Add uptime kuma
All checks were successful
Check Flake / check-flake (push) Successful in 9m15s
2026-02-22 15:49:26 -08:00
bab4b3ff8e Skip build and push when flake.lock has no changes
All checks were successful
Check Flake / check-flake (push) Successful in 2m0s
2026-02-22 15:12:45 -08:00
54ab576914 Fix push auth with PAT, correct run link, and add ntfy to check-flake 2026-02-22 15:12:45 -08:00
c84c0716ce Fix push auth with PAT and use correct run_number in ntfy link 2026-02-22 15:12:45 -08:00
a921f40644 Fix git identity and ntfy URL in auto-update workflow 2026-02-22 15:12:45 -08:00
gitea-runner
a6c17164fa flake.lock: Update
All checks were successful
Check Flake / check-flake (push) Successful in 2m1s
Flake lock file updates:

• Updated input 'home-manager':
    'github:nix-community/home-manager/c6ed3eab64d23520bcbb858aa53fe2b533725d4a?narHash=sha256-WxAEkAbo8dP7qiyPM6VN4ZGAxfuBVlNBNPkrqkrXVEc%3D' (2026-02-21)
  → 'github:nix-community/home-manager/5bd3589390b431a63072868a90c0f24771ff4cbb?narHash=sha256-Tl2I0YXdhSTufGqAaD1ySh8x%2BcvVsEI1mJyJg12lxhI%3D' (2026-02-22)
• Updated input 'microvm':
    'github:astro/microvm.nix/789c90b164b55b4379e7a94af8b9c01489024c18?narHash=sha256-1XJOslVyF7yzf6yd/yl1VjGLywsbtwmQh3X1LuJcLI4%3D' (2026-02-17)
  → 'github:astro/microvm.nix/a3abc020a3d8e624e145f4144ed40702f788ea32?narHash=sha256-Pf4CaRoOLQV02m2POPA%2B0EWvb3gVdpaiS0hNNVZhO3c%3D' (2026-02-21)
• Updated input 'nix-index-database':
    'github:Mic92/nix-index-database/efec7aaad8d43f8e5194df46a007456093c40f88?narHash=sha256-UIKOwG0D9XVIJfNWg6%2BgENAvQP%2B7LO46eO0Jpe%2BItJ0%3D' (2026-02-15)
  → 'github:Mic92/nix-index-database/8f590b832326ab9699444f3a48240595954a4b10?narHash=sha256-/phvMgr1yutyAMjKnZlxkVplzxHiz60i4rc%2BgKzpwhg%3D' (2026-02-22)
2026-02-22 15:04:48 -08:00
9df8390f1f Add daily auto-update workflow with shared build script
All checks were successful
Check Flake / check-flake (push) Successful in 2m7s
2026-02-21 23:29:41 -08:00
156f0183bd Add ntfy push notification server on ponyo 2026-02-21 23:29:36 -08:00
8b92e51ef7 Remove phil machine and aarch64 ISO/kexec 2026-02-21 21:43:12 -08:00
7798872bbf Disable SMB3 directory leases to fix stale listings from local file changes 2026-02-21 21:43:12 -08:00
cf41285cb8 Update inputs + move to nixos-unstable 2026-02-21 21:43:12 -08:00
5a0a525f64 Add Attic binary cache and containerize gitea runner
Replace nix-serve-only setup with Attic for managed binary caching with
upstream filtering and GC. Move gitea actions runner from host into an
isolated NixOS container with private networking. nix-serve kept alongside
Attic during migration.
2026-02-21 21:43:08 -08:00
9154595910 Ad Incus sandbox on fry I've already been using for a while now
All checks were successful
Check Flake / check-flake (push) Successful in 3m35s
2026-02-17 21:35:23 -08:00
1b92363b08 Fix rust analyzer in vscode 2026-02-17 21:28:50 -08:00
136f024cf0 Fix tailscale networking when incus is on 2026-02-17 21:28:28 -08:00
3d08a3e9bc Improve nix settings for sandboxed workspaces
All checks were successful
Check Flake / check-flake (push) Successful in 1m15s
2026-02-14 11:29:02 -08:00
99ef62d31a Fix unused vars
All checks were successful
Check Flake / check-flake (push) Successful in 1m21s
2026-02-11 23:12:00 -08:00
298f473ceb Remove unused vscode-server module 2026-02-11 23:00:48 -08:00
546bd08f83 Fix CI build. Ephemeral targets should not be in nixosConfigurations
All checks were successful
Check Flake / check-flake (push) Successful in 17m45s
2026-02-11 22:49:11 -08:00
10f3e3a7bf Remove old stale/unused configuration 2026-02-11 22:47:38 -08:00
d44bd12e17 Update README.md 2026-02-11 21:58:38 -08:00
60e89dfc90 Clean up CLAUDE.md and make the claude skill correctly this time
Some checks failed
Check Flake / check-flake (push) Failing after 6s
2026-02-10 21:08:13 -08:00
869b6af7f7 Block sandbox access to local network
Add nftables forward rules to prevent sandboxed workspaces from
reaching RFC1918 private addresses while allowing public internet
and the host gateway (for DNS/NAT).
2026-02-09 20:16:02 -08:00
d6a0e8ec49 Disable tailscaleAuth for now because it doesn't work with tailscale's ACL tagged group
Some checks failed
Check Flake / check-flake (push) Failing after 35s
2026-02-09 19:57:20 -08:00
8293a7dc2a Rework Claude Code config in sandboxed workspaces
Remove credential passing to sandboxes (didn't work well enough).
Move onboarding config init from host-side setup into base.nix so
each workspace initializes its own Claude config on first boot.
Wrap claude command in VM and Incus workspaces to always skip
permission prompts.
2026-02-09 19:56:11 -08:00
cbf2aedcad Add use flake for fresh claude code 2026-02-09 18:04:09 -08:00
69fc3ad837 Add ZFS/btrfs snapshot support to backups
Creates filesystem snapshots before backup for point-in-time consistency.
Uses mount namespaces to bind mount snapshots over original paths, so
restic records correct paths while reading from frozen snapshot data.

- Auto-detects filesystem type via findmnt
- Deterministic snapshot names using path hash
- Graceful fallback for unsupported filesystems
2026-02-08 20:16:37 -08:00
6041d4d09f Replace nixos-generators with upstream nixpkgs image support 2026-02-08 17:57:16 -08:00
cf71b74d6f Add Incus container support to sandboxed workspaces
- Add incus.nix module for fully declarative Incus/LXC containers
- Build NixOS LXC images using nixpkgs.lib.nixosSystem
- Ephemeral containers: recreated on each start, cleaned up on stop
- Use flock to serialize concurrent container operations
- Deterministic MAC addresses via lib.mkMac to prevent ARP cache issues
- Add veth* to NetworkManager unmanaged interfaces
- Update CLAUDE.md with coding conventions and shared lib docs
2026-02-08 15:16:40 -08:00
5178ea6835 Configure Claude Code for sandboxed workspaces
- Add credentials bind mount in container.nix
- Create claude-credentials-dir service to copy credentials for VMs
- Generate .claude.json with onboarding skipped and workspace trusted
- Add allowUnfree to container config
2026-02-08 14:53:31 -08:00
87db330e5b Add sandboxed-workspace module for isolated dev environments
Provides isolated development environments using either VMs (microvm.nix)
or containers (systemd-nspawn) with a unified configuration interface.

Features:
- Unified options with required type field ("vm" or "container")
- Shared base configuration for networking, SSH, users, packages
- Automatic SSH host key generation and persistence
- Shell aliases for workspace management (start/stop/status/ssh)
- Automatic /etc/hosts entries for workspace hostnames
- restartIfChanged support for both VMs and containers
- Passwordless doas in workspaces

Container backend:
- Uses hostBridge for proper bridge networking with /24 subnet
- systemd-networkd for IP configuration
- systemd-resolved for DNS

VM backend:
- TAP interface with deterministic MAC addresses
- virtiofs shares for workspace directories
- vsock CID generation
2026-02-07 22:43:08 -08:00
70f0064d7b Add claude-code to personal machines 2026-02-07 22:37:35 -08:00
cef8456332 Add CLAUDE.md with project conventions 2026-02-07 22:36:11 -08:00
c22855175a Add logseq and godot-mono
All checks were successful
Check Flake / check-flake (push) Successful in 3m51s
2026-02-06 21:12:18 -08:00
0a06e3c1ae Move vscodium config to home manager and add vscodium profile 2026-02-06 21:11:59 -08:00
eb416ae409 Update nixpkgs for wireless fix https://github.com/nixos/nixpkgs/issues/476906
All checks were successful
Check Flake / check-flake (push) Successful in 3m43s
2026-01-27 19:14:40 -08:00
ae2a62515a Enable scanner support
All checks were successful
Check Flake / check-flake (push) Successful in 5m46s
2026-01-25 13:11:01 -08:00
2810ba1412 Enable flakes in kexec image and comma integration
All checks were successful
Check Flake / check-flake (push) Successful in 20m21s
2026-01-24 15:02:42 -08:00
e42e30d3cc Fix nix-index autogenerated db comma integration 2026-01-24 15:01:16 -08:00
83b5d3b8c2 Update nextcloud occ command syntax 2026-01-24 14:59:57 -08:00
0b604fd99c Add activate deploy command 2026-01-24 14:58:40 -08:00
51fbae98c5 Update digitalocean key
All checks were successful
Check Flake / check-flake (push) Successful in 5m51s
2026-01-14 19:32:21 -08:00
d8eff26864 VLAN workaround for now 2026-01-14 18:56:24 -08:00
5f7335c2a0 Simplify kexec and iso image generation 2026-01-14 18:54:55 -08:00
bab2df5d7e Use programs.ssh.askPassword
All checks were successful
Check Flake / check-flake (push) Successful in 4m56s
2026-01-11 15:24:53 -08:00
adc04d1bc7 Update nixos mailserver
All checks were successful
Check Flake / check-flake (push) Successful in 18m38s
2026-01-11 14:25:17 -08:00
da9a8f8c03 Update nixpkgs 2026-01-11 14:25:03 -08:00
415cbca33e VLAN workaround for now 2026-01-10 23:04:48 -08:00
51272a172b Add system76-keyboard-configurator to fry 2026-01-10 23:03:19 -08:00
f053c677e8 Set up openwebui + ollama 2026-01-10 23:02:43 -08:00
c130ce6edd Don't generate zed user config file for now 2026-01-10 22:55:31 -08:00
4718326cb6 Configure ssh-agent to work with keepassxc ssh keys 2026-01-10 22:53:28 -08:00
61698aa7e2 Add kde connect 2026-01-10 22:52:17 -08:00
e0af023ac9 barrier was removed from nixpkgs 2026-01-10 22:51:09 -08:00
c0088553ff jellyfin-media-player was removed from nixpkgs 2026-01-10 22:49:04 -08:00
577736fcb2 Add deploy command 2026-01-10 22:46:39 -08:00
cf087b0e39 Add fry
All checks were successful
Check Flake / check-flake (push) Successful in 1h22m48s
2025-10-12 13:36:02 -07:00
cb1c4752ec Use latest kernel on Howl 2025-10-12 13:35:23 -07:00
b77fb54dc6 Disable annoying pls shell integration 2025-10-12 13:35:02 -07:00
3d6a759827 Update nixpkgs 2025-10-12 13:33:53 -07:00
0c455baebd Add languagetool
All checks were successful
Check Flake / check-flake (push) Successful in 5m13s
2025-08-16 19:04:10 -07:00
b58df0632a Add outline service
All checks were successful
Check Flake / check-flake (push) Successful in 15m2s
2025-08-10 20:49:50 -07:00
4956e41285 Add memos service 2025-08-10 19:03:35 -07:00
ead6653de1 Add services to tailscale auth 2025-08-10 19:02:47 -07:00
dd4a5729d4 Workaround for broken librespot spotify api integration
All checks were successful
Check Flake / check-flake (push) Successful in 4m49s
2025-08-10 15:18:29 -07:00
f248c129c8 Open port 8095 for music assistant too 2025-08-10 15:17:52 -07:00
c011faab18 Use flaresolverr with linkwarden 2025-08-10 15:17:27 -07:00
a5d0b3b748 Bring back APU2 router for more experimentation
All checks were successful
Check Flake / check-flake (push) Successful in 19m21s
2025-08-05 19:45:50 -07:00
ed3bee2e4e Improve minimal iso so it can boot on APU2 from sd card 2025-08-05 19:44:49 -07:00
dbde2a40f2 Add linkwarden 2025-08-05 19:42:29 -07:00
6c69d82156 Add support for Home Assistant voice (whisper + piper + cloud llm) and Music Assistant via Spotify by librespot
Music assistant has custom modifications they made to librespot that they haven't bothered to even try to upstream.
Thus, they require a custom librespot.  I tried and tried and tried and tried to just override the one already in nixpkgs
but I had trouble doing so despite copying the pattern already shown in nixpkgs for overriding the src of a cargo pkg
(See mopidy) but it just didn't work... Oh well. So I just patch nixpkgs instead with the new source. It works I guess.

This is about where I gave up...

```nix
nixpkgs.overlays = [
  (final: prev: {
    # Cannot use librespot upstream because music-assistant requires custom changes
    # that they never bothered to even try to uptream
    librespot = prev.librespot.overrideAttrs (oldAttrs: rec {
      src = prev.fetchFromGitHub {
        owner = "music-assistant";
        repo = "librespot";
        rev = "786cc46199e583f304a84c786acb0a9b37bc3fbd";
        sha256 = "sha256-xaOrqC8yCjF23Tz31RD3CzqZ3xxrDM6ncW1yoovEaGQ=";
      };

      cargoDeps = oldAttrs.cargoDeps.overrideAttrs (oldAttrs': {
        vendorStaging = oldAttrs'.vendorStaging.overrideAttrs {
          outputHash = "sha256-SqvJSHkyd1IicT6c4pE96dBJNNodULhpyG14HRGVWCk=";
        };
      });
    });
  })
];
```
2025-08-05 19:37:50 -07:00
01b01f06b4 Stop using systemd-networkd it has some flaws with NixOS' networking I need to figure out later.
It is very elegant, easy to debug/understand, and I definitely want to use it but The most significant
problem is it doesn't work with NixOS containers private networking.  So I'll need to figure that out
or maybe it will be fixed upstream soon.
2025-08-05 19:27:29 -07:00
cf560d4e53 Downgrade Howl's kernel because newer kernels just are horrible with Howl's network card 2025-08-05 19:24:46 -07:00
8cf4957e15 Add build iso helper command 2025-08-05 19:23:42 -07:00
dc02438a63 Finally a fix DHCP+VLANs thanks to systemd-networkd
All checks were successful
Check Flake / check-flake (push) Successful in 3m31s
2025-07-22 21:20:12 -07:00
948984af2d Set ghostty preferences
All checks were successful
Check Flake / check-flake (push) Successful in 22m14s
2025-07-18 19:46:18 -07:00
be23526c2c Add KeepassXC keys, remove some very old user keys, and rekey
All checks were successful
Check Flake / check-flake (push) Successful in 1m50s
2025-07-16 22:01:33 -07:00
e234577268 Disable inactive cache push experiment 2025-07-16 22:00:11 -07:00
82b67ed566 Add Whiteboard app to Nextcloud
All checks were successful
Check Flake / check-flake (push) Successful in 2m17s
2025-07-16 20:49:39 -07:00
53c2e2222c Move shell aliases 2025-07-16 20:48:26 -07:00
846da159d0 Iodine stopped working again 2025-07-16 20:47:49 -07:00
a45125421e Add collabora online and move nextcloud domain 2025-07-16 20:46:51 -07:00
f4e40955c8 Use upstreamed pcie coral and vaapi frigate configuration
All checks were successful
Check Flake / check-flake (push) Successful in 12m12s
2025-07-13 18:04:36 -07:00
af9e462b27 Allow substituters to be offline
Some checks failed
Check Flake / check-flake (push) Has been cancelled
2025-07-13 17:54:32 -07:00
2faea9d380 Update nixpkgs and other flake inputs 2025-07-13 17:52:08 -07:00
8571922796 Add new helpful utilities 2025-07-12 11:42:40 -07:00
131d5e9313 Add rest command for home assistant 2025-07-12 10:50:37 -07:00
fe0ce3a245 Get recyclarr initially running 2025-07-12 10:48:13 -07:00
7b26cfb4eb update single input cmd 2025-07-12 10:27:09 -07:00
1c9fa418b3 Make s0 easier to unlock
All checks were successful
Check Flake / check-flake (push) Successful in 1m25s
2025-03-29 22:52:00 -07:00
8c4dc9cb74 Improve usage of roles. It should be much easier to read and use now. 2025-03-29 22:48:14 -07:00
1f9fbd87ac Use upstream pykms and Actual Budget. Move Actual to s0. Add automated backups for Actual.
All checks were successful
Check Flake / check-flake (push) Successful in 1m37s
2025-03-29 18:36:13 -07:00
23c8076e4d Pinning system nixpkgs is not needed anymore. nixpkgs already does this automatically for flakes.
All checks were successful
Check Flake / check-flake (push) Successful in 1m50s
2025-03-28 21:45:46 -07:00
75ae399b5a Update nixpkgs. Move to new dashy service 2025-03-28 21:05:37 -07:00
87ddad27a4 Add Home Manager 2025-03-28 20:27:14 -07:00
8dd2a00123 Tauri development extensions 2025-03-28 20:24:33 -07:00
944a783ff2 Add nix LSPs for development 2025-03-28 20:23:07 -07:00
c2cb43fd2c Enable iperf3 server on ponyo 2025-03-28 20:22:14 -07:00
02b2fb6309 Disable gc on howl so nix backed projects don't loose their cache 2025-03-28 20:19:15 -07:00
b43660aaef Clean up very old unused config 2025-03-28 20:17:54 -07:00
567d755850 If machine role is personal set de.enable = true; automatically 2025-03-28 20:16:26 -07:00
adc9b9f2b7 Add sandman.s0.neet.dev 2025-03-28 19:39:59 -07:00
9181e3bfa3 Update librechat to v0.7.7 2025-03-28 19:38:41 -07:00
9845270512 Fix gparted 2025-03-28 19:35:35 -07:00
b3b3044690 Downgrade to dailybot to python 3.11
All checks were successful
Check Flake / check-flake (push) Successful in 1m22s
2025-02-18 22:43:47 -08:00
fb1970c316 Upgrade librechat
All checks were successful
Check Flake / check-flake (push) Successful in 6m43s
2025-02-17 12:12:46 -08:00
34f1edf3b3 Fix s0 setting the incorrect default route by using a static configuration 2025-02-17 12:11:52 -08:00
823f0a6ef2 Disable frigate detect for now. It is using excessive CPU 2025-02-17 12:10:59 -08:00
00d2ccc684 Fix sound in some games running in wine 2025-02-17 12:09:51 -08:00
b2acaff783 Fix pykms by downgrading to python 3.11 2025-02-17 12:09:20 -08:00
c51f4ad65b Unlock zoidberg using TPM2
All checks were successful
Check Flake / check-flake (push) Successful in 1m6s
2024-11-21 21:31:19 -08:00
eb6a50664c Upgrade NixOS. Use upstream libedgetpu, frigate, and gasket kernel module. Fix services broken by upgrade.
All checks were successful
Check Flake / check-flake (push) Successful in 17m43s
2024-11-19 21:28:56 -08:00
89ce0f7fc0 Change Howl's NVMe 2024-11-19 21:08:19 -08:00
8ff552818b Rollover digital ocean auth token
All checks were successful
Check Flake / check-flake (push) Successful in 1m13s
2024-10-27 16:41:02 -07:00
020689d987 Fix zigbee2mqtt auth 2024-10-27 16:40:47 -07:00
9109e356bd Backup vikunja
All checks were successful
Check Flake / check-flake (push) Successful in 2m6s
2024-10-27 16:26:32 -07:00
c7d9e84f73 Lock down access to mqtt
All checks were successful
Check Flake / check-flake (push) Successful in 1m6s
2024-10-27 16:15:23 -07:00
5b666a0565 Add nextcloud apps
Some checks failed
Check Flake / check-flake (push) Has been cancelled
2024-10-11 21:58:54 -07:00
6bc11767ca Update Actual Budget
All checks were successful
Check Flake / check-flake (push) Successful in 2m46s
2024-10-11 21:20:46 -07:00
bdd2d9bef9 Update nextcloud 2024-10-11 21:20:18 -07:00
5acc8b3fca Block email for ellen@runyan.org
All checks were successful
Check Flake / check-flake (push) Successful in 1m5s
2024-10-10 20:04:50 -07:00
1e25d8bb71 Add vikunja
Some checks failed
Check Flake / check-flake (push) Has been cancelled
2024-10-10 20:02:43 -07:00
ac1cf1c531 Open up mqtt for valetudo 2024-10-10 20:02:09 -07:00
02357198bc Change timezone 2024-10-10 20:01:41 -07:00
89b49aafc0 flake.lock: Update
All checks were successful
Check Flake / check-flake (push) Successful in 1h32m23s
Flake lock file updates:

• Updated input 'agenix':
    'github:ryantm/agenix/c2fc0762bbe8feb06a2e59a364fa81b3a57671c9' (2024-05-24)
  → 'github:ryantm/agenix/f6291c5935fdc4e0bef208cfc0dcab7e3f7a1c41' (2024-08-10)
• Updated input 'deploy-rs':
    'github:serokell/deploy-rs/3867348fa92bc892eba5d9ddb2d7a97b9e127a8a' (2024-06-12)
  → 'github:serokell/deploy-rs/aa07eb05537d4cd025e2310397a6adcedfe72c76' (2024-09-27)
• Updated input 'flake-utils':
    'github:numtide/flake-utils/b1d9ab70662946ef0850d488da1c9019f3a9752a' (2024-03-11)
  → 'github:numtide/flake-utils/c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a' (2024-09-17)
• Updated input 'nix-index-database':
    'github:Mic92/nix-index-database/ff80cb4a11bb87f3ce8459be6f16a25ac86eb2ac' (2024-05-27)
  → 'github:Mic92/nix-index-database/5fce10c871bab6d7d5ac9e5e7efbb3a2783f5259' (2024-10-07)
• Updated input 'nixos-hardware':
    'github:NixOS/nixos-hardware/7b49d3967613d9aacac5b340ef158d493906ba79' (2024-06-01)
  → 'github:NixOS/nixos-hardware/b7ca02c7565fbf6d27ff20dd6dbd49c5b82eef28' (2024-10-04)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/805a384895c696f802a9bf5bf4720f37385df547' (2024-05-31)
  → 'github:NixOS/nixpkgs/ecbc1ca8ffd6aea8372ad16be9ebbb39889e55b6' (2024-10-06)
• Updated input 'simple-nixos-mailserver':
    'gitlab:simple-nixos-mailserver/nixos-mailserver/29916981e7b3b5782dc5085ad18490113f8ff63b' (2024-06-11)
  → 'gitlab:simple-nixos-mailserver/nixos-mailserver/af7d3bf5daeba3fc28089b015c0dd43f06b176f2' (2024-08-05)
• Removed input 'simple-nixos-mailserver/utils'
2024-10-06 20:28:24 -06:00
e56271b2c3 Add reverse proxy for valetudo
All checks were successful
Check Flake / check-flake (push) Successful in 1m6s
2024-10-06 19:16:05 -06:00
f9ef5e4b89 Clean up 2024-10-06 17:15:25 -06:00
e516bd87b5 Fix VLANs 2024-10-06 17:11:58 -06:00
7c9c657bd0 Fix audio stuttering in wine/proton
See: https://old.reddit.com/r/linux_gaming/comments/11yp7ig/pipewire_audio_stuttering_when_playing_games_or/
2024-10-06 17:07:53 -06:00
dff7d65456 vscodium WGSL support 2024-10-06 17:06:28 -06:00
d269d2e5a0 Enable wayland support in chromium based apps 2024-07-17 21:42:43 -06:00
2527b614e9 vscodium rust dev support 2024-07-17 21:15:33 -06:00
528a53a606 Fix chromium acceleration and wayland support 2024-07-17 21:15:02 -06:00
66bfc62566 Refactor frigate config to add a bunch of features
All checks were successful
Check Flake / check-flake (push) Successful in 2h20m26s
- Enable vaapi GPU video encode/decode support
- Use go2rtc. This allows for watching high resolution camera feeds
- Split nix config into pieces that are easier to understand
- Add utilities for easily adding new cameras in the future
- misc changes
2024-06-30 12:49:26 -06:00
91874b9d53 Move frigate into it's own config file 2024-06-30 07:42:23 -06:00
50fc0a53d2 Enable more hass integrations 2024-06-29 10:13:46 -06:00
0b3322afda First VLAN camera in frigate 2024-06-29 10:13:03 -06:00
b32f6fa315 Enable memtest86 2024-06-29 10:12:11 -06:00
fe41ffc788 Allow s0 to access VLANs 2024-06-29 10:11:34 -06:00
eac443f280 Fix home assisstant
All checks were successful
Check Flake / check-flake (push) Successful in 1m7s
2024-06-21 23:26:30 -06:00
d557820d6c Lockdown intranet services behind tailscale 2024-06-21 21:04:49 -06:00
4d658e10d3 Make LibreChat's auth sessions last longer 2024-06-21 19:54:47 -06:00
9ac9613d67 Add gc cmd to makefile 2024-06-16 20:37:21 -06:00
e657ebb134 Clean up flake inputs 2024-06-16 12:47:29 -06:00
d1b07ec06b Add llsblk helper cmd alias 2024-06-16 12:10:39 -06:00
89621945f8 Fix zoidberg 2024-06-16 12:09:58 -06:00
e69fd5bf8f Use Firefox
All checks were successful
Check Flake / check-flake (push) Successful in 3m2s
2024-06-09 22:43:34 -06:00
c856b762e7 Goodbye Ray
All checks were successful
Check Flake / check-flake (push) Successful in 4m30s
2024-06-08 16:39:00 -06:00
b7f82f2d44 Consolidate common PC config
All checks were successful
Check Flake / check-flake (push) Successful in 1m14s
2024-06-03 21:07:53 -06:00
588e94dcf4 Update to NixOS 24.05
All checks were successful
Check Flake / check-flake (push) Successful in 1m11s
2024-06-02 21:12:07 -06:00
fd1ead0b62 Add nixos-hardware config for Howl 2024-06-01 19:57:24 -06:00
37bd7254b9 Add Howl
All checks were successful
Check Flake / check-flake (push) Successful in 1m54s
2024-05-31 23:29:39 -06:00
74e41de9d6 Enable unify v8 service
All checks were successful
Check Flake / check-flake (push) Successful in 56s
2024-05-26 17:24:46 -06:00
0bf0b8b88b Enable ollama service 2024-05-26 17:24:07 -06:00
702129d778 Enable CUDA support 2024-05-26 17:23:38 -06:00
88c67dde84 Open C&C ports 2024-05-26 17:21:58 -06:00
8e3a0761e8 Clean up 2024-05-26 17:21:34 -06:00
a785890990 Fix esphome so that it can build again 2024-05-26 17:20:05 -06:00
b482a8c106 Restore frigate functionality by reverting to an older tensorflow version for libedgetpu 2024-05-26 17:16:59 -06:00
efe50be604 Update nixpkgs
All checks were successful
Check Flake / check-flake (push) Successful in 53s
2024-03-17 09:39:54 -06:00
99904d0066 Update 'Actual' and 'Actual Server' to 'v24.3.0'
All checks were successful
Check Flake / check-flake (push) Successful in 14m33s
2024-03-03 14:57:23 -07:00
55e44bc3d0 Add 'tree' to system pkgs 2024-03-03 14:53:14 -07:00
da7ffa839b Blackhole spammed email address
All checks were successful
Check Flake / check-flake (push) Successful in 5m18s
2024-02-20 18:13:19 -07:00
01af25a57e Add Actual server
All checks were successful
Check Flake / check-flake (push) Successful in 6m3s
2024-02-19 19:44:07 -07:00
bfc1bb2da9 Use a makefile for utility snippets
All checks were successful
Check Flake / check-flake (push) Successful in 12m54s
2024-02-18 17:30:52 -07:00
0e59fa3518 Add easy boot configuration profile limit 2024-02-18 17:30:12 -07:00
7e812001f0 Add librechat
All checks were successful
Check Flake / check-flake (push) Successful in 6m12s
2024-02-09 19:57:09 -07:00
14c19b80ef Stop auto upgrade
All checks were successful
Check Flake / check-flake (push) Successful in 1m2s
2024-02-05 11:32:16 -07:00
e8dd0cb5ff Increase gitea session length
All checks were successful
Check Flake / check-flake (push) Successful in 2m17s
2024-02-04 15:48:06 -07:00
dc9f5e969a Update nextcloud
All checks were successful
Check Flake / check-flake (push) Successful in 2m48s
2024-02-04 14:34:42 -07:00
03150667b6 Enable gitea index and lfs. Fix warning.
All checks were successful
Check Flake / check-flake (push) Successful in 4m49s
2024-02-04 13:59:39 -07:00
1dfd7bc8a2 Increase seed ratio
All checks were successful
Check Flake / check-flake (push) Successful in 2m58s
2024-02-03 14:15:49 -07:00
fa649b1e2a Add missing locale settings to perl stops complaining
All checks were successful
Check Flake / check-flake (push) Successful in 12m4s
2024-02-03 14:11:26 -07:00
e34752c791 Fix transmission running in a container
https://github.com/NixOS/nixpkgs/issues/258793
2024-02-03 14:10:35 -07:00
75031567bd Two radio endpoints
All checks were successful
Check Flake / check-flake (push) Successful in 50s
2024-02-02 20:23:40 -07:00
800a95d431 Update to nixos 23.11
All checks were successful
Check Flake / check-flake (push) Successful in 1m24s
2024-02-01 21:42:33 -07:00
932b05a42e Basic oauth proxy for frigate
All checks were successful
Check Flake / check-flake (push) Successful in 1m13s
2024-01-30 22:12:18 -07:00
b5cc4d4609 Emulate ARM systems for building 2024-01-30 21:59:09 -07:00
ba3d15d82a PoC: Frigate + PCIe Coral + ESPCam, Home Assistant, ESPHome, MQTT, zigbee2mqtt
All checks were successful
Check Flake / check-flake (push) Successful in 3m24s
2023-12-17 21:29:45 -07:00
e80fb7b3db PoC: Frigate + PCIe Coral + ESPCam, Home Assistant, ESPHome, MQTT, zigbee2mqtt
Some checks failed
Check Flake / check-flake (push) Failing after 1m1s
2023-12-17 14:29:45 -07:00
84e1f6e573 wireless role was removed 2023-12-02 10:26:44 -07:00
c4847bd39b Use dashy for services homepage
All checks were successful
Check Flake / check-flake (push) Successful in 5m25s
2023-11-08 21:35:10 -07:00
c0c1ec5c67 Enable autologin for zoidberg 2023-11-08 21:34:13 -07:00
6739115cfb Fix sddm barrier service for current nixpkgs version 2023-11-08 21:33:38 -07:00
4606cc32ba Enable adb debugging 2023-11-08 21:32:26 -07:00
2d27bf7505 Allow other users to access public samba mount 2023-11-08 21:32:00 -07:00
d07af6d101 Should use tailscale eventually for remote luks unlocking 2023-11-08 21:31:14 -07:00
4890dc20e0 Add basic nix utilities
All checks were successful
Check Flake / check-flake (push) Successful in 2m21s
2023-10-20 20:13:08 -06:00
8b01a9b240 Use podman instead of docker 2023-10-20 20:12:14 -06:00
8dfba8646c Fix CI builder
All checks were successful
Check Flake / check-flake (push) Successful in 1m5s
2023-10-20 19:52:33 -06:00
63c0f52955 s0: use eth1
Some checks failed
Check Flake / check-flake (push) Failing after 9s
2023-10-16 20:21:00 -06:00
5413a8e7db Remove mounts that fail. These never worked 2023-10-16 20:20:32 -06:00
330c801e43 Fix issue where wg vpn starts slightly too early for internet access 2023-10-16 20:19:34 -06:00
8ba08ce982 Zoidberg move /boot device
Some checks failed
Check Flake / check-flake (push) Failing after 6m57s
2023-10-15 19:23:24 -06:00
2b50aeba93 Zoidberg auto login 2023-10-15 19:22:51 -06:00
c1aef574b1 Try to build only x84_64 for now
Some checks failed
Check Flake / check-flake (push) Failing after 8m22s
2023-10-15 19:09:40 -06:00
52ed25f1b9 Push derivations built during nix flake check to binary cache
Some checks failed
Check Flake / check-flake (push) Failing after 1m17s
2023-10-15 18:00:38 -06:00
0446d18712 Use official nixos module for gitea actions runner 2023-10-15 17:58:03 -06:00
d2bbbb827e Disable router 2023-10-15 17:55:44 -06:00
6fba594625 Target nixpkgs 23.05 2023-10-15 17:55:04 -06:00
184 changed files with 7237 additions and 3451 deletions

View File

@@ -0,0 +1,160 @@
---
name: create-workspace
description: >
Creates a new sandboxed workspace (isolated dev environment) by adding
NixOS configuration for a VM, container, or Incus instance. Use when
the user wants to create, set up, or add a new sandboxed workspace.
---
# Create Sandboxed Workspace
Creates an isolated development environment backed by a VM (microvm.nix), container (systemd-nspawn), or Incus instance. This produces:
1. A workspace config file at `machines/<machine>/workspaces/<name>.nix`
2. A registration entry in `machines/<machine>/default.nix`
## Step 1: Parse Arguments
Extract the workspace name and backend type from `$ARGUMENTS`. If either is missing, ask the user.
- **Name**: lowercase alphanumeric with hyphens (e.g., `my-project`)
- **Type**: one of `vm`, `container`, or `incus`
## Step 2: Detect Machine
Run `hostname` to get the current machine name. Verify that `machines/<hostname>/default.nix` exists.
If the machine directory doesn't exist, stop and tell the user this machine isn't managed by this flake.
## Step 3: Allocate IP Address
Read `machines/<hostname>/default.nix` to find existing `sandboxed-workspace.workspaces` entries and their IPs.
All IPs are in the `192.168.83.0/24` subnet. Use these ranges by convention:
| Type | IP Range |
|------|----------|
| vm | 192.168.83.10 - .49 |
| container | 192.168.83.50 - .89 |
| incus | 192.168.83.90 - .129 |
Pick the next available IP in the appropriate range. If no workspaces exist yet for that type, use the first IP in the range.
## Step 4: Create Workspace Config File
Create `machines/<hostname>/workspaces/<name>.nix`. Use this template:
```nix
{ config, lib, pkgs, ... }:
{
environment.systemPackages = with pkgs; [
# Add packages here
];
}
```
Ask the user if they want any packages pre-installed.
Create the `workspaces/` directory if it doesn't exist.
**Important:** After creating the file, run `git add` on it immediately. Nix flakes only see files tracked by git, so new files must be staged before `nix build` will work.
## Step 5: Register Workspace
Edit `machines/<hostname>/default.nix` to add the workspace entry inside the `sandboxed-workspace` block.
The entry should look like:
```nix
workspaces.<name> = {
type = "<type>";
config = ./workspaces/<name>.nix;
ip = "<allocated-ip>";
};
```
**If `sandboxed-workspace` block doesn't exist yet**, add the full block:
```nix
sandboxed-workspace = {
enable = true;
workspaces.<name> = {
type = "<type>";
config = ./workspaces/<name>.nix;
ip = "<allocated-ip>";
};
};
```
The machine also needs `networking.sandbox.upstreamInterface` set. Check if it exists; if not, ask the user for their primary network interface name (they can find it with `ip route show default`).
Do **not** set `hostKey` — it gets auto-generated on first boot and can be added later.
## Step 6: Verify Build
Run a build to check for configuration errors:
```
nix build .#nixosConfigurations.<hostname>.config.system.build.toplevel --no-link
```
If the build fails, fix the configuration and retry.
## Step 7: Deploy
Tell the user to deploy by running:
```
doas nixos-rebuild switch --flake .
```
**Never run this command yourself** — it requires privileges.
## Step 8: Post-Deploy Info
Tell the user to deploy and then start the workspace so the host key gets generated. Provide these instructions:
**Deploy:**
```
doas nixos-rebuild switch --flake .
```
**Starting the workspace:**
```
doas systemctl start <service>
```
Where `<service>` is:
- VM: `microvm@<name>`
- Container: `container@<name>`
- Incus: `incus-workspace-<name>`
Or use the auto-generated shell alias: `workspace_<name>_start`
**Connecting:**
```
ssh googlebot@workspace-<name>
```
Or use the alias: `workspace_<name>`
**Never run deploy or start commands yourself** — they require privileges.
## Step 9: Add Host Key
After the user has deployed and started the workspace, add the SSH host key to the workspace config. Do NOT skip this step — always wait for the user to confirm they've started the workspace, then proceed.
1. Read the host key from `~/sandboxed/<name>/ssh-host-keys/ssh_host_ed25519_key.pub`
2. Add `hostKey = "<contents>";` to the workspace entry in `machines/<hostname>/default.nix`
3. Run the build again to verify
4. Tell the user to redeploy with `doas nixos-rebuild switch --flake .`
## Backend Reference
| | VM | Container | Incus |
|---|---|---|---|
| Isolation | Full kernel (cloud-hypervisor) | Shared kernel (systemd-nspawn) | Unprivileged container |
| Overhead | Higher (separate kernel) | Lower (bind mounts) | Medium |
| Filesystem | virtiofs shares | Bind mounts | Incus-managed |
| Use case | Untrusted code, kernel-level isolation | Fast dev environments | Better security than nspawn |

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,357 @@
---
name: skill-creator
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.
license: Complete terms in LICENSE.txt
---
# Skill Creator
This skill provides guidance for creating effective skills.
## About Skills
Skills are modular, self-contained packages that extend Claude's capabilities by providing
specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific
domains or tasks—they transform Claude from a general-purpose agent into a specialized agent
equipped with procedural knowledge that no model can fully possess.
### What Skills Provide
1. Specialized workflows - Multi-step procedures for specific domains
2. Tool integrations - Instructions for working with specific file formats or APIs
3. Domain expertise - Company-specific knowledge, schemas, business logic
4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks
## Core Principles
### Concise is Key
The context window is a public good. Skills share the context window with everything else Claude needs: system prompt, conversation history, other Skills' metadata, and the actual user request.
**Default assumption: Claude is already very smart.** Only add context Claude doesn't already have. Challenge each piece of information: "Does Claude really need this explanation?" and "Does this paragraph justify its token cost?"
Prefer concise examples over verbose explanations.
### Set Appropriate Degrees of Freedom
Match the level of specificity to the task's fragility and variability:
**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach.
**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior.
**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed.
Think of Claude as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom).
### Anatomy of a Skill
Every skill consists of a required SKILL.md file and optional bundled resources:
```
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter metadata (required)
│ │ ├── name: (required)
│ │ ├── description: (required)
│ │ └── compatibility: (optional, rarely needed)
│ └── Markdown instructions (required)
└── Bundled Resources (optional)
├── scripts/ - Executable code (Python/Bash/etc.)
├── references/ - Documentation intended to be loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts, etc.)
```
#### SKILL.md (required)
Every SKILL.md consists of:
- **Frontmatter** (YAML): Contains `name` and `description` fields (required), plus optional fields like `license`, `metadata`, and `compatibility`. Only `name` and `description` are read by Claude to determine when the skill triggers, so be clear and comprehensive about what the skill is and when it should be used. The `compatibility` field is for noting environment requirements (target product, system packages, etc.) but most skills don't need it.
- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all).
#### Bundled Resources (optional)
##### Scripts (`scripts/`)
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments
##### References (`references/`)
Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking.
- **When to include**: For documentation that Claude should reference while working
- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications
- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed
- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md
- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
##### Assets (`assets/`)
Files not intended to be loaded into context, but rather used within the output Claude produces.
- **When to include**: When the skill needs files that will be used in the final output
- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography
- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context
#### What to Not Include in a Skill
A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including:
- README.md
- INSTALLATION_GUIDE.md
- QUICK_REFERENCE.md
- CHANGELOG.md
- etc.
The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxilary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion.
### Progressive Disclosure Design Principle
Skills use a three-level loading system to manage context efficiently:
1. **Metadata (name + description)** - Always in context (~100 words)
2. **SKILL.md body** - When skill triggers (<5k words)
3. **Bundled resources** - As needed by Claude (Unlimited because scripts can be executed without reading into context window)
#### Progressive Disclosure Patterns
Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them.
**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files.
**Pattern 1: High-level guide with references**
```markdown
# PDF Processing
## Quick start
Extract text with pdfplumber:
[code example]
## Advanced features
- **Form filling**: See [FORMS.md](FORMS.md) for complete guide
- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods
- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns
```
Claude loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed.
**Pattern 2: Domain-specific organization**
For Skills with multiple domains, organize content by domain to avoid loading irrelevant context:
```
bigquery-skill/
├── SKILL.md (overview and navigation)
└── reference/
├── finance.md (revenue, billing metrics)
├── sales.md (opportunities, pipeline)
├── product.md (API usage, features)
└── marketing.md (campaigns, attribution)
```
When a user asks about sales metrics, Claude only reads sales.md.
Similarly, for skills supporting multiple frameworks or variants, organize by variant:
```
cloud-deploy/
├── SKILL.md (workflow + provider selection)
└── references/
├── aws.md (AWS deployment patterns)
├── gcp.md (GCP deployment patterns)
└── azure.md (Azure deployment patterns)
```
When the user chooses AWS, Claude only reads aws.md.
**Pattern 3: Conditional details**
Show basic content, link to advanced content:
```markdown
# DOCX Processing
## Creating documents
Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md).
## Editing documents
For simple edits, modify the XML directly.
**For tracked changes**: See [REDLINING.md](REDLINING.md)
**For OOXML details**: See [OOXML.md](OOXML.md)
```
Claude reads REDLINING.md or OOXML.md only when the user needs those features.
**Important guidelines:**
- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md.
- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Claude can see the full scope when previewing.
## Skill Creation Process
Skill creation involves these steps:
1. Understand the skill with concrete examples
2. Plan reusable skill contents (scripts, references, assets)
3. Initialize the skill (run init_skill.py)
4. Edit the skill (implement resources and write SKILL.md)
5. Package the skill (run package_skill.py)
6. Iterate based on real usage
Follow these steps in order, skipping only if there is a clear reason why they are not applicable.
### Step 1: Understanding the Skill with Concrete Examples
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
For example, when building an image-editor skill, relevant questions include:
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
- "Can you give some examples of how this skill would be used?"
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
- "What would a user say that should trigger this skill?"
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
Conclude this step when there is a clear sense of the functionality the skill should support.
### Step 2: Planning the Reusable Skill Contents
To turn concrete examples into an effective skill, analyze each example by:
1. Considering how to execute on the example from scratch
2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
1. Rotating a PDF requires re-writing the same code each time
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
1. Writing a frontend webapp requires the same boilerplate HTML/React each time
2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill
Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows:
1. Querying BigQuery requires re-discovering the table schemas and relationships each time
2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
### Step 3: Initializing the Skill
At this point, it is time to actually create the skill.
Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
Usage:
```bash
scripts/init_skill.py <skill-name> --path <output-directory>
```
The script:
- Creates the skill directory at the specified path
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
- Creates example resource directories: `scripts/`, `references/`, and `assets/`
- Adds example files in each directory that can be customized or deleted
After initialization, customize or remove the generated SKILL.md and example files as needed.
### Step 4: Edit the Skill
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Include information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively.
#### Learn Proven Design Patterns
Consult these helpful guides based on your skill's needs:
- **Multi-step processes**: See references/workflows.md for sequential workflows and conditional logic
- **Specific output formats or quality standards**: See references/output-patterns.md for template and example patterns
These files contain established best practices for effective skill design.
#### Start with Reusable Skill Contents
To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.
Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion.
Any example files and directories not needed for the skill should be deleted. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them.
#### Update SKILL.md
**Writing Guidelines:** Always use imperative/infinitive form.
##### Frontmatter
Write the YAML frontmatter with `name` and `description`:
- `name`: The skill name
- `description`: This is the primary triggering mechanism for your skill, and helps Claude understand when to use the skill.
- Include both what the Skill does and specific triggers/contexts for when to use it.
- Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Claude.
- Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
Do not include any other fields in YAML frontmatter.
##### Body
Write instructions for using the skill and its bundled resources.
### Step 5: Packaging a Skill
Once development of the skill is complete, it must be packaged into a distributable .skill file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:
```bash
scripts/package_skill.py <path/to/skill-folder>
```
Optional output directory specification:
```bash
scripts/package_skill.py <path/to/skill-folder> ./dist
```
The packaging script will:
1. **Validate** the skill automatically, checking:
- YAML frontmatter format and required fields
- Skill naming conventions and directory structure
- Description completeness and quality
- File organization and resource references
2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension.
If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.
### Step 6: Iterate
After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.
**Iteration workflow:**
1. Use the skill on real tasks
2. Notice struggles or inefficiencies
3. Identify how SKILL.md or bundled resources should be updated
4. Implement changes and test again

View File

@@ -0,0 +1,82 @@
# Output Patterns
Use these patterns when skills need to produce consistent, high-quality output.
## Template Pattern
Provide templates for output format. Match the level of strictness to your needs.
**For strict requirements (like API responses or data formats):**
```markdown
## Report structure
ALWAYS use this exact template structure:
# [Analysis Title]
## Executive summary
[One-paragraph overview of key findings]
## Key findings
- Finding 1 with supporting data
- Finding 2 with supporting data
- Finding 3 with supporting data
## Recommendations
1. Specific actionable recommendation
2. Specific actionable recommendation
```
**For flexible guidance (when adaptation is useful):**
```markdown
## Report structure
Here is a sensible default format, but use your best judgment:
# [Analysis Title]
## Executive summary
[Overview]
## Key findings
[Adapt sections based on what you discover]
## Recommendations
[Tailor to the specific context]
Adjust sections as needed for the specific analysis type.
```
## Examples Pattern
For skills where output quality depends on seeing examples, provide input/output pairs:
```markdown
## Commit message format
Generate commit messages following these examples:
**Example 1:**
Input: Added user authentication with JWT tokens
Output:
```
feat(auth): implement JWT-based authentication
Add login endpoint and token validation middleware
```
**Example 2:**
Input: Fixed bug where dates displayed incorrectly in reports
Output:
```
fix(reports): correct date formatting in timezone conversion
Use UTC timestamps consistently across report generation
```
Follow this style: type(scope): brief description, then detailed explanation.
```
Examples help Claude understand the desired style and level of detail more clearly than descriptions alone.

View File

@@ -0,0 +1,28 @@
# Workflow Patterns
## Sequential Workflows
For complex tasks, break operations into clear, sequential steps. It is often helpful to give Claude an overview of the process towards the beginning of SKILL.md:
```markdown
Filling a PDF form involves these steps:
1. Analyze the form (run analyze_form.py)
2. Create field mapping (edit fields.json)
3. Validate mapping (run validate_fields.py)
4. Fill the form (run fill_form.py)
5. Verify output (run verify_output.py)
```
## Conditional Workflows
For tasks with branching logic, guide Claude through decision points:
```markdown
1. Determine the modification type:
**Creating new content?** → Follow "Creation workflow" below
**Editing existing content?** → Follow "Editing workflow" below
2. Creation workflow: [steps]
3. Editing workflow: [steps]
```

View File

@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""
Skill Initializer - Creates a new skill from template
Usage:
init_skill.py <skill-name> --path <path>
Examples:
init_skill.py my-new-skill --path skills/public
init_skill.py my-api-helper --path skills/private
init_skill.py custom-skill --path /custom/location
"""
import sys
from pathlib import Path
SKILL_TEMPLATE = """---
name: {skill_name}
description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
---
# {skill_title}
## Overview
[TODO: 1-2 sentences explaining what this skill enables]
## Structuring This Skill
[TODO: Choose the structure that best fits this skill's purpose. Common patterns:
**1. Workflow-Based** (best for sequential processes)
- Works well when there are clear step-by-step procedures
- Example: DOCX skill with "Workflow Decision Tree""Reading""Creating""Editing"
- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2...
**2. Task-Based** (best for tool collections)
- Works well when the skill offers different operations/capabilities
- Example: PDF skill with "Quick Start""Merge PDFs""Split PDFs""Extract Text"
- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2...
**3. Reference/Guidelines** (best for standards or specifications)
- Works well for brand guidelines, coding standards, or requirements
- Example: Brand styling with "Brand Guidelines""Colors""Typography""Features"
- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage...
**4. Capabilities-Based** (best for integrated systems)
- Works well when the skill provides multiple interrelated features
- Example: Product Management with "Core Capabilities" → numbered capability list
- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature...
Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations).
Delete this entire "Structuring This Skill" section when done - it's just guidance.]
## [TODO: Replace with the first main section based on chosen structure]
[TODO: Add content here. See examples in existing skills:
- Code samples for technical skills
- Decision trees for complex workflows
- Concrete examples with realistic user requests
- References to scripts/templates/references as needed]
## Resources
This skill includes example resource directories that demonstrate how to organize different types of bundled resources:
### scripts/
Executable code (Python/Bash/etc.) that can be run directly to perform specific operations.
**Examples from other skills:**
- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation
- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing
**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations.
**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments.
### references/
Documentation and reference material intended to be loaded into context to inform Claude's process and thinking.
**Examples from other skills:**
- Product management: `communication.md`, `context_building.md` - detailed workflow guides
- BigQuery: API reference documentation and query examples
- Finance: Schema documentation, company policies
**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working.
### assets/
Files not intended to be loaded into context, but rather used within the output Claude produces.
**Examples from other skills:**
- Brand styling: PowerPoint template files (.pptx), logo files
- Frontend builder: HTML/React boilerplate project directories
- Typography: Font files (.ttf, .woff2)
**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
---
**Any unneeded directories can be deleted.** Not every skill requires all three types of resources.
"""
EXAMPLE_SCRIPT = '''#!/usr/bin/env python3
"""
Example helper script for {skill_name}
This is a placeholder script that can be executed directly.
Replace with actual implementation or delete if not needed.
Example real scripts from other skills:
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
"""
def main():
print("This is an example script for {skill_name}")
# TODO: Add actual script logic here
# This could be data processing, file conversion, API calls, etc.
if __name__ == "__main__":
main()
'''
EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title}
This is a placeholder for detailed reference documentation.
Replace with actual reference content or delete if not needed.
Example real reference docs from other skills:
- product-management/references/communication.md - Comprehensive guide for status updates
- product-management/references/context_building.md - Deep-dive on gathering context
- bigquery/references/ - API references and query examples
## When Reference Docs Are Useful
Reference docs are ideal for:
- Comprehensive API documentation
- Detailed workflow guides
- Complex multi-step processes
- Information too lengthy for main SKILL.md
- Content that's only needed for specific use cases
## Structure Suggestions
### API Reference Example
- Overview
- Authentication
- Endpoints with examples
- Error codes
- Rate limits
### Workflow Guide Example
- Prerequisites
- Step-by-step instructions
- Common patterns
- Troubleshooting
- Best practices
"""
EXAMPLE_ASSET = """# Example Asset File
This placeholder represents where asset files would be stored.
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
Asset files are NOT intended to be loaded into context, but rather used within
the output Claude produces.
Example asset files from other skills:
- Brand guidelines: logo.png, slides_template.pptx
- Frontend builder: hello-world/ directory with HTML/React boilerplate
- Typography: custom-font.ttf, font-family.woff2
- Data: sample_data.csv, test_dataset.json
## Common Asset Types
- Templates: .pptx, .docx, boilerplate directories
- Images: .png, .jpg, .svg, .gif
- Fonts: .ttf, .otf, .woff, .woff2
- Boilerplate code: Project directories, starter files
- Icons: .ico, .svg
- Data files: .csv, .json, .xml, .yaml
Note: This is a text placeholder. Actual assets can be any file type.
"""
def title_case_skill_name(skill_name):
"""Convert hyphenated skill name to Title Case for display."""
return ' '.join(word.capitalize() for word in skill_name.split('-'))
def init_skill(skill_name, path):
"""
Initialize a new skill directory with template SKILL.md.
Args:
skill_name: Name of the skill
path: Path where the skill directory should be created
Returns:
Path to created skill directory, or None if error
"""
# Determine skill directory path
skill_dir = Path(path).resolve() / skill_name
# Check if directory already exists
if skill_dir.exists():
print(f"❌ Error: Skill directory already exists: {skill_dir}")
return None
# Create skill directory
try:
skill_dir.mkdir(parents=True, exist_ok=False)
print(f"✅ Created skill directory: {skill_dir}")
except Exception as e:
print(f"❌ Error creating directory: {e}")
return None
# Create SKILL.md from template
skill_title = title_case_skill_name(skill_name)
skill_content = SKILL_TEMPLATE.format(
skill_name=skill_name,
skill_title=skill_title
)
skill_md_path = skill_dir / 'SKILL.md'
try:
skill_md_path.write_text(skill_content)
print("✅ Created SKILL.md")
except Exception as e:
print(f"❌ Error creating SKILL.md: {e}")
return None
# Create resource directories with example files
try:
# Create scripts/ directory with example script
scripts_dir = skill_dir / 'scripts'
scripts_dir.mkdir(exist_ok=True)
example_script = scripts_dir / 'example.py'
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
example_script.chmod(0o755)
print("✅ Created scripts/example.py")
# Create references/ directory with example reference doc
references_dir = skill_dir / 'references'
references_dir.mkdir(exist_ok=True)
example_reference = references_dir / 'api_reference.md'
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
print("✅ Created references/api_reference.md")
# Create assets/ directory with example asset placeholder
assets_dir = skill_dir / 'assets'
assets_dir.mkdir(exist_ok=True)
example_asset = assets_dir / 'example_asset.txt'
example_asset.write_text(EXAMPLE_ASSET)
print("✅ Created assets/example_asset.txt")
except Exception as e:
print(f"❌ Error creating resource directories: {e}")
return None
# Print next steps
print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}")
print("\nNext steps:")
print("1. Edit SKILL.md to complete the TODO items and update the description")
print("2. Customize or delete the example files in scripts/, references/, and assets/")
print("3. Run the validator when ready to check the skill structure")
return skill_dir
def main():
if len(sys.argv) < 4 or sys.argv[2] != '--path':
print("Usage: init_skill.py <skill-name> --path <path>")
print("\nSkill name requirements:")
print(" - Kebab-case identifier (e.g., 'my-data-analyzer')")
print(" - Lowercase letters, digits, and hyphens only")
print(" - Max 64 characters")
print(" - Must match directory name exactly")
print("\nExamples:")
print(" init_skill.py my-new-skill --path skills/public")
print(" init_skill.py my-api-helper --path skills/private")
print(" init_skill.py custom-skill --path /custom/location")
sys.exit(1)
skill_name = sys.argv[1]
path = sys.argv[3]
print(f"🚀 Initializing skill: {skill_name}")
print(f" Location: {path}")
print()
result = init_skill(skill_name, path)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Skill Packager - Creates a distributable .skill file of a skill folder
Usage:
python utils/package_skill.py <path/to/skill-folder> [output-directory]
Example:
python utils/package_skill.py skills/public/my-skill
python utils/package_skill.py skills/public/my-skill ./dist
"""
import sys
import zipfile
from pathlib import Path
from quick_validate import validate_skill
def package_skill(skill_path, output_dir=None):
"""
Package a skill folder into a .skill file.
Args:
skill_path: Path to the skill folder
output_dir: Optional output directory for the .skill file (defaults to current directory)
Returns:
Path to the created .skill file, or None if error
"""
skill_path = Path(skill_path).resolve()
# Validate skill folder exists
if not skill_path.exists():
print(f"❌ Error: Skill folder not found: {skill_path}")
return None
if not skill_path.is_dir():
print(f"❌ Error: Path is not a directory: {skill_path}")
return None
# Validate SKILL.md exists
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
print(f"❌ Error: SKILL.md not found in {skill_path}")
return None
# Run validation before packaging
print("🔍 Validating skill...")
valid, message = validate_skill(skill_path)
if not valid:
print(f"❌ Validation failed: {message}")
print(" Please fix the validation errors before packaging.")
return None
print(f"{message}\n")
# Determine output location
skill_name = skill_path.name
if output_dir:
output_path = Path(output_dir).resolve()
output_path.mkdir(parents=True, exist_ok=True)
else:
output_path = Path.cwd()
skill_filename = output_path / f"{skill_name}.skill"
# Create the .skill file (zip format)
try:
with zipfile.ZipFile(skill_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Walk through the skill directory
for file_path in skill_path.rglob('*'):
if file_path.is_file():
# Calculate the relative path within the zip
arcname = file_path.relative_to(skill_path.parent)
zipf.write(file_path, arcname)
print(f" Added: {arcname}")
print(f"\n✅ Successfully packaged skill to: {skill_filename}")
return skill_filename
except Exception as e:
print(f"❌ Error creating .skill file: {e}")
return None
def main():
if len(sys.argv) < 2:
print("Usage: python utils/package_skill.py <path/to/skill-folder> [output-directory]")
print("\nExample:")
print(" python utils/package_skill.py skills/public/my-skill")
print(" python utils/package_skill.py skills/public/my-skill ./dist")
sys.exit(1)
skill_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
print(f"📦 Packaging skill: {skill_path}")
if output_dir:
print(f" Output directory: {output_dir}")
print()
result = package_skill(skill_path, output_dir)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
Quick validation script for skills - minimal version
"""
import sys
import os
import re
import yaml
from pathlib import Path
def validate_skill(skill_path):
"""Basic validation of a skill"""
skill_path = Path(skill_path)
# Check SKILL.md exists
skill_md = skill_path / 'SKILL.md'
if not skill_md.exists():
return False, "SKILL.md not found"
# Read and validate frontmatter
content = skill_md.read_text()
if not content.startswith('---'):
return False, "No YAML frontmatter found"
# Extract frontmatter
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not match:
return False, "Invalid frontmatter format"
frontmatter_text = match.group(1)
# Parse YAML frontmatter
try:
frontmatter = yaml.safe_load(frontmatter_text)
if not isinstance(frontmatter, dict):
return False, "Frontmatter must be a YAML dictionary"
except yaml.YAMLError as e:
return False, f"Invalid YAML in frontmatter: {e}"
# Define allowed properties
ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata', 'compatibility'}
# Check for unexpected properties (excluding nested keys under metadata)
unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES
if unexpected_keys:
return False, (
f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. "
f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}"
)
# Check required fields
if 'name' not in frontmatter:
return False, "Missing 'name' in frontmatter"
if 'description' not in frontmatter:
return False, "Missing 'description' in frontmatter"
# Extract name for validation
name = frontmatter.get('name', '')
if not isinstance(name, str):
return False, f"Name must be a string, got {type(name).__name__}"
name = name.strip()
if name:
# Check naming convention (kebab-case: lowercase with hyphens)
if not re.match(r'^[a-z0-9-]+$', name):
return False, f"Name '{name}' should be kebab-case (lowercase letters, digits, and hyphens only)"
if name.startswith('-') or name.endswith('-') or '--' in name:
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens"
# Check name length (max 64 characters per spec)
if len(name) > 64:
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters."
# Extract and validate description
description = frontmatter.get('description', '')
if not isinstance(description, str):
return False, f"Description must be a string, got {type(description).__name__}"
description = description.strip()
if description:
# Check for angle brackets
if '<' in description or '>' in description:
return False, "Description cannot contain angle brackets (< or >)"
# Check description length (max 1024 characters per spec)
if len(description) > 1024:
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters."
# Validate compatibility field if present (optional)
compatibility = frontmatter.get('compatibility', '')
if compatibility:
if not isinstance(compatibility, str):
return False, f"Compatibility must be a string, got {type(compatibility).__name__}"
if len(compatibility) > 500:
return False, f"Compatibility is too long ({len(compatibility)} characters). Maximum is 500 characters."
return True, "Skill is valid!"
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python quick_validate.py <skill_directory>")
sys.exit(1)
valid, message = validate_skill(sys.argv[1])
print(message)
sys.exit(0 if valid else 1)

View File

@@ -0,0 +1,46 @@
---
name: update-flake
description: Update nix flake inputs to latest versions, fix build breakage from upstream changes, build all NixOS machines, and run garbage collection. Use when the user wants to update nixpkgs, update flake inputs, upgrade packages, or refresh the flake lockfile.
---
# Update Flake
## Workflow
### 1. Update All Inputs
The flake tracks `nixos-unstable`. Update everything at once:
```bash
nix flake update
```
### 2. Check for Breakage and Fix
Build errors typically fall into these categories:
- **Patches failing to apply**: Check `patches/` directory. Rebase or remove patches if the upstream issue was fixed.
- **Nextcloud version bump**: Check `common/server/nextcloud.nix` for the pinned version (e.g. `pkgs.nextcloud32`). If nixpkgs dropped the current version, upgrade by exactly ONE major version. Notify the user.
- **Removed/renamed packages or options**: Search nixpkgs history for migration guidance and apply fixes.
- **stateVersion changes**: If ANY change requires incrementing `system.stateVersion`, `home.stateVersion`, or data migration — STOP and ask the user. Do not proceed.
### 3. Build All Machines
See [references/machines.md](references/machines.md). Get machine list and build each:
```bash
nix eval .#nixosConfigurations --apply 'x: builtins.attrNames x' --json
nix build .#nixosConfigurations.<hostname>.config.system.build.toplevel --no-link
```
Fix any build failures before continuing.
### 4. Garbage Collection
```bash
nix store gc
```
### 5. Summary
Report: inputs updated, fixes applied, nextcloud changes, and anything needing user attention.

View File

@@ -0,0 +1,19 @@
# Machine Build Reference
## Listing Machines
Get the current list dynamically:
```bash
nix eval .#nixosConfigurations --apply 'x: builtins.attrNames x' --json
```
## Building a Machine
```bash
nix build .#nixosConfigurations.<hostname>.config.system.build.toplevel --no-link
```
## Important Constraints
- **stateVersion**: If any update requires incrementing `system.stateVersion` or `home.stateVersion`, or any data migration, STOP and ask the user. Do not proceed on your own.
- **nextcloud**: Pinned to a specific version (e.g. `pkgs.nextcloud32`). Only upgrade one major version at a time. Notify the user when upgrading.

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
# Configure Attic cache
attic login local "$ATTIC_ENDPOINT" "$ATTIC_TOKEN"
attic use local:nixos
# Check flake
nix flake check --all-systems --print-build-logs --log-format raw --show-trace
# Build all systems
nix eval .#nixosConfigurations --apply 'cs: builtins.attrNames cs' --json \
| jq -r '.[]' \
| xargs -I{} nix build ".#nixosConfigurations.{}.config.system.build.toplevel" \
--no-link --print-build-logs --log-format raw
# Push to cache (only locally-built paths >= 0.5MB)
toplevels=$(nix eval .#nixosConfigurations \
--apply 'cs: map (n: "${cs.${n}.config.system.build.toplevel}") (builtins.attrNames cs)' \
--json | jq -r '.[]')
echo "Found $(echo "$toplevels" | wc -l) system toplevels"
paths=$(echo "$toplevels" \
| xargs nix path-info -r --json \
| jq -r '[to_entries[] | select(
(.value.signatures | all(startswith("cache.nixos.org") | not))
and .value.narSize >= 524288
) | .key] | unique[]')
echo "Pushing $(echo "$paths" | wc -l) unique paths to cache"
echo "$paths" | xargs attic push local:nixos

View File

@@ -0,0 +1,60 @@
name: Auto Update Flake
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch: {}
env:
DEBIAN_FRONTEND: noninteractive
PATH: /run/current-system/sw/bin/
XDG_CONFIG_HOME: ${{ runner.temp }}/.config
ATTIC_ENDPOINT: ${{ vars.ATTIC_ENDPOINT }}
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
jobs:
auto-update:
runs-on: nixos
steps:
- name: Checkout the repository
uses: actions/checkout@v3
with:
fetch-depth: 0
ref: master
token: ${{ secrets.PUSH_TOKEN }}
- name: Configure git identity
run: |
git config user.name "gitea-runner"
git config user.email "gitea-runner@neet.dev"
- name: Update flake inputs
id: update
run: |
nix flake update
if git diff --quiet flake.lock; then
echo "No changes to flake.lock, nothing to do"
echo "changed=false" >> "$GITHUB_OUTPUT"
else
git add flake.lock
git commit -m "flake.lock: update inputs"
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Build and cache
if: steps.update.outputs.changed == 'true'
run: bash .gitea/scripts/build-and-cache.sh
- name: Push updated lockfile
if: steps.update.outputs.changed == 'true'
run: git push
- name: Notify on failure
if: failure() && steps.update.outputs.changed == 'true'
run: |
curl -s \
-H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" \
-H "Title: Flake auto-update failed" \
-H "Priority: high" \
-H "Tags: warning" \
-d "Auto-update workflow failed. Check: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }}" \
https://ntfy.neet.dev/nix-flake-updates

View File

@@ -4,35 +4,30 @@ on: [push]
env:
DEBIAN_FRONTEND: noninteractive
PATH: /run/current-system/sw/bin/:/nix/var/nix/profiles/per-user/gitea-runner/profile/bin
# defaults:
# run:
# shell: nix shell nixpkgs#nodejs-18_x
PATH: /run/current-system/sw/bin/
XDG_CONFIG_HOME: ${{ runner.temp }}/.config
ATTIC_ENDPOINT: ${{ vars.ATTIC_ENDPOINT }}
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
jobs:
check-flake:
runs-on: nixos
steps:
# - run: node --version
# - name: Install basic dependencies
# run: apt-get update && apt-get install -y --no-install-recommends sudo curl ca-certificates xz-utils
# - name: Install Nix
# uses: https://github.com/cachix/install-nix-action@v20
# with:
# github_access_token: ${{ secrets.__GITHUB_TOKEN }}
- name: Install dependencies
run: nix profile install nixpkgs#nodejs-18_x
- name: Checkout the repository
uses: actions/checkout@v3
with:
fetch-depth: 0
# - name: Get ENV var names
# run: printenv | cut -d'=' -f1
- name: Build and cache
run: bash .gitea/scripts/build-and-cache.sh
- name: Check Flake
run: nix flake check --show-trace
- name: Notify on failure
if: failure()
run: |
curl -s \
-H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" \
-H "Title: Flake check failed" \
-H "Priority: high" \
-H "Tags: warning" \
-d "Check failed for ${{ gitea.ref_name }}. Check: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }}" \
https://ntfy.neet.dev/nix-flake-updates

1
.gitignore vendored
View File

@@ -1 +1,2 @@
result
.claude/worktrees

101
CLAUDE.md Normal file
View File

@@ -0,0 +1,101 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What This Is
A NixOS flake managing multiple machines. All machines import `/common` for shared config, and each machine has its own directory under `/machines/<hostname>/` with a `default.nix` (machine-specific config), `hardware-configuration.nix`, and `properties.nix` (metadata: hostnames, arch, roles, SSH keys).
## Common Commands
```bash
# Build a machine config (check for errors without deploying)
nix build .#nixosConfigurations.<hostname>.config.system.build.toplevel --no-link
# Deploy to local machine (user must run this themselves - requires privileges)
doas nixos-rebuild switch --flake .
# Deploy to a remote machine (boot-only, no activate)
deploy --remote-build --boot --debug-logs --skip-checks .#<hostname>
# Deploy to a remote machine (activate immediately)
deploy --remote-build --debug-logs --skip-checks .#<hostname>
# Update flake lockfile
make update-lockfile
# Update a single flake input
make update-input <input-name>
# Edit an agenix secret
make edit-secret <secret-filename>
# Rekey all secrets (after adding/removing machine host keys)
make rekey-secrets
```
## Architecture
### Machine Discovery (Auto-Registration)
Machines are **not** listed in `flake.nix`. Instead, `common/machine-info/default.nix` recursively scans `/machines/` for any `properties.nix` file and auto-registers that directory as a machine. To add a machine, create `machines/<name>/properties.nix` and `machines/<name>/default.nix`.
`properties.nix` returns a plain attrset (no NixOS module args) with: `hostNames`, `arch`, `systemRoles`, `hostKey`, and optionally `userKeys`, `deployKeys`, `remoteUnlock`.
### Role System
Each machine declares `systemRoles` in its `properties.nix` (e.g., `["personal" "dns-challenge"]`). Roles drive conditional config:
- `config.thisMachine.hasRole.<role>` - boolean, used to conditionally enable features (e.g., `de.enable` for `personal` role)
- `config.machines.withRole.<role>` - list of hostnames with that role
- Roles also determine which machines can decrypt which agenix secrets (see `secrets/secrets.nix`)
### Secrets (agenix)
Secrets in `/secrets/` are encrypted `.age` files. `secrets.nix` maps each secret to the SSH host keys (by role) that can decrypt it. After changing which machines have access, run `make rekey-secrets`.
### Sandboxed Workspaces
`common/sandboxed-workspace/` provides isolated dev environments. Three backends: `vm` (microvm/cloud-hypervisor), `container` (systemd-nspawn), `incus`. Workspaces are defined in machine `default.nix` files and their per-workspace config goes in `machines/<hostname>/workspaces/<name>.nix`. The base config (`base.nix`) handles networking, SSH, user setup, and Claude Code pre-configuration.
IP allocation convention: VMs `.10-.49`, containers `.50-.89`, incus `.90-.129` in `192.168.83.0/24`.
### Backups
`common/backups.nix` defines a `backup.group` option. Machines declare backup groups with paths; restic handles daily backups to Backblaze B2 with automatic ZFS/btrfs snapshot support. Each group gets a `restic_<group>` CLI wrapper for manual operations.
### Nixpkgs Patching
`flake.nix` applies patches from `/patches/` to nixpkgs before building (workaround for nix#3920).
### Service Dashboard & Monitoring
When adding or removing a web-facing service, update both:
- **Gatus** (`common/server/gatus.nix`) — add/remove the endpoint monitor
- **Dashy** — add/remove the service entry from the dashboard config
### Key Conventions
- Uses `doas` instead of `sudo` everywhere
- Fish shell is the default user shell
- Home Manager is used for user-level config (`home/googlebot.nix`)
- `lib/default.nix` extends nixpkgs lib with custom utility functions (extends via `nixpkgs.lib.extend`)
- Overlays are in `/overlays/` and applied globally via `flake.nix`
- The Nix formatter for this project is `nixpkgs-fmt`
- Do not add "Co-Authored-By" lines to commit messages
- Always use `--no-link` when running `nix build`
- Don't use `nix build --dry-run` unless you only need evaluation — it skips the actual build
- Avoid `2>&1` on nix commands — it can cause error output to be missed
## Git Worktrees
When the user asks you to "start a worktree" or work in a worktree, **do not create one manually** with `git worktree add`. Instead, tell the user to start a new session with:
```bash
claude --worktree <name>
```
This is the built-in Claude Code worktree workflow. It creates the worktree at `.claude/worktrees/<name>/` with a branch `worktree-<name>` and starts a new Claude session inside it. Cleanup is handled automatically on exit.
When instructed to work in a git worktree (e.g., via `isolation: "worktree"` on a subagent), you **MUST** do so. If you are unable to create or use a git worktree, you **MUST** stop work immediately and report the failure to the user. Do not fall back to working in the main working tree.
When applying work from a git worktree back to the main branch, commit in the worktree first, then use `git cherry-pick` from the main working tree to bring the commit over. Do not use `git checkout` or `git apply` to copy files directly. Do **not** automatically apply worktree work to the main branch — always ask the user for approval first.

52
Makefile Normal file
View File

@@ -0,0 +1,52 @@
# Lockfile utils
.PHONY: update-lockfile
update-lockfile:
nix flake update --commit-lock-file
.PHONY: update-lockfile-without-commit
update-lockfile-without-commit:
nix flake update
# Agenix utils
.PHONY: edit-secret
edit-secret:
cd secrets && agenix -e $(filter-out $@,$(MAKECMDGOALS))
.PHONY: rekey-secrets
rekey-secrets:
cd secrets && agenix -r
# NixOS utils
.PHONY: clean-old-nixos-profiles
clean-old-nixos-profiles:
doas nix-collect-garbage -d
# Garbage Collect
.PHONY: gc
gc:
nix store gc
# Update a flake input by name (ex: 'nixpkgs')
.PHONY: update-input
update-input:
nix flake update $(filter-out $@,$(MAKECMDGOALS))
# Build Custom Install ISO
.PHONY: iso
iso:
nix build .#packages.x86_64-linux.iso
# Build Custom kexec image
.PHONY: kexec-img
kexec-img:
nix build .#packages.x86_64-linux.kexec
# Deploy a host by name (ex: 's0') but don't activate
.PHONY: deploy
deploy:
deploy --remote-build --boot --debug-logs --skip-checks .#$(filter-out $@,$(MAKECMDGOALS))
# Deploy a host by name (ex: 's0')
.PHONY: deploy-activate
deploy-activate:
deploy --remote-build --debug-logs --skip-checks .#$(filter-out $@,$(MAKECMDGOALS))

View File

@@ -1,11 +1,32 @@
# My NixOS configurations
# NixOS Configuration
### Source Layout
- `/common` - common configuration imported into all `/machines`
- `/boot` - config related to bootloaders, cpu microcode, and unlocking LUKS root disks over tor
- `/network` - config for tailscale, and NixOS container with automatic vpn tunneling via PIA
- `/pc` - config that a graphical desktop computer should have. Use `de.enable = true;` to enable everthing.
- `/server` - config that creates new nixos services or extends existing ones to meet my needs
- `/machines` - all my NixOS machines along with their machine unique configuration for hardware and services
- `/kexec` - a special machine for generating minimal kexec images. Does not import `/common`
- `/secrets` - encrypted shared secrets unlocked through `/machines` ssh host keys
A NixOS flake managing multiple machines with role-based configuration, agenix secrets, and sandboxed dev workspaces.
## Layout
- `/common` - shared configuration imported by all machines
- `/boot` - bootloaders, CPU microcode, remote LUKS unlock over Tor
- `/network` - Tailscale, VPN tunneling via PIA
- `/pc` - desktop/graphical config (enabled by the `personal` role)
- `/server` - service definitions and extensions
- `/sandboxed-workspace` - isolated dev environments (VM, container, or Incus)
- `/machines` - per-machine config (`default.nix`, `hardware-configuration.nix`, `properties.nix`)
- `/secrets` - agenix-encrypted secrets, decryptable by machines based on their roles
- `/home` - Home Manager user config
- `/lib` - custom library functions extending nixpkgs lib
- `/overlays` - nixpkgs overlays applied globally
- `/patches` - patches applied to nixpkgs at build time
## Notable Features
**Auto-discovery & roles** — Machines register themselves by placing a `properties.nix` under `/machines/`. No manual listing in `flake.nix`. Roles declared per-machine (`"personal"`, `"dns-challenge"`, etc.) drive feature enablement via `config.thisMachine.hasRole.<role>` and control which agenix secrets each machine can decrypt.
**Machine properties module system**`properties.nix` files form a separate lightweight module system (`machine-info`) for recording machine metadata (hostnames, architecture, roles, SSH keys). Since every machine's properties are visible to every other machine, each system can reflect on the properties of the entire fleet — enabling automatic SSH trust, role-based secret access, and cross-machine coordination without duplicating information.
**Remote LUKS unlock over Tor** — Machines with encrypted root disks can be unlocked remotely via SSH. An embedded Tor hidden service starts in the initrd so the machine is reachable even without a known IP, using a separate SSH host key for the boot environment.
**VPN containers** — A `vpn-container` module spins up an ephemeral NixOS container with a PIA WireGuard tunnel. The host creates the WireGuard interface and authenticates with PIA, then hands it off to the container's network namespace. This ensures that the container can **never** have direct internet access. Leakage is impossible.
**Sandboxed workspaces** — Isolated dev environments backed by microVMs (cloud-hypervisor), systemd-nspawn containers, or Incus. Each workspace gets a static IP on a NAT'd bridge, auto-generated SSH host keys, shell aliases for management, and comes pre-configured with Claude Code. The sandbox network blocks access to the local LAN while allowing internet.
**Snapshot-aware backups** — Restic backups to Backblaze B2 automatically create ZFS snapshots or btrfs read-only snapshots before backing up, using mount namespaces to bind-mount frozen data over the original paths so restic records correct paths. Each backup group gets a `restic_<group>` CLI wrapper. Supports `.nobackup` marker files.

View File

@@ -2,13 +2,101 @@
let
cfg = config.backup;
hostname = config.networking.hostName;
mkRespository = group: "s3:s3.us-west-004.backblazeb2.com/D22TgIt0-main-backup/${group}";
findmnt = "${pkgs.util-linux}/bin/findmnt";
mount = "${pkgs.util-linux}/bin/mount";
umount = "${pkgs.util-linux}/bin/umount";
btrfs = "${pkgs.btrfs-progs}/bin/btrfs";
zfs = "/run/current-system/sw/bin/zfs";
# Creates snapshots and bind mounts them over original paths within the
# service's mount namespace, so restic sees correct paths but reads frozen data
snapshotHelperFn = ''
snapshot_for_path() {
local group="$1" path="$2" action="$3"
local pathhash fstype
pathhash=$(echo -n "$path" | sha256sum | cut -c1-8)
fstype=$(${findmnt} -n -o FSTYPE -T "$path" 2>/dev/null || echo "unknown")
case "$fstype" in
zfs)
local dataset mount subpath snapname snappath
dataset=$(${findmnt} -n -o SOURCE -T "$path")
mount=$(${findmnt} -n -o TARGET -T "$path")
subpath=''${path#"$mount"}
[[ "$subpath" != /* ]] && subpath="/$subpath"
snapname="''${dataset}@restic-''${group}-''${pathhash}"
snappath="''${mount}/.zfs/snapshot/restic-''${group}-''${pathhash}''${subpath}"
case "$action" in
create)
${zfs} destroy "$snapname" 2>/dev/null || true
${zfs} snapshot "$snapname"
${mount} --bind "$snappath" "$path"
echo "$path"
;;
destroy)
${umount} "$path" 2>/dev/null || true
${zfs} destroy "$snapname" 2>/dev/null || true
;;
esac
;;
btrfs)
local mount subpath snapdir snappath
mount=$(${findmnt} -n -o TARGET -T "$path")
subpath=''${path#"$mount"}
[[ "$subpath" != /* ]] && subpath="/$subpath"
snapdir="/.restic-snapshots/''${group}-''${pathhash}"
snappath="''${snapdir}''${subpath}"
case "$action" in
create)
${btrfs} subvolume delete "$snapdir" 2>/dev/null || true
mkdir -p /.restic-snapshots
${btrfs} subvolume snapshot -r "$mount" "$snapdir" >&2
${mount} --bind "$snappath" "$path"
echo "$path"
;;
destroy)
${umount} "$path" 2>/dev/null || true
${btrfs} subvolume delete "$snapdir" 2>/dev/null || true
;;
esac
;;
*)
echo "No snapshot support for $fstype ($path), using original" >&2
[ "$action" = "create" ] && echo "$path"
;;
esac
}
'';
mkBackup = group: paths: {
repository = mkRespository group;
inherit paths;
dynamicFilesFrom = "cat /run/restic-backup-${group}/paths";
backupPrepareCommand = ''
mkdir -p /run/restic-backup-${group}
: > /run/restic-backup-${group}/paths
${snapshotHelperFn}
for path in ${lib.escapeShellArgs paths}; do
snapshot_for_path ${lib.escapeShellArg group} "$path" create >> /run/restic-backup-${group}/paths
done
'';
backupCleanupCommand = ''
${snapshotHelperFn}
for path in ${lib.escapeShellArgs paths}; do
snapshot_for_path ${lib.escapeShellArg group} "$path" destroy
done
rm -rf /run/restic-backup-${group}
'';
initialize = true;
@@ -21,11 +109,11 @@ let
''--exclude-if-present ".nobackup"''
];
# Keeps backups from up to 6 months ago
pruneOpts = [
"--keep-daily 7" # one backup for each of the last n days
"--keep-weekly 5" # one backup for each of the last n weeks
"--keep-monthly 12" # one backup for each of the last n months
"--keep-yearly 75" # one backup for each of the last n years
"--keep-monthly 6" # one backup for each of the last n months
];
environmentFile = "/run/agenix/backblaze-s3-backups";
@@ -64,12 +152,25 @@ in
};
config = lib.mkIf (cfg.group != null) {
assertions = lib.mapAttrsToList (group: _: {
assertion = config.systemd.services."restic-backups-${group}".enable;
message = "Expected systemd service 'restic-backups-${group}' not found. The nixpkgs restic module may have changed its naming convention.";
}) cfg.group;
services.restic.backups = lib.concatMapAttrs
(group: groupCfg: {
${group} = mkBackup group groupCfg.paths;
})
cfg.group;
# Mount namespace lets us bind mount snapshots over original paths,
# so restic backs up from frozen snapshots while recording correct paths
systemd.services = lib.concatMapAttrs
(group: _: {
"restic-backups-${group}".serviceConfig.PrivateMounts = true;
})
cfg.group;
age.secrets.backblaze-s3-backups.file = ../secrets/backblaze-s3-backups.age;
age.secrets.restic-password.file = ../secrets/restic-password.age;

View File

@@ -1,4 +1,4 @@
{ config, lib, ... }:
{ config, ... }:
{
nix = {
@@ -6,12 +6,24 @@
substituters = [
"https://cache.nixos.org/"
"https://nix-community.cachix.org"
"http://s0.koi-bebop.ts.net:5000"
"http://s0.neet.dev:28338/nixos"
];
trusted-public-keys = [
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
"s0.koi-bebop.ts.net:OjbzD86YjyJZpCp9RWaQKANaflcpKhtzBMNP8I2aPUU="
"nixos:e5AMCUWWEX9MESWAAMjBkZdGUpl588NhgsUO3HsdhFw="
];
# Allow substituters to be offline
# This isn't exactly ideal since it would be best if I could set up a system
# so that it is an error if a derivation isn't available for any substituters
# and use this flag as intended for deciding if it should build missing
# derivations locally. See https://github.com/NixOS/nix/issues/6901
fallback = true;
# Authenticate to private nixos cache
netrc-file = config.age.secrets.attic-netrc.path;
};
};
age.secrets.attic-netrc.file = ../secrets/attic-netrc.age;
}

View File

@@ -10,6 +10,10 @@ in
device = mkOption {
type = types.str;
};
configurationLimit = mkOption {
default = 20;
type = types.int;
};
};
config = mkIf cfg.enable {
@@ -19,7 +23,7 @@ in
enable = true;
device = cfg.device;
useOSProber = true;
configurationLimit = 20;
configurationLimit = cfg.configurationLimit;
theme = pkgs.nixos-grub2-theme;
};
};

View File

@@ -1,4 +1,4 @@
{ lib, config, pkgs, ... }:
{ ... }:
{
imports = [

View File

@@ -7,6 +7,10 @@ in
{
options.efi = {
enable = mkEnableOption "enable efi boot";
configurationLimit = mkOption {
default = 20;
type = types.int;
};
};
config = mkIf cfg.enable {
@@ -19,7 +23,7 @@ in
efiSupport = true;
useOSProber = true;
# memtest86.enable = true;
configurationLimit = 20;
configurationLimit = cfg.configurationLimit;
theme = pkgs.nixos-grub2-theme;
};
};

View File

@@ -1,4 +1,4 @@
{ lib, config, pkgs, ... }:
{ lib, config, ... }:
with lib;
let

View File

@@ -1,5 +1,7 @@
{ config, pkgs, lib, ... }:
# TODO: use tailscale instead of tor https://gist.github.com/antifuchs/e30d58a64988907f282c82231dde2cbc
let
cfg = config.remoteLuksUnlock;
in
@@ -33,11 +35,6 @@ in
};
config = lib.mkIf cfg.enable {
# boot.initrd.luks.devices.${cfg.device.name} = {
# device = cfg.device.path;
# allowDiscards = cfg.device.allowDiscards;
# };
# Unlock LUKS disk over ssh
boot.initrd.network.enable = true;
boot.initrd.kernelModules = cfg.kernelModules;

View File

@@ -1,4 +1,4 @@
{ config, pkgs, ... }:
{ config, pkgs, lib, ... }:
{
imports = [
@@ -6,6 +6,7 @@
./binary-cache.nix
./flakes.nix
./auto-update.nix
./ntfy
./shell.nix
./network
./boot
@@ -14,19 +15,26 @@
./machine-info
./nix-builder.nix
./ssh.nix
./sandboxed-workspace
];
nix.flakes.enable = true;
system.stateVersion = "21.11";
system.stateVersion = "23.11";
networking.useDHCP = false;
networking.useDHCP = lib.mkDefault true;
networking.firewall.enable = true;
networking.firewall.allowPing = true;
time.timeZone = "America/Denver";
i18n.defaultLocale = "en_US.UTF-8";
time.timeZone = "America/Los_Angeles";
i18n = {
defaultLocale = "en_US.UTF-8";
extraLocaleSettings = {
LANGUAGE = "en_US.UTF-8";
LC_ALL = "en_US.UTF-8";
};
};
services.openssh = {
enable = true;
@@ -49,12 +57,13 @@
pciutils
usbutils
killall
screen
micro
helix
lm_sensors
picocom
lf
gnumake
tree
];
nixpkgs.config.allowUnfree = true;
@@ -86,8 +95,11 @@
{ groups = [ "wheel" ]; persist = true; }
];
nix.gc.automatic = true;
nix.gc.automatic = !config.boot.isContainer;
security.acme.acceptTerms = true;
security.acme.defaults.email = "zuckerberg@neet.dev";
# Enable Desktop Environment if this is a PC (machine role is "personal")
de.enable = lib.mkDefault (config.thisMachine.hasRole."personal" && !config.boot.isContainer);
}

View File

@@ -1,4 +1,4 @@
{ lib, pkgs, config, ... }:
{ lib, config, ... }:
with lib;
let
cfg = config.nix.flakes;
@@ -10,16 +10,9 @@ in
config = mkIf cfg.enable {
nix = {
package = pkgs.nixFlakes;
extraOptions = ''
experimental-features = nix-command flakes
'';
# pin nixpkgs for system commands such as "nix shell"
registry.nixpkgs.flake = config.inputs.nixpkgs;
# pin system nixpkgs to the same version as the flake input
nixPath = [ "nixpkgs=${config.inputs.nixpkgs}" ];
};
};
}

View File

@@ -1,10 +1,94 @@
# Gathers info about each machine to constuct overall configuration
# Ex: Each machine already trusts each others SSH fingerprint already
{ config, lib, pkgs, ... }:
{ config, lib, ... }:
let
machines = config.machines.hosts;
hostOptionsSubmoduleType = lib.types.submodule {
options = {
hostNames = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
List of hostnames for this machine. The first one is the default so it is the target of deployments.
Used for automatically trusting hosts for ssh connections.
'';
};
arch = lib.mkOption {
type = lib.types.enum [ "x86_64-linux" "aarch64-linux" ];
description = ''
The architecture of this machine.
'';
};
systemRoles = lib.mkOption {
type = lib.types.listOf lib.types.str; # TODO: maybe use an enum?
description = ''
The set of roles this machine holds. Affects secrets available. (TODO add service config as well using this info)
'';
};
hostKey = lib.mkOption {
type = lib.types.str;
description = ''
The system ssh host key of this machine. Used for automatically trusting hosts for ssh connections
and for decrypting secrets with agenix.
'';
};
remoteUnlock = lib.mkOption {
default = null;
type = lib.types.nullOr (lib.types.submodule {
options = {
hostKey = lib.mkOption {
type = lib.types.str;
description = ''
The system ssh host key of this machine used for luks boot unlocking only.
'';
};
clearnetHost = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
The hostname resolvable over clearnet used to luks boot unlock this machine
'';
};
onionHost = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
The hostname resolvable over tor used to luks boot unlock this machine
'';
};
};
});
};
userKeys = lib.mkOption {
default = [ ];
type = lib.types.listOf lib.types.str;
description = ''
The list of user keys. Each key here can be used to log into all other systems as `googlebot`.
TODO: consider auto populating other programs that use ssh keys such as gitea
'';
};
deployKeys = lib.mkOption {
default = [ ];
type = lib.types.listOf lib.types.str;
description = ''
The list of deployment keys. Each key here can be used to log into all other systems as `root`.
'';
};
configurationPath = lib.mkOption {
type = lib.types.path;
description = ''
The path to this machine's configuration directory.
'';
};
};
};
in
{
imports = [
@@ -13,104 +97,16 @@ in
];
options.machines = {
hosts = lib.mkOption {
type = lib.types.attrsOf
(lib.types.submodule {
options = {
hostNames = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
List of hostnames for this machine. The first one is the default so it is the target of deployments.
Used for automatically trusting hosts for ssh connections.
'';
};
arch = lib.mkOption {
type = lib.types.enum [ "x86_64-linux" "aarch64-linux" ];
description = ''
The architecture of this machine.
'';
};
systemRoles = lib.mkOption {
type = lib.types.listOf lib.types.str; # TODO: maybe use an enum?
description = ''
The set of roles this machine holds. Affects secrets available. (TODO add service config as well using this info)
'';
};
hostKey = lib.mkOption {
type = lib.types.str;
description = ''
The system ssh host key of this machine. Used for automatically trusting hosts for ssh connections
and for decrypting secrets with agenix.
'';
};
remoteUnlock = lib.mkOption {
default = null;
type = lib.types.nullOr (lib.types.submodule {
options = {
hostKey = lib.mkOption {
type = lib.types.str;
description = ''
The system ssh host key of this machine used for luks boot unlocking only.
'';
};
clearnetHost = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
The hostname resolvable over clearnet used to luks boot unlock this machine
'';
};
onionHost = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
The hostname resolvable over tor used to luks boot unlock this machine
'';
};
};
});
};
userKeys = lib.mkOption {
default = [ ];
type = lib.types.listOf lib.types.str;
description = ''
The list of user keys. Each key here can be used to log into all other systems as `googlebot`.
TODO: consider auto populating other programs that use ssh keys such as gitea
'';
};
deployKeys = lib.mkOption {
default = [ ];
type = lib.types.listOf lib.types.str;
description = ''
The list of deployment keys. Each key here can be used to log into all other systems as `root`.
'';
};
configurationPath = lib.mkOption {
type = lib.types.path;
description = ''
The path to this machine's configuration directory.
'';
};
};
});
type = lib.types.attrsOf hostOptionsSubmoduleType;
};
};
options.thisMachine.config = lib.mkOption {
# For ease of use, a direct copy of the host config from machines.hosts.${hostName}
type = hostOptionsSubmoduleType;
};
config = {
assertions = (lib.concatLists (lib.mapAttrsToList
(
@@ -196,5 +192,16 @@ in
builtins.map (p: { "${dirName p}" = p; }) propFiles;
in
properties ../../machines;
# Don't try to evaluate "thisMachine" when reflecting using moduleless.nix.
# When evaluated by moduleless.nix this will fail due to networking.hostName not
# existing. This is because moduleless.nix is not intended for reflection from the
# perspective of a perticular machine but is instead intended for reflecting on
# the properties of all machines as a whole system.
thisMachine.config = config.machines.hosts.${config.networking.hostName};
# Add ssh keys from KeepassXC
machines.ssh.userKeys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILACiZO7QnB4bcmziVaUkUE0ZPMR0M/yJbbHYsHIZz9g" ];
machines.ssh.deployKeys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID58MvKGs3GDMMcN8Iyi9S59SciSrVM97wKtOvUAl3li" ];
};
}

View File

@@ -1,19 +1,55 @@
{ config, lib, ... }:
# Maps roles to their hosts
# Maps roles to their hosts.
# machines.withRole = {
# personal = [
# "machine1" "machine3"
# ];
# cache = [
# "machine2"
# ];
# };
#
# A list of all possible roles
# machines.allRoles = [
# "personal"
# "cache"
# ];
#
# For each role has true or false if the current machine has that role
# thisMachine.hasRole = {
# personal = true;
# cache = false;
# };
{
options.machines.roles = lib.mkOption {
options.machines.withRole = lib.mkOption {
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
};
options.machines.allRoles = lib.mkOption {
type = lib.types.listOf lib.types.str;
};
options.thisMachine.hasRole = lib.mkOption {
type = lib.types.attrsOf lib.types.bool;
};
config = {
machines.roles = lib.zipAttrs
machines.withRole = lib.zipAttrs
(lib.mapAttrsToList
(host: cfg:
lib.foldl (lib.mergeAttrs) { }
(builtins.map (role: { ${role} = host; })
cfg.systemRoles))
config.machines.hosts);
machines.allRoles = lib.attrNames config.machines.withRole;
thisMachine.hasRole = lib.mapAttrs
(role: cfg:
builtins.elem config.networking.hostName config.machines.withRole.${role}
)
config.machines.withRole;
};
}

View File

@@ -39,6 +39,6 @@ in
builtins.map
(host: machines.hosts.${host}.hostKey)
hosts)
machines.roles;
machines.withRole;
};
}

View File

@@ -7,11 +7,9 @@ let
in
{
imports = [
./pia-openvpn.nix
./pia-wireguard.nix
./ping.nix
./pia-vpn
./tailscale.nix
./vpn.nix
./sandbox.nix
];
options.networking.ip_forward = mkEnableOption "Enable ip forwarding";

View File

@@ -1,113 +0,0 @@
{ config, pkgs, lib, ... }:
let
cfg = config.pia.openvpn;
vpnfailsafe = pkgs.stdenv.mkDerivation {
pname = "vpnfailsafe";
version = "0.0.1";
src = ./.;
installPhase = ''
mkdir -p $out
cp vpnfailsafe.sh $out/vpnfailsafe.sh
sed -i 's|getent|${pkgs.getent}/bin/getent|' $out/vpnfailsafe.sh
'';
};
in
{
options.pia.openvpn = {
enable = lib.mkEnableOption "Enable private internet access";
server = lib.mkOption {
type = lib.types.str;
default = "us-washingtondc.privacy.network";
example = "swiss.privacy.network";
};
};
config = lib.mkIf cfg.enable {
services.openvpn = {
servers = {
pia = {
config = ''
client
dev tun
proto udp
remote ${cfg.server} 1198
resolv-retry infinite
nobind
persist-key
persist-tun
cipher aes-128-cbc
auth sha1
tls-client
remote-cert-tls server
auth-user-pass
compress
verb 1
reneg-sec 0
<crl-verify>
-----BEGIN X509 CRL-----
MIICWDCCAUAwDQYJKoZIhvcNAQENBQAwgegxCzAJBgNVBAYTAlVTMQswCQYDVQQI
EwJDQTETMBEGA1UEBxMKTG9zQW5nZWxlczEgMB4GA1UEChMXUHJpdmF0ZSBJbnRl
cm5ldCBBY2Nlc3MxIDAeBgNVBAsTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAw
HgYDVQQDExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UEKRMXUHJpdmF0
ZSBJbnRlcm5ldCBBY2Nlc3MxLzAtBgkqhkiG9w0BCQEWIHNlY3VyZUBwcml2YXRl
aW50ZXJuZXRhY2Nlc3MuY29tFw0xNjA3MDgxOTAwNDZaFw0zNjA3MDMxOTAwNDZa
MCYwEQIBARcMMTYwNzA4MTkwMDQ2MBECAQYXDDE2MDcwODE5MDA0NjANBgkqhkiG
9w0BAQ0FAAOCAQEAQZo9X97ci8EcPYu/uK2HB152OZbeZCINmYyluLDOdcSvg6B5
jI+ffKN3laDvczsG6CxmY3jNyc79XVpEYUnq4rT3FfveW1+Ralf+Vf38HdpwB8EW
B4hZlQ205+21CALLvZvR8HcPxC9KEnev1mU46wkTiov0EKc+EdRxkj5yMgv0V2Re
ze7AP+NQ9ykvDScH4eYCsmufNpIjBLhpLE2cuZZXBLcPhuRzVoU3l7A9lvzG9mjA
5YijHJGHNjlWFqyrn1CfYS6koa4TGEPngBoAziWRbDGdhEgJABHrpoaFYaL61zqy
MR6jC0K2ps9qyZAN74LEBedEfK7tBOzWMwr58A==
-----END X509 CRL-----
</crl-verify>
<ca>
-----BEGIN CERTIFICATE-----
MIIFqzCCBJOgAwIBAgIJAKZ7D5Yv87qDMA0GCSqGSIb3DQEBDQUAMIHoMQswCQYD
VQQGEwJVUzELMAkGA1UECBMCQ0ExEzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNV
BAoTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIElu
dGVybmV0IEFjY2VzczEgMB4GA1UEAxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3Mx
IDAeBgNVBCkTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkB
FiBzZWN1cmVAcHJpdmF0ZWludGVybmV0YWNjZXNzLmNvbTAeFw0xNDA0MTcxNzM1
MThaFw0zNDA0MTIxNzM1MThaMIHoMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex
EzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNVBAoTF1ByaXZhdGUgSW50ZXJuZXQg
QWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UE
AxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBCkTF1ByaXZhdGUgSW50
ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkBFiBzZWN1cmVAcHJpdmF0ZWludGVy
bmV0YWNjZXNzLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPXD
L1L9tX6DGf36liA7UBTy5I869z0UVo3lImfOs/GSiFKPtInlesP65577nd7UNzzX
lH/P/CnFPdBWlLp5ze3HRBCc/Avgr5CdMRkEsySL5GHBZsx6w2cayQ2EcRhVTwWp
cdldeNO+pPr9rIgPrtXqT4SWViTQRBeGM8CDxAyTopTsobjSiYZCF9Ta1gunl0G/
8Vfp+SXfYCC+ZzWvP+L1pFhPRqzQQ8k+wMZIovObK1s+nlwPaLyayzw9a8sUnvWB
/5rGPdIYnQWPgoNlLN9HpSmsAcw2z8DXI9pIxbr74cb3/HSfuYGOLkRqrOk6h4RC
OfuWoTrZup1uEOn+fw8CAwEAAaOCAVQwggFQMB0GA1UdDgQWBBQv63nQ/pJAt5tL
y8VJcbHe22ZOsjCCAR8GA1UdIwSCARYwggESgBQv63nQ/pJAt5tLy8VJcbHe22ZO
sqGB7qSB6zCB6DELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRMwEQYDVQQHEwpM
b3NBbmdlbGVzMSAwHgYDVQQKExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4G
A1UECxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBAMTF1ByaXZhdGUg
SW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQpExdQcml2YXRlIEludGVybmV0IEFjY2Vz
czEvMC0GCSqGSIb3DQEJARYgc2VjdXJlQHByaXZhdGVpbnRlcm5ldGFjY2Vzcy5j
b22CCQCmew+WL/O6gzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBDQUAA4IBAQAn
a5PgrtxfwTumD4+3/SYvwoD66cB8IcK//h1mCzAduU8KgUXocLx7QgJWo9lnZ8xU
ryXvWab2usg4fqk7FPi00bED4f4qVQFVfGfPZIH9QQ7/48bPM9RyfzImZWUCenK3
7pdw4Bvgoys2rHLHbGen7f28knT2j/cbMxd78tQc20TIObGjo8+ISTRclSTRBtyC
GohseKYpTS9himFERpUgNtefvYHbn70mIOzfOJFTVqfrptf9jXa9N8Mpy3ayfodz
1wiqdteqFXkTYoSDctgKMiZ6GdocK9nMroQipIQtpnwd4yBDWIyC6Bvlkrq5TQUt
YDQ8z9v+DMO6iwyIDRiU
-----END CERTIFICATE-----
</ca>
disable-occ
auth-user-pass /run/agenix/pia-login.conf
'';
autoStart = true;
up = "${vpnfailsafe}/vpnfailsafe.sh";
down = "${vpnfailsafe}/vpnfailsafe.sh";
};
};
};
age.secrets."pia-login.conf".file = ../../secrets/pia-login.age;
};
}

View File

@@ -0,0 +1,89 @@
# PIA VPN Multi-Container Module
Routes service containers through a PIA WireGuard VPN using a shared bridge network.
## Architecture
```
internet
┌──────┴──────┐
│ Host │
│ tinyproxy │ ← PIA API bootstrap proxy
│ 10.100.0.1 │
└──────┬───────┘
│ br-vpn (no IPMasquerade)
┌────────────┼──────────────┐
│ │ │
┌──────┴──────┐ ┌───┴────┐ ┌─────┴──────┐
│ VPN ctr │ │ servarr│ │transmission│
│ 10.100.0.2 │ │ .11 │ │ .10 │
│ piaw (WG) │ │ │ │ │
│ gateway+NAT │ └────────┘ └────────────┘
└─────────────┘
```
- **Host** creates the WG interface (encrypted UDP stays in host netns) and runs tinyproxy on the bridge so the VPN container can bootstrap PIA auth before WG is up.
- **VPN container** authenticates with PIA via the proxy, configures WG, sets up NAT (masquerade bridge→WG) and optional port forwarding DNAT.
- **Service containers** default-route through the VPN container. No WG interface = no internet if VPN is down = leak-proof by topology.
- **Host** reaches containers directly on the bridge for nginx reverse proxying.
## Key design decisions
- **Bridge, not veth pairs**: All containers share one bridge (`br-vpn`), so the VPN container can act as a single gateway. The host does NOT masquerade bridge traffic — only the VPN container does (through WG).
- **Port forwarding is implicit**: If any container sets `receiveForwardedPort`, the VPN container automatically handles PIA port forwarding and DNAT. No separate toggle needed.
- **DNS through WG**: Service containers use the VPN container as their DNS server. The VPN container runs `systemd-resolved` listening on its bridge IP, forwarding queries through the WG tunnel.
- **Monthly renewal**: `pia-vpn-setup` uses `Type=simple` + `Restart=always` + `RuntimeMaxSec=30d` to periodically re-authenticate with PIA and get a fresh port forwarding signature (signatures expire after ~2 months). Service containers are unaffected during renewal.
## Files
| File | Purpose |
|---|---|
| `default.nix` | Options, bridge, tinyproxy, host firewall, WG interface creation, assertions |
| `vpn-container.nix` | VPN container: PIA auth, WG config, NAT, DNAT, port refresh timer |
| `service-container.nix` | Generates service containers with static IP and gateway→VPN |
| `scripts.nix` | Bash function library for PIA API calls and WG configuration |
| `ca.rsa.4096.crt` | PIA CA certificate for API TLS verification |
## Usage
```nix
pia-vpn = {
enable = true;
serverLocation = "swiss";
containers.my-service = {
ip = "10.100.0.10";
mounts."/data".hostPath = "/data";
config = { services.my-app.enable = true; };
# Optional: receive PIA's forwarded port (at most one container)
receiveForwardedPort = { port = 8080; protocol = "both"; };
onPortForwarded = ''
echo "PIA assigned port $PORT, forwarding to $TARGET_IP:8080"
'';
};
};
```
## Debugging
```bash
# Check VPN container status
machinectl shell pia-vpn
systemctl status pia-vpn-setup
journalctl -u pia-vpn-setup
# Verify WG tunnel
wg show
# Check NAT/DNAT rules
iptables -t nat -L -v
iptables -L FORWARD -v
# From a service container — verify VPN routing
curl ifconfig.me
# Port refresh logs
journalctl -u pia-vpn-port-refresh
```

View File

@@ -0,0 +1,279 @@
{ config, lib, pkgs, ... }:
# PIA VPN multi-container module.
#
# Architecture:
# Host creates WG interface, runs tinyproxy on bridge for PIA API bootstrap.
# VPN container does all PIA logic via proxy, configures WG, masquerades bridge→piaw.
# Service containers default route → VPN container (leak-proof by topology).
#
# Reference: https://www.wireguard.com/netns/#ordinary-containerization
with lib;
let
cfg = config.pia-vpn;
# Derive prefix length from subnet CIDR (e.g. "10.100.0.0/24" → "24")
subnetPrefixLen = last (splitString "/" cfg.subnet);
containerSubmodule = types.submodule ({ name, ... }: {
options = {
ip = mkOption {
type = types.str;
description = "Static IP address for this container on the VPN bridge";
};
config = mkOption {
type = types.anything;
default = { };
description = "NixOS configuration for this container";
};
mounts = mkOption {
type = types.attrsOf (types.submodule {
options = {
hostPath = mkOption {
type = types.str;
description = "Path on the host to bind mount";
};
isReadOnly = mkOption {
type = types.bool;
default = false;
description = "Whether the mount is read-only";
};
};
});
default = { };
description = "Bind mounts for the container";
};
receiveForwardedPort = mkOption {
type = types.nullOr (types.submodule {
options = {
port = mkOption {
type = types.nullOr types.port;
default = null;
description = ''
Target port to forward to. If null, forwards to the same PIA-assigned port.
PIA-assigned ports below 10000 are rejected to avoid accidentally
forwarding traffic to other services.
'';
};
protocol = mkOption {
type = types.enum [ "tcp" "udp" "both" ];
default = "both";
description = "Protocol(s) to forward";
};
};
});
default = null;
description = "Port forwarding configuration. At most one container may set this.";
};
onPortForwarded = mkOption {
type = types.nullOr types.lines;
default = null;
description = ''
Optional script run in the VPN container after port forwarding is established.
Available environment variables: $PORT (PIA-assigned port), $TARGET_IP (this container's IP).
'';
};
};
});
# NOTE: All derivations of cfg.containers are kept INSIDE config = mkIf ... { }
# to avoid infinite recursion. The module system's pushDownProperties eagerly
# evaluates let bindings and mkMerge contents, so any top-level let binding
# that touches cfg.containers would force config evaluation during structure
# discovery, creating a cycle.
in
{
imports = [
./vpn-container.nix
./service-container.nix
];
options.pia-vpn = {
enable = mkEnableOption "PIA VPN multi-container setup";
serverLocation = mkOption {
type = types.str;
default = "swiss";
description = "PIA server region ID";
};
interfaceName = mkOption {
type = types.str;
default = "piaw";
description = "WireGuard interface name";
};
wireguardListenPort = mkOption {
type = types.port;
default = 51820;
description = "WireGuard listen port";
};
bridgeName = mkOption {
type = types.str;
default = "br-vpn";
description = "Bridge interface name for VPN containers";
};
subnet = mkOption {
type = types.str;
default = "10.100.0.0/24";
description = "Subnet CIDR for VPN bridge network";
};
hostAddress = mkOption {
type = types.str;
default = "10.100.0.1";
description = "Host IP on the VPN bridge";
};
vpnAddress = mkOption {
type = types.str;
default = "10.100.0.2";
description = "VPN container IP on the bridge";
};
proxyPort = mkOption {
type = types.port;
default = 8888;
description = "Tinyproxy port for PIA API bootstrap";
};
containers = mkOption {
type = types.attrsOf containerSubmodule;
default = { };
description = "Service containers that route through the VPN";
};
# Subnet prefix length derived from cfg.subnet (exposed for other submodules)
subnetPrefixLen = mkOption {
type = types.str;
default = subnetPrefixLen;
description = "Prefix length derived from subnet CIDR";
readOnly = true;
};
# Shared host entries for all containers (host + VPN + service containers)
containerHosts = mkOption {
type = types.attrsOf (types.listOf types.str);
internal = true;
readOnly = true;
};
};
config = mkIf cfg.enable {
assertions =
let
forwardingContainers = filterAttrs (_: c: c.receiveForwardedPort != null) cfg.containers;
containerIPs = mapAttrsToList (_: c: c.ip) cfg.containers;
in
[
{
assertion = length (attrNames forwardingContainers) <= 1;
message = "At most one pia-vpn container may set receiveForwardedPort";
}
{
assertion = length containerIPs == length (unique containerIPs);
message = "pia-vpn container IPs must be unique";
}
];
# Enable systemd-networkd for bridge management
systemd.network.enable = true;
# TODO: re-enable once primary networking uses networkd
systemd.network.wait-online.enable = false;
# Tell NetworkManager to ignore VPN bridge and container interfaces
networking.networkmanager.unmanaged = mkIf config.networking.networkmanager.enable [
"interface-name:${cfg.bridgeName}"
"interface-name:ve-*"
];
# Bridge network device
systemd.network.netdevs."20-${cfg.bridgeName}".netdevConfig = {
Kind = "bridge";
Name = cfg.bridgeName;
};
# Bridge network configuration — NO IPMasquerade (host must NOT be gateway)
systemd.network.networks."20-${cfg.bridgeName}" = {
matchConfig.Name = cfg.bridgeName;
networkConfig = {
Address = "${cfg.hostAddress}/${cfg.subnetPrefixLen}";
DHCPServer = false;
};
linkConfig.RequiredForOnline = "no";
};
# Allow wireguard traffic through rpfilter
networking.firewall.checkReversePath = "loose";
# Block bridge → outside forwarding (prevents host from being a gateway for containers)
networking.firewall.extraForwardRules = ''
iifname "${cfg.bridgeName}" oifname != "${cfg.bridgeName}" drop
'';
# Allow tinyproxy from bridge (tinyproxy itself restricts to VPN container IP)
networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = [ cfg.proxyPort ];
# Tinyproxy — runs on bridge IP so VPN container can bootstrap PIA auth
services.tinyproxy = {
enable = true;
settings = {
Listen = cfg.hostAddress;
Port = cfg.proxyPort;
};
};
systemd.services.tinyproxy.before = [ "container@pia-vpn.service" ];
# WireGuard interface creation (host-side oneshot)
# Creates the interface in the host namespace so encrypted UDP stays in host netns.
# The container takes ownership of the interface on startup via `interfaces = [ ... ]`.
systemd.services.pia-vpn-wg-create = {
description = "Create PIA VPN WireGuard interface";
before = [ "container@pia-vpn.service" ];
requiredBy = [ "container@pia-vpn.service" ];
partOf = [ "container@pia-vpn.service" ];
wantedBy = [ "multi-user.target" ];
path = with pkgs; [ iproute2 ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
[[ -z $(ip link show dev ${cfg.interfaceName} 2>/dev/null) ]] || exit 0
ip link add ${cfg.interfaceName} type wireguard
'';
preStop = ''
ip link del ${cfg.interfaceName} 2>/dev/null || true
'';
};
# Host entries for container hostnames — NixOS only auto-creates these for
# hostAddress/localAddress containers, not hostBridge. Use the standard
# {name}.containers convention.
pia-vpn.containerHosts =
{ ${cfg.vpnAddress} = [ "pia-vpn.containers" ]; }
// mapAttrs' (name: ctr: nameValuePair ctr.ip [ "${name}.containers" ]) cfg.containers;
networking.hosts = cfg.containerHosts;
# PIA login secret
age.secrets."pia-login.conf".file = ../../../secrets/pia-login.age;
# IP forwarding needed for bridge traffic between containers
networking.ip_forward = true;
};
}

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzLYHwX5Ug/oUObZ5eH5P
rEwmfj4E/YEfSKLgFSsyRGGsVmmjiXBmSbX2s3xbj/ofuvYtkMkP/VPFHy9E/8ox
Y+cRjPzydxz46LPY7jpEw1NHZjOyTeUero5e1nkLhiQqO/cMVYmUnuVcuFfZyZvc
8Apx5fBrIp2oWpF/G9tpUZfUUJaaHiXDtuYP8o8VhYtyjuUu3h7rkQFoMxvuoOFH
6nkc0VQmBsHvCfq4T9v8gyiBtQRy543leapTBMT34mxVIQ4ReGLPVit/6sNLoGLb
gSnGe9Bk/a5V/5vlqeemWF0hgoRtUxMtU1hFbe7e8tSq1j+mu0SHMyKHiHd+OsmU
IQIDAQAB
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,227 @@
let
caPath = ./ca.rsa.4096.crt;
pubKeyPath = ./pubkey.pem;
in
# Bash function library for PIA VPN WireGuard operations.
# All PIA API calls accept an optional $proxy variable:
# proxy="http://10.100.0.1:8888" fetchPIAToken
# When $proxy is set, curl uses --proxy "$proxy"; otherwise direct connection.
# Reference materials:
# https://serverlist.piaservers.net/vpninfo/servers/v6
# https://github.com/pia-foss/manual-connections
# https://github.com/thrnz/docker-wireguard-pia/blob/master/extra/wg-gen.sh
# https://www.wireguard.com/netns/#ordinary-containerization
{
scriptCommon = ''
proxy_args() {
if [[ -n "''${proxy:-}" ]]; then
echo "--proxy $proxy"
fi
}
# Verify the server list RSA-SHA256 signature (line 1 = JSON, lines 3+ = base64 signature).
verifyServerList() {
local raw=$1
local sig_file
sig_file=$(mktemp)
echo "$raw" | tail -n +3 | base64 -d > "$sig_file"
if ! echo -n "$(echo "$raw" | head -n 1 | tr -d '\n')" | \
openssl dgst -sha256 -verify "${pubKeyPath}" \
-signature "$sig_file"; then
echo "ERROR: Server list signature verification failed" >&2
rm -f "$sig_file"
return 1
fi
echo "Server list signature verified"
rm -f "$sig_file"
}
fetchPIAToken() {
local PIA_USER PIA_PASS resp
echo "Reading PIA credentials..."
PIA_USER=$(sed '1q;d' /run/agenix/pia-login.conf)
PIA_PASS=$(sed '2q;d' /run/agenix/pia-login.conf)
echo "Requesting PIA authentication token..."
resp=$(curl -s $(proxy_args) -u "$PIA_USER:$PIA_PASS" \
"https://www.privateinternetaccess.com/gtoken/generateToken")
PIA_TOKEN=$(echo "$resp" | jq -r '.token')
if [[ -z "$PIA_TOKEN" || "$PIA_TOKEN" == "null" ]]; then
echo "ERROR: Failed to fetch PIA token: $resp" >&2
return 1
fi
echo "PIA token acquired"
}
choosePIAServer() {
local serverLocation=$1
local raw servers_json totalservers serverindex
servers_json=$(mktemp)
echo "Fetching PIA server list..."
raw=$(curl -s $(proxy_args) \
"https://serverlist.piaservers.net/vpninfo/servers/v6")
verifyServerList "$raw"
echo "$raw" | head -n 1 | tr -d '\n' > "$servers_json"
totalservers=$(jq -r \
'.regions | .[] | select(.id=="'"$serverLocation"'") | .servers.wg | length' \
"$servers_json")
if ! [[ "$totalservers" =~ ^[0-9]+$ ]] || [ "$totalservers" -eq 0 ] 2>/dev/null; then
echo "ERROR: Location \"$serverLocation\" not found." >&2
rm -f "$servers_json"
return 1
fi
echo "Found $totalservers WireGuard servers in region '$serverLocation'"
serverindex=$(( RANDOM % totalservers ))
WG_HOSTNAME=$(jq -r \
'.regions | .[] | select(.id=="'"$serverLocation"'") | .servers.wg | .['"$serverindex"'].cn' \
"$servers_json")
WG_SERVER_IP=$(jq -r \
'.regions | .[] | select(.id=="'"$serverLocation"'") | .servers.wg | .['"$serverindex"'].ip' \
"$servers_json")
WG_SERVER_PORT=$(jq -r '.groups.wg | .[0] | .ports | .[0]' "$servers_json")
rm -f "$servers_json"
echo "Selected server $serverindex/$totalservers: $WG_HOSTNAME ($WG_SERVER_IP:$WG_SERVER_PORT)"
}
generateWireguardKey() {
PRIVATE_KEY=$(wg genkey)
PUBLIC_KEY=$(echo "$PRIVATE_KEY" | wg pubkey)
echo "Generated WireGuard keypair"
}
authorizeKeyWithPIAServer() {
local addKeyResponse
echo "Sending addKey request to $WG_HOSTNAME ($WG_SERVER_IP:$WG_SERVER_PORT)..."
addKeyResponse=$(curl -s -G $(proxy_args) \
--connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" \
--cacert "${caPath}" \
--data-urlencode "pt=$PIA_TOKEN" \
--data-urlencode "pubkey=$PUBLIC_KEY" \
"https://$WG_HOSTNAME:$WG_SERVER_PORT/addKey")
local status
status=$(echo "$addKeyResponse" | jq -r '.status')
if [[ "$status" != "OK" ]]; then
echo "ERROR: addKey failed: $addKeyResponse" >&2
return 1
fi
MY_IP=$(echo "$addKeyResponse" | jq -r '.peer_ip')
WG_SERVER_PUBLIC_KEY=$(echo "$addKeyResponse" | jq -r '.server_key')
WG_SERVER_PORT=$(echo "$addKeyResponse" | jq -r '.server_port')
echo "Key authorized assigned VPN IP: $MY_IP, server port: $WG_SERVER_PORT"
}
writeWireguardQuickFile() {
local wgFile=$1
local listenPort=$2
rm -f "$wgFile"
touch "$wgFile"
chmod 700 "$wgFile"
cat > "$wgFile" <<WGEOF
[Interface]
PrivateKey = $PRIVATE_KEY
ListenPort = $listenPort
[Peer]
PersistentKeepalive = 25
PublicKey = $WG_SERVER_PUBLIC_KEY
AllowedIPs = 0.0.0.0/0
Endpoint = $WG_SERVER_IP:$WG_SERVER_PORT
WGEOF
echo "Wrote WireGuard config to $wgFile (listen=$listenPort)"
}
writeChosenServerToFile() {
local serverFile=$1
jq -n \
--arg hostname "$WG_HOSTNAME" \
--arg ip "$WG_SERVER_IP" \
--arg port "$WG_SERVER_PORT" \
'{hostname: $hostname, ip: $ip, port: $port}' > "$serverFile"
chmod 700 "$serverFile"
echo "Wrote server info to $serverFile"
}
loadChosenServerFromFile() {
local serverFile=$1
WG_HOSTNAME=$(jq -r '.hostname' "$serverFile")
WG_SERVER_IP=$(jq -r '.ip' "$serverFile")
WG_SERVER_PORT=$(jq -r '.port' "$serverFile")
echo "Loaded server info from $serverFile: $WG_HOSTNAME ($WG_SERVER_IP:$WG_SERVER_PORT)"
}
# Reset WG interface and tear down NAT/forwarding rules.
# Called on startup (clear stale state) and on exit via trap.
cleanupVpn() {
local interfaceName=$1
wg set "$interfaceName" listen-port 0 2>/dev/null || true
ip -4 address flush dev "$interfaceName" 2>/dev/null || true
ip route del default dev "$interfaceName" 2>/dev/null || true
iptables -t nat -F 2>/dev/null || true
iptables -F FORWARD 2>/dev/null || true
}
connectToServer() {
local wgFile=$1
local interfaceName=$2
echo "Applying WireGuard config to $interfaceName..."
wg setconf "$interfaceName" "$wgFile"
ip -4 address add "$MY_IP" dev "$interfaceName"
ip link set mtu 1420 up dev "$interfaceName"
echo "WireGuard interface $interfaceName is up with IP $MY_IP"
}
reservePortForward() {
local payload_and_signature
echo "Requesting port forward signature from $WG_HOSTNAME..."
payload_and_signature=$(curl -s -m 5 \
--connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" \
--cacert "${caPath}" \
-G --data-urlencode "token=$PIA_TOKEN" \
"https://$WG_HOSTNAME:19999/getSignature")
local status
status=$(echo "$payload_and_signature" | jq -r '.status')
if [[ "$status" != "OK" ]]; then
echo "ERROR: getSignature failed: $payload_and_signature" >&2
return 1
fi
PORT_SIGNATURE=$(echo "$payload_and_signature" | jq -r '.signature')
PORT_PAYLOAD=$(echo "$payload_and_signature" | jq -r '.payload')
PORT=$(echo "$PORT_PAYLOAD" | base64 -d | jq -r '.port')
echo "Port forward reserved: port $PORT"
}
writePortRenewalFile() {
local portRenewalFile=$1
jq -n \
--arg signature "$PORT_SIGNATURE" \
--arg payload "$PORT_PAYLOAD" \
'{signature: $signature, payload: $payload}' > "$portRenewalFile"
chmod 700 "$portRenewalFile"
echo "Wrote port renewal data to $portRenewalFile"
}
readPortRenewalFile() {
local portRenewalFile=$1
PORT_SIGNATURE=$(jq -r '.signature' "$portRenewalFile")
PORT_PAYLOAD=$(jq -r '.payload' "$portRenewalFile")
echo "Loaded port renewal data from $portRenewalFile"
}
refreshPIAPort() {
local bindPortResponse
echo "Refreshing port forward binding with $WG_HOSTNAME..."
bindPortResponse=$(curl -Gs -m 5 \
--connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" \
--cacert "${caPath}" \
--data-urlencode "payload=$PORT_PAYLOAD" \
--data-urlencode "signature=$PORT_SIGNATURE" \
"https://$WG_HOSTNAME:19999/bindPort")
echo "bindPort response: $bindPortResponse"
}
'';
}

View File

@@ -0,0 +1,87 @@
{ config, lib, allModules, ... }:
# Generates service containers that route all traffic through the VPN container.
# Each container gets a static IP on the VPN bridge with default route → VPN container.
#
# Uses lazy mapAttrs inside fixed config keys to avoid infinite recursion.
# (mkMerge + mapAttrsToList at the top level forces eager evaluation of cfg.containers
# during module structure discovery, which creates a cycle with config evaluation.)
with lib;
let
cfg = config.pia-vpn;
mkContainer = name: ctr: {
autoStart = true;
ephemeral = true;
privateNetwork = true;
hostBridge = cfg.bridgeName;
bindMounts = mapAttrs
(_: mount: {
hostPath = mount.hostPath;
isReadOnly = mount.isReadOnly;
})
ctr.mounts;
config = { config, pkgs, lib, ... }: {
imports = allModules ++ [ ctr.config ];
# Static IP with gateway pointing to VPN container
networking.useNetworkd = true;
systemd.network.enable = true;
networking.useDHCP = false;
systemd.network.networks."20-eth0" = {
matchConfig.Name = "eth0";
networkConfig = {
Address = "${ctr.ip}/${cfg.subnetPrefixLen}";
Gateway = cfg.vpnAddress;
DNS = [ cfg.vpnAddress ];
};
};
networking.hosts = cfg.containerHosts;
# DNS through VPN container (queries go through WG tunnel = no DNS leak)
networking.nameservers = [ cfg.vpnAddress ];
# Wait for actual VPN connectivity before network-online.target.
# Without this, services start before the VPN tunnel is ready and failures
# can't be reported to ntfy (no outbound connectivity yet).
systemd.services.wait-for-vpn = {
description = "Wait for VPN connectivity";
before = [ "network-online.target" ];
wantedBy = [ "network-online.target" ];
after = [ "systemd-networkd-wait-online.service" ];
serviceConfig.Type = "oneshot";
path = [ pkgs.iputils ];
script = ''
until ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; do
echo "Waiting for VPN connectivity..."
sleep 1
done
'';
};
# Trust the bridge interface (host reaches us directly for nginx)
networking.firewall.trustedInterfaces = [ "eth0" ];
# Disable host resolv.conf — we use our own networkd DNS config
networking.useHostResolvConf = false;
};
};
mkContainerOrdering = name: _ctr: nameValuePair "container@${name}" {
after = [ "container@pia-vpn.service" ];
requires = [ "container@pia-vpn.service" ];
partOf = [ "container@pia-vpn.service" ];
};
in
{
config = mkIf cfg.enable {
containers = mapAttrs mkContainer cfg.containers;
systemd.services = mapAttrs' mkContainerOrdering cfg.containers;
};
}

View File

@@ -0,0 +1,232 @@
{ config, lib, allModules, ... }:
# VPN container: runs all PIA logic, acts as WireGuard gateway + NAT for service containers.
with lib;
let
cfg = config.pia-vpn;
scripts = import ./scripts.nix;
# Port forwarding derived state
forwardingContainers = filterAttrs (_: c: c.receiveForwardedPort != null) cfg.containers;
portForwarding = forwardingContainers != { };
forwardingContainerName = if portForwarding then head (attrNames forwardingContainers) else null;
forwardingContainer = if portForwarding then forwardingContainers.${forwardingContainerName} else null;
serverFile = "/var/lib/pia-vpn/server.json";
wgFile = "/var/lib/pia-vpn/wg.conf";
portRenewalFile = "/var/lib/pia-vpn/port-renewal.json";
proxy = "http://${cfg.hostAddress}:${toString cfg.proxyPort}";
# DNAT/forwarding rules for port forwarding
dnatSetupScript = optionalString portForwarding (
let
fwd = forwardingContainer.receiveForwardedPort;
targetIp = forwardingContainer.ip;
dnatTarget = if fwd.port != null then "${targetIp}:${toString fwd.port}" else targetIp;
targetPort = if fwd.port != null then toString fwd.port else "$PORT";
tcpRules = optionalString (fwd.protocol == "tcp" || fwd.protocol == "both") ''
echo "Setting up TCP DNAT: port $PORT ${targetIp}:${targetPort}"
iptables -t nat -A PREROUTING -i ${cfg.interfaceName} -p tcp --dport $PORT -j DNAT --to ${dnatTarget}
iptables -A FORWARD -i ${cfg.interfaceName} -d ${targetIp} -p tcp --dport ${targetPort} -j ACCEPT
'';
udpRules = optionalString (fwd.protocol == "udp" || fwd.protocol == "both") ''
echo "Setting up UDP DNAT: port $PORT ${targetIp}:${targetPort}"
iptables -t nat -A PREROUTING -i ${cfg.interfaceName} -p udp --dport $PORT -j DNAT --to ${dnatTarget}
iptables -A FORWARD -i ${cfg.interfaceName} -d ${targetIp} -p udp --dport ${targetPort} -j ACCEPT
'';
onPortForwarded = optionalString (forwardingContainer.onPortForwarded != null) ''
TARGET_IP="${targetIp}"
export PORT TARGET_IP
echo "Running onPortForwarded hook for ${forwardingContainerName} (port=$PORT, target=$TARGET_IP)"
${forwardingContainer.onPortForwarded}
'';
in
''
if [ "$PORT" -lt 10000 ]; then
echo "ERROR: PIA assigned low port $PORT (< 10000), refusing to set up DNAT" >&2
else
${tcpRules}
${udpRules}
${onPortForwarded}
fi
''
);
in
{
config = mkIf cfg.enable {
# Give the container more time to boot (pia-vpn-setup retries can delay readiness)
systemd.services."container@pia-vpn".serviceConfig.TimeoutStartSec = mkForce "180s";
containers.pia-vpn = {
autoStart = true;
ephemeral = true;
privateNetwork = true;
hostBridge = cfg.bridgeName;
interfaces = [ cfg.interfaceName ];
bindMounts."/run/agenix" = {
hostPath = "/run/agenix";
isReadOnly = true;
};
config = { config, pkgs, lib, ... }:
let
scriptPkgs = with pkgs; [ wireguard-tools iproute2 curl jq iptables coreutils openssl ];
in
{
imports = allModules;
networking.hosts = cfg.containerHosts;
# Static IP on bridge — no gateway (VPN container routes via WG only)
networking.useNetworkd = true;
systemd.network.enable = true;
networking.useDHCP = false;
systemd.network.networks."20-eth0" = {
matchConfig.Name = "eth0";
networkConfig = {
Address = "${cfg.vpnAddress}/${cfg.subnetPrefixLen}";
DHCPServer = false;
};
};
# Ignore WG interface for wait-online (it's configured manually, not by networkd)
systemd.network.wait-online.ignoredInterfaces = [ cfg.interfaceName ];
# Route ntfy alerts through the host proxy (VPN container has no gateway on eth0)
ntfy-alerts.curlExtraArgs = "--proxy http://${cfg.hostAddress}:${toString cfg.proxyPort}";
# Enable forwarding so bridge traffic can go through WG
boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
# Trust bridge interface
networking.firewall.trustedInterfaces = [ "eth0" ];
# DNS: use systemd-resolved listening on bridge IP so service containers
# can use VPN container as DNS server (queries go through WG tunnel = no DNS leak)
services.resolved = {
enable = true;
settings.Resolve.DNSStubListenerExtra = cfg.vpnAddress;
};
# Don't use host resolv.conf — resolved manages DNS
networking.useHostResolvConf = false;
# State directory for PIA config files
systemd.tmpfiles.rules = [
"d /var/lib/pia-vpn 0700 root root -"
];
# PIA VPN setup service — does all the PIA auth, WG config, and NAT setup
systemd.services.pia-vpn-setup = {
description = "PIA VPN WireGuard Setup";
wants = [ "network-online.target" ];
after = [ "network.target" "network-online.target" "systemd-networkd.service" ];
wantedBy = [ "multi-user.target" ];
path = scriptPkgs;
serviceConfig = {
Type = "simple";
Restart = "always";
RestartSec = "5m";
RuntimeMaxSec = "30d";
};
script = ''
set -euo pipefail
${scripts.scriptCommon}
trap 'cleanupVpn ${cfg.interfaceName}' EXIT
cleanupVpn ${cfg.interfaceName}
proxy="${proxy}"
# 1. Authenticate with PIA via proxy (VPN container has no internet yet)
echo "Choosing PIA server in region '${cfg.serverLocation}'..."
choosePIAServer '${cfg.serverLocation}'
echo "Fetching PIA authentication token..."
fetchPIAToken
# 2. Generate WG keys and authorize with PIA server
echo "Generating WireGuard keypair..."
generateWireguardKey
echo "Authorizing key with PIA server $WG_HOSTNAME..."
authorizeKeyWithPIAServer
# 3. Configure WG interface (already created by host and moved into our namespace)
echo "Configuring WireGuard interface ${cfg.interfaceName}..."
writeWireguardQuickFile '${wgFile}' ${toString cfg.wireguardListenPort}
writeChosenServerToFile '${serverFile}'
connectToServer '${wgFile}' '${cfg.interfaceName}'
# 4. Default route through WG
ip route replace default dev ${cfg.interfaceName}
echo "Default route set through ${cfg.interfaceName}"
# 5. NAT: masquerade bridge WG (so service containers' traffic appears to come from VPN IP)
echo "Setting up NAT masquerade..."
iptables -t nat -A POSTROUTING -o ${cfg.interfaceName} -j MASQUERADE
iptables -A FORWARD -i eth0 -o ${cfg.interfaceName} -j ACCEPT
iptables -A FORWARD -i ${cfg.interfaceName} -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT
${optionalString portForwarding ''
# 6. Port forwarding setup
echo "Reserving port forward..."
reservePortForward
writePortRenewalFile '${portRenewalFile}'
# First bindPort triggers actual port allocation
echo "Binding port $PORT..."
refreshPIAPort
echo "PIA assigned port: $PORT"
# DNAT rules to forward PIA port to target container
${dnatSetupScript}
''}
echo "PIA VPN setup complete"
exec sleep infinity
'';
};
# Port refresh timer (every 10 min) — keeps PIA port forwarding alive
systemd.services.pia-vpn-port-refresh = mkIf portForwarding {
description = "PIA VPN Port Forward Refresh";
after = [ "pia-vpn-setup.service" ];
requires = [ "pia-vpn-setup.service" ];
path = scriptPkgs;
serviceConfig.Type = "oneshot";
script = ''
set -euo pipefail
${scripts.scriptCommon}
loadChosenServerFromFile '${serverFile}'
readPortRenewalFile '${portRenewalFile}'
echo "Refreshing PIA port forward..."
refreshPIAPort
'';
};
systemd.timers.pia-vpn-port-refresh = mkIf portForwarding {
partOf = [ "pia-vpn-port-refresh.service" ];
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*:0/10";
RandomizedDelaySec = "1m";
};
};
};
};
};
}

View File

@@ -1,357 +0,0 @@
{ config, lib, pkgs, ... }:
# Server list:
# https://serverlist.piaservers.net/vpninfo/servers/v6
# Reference materials:
# https://github.com/pia-foss/manual-connections
# https://github.com/thrnz/docker-wireguard-pia/blob/master/extra/wg-gen.sh
# TODO handle potential errors (or at least print status, success, and failures to the console)
# TODO parameterize names of systemd services so that multiple wg VPNs could coexist in theory easier
# TODO implement this module such that the wireguard VPN doesn't have to live in a container
# TODO don't add forward rules if the PIA port is the same as cfg.forwardedPort
# TODO verify signatures of PIA responses
with builtins;
with lib;
let
cfg = config.pia.wireguard;
getPIAToken = ''
PIA_USER=`sed '1q;d' /run/agenix/pia-login.conf`
PIA_PASS=`sed '2q;d' /run/agenix/pia-login.conf`
# PIA_TOKEN only lasts 24hrs
PIA_TOKEN=`curl -s -u "$PIA_USER:$PIA_PASS" https://www.privateinternetaccess.com/gtoken/generateToken | jq -r '.token'`
'';
chooseWireguardServer = ''
servers=$(mktemp)
servers_json=$(mktemp)
curl -s "https://serverlist.piaservers.net/vpninfo/servers/v6" > "$servers"
# extract json part only
head -n 1 "$servers" | tr -d '\n' > "$servers_json"
echo "Available location ids:" && jq '.regions | .[] | {name, id, port_forward}' "$servers_json"
# Some locations have multiple servers available. Pick a random one.
totalservers=$(jq -r '.regions | .[] | select(.id=="'${cfg.serverLocation}'") | .servers.wg | length' "$servers_json")
if ! [[ "$totalservers" =~ ^[0-9]+$ ]] || [ "$totalservers" -eq 0 ] 2>/dev/null; then
echo "Location \"${cfg.serverLocation}\" not found."
exit 1
fi
serverindex=$(( RANDOM % totalservers))
WG_HOSTNAME=$(jq -r '.regions | .[] | select(.id=="'${cfg.serverLocation}'") | .servers.wg | .['$serverindex'].cn' "$servers_json")
WG_SERVER_IP=$(jq -r '.regions | .[] | select(.id=="'${cfg.serverLocation}'") | .servers.wg | .['$serverindex'].ip' "$servers_json")
WG_SERVER_PORT=$(jq -r '.groups.wg | .[0] | .ports | .[0]' "$servers_json")
# write chosen server
rm -f /tmp/${cfg.interfaceName}-server.conf
touch /tmp/${cfg.interfaceName}-server.conf
chmod 700 /tmp/${cfg.interfaceName}-server.conf
echo "$WG_HOSTNAME" >> /tmp/${cfg.interfaceName}-server.conf
echo "$WG_SERVER_IP" >> /tmp/${cfg.interfaceName}-server.conf
echo "$WG_SERVER_PORT" >> /tmp/${cfg.interfaceName}-server.conf
rm $servers_json $servers
'';
getChosenWireguardServer = ''
WG_HOSTNAME=`sed '1q;d' /tmp/${cfg.interfaceName}-server.conf`
WG_SERVER_IP=`sed '2q;d' /tmp/${cfg.interfaceName}-server.conf`
WG_SERVER_PORT=`sed '3q;d' /tmp/${cfg.interfaceName}-server.conf`
'';
refreshPIAPort = ''
${getChosenWireguardServer}
signature=`sed '1q;d' /tmp/${cfg.interfaceName}-port-renewal`
payload=`sed '2q;d' /tmp/${cfg.interfaceName}-port-renewal`
bind_port_response=`curl -Gs -m 5 --connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" --cacert "${./ca.rsa.4096.crt}" --data-urlencode "payload=$payload" --data-urlencode "signature=$signature" "https://$WG_HOSTNAME:19999/bindPort"`
'';
portForwarding = cfg.forwardPortForTransmission || cfg.forwardedPort != null;
containerServiceName = "container@${config.vpn-container.containerName}.service";
in
{
options.pia.wireguard = {
enable = mkEnableOption "Enable private internet access";
badPortForwardPorts = mkOption {
type = types.listOf types.port;
description = ''
Ports that will not be accepted from PIA.
If PIA assigns a port from this list, the connection is aborted since we cannot ask for a different port.
This is used to guarantee we are not assigned a port that is used by a service we do not want exposed.
'';
};
wireguardListenPort = mkOption {
type = types.port;
description = "The port wireguard listens on for this VPN connection";
default = 51820;
};
serverLocation = mkOption {
type = types.str;
default = "swiss";
};
interfaceName = mkOption {
type = types.str;
default = "piaw";
};
forwardedPort = mkOption {
type = types.nullOr types.port;
description = "The port to redirect port forwarded TCP VPN traffic too";
default = null;
};
forwardPortForTransmission = mkEnableOption "PIA port forwarding for transmission should be performed.";
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.forwardPortForTransmission != (cfg.forwardedPort != null);
message = ''
The PIA forwarded port cannot simultaneously be used by transmission and redirected to another port.
'';
}
];
# mounts used to pass the connection parameters to the container
# the container doesn't have internet until it uses these parameters so it cannot fetch them itself
vpn-container.mounts = [
"/tmp/${cfg.interfaceName}.conf"
"/tmp/${cfg.interfaceName}-server.conf"
"/tmp/${cfg.interfaceName}-address.conf"
];
# The container takes ownership of the wireguard interface on its startup
containers.vpn.interfaces = [ cfg.interfaceName ];
# TODO: while this is much better than "loose" networking, it seems to have issues with firewall restarts
# allow traffic for wireguard interface to pass since wireguard trips up rpfilter
# networking.firewall = {
# extraCommands = ''
# ip46tables -t raw -I nixos-fw-rpfilter -p udp -m udp --sport ${toString cfg.wireguardListenPort} -j RETURN
# ip46tables -t raw -I nixos-fw-rpfilter -p udp -m udp --dport ${toString cfg.wireguardListenPort} -j RETURN
# '';
# extraStopCommands = ''
# ip46tables -t raw -D nixos-fw-rpfilter -p udp -m udp --sport ${toString cfg.wireguardListenPort} -j RETURN || true
# ip46tables -t raw -D nixos-fw-rpfilter -p udp -m udp --dport ${toString cfg.wireguardListenPort} -j RETURN || true
# '';
# };
networking.firewall.checkReversePath = "loose";
systemd.services.pia-vpn-wireguard-init = {
description = "Creates PIA VPN Wireguard Interface";
requires = [ "network-online.target" ];
after = [ "network.target" "network-online.target" ];
before = [ containerServiceName ];
requiredBy = [ containerServiceName ];
partOf = [ containerServiceName ];
wantedBy = [ "multi-user.target" ];
path = with pkgs; [ wireguard-tools jq curl iproute ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# restart once a month; PIA forwarded port expires after two months
# because the container is "PartOf" this unit, it gets restarted too
RuntimeMaxSec = "30d";
};
script = ''
# Prepare to connect by generating wg secrets and auth'ing with PIA since the container
# cannot do without internet to start with. NAT'ing the host's internet would address this
# issue but is not ideal because then leaking network outside of the VPN is more likely.
${chooseWireguardServer}
${getPIAToken}
# generate wireguard keys
privKey=$(wg genkey)
pubKey=$(echo "$privKey" | wg pubkey)
# authorize our WG keys with the PIA server we are about to connect to
wireguard_json=`curl -s -G --connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" --cacert "${./ca.rsa.4096.crt}" --data-urlencode "pt=$PIA_TOKEN" --data-urlencode "pubkey=$pubKey" https://$WG_HOSTNAME:$WG_SERVER_PORT/addKey`
# create wg-quick config file
rm -f /tmp/${cfg.interfaceName}.conf /tmp/${cfg.interfaceName}-address.conf
touch /tmp/${cfg.interfaceName}.conf /tmp/${cfg.interfaceName}-address.conf
chmod 700 /tmp/${cfg.interfaceName}.conf /tmp/${cfg.interfaceName}-address.conf
echo "
[Interface]
# Address = $(echo "$wireguard_json" | jq -r '.peer_ip')
PrivateKey = $privKey
ListenPort = ${toString cfg.wireguardListenPort}
[Peer]
PersistentKeepalive = 25
PublicKey = $(echo "$wireguard_json" | jq -r '.server_key')
AllowedIPs = 0.0.0.0/0
Endpoint = $WG_SERVER_IP:$(echo "$wireguard_json" | jq -r '.server_port')
" >> /tmp/${cfg.interfaceName}.conf
# create file storing the VPN ip address PIA assigned to us
echo "$wireguard_json" | jq -r '.peer_ip' >> /tmp/${cfg.interfaceName}-address.conf
# Create wg interface now so it inherits from the namespace with internet access
# the container will handle actually connecting the interface since that info is
# not preserved upon moving into the container's networking namespace
# Roughly following this guide https://www.wireguard.com/netns/#ordinary-containerization
[[ -z $(ip link show dev ${cfg.interfaceName} 2>/dev/null) ]] || exit
ip link add ${cfg.interfaceName} type wireguard
'';
preStop = ''
# cleanup wireguard interface
ip link del ${cfg.interfaceName}
rm -f /tmp/${cfg.interfaceName}.conf /tmp/${cfg.interfaceName}-address.conf
'';
};
vpn-container.config.systemd.services.pia-vpn-wireguard = {
description = "Initializes the PIA VPN WireGuard Tunnel";
requires = [ "network-online.target" ];
after = [ "network.target" "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = with pkgs; [ wireguard-tools iproute curl jq iptables ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
# pseudo calls wg-quick
# Near equivalent of "wg-quick up /tmp/${cfg.interfaceName}.conf"
# cannot actually call wg-quick because the interface has to be already
# created before the container taken ownership of the interface
# Thus, assumes wg interface was already created:
# ip link add ${cfg.interfaceName} type wireguard
${getChosenWireguardServer}
myaddress=`cat /tmp/${cfg.interfaceName}-address.conf`
wg setconf ${cfg.interfaceName} /tmp/${cfg.interfaceName}.conf
ip -4 address add $myaddress dev ${cfg.interfaceName}
ip link set mtu 1420 up dev ${cfg.interfaceName}
wg set ${cfg.interfaceName} fwmark ${toString cfg.wireguardListenPort}
ip -4 route add 0.0.0.0/0 dev ${cfg.interfaceName} table ${toString cfg.wireguardListenPort}
# TODO is this needed?
ip -4 rule add not fwmark ${toString cfg.wireguardListenPort} table ${toString cfg.wireguardListenPort}
ip -4 rule add table main suppress_prefixlength 0
# The rest of the script is only for only for port forwarding skip if not needed
if [ ${boolToString portForwarding} == false ]; then exit 0; fi
# Reserve port
${getPIAToken}
payload_and_signature=`curl -s -m 5 --connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" --cacert "${./ca.rsa.4096.crt}" -G --data-urlencode "token=$PIA_TOKEN" "https://$WG_HOSTNAME:19999/getSignature"`
signature=$(echo "$payload_and_signature" | jq -r '.signature')
payload=$(echo "$payload_and_signature" | jq -r '.payload')
port=$(echo "$payload" | base64 -d | jq -r '.port')
# Check if the port is acceptable
notallowed=(${concatStringsSep " " (map toString cfg.badPortForwardPorts)})
if [[ " ''${notallowed[*]} " =~ " $port " ]]; then
# the port PIA assigned is not allowed, kill the connection
wg-quick down /tmp/${cfg.interfaceName}.conf
exit 1
fi
# write reserved port to file readable for all users
echo $port > /tmp/${cfg.interfaceName}-port
chmod 644 /tmp/${cfg.interfaceName}-port
# write payload and signature info needed to allow refreshing allocated forwarded port
rm -f /tmp/${cfg.interfaceName}-port-renewal
touch /tmp/${cfg.interfaceName}-port-renewal
chmod 700 /tmp/${cfg.interfaceName}-port-renewal
echo $signature >> /tmp/${cfg.interfaceName}-port-renewal
echo $payload >> /tmp/${cfg.interfaceName}-port-renewal
# Block all traffic from VPN interface except for traffic that is from the forwarded port
iptables -I nixos-fw -p tcp --dport $port -j nixos-fw-accept -i ${cfg.interfaceName}
iptables -I nixos-fw -p udp --dport $port -j nixos-fw-accept -i ${cfg.interfaceName}
# The first port refresh triggers the port to be actually allocated
${refreshPIAPort}
${optionalString (cfg.forwardedPort != null) ''
# redirect the fowarded port
iptables -A INPUT -i ${cfg.interfaceName} -p tcp --dport $port -j ACCEPT
iptables -A INPUT -i ${cfg.interfaceName} -p udp --dport $port -j ACCEPT
iptables -A INPUT -i ${cfg.interfaceName} -p tcp --dport ${toString cfg.forwardedPort} -j ACCEPT
iptables -A INPUT -i ${cfg.interfaceName} -p udp --dport ${toString cfg.forwardedPort} -j ACCEPT
iptables -A PREROUTING -t nat -i ${cfg.interfaceName} -p tcp --dport $port -j REDIRECT --to-port ${toString cfg.forwardedPort}
iptables -A PREROUTING -t nat -i ${cfg.interfaceName} -p udp --dport $port -j REDIRECT --to-port ${toString cfg.forwardedPort}
''}
${optionalString cfg.forwardPortForTransmission ''
# assumes no auth needed for transmission
curlout=$(curl localhost:9091/transmission/rpc 2>/dev/null)
regex='X-Transmission-Session-Id\: (\w*)'
if [[ $curlout =~ $regex ]]; then
sessionId=''${BASH_REMATCH[1]}
else
exit 1
fi
# set the port in transmission
data='{"method": "session-set", "arguments": { "peer-port" :'$port' } }'
curl http://localhost:9091/transmission/rpc -d "$data" -H "X-Transmission-Session-Id: $sessionId"
''}
'';
preStop = ''
wg-quick down /tmp/${cfg.interfaceName}.conf
# The rest of the script is only for only for port forwarding skip if not needed
if [ ${boolToString portForwarding} == false ]; then exit 0; fi
${optionalString (cfg.forwardedPort != null) ''
# stop redirecting the forwarded port
iptables -D INPUT -i ${cfg.interfaceName} -p tcp --dport $port -j ACCEPT
iptables -D INPUT -i ${cfg.interfaceName} -p udp --dport $port -j ACCEPT
iptables -D INPUT -i ${cfg.interfaceName} -p tcp --dport ${toString cfg.forwardedPort} -j ACCEPT
iptables -D INPUT -i ${cfg.interfaceName} -p udp --dport ${toString cfg.forwardedPort} -j ACCEPT
iptables -D PREROUTING -t nat -i ${cfg.interfaceName} -p tcp --dport $port -j REDIRECT --to-port ${toString cfg.forwardedPort}
iptables -D PREROUTING -t nat -i ${cfg.interfaceName} -p udp --dport $port -j REDIRECT --to-port ${toString cfg.forwardedPort}
''}
'';
};
vpn-container.config.systemd.services.pia-vpn-wireguard-forward-port = {
enable = portForwarding;
description = "PIA VPN WireGuard Tunnel Port Forwarding";
after = [ "pia-vpn-wireguard.service" ];
requires = [ "pia-vpn-wireguard.service" ];
path = with pkgs; [ curl ];
serviceConfig = {
Type = "oneshot";
};
script = refreshPIAPort;
};
vpn-container.config.systemd.timers.pia-vpn-wireguard-forward-port = {
enable = portForwarding;
partOf = [ "pia-vpn-wireguard-forward-port.service" ];
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*:0/10"; # 10 minutes
RandomizedDelaySec = "1m"; # vary by 1 min to give PIA servers some relief
};
};
age.secrets."pia-login.conf".file = ../../secrets/pia-login.age;
};
}

View File

@@ -1,59 +0,0 @@
{ config, pkgs, lib, ... }:
# keeps peer to peer connections alive with a periodic ping
with lib;
with builtins;
# todo auto restart
let
cfg = config.keepalive-ping;
serviceTemplate = host:
{
"keepalive-ping@${host}" = {
description = "Periodic ping keep alive for ${host} connection";
requires = [ "network-online.target" ];
after = [ "network.target" "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig.Restart = "always";
path = with pkgs; [ iputils ];
script = ''
ping -i ${cfg.delay} ${host} &>/dev/null
'';
};
};
combineAttrs = foldl recursiveUpdate { };
serviceList = map serviceTemplate cfg.hosts;
services = combineAttrs serviceList;
in
{
options.keepalive-ping = {
enable = mkEnableOption "Enable keep alive ping task";
hosts = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Hosts to ping periodically
'';
};
delay = mkOption {
type = types.str;
default = "60";
description = ''
Ping interval in seconds of periodic ping per host being pinged
'';
};
};
config = mkIf cfg.enable {
systemd.services = services;
};
}

126
common/network/sandbox.nix Normal file
View File

@@ -0,0 +1,126 @@
{ config, lib, ... }:
# Network configuration for sandboxed workspaces (VMs and containers)
# Creates a bridge network with NAT for isolated environments
with lib;
let
cfg = config.networking.sandbox;
in
{
options.networking.sandbox = {
enable = mkEnableOption "sandboxed workspace network bridge";
bridgeName = mkOption {
type = types.str;
default = "sandbox-br";
description = "Name of the bridge interface for sandboxed workspaces";
};
subnet = mkOption {
type = types.str;
default = "192.168.83.0/24";
description = "Subnet for sandboxed workspace network";
};
hostAddress = mkOption {
type = types.str;
default = "192.168.83.1";
description = "Host address on the sandbox bridge";
};
upstreamInterface = mkOption {
type = types.str;
description = "Upstream network interface for NAT";
};
};
config = mkIf cfg.enable {
networking.ip_forward = true;
# Create the bridge interface
systemd.network.netdevs."10-${cfg.bridgeName}" = {
netdevConfig = {
Kind = "bridge";
Name = cfg.bridgeName;
};
};
systemd.network.networks."10-${cfg.bridgeName}" = {
matchConfig.Name = cfg.bridgeName;
networkConfig = {
Address = "${cfg.hostAddress}/24";
DHCPServer = false;
IPv4Forwarding = true;
IPv6Forwarding = false;
IPMasquerade = "ipv4";
};
linkConfig.RequiredForOnline = "no";
};
# Automatically attach VM tap interfaces to the bridge
systemd.network.networks."11-vm" = {
matchConfig.Name = "vm-*";
networkConfig.Bridge = cfg.bridgeName;
linkConfig.RequiredForOnline = "no";
};
# Automatically attach container veth interfaces to the bridge
systemd.network.networks."11-container" = {
matchConfig.Name = "ve-*";
networkConfig.Bridge = cfg.bridgeName;
linkConfig.RequiredForOnline = "no";
};
# NAT configuration for sandboxed workspaces
networking.nat = {
enable = true;
internalInterfaces = [ cfg.bridgeName ];
externalInterface = cfg.upstreamInterface;
};
# Enable systemd-networkd (required for bridge setup)
systemd.network.enable = true;
# When NetworkManager handles primary networking, disable systemd-networkd-wait-online.
# The bridge is the only interface managed by systemd-networkd and it never reaches
# "online" state without connected workspaces. NetworkManager-wait-online.service already
# gates network-online.target for the primary interface.
# On pure systemd-networkd systems (no NM), we just ignore the bridge.
systemd.network.wait-online.enable =
!config.networking.networkmanager.enable;
systemd.network.wait-online.ignoredInterfaces =
lib.mkIf (!config.networking.networkmanager.enable) [ cfg.bridgeName ];
# If NetworkManager is enabled, tell it to ignore sandbox interfaces
# This allows systemd-networkd and NetworkManager to coexist
networking.networkmanager.unmanaged = [
"interface-name:${cfg.bridgeName}"
"interface-name:vm-*"
"interface-name:ve-*"
"interface-name:veth*"
];
# Make systemd-resolved listen on the bridge for workspace DNS queries.
# By default resolved only listens on 127.0.0.53 (localhost).
# DNSStubListenerExtra adds the bridge address so workspaces can use the host as DNS.
services.resolved.settings.Resolve.DNSStubListenerExtra = cfg.hostAddress;
# Allow DNS traffic from workspaces to the host
networking.firewall.interfaces.${cfg.bridgeName} = {
allowedTCPPorts = [ 53 ];
allowedUDPPorts = [ 53 ];
};
# Block sandboxes from reaching the local network (private RFC1918 ranges)
# while still allowing public internet access via NAT.
# The sandbox subnet itself is allowed so workspaces can reach the host gateway.
networking.firewall.extraForwardRules = ''
iifname ${cfg.bridgeName} ip daddr ${cfg.hostAddress} accept
iifname ${cfg.bridgeName} ip daddr 10.0.0.0/8 drop
iifname ${cfg.bridgeName} ip daddr 172.16.0.0/12 drop
iifname ${cfg.bridgeName} ip daddr 192.168.0.0/16 drop
'';
};
}

View File

@@ -10,6 +10,10 @@ in
config.services.tailscale.enable = mkDefault (!config.boot.isContainer);
# Trust Tailscale interface - access control is handled by Tailscale ACLs.
# Required because nftables (used by Incus) breaks Tailscale's automatic iptables rules.
config.networking.firewall.trustedInterfaces = mkIf cfg.enable [ "tailscale0" ];
# MagicDNS
config.networking.nameservers = mkIf cfg.enable [ "1.1.1.1" "8.8.8.8" ];
config.networking.search = mkIf cfg.enable [ "koi-bebop.ts.net" ];

View File

@@ -1,109 +0,0 @@
{ config, pkgs, lib, allModules, ... }:
with lib;
let
cfg = config.vpn-container;
in
{
options.vpn-container = {
enable = mkEnableOption "Enable VPN container";
containerName = mkOption {
type = types.str;
default = "vpn";
description = ''
Name of the VPN container.
'';
};
mounts = mkOption {
type = types.listOf types.str;
default = [ "/var/lib" ];
example = "/home/example";
description = ''
List of mounts on the host to bind to the vpn container.
'';
};
useOpenVPN = mkEnableOption "Uses OpenVPN instead of wireguard for PIA VPN connection";
config = mkOption {
type = types.anything;
default = { };
example = ''
{
services.nginx.enable = true;
}
'';
description = ''
NixOS config for the vpn container.
'';
};
};
config = mkIf cfg.enable {
pia.wireguard.enable = !cfg.useOpenVPN;
pia.wireguard.forwardPortForTransmission = !cfg.useOpenVPN;
containers.${cfg.containerName} = {
ephemeral = true;
autoStart = true;
bindMounts = mkMerge ([{
"/run/agenix" = {
hostPath = "/run/agenix";
isReadOnly = true;
};
}] ++ (lists.forEach cfg.mounts (mount:
{
"${mount}" = {
hostPath = mount;
isReadOnly = false;
};
}
)));
enableTun = cfg.useOpenVPN;
privateNetwork = true;
hostAddress = "172.16.100.1";
localAddress = "172.16.100.2";
config = {
imports = allModules ++ [ cfg.config ];
# speeds up evaluation
nixpkgs.pkgs = pkgs;
# networking.firewall.enable = mkForce false;
networking.firewall.trustedInterfaces = [
# completely trust internal interface to host
"eth0"
];
pia.openvpn.enable = cfg.useOpenVPN;
pia.openvpn.server = "swiss.privacy.network"; # swiss vpn
# TODO fix so it does run it's own resolver again
# run it's own DNS resolver
networking.useHostResolvConf = false;
# services.resolved.enable = true;
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
};
};
# load secrets the container needs
age.secrets = config.containers.${cfg.containerName}.config.age.secrets;
# forwarding for vpn container (only for OpenVPN)
networking.nat.enable = mkIf cfg.useOpenVPN true;
networking.nat.internalInterfaces = mkIf cfg.useOpenVPN [
"ve-${cfg.containerName}"
];
networking.ip_forward = mkIf cfg.useOpenVPN true;
# assumes only one potential interface
networking.usePredictableInterfaceNames = false;
networking.nat.externalInterface = "eth0";
};
}

View File

@@ -1,187 +0,0 @@
#!/usr/bin/env bash
set -eEo pipefail
# $@ := ""
set_route_vars() {
local network_var
local -a network_vars; read -ra network_vars <<<"${!route_network_*}"
for network_var in "${network_vars[@]}"; do
local -i i="${network_var#route_network_}"
local -a vars=("route_network_$i" "route_netmask_$i" "route_gateway_$i" "route_metric_$i")
route_networks[i]="${!vars[0]}"
route_netmasks[i]="${!vars[1]:-255.255.255.255}"
route_gateways[i]="${!vars[2]:-$route_vpn_gateway}"
route_metrics[i]="${!vars[3]:-0}"
done
}
# Configuration.
readonly prog="$(basename "$0")"
readonly private_nets="127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
declare -a remotes cnf_remote_domains cnf_remote_ips route_networks route_netmasks route_gateways route_metrics
read -ra remotes <<<"$(env|grep -oP '^remote_[0-9]+=.*'|sort -n|cut -d= -f2|tr '\n' '\t')"
read -ra cnf_remote_domains <<<"$(printf '%s\n' "${remotes[@]%%*[0-9]}"|sort -u|tr '\n' '\t')"
read -ra cnf_remote_ips <<<"$(printf '%s\n' "${remotes[@]##*[!0-9.]*}"|sort -u|tr '\n' '\t')"
set_route_vars
read -ra numbered_vars <<<"${!foreign_option_*} ${!proto_*} ${!remote_*} ${!remote_port_*} \
${!route_network_*} ${!route_netmask_*} ${!route_gateway_*} ${!route_metric_*}"
readonly numbered_vars "${numbered_vars[@]}" dev ifconfig_local ifconfig_netmask ifconfig_remote \
route_net_gateway route_vpn_gateway script_type trusted_ip trusted_port untrusted_ip untrusted_port \
remotes cnf_remote_domains cnf_remote_ips route_networks route_netmasks route_gateways route_metrics
readonly cur_remote_ip="${trusted_ip:-$untrusted_ip}"
readonly cur_port="${trusted_port:-$untrusted_port}"
# $@ := ""
update_hosts() {
if remote_entries="$(getent -s dns hosts "${cnf_remote_domains[@]}"|grep -v :)"; then
local -r beg="# VPNFAILSAFE BEGIN" end="# VPNFAILSAFE END"
{
sed -e "/^$beg/,/^$end/d" /etc/hosts
echo -e "$beg\\n$remote_entries\\n$end"
} >/etc/hosts.vpnfailsafe
chmod --reference=/etc/hosts /etc/hosts.vpnfailsafe
mv /etc/hosts.vpnfailsafe /etc/hosts
fi
}
# $@ := "up" | "down"
update_routes() {
local -a resolved_ips
read -ra resolved_ips <<<"$(getent -s files hosts "${cnf_remote_domains[@]:-ENOENT}"|cut -d' ' -f1|tr '\n' '\t' || true)"
local -ar remote_ips=("$cur_remote_ip" "${resolved_ips[@]}" "${cnf_remote_ips[@]}")
if [[ "$*" == up ]]; then
for remote_ip in "${remote_ips[@]}"; do
if [[ -n "$remote_ip" && -z "$(ip route show "$remote_ip")" ]]; then
ip route add "$remote_ip" via "$route_net_gateway"
fi
done
for net in 0.0.0.0/1 128.0.0.0/1; do
if [[ -z "$(ip route show "$net")" ]]; then
ip route add "$net" via "$route_vpn_gateway"
fi
done
for i in $(seq 1 "${#route_networks[@]}"); do
if [[ -z "$(ip route show "${route_networks[i]}/${route_netmasks[i]}")" ]]; then
ip route add "${route_networks[i]}/${route_netmasks[i]}" \
via "${route_gateways[i]}" metric "${route_metrics[i]}" dev "$dev"
fi
done
elif [[ "$*" == down ]]; then
for route in "${remote_ips[@]}" 0.0.0.0/1 128.0.0.0/1; do
if [[ -n "$route" && -n "$(ip route show "$route")" ]]; then
ip route del "$route"
fi
done
for i in $(seq 1 "${#route_networks[@]}"); do
if [[ -n "$(ip route show "${route_networks[i]}/${route_netmasks[i]}")" ]]; then
ip route del "${route_networks[i]}/${route_netmasks[i]}"
fi
done
fi
}
# $@ := ""
update_firewall() {
# $@ := "INPUT" | "OUTPUT" | "FORWARD"
insert_chain() {
if iptables -C "$*" -j "VPNFAILSAFE_$*" 2>/dev/null; then
iptables -D "$*" -j "VPNFAILSAFE_$*"
for opt in F X; do
iptables -"$opt" "VPNFAILSAFE_$*"
done
fi
iptables -N "VPNFAILSAFE_$*"
iptables -I "$*" -j "VPNFAILSAFE_$*"
}
# $@ := "INPUT" | "OUTPUT"
accept_remotes() {
case "$@" in
INPUT) local -r icmp_type=reply io=i sd=s states="";;
OUTPUT) local -r icmp_type=request io=o sd=d states=NEW,;;
esac
local -r public_nic="$(ip route show "$cur_remote_ip"|cut -d' ' -f5)"
local -ar suf=(-m conntrack --ctstate "$states"RELATED,ESTABLISHED -"$io" "${public_nic:?}" -j ACCEPT)
icmp_rule() {
iptables "$1" "$2" -p icmp --icmp-type "echo-$icmp_type" -"$sd" "$3" "${suf[@]/%ACCEPT/RETURN}"
}
for ((i=1; i <= ${#remotes[*]}; ++i)); do
local port="remote_port_$i"
local proto="proto_$i"
iptables -A "VPNFAILSAFE_$*" -p "${!proto%-client}" -"$sd" "${remotes[i-1]}" --"$sd"port "${!port}" "${suf[@]}"
if ! icmp_rule -C "VPNFAILSAFE_$*" "${remotes[i-1]}" 2>/dev/null; then
icmp_rule -A "VPNFAILSAFE_$*" "${remotes[i-1]}"
fi
done
if ! iptables -S|grep -q "^-A VPNFAILSAFE_$* .*-$sd $cur_remote_ip/32 .*-j ACCEPT$"; then
for p in tcp udp; do
iptables -A "VPNFAILSAFE_$*" -p "$p" -"$sd" "$cur_remote_ip" --"$sd"port "${cur_port}" "${suf[@]}"
done
icmp_rule -A "VPNFAILSAFE_$*" "$cur_remote_ip"
fi
}
# $@ := "OUTPUT" | "FORWARD"
reject_dns() {
for proto in udp tcp; do
iptables -A "VPNFAILSAFE_$*" -p "$proto" --dport 53 ! -o "$dev" -j REJECT
done
}
# $@ := "INPUT" | "OUTPUT" | "FORWARD"
pass_private_nets() {
case "$@" in
INPUT) local -r io=i sd=s;;&
OUTPUT|FORWARD) local -r io=o sd=d;;&
INPUT) local -r vpn="${ifconfig_remote:-$ifconfig_local}/${ifconfig_netmask:-32}"
iptables -A "VPNFAILSAFE_$*" -"$sd" "$vpn" -"$io" "$dev" -j RETURN
for i in $(seq 1 "${#route_networks[@]}"); do
iptables -A "VPNFAILSAFE_$*" -"$sd" "${route_networks[i]}/${route_netmasks[i]}" -"$io" "$dev" -j RETURN
done;;&
*) iptables -A "VPNFAILSAFE_$*" -"$sd" "$private_nets" ! -"$io" "$dev" -j RETURN;;&
INPUT) iptables -A "VPNFAILSAFE_$*" -s "$private_nets" -i "$dev" -j DROP;;&
*) for iface in "$dev" lo+; do
iptables -A "VPNFAILSAFE_$*" -"$io" "$iface" -j RETURN
done;;
esac
}
# $@ := "INPUT" | "OUTPUT" | "FORWARD"
drop_other() {
iptables -A "VPNFAILSAFE_$*" -j DROP
}
for chain in INPUT OUTPUT FORWARD; do
insert_chain "$chain"
[[ $chain == FORWARD ]] || accept_remotes "$chain"
[[ $chain == INPUT ]] || reject_dns "$chain"
pass_private_nets "$chain"
drop_other "$chain"
done
}
# $@ := ""
cleanup() {
update_resolv down
update_routes down
}
trap cleanup INT TERM
# $@ := line_number exit_code
err_msg() {
echo "$0:$1: \`$(sed -n "$1,+0{s/^\\s*//;p}" "$0")' returned $2" >&2
cleanup
}
trap 'err_msg "$LINENO" "$?"' ERR
# $@ := ""
main() {
case "${script_type:-down}" in
up) for f in hosts routes firewall; do "update_$f" up; done;;
down) update_routes down
update_resolv down;;
esac
}
main

View File

@@ -1,22 +1,18 @@
{ config, lib, ... }:
let
builderRole = "nix-builder";
builderUserName = "nix-builder";
machinesByRole = role: lib.filterAttrs (hostname: cfg: builtins.elem role cfg.systemRoles) config.machines.hosts;
otherMachinesByRole = role: lib.filterAttrs (hostname: cfg: hostname != config.networking.hostName) (machinesByRole role);
thisMachineHasRole = role: builtins.hasAttr config.networking.hostName (machinesByRole role);
builders = machinesByRole builderRole;
thisMachineIsABuilder = thisMachineHasRole builderRole;
builderRole = "nix-builder";
builders = config.machines.withRole.${builderRole} or [];
thisMachineIsABuilder = config.thisMachine.hasRole.${builderRole} or false;
# builders don't include themselves as a remote builder
otherBuilders = lib.filterAttrs (hostname: cfg: hostname != config.networking.hostName) builders;
otherBuilders = lib.filter (hostname: hostname != config.networking.hostName) builders;
in
lib.mkMerge [
# configure builder
(lib.mkIf thisMachineIsABuilder {
(lib.mkIf (thisMachineIsABuilder && !config.boot.isContainer) {
users.users.${builderUserName} = {
description = "Distributed Nix Build User";
group = builderUserName;
@@ -40,9 +36,9 @@ lib.mkMerge [
nix.distributedBuilds = true;
nix.buildMachines = builtins.map
(builderCfg: {
hostName = builtins.elemAt builderCfg.hostNames 0;
system = builderCfg.arch;
(builderHostname: {
hostName = builderHostname;
system = config.machines.hosts.${builderHostname}.arch;
protocol = "ssh-ng";
sshUser = builderUserName;
sshKey = "/etc/ssh/ssh_host_ed25519_key";
@@ -50,7 +46,7 @@ lib.mkMerge [
speedFactor = 10;
supportedFeatures = [ "nixos-test" "benchmark" "big-parallel" "kvm" ];
})
(builtins.attrValues otherBuilders);
otherBuilders;
# It is very likely that the builder's internet is faster or just as fast
nix.extraOptions = ''

27
common/ntfy/default.nix Normal file
View File

@@ -0,0 +1,27 @@
{ config, lib, ... }:
{
imports = [
./service-failure.nix
./ssh-login.nix
./zfs.nix
];
options.ntfy-alerts = {
serverUrl = lib.mkOption {
type = lib.types.str;
default = "https://ntfy.neet.dev";
description = "Base URL of the ntfy server.";
};
curlExtraArgs = lib.mkOption {
type = lib.types.str;
default = "";
description = "Extra arguments to pass to curl (e.g. --proxy http://host:port).";
};
};
config = lib.mkIf config.thisMachine.hasRole."ntfy" {
age.secrets.ntfy-token.file = ../../secrets/ntfy-token.age;
};
}

View File

@@ -0,0 +1,48 @@
{ config, lib, pkgs, ... }:
let
cfg = config.ntfy-alerts;
in
{
config = lib.mkIf config.thisMachine.hasRole."ntfy" {
systemd.services."ntfy-failure@" = {
description = "Send ntfy alert for failed unit %i";
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
EnvironmentFile = "/run/agenix/ntfy-token";
ExecStart = "${pkgs.writeShellScript "ntfy-failure-notify" ''
unit="$1"
logfile=$(mktemp)
trap 'rm -f "$logfile"' EXIT
${pkgs.systemd}/bin/journalctl -u "$unit" -n 50 --no-pager -o short > "$logfile" 2>/dev/null \
|| echo "(no logs available)" > "$logfile"
${lib.getExe pkgs.curl} \
-T "$logfile" \
--fail --silent --show-error \
--max-time 30 --retry 3 \
${cfg.curlExtraArgs} \
-H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: Service failure on ${config.networking.hostName}" \
-H "Priority: high" \
-H "Tags: rotating_light" \
-H "Message: Unit $unit failed at $(date +%c)" \
-H "Filename: $unit.log" \
"${cfg.serverUrl}/service-failures"
''} %i";
};
};
# Apply OnFailure to all services via a systemd drop-in
systemd.packages = [
(pkgs.runCommand "ntfy-on-failure-dropin" { } ''
mkdir -p $out/lib/systemd/system/service.d
cat > $out/lib/systemd/system/service.d/ntfy-on-failure.conf <<'EOF'
[Unit]
OnFailure=ntfy-failure@%p.service
EOF
'')
];
};
}

36
common/ntfy/ssh-login.nix Normal file
View File

@@ -0,0 +1,36 @@
{ config, lib, pkgs, ... }:
let
cfg = config.ntfy-alerts;
notifyScript = pkgs.writeShellScript "ssh-login-notify" ''
# Only notify on session open, not close
[ "$PAM_TYPE" = "open_session" ] || exit 0
. /run/agenix/ntfy-token
# Send notification in background so login isn't delayed
${lib.getExe pkgs.curl} \
--fail --silent --show-error \
--max-time 10 --retry 1 \
${cfg.curlExtraArgs} \
-H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: SSH login on ${config.networking.hostName}" \
-H "Tags: key" \
-d "$PAM_USER from $PAM_RHOST at $(date +%c)" \
"${cfg.serverUrl}/ssh-logins" &
'';
in
{
config = lib.mkIf config.thisMachine.hasRole."ntfy" {
security.pam.services.sshd.rules.session.ntfy-login = {
order = 99999;
control = "optional";
modulePath = "${pkgs.pam}/lib/security/pam_exec.so";
args = [
"quiet"
(toString notifyScript)
];
};
};
}

87
common/ntfy/zfs.nix Normal file
View File

@@ -0,0 +1,87 @@
{ config, lib, pkgs, ... }:
let
cfg = config.ntfy-alerts;
hasZfs = config.boot.supportedFilesystems.zfs or false;
hasNtfy = config.thisMachine.hasRole."ntfy";
checkScript = pkgs.writeShellScript "zfs-health-check" ''
PATH="${lib.makeBinPath [ pkgs.zfs pkgs.coreutils pkgs.gawk pkgs.curl ]}"
unhealthy=""
# Check pool health status
while IFS=$'\t' read -r pool state; do
if [ "$state" != "ONLINE" ]; then
unhealthy="$unhealthy"$'\n'"Pool '$pool' is $state"
fi
done < <(zpool list -H -o name,health)
# Check for errors (read, write, checksum) on any vdev
while IFS=$'\t' read -r pool errors; do
if [ "$errors" != "No known data errors" ] && [ -n "$errors" ]; then
unhealthy="$unhealthy"$'\n'"Pool '$pool' has errors: $errors"
fi
done < <(zpool status -x 2>/dev/null | awk '
/pool:/ { pool=$2 }
/errors:/ { sub(/^[[:space:]]*errors: /, ""); print pool "\t" $0 }
')
# Check for any drives with non-zero error counts
drive_errors=$(zpool status 2>/dev/null | awk '
/DEGRADED|FAULTED|OFFLINE|UNAVAIL|REMOVED/ && !/pool:/ && !/state:/ {
print " " $0
}
/[0-9]+[[:space:]]+[0-9]+[[:space:]]+[0-9]+/ {
if ($3 > 0 || $4 > 0 || $5 > 0) {
print " " $1 " (read:" $3 " write:" $4 " cksum:" $5 ")"
}
}
')
if [ -n "$drive_errors" ]; then
unhealthy="$unhealthy"$'\n'"Device errors:"$'\n'"$drive_errors"
fi
if [ -n "$unhealthy" ]; then
message="ZFS health check failed on ${config.networking.hostName}:$unhealthy"
curl \
--fail --silent --show-error \
--max-time 30 --retry 3 \
-H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: ZFS issue on ${config.networking.hostName}" \
-H "Priority: urgent" \
-H "Tags: warning" \
-d "$message" \
"${cfg.serverUrl}/service-failures"
echo "$message" >&2
fi
echo "All ZFS pools healthy"
'';
in
{
config = lib.mkIf (hasZfs && hasNtfy) {
systemd.services.zfs-health-check = {
description = "Check ZFS pool health and alert on issues";
wants = [ "network-online.target" ];
after = [ "network-online.target" "zfs.target" ];
serviceConfig = {
Type = "oneshot";
EnvironmentFile = "/run/agenix/ntfy-token";
ExecStart = checkScript;
};
};
systemd.timers.zfs-health-check = {
description = "Periodic ZFS health check";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "daily";
Persistent = true;
RandomizedDelaySec = "1h";
};
};
};
}

View File

@@ -1,4 +1,4 @@
{ lib, config, pkgs, ... }:
{ lib, config, ... }:
let
cfg = config.de;
@@ -19,6 +19,15 @@ in
jack.enable = true;
};
services.pipewire.extraConfig.pipewire."92-fix-wine-audio" = {
context.properties = {
default.clock.rate = 48000;
default.clock.quantum = 256;
default.clock.min-quantum = 256;
default.clock.max-quantum = 2048;
};
};
users.users.googlebot.extraGroups = [ "audio" ];
# bt headset support

View File

@@ -41,32 +41,28 @@ in
"SpellcheckLanguage" = [ "en-US" ];
};
defaultSearchProviderSuggestURL = null;
defaultSearchProviderSearchURL = " https://duckduckgo.com/?q={searchTerms}&kp=-1&kl=us-en";
defaultSearchProviderSearchURL = "https://duckduckgo.com/?q={searchTerms}&kp=-1&kl=us-en";
};
# hardware accelerated video playback (on intel)
nixpkgs.config.packageOverrides = pkgs: {
vaapiIntel = pkgs.vaapiIntel.override { enableHybridCodec = true; };
chromium = pkgs.chromium.override {
enableWideVine = true;
# ungoogled = true;
# --enable-native-gpu-memory-buffers # fails on AMD APU
# --enable-webrtc-vp9-support
commandLineArgs = "--use-vulkan --use-gl=desktop --enable-zero-copy --enable-hardware-overlays --enable-features=VaapiVideoDecoder,CanvasOopRasterization --ignore-gpu-blocklist --enable-accelerated-mjpeg-decode --enable-accelerated-video --enable-gpu-rasterization";
commandLineArgs = "--use-vulkan";
};
};
# todo vulkan in chrome
# todo video encoding in chrome
hardware.opengl = {
hardware.graphics = {
enable = true;
extraPackages = with pkgs; [
intel-media-driver # LIBVA_DRIVER_NAME=iHD
vaapiIntel # LIBVA_DRIVER_NAME=i965 (older but works better for Firefox/Chromium)
# vaapiVdpau
libvdpau-va-gl
nvidia-vaapi-driver
];
extraPackages32 = with pkgs.pkgsi686Linux; [ vaapiIntel ];
};
};
}

View File

@@ -6,19 +6,17 @@ in
{
imports = [
./kde.nix
./xfce.nix
./yubikey.nix
./chromium.nix
# ./firefox.nix
./firefox.nix
./audio.nix
# ./torbrowser.nix
./pithos.nix
./spotify.nix
./vscodium.nix
./discord.nix
./steam.nix
./touchpad.nix
./mount-samba.nix
./udev.nix
./virtualisation.nix
];
options.de = {
@@ -26,9 +24,10 @@ in
};
config = lib.mkIf cfg.enable {
# vulkan
hardware.opengl.driSupport = true;
hardware.opengl.driSupport32Bit = true;
environment.systemPackages = with pkgs; [
# https://github.com/NixOS/nixpkgs/pull/328086#issuecomment-2235384618
gparted
];
# Applications
users.users.googlebot.packages = with pkgs; [
@@ -41,42 +40,77 @@ in
mpv
nextcloud-client
signal-desktop
minecraft
gparted
libreoffice-fresh
thunderbird
spotifyd
spotify-qt
spotify
arduino
yt-dlp
jellyfin-media-player
joplin-desktop
config.inputs.deploy-rs.packages.${config.currentSystem}.deploy-rs
lxqt.pavucontrol-qt
barrier
deskflow
file-roller
android-tools
logseq
# For Nix IDE
nixpkgs-fmt
rnix-lsp
nixd
nil
godot-mono
];
# Networking
networking.networkmanager.enable = true;
users.users.googlebot.extraGroups = [ "networkmanager" ];
# Printing
services.printing.enable = true;
services.printing.drivers = with pkgs; [
gutenprint
];
# Printer discovery
services.avahi.enable = true;
services.avahi.nssmdns = true;
programs.file-roller.enable = true;
# Scanning
hardware.sane.enable = true;
hardware.sane.extraBackends = with pkgs; [
# Enable support for "driverless" scanners
# Check for support here: https://mfi.apple.com/account/airprint-search
sane-airscan
];
# Printer/Scanner discovery
services.avahi.enable = true;
services.avahi.nssmdns4 = true;
# Security
services.gnome.gnome-keyring.enable = true;
security.pam.services.googlebot.enableGnomeKeyring = true;
# Mount personal SMB stores
services.mount-samba.enable = true;
# allow building ARM derivations
boot.binfmt.emulatedSystems = [ "aarch64-linux" ];
# for luks onlock over tor
services.tor.enable = true;
services.tor.client.enable = true;
# Enable wayland support in various chromium based applications
environment.sessionVariables.NIXOS_OZONE_WL = "1";
fonts.packages = with pkgs; [ nerd-fonts.symbols-only ];
# SSH Ask pass
programs.ssh.enableAskPassword = true;
programs.ssh.askPassword = "${pkgs.kdePackages.ksshaskpass}/bin/ksshaskpass";
users.users.googlebot.extraGroups = [
# Networking
"networkmanager"
# Scanning
"scanner"
"lp"
];
};
}

View File

@@ -20,31 +20,6 @@ let
};
firefox = pkgs.wrapFirefox somewhatPrivateFF {
desktopName = "Sneed Browser";
nixExtensions = [
(pkgs.fetchFirefoxAddon {
name = "ublock-origin";
url = "https://addons.mozilla.org/firefox/downloads/file/3719054/ublock_origin-1.33.2-an+fx.xpi";
sha256 = "XDpe9vW1R1iVBTI4AmNgAg1nk7BVQdIAMuqd0cnK5FE=";
})
(pkgs.fetchFirefoxAddon {
name = "sponsorblock";
url = "https://addons.mozilla.org/firefox/downloads/file/3720594/sponsorblock_skip_sponsorships_on_youtube-2.0.12.3-an+fx.xpi";
sha256 = "HRtnmZWyXN3MKo4AvSYgNJGkBEsa2RaMamFbkz+YzQg=";
})
(pkgs.fetchFirefoxAddon {
name = "KeePassXC-Browser";
url = "https://addons.mozilla.org/firefox/downloads/file/3720664/keepassxc_browser-1.7.6-fx.xpi";
sha256 = "3K404/eq3amHhIT0WhzQtC892he5I0kp2SvbzE9dbZg=";
})
(pkgs.fetchFirefoxAddon {
name = "https-everywhere";
url = "https://addons.mozilla.org/firefox/downloads/file/3716461/https_everywhere-2021.1.27-an+fx.xpi";
sha256 = "2gSXSLunKCwPjAq4Wsj0lOeV551r3G+fcm1oeqjMKh8=";
})
];
extraPolicies = {
CaptivePortal = false;
DisableFirefoxStudies = true;
@@ -74,12 +49,6 @@ let
ExtensionRecommendations = false;
SkipOnboarding = true;
};
WebsiteFilter = {
Block = [
"http://paradigminteractive.io/"
"https://paradigminteractive.io/"
];
};
};
extraPrefs = ''

View File

@@ -5,22 +5,18 @@ let
in
{
config = lib.mkIf cfg.enable {
# kde plasma
services.xserver = {
enable = true;
desktopManager.plasma5.enable = true;
displayManager.sddm.enable = true;
};
services.displayManager.sddm.enable = true;
services.displayManager.sddm.wayland.enable = true;
services.desktopManager.plasma6.enable = true;
# kde apps
nixpkgs.config.firefox.enablePlasmaBrowserIntegration = true;
users.users.googlebot.packages = with pkgs; [
# akonadi
# kmail
# plasma5Packages.kmail-account-wizard
kate
kdePackages.kate
kdePackages.kdeconnect-kde
kdePackages.skanpage
];
services.xserver.desktopManager.plasma5.useQtScaling = true;
};
}

View File

@@ -13,6 +13,8 @@ let
auth_opts = "sec=ntlmv2i,credentials=/run/agenix/smb-secrets";
version_opts = "vers=3.1.1";
public_user_opts = "gid=${toString config.users.groups.users.gid}";
opts = "${systemd_opts},${network_opts},${user_opts},${version_opts},${auth_opts}";
in
{
@@ -24,7 +26,7 @@ in
fileSystems."/mnt/public" = {
device = "//s0.koi-bebop.ts.net/public";
fsType = "cifs";
options = [ opts ];
options = [ "${opts},${public_user_opts}" ];
};
fileSystems."/mnt/private" = {

View File

@@ -1,86 +0,0 @@
{ lib, config, pkgs, ... }:
with lib;
let
cfg = config.services.spotifyd;
toml = pkgs.formats.toml { };
spotifydConf = toml.generate "spotify.conf" cfg.settings;
in
{
disabledModules = [
"services/audio/spotifyd.nix"
];
options = {
services.spotifyd = {
enable = mkEnableOption "spotifyd, a Spotify playing daemon";
settings = mkOption {
default = { };
type = toml.type;
example = { global.bitrate = 320; };
description = ''
Configuration for Spotifyd. For syntax and directives, see
<link xlink:href="https://github.com/Spotifyd/spotifyd#Configuration"/>.
'';
};
users = mkOption {
type = with types; listOf str;
default = [ ];
description = ''
Usernames to be added to the "spotifyd" group, so that they
can start and interact with the userspace daemon.
'';
};
};
};
config = mkIf cfg.enable {
# username specific stuff because i'm lazy...
services.spotifyd.users = [ "googlebot" ];
users.users.googlebot.packages = with pkgs; [
spotify
spotify-tui
];
users.groups.spotifyd = {
members = cfg.users;
};
age.secrets.spotifyd = {
file = ../../secrets/spotifyd.age;
group = "spotifyd";
mode = "0440"; # group can read
};
# spotifyd to read secrets and run as user service
services.spotifyd = {
settings.global = {
username_cmd = "sed '1q;d' /run/agenix/spotifyd";
password_cmd = "sed '2q;d' /run/agenix/spotifyd";
bitrate = 320;
backend = "pulseaudio";
device_name = config.networking.hostName;
device_type = "computer";
# on_song_change_hook = "command_to_run_on_playback_events"
autoplay = true;
};
};
systemd.user.services.spotifyd-daemon = {
enable = true;
wantedBy = [ "graphical-session.target" ];
partOf = [ "graphical-session.target" ];
description = "spotifyd, a Spotify playing daemon";
environment.SHELL = "/bin/sh";
serviceConfig = {
ExecStart = "${pkgs.spotifyd}/bin/spotifyd --no-daemon --config-path ${spotifydConf}";
Restart = "always";
CacheDirectory = "spotifyd";
};
};
};
}

View File

@@ -1,25 +0,0 @@
{ lib, config, pkgs, ... }:
let
cfg = config.de;
in
{
config = lib.mkIf cfg.enable {
nixpkgs.overlays = [
(self: super: {
tor-browser-bundle-bin = super.tor-browser-bundle-bin.overrideAttrs (old: rec {
version = "10.0.10";
lang = "en-US";
src = pkgs.fetchurl {
url = "https://dist.torproject.org/torbrowser/${version}/tor-browser-linux64-${version}_${lang}.tar.xz";
sha256 = "vYWZ+NsGN8YH5O61+zrUjlFv3rieaBqjBQ+a18sQcZg=";
};
});
})
];
users.users.googlebot.packages = with pkgs; [
tor-browser-bundle-bin
];
};
}

View File

@@ -1,15 +1,11 @@
{ lib, config, pkgs, ... }:
{ lib, config, ... }:
let
cfg = config.de.touchpad;
cfg = config.de;
in
{
options.de.touchpad = {
enable = lib.mkEnableOption "enable touchpad";
};
config = lib.mkIf cfg.enable {
services.xserver.libinput.enable = true;
services.xserver.libinput.touchpad.naturalScrolling = true;
services.libinput.enable = true;
services.libinput.touchpad.naturalScrolling = true;
};
}

25
common/pc/udev.nix Normal file
View File

@@ -0,0 +1,25 @@
{ config, lib, pkgs, ... }:
let
cfg = config.de;
in
{
config = lib.mkIf cfg.enable {
services.udev.extraRules = ''
# depthai
SUBSYSTEM=="usb", ATTRS{idVendor}=="03e7", MODE="0666"
# Moonlander
# Rules for Oryx web flashing and live training
KERNEL=="hidraw*", ATTRS{idVendor}=="16c0", MODE="0664", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="3297", MODE="0664", GROUP="plugdev"
# Wally Flashing rules for the Moonlander and Planck EZ
SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="df11", MODE:="0666", SYMLINK+="stm32_dfu"
'';
services.udev.packages = [ pkgs.platformio ];
users.groups.plugdev = {
members = [ "googlebot" ];
};
};
}

View File

@@ -1,22 +0,0 @@
diff --git a/meson.build b/meson.build
index dace367..8c0e290 100644
--- a/meson.build
+++ b/meson.build
@@ -8,7 +8,7 @@ project(
'warning_level=0',
],
license: 'MIT',
- meson_version: '>= 0.58.0',
+ meson_version: '>= 0.57.0',
)
cc = meson.get_compiler('c')
@@ -47,8 +47,3 @@ shared_library(
gnu_symbol_visibility: 'hidden',
)
-meson.add_devenv(environment({
- 'NVD_LOG': '1',
- 'LIBVA_DRIVER_NAME': 'nvidia',
- 'LIBVA_DRIVERS_PATH': meson.project_build_root(),
-}))

View File

@@ -0,0 +1,23 @@
{ config, lib, pkgs, ... }:
let
cfg = config.de;
in
{
config = lib.mkIf cfg.enable {
# AppVMs
virtualisation.appvm.enable = true;
virtualisation.appvm.user = "googlebot";
# Use podman instead of docker
virtualisation.podman.enable = true;
virtualisation.podman.dockerCompat = true;
# virt-manager
virtualisation.libvirtd.enable = true;
programs.dconf.enable = true;
virtualisation.spiceUSBRedirection.enable = true;
environment.systemPackages = with pkgs; [ virt-manager ];
users.users.googlebot.extraGroups = [ "libvirtd" "adbusers" ];
};
}

View File

@@ -1,34 +0,0 @@
{ lib, config, pkgs, ... }:
let
cfg = config.de;
extensions = with pkgs.vscode-extensions; [
bbenoist.nix # nix syntax support
arrterian.nix-env-selector # nix dev envs
dart-code.dart-code
dart-code.flutter
golang.go
jnoortheen.nix-ide
ms-vscode.cpptools
] ++ pkgs.vscode-utils.extensionsFromVscodeMarketplace [
{
name = "platformio-ide";
publisher = "platformio";
version = "3.1.1";
sha256 = "g9yTG3DjVUS2w9eHGAai5LoIfEGus+FPhqDnCi4e90Q=";
}
];
vscodium-with-extensions = pkgs.vscode-with-extensions.override {
vscode = pkgs.vscodium;
vscodeExtensions = extensions;
};
in
{
config = lib.mkIf cfg.enable {
users.users.googlebot.packages = [
vscodium-with-extensions
];
};
}

View File

@@ -1,23 +0,0 @@
{ lib, config, pkgs, ... }:
let
cfg = config.de;
in
{
config = lib.mkIf cfg.enable {
services.xserver = {
enable = true;
desktopManager = {
xterm.enable = false;
xfce.enable = true;
};
displayManager.sddm.enable = true;
};
# xfce apps
# TODO for some reason whiskermenu needs to be global for it to work
environment.systemPackages = with pkgs; [
xfce.xfce4-whiskermenu-plugin
];
};
}

View File

@@ -0,0 +1,155 @@
{ hostConfig, workspaceName, ip, networkInterface }:
# Base configuration shared by all sandboxed workspaces (VMs and containers)
# This provides common settings for networking, SSH, users, and packages
#
# Parameters:
# hostConfig - The host's NixOS config (for inputs, ssh keys, etc.)
# workspaceName - Name of the workspace (used as hostname)
# ip - Static IP address for the workspace
# networkInterface - Match config for systemd-networkd (e.g., { Type = "ether"; } or { Name = "host0"; })
{ config, lib, pkgs, ... }:
let
claudeConfigFile = pkgs.writeText "claude-config.json" (builtins.toJSON {
hasCompletedOnboarding = true;
theme = "dark";
projects = {
"/home/googlebot/workspace" = {
hasTrustDialogAccepted = true;
};
};
});
in
{
imports = [
../shell.nix
hostConfig.inputs.home-manager.nixosModules.home-manager
hostConfig.inputs.nix-index-database.nixosModules.default
hostConfig.inputs.agenix.nixosModules.default
];
nixpkgs.overlays = [
hostConfig.inputs.claude-code-nix.overlays.default
];
# Basic system configuration
system.stateVersion = "25.11";
# Set hostname to match the workspace name
networking.hostName = workspaceName;
# Networking with systemd-networkd
networking.useNetworkd = true;
systemd.network.enable = true;
# Enable resolved to populate /etc/resolv.conf from networkd's DNS settings
services.resolved.enable = true;
# Basic networking configuration
networking.useDHCP = false;
# Static IP configuration
# Uses the host as DNS server (host forwards to upstream DNS)
systemd.network.networks."20-workspace" = {
matchConfig = networkInterface;
networkConfig = {
Address = "${ip}/24";
Gateway = hostConfig.networking.sandbox.hostAddress;
DNS = [ hostConfig.networking.sandbox.hostAddress ];
};
};
# Disable firewall inside workspaces (we're behind NAT)
networking.firewall.enable = false;
# Enable SSH for access
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "prohibit-password";
};
};
# Use persistent SSH host keys from shared directory
services.openssh.hostKeys = lib.mkForce [
{
path = "/etc/ssh-host-keys/ssh_host_ed25519_key";
type = "ed25519";
}
];
# Basic system packages
environment.systemPackages = with pkgs; [
claude-code
kakoune
vim
git
htop
wget
curl
tmux
dnsutils
];
# User configuration
users.mutableUsers = false;
users.users.googlebot = {
isNormalUser = true;
extraGroups = [ "wheel" ];
shell = pkgs.fish;
openssh.authorizedKeys.keys = hostConfig.machines.ssh.userKeys;
};
security.doas.enable = true;
security.sudo.enable = false;
security.doas.extraRules = [
{ groups = [ "wheel" ]; noPass = true; }
];
# Minimal locale settings
i18n.defaultLocale = "en_US.UTF-8";
time.timeZone = "America/Los_Angeles";
# Enable flakes
nix.settings.experimental-features = [ "nix-command" "flakes" ];
nix.settings.trusted-users = [ "googlebot" ];
# Binary cache configuration (inherited from host's common/binary-cache.nix)
nix.settings.substituters = hostConfig.nix.settings.substituters;
nix.settings.trusted-public-keys = hostConfig.nix.settings.trusted-public-keys;
nix.settings.fallback = true;
nix.settings.netrc-file = config.age.secrets.attic-netrc.path;
age.secrets.attic-netrc.file = ../../secrets/attic-netrc.age;
# Make nixpkgs available in NIX_PATH and registry (like the NixOS ISO)
# This allows `nix-shell -p`, `nix repl '<nixpkgs>'`, etc. to work
nix.nixPath = [ "nixpkgs=${hostConfig.inputs.nixpkgs}" ];
nix.registry.nixpkgs.flake = hostConfig.inputs.nixpkgs;
# Enable fish shell
programs.fish.enable = true;
# Initialize Claude Code config on first boot (skips onboarding, trusts workspace)
systemd.services.claude-config-init = {
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "googlebot";
Group = "users";
};
script = ''
if [ ! -f /home/googlebot/claude-config/.claude.json ]; then
cp ${claudeConfigFile} /home/googlebot/claude-config/.claude.json
fi
'';
};
# Home Manager configuration
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.users.googlebot = import ./home.nix;
}

View File

@@ -0,0 +1,74 @@
{ config, lib, pkgs, ... }:
# Container-specific configuration for sandboxed workspaces using systemd-nspawn
# This module is imported by default.nix for workspaces with type = "container"
with lib;
let
cfg = config.sandboxed-workspace;
hostConfig = config;
# Filter for container-type workspaces only
containerWorkspaces = filterAttrs (n: ws: ws.type == "container") cfg.workspaces;
in
{
config = mkIf (cfg.enable && containerWorkspaces != { }) {
# NixOS container module only sets restartIfChanged when autoStart=true
# Work around this by setting it directly on the systemd service
systemd.services = mapAttrs'
(name: ws: nameValuePair "container@${name}" {
restartIfChanged = lib.mkForce true;
restartTriggers = [
config.containers.${name}.path
config.environment.etc."nixos-containers/${name}.conf".source
];
})
containerWorkspaces;
# Convert container workspace configs to NixOS containers format
containers = mapAttrs
(name: ws: {
autoStart = ws.autoStart;
privateNetwork = true;
ephemeral = true;
restartIfChanged = true;
# Attach container's veth to the sandbox bridge
# This creates the veth pair and attaches host side to the bridge
hostBridge = config.networking.sandbox.bridgeName;
bindMounts = {
"/home/googlebot/workspace" = {
hostPath = "/home/googlebot/sandboxed/${name}/workspace";
isReadOnly = false;
};
"/etc/ssh-host-keys" = {
hostPath = "/home/googlebot/sandboxed/${name}/ssh-host-keys";
isReadOnly = false;
};
"/home/googlebot/claude-config" = {
hostPath = "/home/googlebot/sandboxed/${name}/claude-config";
isReadOnly = false;
};
};
config = { config, lib, pkgs, ... }: {
imports = [
(import ./base.nix {
inherit hostConfig;
workspaceName = name;
ip = ws.ip;
networkInterface = { Name = "eth0"; };
})
(import ws.config)
];
networking.useHostResolvConf = false;
nixpkgs.config.allowUnfree = true;
};
})
containerWorkspaces;
};
}

View File

@@ -0,0 +1,164 @@
{ config, lib, pkgs, ... }:
# Unified sandboxed workspace module supporting both VMs and containers
# This module provides isolated development environments with shared configuration
with lib;
let
cfg = config.sandboxed-workspace;
in
{
imports = [
./vm.nix
./container.nix
./incus.nix
];
options.sandboxed-workspace = {
enable = mkEnableOption "sandboxed workspace management";
workspaces = mkOption {
type = types.attrsOf (types.submodule {
options = {
type = mkOption {
type = types.enum [ "vm" "container" "incus" ];
description = ''
Backend type for this workspace:
- "vm": microVM with cloud-hypervisor (more isolation, uses virtiofs)
- "container": systemd-nspawn via NixOS containers (less overhead, uses bind mounts)
- "incus": Incus/LXD container (unprivileged, better security than NixOS containers)
'';
};
config = mkOption {
type = types.path;
description = "Path to the workspace configuration file";
};
ip = mkOption {
type = types.str;
example = "192.168.83.10";
description = ''
Static IP address for this workspace on the microvm bridge network.
Configures the workspace's network interface and adds an entry to /etc/hosts
on the host so the workspace can be accessed by name (e.g., ssh workspace-example).
Must be in the 192.168.83.0/24 subnet (or whatever networking.sandbox.subnet is).
'';
};
hostKey = mkOption {
type = types.nullOr types.str;
default = null;
example = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...";
description = ''
SSH host public key for this workspace. If set, adds to programs.ssh.knownHosts
so the host automatically trusts the workspace without prompting.
Get the key from: ~/sandboxed/<name>/ssh-host-keys/ssh_host_ed25519_key.pub
'';
};
autoStart = mkOption {
type = types.bool;
default = false;
description = "Whether to automatically start this workspace on boot";
};
cid = mkOption {
type = types.nullOr types.int;
default = null;
description = ''
vsock Context Identifier for this workspace (VM-only, ignored for containers).
If null, auto-generated from workspace name.
Must be unique per host. Valid range: 3 to 4294967294.
See: https://man7.org/linux/man-pages/man7/vsock.7.html
'';
};
};
});
default = { };
description = "Sandboxed workspace configurations";
};
};
config = mkIf cfg.enable {
# Automatically enable sandbox networking when workspaces are defined
networking.sandbox.enable = mkIf (cfg.workspaces != { }) true;
# Add workspace hostnames to /etc/hosts so they can be accessed by name
networking.hosts = lib.mkMerge (lib.mapAttrsToList
(name: ws: {
${ws.ip} = [ "workspace-${name}" ];
})
cfg.workspaces);
# Add workspace SSH host keys to known_hosts so host trusts workspaces without prompting
programs.ssh.knownHosts = lib.mkMerge (lib.mapAttrsToList
(name: ws:
lib.optionalAttrs (ws.hostKey != null) {
"workspace-${name}" = {
publicKey = ws.hostKey;
extraHostNames = [ ws.ip ];
};
})
cfg.workspaces);
# Shell aliases for workspace management
environment.shellAliases = lib.mkMerge (lib.mapAttrsToList
(name: ws:
let
serviceName =
if ws.type == "vm" then "microvm@${name}"
else if ws.type == "incus" then "incus-workspace-${name}"
else "container@${name}";
in
{
"workspace_${name}" = "ssh googlebot@workspace-${name}";
"workspace_${name}_start" = "doas systemctl start ${serviceName}";
"workspace_${name}_stop" = "doas systemctl stop ${serviceName}";
"workspace_${name}_restart" = "doas systemctl restart ${serviceName}";
"workspace_${name}_status" = "doas systemctl status ${serviceName}";
})
cfg.workspaces);
# Automatically generate SSH host keys and directories for all workspaces
systemd.services = lib.mapAttrs'
(name: ws:
let
serviceName =
if ws.type == "vm" then "microvm@${name}"
else if ws.type == "incus" then "incus-workspace-${name}"
else "container@${name}";
in
lib.nameValuePair "workspace-${name}-setup" {
description = "Setup directories and SSH keys for workspace ${name}";
wantedBy = [ "multi-user.target" ];
before = [ "${serviceName}.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
# Create directories if they don't exist
mkdir -p /home/googlebot/sandboxed/${name}/workspace
mkdir -p /home/googlebot/sandboxed/${name}/ssh-host-keys
mkdir -p /home/googlebot/sandboxed/${name}/claude-config
# Fix ownership
chown -R googlebot:users /home/googlebot/sandboxed/${name}
# Generate SSH host key if it doesn't exist
if [ ! -f /home/googlebot/sandboxed/${name}/ssh-host-keys/ssh_host_ed25519_key ]; then
${pkgs.openssh}/bin/ssh-keygen -t ed25519 -N "" \
-f /home/googlebot/sandboxed/${name}/ssh-host-keys/ssh_host_ed25519_key
chown googlebot:users /home/googlebot/sandboxed/${name}/ssh-host-keys/ssh_host_ed25519_key*
echo "Generated SSH host key for workspace ${name}"
fi
'';
}
)
cfg.workspaces;
};
}

View File

@@ -0,0 +1,50 @@
{ lib, pkgs, ... }:
# Home Manager configuration for sandboxed workspace user environment
# This sets up the shell and tools inside VMs and containers
{
home.username = "googlebot";
home.homeDirectory = "/home/googlebot";
home.stateVersion = "24.11";
programs.home-manager.enable = true;
# Shell configuration
programs.fish.enable = true;
programs.starship.enable = true;
programs.starship.enableFishIntegration = true;
programs.starship.settings.container.disabled = true;
# Basic command-line tools
programs.btop.enable = true;
programs.ripgrep.enable = true;
programs.eza.enable = true;
# Git configuration
programs.git = {
enable = true;
settings = {
user.name = lib.mkDefault "googlebot";
user.email = lib.mkDefault "zuckerberg@neet.dev";
};
};
# Shell aliases
home.shellAliases = {
ls = "eza";
la = "eza -la";
ll = "eza -l";
};
# Environment variables for Claude Code
home.sessionVariables = {
# Isolate Claude config to a specific directory on the host
CLAUDE_CONFIG_DIR = "/home/googlebot/claude-config";
};
# Additional packages for development
home.packages = with pkgs; [
# Add packages as needed per workspace
];
}

View File

@@ -0,0 +1,182 @@
{ config, lib, pkgs, ... }:
# Incus-specific configuration for sandboxed workspaces
# Creates fully declarative Incus containers from NixOS configurations
with lib;
let
cfg = config.sandboxed-workspace;
hostConfig = config;
incusWorkspaces = filterAttrs (n: ws: ws.type == "incus") cfg.workspaces;
# Build a NixOS LXC image for a workspace
mkContainerImage = name: ws:
let
nixpkgs = hostConfig.inputs.nixpkgs;
containerSystem = nixpkgs.lib.nixosSystem {
modules = [
(import ./base.nix {
inherit hostConfig;
workspaceName = name;
ip = ws.ip;
networkInterface = { Name = "eth0"; };
})
(import ws.config)
({ config, lib, pkgs, ... }: {
nixpkgs.hostPlatform = hostConfig.currentSystem;
boot.isContainer = true;
networking.useHostResolvConf = false;
nixpkgs.config.allowUnfree = true;
# Incus containers don't support the kernel features nix sandbox requires
nix.settings.sandbox = false;
environment.systemPackages = [
(lib.hiPrio (pkgs.writeShellScriptBin "claude" ''
exec ${pkgs.claude-code}/bin/claude --dangerously-skip-permissions "$@"
''))
];
})
];
};
in
{
rootfs = containerSystem.config.system.build.images.lxc;
metadata = containerSystem.config.system.build.images.lxc-metadata;
toplevel = containerSystem.config.system.build.toplevel;
};
mkIncusService = name: ws:
let
images = mkContainerImage name ws;
hash = builtins.substring 0 12 (builtins.hashString "sha256" "${images.rootfs}");
imageName = "nixos-workspace-${name}-${hash}";
containerName = "workspace-${name}";
bridgeName = config.networking.sandbox.bridgeName;
mac = lib.mkMac "incus-${name}";
addDevices = ''
incus config device add ${containerName} eth0 nic nictype=bridged parent=${bridgeName} hwaddr=${mac}
incus config device add ${containerName} workspace disk source=/home/googlebot/sandboxed/${name}/workspace path=/home/googlebot/workspace shift=true
incus config device add ${containerName} ssh-keys disk source=/home/googlebot/sandboxed/${name}/ssh-host-keys path=/etc/ssh-host-keys shift=true
incus config device add ${containerName} claude-config disk source=/home/googlebot/sandboxed/${name}/claude-config path=/home/googlebot/claude-config shift=true
'';
in
{
description = "Incus workspace ${name}";
after = [ "incus.service" "incus-preseed.service" "workspace-${name}-setup.service" ];
requires = [ "incus.service" ];
wants = [ "workspace-${name}-setup.service" ];
wantedBy = optional ws.autoStart "multi-user.target";
path = [ config.virtualisation.incus.package pkgs.gnutar pkgs.xz pkgs.util-linux ];
restartTriggers = [ images.rootfs images.metadata ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
set -euo pipefail
# Serialize incus operations - concurrent container creation causes race conditions
exec 9>/run/incus-workspace.lock
flock -x 9
# Import image if not present
if ! incus image list --format csv | grep -q "${imageName}"; then
metadata_tarball=$(echo ${images.metadata}/tarball/*.tar.xz)
rootfs_tarball=$(echo ${images.rootfs}/tarball/*.tar.xz)
incus image import "$metadata_tarball" "$rootfs_tarball" --alias ${imageName}
# Clean up old images for this workspace
incus image list --format csv | grep "nixos-workspace-${name}-" | grep -v "${imageName}" | cut -d, -f2 | while read old_image; do
incus image delete "$old_image" || true
done || true
fi
# Always recreate container for ephemeral behavior
incus stop ${containerName} --force 2>/dev/null || true
incus delete ${containerName} --force 2>/dev/null || true
incus init ${imageName} ${containerName}
${addDevices}
incus start ${containerName}
# Wait for container to start
for i in $(seq 1 30); do
if incus list --format csv | grep -q "^${containerName},RUNNING"; then
exit 0
fi
sleep 1
done
exit 1
'';
preStop = ''
exec 9>/run/incus-workspace.lock
flock -x 9
incus stop ${containerName} --force 2>/dev/null || true
incus delete ${containerName} --force 2>/dev/null || true
# Clean up all images for this workspace
incus image list --format csv 2>/dev/null | grep "nixos-workspace-${name}-" | cut -d, -f2 | while read img; do
incus image delete "$img" 2>/dev/null || true
done
'';
};
in
{
config = mkIf (cfg.enable && incusWorkspaces != { }) {
virtualisation.incus.enable = true;
networking.nftables.enable = true;
virtualisation.incus.preseed = {
storage_pools = [{
name = "default";
driver = "dir";
config = {
source = "/var/lib/incus/storage-pools/default";
};
}];
profiles = [{
name = "default";
config = {
"security.privileged" = "false";
"security.idmap.isolated" = "true";
};
devices = {
root = {
path = "/";
pool = "default";
type = "disk";
};
};
}];
};
systemd.services = mapAttrs'
(name: ws: nameValuePair "incus-workspace-${name}" (mkIncusService name ws))
incusWorkspaces;
# Extra alias for incus shell access (ssh is also available via default.nix aliases)
environment.shellAliases = mkMerge (mapAttrsToList
(name: ws: {
"workspace_${name}_shell" = "doas incus exec workspace-${name} -- su -l googlebot";
})
incusWorkspaces);
};
}

View File

@@ -0,0 +1,147 @@
{ config, lib, pkgs, ... }:
# VM-specific configuration for sandboxed workspaces using microvm.nix
# This module is imported by default.nix for workspaces with type = "vm"
with lib;
let
cfg = config.sandboxed-workspace;
hostConfig = config;
# Generate a deterministic vsock CID from workspace name.
#
# vsock (virtual sockets) enables host-VM communication without networking.
# cloud-hypervisor uses vsock for systemd-notify integration: when a VM finishes
# booting, systemd sends READY=1 to the host via vsock, allowing the host's
# microvm@ service to accurately track VM boot status instead of guessing.
#
# Each VM needs a unique CID (Context Identifier). Reserved CIDs per vsock(7):
# - VMADDR_CID_HYPERVISOR (0): reserved for hypervisor
# - VMADDR_CID_LOCAL (1): loopback address
# - VMADDR_CID_HOST (2): host address
# See: https://man7.org/linux/man-pages/man7/vsock.7.html
# https://docs.kernel.org/virt/kvm/vsock.html
#
# We auto-generate from SHA256 hash to ensure uniqueness without manual assignment.
# Range: 100 - 16777315 (offset avoids reserved CIDs and leaves 3-99 for manual use)
nameToCid = name:
let
hash = builtins.hashString "sha256" name;
hexPart = builtins.substring 0 6 hash;
in
100 + (builtins.foldl'
(acc: c: acc * 16 + (
if c == "a" then 10
else if c == "b" then 11
else if c == "c" then 12
else if c == "d" then 13
else if c == "e" then 14
else if c == "f" then 15
else lib.strings.toInt c
)) 0
(lib.stringToCharacters hexPart));
# Filter for VM-type workspaces only
vmWorkspaces = filterAttrs (n: ws: ws.type == "vm") cfg.workspaces;
# Generate VM configuration for a workspace
mkVmConfig = name: ws: {
inherit pkgs; # Use host's pkgs (includes allowUnfree)
config = import ws.config;
specialArgs = { inputs = hostConfig.inputs; };
extraModules = [
(import ./base.nix {
inherit hostConfig;
workspaceName = name;
ip = ws.ip;
networkInterface = { Type = "ether"; };
})
{
environment.systemPackages = [
(lib.hiPrio (pkgs.writeShellScriptBin "claude" ''
exec ${pkgs.claude-code}/bin/claude --dangerously-skip-permissions "$@"
''))
];
# MicroVM specific configuration
microvm = {
# Use cloud-hypervisor for better performance
hypervisor = lib.mkDefault "cloud-hypervisor";
# Resource allocation
vcpu = 8;
mem = 4096; # 4GB RAM
# Disk for writable overlay
volumes = [{
image = "overlay.img";
mountPoint = "/nix/.rw-store";
size = 8192; # 8GB
}];
# Shared directories with host using virtiofs
shares = [
{
# Share the host's /nix/store for accessing packages
proto = "virtiofs";
tag = "ro-store";
source = "/nix/store";
mountPoint = "/nix/.ro-store";
}
{
proto = "virtiofs";
tag = "workspace";
source = "/home/googlebot/sandboxed/${name}/workspace";
mountPoint = "/home/googlebot/workspace";
}
{
proto = "virtiofs";
tag = "ssh-host-keys";
source = "/home/googlebot/sandboxed/${name}/ssh-host-keys";
mountPoint = "/etc/ssh-host-keys";
}
{
proto = "virtiofs";
tag = "claude-config";
source = "/home/googlebot/sandboxed/${name}/claude-config";
mountPoint = "/home/googlebot/claude-config";
}
];
# Writeable overlay for /nix/store
writableStoreOverlay = "/nix/.rw-store";
# TAP interface for bridged networking
# The interface name "vm-*" matches the pattern in common/network/microvm.nix
# which automatically attaches it to the microbr bridge
interfaces = [{
type = "tap";
id = "vm-${name}";
mac = lib.mkMac "vm-${name}";
}];
# Enable vsock for systemd-notify integration
vsock.cid =
if ws.cid != null
then ws.cid
else nameToCid name;
};
}
];
autostart = ws.autoStart;
};
in
{
config = mkMerge [
(mkIf (cfg.enable && vmWorkspaces != { }) {
# Convert VM workspace configs to microvm.nix format
microvm.vms = mapAttrs mkVmConfig vmWorkspaces;
})
# microvm.nixosModules.host enables KSM, but /sys is read-only in containers
(mkIf config.boot.isContainer {
hardware.ksm.enable = false;
})
];
}

View File

@@ -0,0 +1,16 @@
{ config, lib, ... }:
let
cfg = config.services.actual;
in
{
config = lib.mkIf cfg.enable {
services.actual.settings = {
port = 25448;
};
backup.group."actual-budget".paths = [
"/var/lib/actual"
];
};
}

62
common/server/atticd.nix Normal file
View File

@@ -0,0 +1,62 @@
{ config, lib, ... }:
{
config = lib.mkIf (config.thisMachine.hasRole."binary-cache" && !config.boot.isContainer) {
services.atticd = {
enable = true;
environmentFile = config.age.secrets.atticd-credentials.path;
settings = {
listen = "[::]:28338";
database.url = "postgresql:///atticd?host=/run/postgresql";
require-proof-of-possession = false;
# Disable chunking — the dedup savings don't justify the CPU/IO
# overhead for local storage, especially on ZFS which already
# does block-level compression.
chunking = {
nar-size-threshold = 0;
min-size = 16 * 1024;
avg-size = 64 * 1024;
max-size = 256 * 1024;
};
# Let ZFS handle compression instead of double-compressing.
compression.type = "none";
garbage-collection.default-retention-period = "6 months";
};
};
# PostgreSQL for atticd
services.postgresql = {
enable = true;
ensureDatabases = [ "atticd" ];
ensureUsers = [{
name = "atticd";
ensureDBOwnership = true;
}];
};
# Use a static user so the ZFS mountpoint at /var/lib/atticd works
# (DynamicUser conflicts with ZFS mountpoints)
users.users.atticd = {
isSystemUser = true;
group = "atticd";
home = "/var/lib/atticd";
};
users.groups.atticd = { };
systemd.services.atticd = {
after = [ "postgresql.service" ];
requires = [ "postgresql.service" ];
partOf = [ "postgresql.service" ];
serviceConfig = {
DynamicUser = lib.mkForce false;
User = "atticd";
Group = "atticd";
};
};
age.secrets.atticd-credentials.file = ../../secrets/atticd-credentials.age;
};
}

View File

@@ -1,43 +0,0 @@
{ config, lib, ... }:
with lib;
let
cfg = config.ceph;
in
{
options.ceph = { };
config = mkIf cfg.enable {
# ceph.enable = true;
## S3 Object gateway
#ceph.rgw.enable = true;
#ceph.rgw.daemons = [
#];
# https://docs.ceph.com/en/latest/start/intro/
# meta object storage daemon
ceph.osd.enable = true;
ceph.osd.daemons = [
];
# monitor's ceph state
ceph.mon.enable = true;
ceph.mon.daemons = [
];
# manage ceph
ceph.mgr.enable = true;
ceph.mgr.daemons = [
];
# metadata server
ceph.mds.enable = true;
ceph.mds.daemons = [
];
ceph.global.fsid = "925773DC-D95F-476C-BBCD-08E01BF0865F";
};
}

View File

@@ -1,23 +1,22 @@
{ config, pkgs, ... }:
{ ... }:
{
imports = [
./nginx.nix
./thelounge.nix
./mumble.nix
./icecast.nix
./nginx-stream.nix
./matrix.nix
./zerobin.nix
./gitea.nix
./gitea-runner.nix
./privatebin/privatebin.nix
./radio.nix
./samba.nix
./owncast.nix
./mailserver.nix
./nextcloud.nix
./iodine.nix
./searx.nix
./gitea-actions-runner.nix
./atticd.nix
./librechat.nix
./actualbudget.nix
./unifi.nix
./ntfy.nix
./gatus.nix
];
}

366
common/server/gatus.nix Normal file
View File

@@ -0,0 +1,366 @@
{ lib, config, ... }:
let
cfg = config.services.gatus;
port = 31103;
in
{
options.services.gatus = {
hostname = lib.mkOption {
type = lib.types.str;
example = "status.example.com";
};
};
config = lib.mkIf cfg.enable {
services.gatus = {
environmentFile = "/run/agenix/ntfy-token";
settings = {
storage = {
type = "sqlite";
path = "/var/lib/gatus/data.db";
};
web = {
address = "127.0.0.1";
port = port;
};
alerting.ntfy = {
url = "https://ntfy.neet.dev";
topic = "service-failures";
priority = 4;
default-alert = {
enabled = true;
failure-threshold = 3;
success-threshold = 2;
send-on-resolved = true;
};
token = "$NTFY_TOKEN";
};
endpoints = [
{
name = "Gitea";
group = "services";
url = "https://git.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "The Lounge";
group = "services";
url = "https://irc.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "ntfy";
group = "services";
url = "https://ntfy.neet.dev/v1/health";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Librechat";
group = "services";
url = "https://chat.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Owncast";
group = "services";
url = "https://live.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Nextcloud";
group = "services";
url = "https://neet.cloud";
interval = "5m";
conditions = [
"[STATUS] == any(200, 302)"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Element Web";
group = "services";
url = "https://chat.neet.space";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Mumble";
group = "services";
url = "tcp://voice.neet.space:23563";
interval = "5m";
conditions = [
"[CONNECTED] == true"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Navidrome";
group = "services";
url = "https://navidrome.neet.cloud";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Roundcube";
group = "services";
url = "https://mail.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Collabora Online";
group = "services";
url = "https://collabora.runyan.org";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Jellyfin";
group = "s0";
url = "https://jellyfin.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Sonarr";
group = "s0";
url = "https://sonarr.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Radarr";
group = "s0";
url = "https://radarr.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Lidarr";
group = "s0";
url = "https://lidarr.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Prowlarr";
group = "s0";
url = "https://prowlarr.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Bazarr";
group = "s0";
url = "https://bazarr.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Transmission";
group = "s0";
url = "https://transmission.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Home Assistant";
group = "s0";
url = "https://ha.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "ESPHome";
group = "s0";
url = "https://esphome.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Zigbee2MQTT";
group = "s0";
url = "https://zigbee.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Frigate";
group = "s0";
url = "https://frigate.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Valetudo";
group = "s0";
url = "https://vacuum.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Sandman";
group = "s0";
url = "https://sandman.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Vikunja";
group = "s0";
url = "https://todo.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Actual Budget";
group = "s0";
url = "https://budget.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Linkwarden";
group = "s0";
url = "https://linkwarden.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Memos";
group = "s0";
url = "https://memos.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Outline";
group = "s0";
url = "https://outline.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "LanguageTool";
group = "s0";
url = "https://languagetool.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
{
name = "Unifi";
group = "s0";
url = "https://unifi.s0.neet.dev";
interval = "5m";
conditions = [
"[STATUS] == 200"
];
alerts = [{ type = "ntfy"; }];
}
];
};
};
services.nginx.enable = true;
services.nginx.virtualHosts.${cfg.hostname} = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString port}";
proxyWebsockets = true;
};
};
};
}

View File

@@ -0,0 +1,86 @@
{ config, lib, ... }:
# Gitea Actions Runner inside a NixOS container.
# The container shares the host's /nix/store (read-only) and nix-daemon socket,
# so builds go through the host daemon and outputs land in the host store.
# Warning: NixOS containers are not fully secure — do not run untrusted code.
# To enable, assign a machine the 'gitea-actions-runner' system role.
let
thisMachineIsARunner = config.thisMachine.hasRole."gitea-actions-runner";
containerName = "gitea-runner";
giteaRunnerUid = 991;
giteaRunnerGid = 989;
in
{
config = lib.mkIf (thisMachineIsARunner && !config.boot.isContainer) {
containers.${containerName} = {
autoStart = true;
ephemeral = true;
bindMounts = {
"/run/agenix/gitea-actions-runner-token" = {
hostPath = "/run/agenix/gitea-actions-runner-token";
isReadOnly = true;
};
"/var/lib/gitea-runner" = {
hostPath = "/var/lib/gitea-runner";
isReadOnly = false;
};
};
config = { config, lib, pkgs, ... }: {
system.stateVersion = "25.11";
services.gitea-actions-runner.instances.inst = {
enable = true;
name = containerName;
url = "https://git.neet.dev/";
tokenFile = "/run/agenix/gitea-actions-runner-token";
labels = [ "nixos:host" ];
};
# Disable dynamic user so runner state persists via bind mount
assertions = [{
assertion = config.systemd.services.gitea-runner-inst.enable;
message = "Expected systemd service 'gitea-runner-inst' is not enabled the gitea-actions-runner module may have changed its naming scheme.";
}];
systemd.services.gitea-runner-inst.serviceConfig.DynamicUser = lib.mkForce false;
users.users.gitea-runner = {
uid = giteaRunnerUid;
home = "/var/lib/gitea-runner";
group = "gitea-runner";
isSystemUser = true;
createHome = true;
};
users.groups.gitea-runner.gid = giteaRunnerGid;
nix.settings.experimental-features = [ "nix-command" "flakes" ];
environment.systemPackages = with pkgs; [
git
nodejs
jq
attic-client
];
};
};
# Needs to be outside of the container because container uses's the host's nix-daemon
nix.settings.trusted-users = [ "gitea-runner" ];
# Matching user on host — the container's gitea-runner UID must be
# recognized by the host's nix-daemon as trusted (shared UID namespace)
users.users.gitea-runner = {
uid = giteaRunnerUid;
home = "/var/lib/gitea-runner";
group = "gitea-runner";
isSystemUser = true;
createHome = true;
};
users.groups.gitea-runner.gid = giteaRunnerGid;
age.secrets.gitea-actions-runner-token.file = ../../secrets/gitea-actions-runner-token.age;
};
}

View File

@@ -1,52 +0,0 @@
{ config, pkgs, lib, ... }:
let
cfg = config.services.gitea-runner;
in
{
options.services.gitea-runner = {
enable = lib.mkEnableOption "Enables gitea runner";
dataDir = lib.mkOption {
default = "/var/lib/gitea-runner";
type = lib.types.str;
description = lib.mdDoc "gitea runner data directory.";
};
};
config = lib.mkIf cfg.enable {
virtualisation.docker.enable = true;
users.users.gitea-runner = {
description = "Gitea Runner Service";
home = cfg.dataDir;
useDefaultShell = true;
group = "gitea-runner";
isSystemUser = true;
createHome = true;
extraGroups = [
"docker" # allow creating docker containers
];
};
users.groups.gitea-runner = { };
systemd.services.gitea-runner = {
description = "Gitea Runner";
serviceConfig = {
WorkingDirectory = cfg.dataDir;
User = "gitea-runner";
Group = "gitea-runner";
};
requires = [ "network-online.target" ];
after = [ "network.target" "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = with pkgs; [ gitea-actions-runner ];
script = ''
exec act_runner daemon
'';
};
};
}

View File

@@ -1,4 +1,4 @@
{ lib, pkgs, config, ... }:
{ lib, config, ... }:
let
cfg = config.services.gitea;
@@ -12,23 +12,28 @@ in
};
config = lib.mkIf cfg.enable {
services.gitea = {
domain = cfg.hostname;
rootUrl = "https://${cfg.hostname}/";
appName = cfg.hostname;
# lfs.enable = true;
lfs.enable = true;
# dump.enable = true;
settings = {
server = {
ROOT_URL = "https://${cfg.hostname}/";
DOMAIN = cfg.hostname;
};
other = {
SHOW_FOOTER_VERSION = false;
};
ui = {
DEFAULT_THEME = "arc-green";
DEFAULT_THEME = "gitea-dark";
};
service = {
DISABLE_REGISTRATION = true;
};
session = {
COOKIE_SECURE = true;
PROVIDER = "db";
SESSION_LIFE_TIME = 259200; # 3 days
GC_INTERVAL_TIME = 259200; # 3 days
};
mailer = {
ENABLED = true;
@@ -42,6 +47,9 @@ in
actions = {
ENABLED = true;
};
indexer = {
REPO_INDEXER_ENABLED = true;
};
};
mailerPasswordFile = "/run/agenix/robots-email-pw";
};
@@ -60,7 +68,7 @@ in
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://localhost:${toString cfg.httpPort}";
proxyPass = "http://localhost:${toString cfg.settings.server.HTTP_PORT}";
};
};
};

View File

@@ -1,42 +0,0 @@
{ config, pkgs, ... }:
{
services.gitlab = {
enable = true;
databasePasswordFile = "/var/keys/gitlab/db_password";
initialRootPasswordFile = "/var/keys/gitlab/root_password";
https = true;
host = "git.neet.dev";
port = 443;
user = "git";
group = "git";
databaseUsername = "git";
smtp = {
enable = true;
address = "localhost";
port = 25;
};
secrets = {
dbFile = "/var/keys/gitlab/db";
secretFile = "/var/keys/gitlab/secret";
otpFile = "/var/keys/gitlab/otp";
jwsFile = "/var/keys/gitlab/jws";
};
extraConfig = {
gitlab = {
email_from = "gitlab-no-reply@neet.dev";
email_display_name = "neet.dev GitLab";
email_reply_to = "gitlab-no-reply@neet.dev";
};
};
pagesExtraArgs = [ "-listen-proxy" "127.0.0.1:8090" ];
};
services.nginx.virtualHosts = {
"git.neet.dev" = {
enableACME = true;
forceSSL = true;
locations."/".proxyPass = "http://unix:/run/gitlab/gitlab-workhorse.socket";
};
};
}

View File

@@ -1,25 +0,0 @@
{ config, pkgs, ... }:
let
domain = "hydra.neet.dev";
port = 3000;
notifyEmail = "hydra@neet.dev";
in
{
services.nginx.virtualHosts."${domain}" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://localhost:${toString port}";
};
};
services.hydra = {
enable = true;
inherit port;
hydraURL = "https://${domain}";
useSubstitutes = true;
notificationSender = notifyEmail;
buildMachinesFiles = [ ];
};
}

View File

@@ -1,65 +0,0 @@
{ lib, config, ... }:
# configures icecast to only accept source from localhost
# to a audio optimized stream on services.icecast.mount
# made available via nginx for http access on
# https://host/mount
let
cfg = config.services.icecast;
in
{
options.services.icecast = {
mount = lib.mkOption {
type = lib.types.str;
example = "stream.mp3";
};
fallback = lib.mkOption {
type = lib.types.str;
example = "fallback.mp3";
};
nginx = lib.mkEnableOption "enable nginx";
};
config = lib.mkIf cfg.enable {
services.icecast = {
listen.address = "0.0.0.0";
listen.port = 8001;
admin.password = "hackme";
extraConf = ''
<authentication>
<source-password>hackme</source-password>
</authentication>
<http-headers>
<header type="cors" name="Access-Control-Allow-Origin" />
</http-headers>
<mount type="normal">
<mount-name>/${cfg.mount}</mount-name>
<max-listeners>30</max-listeners>
<bitrate>64000</bitrate>
<hidden>false</hidden>
<public>false</public>
<fallback-mount>/${cfg.fallback}</fallback-mount>
<fallback-override>1</fallback-override>
</mount>
<mount type="normal">
<mount-name>/${cfg.fallback}</mount-name>
<max-listeners>30</max-listeners>
<bitrate>64000</bitrate>
<hidden>false</hidden>
<public>false</public>
</mount>
'';
};
services.nginx.virtualHosts.${cfg.hostname} = lib.mkIf cfg.nginx {
enableACME = true;
forceSSL = true;
locations."/${cfg.mount}" = {
proxyPass = "http://localhost:${toString cfg.listen.port}/${cfg.mount}";
extraConfig = ''
add_header Access-Control-Allow-Origin *;
'';
};
};
};
}

View File

@@ -1,21 +0,0 @@
{ config, pkgs, lib, ... }:
let
cfg = config.services.iodine.server;
in
{
config = lib.mkIf cfg.enable {
# iodine DNS-based vpn
services.iodine.server = {
ip = "192.168.99.1";
domain = "tun.neet.dev";
passwordFile = "/run/agenix/iodine";
};
age.secrets.iodine.file = ../../secrets/iodine.age;
networking.firewall.allowedUDPPorts = [ 53 ];
networking.nat.internalInterfaces = [
"dns0" # iodine
];
};
}

View File

@@ -0,0 +1,69 @@
{ config, lib, ... }:
with lib;
let
cfg = config.services.librechat-container;
in
{
options.services.librechat-container = {
enable = mkEnableOption "librechat";
port = mkOption {
type = types.int;
default = 3080;
};
host = lib.mkOption {
type = lib.types.str;
example = "example.com";
};
};
config = mkIf cfg.enable {
virtualisation.oci-containers.containers = {
librechat = {
image = "ghcr.io/danny-avila/librechat:v0.8.1";
environment = {
HOST = "0.0.0.0";
MONGO_URI = "mongodb://host.containers.internal:27017/LibreChat";
ENDPOINTS = "openAI,google,bingAI,gptPlugins";
OPENAI_MODELS = lib.concatStringsSep "," [
"gpt-4o-mini"
"o3-mini"
"gpt-4o"
"o1"
];
REFRESH_TOKEN_EXPIRY = toString (1000 * 60 * 60 * 24 * 30); # 30 days
};
environmentFiles = [
"/run/agenix/librechat-env-file"
];
ports = [
"${toString cfg.port}:3080"
];
};
};
age.secrets.librechat-env-file.file = ../../secrets/librechat-env-file.age;
services.mongodb.enable = true;
services.mongodb.bind_ip = "0.0.0.0";
# easier podman maintenance
virtualisation.oci-containers.backend = "podman";
virtualisation.podman.dockerSocket.enable = true;
virtualisation.podman.dockerCompat = true;
# For mongodb access
networking.firewall.trustedInterfaces = [
"podman0" # for librechat
];
services.nginx.virtualHosts.${cfg.host} = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://localhost:${toString cfg.port}";
proxyWebsockets = true;
};
};
};
}

View File

@@ -28,7 +28,6 @@ in
indexDir = "/var/lib/mailindex";
enableManageSieve = true;
fullTextSearch.enable = true;
fullTextSearch.indexAttachments = true;
fullTextSearch.memoryLimit = 500;
inherit domains;
loginAccounts = {
@@ -55,6 +54,8 @@ in
"joslyn@runyan.org"
"damon@runyan.org"
"jonas@runyan.org"
"simon@neet.dev"
"ellen@runyan.org"
];
forwards = {
"amazon@runyan.org" = [
@@ -62,18 +63,28 @@ in
"cris@runyan.org"
];
};
certificateScheme = 3; # use let's encrypt for certs
x509.useACMEHost = config.mailserver.fqdn; # use let's encrypt for certs
stateVersion = 3;
};
age.secrets.hashed-email-pw.file = ../../secrets/hashed-email-pw.age;
age.secrets.cris-hashed-email-pw.file = ../../secrets/cris-hashed-email-pw.age;
age.secrets.hashed-robots-email-pw.file = ../../secrets/hashed-robots-email-pw.age;
# Get let's encrypt cert
services.nginx = {
enable = true;
virtualHosts."${config.mailserver.fqdn}" = {
forceSSL = true;
enableACME = true;
};
};
# sendmail to use xxx@domain instead of xxx@mail.domain
services.postfix.origin = "$mydomain";
services.postfix.settings.main.myorigin = "$mydomain";
# relay sent mail through mailgun
# https://www.howtoforge.com/community/threads/different-smtp-relays-for-different-domains-in-postfix.82711/#post-392620
services.postfix.config = {
services.postfix.settings.main = {
smtp_sasl_auth_enable = "yes";
smtp_sasl_security_options = "noanonymous";
smtp_sasl_password_maps = "hash:/var/lib/postfix/conf/sasl_relay_passwd";
@@ -91,7 +102,6 @@ in
age.secrets.sasl_relay_passwd.file = ../../secrets/sasl_relay_passwd.age;
# webmail
services.nginx.enable = true;
services.roundcube = {
enable = true;
hostName = config.mailserver.fqdn;

View File

@@ -3,18 +3,42 @@
let
cfg = config.services.nextcloud;
nextcloudHostname = "runyan.org";
collaboraOnlineHostname = "collabora.runyan.org";
whiteboardHostname = "whiteboard.runyan.org";
whiteboardPort = 3002; # Seems impossible to change
# Hardcoded public ip of ponyo... I wish I didn't need this...
public_ip_address = "147.135.114.130";
in
{
config = lib.mkIf cfg.enable {
services.nextcloud = {
https = true;
package = pkgs.nextcloud25;
hostName = "neet.cloud";
package = pkgs.nextcloud32;
hostName = nextcloudHostname;
config.dbtype = "sqlite";
config.adminuser = "jeremy";
config.adminpassFile = "/run/agenix/nextcloud-pw";
autoUpdateApps.enable = true;
enableBrokenCiphersForSSE = false;
# Apps
extraAppsEnable = true;
extraApps = with config.services.nextcloud.package.packages.apps; {
# Want
inherit end_to_end_encryption mail spreed;
# For file and document editing (collabora online and excalidraw)
inherit richdocuments whiteboard;
# Might use
inherit calendar qownnotesapi;
# Try out
# inherit bookmarks cookbook deck memories maps music news notes phonetrack polls forms;
};
};
age.secrets.nextcloud-pw = {
file = ../../secrets/nextcloud-pw.age;
@@ -30,5 +54,100 @@ in
enableACME = true;
forceSSL = true;
};
# collabora-online
# https://diogotc.com/blog/collabora-nextcloud-nixos/
services.collabora-online = {
enable = true;
port = 15972;
settings = {
# Rely on reverse proxy for SSL
ssl = {
enable = false;
termination = true;
};
# Listen on loopback interface only
net = {
listen = "loopback";
post_allow.host = [ "localhost" ];
};
# Restrict loading documents from WOPI Host
storage.wopi = {
"@allow" = true;
host = [ config.services.nextcloud.hostName ];
};
server_name = collaboraOnlineHostname;
};
};
services.nginx.virtualHosts.${config.services.collabora-online.settings.server_name} = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://localhost:${toString config.services.collabora-online.port}";
proxyWebsockets = true;
};
};
systemd.services.nextcloud-config-collabora =
let
wopi_url = "http://localhost:${toString config.services.collabora-online.port}";
public_wopi_url = "https://${collaboraOnlineHostname}";
wopi_allowlist = lib.concatStringsSep "," [
"127.0.0.1"
"::1"
public_ip_address
];
in
{
wantedBy = [ "multi-user.target" ];
after = [ "nextcloud-setup.service" "coolwsd.service" ];
requires = [ "coolwsd.service" ];
path = [
config.services.nextcloud.occ
];
script = ''
nextcloud-occ config:app:set richdocuments wopi_url --value ${lib.escapeShellArg wopi_url}
nextcloud-occ config:app:set richdocuments public_wopi_url --value ${lib.escapeShellArg public_wopi_url}
nextcloud-occ config:app:set richdocuments wopi_allowlist --value ${lib.escapeShellArg wopi_allowlist}
nextcloud-occ richdocuments:setup
'';
serviceConfig = {
Type = "oneshot";
};
};
# Whiteboard
services.nextcloud-whiteboard-server = {
enable = true;
settings.NEXTCLOUD_URL = "https://${nextcloudHostname}";
secrets = [ "/run/agenix/whiteboard-server-jwt-secret" ];
};
systemd.services.nextcloud-config-whiteboard = {
wantedBy = [ "multi-user.target" ];
after = [ "nextcloud-setup.service" ];
requires = [ "coolwsd.service" ];
path = [
config.services.nextcloud.occ
];
script = ''
nextcloud-occ config:app:set whiteboard collabBackendUrl --value="https://${whiteboardHostname}"
nextcloud-occ config:app:set whiteboard jwt_secret_key --value="$JWT_SECRET_KEY"
'';
serviceConfig = {
Type = "oneshot";
EnvironmentFile = [ "/run/agenix/whiteboard-server-jwt-secret" ];
};
};
age.secrets.whiteboard-server-jwt-secret.file = ../../secrets/whiteboard-server-jwt-secret.age;
services.nginx.virtualHosts.${whiteboardHostname} = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://localhost:${toString whiteboardPort}";
proxyWebsockets = true;
};
};
};
}

View File

@@ -1,76 +0,0 @@
{ lib, config, pkgs, ... }:
let
cfg = config.services.nginx.stream;
nginxWithRTMP = pkgs.nginx.override {
modules = [ pkgs.nginxModules.rtmp ];
};
in
{
options.services.nginx.stream = {
enable = lib.mkEnableOption "enable nginx rtmp/hls/dash video streaming";
port = lib.mkOption {
type = lib.types.int;
default = 1935;
description = "rtmp injest/serve port";
};
rtmpName = lib.mkOption {
type = lib.types.str;
default = "live";
description = "the name of the rtmp application";
};
hostname = lib.mkOption {
type = lib.types.str;
description = "the http host to serve hls";
};
httpLocation = lib.mkOption {
type = lib.types.str;
default = "/tmp";
description = "the path of the tmp http files";
};
};
config = lib.mkIf cfg.enable {
services.nginx = {
enable = true;
package = nginxWithRTMP;
virtualHosts.${cfg.hostname} = {
enableACME = true;
forceSSL = true;
locations = {
"/stream/hls".root = "${cfg.httpLocation}/hls";
"/stream/dash".root = "${cfg.httpLocation}/dash";
};
extraConfig = ''
location /stat {
rtmp_stat all;
}
'';
};
appendConfig = ''
rtmp {
server {
listen ${toString cfg.port};
chunk_size 4096;
application ${cfg.rtmpName} {
allow publish all;
allow publish all;
live on;
record off;
hls on;
hls_path ${cfg.httpLocation}/hls;
dash on;
dash_path ${cfg.httpLocation}/dash;
}
}
}
'';
};
networking.firewall.allowedTCPPorts = [
cfg.port
];
};
}

View File

@@ -1,9 +1,13 @@
{ lib, config, pkgs, ... }:
{ lib, config, ... }:
let
cfg = config.services.nginx;
in
{
options.services.nginx = {
openFirewall = lib.mkEnableOption "Open firewall ports 80 and 443";
};
config = lib.mkIf cfg.enable {
services.nginx = {
recommendedGzipSettings = true;
@@ -12,6 +16,8 @@ in
recommendedTlsSettings = true;
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
services.nginx.openFirewall = lib.mkDefault true;
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ 80 443 ];
};
}

38
common/server/ntfy.nix Normal file
View File

@@ -0,0 +1,38 @@
{ lib, config, ... }:
let
cfg = config.services.ntfy-sh;
in
{
options.services.ntfy-sh = {
hostname = lib.mkOption {
type = lib.types.str;
example = "ntfy.example.com";
};
};
config = lib.mkIf cfg.enable {
services.ntfy-sh.settings = {
base-url = "https://${cfg.hostname}";
listen-http = "127.0.0.1:2586";
auth-default-access = "deny-all";
behind-proxy = true;
enable-login = true;
};
# backups
backup.group."ntfy".paths = [
"/var/lib/ntfy-sh"
];
services.nginx.enable = true;
services.nginx.virtualHosts.${cfg.hostname} = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:2586";
proxyWebsockets = true;
};
};
};
}

View File

@@ -1,42 +0,0 @@
;<?php http_response_code(403); /*
[main]
name = "Kode Paste"
discussion = false
opendiscussion = false
password = true
fileupload = false
burnafterreadingselected = false
defaultformatter = "plaintext"
sizelimit = 10485760
template = "bootstrap"
languageselection = false
[expire]
default = "1week"
[expire_options]
5min = 300
10min = 600
1hour = 3600
1day = 86400
1week = 604800
[formatter_options]
plaintext = "Plain Text"
syntaxhighlighting = "Source Code"
markdown = "Markdown"
[traffic]
limit = 10
dir = "/var/lib/privatebin"
[purge]
limit = 300
batchsize = 10
dir = "/var/lib/privatebin"
[model]
class = Filesystem
[model_options]
dir = "/var/lib/privatebin"

View File

@@ -1,74 +0,0 @@
{ config, pkgs, lib, ... }:
let
cfg = config.services.privatebin;
privateBinSrc = pkgs.stdenv.mkDerivation {
name = "privatebin";
src = pkgs.fetchFromGitHub {
owner = "privatebin";
repo = "privatebin";
rev = "d65bf02d7819a530c3c2a88f6f9947651fe5258d";
sha256 = "7ttAvEDL1ab0cUZcqZzXFkXwB2rF2t4eNpPxt48ap94=";
};
installPhase = ''
cp -ar $src $out
'';
};
in
{
options.services.privatebin = {
enable = lib.mkEnableOption "enable privatebin";
host = lib.mkOption {
type = lib.types.str;
example = "example.com";
};
};
config = lib.mkIf cfg.enable {
users.users.privatebin = {
description = "privatebin service user";
group = "privatebin";
isSystemUser = true;
};
users.groups.privatebin = { };
services.nginx.enable = true;
services.nginx.virtualHosts.${cfg.host} = {
enableACME = true;
forceSSL = true;
locations."/" = {
root = privateBinSrc;
index = "index.php";
};
locations."~ \.php$" = {
root = privateBinSrc;
extraConfig = ''
fastcgi_pass unix:${config.services.phpfpm.pools.privatebin.socket};
fastcgi_index index.php;
'';
};
};
systemd.tmpfiles.rules = [
"d '/var/lib/privatebin' 0750 privatebin privatebin - -"
];
services.phpfpm.pools.privatebin = {
user = "privatebin";
group = "privatebin";
phpEnv = {
CONFIG_PATH = "${./conf.php}";
};
settings = {
pm = "dynamic";
"listen.owner" = config.services.nginx.user;
"pm.max_children" = 5;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 1;
"pm.max_spare_servers" = 3;
"pm.max_requests" = 500;
};
};
};
}

View File

@@ -1,75 +0,0 @@
{ config, pkgs, lib, ... }:
let
cfg = config.services.radio;
radioPackage = config.inputs.radio.packages.${config.currentSystem}.radio;
in
{
options.services.radio = {
enable = lib.mkEnableOption "enable radio";
user = lib.mkOption {
type = lib.types.str;
default = "radio";
description = ''
The user radio should run as
'';
};
group = lib.mkOption {
type = lib.types.str;
default = "radio";
description = ''
The group radio should run as
'';
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/radio";
description = ''
Path to the radio data directory
'';
};
host = lib.mkOption {
type = lib.types.str;
description = ''
Domain radio is hosted on
'';
};
nginx = lib.mkEnableOption "enable nginx";
};
config = lib.mkIf cfg.enable {
services.icecast = {
enable = true;
hostname = cfg.host;
mount = "stream.mp3";
fallback = "fallback.mp3";
};
services.nginx.virtualHosts.${cfg.host} = lib.mkIf cfg.nginx {
enableACME = true;
forceSSL = true;
locations."/".root = config.inputs.radio-web;
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.group} = { };
systemd.services.radio = {
enable = true;
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig.ExecStart = "${radioPackage}/bin/radio ${config.services.icecast.listen.address}:${toString config.services.icecast.listen.port} ${config.services.icecast.mount} 5500";
serviceConfig.User = cfg.user;
serviceConfig.Group = cfg.group;
serviceConfig.WorkingDirectory = cfg.dataDir;
preStart = ''
mkdir -p ${cfg.dataDir}
chown ${cfg.user} ${cfg.dataDir}
'';
};
};
}

View File

@@ -5,30 +5,38 @@
services.samba = {
openFirewall = true;
package = pkgs.sambaFull; # printer sharing
securityType = "user";
# should this be on?
nsswins = true;
extraConfig = ''
workgroup = HOME
server string = smbnix
netbios name = smbnix
security = user
use sendfile = yes
min protocol = smb2
guest account = nobody
map to guest = bad user
settings = {
global = {
security = "user";
workgroup = "HOME";
"server string" = "smbnix";
"netbios name" = "smbnix";
"use sendfile" = "yes";
"min protocol" = "smb2";
"guest account" = "nobody";
"map to guest" = "bad user";
# printing
load printers = yes
printing = cups
printcap name = cups
# printing
"load printers" = "yes";
printing = "cups";
"printcap name" = "cups";
hide files = /.nobackup/.DS_Store/._.DS_Store/
'';
"hide files" = "/.nobackup/.DS_Store/._.DS_Store/";
shares = {
# Samba 4.22+ enables SMB3 directory leases by default, allowing clients
# to cache directory listings locally. When files are created locally on
# the server (bypassing Samba), these cached listings go stale because
# kernel oplocks — the mechanism that would break leases on local
# changes — is incompatible with smb2 leases. Enabling kernel oplocks
# would fix this but forces Samba to disable smb2 leases, durable
# handles, and level2 oplocks, losing handle caching performance.
# https://wiki.samba.org/index.php/Editing_files_locally_on_server:_interoperability
"smb3 directory leases" = "no";
};
public = {
path = "/data/samba/Public";
browseable = "yes";
@@ -77,9 +85,9 @@
# backups
backup.group."samba".paths = [
config.services.samba.shares.googlebot.path
config.services.samba.shares.cris.path
config.services.samba.shares.public.path
config.services.samba.settings.googlebot.path
config.services.samba.settings.cris.path
config.services.samba.settings.public.path
];
# Windows discovery of samba server
@@ -97,7 +105,7 @@
# Printer discovery
# (is this needed?)
services.avahi.enable = true;
services.avahi.nssmdns = true;
services.avahi.nssmdns4 = true;
# printer sharing
systemd.tmpfiles.rules = [

View File

@@ -1,30 +0,0 @@
{ config, pkgs, lib, ... }:
let
cfg = config.services.searx;
in
{
config = lib.mkIf cfg.enable {
services.searx = {
environmentFile = "/run/agenix/searx";
settings = {
server.port = 43254;
server.secret_key = "@SEARX_SECRET_KEY@";
engines = [{
name = "wolframalpha";
shortcut = "wa";
api_key = "@WOLFRAM_API_KEY@";
engine = "wolframalpha_api";
}];
};
};
services.nginx.virtualHosts."search.neet.space" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://localhost:${toString config.services.searx.settings.server.port}";
};
};
age.secrets.searx.file = ../../secrets/searx.age;
};
}

26
common/server/unifi.nix Normal file
View File

@@ -0,0 +1,26 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.unifi;
in
{
options.services.unifi = {
# Open select Unifi ports instead of using openFirewall to avoid opening access to unifi's control panel
openMinimalFirewall = lib.mkEnableOption "Open bare minimum firewall ports";
};
config = lib.mkIf cfg.enable {
services.unifi.unifiPackage = pkgs.unifi;
services.unifi.mongodbPackage = pkgs.mongodb-7_0;
networking.firewall = lib.mkIf cfg.openMinimalFirewall {
allowedUDPPorts = [
3478 # STUN
10001 # used for device discovery.
];
allowedTCPPorts = [
8080 # Used for device and application communication.
];
};
};
}

View File

@@ -1,97 +0,0 @@
{ config, pkgs, ... }:
let
# external
rtp-port = 8083;
webrtc-peer-lower-port = 20000;
webrtc-peer-upper-port = 20100;
domain = "live.neet.space";
# internal
ingest-port = 8084;
web-port = 8085;
webrtc-port = 8086;
toStr = builtins.toString;
in
{
networking.firewall.allowedUDPPorts = [ rtp-port ];
networking.firewall.allowedTCPPortRanges = [{
from = webrtc-peer-lower-port;
to = webrtc-peer-upper-port;
}];
networking.firewall.allowedUDPPortRanges = [{
from = webrtc-peer-lower-port;
to = webrtc-peer-upper-port;
}];
virtualisation.docker.enable = true;
services.nginx.virtualHosts.${domain} = {
enableACME = true;
forceSSL = true;
locations = {
"/" = {
proxyPass = "http://localhost:${toStr web-port}";
};
"websocket" = {
proxyPass = "http://localhost:${toStr webrtc-port}/websocket";
proxyWebsockets = true;
};
};
};
virtualisation.oci-containers = {
backend = "docker";
containers = {
"lightspeed-ingest" = {
workdir = "/var/lib/lightspeed-ingest";
image = "projectlightspeed/ingest";
ports = [
"${toStr ingest-port}:8084"
];
# imageFile = pkgs.dockerTools.pullImage {
# imageName = "projectlightspeed/ingest";
# finalImageTag = "version-0.1.4";
# imageDigest = "sha256:9fc51833b7c27a76d26e40f092b9cec1ac1c4bfebe452e94ad3269f1f73ff2fc";
# sha256 = "19kxl02x0a3i6hlnsfcm49hl6qxnq2f3hfmyv1v8qdaz58f35kd5";
# };
};
"lightspeed-react" = {
workdir = "/var/lib/lightspeed-react";
image = "projectlightspeed/react";
ports = [
"${toStr web-port}:80"
];
# imageFile = pkgs.dockerTools.pullImage {
# imageName = "projectlightspeed/react";
# finalImageTag = "version-0.1.3";
# imageDigest = "sha256:b7c58425f1593f7b4304726b57aa399b6e216e55af9c0962c5c19333fae638b6";
# sha256 = "0d2jh7mr20h7dxgsp7ml7cw2qd4m8ja9rj75dpy59zyb6v0bn7js";
# };
};
"lightspeed-webrtc" = {
workdir = "/var/lib/lightspeed-webrtc";
image = "projectlightspeed/webrtc";
ports = [
"${toStr webrtc-port}:8080"
"${toStr rtp-port}:65535/udp"
"${toStr webrtc-peer-lower-port}-${toStr webrtc-peer-upper-port}:${toStr webrtc-peer-lower-port}-${toStr webrtc-peer-upper-port}/tcp"
"${toStr webrtc-peer-lower-port}-${toStr webrtc-peer-upper-port}:${toStr webrtc-peer-lower-port}-${toStr webrtc-peer-upper-port}/udp"
];
cmd = [
"lightspeed-webrtc"
"--addr=0.0.0.0"
"--ip=${domain}"
"--ports=${toStr webrtc-peer-lower-port}-${toStr webrtc-peer-upper-port}"
"run"
];
# imageFile = pkgs.dockerTools.pullImage {
# imageName = "projectlightspeed/webrtc";
# finalImageTag = "version-0.1.2";
# imageDigest = "sha256:ddf8b3dd294485529ec11d1234a3fc38e365a53c4738998c6bc2c6930be45ecf";
# sha256 = "1bdy4ak99fjdphj5bsk8rp13xxmbqdhfyfab14drbyffivg9ad2i";
# };
};
};
};
}

View File

@@ -1,7 +0,0 @@
Copyright 2020 Matthijs Steen
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,37 +0,0 @@
# Visual Studio Code Server support in NixOS
Experimental support for VS Code Server in NixOS. The NodeJS by default supplied by VS Code cannot be used within NixOS due to missing hardcoded paths, so it is automatically replaced by a symlink to a compatible version of NodeJS that does work under NixOS.
## Installation
```nix
{
imports = [
(fetchTarball "https://github.com/msteen/nixos-vscode-server/tarball/master")
];
services.vscode-server.enable = true;
}
```
And then enable them for the relevant users:
```
systemctl --user enable auto-fix-vscode-server.service
```
### Home Manager
```nix
{
imports = [
"${fetchTarball "https://github.com/msteen/nixos-vscode-server/tarball/master"}/modules/vscode-server/home.nix"
];
services.vscode-server.enable = true;
}
```
## Usage
When the service is enabled and running it should simply work, there is nothing for you to do.

View File

@@ -1 +0,0 @@
import ./modules/vscode-server

View File

@@ -1,8 +0,0 @@
import ./module.nix ({ name, description, serviceConfig }:
{
systemd.user.services.${name} = {
inherit description serviceConfig;
wantedBy = [ "default.target" ];
};
})

View File

@@ -1,15 +0,0 @@
import ./module.nix ({ name, description, serviceConfig }:
{
systemd.user.services.${name} = {
Unit = {
Description = description;
};
Service = serviceConfig;
Install = {
WantedBy = [ "default.target" ];
};
};
})

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