summaryrefslogtreecommitdiff
path: root/hostnix
diff options
context:
space:
mode:
Diffstat (limited to 'hostnix')
-rw-r--r--hostnix/creep/configuration.nix105
-rw-r--r--hostnix/creep/hardware-configuration.nix41
-rw-r--r--hostnix/creep/wifi-vpn.nix101
-rw-r--r--hostnix/elmo/Makefile12
-rw-r--r--hostnix/elmo/acme.nix49
-rw-r--r--hostnix/elmo/backup.nix61
-rw-r--r--hostnix/elmo/bjj-booker.nix47
-rw-r--r--hostnix/elmo/cal.nix35
-rw-r--r--hostnix/elmo/cgithub.nix11
-rw-r--r--hostnix/elmo/clippersnip.nix62
-rw-r--r--hostnix/elmo/configuration.nix110
-rw-r--r--hostnix/elmo/dns.nix22
-rw-r--r--hostnix/elmo/dyndns.nix76
-rw-r--r--hostnix/elmo/email.nix101
-rw-r--r--hostnix/elmo/flake.lock27
-rw-r--r--hostnix/elmo/flake.nix12
-rw-r--r--hostnix/elmo/garage.nix29
-rw-r--r--hostnix/elmo/git.nix38
-rw-r--r--hostnix/elmo/hardware-configuration.nix74
-rw-r--r--hostnix/elmo/home-assistant.nix473
-rw-r--r--hostnix/elmo/media.nix141
-rw-r--r--hostnix/elmo/oidc.nix75
-rw-r--r--hostnix/elmo/pinchflat.nix87
-rw-r--r--hostnix/elmo/rss.nix34
-rw-r--r--hostnix/elmo/syncthing.nix193
-rw-r--r--hostnix/elmo/system.nix79
-rw-r--r--hostnix/elmo/typetype.nix158
-rw-r--r--hostnix/elmo/usenet.nix52
-rw-r--r--hostnix/elmo/web.nix34
-rw-r--r--hostnix/elmo/wireguard.nix32
-rw-r--r--hostnix/elmo/yakatak.nix41
-rw-r--r--hostnix/mojo/Makefile7
-rw-r--r--hostnix/mojo/flake.lock66
-rw-r--r--hostnix/mojo/flake.nix53
-rw-r--r--hostnix/mojo/modules/apps.nix41
-rw-r--r--hostnix/mojo/modules/obsidian.nix17
-rw-r--r--hostnix/mojo/modules/sunshine.nix23
-rw-r--r--hostnix/mojo/modules/system.nix28
-rw-r--r--hostnix/mojo/packages/claude-code/claude.sb314
-rw-r--r--hostnix/mojo/packages/claude-code/default.nix21
-rw-r--r--hostnix/weebnix/Makefile7
-rw-r--r--hostnix/weebnix/boot/config.txt36
-rw-r--r--hostnix/weebnix/configuration.nix93
-rw-r--r--hostnix/weebnix/dyndns.nix78
-rw-r--r--hostnix/weebnix/hardware-configuration.nix51
-rw-r--r--hostnix/weebnix/home-assistant.nix116
-rw-r--r--hostnix/weebnix/oidc.nix51
-rw-r--r--hostnix/weebnix/privacy-frontends.nix15
-rw-r--r--hostnix/weebnix/syncthing.nix136
-rw-r--r--hostnix/weebnix/system.nix49
50 files changed, 3614 insertions, 0 deletions
diff --git a/hostnix/creep/configuration.nix b/hostnix/creep/configuration.nix
new file mode 100644
index 0000000..7fa748d
--- /dev/null
+++ b/hostnix/creep/configuration.nix
@@ -0,0 +1,105 @@
+# NetComm router (CG-NAT) - 192.168.20.1
+
+{ pkgs, ... }:
+
+{
+ imports = [
+ ./hardware-configuration.nix
+ ./wifi-vpn.nix
+ ];
+
+ nix.settings.experimental-features = [ "nix-command" "flakes" ];
+
+ boot.loader.systemd-boot.enable = true;
+ # Raspberry Pi has no NVRAM.
+ boot.loader.efi.canTouchEfiVariables = false;
+
+ boot.kernelPackages = pkgs.linuxPackages_rpi4;
+ # https://github.com/NixOS/nixpkgs/issues/122130#issuecomment-1568815007
+ # It's unclear if these are strictly necessary with the downstream kernel,
+ # but let's leave them in to keep working with mainline.
+ boot.initrd.availableKernelModules = [ "uas" "pcie-brcmstb" "reset-raspberrypi" ];
+
+ networking.hostName = "creep";
+ networking.domain = "mou.fo";
+
+ networking.wireless = {
+ enable = true;
+ interfaces = [ "wlan0" ];
+ networks = {
+ "Girls Gone Wireless".psk = "Paddlepops103!";
+ "Cali's internet".psk = "calibanthetempest2019";
+ };
+ };
+
+ time.timeZone = "Australia/Sydney";
+ users.users.joe = {
+ isNormalUser = true;
+ description = "Joe Mou";
+ extraGroups = [ "wheel" ];
+ openssh.authorizedKeys.keys = [
+ "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDHcqQDnH0LcUWFV+cd9aABfM55+72UAn1ZY5x5bHwPL4IEgT1nagbi1X3FwCJkh2rEcDsHl4FflcNfh+IyU2VyuQfRIXl7CKP040ovXmPVzgU8JiEDrrRebpZ7aHCKk+4Q6cJ8dzAI1sRogVyYrV0hLB4yeMnVexkIcsQMt5pXQ1zodlzHfW1W7OWQDbKjIyh4pv1KJ/IIjiBIt7l+OCqi7wZK4ubmxyMhmZx1xpRC5mz2/29Mpt+1VgCmxC7Hv1SG7yu+U0s4eJ3kbzFKNcZ/bCYqFxK48dT66qhS3qZFg09omumG8IbEUcx+WqM1V7WhiJYig9x5ovmMToQXTJ5uxLtcovnxO/53IgwV7INvmr8+NhD+LlkQfSjjUAW09bhVj9sKIW2bbrIxlOT8z6CQY9zGXNXi8u1oxPt3Q7d3rMxoWhWnr2l3Ok7OIkWooOK3e0NdhgIAxZKlYJAmIYaX391sGTyiuwUy96mg5JcYsLYMfkEg/Sv+2vQp8kkvB0E= joe@sparky"
+ ];
+ };
+
+ environment.systemPackages = with pkgs; [
+ dig
+ file
+ gitFull
+ libraspberrypi
+ psmisc
+ tmux
+ tree
+ ];
+
+ programs.vim.defaultEditor = true;
+ programs.nano.enable = false;
+
+ services.openssh.enable = true;
+
+ # Note: seems to leave stale connections open on server on dirty poweroff.
+ systemd.services.reverse-ssh = {
+ description = "SSH reverse tunnel";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network-online.target" ];
+ serviceConfig = {
+ RestartSec = 60;
+ Restart = "always";
+ };
+ script = ''
+ ${pkgs.openssh}/bin/ssh \
+ -o ServerAliveInterval=60 -o ExitOnForwardFailure=yes \
+ -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no \
+ -i /etc/ssh/ssh_host_ed25519_key \
+ -q -N -R 19422:localhost:22 joe@creepgw.mou.fo
+ '';
+ };
+
+ systemd.services.dyndns = {
+ description = "Dynamic DNS update";
+ after = [ "network-online.target" ];
+ serviceConfig = {
+ Type = "oneshot";
+ TimeoutStartSec = "60s";
+ };
+ script = ''
+ ${pkgs.curl}/bin/curl -4 -fsS https://dyn.dns.he.net/nic/update -d hostname=creep.he.mou.fo -d password=FriE83Y4HPWmwdYn
+ '';
+ };
+
+ systemd.timers.dyndns = {
+ wantedBy = [ "multi-user.target" ];
+ timerConfig = {
+ OnStartupSec = "10";
+ OnUnitActiveSec = "5min";
+ };
+ };
+
+ # This value determines the NixOS release from which the default
+ # settings for stateful data, like file locations and database versions
+ # on your system were taken. It's perfectly fine and recommended to leave
+ # this value at the release version of the first install of this system.
+ # Before changing this value read the documentation for this option
+ # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
+ system.stateVersion = "23.11"; # Did you read the comment?
+}
diff --git a/hostnix/creep/hardware-configuration.nix b/hostnix/creep/hardware-configuration.nix
new file mode 100644
index 0000000..0dce739
--- /dev/null
+++ b/hostnix/creep/hardware-configuration.nix
@@ -0,0 +1,41 @@
+# Do not modify this file! It was generated by ‘nixos-generate-config’
+# and may be overwritten by future invocations. Please make changes
+# to /etc/nixos/configuration.nix instead.
+{ config, lib, pkgs, modulesPath, ... }:
+
+{
+ imports =
+ [ (modulesPath + "/installer/scan/not-detected.nix")
+ ];
+
+ boot.initrd.availableKernelModules = [ "xhci_pci" "usb_storage" ];
+ boot.initrd.kernelModules = [ ];
+ boot.kernelModules = [ ];
+ boot.extraModulePackages = [ ];
+
+ fileSystems."/" =
+ { device = "/dev/disk/by-uuid/6457623e-7a80-41bc-8124-9aba2472a46d";
+ fsType = "ext4";
+ };
+
+ fileSystems."/boot" =
+ { device = "/dev/disk/by-uuid/AD39-9DD6";
+ fsType = "vfat";
+ };
+
+ swapDevices = [ {
+ device = "/var/lib/swap";
+ size = 4 * 1024;
+ randomEncryption.enable = true;
+ } ];
+
+ # Enables DHCP on each ethernet and wireless interface. In case of scripted networking
+ # (the default) this is the recommended approach. When using systemd-networkd it's
+ # still possible to use this option, but it's recommended to use it in conjunction
+ # with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
+ networking.useDHCP = lib.mkDefault true;
+ # networking.interfaces.end0.useDHCP = lib.mkDefault true;
+ # networking.interfaces.wlan0.useDHCP = lib.mkDefault true;
+
+ nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
+}
diff --git a/hostnix/creep/wifi-vpn.nix b/hostnix/creep/wifi-vpn.nix
new file mode 100644
index 0000000..ad94f4b
--- /dev/null
+++ b/hostnix/creep/wifi-vpn.nix
@@ -0,0 +1,101 @@
+# TODO not working?
+
+{ pkgs, ... }:
+
+{
+ boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
+
+ # Manual configuration on popfresh.mou.town:
+ # /etc/wireguard/wg0.conf
+ # # firewall-cmd --add-port=51820/udp
+ # # systemctl enable --now wg-quick@wg0
+ networking.wg-quick.interfaces = {
+ wg0 = {
+ address = [ "172.28.89.2/24" ];
+ privateKeyFile = "/root/wg0.key";
+ # wg-quick normally adds routes for AllowedIPs to the default table.
+ # Specify a non-default table to instead use policy-based routing.
+ # Using netns may be an alternative.
+ table = "89";
+ # IP masquerade (SNAT) anything from the WiFi AP.
+ postUp = ''
+ ${pkgs.iptables}/bin/iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE
+ ${pkgs.iproute2}/bin/ip rule add iif wlp1s0u1u3 lookup 89
+ '';
+ preDown = ''
+ ${pkgs.iptables}/bin/iptables -t nat -D POSTROUTING -o wg0 -j MASQUERADE
+ ${pkgs.iproute2}/bin/ip rule del iif wlp1s0u1u3 lookup 89
+ '';
+ peers = [ {
+ publicKey = "iIRCcLGBvo0LBCysJKa5sbTs/Y4VR4PUDHMkoaU6sgo=";
+ allowedIPs = [ "0.0.0.0/0" ];
+ endpoint = "creepgw.mou.fo:51820";
+ persistentKeepalive = 25; # keep NAT rules alive
+ } ];
+ };
+ };
+
+ services.hostapd = {
+ enable = true;
+ radios.wlp1s0u1u3 = {
+ band = "5g";
+ # Wasn't able to get Auto Channel Selection working, so specify a band
+ # that should allow for High Throughput 40 MHz (HT40).
+ channel = 40;
+ countryCode = "AU";
+ # The network must have the same name as the radio.
+ networks.wlp1s0u1u3 = {
+ ssid = "The Up Over";
+ authentication = {
+ mode = "wpa3-sae-transition";
+ wpaPassword = "comingtoamerica";
+ saePasswords = [ { password = "comingtoamerica"; } ];
+ };
+ };
+ };
+ };
+
+ networking.interfaces.wlp1s0u1u3.ipv4.addresses = [ {
+ address = "172.16.175.1";
+ prefixLength = 24;
+ } ];
+
+ services.kea.dhcp4 = {
+ enable = true;
+ settings = {
+ interfaces-config = {
+ interfaces = [ "wlp1s0u1u3" ];
+ };
+ lease-database = {
+ type = "memfile";
+ persist = true;
+ name = "/var/lib/kea/dhcp4.leases";
+ };
+ subnet4 = [
+ {
+ id = 1;
+ subnet = "172.16.175.0/24";
+ pools = [ { pool = "172.16.175.100 - 172.16.175.240"; } ];
+ option-data = [ {
+ name = "routers";
+ data = "172.16.175.1";
+ } {
+ name = "domain-name-servers";
+ data = "1.1.1.1, 1.0.0.1";
+ } ];
+ }
+ ];
+ };
+ };
+ # Ensure interface is available to serve DHCP.
+ systemd.services.kea-dhcp4-server = {
+ requires = [ "network-addresses-wlp1s0u1u3.service" ];
+ };
+
+ # Generated entropy helps prevent WiFi AP from blocking.
+ services.haveged.enable = true;
+
+ # Reverse path forwarding has complications with multiple interfaces, like
+ # connectivity issues when WiFi and wired are on the same network.
+ networking.firewall.checkReversePath = false;
+}
diff --git a/hostnix/elmo/Makefile b/hostnix/elmo/Makefile
new file mode 100644
index 0000000..825a23e
--- /dev/null
+++ b/hostnix/elmo/Makefile
@@ -0,0 +1,12 @@
+push:
+ rsync -r --rsync-path='sudo rsync' --exclude Makefile . elmo:/etc/nixos/
+
+switch: push
+ ssh elmo sudo nixos-rebuild switch
+
+update:
+ nix flake update
+
+upgrade: update switch
+
+.PHONY: push switch update upgrade
diff --git a/hostnix/elmo/acme.nix b/hostnix/elmo/acme.nix
new file mode 100644
index 0000000..fccd5c8
--- /dev/null
+++ b/hostnix/elmo/acme.nix
@@ -0,0 +1,49 @@
+{ config, lib, pkgs, ... }:
+
+{
+ imports = [ ./dyndns.nix ];
+
+ # https://github.com/NixOS/nixpkgs/issues/210807#issuecomment-1383263210
+ options.services.nginx.virtualHosts = lib.mkOption {
+ type = lib.types.attrsOf (lib.types.submodule {
+ config.acmeRoot = lib.mkDefault null;
+ });
+ };
+
+ config = {
+ security.acme.acceptTerms = true;
+ security.acme.defaults.email = "hostmaster@mou.fo";
+
+ # https://go-acme.github.io/lego/dns/exec/
+ security.acme.defaults.dnsProvider = "exec";
+ security.acme.defaults.credentialFiles = {
+ "DDNS_FILE" = "/var/secrets/dyndns/";
+ };
+ security.acme.defaults.environmentFile = pkgs.writeText "lego.env" ''
+ # While it can be helpful to follow CNAMEs to find the challenge domain,
+ # this heuristic may not work with wildcard domains or DNAME.
+ LEGO_DISABLE_CNAME_SUPPORT=1
+ EXEC_PATH=${pkgs.writers.writeBash "lego-exec" ''
+ set -e
+
+ fqdn=${config.networking.fqdn}
+ challenge_fqdn=$2''${fqdn%%.*}.dynamic.''${fqdn#*.}
+
+ unset update_rr
+ if [[ $1 = present ]]; then
+ update_rr="update add $challenge_fqdn. 300 TXT $3"
+ fi
+
+ ${pkgs.dnsutils}/bin/nsupdate -v -k ''${DDNS_FILE}_$(< ''${DDNS_FILE}_basename).private <<.
+ update delete $challenge_fqdn. TXT
+ $update_rr
+ send
+ .
+
+ if [[ $1 = present ]]; then
+ sleep 5
+ fi
+ ''}
+ '';
+ };
+}
diff --git a/hostnix/elmo/backup.nix b/hostnix/elmo/backup.nix
new file mode 100644
index 0000000..edba6b9
--- /dev/null
+++ b/hostnix/elmo/backup.nix
@@ -0,0 +1,61 @@
+{ lib, ... }:
+
+# TODO consistent btrfs snapshots?
+# TODO dump Home Assistant? Postgres?
+# TODO explicit backup blacklist for /srv and /var? can be a separate cron
+
+{
+ services.restic.backups.local = {
+ # The repo file permissions and our exclude file assume our user.
+ user = "joe";
+ repository = "/srv/restic/repo";
+ paths = [
+ # Same as ~/.dotfiles/restic/run
+ "/etc"
+ "/home"
+ "/root"
+ "/var/home"
+ "/var/spool/cron"
+ "/var/www"
+
+ "/srv/git"
+ "/var/lib"
+ "/var/secrets"
+ ];
+ # The restic repo is not secure at rest because our password is colocated.
+ passwordFile = "%d/password";
+ # Same as ~/.dotfiles/restic/run
+ extraBackupArgs = [
+ "--one-file-system"
+ "--exclude-file=/home/joe/.dotfiles/restic/exclude"
+ "--exclude-caches"
+ ];
+ backupPrepareCommand = let ls-lR = [
+ "/srv/media"
+ "/var/lib/acme"
+ "/var/secrets"
+ ];
+ in
+ ''
+ ls -lR ${lib.concatStringsSep " " ls-lR} > ~/.dotfiles/restic/errata/ls-lR.excluded
+ '';
+ # The wrapper would not be able to use RESTIC_PASSWORD_FILE from a systemd
+ # credential.
+ createWrapper = false;
+ timerConfig = null; # TODO daily?
+ };
+
+ systemd.services.restic-backups-local = {
+ serviceConfig = {
+ LoadCredential = [ "password:/var/secrets/restic" ];
+ AmbientCapabilities = [ "CAP_DAC_READ_SEARCH" ];
+ };
+ };
+
+ # TODO restic-sync to spanommers
+
+ # TODO mirror?
+ # - /srv/Attic (split into archive/mirror and backup/adhoc?)
+ # - /srv/from-spanommers (move to /srv/Attic/Backups?)
+ # - /srv/syncthing (or configure spanommers with syncthing?)
+}
diff --git a/hostnix/elmo/bjj-booker.nix b/hostnix/elmo/bjj-booker.nix
new file mode 100644
index 0000000..39dd44d
--- /dev/null
+++ b/hostnix/elmo/bjj-booker.nix
@@ -0,0 +1,47 @@
+{ pkgs, ... }:
+
+{
+ systemd.tmpfiles.rules = [
+ "d /opt/bjj-booker 0755 joe users"
+ ];
+
+ systemd.sockets.bjj-booker = {
+ wantedBy = [ "sockets.target" ];
+ socketConfig = {
+ ListenStream = "/run/bjj-booker/socket";
+ SocketGroup = "nginx";
+ SocketMode = "0660";
+ };
+ };
+
+ systemd.services.bjj-booker = {
+ environment.GYMDESK_EMAIL = "nyc@mou.fo";
+ serviceConfig = {
+ Type = "exec";
+ DynamicUser = true;
+ WorkingDirectory = "/opt/bjj-booker";
+ StateDirectory = "bjj-booker";
+ LoadCredential = [ "GYMDESK_PASSWORD:/var/secrets/bjj-booker.password" ];
+ };
+ script = ''
+ export GYMDESK_PASSWORD=$(< $CREDENTIALS_DIRECTORY/GYMDESK_PASSWORD)
+ exec ${pkgs.nodejs-slim_24}/bin/node server.js
+ '';
+ };
+
+ services.nginx.virtualHosts."bjj.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/" = {
+ proxyPass = "http://unix:/run/bjj-booker/socket";
+ };
+ locations."=/bookings.ics" = {
+ proxyPass = "http://unix:/run/bjj-booker/socket";
+ extraConfig = ''
+ auth_request off;
+ '';
+ };
+ };
+
+ services.oauth2-proxy.nginx.virtualHosts."bjj.mou.fo" = { };
+}
diff --git a/hostnix/elmo/cal.nix b/hostnix/elmo/cal.nix
new file mode 100644
index 0000000..54946a9
--- /dev/null
+++ b/hostnix/elmo/cal.nix
@@ -0,0 +1,35 @@
+{ pkgs, ... }:
+
+{
+ systemd.tmpfiles.rules = [
+ "d /opt/cal 0755 joe users"
+ ];
+
+ systemd.sockets.cal = {
+ wantedBy = [ "sockets.target" ];
+ socketConfig = {
+ ListenStream = "/run/cal/socket";
+ SocketGroup = "nginx";
+ SocketMode = "0660";
+ };
+ };
+
+ systemd.services.cal = {
+ serviceConfig = {
+ Type = "exec";
+ DynamicUser = true;
+ WorkingDirectory = "/opt/cal";
+ StateDirectory = "cal";
+ ExecStart = "${pkgs.nodejs-slim_26}/bin/node server.ts";
+ };
+ };
+
+ # TODO allocate domain?
+ services.nginx.virtualHosts."cal.elmo.mou.fo" = {
+ useACMEHost = "elmo.mou.fo";
+ forceSSL = true;
+ locations."/" = {
+ proxyPass = "http://unix:/run/cal/socket";
+ };
+ };
+}
diff --git a/hostnix/elmo/cgithub.nix b/hostnix/elmo/cgithub.nix
new file mode 100644
index 0000000..ec7c162
--- /dev/null
+++ b/hostnix/elmo/cgithub.nix
@@ -0,0 +1,11 @@
+{ ... }:
+
+{
+ services.nginx.virtualHosts."fluffy-kitten.elmo.mou.fo" = {
+ useACMEHost = "elmo.mou.fo";
+ forceSSL = true;
+ locations."/" = {
+ return = "301 https://cgithub.jmou.workers.dev$request_uri";
+ };
+ };
+}
diff --git a/hostnix/elmo/clippersnip.nix b/hostnix/elmo/clippersnip.nix
new file mode 100644
index 0000000..5f3efc5
--- /dev/null
+++ b/hostnix/elmo/clippersnip.nix
@@ -0,0 +1,62 @@
+{ pkgs, ... }:
+
+{
+ systemd.tmpfiles.rules = [
+ "d /opt/clippersnip 0755 joe users"
+ "e /var/lib/private/clippersnip - - - 365d"
+ ];
+
+ systemd.sockets.clippersnip = {
+ wantedBy = [ "sockets.target" ];
+ socketConfig = {
+ ListenStream = "/run/clippersnip/socket";
+ SocketGroup = "nginx";
+ SocketMode = "0660";
+ };
+ };
+
+ systemd.services.clippersnip = {
+ path = [ pkgs.ffmpeg-headless ];
+ environment.CLIPS_DIR = "/var/lib/clippersnip";
+ serviceConfig = {
+ Type = "exec";
+ DynamicUser = true;
+ WorkingDirectory = "/opt/clippersnip";
+ StateDirectory = "clippersnip";
+ ExecStart = "${pkgs.nodejs-slim_24}/bin/node server.ts";
+ };
+ };
+
+ # TODO allocate domain?
+ services.nginx.virtualHosts."cs.elmo.mou.fo" = {
+ useACMEHost = "elmo.mou.fo";
+ forceSSL = true;
+ locations."/" = {
+ proxyPass = "http://unix:/run/clippersnip/socket";
+ };
+ locations."/c/" = {
+ proxyPass = "http://unix:/run/clippersnip/socket";
+ extraConfig = ''
+ auth_request off;
+ proxy_buffering off;
+ '';
+ };
+ # ffmpeg can run for minutes.
+ locations."/api/clip" = {
+ proxyPass = "http://unix:/run/clippersnip/socket";
+ extraConfig = ''
+ proxy_read_timeout 600s;
+ '';
+ };
+ # Serve audio HTTP Range requests directly.
+ locations."/api/audio" = {
+ proxyPass = "http://unix:/run/clippersnip/socket";
+ extraConfig = ''
+ proxy_buffering off;
+ proxy_read_timeout 300s;
+ '';
+ };
+ };
+
+ services.oauth2-proxy.nginx.virtualHosts."cs.elmo.mou.fo" = { };
+}
diff --git a/hostnix/elmo/configuration.nix b/hostnix/elmo/configuration.nix
new file mode 100644
index 0000000..c53e4c3
--- /dev/null
+++ b/hostnix/elmo/configuration.nix
@@ -0,0 +1,110 @@
+{ config, lib, pkgs, ... }:
+
+{
+ imports = [
+ ./acme.nix
+ ./backup.nix
+ ./bjj-booker.nix
+ ./cal.nix
+ ./cgithub.nix
+ ./clippersnip.nix
+ ./dns.nix
+ ./dyndns.nix
+ ./email.nix
+ ./garage.nix
+ ./git.nix
+ ./hardware-configuration.nix
+ ./home-assistant.nix
+ ./media.nix
+ ./oidc.nix
+ ./pinchflat.nix
+ ./rss.nix
+ ./syncthing.nix
+ ./system.nix
+ ./typetype.nix
+ ./usenet.nix
+ ./web.nix
+ ./wireguard.nix
+ ./yakatak.nix
+ ];
+
+ nix.settings.experimental-features = [ "nix-command" "flakes" ];
+
+ security.sudo.wheelNeedsPassword = false;
+
+ users.users.joe = {
+ isNormalUser = true;
+ description = "Joe Mou";
+ extraGroups = [ "networkmanager" "wheel" ];
+ packages = with pkgs; [
+ jq
+ sqlite-interactive
+ ];
+ openssh.authorizedKeys.keys = [
+ "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDHcqQDnH0LcUWFV+cd9aABfM55+72UAn1ZY5x5bHwPL4IEgT1nagbi1X3FwCJkh2rEcDsHl4FflcNfh+IyU2VyuQfRIXl7CKP040ovXmPVzgU8JiEDrrRebpZ7aHCKk+4Q6cJ8dzAI1sRogVyYrV0hLB4yeMnVexkIcsQMt5pXQ1zodlzHfW1W7OWQDbKjIyh4pv1KJ/IIjiBIt7l+OCqi7wZK4ubmxyMhmZx1xpRC5mz2/29Mpt+1VgCmxC7Hv1SG7yu+U0s4eJ3kbzFKNcZ/bCYqFxK48dT66qhS3qZFg09omumG8IbEUcx+WqM1V7WhiJYig9x5ovmMToQXTJ5uxLtcovnxO/53IgwV7INvmr8+NhD+LlkQfSjjUAW09bhVj9sKIW2bbrIxlOT8z6CQY9zGXNXi8u1oxPt3Q7d3rMxoWhWnr2l3Ok7OIkWooOK3e0NdhgIAxZKlYJAmIYaX391sGTyiuwUy96mg5JcYsLYMfkEg/Sv+2vQp8kkvB0E= joe@sparky"
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIvvJXGg1HVDU2z2osjq5FEAcwte8ZybuYj1wpTtwr1m joe@doughboy"
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPsci2NPhPgg7T77vtcnkcv5Z9sbHAsmp9XC11WPePvL joe@Joes-Mac-mini.local"
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILU1pGPkl/6A2DXrEZd5elLCJ7OCnG9QCEvaopFW8gEg joe@penguin"
+ ];
+ };
+
+ # TODO make into a module
+ nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [
+ "unrar" # from nzbget
+ ];
+
+ environment.systemPackages = with pkgs; [
+ dig
+ file
+ gitFull
+ openssl
+ psmisc
+ python3
+ restic
+ tmux
+ tree
+ unzip
+ ];
+
+ programs.vim = {
+ enable = true;
+ defaultEditor = true;
+ };
+ programs.nano.enable = false;
+
+ services.envfs.enable = true;
+ services.fstrim.enable = true;
+ services.openssh.enable = true;
+
+ services.sshguard = {
+ enable = true;
+ whitelist = [ "192.168.0.0/24" ];
+ };
+
+ services.locate.enable = true;
+
+ services.postgresql = {
+ enable = true;
+ package = pkgs.postgresql_15;
+ };
+
+ systemd.services.duperemove = {
+ serviceConfig = {
+ Type = "simple";
+ CacheDirectory = "duperemove";
+ };
+ script = ''
+ exec ${pkgs.duperemove}/bin/duperemove -dhrq --hashfile $CACHE_DIRECTORY/hashfile /srv /var
+ '';
+ };
+
+ networking.firewall.allowedTCPPorts = [ 80 443 ];
+
+ # This value determines the NixOS release from which the default
+ # settings for stateful data, like file locations and database versions
+ # on your system were taken. It's perfectly fine and recommended to leave
+ # this value at the release version of the first install of this system.
+ # Before changing this value read the documentation for this option
+ # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
+ system.stateVersion = "23.11"; # Did you read the comment?
+}
diff --git a/hostnix/elmo/dns.nix b/hostnix/elmo/dns.nix
new file mode 100644
index 0000000..62331a0
--- /dev/null
+++ b/hostnix/elmo/dns.nix
@@ -0,0 +1,22 @@
+{ ... }:
+
+{
+ services.blocky = {
+ enable = true;
+ settings = {
+ upstreams.groups.default = [
+ "1.1.1.1" "1.0.0.1"
+ "2606:4700:4700::1111" "2606:4700:4700::1001"
+ ];
+ blocking = {
+ blackLists.ads = [
+ # https://jasonpearce.com/2020/09/16/how-to-disable-ads-on-the-roku-home-screen/
+ "https://www.github.developerdan.com/hosts/lists/ads-and-tracking-extended.txt"
+ ];
+ clientGroupsBlock.default = [ "ads" ];
+ };
+ };
+ };
+
+ networking.firewall.allowedUDPPorts = [ 53 ];
+}
diff --git a/hostnix/elmo/dyndns.nix b/hostnix/elmo/dyndns.nix
new file mode 100644
index 0000000..56a46cc
--- /dev/null
+++ b/hostnix/elmo/dyndns.nix
@@ -0,0 +1,76 @@
+{ config, pkgs, ... }:
+
+{
+ systemd.tmpfiles.rules = [
+ "d /var/secrets 0750 root wheel"
+ ];
+
+ # Needs to be started manually, and the key added to nameservers.
+ # Based on https://nixos.org/manual/nixos/stable/index.html#module-security-acme-config-dns
+ systemd.services.sig0-keygen = {
+ unitConfig = {
+ ConditionPathExists = "!/var/secrets/dyndns";
+ };
+ serviceConfig = {
+ Type = "oneshot";
+ };
+ scriptArgs = config.networking.fqdn;
+ script = ''
+ mkdir /var/secrets/dyndns
+ cd /var/secrets/dyndns
+ ${pkgs.bind}/bin/dnssec-keygen -a RSASHA512 -b 2048 -n HOST -T KEY $1. > basename
+ '';
+ };
+
+ # Unused with authoritative DNS on the router. We leave it for redundancy.
+ systemd.services.dyndns = {
+ requires = [ "network-online.target" ];
+ after = [ "network-online.target" ];
+ unitConfig = {
+ AssertPathExists = "/var/secrets/dyndns";
+ # Retry ~30min before giving up.
+ StartLimitIntervalSec = "45min";
+ StartLimitBurst = "60";
+ OnFailure = "status-email@%n.service";
+ };
+ serviceConfig = {
+ Type = "oneshot";
+ Restart = "on-failure";
+ # Restart must be faster than the regular timer interval to exceed the
+ # start limit when flapping.
+ RestartSec = "30";
+ # Defer OnFailure until after retries.
+ RestartMode = "direct";
+ };
+ path = [ pkgs.dnsutils pkgs.gawk pkgs.iproute2 ];
+ scriptArgs = config.networking.fqdn;
+ script = ''
+ RR=''${1%%.*}.dynamic.''${1#*.}
+
+ IP4=`dig +short @1.1.1.1 ch txt whoami.cloudflare | tr -d \"`
+ if [ -z "$IP4" ]; then
+ echo "Missing IP: $IP4" >&2
+ exit 100
+ fi
+
+ OLDIP4=`dig +short @popfresh.mou.fo $RR A 2> /dev/null`
+ [ "x$IP4" = "x$OLDIP4" ] && exit 0 # no update
+
+ nsupdate -v -k /var/secrets/dyndns/`< /var/secrets/dyndns/basename`.private <<.
+ update delete $RR. A
+ update add $RR. 300 A $IP4
+ update delete $RR. TXT
+ update add $RR. 300 TXT "v=spf1 include:_spf.mou.fo ~all"
+ send
+ .
+ '';
+ };
+
+ systemd.timers.dyndns = {
+ wantedBy = [ "multi-user.target" ];
+ timerConfig = {
+ OnStartupSec = "10";
+ OnUnitActiveSec = "1min";
+ };
+ };
+}
diff --git a/hostnix/elmo/email.nix b/hostnix/elmo/email.nix
new file mode 100644
index 0000000..71f85c9
--- /dev/null
+++ b/hostnix/elmo/email.nix
@@ -0,0 +1,101 @@
+{ config, pkgs, ... }:
+
+{
+ imports = [ ./dyndns.nix ];
+
+ systemd.tmpfiles.rules = [
+ "d /var/lib/postfix/tls 0770 root root"
+ ];
+
+ security.acme.certs."${config.networking.fqdn}".postRun = ''
+ rm -rf /var/lib/postfix/tls/new
+ mkdir /var/lib/postfix/tls/new
+ cp -a fullchain.pem key.pem /var/lib/postfix/tls/new/
+ systemctl start postfix-tls-rotate
+ '';
+
+ systemd.services.postfix-tls-rotate = {
+ requires = [ "network-online.target" ];
+ after = [ "network-online.target" ];
+ unitConfig = {
+ OnFailure = "status-email@%n.service";
+ };
+ serviceConfig = {
+ Type = "oneshot";
+ # Not really necessary indirection but interesting to try out. Note we
+ # must run as root (not DynamicUser) to run systemctl.
+ LoadCredential = [ "dyndns:/var/secrets/dyndns/" ];
+ };
+ environment = {
+ "DYNDNS" = "%d/dyndns";
+ };
+ script = ''
+ cd /var/lib/postfix/tls
+
+ publish() {
+ fqdn=${config.networking.fqdn}
+ tlsfps_fqdn=_tlsfps.''${fqdn%%.*}.dynamic.''${fqdn#*.}
+
+ ${pkgs.dnsutils}/bin/nsupdate -v -k ''${DYNDNS}_$(< ''${DYNDNS}_basename).private <<.
+ update delete $tlsfps_fqdn. TXT
+ $(
+ for cert in */fullchain.pem; do
+ echo -n "update add $tlsfps_fqdn. 300 TXT "
+ ${pkgs.openssl}/bin/openssl x509 -noout -fingerprint -sha256 -in "$cert" | cut -d= -f2
+ done
+ )
+ send
+ .
+ }
+
+ # If a certificate is next, we must have been invoked by our timer;
+ # rotate it to live.
+ if [[ -d next ]]; then
+ rm -rf prev
+ mv live prev
+ mv next live
+ systemctl reload postfix
+ # If a certificate is new then publish it.
+ elif [[ -d new ]]; then
+ publish
+ # We'll run again in at least an hour, after the postfix master picks up
+ # the new TLS fingerprints. But if this is the first run (there are no
+ # live certificates), rotate immediately.
+ if [[ -d live ]]; then
+ mv new next
+ else
+ mv new live
+ systemctl reload postfix
+ fi
+ fi
+ '';
+ };
+
+ systemd.timers.postfix-tls-rotate = {
+ wantedBy = [ "multi-user.target" ];
+ timerConfig = {
+ OnBootSec = "2h";
+ OnUnitInactiveSec = "2h";
+ };
+ };
+
+ services.postfix = {
+ enable = true;
+ extraAliases = ''
+ root: joe
+ joe: joe@mou.fo
+ '';
+ settings.main = {
+ myhostname = config.networking.fqdn;
+ relayhost = [ "smtp.mou.fo:587" ];
+ smtp_tls_chain_files = [
+ "/var/lib/postfix/tls/live/key.pem"
+ "/var/lib/postfix/tls/live/fullchain.pem"
+ ];
+ smtp_tls_security_level = "encrypt";
+ smtp_tls_session_cache_database = "btree:\${data_directory}/smtp_scache";
+ message_size_limit = 51200000;
+ default_destination_rate_delay = "1s";
+ };
+ };
+}
diff --git a/hostnix/elmo/flake.lock b/hostnix/elmo/flake.lock
new file mode 100644
index 0000000..80719b1
--- /dev/null
+++ b/hostnix/elmo/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1787414105,
+ "narHash": "sha256-WncT27+3BOkgTaJZLnCsf3LcYf9RXMuR9ONSN4rzQ7s=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "a9e6d84f9c2f9012f5fe7d964a7851352300e61a",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-26.05",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/hostnix/elmo/flake.nix b/hostnix/elmo/flake.nix
new file mode 100644
index 0000000..0a3ccc1
--- /dev/null
+++ b/hostnix/elmo/flake.nix
@@ -0,0 +1,12 @@
+{
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
+ };
+
+ outputs = { self, nixpkgs }: {
+ nixosConfigurations.elmo = nixpkgs.lib.nixosSystem {
+ system = "x86_64-linux";
+ modules = [ ./configuration.nix ];
+ };
+ };
+}
diff --git a/hostnix/elmo/garage.nix b/hostnix/elmo/garage.nix
new file mode 100644
index 0000000..7514904
--- /dev/null
+++ b/hostnix/elmo/garage.nix
@@ -0,0 +1,29 @@
+{ pkgs, ... }:
+
+{
+ systemd.tmpfiles.rules = [
+ "d /opt 775 root root"
+ "d /opt/garage 770 joe nginx"
+ ];
+
+ systemd.services.garage = {
+ wantedBy = [ "multi-user.target" ];
+ unitConfig = {
+ AssertPathExists = "/opt/garage/serve.py";
+ };
+ serviceConfig = {
+ WorkingDirectory = "/opt/garage";
+ UMask = "002";
+ User = "joe";
+ Group = "nginx";
+ };
+ path = [ (pkgs.python3.withPackages (ps: [ ps.aiohttp ])) ];
+ script = "exec python3 serve.py ./sock";
+ };
+
+ services.nginx.virtualHosts."ga.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/".proxyPass = "http://unix:/opt/garage/sock";
+ };
+}
diff --git a/hostnix/elmo/git.nix b/hostnix/elmo/git.nix
new file mode 100644
index 0000000..24340be
--- /dev/null
+++ b/hostnix/elmo/git.nix
@@ -0,0 +1,38 @@
+{ pkgs, ... }:
+
+{
+ users.users.git = {
+ isSystemUser = true;
+ group = "git";
+ home = "/srv/git";
+ createHome = true;
+ homeMode = "755"; # allow nginx (and world) to read
+ shell = "${pkgs.git}/bin/git-shell";
+ openssh.authorizedKeys.keys = [
+ "restrict ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDHcqQDnH0LcUWFV+cd9aABfM55+72UAn1ZY5x5bHwPL4IEgT1nagbi1X3FwCJkh2rEcDsHl4FflcNfh+IyU2VyuQfRIXl7CKP040ovXmPVzgU8JiEDrrRebpZ7aHCKk+4Q6cJ8dzAI1sRogVyYrV0hLB4yeMnVexkIcsQMt5pXQ1zodlzHfW1W7OWQDbKjIyh4pv1KJ/IIjiBIt7l+OCqi7wZK4ubmxyMhmZx1xpRC5mz2/29Mpt+1VgCmxC7Hv1SG7yu+U0s4eJ3kbzFKNcZ/bCYqFxK48dT66qhS3qZFg09omumG8IbEUcx+WqM1V7WhiJYig9x5ovmMToQXTJ5uxLtcovnxO/53IgwV7INvmr8+NhD+LlkQfSjjUAW09bhVj9sKIW2bbrIxlOT8z6CQY9zGXNXi8u1oxPt3Q7d3rMxoWhWnr2l3Ok7OIkWooOK3e0NdhgIAxZKlYJAmIYaX391sGTyiuwUy96mg5JcYsLYMfkEg/Sv+2vQp8kkvB0E= joe@sparky"
+ "restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIvvJXGg1HVDU2z2osjq5FEAcwte8ZybuYj1wpTtwr1m joe@doughboy"
+ "restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHbrW/EovRZGOjOS1sGx2jgNpvtfevFnKApwhYdB9gZl joe@mojo.local"
+ ];
+ };
+
+ users.groups.git = { };
+
+ services.cgit."git.mou.fo" = {
+ enable = true;
+ scanPath = "/srv/git";
+ gitHttpBackend.checkExportOkFiles = false;
+ settings = {
+ section-from-path = -1;
+ clone-url = "git@git.mou.fo:$CGIT_REPO_URL";
+ about-filter = "${pkgs.cgit}/lib/cgit/filters/about-formatting.sh";
+ source-filter = "${pkgs.cgit}/lib/cgit/filters/syntax-highlighting.py";
+ };
+ };
+
+ services.nginx.virtualHosts."git.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ };
+
+ services.oauth2-proxy.nginx.virtualHosts."git.mou.fo" = { };
+}
diff --git a/hostnix/elmo/hardware-configuration.nix b/hostnix/elmo/hardware-configuration.nix
new file mode 100644
index 0000000..8dc6c69
--- /dev/null
+++ b/hostnix/elmo/hardware-configuration.nix
@@ -0,0 +1,74 @@
+# Do not modify this file! It was generated by ‘nixos-generate-config’
+# and may be overwritten by future invocations. Please make changes
+# to /etc/nixos/configuration.nix instead.
+{ config, lib, pkgs, modulesPath, ... }:
+
+{
+ imports =
+ [ (modulesPath + "/installer/scan/not-detected.nix")
+ ];
+
+ boot.initrd.availableKernelModules = [ "xhci_pci" "ahci" "nvme" "usbhid" "usb_storage" "sd_mod" "sdhci_pci" ];
+ boot.initrd.kernelModules = [ ];
+ boot.kernelModules = [ "kvm-intel" "wl" ];
+ boot.loader.grub.configurationLimit = 10;
+
+ fileSystems."/" =
+ { device = "/dev/disk/by-uuid/d0564e9f-ae39-46e6-a380-9b614da0ec29";
+ fsType = "btrfs";
+ options = [ "subvol=@" ];
+ };
+
+ fileSystems."/nix" =
+ { device = "/dev/disk/by-uuid/d0564e9f-ae39-46e6-a380-9b614da0ec29";
+ fsType = "btrfs";
+ options = [ "subvol=@nix" ];
+ };
+
+ fileSystems."/home" =
+ { device = "/dev/disk/by-uuid/d0564e9f-ae39-46e6-a380-9b614da0ec29";
+ fsType = "btrfs";
+ options = [ "subvol=@home" ];
+ };
+
+ fileSystems."/srv" =
+ { device = "/dev/disk/by-uuid/288b0110-1816-4cc7-8cd9-9c019ebd0036";
+ fsType = "btrfs";
+ options = [ "subvol=@srv" "compress=zstd" ];
+ };
+
+ fileSystems."/srv/restic" =
+ { device = "/dev/disk/by-uuid/288b0110-1816-4cc7-8cd9-9c019ebd0036";
+ fsType = "btrfs";
+ options = [ "subvol=@srv-restic" "compress=zstd" ];
+ };
+
+ fileSystems."/var" =
+ { device = "/dev/disk/by-uuid/288b0110-1816-4cc7-8cd9-9c019ebd0036";
+ fsType = "btrfs";
+ options = [ "subvol=@var" "compress=zstd" ];
+ };
+
+ fileSystems."/var/log/journal" =
+ { device = "/dev/disk/by-uuid/d0564e9f-ae39-46e6-a380-9b614da0ec29";
+ fsType = "btrfs";
+ options = [ "subvol=@var-log-journal" ];
+ };
+
+ fileSystems."/boot" =
+ { device = "/dev/disk/by-uuid/C663-3CFA";
+ fsType = "vfat";
+ };
+
+ swapDevices = [ ];
+
+ # Enables DHCP on each ethernet and wireless interface. In case of scripted networking
+ # (the default) this is the recommended approach. When using systemd-networkd it's
+ # still possible to use this option, but it's recommended to use it in conjunction
+ # with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
+ networking.useDHCP = lib.mkDefault true;
+ # networking.interfaces.enp3s0f0.useDHCP = lib.mkDefault true;
+
+ nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
+ hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
+}
diff --git a/hostnix/elmo/home-assistant.nix b/hostnix/elmo/home-assistant.nix
new file mode 100644
index 0000000..5648a0f
--- /dev/null
+++ b/hostnix/elmo/home-assistant.nix
@@ -0,0 +1,473 @@
+{ lib, pkgs, ... }:
+
+let
+
+ ha = {
+ trigger = {
+ at = time: {
+ platform = "time";
+ at = time;
+ };
+ };
+
+ action = {
+ on = entity: {
+ service = "${lib.head (lib.strings.splitString "." entity)}.turn_on";
+ target.entity_id = entity;
+ };
+
+ off = entity: {
+ service = "${lib.head (lib.strings.splitString "." entity)}.turn_off";
+ target.entity_id = entity;
+ };
+
+ toggle = entity: {
+ service = "${lib.head (lib.strings.splitString "." entity)}.toggle";
+ target.entity_id = entity;
+ };
+ };
+
+ button = entity_id: action: {
+ inherit action;
+ id = "button_${entity_id}";
+ alias = "Button ${entity_id}";
+ trigger = {
+ inherit entity_id;
+ platform = "state";
+ to = "on";
+ };
+ };
+ };
+
+in
+{
+ services.postgresql = {
+ ensureDatabases = [ "hass" ];
+ ensureUsers = [
+ {
+ name = "hass";
+ ensureDBOwnership = true;
+ }
+ ];
+ };
+
+ services.home-assistant = {
+ enable = true;
+ extraPackages =
+ ps: with ps; [
+ aiohomekit
+ psycopg2
+ ];
+ extraComponents = [
+ "apple_tv"
+ "esphome"
+ "met"
+ "roku"
+ "spotify"
+ "vesync"
+ ];
+ customComponents = with pkgs.home-assistant-custom-components; [
+ adaptive_lighting
+ auth_oidc
+ tuya_local
+ ];
+
+ config = {
+ default_config = { };
+ http = {
+ server_host = "::1";
+ trusted_proxies = [ "::1" ];
+ use_x_forwarded_for = true;
+ };
+ recorder.db_url = "postgresql://@/hass";
+
+ auth_oidc = {
+ client_id = "9332ad56-1917-4f12-a0ef-f6ff69994cf4";
+ discovery_url = "https://pi.mou.fo/.well-known/openid-configuration";
+ };
+ # "Smart" configured to channel 25 (some overlap with Wi-Fi channel 11).
+ zha = { };
+
+ adaptive_lighting = rec {
+ lights = [
+ # Unfortunately the grow light cannot have its own schedule.
+ "light.grow_light"
+ "light.panel_light"
+ ];
+ # Parameters modeled at https://basnijholt.github.io/adaptive-lighting/
+ min_color_temp = 2700; # lower bound of panel light
+ max_color_temp = 4300;
+ sunrise_offset = 60 * 60;
+ brightness_mode = "linear";
+ brightness_mode_time_dark = sunrise_offset;
+ brightness_mode_time_light = 3 * 60 * 60;
+ };
+
+ input_boolean = {
+ rain_today = {
+ name = "Rain today";
+ icon = "mdi:weather-rainy";
+ };
+ };
+
+ template = [
+ {
+ switch = [
+ {
+ unique_id = "switch_midea_cool";
+ name = "midea_cool";
+ state = "{{ is_state('climate.air_conditioner_1', 'cool') and state_attr('climate.air_conditioner_1', 'temperature')|float < 72 }}";
+ turn_on = [
+ {
+ service = "climate.set_temperature";
+ target.entity_id = "climate.air_conditioner_1";
+ data = {
+ hvac_mode = "cool";
+ temperature = 70;
+ };
+ }
+ ];
+ turn_off = [ (ha.action.off "climate.air_conditioner_1") ];
+ }
+ ];
+ }
+ ];
+
+ climate = [
+ {
+ unique_id = "climate_bedroom_heat";
+ name = "Bedroom Heat";
+ platform = "generic_thermostat";
+ heater = "switch.thermostat_heat";
+ target_sensor = "sensor.bedroom_temperature";
+ min_cycle_duration.minutes = 2;
+ # Remember HVAC mode and periodically explicitly sync heater.
+ # initial_hvac_mode = "off"
+ keep_alive.minutes = 5;
+ # Note: winter schedule has been removed.
+ # (setTemperatureAt "06:00" 71)
+ # (setTemperatureAt "12:00" 70)
+ # (setTemperatureAt "19:00" 71)
+ # (setTemperatureAt "22:00" 69)
+ }
+ {
+ unique_id = "climate_bedroom_cool";
+ name = "Bedroom Cool";
+ platform = "generic_thermostat";
+ ac_mode = true;
+ heater = "switch.midea_cool";
+ target_sensor = "sensor.bedroom_temperature";
+ min_cycle_duration.minutes = 2;
+ }
+ ];
+
+ automation = [
+ {
+ id = "depart";
+ alias = "Depart";
+ trigger = {
+ platform = "zone";
+ entity_id = "person.joe";
+ zone = "zone.home";
+ event = "leave";
+ };
+ action = [
+ (ha.action.off "light.panel_light")
+ (ha.action.off "climate.bedroom_cool")
+ (ha.action.off "climate.air_conditioner_1")
+ ];
+ }
+
+ {
+ id = "arrive_sunrise";
+ alias = "Arrive/Sunrise";
+ trigger = [
+ {
+ platform = "sun";
+ event = "sunrise";
+ }
+ {
+ platform = "zone";
+ entity_id = "person.joe";
+ zone = "zone.home";
+ event = "enter";
+ }
+ ];
+ condition = [
+ {
+ condition = "zone";
+ entity_id = "person.joe";
+ zone = "zone.home";
+ }
+ ];
+ action = [
+ (ha.action.on "light.panel_light")
+ ];
+ }
+
+ {
+ id = "summer_thermostat";
+ alias = "Summer thermostat";
+ trigger = [
+ (ha.trigger.at "08:00")
+ (ha.trigger.at "22:00")
+ {
+ platform = "zone";
+ entity_id = "person.joe";
+ zone = "zone.home";
+ event = "enter";
+ }
+ # TODO toggle a helper
+ # {
+ # platform = "event";
+ # event_type = "ios.action_fired";
+ # event_data.actionName = "Homebound";
+ # }
+ # TODO maybe trigger if home and over 78?
+ ];
+ condition = [
+ {
+ condition = "template";
+ value_template = "is_state('person.joe', 'home') || trigger.platform == 'event'";
+ }
+ ];
+ action = [
+ # Turn off bedroom_cool if we're controlling air_conditioner_1.
+ {
+ "if" = [
+ {
+ condition = "time";
+ after = "08:00";
+ before = "22:00";
+ }
+ {
+ condition = "template";
+ value_template = "{{ not is_state('climate.bedroom_cool', 'off') }}";
+ }
+ ];
+ "then" = [
+ (ha.action.off "climate.bedroom_cool")
+ { delay = 5; } # allow effect on air_conditioner_1 to settle
+ ];
+ }
+
+ {
+ service = "climate.set_temperature";
+ target.entity_id = ''
+ {% if 8 < now().hour < 22 %}
+ climate.air_conditioner_1
+ {% else %}
+ climate.bedroom_cool
+ {% endif %}
+ '';
+ # TODO Can Homebound burst to 72 for an hour?
+ data_template = {
+ hvac_mode = "auto";
+ # hvac_mode = ''
+ # {% if 8 < now().hour < 22 %}
+ # auto
+ # {% else %}
+ # cool
+ # {% endif %}
+ # '';
+ temperature = ''
+ {% if 8 < now().hour < 22 %}
+ 75
+ {% else %}
+ 73
+ {% endif %}
+ '';
+ };
+ }
+ ];
+ }
+
+ # TODO remove?
+ # {
+ # alias = "Pre-wake";
+ # trigger = [ (ha.trigger.at "07:00") ];
+ # action = [
+ # (ha.action.off "climate.bedroom_cool")
+ # ];
+ # }
+ {
+ id = "grow_light_morning";
+ alias = "Grow light morning";
+ # TODO make configurable (snooze)
+ trigger = [ (ha.trigger.at "09:00") ];
+ condition = [
+ {
+ condition = "zone";
+ entity_id = "person.joe";
+ zone = "zone.home";
+ }
+ ];
+ action = [
+ (ha.action.on "light.grow_light")
+ (ha.action.on "switch.wakko")
+ # {
+ # service = "fan.set_percentage";
+ # target.entity_id = "fan.core_200s";
+ # data.percentage = 100;
+ # }
+ ];
+ }
+ {
+ id = "grow_light_night";
+ alias = "Grow light night";
+ trigger = [ (ha.trigger.at "20:00") ];
+ action = [ (ha.action.off "switch.wakko") ];
+ }
+
+ (ha.button "binary_sensor.bedroom_button_1" [
+ (ha.action.toggle "light.panel_light")
+ {
+ service = "fan.set_percentage";
+ target.entity_id = "fan.core_200s";
+ data.percentage = 66;
+ }
+ ])
+ (ha.button "binary_sensor.bedroom_button_2" (ha.action.toggle "switch.wakko"))
+
+ {
+ id = "fridge_door_ajar";
+ alias = "Fridge door ajar";
+ triggers = [
+ {
+ trigger = "state";
+ entity_id = [ "binary_sensor.fridge_door_sensor" ];
+ to = [ "on" ];
+ for.minutes = 2;
+ }
+ ];
+ actions = [
+ {
+ action = "notify.notify";
+ data = {
+ message = "Check fridge door";
+ data = {
+ tag = "fridge-door";
+ push.interruption_level = "critical";
+ };
+ };
+ }
+ (ha.action.toggle "light.panel_light")
+ { delay.milliseconds = 500; }
+ (ha.action.toggle "light.panel_light")
+ ];
+ }
+ {
+ id = "fridge_door_closed";
+ alias = "Fridge door closed";
+ triggers = [
+ {
+ trigger = "state";
+ entity_id = [ "binary_sensor.fridge_door_sensor" ];
+ to = [ "off" ];
+ }
+ ];
+ actions = [
+ {
+ action = "notify.notify";
+ data = {
+ message = "clear_notification";
+ data.tag = "fridge-door";
+ };
+ }
+ ];
+ }
+
+ {
+ id = "check_rain_forecast";
+ alias = "Check rain forecast";
+ trigger = [ (ha.trigger.at "05:00") ];
+ action = [
+ {
+ action = "weather.get_forecasts";
+ target.entity_id = "weather.forecast_home";
+ data.type = "daily";
+ response_variable = "forecast";
+ }
+ {
+ "if" = [
+ {
+ condition = "template";
+ value_template = "{{ forecast['weather.forecast_home'].forecast[0].precipitation > 0 }}";
+ }
+ ];
+ "then" = [
+ {
+ action = "input_boolean.turn_on";
+ target.entity_id = "input_boolean.rain_today";
+ }
+ {
+ action = "notify.notify";
+ data.message = "Rain expected today ({{ forecast['weather.forecast_home'].forecast[0].precipitation }} inches)";
+ }
+ ];
+ "else" = [
+ {
+ action = "input_boolean.turn_off";
+ target.entity_id = "input_boolean.rain_today";
+ }
+ ];
+ }
+ ];
+ }
+
+ # The panel light can become unresponsive and need to be reboot.
+ {
+ id = "panel_light_reinitialize";
+ alias = "Panel light reinitialize";
+ trigger = [
+ {
+ platform = "state";
+ entity_id = [ "light.panel_light" ];
+ to = "unavailable";
+ for = "00:05:00";
+ }
+ ];
+ action = [
+ {
+ repeat = {
+ while = [
+ {
+ condition = "state";
+ entity_id = "light.panel_light";
+ state = "unavailable";
+ }
+ ];
+ sequence = [
+ (ha.action.off "switch.pinky")
+ { delay = 10; }
+ (ha.action.on "switch.pinky")
+ { delay = 30; }
+ ];
+ };
+ }
+ ];
+ }
+ ];
+ };
+ };
+
+ services.nginx.virtualHosts."ha.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/" = {
+ proxyPass = "http://[::1]:8123";
+ proxyWebsockets = true;
+ extraConfig = ''
+ # This is frequently used in examples but without clear explanation. It
+ # might help with WebSockets.
+ proxy_buffering off;
+ '';
+ };
+ # Disable service worker caching that works improperly with reverse proxy.
+ # https://github.com/home-assistant/frontend/issues/14836
+ # https://community.home-assistant.io/t/disabling-service-worker-reverse-proxy-auth-causes-issues/167082
+ locations."/service_worker.js" = {
+ return = ''410 "Service worker disabled: https://github.com/home-assistant/frontend/issues/14836"'';
+ };
+ };
+}
diff --git a/hostnix/elmo/media.nix b/hostnix/elmo/media.nix
new file mode 100644
index 0000000..7a60030
--- /dev/null
+++ b/hostnix/elmo/media.nix
@@ -0,0 +1,141 @@
+{ pkgs, ... }:
+
+# https://nixos.wiki/wiki/Jellyfin
+{
+ hardware.graphics = {
+ enable = true;
+ # Haswell seems too old to be supported by intel-media-driver (iHD). While
+ # QSV is apparently implemented for intel-vaapi-driver (i965) by
+ # intel-media-sdk, Jellyfin seems to only support QSV on iHD.
+ extraPackages = [
+ # Apparently adds some hardware acceleration.
+ (pkgs.intel-vaapi-driver.override { enableHybridCodec = true; })
+ ];
+ };
+
+ # Manual configuration:
+ # - Create joe and guest users
+ # - Add Media Library
+ # - /srv/media/Movies
+ # - /srv/media/Shows
+ # - /srv/media/incoming/YouTube (Shows)
+ # - Administration: Dashboard > Playback: Transcoding
+ # TODO try QSV
+ # - Hardware acceleration: VAAPI
+ services.jellyfin.enable = true;
+
+ services.nginx.virtualHosts."jf.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/".proxyPass = "http://127.0.0.1:8096";
+ };
+
+ systemd.tmpfiles.rules = [
+ "d /srv/media/incoming/YouTube 2775 ytdl-sub media -"
+ ];
+
+ services.ytdl-sub.instances.main = {
+ enable = true;
+ # TODO schedule = null;
+ # The unit runs with ProtectSystem=strict, which leaves the whole
+ # filesystem read-only apart from its own state and runtime directories.
+ # Without this the output tree is unwritable however it is chowned.
+ readWritePaths = [ "/srv/media/incoming/YouTube" ];
+ config = {
+ presets = {
+ "YouTube Channel" = {
+ preset = [
+ "Jellyfin TV Show by Date"
+ "Max 1080p"
+ ];
+ overrides = {
+ tv_show_directory = "/srv/media/incoming/YouTube";
+ date_range_after = "20240101"; # arbitrarily early default
+ };
+ embed_thumbnail = true;
+ subtitles = {
+ embed_subtitles = true;
+ allow_auto_generated_subtitles = true;
+ };
+ chapters = {
+ embed_chapters = true;
+ sponsorblock_categories = [ "all" ];
+ };
+ date_range = {
+ after = "{date_range_after}";
+ before = "today-2days";
+ };
+ ytdl_options = {
+ break_on_existing = true;
+ };
+ };
+ };
+ };
+ subscriptions = {
+ "YouTube Channel" = {
+ "~Moon Channel" = {
+ url = "https://www.youtube.com/@moon-channel";
+ date_range_after = "20241201";
+ };
+ "Pinchflat" = "https://www.youtube.com/playlist?list=PLOqoltSk7NvI";
+ };
+ };
+ };
+
+ systemd.services.ytdl-sub-main.serviceConfig.UMask = "0002";
+
+ # TODO kavita vs komga?
+ services.kavita = {
+ enable = true;
+ tokenKeyFile = "/var/secrets/kavita.key";
+ settings = {
+ Port = 7565;
+ IpAddresses = "::1";
+ };
+ };
+
+ services.komga = {
+ enable = true;
+ # Cannot override listening on all IPv4 interfaces.
+ settings.server.port = 7579;
+ };
+
+ # TODO SSO
+ # systemd.tmpfiles.rules = let
+ # cfg = pkgs.writeText "application.yml" ''
+ # spring:
+ # security:
+ # oauth2:
+ # client:
+ # registration:
+ # keycloak:
+ # provider: keycloak # this must match the provider below
+ # client-id: your-client-id
+ # client-secret: c830e452-a2a9-40a0-93c1-eb84ea688245
+ # client-name: Keycloak
+ # scope: openid,email
+ # authorization-grant-type: authorization_code
+ # # the placeholders in {} will be replaced automatically, you don't need to change this line
+ # redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
+ # provider:
+ # keycloak: # this must match the provider above
+ # user-name-attribute: sub
+ # # either set the issuer-uri, in which case the app will lookup the configuration for you automatically
+ # issuer-uri: http://localhost:8085/auth/realms/komgatest
+ # # or set all of the following
+ # authorization-uri: http://localhost:8085/auth/realms/komgatest/protocol/openid-connect/auth
+ # token-uri: http://localhost:8085/auth/realms/komgatest/protocol/openid-connect/token
+ # jwk-set-uri: http://localhost:8085/auth/realms/komgatest/protocol/openid-connect/certs
+ # user-info-uri: http://localhost:8085/auth/realms/komgatest/protocol/openid-connect/userinfo
+ # '';
+ # in
+ # [
+ # "L+ /var/lib/komga/application.yml - - - - ${cfg}"
+ # ];
+
+ services.nginx.virtualHosts."ka.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/".proxyPass = "http://127.0.0.1:7579";
+ };
+}
diff --git a/hostnix/elmo/oidc.nix b/hostnix/elmo/oidc.nix
new file mode 100644
index 0000000..ebdd19a
--- /dev/null
+++ b/hostnix/elmo/oidc.nix
@@ -0,0 +1,75 @@
+{ lib, pkgs, ... }:
+
+{
+ systemd.tmpfiles.rules = [
+ "d /run/pocket-id 750 pocket-id nginx"
+ ];
+
+ # - Create user joe
+ # - Create OIDC Client: oauth2-proxy
+ # - Callback URLs: https://op.mou.fo/oauth2/callback
+ # - PKCE
+ services.pocket-id = {
+ enable = true;
+ credentials.ENCRYPTION_KEY = "/var/secrets/pocket-id.key";
+ settings = {
+ APP_URL = "https://pi.mou.fo";
+ TRUST_PROXY = true;
+ UNIX_SOCKET = "/run/pocket-id/socket";
+ UNIX_SOCKET_MODE = "0777";
+
+ # https://pocket-id.org/docs/configuration/environment-variables#overriding-the-ui-configuration
+ UI_CONFIG_DISABLED = true;
+ EMAILS_VERIFIED = true; # needed by oauth2-proxy
+ SMTP_HOST = "localhost";
+ SMTP_PORT = 25;
+ SMTP_FROM = "noreply@pi.mou.fo";
+ EMAIL_LOGIN_NOTIFICATION_ENABLED = true;
+ EMAIL_API_KEY_EXPIRATION_ENABLED = true;
+ EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED = true;
+ };
+ };
+
+ services.nginx.virtualHosts."pi.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/".proxyPass = "http://unix:/run/pocket-id/socket";
+ };
+
+ # The oauth2-proxy module has a magic nginx.virtualHosts option that rewrites
+ # nginx configs. It can be heavy handed, but we use it for brevity. In
+ # particular, it configures Traefik-like ForwardAuth authentication with
+ # auth_request. Note if this resource is missing for whatever reason, the
+ # module magic will fail open (auth_request unset).
+ services.oauth2-proxy = {
+ enable = true;
+ cookie.domain = "mou.fo";
+ nginx.domain = "op.mou.fo";
+ setXauthrequest = true; # let oauth2-proxy nginx module pass X-User/X-Email
+ reverseProxy = true;
+ trustedProxyIP = [ "127.0.0.1" ];
+ provider = "oidc";
+ clientID = "39edd929-8983-4cb6-b1cd-dc08e2e3358f";
+ oidcIssuerUrl = "https://pi.mou.fo";
+ # Sets OAUTH2_PROXY_COOKIE_SECRET and OAUTH2_PROXY_CLIENT_SECRET.
+ keyFile = "/var/secrets/oauth2-proxy.env";
+ # Ignore e-mail address.
+ email.domains = [ "*" ];
+ extraConfig = {
+ code-challenge-method = "S256";
+ whitelist-domain = ".mou.fo"; # allowed redirects after authentication
+ insecure-oidc-allow-unverified-email = true;
+ };
+ };
+
+ systemd.services.oauth2-proxy.after = [ "pocket-id.service" ];
+
+ # It's somewhat overkill to assign oauth2-proxy its own domain. We can't use
+ # Kanidm's though, because it uses the /oauth2 path which the oauth2-proxy
+ # nginx module is hardcoded for (proxyPrefix is ignored); this module magic is
+ # also why we do not need to explicitly specify proxyPass.
+ services.nginx.virtualHosts."op.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ };
+}
diff --git a/hostnix/elmo/pinchflat.nix b/hostnix/elmo/pinchflat.nix
new file mode 100644
index 0000000..6ec4d0f
--- /dev/null
+++ b/hostnix/elmo/pinchflat.nix
@@ -0,0 +1,87 @@
+{ ... }:
+
+# Self-hosted YouTube downloader: https://github.com/kieraneglin/pinchflat
+#
+# Coexists with ytdl-sub (media.nix) rather than replacing it. Each gets its
+# own tree under /srv/media/incoming so the two never manage the same files.
+
+# Manual configuration:
+# - Jellyfin: add Media Library /srv/media/incoming/Pinchflat (Shows)
+# - Copy feed URLs from https://pf.elmo.mou.fo, never from localhost: the XML
+# is generated with whatever host and X-Forwarded-Proto the request carried.
+
+let
+ mediaDir = "/srv/media/incoming/Pinchflat";
+ upstream = "http://127.0.0.1:8945";
+
+ # Podcast clients can't log in, so the feed endpoints have to sit outside
+ # oauth2-proxy. These are exactly the routes pinchflat itself serves
+ # unauthenticated (the maybe_basic_auth scope in router.ex); each is
+ # addressed by an unguessable UUID rather than a sequential id, which is the
+ # only thing keeping them private.
+ #
+ # The trailing extension is optional because the feed builder emits
+ # feed.xml, stream.mp4, episode_image.jpg and so on, while endpoint.ex
+ # strips the extension again before routing.
+ feedRoutes = "~* ^/(sources/[^/]+/feed(_image)?|media/[^/]+/(stream|episode_image))(\\.[a-zA-Z0-9]+)?$";
+
+ # Lists every source's feed for bulk import. Unlike the routes above this one
+ # is not public: pinchflat 401s unless ?route_token= matches.
+ opmlRoute = "~* ^/sources/opml(\\.[a-zA-Z0-9]+)?$";
+in
+{
+ # Setgid so downloads land in the media group, as Jellyfin expects.
+ systemd.tmpfiles.rules = [
+ "d ${mediaDir} 2775 pinchflat media -"
+ ];
+
+ services.pinchflat = {
+ enable = true;
+ inherit mediaDir;
+ # Uses a weak built-in SECRET_KEY_BASE instead of a hand-managed
+ # /var/secrets file. Acceptable here: the port is not opened in the
+ # firewall and the vhost is behind oauth2-proxy.
+ selfhosted = true;
+ # A no-op while BASIC_AUTH_* is unset, since authentication is nginx's job
+ # (see feedRoutes). Set so the feeds keep working if basic auth is ever
+ # turned on.
+ extraConfig.EXPOSE_FEED_ENDPOINTS = "yes";
+ };
+
+ systemd.services.pinchflat.serviceConfig.UMask = "0002";
+
+ users.users.pinchflat = {
+ extraGroups = [ "media" ];
+ };
+
+ # TODO allocate domain?
+ services.nginx.virtualHosts."pf.elmo.mou.fo" = {
+ useACMEHost = "elmo.mou.fo";
+ forceSSL = true;
+ locations = {
+ "/" = {
+ # Phoenix listens on all interfaces; only nginx should reach it.
+ proxyPass = upstream;
+ # LiveView drives the whole UI over a websocket.
+ proxyWebsockets = true;
+ };
+ ${feedRoutes} = {
+ proxyPass = upstream;
+ extraConfig = ''
+ auth_request off;
+ # Whole episodes stream through here, and the app serves its own
+ # Range requests; don't spool them into nginx temp files first.
+ proxy_buffering off;
+ '';
+ };
+ ${opmlRoute} = {
+ proxyPass = upstream;
+ extraConfig = ''
+ auth_request off;
+ '';
+ };
+ };
+ };
+
+ services.oauth2-proxy.nginx.virtualHosts."pf.elmo.mou.fo" = { };
+}
diff --git a/hostnix/elmo/rss.nix b/hostnix/elmo/rss.nix
new file mode 100644
index 0000000..de611a9
--- /dev/null
+++ b/hostnix/elmo/rss.nix
@@ -0,0 +1,34 @@
+{ ... }:
+
+{
+ services.miniflux = {
+ enable = true;
+ config = {
+ BASE_URL = "https://mf.mou.fo";
+ CREATE_ADMIN = 0;
+ };
+ };
+
+ # https://github.com/miniflux/website/blob/main/content/docs/howto.md#systemd-socket-activation
+ systemd.sockets.miniflux = {
+ wantedBy = [ "sockets.target" ];
+ socketConfig = {
+ ListenStream = "/run/miniflux.sock";
+ SocketGroup = "nginx";
+ SocketMode = "0660";
+ NoDelay = true;
+ };
+ };
+
+ systemd.services.miniflux.serviceConfig.NonBlocking = true;
+
+ services.nginx.virtualHosts."mf.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/" = {
+ proxyPass = "http://unix:/run/miniflux.sock";
+ };
+ };
+
+ # TODO services.oauth2-proxy.nginx.virtualHosts = { "mf.mou.fo" = {}; };
+}
diff --git a/hostnix/elmo/syncthing.nix b/hostnix/elmo/syncthing.nix
new file mode 100644
index 0000000..2bb3425
--- /dev/null
+++ b/hostnix/elmo/syncthing.nix
@@ -0,0 +1,193 @@
+{ lib, ... }:
+
+# TODO iCloud bridge
+
+let
+ staggeredVersioning = {
+ type = "staggered";
+ params = {
+ cleanInterval = "3600";
+ maxAge = "31536000";
+ };
+ };
+in
+{
+ # Syncthing generally ignores umask and makes it hard to set permission bits
+ # by default, so use ACLs to grant access. Also nginx is particularly
+ # difficult to grant granular access with classic permissions.
+ systemd.tmpfiles.rules =
+ let
+ acls = builtins.concatStringsSep "," [
+ "user:joe:rwX"
+ "default:user:joe:rwX"
+ "user:nginx:rX"
+ "default:user:nginx:rX"
+ ];
+ in
+ [
+ "d /srv/syncthing 0700 syncthing syncthing"
+ "A /srv/syncthing - - - - ${acls}"
+ ];
+
+ # Disable Syncthing service home creation which clobbers above permissions.
+ users.users.syncthing.createHome = lib.mkForce false;
+
+ services.syncthing = {
+ enable = true;
+ openDefaultPorts = true;
+ # Syncthing supports named sockets but the NixOS module assumes network.
+ guiAddress = "[::1]:8384";
+ dataDir = "/srv/syncthing";
+ settings = {
+ devices = {
+ "Asus Nexus 7" = {
+ id = "BVZARYC-2D56A6I-V6L2VQF-MU7IVCT-ITJTCGQ-IGMZG2W-RZRCTOT-K4GDHAY";
+ };
+ "DESKTOP-SFVBFBU" = {
+ id = "T547Y5S-HUO5WIC-DM3Z7LS-UG647YR-ZQAMZHU-LIZHDY5-Q5WQLXL-RNH2GAV";
+ autoAcceptFolders = true;
+ };
+ "Joe's iPad" = {
+ id = "Z34UWON-Y3H7CR6-XAMRQ6L-CZ4UQY7-ELOE44T-O6T6OYV-VHJSVTS-TIERJQX";
+ autoAcceptFolders = true;
+ };
+ "Joes-iPhone" = {
+ id = "P25LZDL-ZCHYEB3-FE3W4WW-YMMPWWF-27VY7DR-7Y25WNI-NJN7FXX-5PZZTQ5";
+ autoAcceptFolders = true;
+ };
+ "Joes-Mac-mini.local" = {
+ id = "K532ULN-SMFZDJR-U2NSGTY-HY35MXX-6POK7KI-CETEKLV-RMCGRHL-DVROHAF";
+ autoAcceptFolders = true;
+ };
+ "doughboy" = {
+ id = "BI3SWOB-NHPQBVW-XM46DB6-BQBO2PP-DI2OVAS-WBVX24P-WM7CZ3U-NXMBGAV";
+ autoAcceptFolders = true;
+ };
+ "penguin" = {
+ id = "5BEB6SZ-YAPV3CC-54RZF3V-HQXXP3Y-TTVDWDU-SHHNVCH-IXKZ3O2-6RXG6QL";
+ autoAcceptFolders = true;
+ };
+ "sparky" = {
+ id = "FE43LDI-33WS467-LIGFWCC-5PPSKN6-GYODEWJ-KLQZLN7-H3GYGLG-IJ2JGQW";
+ autoAcceptFolders = true;
+ };
+ "sparky-win" = {
+ id = "UWA5IFV-CKFMLRS-CUMUB7X-LABLQST-QK2ULLM-BUVW6CW-PBT5AQX-AUNPBAK";
+ };
+ "steamdeck" = {
+ id = "SCUAABL-XU6AS5H-IZZE4PN-LY5J4LH-OYZMXTU-2UMTVUL-I7B7QKE-ZBPSAQ5";
+ autoAcceptFolders = true;
+ };
+ };
+ folders = {
+ "Deck/Documents" = {
+ id = "mwxed-yy9gn";
+ path = "~/Deck/Documents";
+ versioning = staggeredVersioning;
+ devices = [ "steamdeck" ];
+ };
+ "Deck/extra" = {
+ id = "jzncl-7nkcq";
+ path = "~/Deck/extra";
+ versioning = staggeredVersioning;
+ devices = [ "steamdeck" ];
+ };
+ "Documents" = {
+ id = "bhemx-9nh3v";
+ path = "~/Documents";
+ versioning = staggeredVersioning;
+ devices = [
+ "doughboy"
+ "sparky"
+ ];
+ };
+ "Downloads" = {
+ id = "kvq6q-axjhu";
+ path = "~/Downloads";
+ versioning = staggeredVersioning;
+ devices = [
+ "doughboy"
+ "sparky"
+ "Joe's iPad"
+ ];
+ };
+ "Game/Epic Games/TheTalosPrinciple/UserData" = {
+ id = "vek7u-iausx";
+ path = "~/Game/Epic Games/TheTalosPrinciple/UserData";
+ versioning = staggeredVersioning;
+ devices = [
+ "DESKTOP-SFVBFBU"
+ "steamdeck"
+ ];
+ };
+ "Game/PCSX2" = {
+ id = "chxsg-hpqgm";
+ path = "~/Game/PCSX2";
+ versioning = staggeredVersioning;
+ devices = [ "steamdeck" ];
+ };
+ "Pictures" = {
+ id = "vfjsd-4fczh";
+ path = "~/Pictures";
+ versioning = staggeredVersioning;
+ devices = [
+ "doughboy"
+ "sparky"
+ ];
+ };
+ "Public" = {
+ id = "f6iys-eunyf";
+ path = "~/Public";
+ versioning = staggeredVersioning;
+ devices = [
+ "doughboy"
+ "sparky"
+ ];
+ };
+ "Sync" = {
+ id = "7thks-5badk";
+ path = "~/Sync";
+ versioning = staggeredVersioning;
+ devices = [
+ "Asus Nexus 7"
+ "DESKTOP-SFVBFBU"
+ "Joe's iPad"
+ "Joes-iPhone"
+ "doughboy"
+ "penguin"
+ "sparky"
+ "sparky-win"
+ ];
+ };
+ "Windows" = {
+ id = "gjcn7-qrsjr";
+ path = "~/Windows";
+ versioning = staggeredVersioning;
+ devices = [
+ "DESKTOP-SFVBFBU"
+ "sparky-win"
+ ];
+ };
+ "iPad" = {
+ id = "qtzmu-fqdrs";
+ path = "~/iPad";
+ devices = [
+ "Joe's iPad"
+ ];
+ };
+ };
+ };
+ };
+
+ services.nginx.virtualHosts."st.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/" = {
+ proxyPass = "http://[::1]:8384";
+ # https://docs.syncthing.net/users/faq.html#why-do-i-get-host-check-error-in-the-gui-api
+ recommendedProxySettings = false;
+ };
+ };
+
+ services.oauth2-proxy.nginx.virtualHosts."st.mou.fo" = { };
+}
diff --git a/hostnix/elmo/system.nix b/hostnix/elmo/system.nix
new file mode 100644
index 0000000..8630abc
--- /dev/null
+++ b/hostnix/elmo/system.nix
@@ -0,0 +1,79 @@
+{ config, pkgs, ... }:
+
+{
+ nix.gc = {
+ automatic = true;
+ dates = "weekly";
+ options = "--delete-older-than 30d";
+ };
+ nix.settings.auto-optimise-store = true;
+
+ boot.loader.systemd-boot.enable = true;
+ boot.loader.systemd-boot.configurationLimit = 10;
+ boot.loader.efi.canTouchEfiVariables = true;
+
+ boot.tmp.useTmpfs = true;
+
+ time.timeZone = "America/New_York";
+
+ networking.hostName = "elmo";
+ networking.domain = "mou.fo";
+
+ # TODO switch to networkd
+ networking.networkmanager.enable = true;
+
+ services.avahi = {
+ enable = true;
+ nssmdns4 = true;
+ publish = {
+ enable = true;
+ addresses = true;
+ };
+ };
+
+ # https://nixos.wiki/wiki/Hardware/Apple#Auto_Restart
+ systemd.services.enable-autorestart = {
+ description = "Boot after power failure (server mode)";
+ wantedBy = [ "multi-user.target" ];
+ serviceConfig = {
+ Type = "oneshot";
+ };
+ # https://superuser.com/a/1051137
+ script = "${pkgs.pciutils}/bin/setpci -s 00:1f.0 0xa4.b=0";
+ };
+
+ systemd.services.boot-email = {
+ description = "Boot email";
+ wantedBy = [ "multi-user.target" ];
+ serviceConfig = {
+ Type = "oneshot";
+ };
+ script = ''
+ echo -e "Subject: ${config.networking.fqdn} restarted\n\n$(date)" | /run/wrappers/bin/sendmail root
+ '';
+ };
+
+ # https://wiki.archlinux.org/index.php/Systemd/Timers#MAILTO
+ systemd.services."status-email@" = {
+ description = "Status email for %i";
+ unitConfig = {
+ # Throttle notifications to twice daily.
+ StartLimitIntervalSec = "12hr";
+ StartLimitBurst = "1";
+ };
+ serviceConfig = {
+ Type = "oneshot";
+ };
+ scriptArgs = "%i";
+ script = ''
+ /run/wrappers/bin/sendmail root <<EOF
+ From: systemd <root>
+ Subject: $1 status
+ Content-Transfer-Encoding: 8bit
+ Content-Type: text/plain; charset=UTF-8
+
+ $(systemctl status --full "$1")
+ EOF
+ '';
+ };
+}
diff --git a/hostnix/elmo/typetype.nix b/hostnix/elmo/typetype.nix
new file mode 100644
index 0000000..e94f5f8
--- /dev/null
+++ b/hostnix/elmo/typetype.nix
@@ -0,0 +1,158 @@
+{ lib, pkgs, ... }:
+
+# Self-hosted TypeType instance: https://github.com/TypeType-Video/TypeType
+#
+# Upstream only ships container images, so this is a translation of their
+# docker-compose.yml rather than a native service. Omitted from the upstream
+# stack: typetype-downloader, garage, garage-config (the download/S3
+# subsystem) and typetype-secrets (replaced by /var/secrets, below).
+
+# TODO downloads: needs typetype-downloader + a Garage bucket bootstrapped by
+# hand (scripts/bootstrap-garage.sh does layout assign / bucket create / key
+# create), plus the typetype_downloader database.
+# TODO SSO
+
+let
+ network = "typetype";
+
+ # The frontend image's nginx resolves these names over Docker's embedded DNS
+ # (resolver 127.0.0.11), so retain the original container names.
+ containers = [
+ "typetype"
+ "typetype-server"
+ "typetype-token"
+ "typetype-postgres"
+ "typetype-dragonfly"
+ ];
+
+ # Pin by version tag and digest.
+ images = {
+ web = "ghcr.io/typetype-video/typetype:1.3.1@sha256:4da200fb96d858cfa3bc2a8cbb98a9682a560f40a055b9c407f3e173a28dcf82";
+ server = "ghcr.io/typetype-video/typetype-server:1.3.1@sha256:f1ad7fd31e5c1cb994601f714df82e8207c3769d759df232e21a3876751a8faf";
+ token = "ghcr.io/typetype-video/typetype-token:1.3.1@sha256:8dfcc6d84cc09c33d18add0ec807093c2182be10857a021a4c61ace9a3f561d5";
+ };
+
+ secrets = "/var/secrets/typetype";
+in
+
+{
+ systemd.tmpfiles.rules = [
+ "d /var/lib/typetype 0750 root root -"
+ # Bind mounted rather than a Docker volume so backup.nix picks it up; 999
+ # is the postgres uid inside the image.
+ "d /var/lib/typetype/postgres 0700 999 999 -"
+ "d ${secrets} 0750 root root -"
+ ];
+
+ systemd.services =
+ lib.genAttrs (map (c: "docker-${c}") containers) (_: {
+ after = [ "docker-network-typetype.service" ];
+ requires = [ "docker-network-typetype.service" ];
+ unitConfig.AssertPathExists = "${secrets}/env";
+ })
+ // {
+ # Initially create network.
+ docker-network-typetype = {
+ wantedBy = [ "multi-user.target" ];
+ after = [ "docker.service" ];
+ requires = [ "docker.service" ];
+ path = [ pkgs.docker ];
+ serviceConfig = {
+ Type = "oneshot";
+ RemainAfterExit = true;
+ };
+ script = ''
+ docker network inspect ${network} >/dev/null 2>&1 ||
+ docker network create ${network}
+ '';
+ };
+ };
+
+ virtualisation.oci-containers.containers = {
+ typetype = {
+ image = images.web;
+ networks = [ network ];
+ dependsOn = [
+ "typetype-server"
+ "typetype-token"
+ ];
+ ports = [ "127.0.0.1:8082:80" ];
+ };
+
+ typetype-server = {
+ image = images.server;
+ networks = [ network ];
+ dependsOn = [
+ "typetype-postgres"
+ "typetype-dragonfly"
+ "typetype-token"
+ ];
+ # Sets DATABASE_PASSWORD.
+ environmentFiles = [ "${secrets}/env" ];
+ environment = {
+ ALLOWED_ORIGINS = "https://tt.elmo.mou.fo";
+ DATABASE_URL = "jdbc:postgresql://typetype-postgres:5432/typetype";
+ DATABASE_USER = "typetype";
+ DRAGONFLY_URL = "redis://typetype-dragonfly:6379";
+ YOUTUBE_REMOTE_LOGIN_ENABLED = "false";
+ YOUTUBE_REMOTE_LOGIN_SERVICE_URL = "http://typetype-token:8081";
+ YOUTUBE_REMOTE_LOGIN_CALLBACK_BASE_URL = "http://typetype-server:8080";
+ YOUTUBE_REMOTE_LOGIN_INTERNAL_TOKEN_FILE = "/run/typetype-secrets/youtube_remote_login_internal_token";
+ YOUTUBE_SESSION_ENCRYPTION_KEY_FILE = "/run/typetype-secrets/youtube_session_encryption_key";
+ };
+ volumes = [ "${secrets}:/run/typetype-secrets:ro" ];
+ };
+
+ typetype-token = {
+ image = images.token;
+ networks = [ network ];
+ environment = {
+ NODE_ENV = "production";
+ YOUTUBE_REMOTE_LOGIN_ENABLED = "false";
+ YOUTUBE_REMOTE_LOGIN_INTERNAL_TOKEN_FILE = "/run/typetype-secrets/youtube_remote_login_internal_token";
+ };
+ volumes = [ "${secrets}:/run/typetype-secrets:ro" ];
+ # --ipc=host is upstream's; it only matters once remote login is enabled
+ # and the service starts driving a headless browser.
+ extraOptions = [
+ "--init"
+ "--ipc=host"
+ ];
+ };
+
+ typetype-postgres = {
+ image = "postgres:17";
+ networks = [ network ];
+ # Sets POSTGRES_PASSWORD.
+ environmentFiles = [ "${secrets}/env" ];
+ environment = {
+ POSTGRES_DB = "typetype";
+ POSTGRES_USER = "typetype";
+ };
+ volumes = [ "/var/lib/typetype/postgres:/var/lib/postgresql/data" ];
+ };
+
+ typetype-dragonfly = {
+ image = "docker.dragonflydb.io/dragonflydb/dragonfly:v1.39.0";
+ networks = [ network ];
+ extraOptions = [
+ "--ulimit"
+ "memlock=-1"
+ ];
+ };
+ };
+
+ # TODO allocate domain
+ services.nginx.virtualHosts."tt.elmo.mou.fo" = {
+ useACMEHost = "elmo.mou.fo";
+ forceSSL = true;
+ locations."/" = {
+ proxyPass = "http://127.0.0.1:8082";
+ proxyWebsockets = true;
+ };
+ # Matches client_max_body_size in the frontend image's nginx.conf.
+ extraConfig = ''
+ client_max_body_size 2g;
+ '';
+ };
+}
diff --git a/hostnix/elmo/usenet.nix b/hostnix/elmo/usenet.nix
new file mode 100644
index 0000000..78901a1
--- /dev/null
+++ b/hostnix/elmo/usenet.nix
@@ -0,0 +1,52 @@
+{ ... }:
+
+let
+ DestDir = "/srv/media/incoming";
+in
+{
+ systemd.tmpfiles.rules = [
+ "d ${DestDir} 0775 nzbget nzbget"
+ ];
+
+ # Several low risk credentials are included.
+ services.nzbget = {
+ enable = true;
+ # Settings are passed as command line flags and not written to the config
+ # file. They will not show up in the web UI.
+ settings = {
+ inherit DestDir;
+ AppendCategoryDir = false;
+ # Also accepts a named pipe path, but wasn't able to set the permissions
+ # correctly. Trying socket activation failed with EADDRINUSE.
+ ControlIP = "::1";
+ ControlPassword = "";
+
+ "Server1.Host" = "secure.news.thecubenet.com";
+ "Server1.Port" = "563";
+ "Server1.Encryption" = "yes";
+ "Server1.Connections" = "20";
+ "Server1.Username" = "spanommers+thecubenet99524";
+ "Server1.Password" = "Wua8Dn57i3";
+
+ "Server2.Host" = "secure.news.thecubenet.com";
+ "Server2.Port" = "563";
+ "Server2.Encryption" = "yes";
+ "Server2.Connections" = "20";
+ "Server2.Username" = "spanommers+thecubenet99525";
+ "Server2.Password" = "YWt4x47g9r";
+ "Server2.Level" = 1;
+
+ "Feed1.Name" = "nzbfinder";
+ "Feed1.URL" = "https://nzbfinder.ws/rss/cart?dl=1&api_token=59f0e1aa3f25da0b76fee712a9ee0a16&del=1";
+ "Feed1.Interval" = "0";
+ };
+ };
+
+ services.nginx.virtualHosts."ng.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/".proxyPass = "http://[::1]:6789";
+ };
+
+ services.oauth2-proxy.nginx.virtualHosts = { "ng.mou.fo" = {}; };
+}
diff --git a/hostnix/elmo/web.nix b/hostnix/elmo/web.nix
new file mode 100644
index 0000000..67a965b
--- /dev/null
+++ b/hostnix/elmo/web.nix
@@ -0,0 +1,34 @@
+{ ... }:
+
+# TODO serve /srv behind authentication
+
+{
+ # Assumes proper permissions set by syncthing.nix
+ systemd.tmpfiles.rules = [
+ "L /home/joe/Public - - - - /srv/syncthing/Public/"
+ ];
+
+ services.nginx = {
+ enable = true;
+ recommendedGzipSettings = true;
+ recommendedOptimisation = true;
+ recommendedProxySettings = true;
+ recommendedTlsSettings = true;
+ virtualHosts."elmo.mou.fo" = {
+ default = true;
+ enableACME = true;
+ forceSSL = true;
+ root = "/var/www";
+ locations = {
+ "/user/".extraConfig = ''
+ charset utf-8;
+ autoindex on;
+ autoindex_exact_size off;
+ autoindex_localtime on;
+ '';
+ };
+ };
+ };
+
+ security.acme.certs."elmo.mou.fo".extraDomainNames = [ "*.elmo.mou.fo" ];
+}
diff --git a/hostnix/elmo/wireguard.nix b/hostnix/elmo/wireguard.nix
new file mode 100644
index 0000000..7d56062
--- /dev/null
+++ b/hostnix/elmo/wireguard.nix
@@ -0,0 +1,32 @@
+{ pkgs, ... }:
+
+{
+ networking.nat = {
+ enable = true;
+ enableIPv6 = true;
+ externalInterface = "enp3s0f0";
+ internalInterfaces = [ "wg0" ];
+ };
+
+ networking.wg-quick.interfaces = {
+ wg0 = {
+ address = [ "172.28.92.1/24" "fd61:754f:ebd3:1c5c::1/64" ];
+ listenPort = 51820;
+ privateKeyFile = "/var/secrets/wg0.key";
+ postUp = ''
+ ${pkgs.iptables}/bin/iptables -t nat -A POSTROUTING -o enp3s0f0 -j MASQUERADE
+ ${pkgs.iptables}/bin/ip6tables -t nat -A POSTROUTING -o enp3s0f0 -j MASQUERADE
+ '';
+ preDown = ''
+ ${pkgs.iptables}/bin/iptables -t nat -D POSTROUTING -o enp3s0f0 -j MASQUERADE
+ ${pkgs.iptables}/bin/ip6tables -t nat -D POSTROUTING -o enp3s0f0 -j MASQUERADE
+ '';
+ peers = [ {
+ publicKey = "ZfJaZgG8e2neWJBWcN3cZsDd740Zq+sW/2pmBqSgbRI=";
+ allowedIPs = [ "172.28.92.2" "fd61:754f:ebd3:1c5c::2" ];
+ } ];
+ };
+ };
+
+ networking.firewall.allowedUDPPorts = [ 51820 ];
+}
diff --git a/hostnix/elmo/yakatak.nix b/hostnix/elmo/yakatak.nix
new file mode 100644
index 0000000..bc11942
--- /dev/null
+++ b/hostnix/elmo/yakatak.nix
@@ -0,0 +1,41 @@
+{ pkgs, ... }:
+
+{
+ systemd.tmpfiles.rules = [
+ "d /opt/yakatak 0755 joe users"
+ ];
+
+ systemd.services.yakatak = {
+ wantedBy = [ "multi-user.target" ];
+ serviceConfig = {
+ Type = "exec";
+ DynamicUser = true;
+ SupplementaryGroups = "nginx";
+ RuntimeDirectory = "yakatak";
+ StateDirectory = "yakatak";
+ WorkingDirectory = "/opt/yakatak";
+ };
+ environment = {
+ NITRO_UNIX_SOCKET = "/run/yakatak/socket";
+ NUXT_DB_PATH = "/var/lib/yakatak/yakatak.db";
+ };
+ script = ''
+ # Hack to make our socket connectable by nginx.
+ (
+ sleep 5
+ chown :nginx /run/yakatak/socket
+ chmod g+w /run/yakatak/socket
+ ) &
+
+ exec ${pkgs.nodejs-slim_24}/bin/node .output/server/index.mjs
+ '';
+ };
+
+ services.nginx.virtualHosts."yakatak.app" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/" = {
+ proxyPass = "http://unix:/run/yakatak/socket";
+ };
+ };
+}
diff --git a/hostnix/mojo/Makefile b/hostnix/mojo/Makefile
new file mode 100644
index 0000000..27bbc67
--- /dev/null
+++ b/hostnix/mojo/Makefile
@@ -0,0 +1,7 @@
+switch:
+ sudo darwin-rebuild switch --flake path:.
+
+update:
+ nix flake update --flake path:.
+
+upgrade: update switch
diff --git a/hostnix/mojo/flake.lock b/hostnix/mojo/flake.lock
new file mode 100644
index 0000000..4a1a0ef
--- /dev/null
+++ b/hostnix/mojo/flake.lock
@@ -0,0 +1,66 @@
+{
+ "nodes": {
+ "nix-darwin": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1783744694,
+ "narHash": "sha256-2cp6N3rrwnGYLTx9l6N+NI+kwrCWxvJUbj5WJhvB29A=",
+ "owner": "nix-darwin",
+ "repo": "nix-darwin",
+ "rev": "c3e90c89649b07d1a96e4b9dd6cd0d6e44b91a74",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-darwin",
+ "ref": "nix-darwin-26.05",
+ "repo": "nix-darwin",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1788966248,
+ "narHash": "sha256-F36C+08KdnP1gQ6bu9Ok+UKg50A5EVSZOeGqg+hjoO0=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "104a7c61006cd22d11c0379663afee90c62273ab",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-26.05-darwin",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs-unstable": {
+ "locked": {
+ "lastModified": 1788922888,
+ "narHash": "sha256-QMX3uerxnW2R5dHcWpIQILHDltOZnon3x3Spq7EzBFI=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "a391f95d4557d685fd8e5dc0d4b1f9271aa2f342",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nix-darwin": "nix-darwin",
+ "nixpkgs": "nixpkgs",
+ "nixpkgs-unstable": "nixpkgs-unstable"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/hostnix/mojo/flake.nix b/hostnix/mojo/flake.nix
new file mode 100644
index 0000000..9bffbfa
--- /dev/null
+++ b/hostnix/mojo/flake.nix
@@ -0,0 +1,53 @@
+{
+ description = "nix-darwin system flake";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-26.05-darwin";
+ nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+ nix-darwin.url = "github:nix-darwin/nix-darwin/nix-darwin-26.05";
+ nix-darwin.inputs.nixpkgs.follows = "nixpkgs";
+ };
+
+ outputs =
+ inputs@{
+ self,
+ nix-darwin,
+ nixpkgs,
+ nixpkgs-unstable,
+ }:
+ let
+ configuration =
+ { pkgs, pkgsUnstable, ... }:
+ {
+ nix.settings.experimental-features = "nix-command flakes";
+
+ # Set Git commit hash for darwin-version.
+ system.configurationRevision = self.rev or self.dirtyRev or null;
+
+ # Used for backwards compatibility, please read the changelog before changing.
+ # $ darwin-rebuild changelog
+ system.stateVersion = 6;
+
+ # The platform the configuration will be used on.
+ nixpkgs.hostPlatform = "aarch64-darwin";
+ };
+
+ in
+ {
+ darwinConfigurations.mojo = nix-darwin.lib.darwinSystem {
+ specialArgs = {
+ pkgsUnstable = import nixpkgs-unstable {
+ system = "aarch64-darwin";
+ config.allowUnfree = true;
+ };
+ };
+ modules = [
+ configuration
+ ./modules/apps.nix
+ ./modules/obsidian.nix
+ ./modules/sunshine.nix
+ ./modules/system.nix
+ ];
+ };
+ };
+}
diff --git a/hostnix/mojo/modules/apps.nix b/hostnix/mojo/modules/apps.nix
new file mode 100644
index 0000000..5c10633
--- /dev/null
+++ b/hostnix/mojo/modules/apps.nix
@@ -0,0 +1,41 @@
+{ pkgs, pkgsUnstable, ... }:
+{
+ environment.systemPackages = [
+ pkgs.direnv
+ pkgs.fd
+ pkgs.fzf
+ pkgs.nix-direnv
+ pkgs.nixfmt
+ pkgs.ripgrep
+ pkgs.tmux
+ pkgs.tree
+ pkgs.uv
+
+ (pkgsUnstable.callPackage ../packages/claude-code { })
+ pkgsUnstable.jujutsu
+ pkgsUnstable.llama-cpp
+ ];
+
+ environment.pathsToLink = [ "/share/vim-plugins" ]; # for fzf
+
+ homebrew = {
+ taps = [ "LizardByte/homebrew" ];
+
+ brews = [
+ "mas"
+ "sunshine"
+ ];
+
+ casks = [
+ "karabiner-elements" # modifiers, fn, reverse scroll
+ "linearmouse" # scroll by lines, universal back/forward
+ ];
+
+ masApps = {
+ "Ghostery Privacy Ad Blocker" = 6504861501;
+ "Kagi for Safari" = 1622835804;
+ "KeePassium (KeePass passwords)" = 1435127111;
+ Xcode = 497799835;
+ };
+ };
+}
diff --git a/hostnix/mojo/modules/obsidian.nix b/hostnix/mojo/modules/obsidian.nix
new file mode 100644
index 0000000..0357b0f
--- /dev/null
+++ b/hostnix/mojo/modules/obsidian.nix
@@ -0,0 +1,17 @@
+{ ... }:
+{
+ homebrew.casks = [ "obsidian" ];
+
+ launchd.user.agents.obsidian-auto-sync = {
+ script = ''
+ cd /Users/joe/src/Obsidian
+ ./.obsidian/auto-sync
+ '';
+ serviceConfig = {
+ StartInterval = 300;
+ StandardOutPath = "/Users/joe/Library/Logs/obsidian-auto-sync.log";
+ StandardErrorPath = "/Users/joe/Library/Logs/obsidian-auto-sync.log";
+ RunAtLoad = false;
+ };
+ };
+}
diff --git a/hostnix/mojo/modules/sunshine.nix b/hostnix/mojo/modules/sunshine.nix
new file mode 100644
index 0000000..4f12601
--- /dev/null
+++ b/hostnix/mojo/modules/sunshine.nix
@@ -0,0 +1,23 @@
+{ ... }:
+{
+ homebrew = {
+ taps = [
+ {
+ name = "LizardByte/homebrew";
+ trusted = true;
+ }
+ ];
+ brews = [ "LizardByte/homebrew/sunshine" ];
+ };
+
+ launchd.user.agents.sunshine = {
+ serviceConfig = {
+ Label = "com.lizardbyte.sunshine";
+ ProgramArguments = [ "/opt/homebrew/bin/sunshine" ];
+ RunAtLoad = true;
+ KeepAlive = true;
+ StandardOutPath = "/tmp/sunshine.log";
+ StandardErrorPath = "/tmp/sunshine.err";
+ };
+ };
+}
diff --git a/hostnix/mojo/modules/system.nix b/hostnix/mojo/modules/system.nix
new file mode 100644
index 0000000..b34905f
--- /dev/null
+++ b/hostnix/mojo/modules/system.nix
@@ -0,0 +1,28 @@
+{
+ pkgs,
+ pkgsUnstable,
+ self,
+ ...
+}:
+{
+ nixpkgs.config.allowUnfree = true;
+
+ homebrew = {
+ enable = true;
+ enableBashIntegration = true;
+ caskArgs.require_sha = true;
+ # Error: Refusing to uninstall /opt/homebrew/Cellar/brotli/1.2.0, [...snip...]
+ # because they are required by sunshine, which is currently installed.
+ # onActivation.cleanup = "uninstall";
+ };
+
+ system.primaryUser = "joe";
+
+ security.sudo.extraConfig = ''
+ Defaults!/run/current-system/sw/bin/darwin-rebuild timestamp_timeout=120
+ '';
+
+ # $ chsh -s /run/current-system/sw/bin/bash
+ environment.shells = [ pkgs.bashInteractive ];
+ programs.bash.completion.enable = true;
+}
diff --git a/hostnix/mojo/packages/claude-code/claude.sb b/hostnix/mojo/packages/claude-code/claude.sb
new file mode 100644
index 0000000..8e5dc9c
--- /dev/null
+++ b/hostnix/mojo/packages/claude-code/claude.sb
@@ -0,0 +1,314 @@
+(version 1)
+
+;; Based on Para Sandboxing Profile - Standard - https://github.com/2mawi2/para/blob/218259b6e260be43334f308a74108f31920f7ca4/src/core/sandbox/profiles/standard.sb
+;; Forbids reading HOME_DIR except for cwd (TARGET_DIR)
+;; Forbids writing other than to cwd (TARGET_DIR)
+;; All network is allowed
+
+;; Deny everything by default
+(deny default)
+
+;; Allow network access (required for Claude API)
+(allow network*)
+
+;; Deny reading files anywhere on host (allow rules override this below)
+(deny file-read*)
+
+(deny file-read*
+ (subpath "/")
+ (subpath "/Users")
+ (subpath (param "HOME_DIR"))
+ (subpath (string-append (param "HOME_DIR") "/.ssh"))
+)
+
+;; allow directories required to launch claude-code
+(allow file-read*
+ (subpath "/usr")
+ (subpath "/bin")
+ (subpath "/opt")
+ (subpath "/var")
+ (subpath "/private/var")
+ (subpath "/etc")
+ (subpath "/private/etc")
+ (subpath "/System")
+ (subpath "/nix")
+ )
+
+;; necessary for nix-darwin's `/run/current-system/sw/bin`
+(allow file-read-metadata
+ (subpath "/run"))
+
+;; === IMPORTANT === MODIFY this section to include ALL directories leading to claude workdir ===
+;; for some reason claude-code needs list access to all parent directories of TARGET_DIR
+;; - it doesn't need access to read the contents of directories, only the directories
+;; themselves. Otherwise it will set PATH to "" and disable colored output
+(allow file-read*
+ (literal "/")
+ )
+
+(allow file-read*
+ ;; Git configuration (for commits)
+ (subpath (string-append (param "HOME_DIR") "/.config/git"))
+ (subpath (string-append (param "HOME_DIR") "/.config/jj"))
+ (literal (string-append (param "HOME_DIR") "/.gitconfig"))
+ ;; Nix configuration
+ (subpath (string-append (param "HOME_DIR") "/.config/nix"))
+ (subpath (string-append (param "HOME_DIR") "/.local/share/nix"))
+ ;; Nix profile binaries (symlinks to /nix/store)
+ (subpath (string-append (param "HOME_DIR") "/.nix-profile"))
+ (subpath (string-append (param "HOME_DIR") "/.local/state/nix"))
+ ;; gh CLI
+ (subpath (string-append (param "HOME_DIR") "/.config/gh"))
+)
+
+;; Allow process execution and forking (children inherit policy)
+(allow process-exec)
+(allow process-fork)
+;; Essential permissions - based on Chrome sandbox policy
+;; Process permissions - from https://github.com/anthropic-experimental/sandbox-runtime/blob/1bafa66a2c3ebc52569fc0c1a868e85e778f66a0/src/sandbox/macos-sandbox-utils.ts#L200
+(allow process-info* (target same-sandbox))
+;; Allow signals to all children
+(allow signal (target same-sandbox))
+(allow mach-priv-task-port (target same-sandbox))
+
+;; User preferences - from https://github.com/anthropic-experimental/sandbox-runtime/blob/1bafa66a2c3ebc52569fc0c1a868e85e778f66a0/src/sandbox/macos-sandbox-utils.ts#L200
+;; (allow user-preference-read) ;; doesn't seem to be required by claude-code
+
+;; Allow read access to system information
+;; From Chromium's sandbox policy for macOS
+(allow sysctl-read
+ (sysctl-name "hw.activecpu")
+ (sysctl-name "hw.busfrequency_compat")
+ (sysctl-name "hw.byteorder")
+ (sysctl-name "hw.cacheconfig")
+ (sysctl-name "hw.cachelinesize_compat")
+ (sysctl-name "hw.cpufamily")
+ (sysctl-name "hw.cpufrequency_compat")
+ (sysctl-name "hw.cputype")
+ (sysctl-name "hw.l1dcachesize_compat")
+ (sysctl-name "hw.l1icachesize_compat")
+ (sysctl-name "hw.l2cachesize_compat")
+ (sysctl-name "hw.l3cachesize_compat")
+ (sysctl-name "hw.logicalcpu_max")
+ (sysctl-name "hw.machine")
+ (sysctl-name "hw.memsize")
+ (sysctl-name "hw.ncpu")
+ ;; Needed for Lix
+ (sysctl-name "hw.pagesize")
+ (sysctl-name "hw.pagesize_compat")
+ (sysctl-name "hw.physicalcpu_max")
+ (sysctl-name "hw.tbfrequency_compat")
+ (sysctl-name "kern.hostname")
+ (sysctl-name "kern.maxfilesperproc")
+ (sysctl-name "kern.osproductversion")
+ (sysctl-name "kern.osrelease")
+ (sysctl-name "kern.ostype")
+ (sysctl-name "kern.osversion")
+ (sysctl-name "kern.secure_kernel")
+ (sysctl-name "kern.version")
+)
+
+;; Allow file writes to specific paths only
+;; Note: file-write* does NOT include file-write-create, so we need both
+(allow file-read* file-write* file-write-create file-read-metadata file-ioctl
+ ;; Project directory - primary workspace
+ (subpath (param "TARGET_DIR"))
+
+ ;; Include .git and .jj directories in case the root is above TARGET_DIR.
+ (subpath (param "GIT_DIR"))
+ (subpath (param "JJ_DIR"))
+
+ ;; Temporary directories
+ (subpath (param "TMP_DIR"))
+ (subpath "/tmp")
+ (subpath "/private/tmp")
+ (subpath "/var/folders") ; macOS temp directory root
+ (subpath "/private/var/folders") ; Real path (var is symlink to private/var)
+
+ ;; below is from `para`'s' sandbox.sb, but these rules don't work because sandbox-exec uses GLOB 'regexes'
+ ;; (regex #"^/var/folders/[^/]+/[^/]+/[^/]+/.*") ; macOS temp dirs and subdirs
+ ;; (regex #"^/private/var/folders/[^/]+/[^/]+/[^/]+/.*") ; Real path version
+ ;; (regex #"^/var/folders/.*") ; Allow all subdirectories under /var/folders for broader compatibility
+ ;; (regex #"^/private/var/folders/.*") ; Real path version
+
+ ;; Cache directory
+ (subpath (param "CACHE_DIR"))
+ (subpath (string-append (param "HOME_DIR") "/.cache"))
+
+ ;; Claude configuration
+ (subpath (string-append (param "HOME_DIR") "/.claude"))
+ (literal (string-append (param "HOME_DIR") "/.claude.json"))
+ (literal (string-append (param "HOME_DIR") "/.claude.json.backup"))
+ (subpath (string-append (param "HOME_DIR") "/Library/Caches/claude-cli-nodejs"))
+
+ ;; Gemini configuration
+ (subpath (string-append (param "HOME_DIR") "/.gemini"))
+
+ ;; Standard I/O devices
+ (literal "/dev/stdout")
+ (literal "/dev/stderr")
+ (literal "/dev/null")
+ (literal "/dev/zero")
+ (literal "/dev/tty")
+ (literal "/dev/ptmx")
+ (literal "/dev/urandom")
+ (literal "/dev/random")
+ (regex #"^/dev/tty*")
+ (regex #"^/dev/pty*")
+)
+
+;; File I/O on device files - sandbox-runtime - https://github.com/anthropic-experimental/sandbox-runtime/blob/1bafa66a2c3ebc52569fc0c1a868e85e778f66a0/src/sandbox/macos-sandbox-utils.ts#L200
+(allow file-ioctl file-read-metadata file-read-data file-write-data (literal "/dev/dtracehelper"))
+(allow file-ioctl file-read-metadata file-read-data file-write-data
+ (require-all
+ (literal "/dev/null")
+ (vnode-type CHARACTER-DEVICE)
+ )
+)
+
+;; Gemini-specific permissions
+(allow pseudo-tty)
+
+;; Allow mach lookups for essential services
+(allow mach-lookup
+ (global-name "com.apple.sysmond") ; For process listing
+ (global-name "com.apple.FSEvents") ; For Node.js file watching
+ (global-name "com.apple.SystemConfiguration.DNSConfiguration") ; For DNS resolution in Lix
+)
+
+;; Allow file attribute operations needed for file creation
+;; (allow file-write-setugid)
+;; (allow file-write-mode)
+;; (allow file-write-owner)
+;; (allow file-write-times)
+;; (allow file-write-flags)
+
+(allow mach-lookup
+ (global-name "com.apple.audio.systemsoundserver")
+ (global-name "com.apple.distributed_notifications@Uv3")
+ (global-name "com.apple.FontObjectsServer")
+ (global-name "com.apple.fonts")
+ (global-name "com.apple.logd")
+ (global-name "com.apple.lsd.mapdb")
+ (global-name "com.apple.PowerManagement.control")
+ (global-name "com.apple.system.logger")
+ (global-name "com.apple.system.notification_center")
+ (global-name "com.apple.trustd.agent")
+ (global-name "com.apple.system.opendirectoryd.libinfo")
+ (global-name "com.apple.system.opendirectoryd.membership")
+ (global-name "com.apple.bsd.dirhelper")
+ (global-name "com.apple.securityd.xpc")
+ (global-name "com.apple.coreservices.launchservicesd")
+)
+
+;; The following is required to get Claude API Key from macOS Keychain (if logged in via /login)
+
+;; Specifically allow login keychain
+(allow file-read*
+ (literal (string-append (param "HOME_DIR") "/Library/Keychains/login.keychain-db"))
+)
+
+;; Critical: Allow communication with securityd (keychain daemon)
+(allow mach-lookup
+ (global-name "com.apple.SecurityServer")
+ (global-name "com.apple.securityd")
+ (global-name "com.apple.securityd.xpc")
+)
+
+;; Java/Scala development permissions
+
+;; Java-specific services
+(allow mach-lookup
+ (global-name "com.apple.diagnosticd")
+ (global-name "com.apple.SystemConfiguration.configd")
+)
+
+;; Java-specific sysctl reads
+(allow sysctl-read
+ (sysctl-name "security.mac.lockdown_mode_state")
+ (sysctl-name "kern.bootargs")
+ (sysctl-name "kern.osvariant_status")
+ (sysctl-name "kern.argmax")
+ (sysctl-name "hw.ephemeral_storage")
+ (sysctl-name "hw.optional.armv8_crc32")
+ (sysctl-name "hw.optional.arm.FEAT_LSE")
+ (sysctl-name "hw.optional.armv8_1_atomics")
+ (sysctl-name "hw.optional.arm.FEAT_SHA512")
+ (sysctl-name "hw.optional.armv8_2_sha512")
+ (sysctl-name "hw.optional.arm.FEAT_SHA3")
+ (sysctl-name "hw.optional.armv8_2_sha3")
+ (sysctl-name "net.routetable.0.0.3.0")
+)
+
+;; Java needs to read dtracehelper
+(allow file-read-data (literal "/dev/dtracehelper"))
+
+;: ;; Java needs IPC shared memory for notification center
+;; (allow ipc-posix-shm-read-data
+;; (ipc-posix-name "apple.shm.notification_center")
+;; )
+
+;; Java needs system sockets (domain:32 is AF_NDRV for network device raw access)
+(allow system-socket)
+
+;; Java needs to read metadata on certain directories
+(allow file-read-metadata
+ (literal "/dev")
+ (literal "/private")
+ (literal "/Library")
+ (literal (string-append (param "HOME_DIR") "/Library"))
+ (literal (string-append (param "HOME_DIR") "/Library/Caches"))
+)
+
+(allow file-read-data
+ (literal "/dev")
+)
+
+;; Java needs to read various preference files
+(allow file-read*
+ (literal "/Library/Preferences/Logging/com.apple.diagnosticd.filter.plist")
+ (literal "/Library/Preferences/.GlobalPreferences.plist")
+ (literal "/Library/Preferences/com.apple.networkd.plist")
+ (literal (string-append (param "HOME_DIR") "/Library/Preferences/.GlobalPreferences.plist"))
+ (literal (string-append (param "HOME_DIR") "/Library/Preferences/.GlobalPreferences_m.plist"))
+ (regex (string-append "^" (param "HOME_DIR") "/Library/Preferences/ByHost/\\.GlobalPreferences\\..*\\.plist$"))
+)
+
+;; read/write ~/.sbt, coursier and bloop caches
+(allow file-read* file-write*
+ (subpath (string-append (param "HOME_DIR") "/.sbt"))
+ (subpath (string-append (param "HOME_DIR") "/.ivy2"))
+ (subpath (string-append (param "HOME_DIR") "/.m2"))
+ (subpath (string-append (param "HOME_DIR") "/.jgit"))
+ (subpath (string-append (param "HOME_DIR") "/.config/jgit"))
+ (subpath (string-append (param "HOME_DIR") "/Library/Caches/Coursier"))
+ (subpath (string-append (param "HOME_DIR") "/Library/Caches/ScalaCli"))
+)
+
+;; read/write pnpm store
+(allow file-read* file-write* file-write-create
+ (subpath (string-append (param "HOME_DIR") "/Library/pnpm/store"))
+)
+
+;; read [~]/Library/Java
+(allow file-read*
+ (subpath (string-append (param "HOME_DIR") "/Library/Java"))
+ (subpath "/Library/Java")
+)
+
+;; Xcode Command Line Tools - needed so /usr/bin/git (an xcode-select shim)
+;; can locate the real git binary
+(allow file-read*
+ (subpath "/Library/Developer/CommandLineTools")
+)
+
+;; Generated allow-read rules for: /Users/joe/src
+;; for some reason claude-code needs list access to all parent directories of TARGET_DIR
+;; - it doesn't need access to read the contents of directories, only the directories
+;; themselves. Otherwise it will set PATH to "" and disable colored output
+(allow file-read* (literal "/Users"))
+(allow file-read* (literal "/Users/joe"))
+(allow file-read* (subpath "/Users/joe/src"))
+
+(allow file-read* (subpath (string-append (param "HOME_DIR") "/.dotfiles")))
+(deny file-read* (subpath (string-append (param "HOME_DIR") "/.dotfiles/tmp")))
diff --git a/hostnix/mojo/packages/claude-code/default.nix b/hostnix/mojo/packages/claude-code/default.nix
new file mode 100644
index 0000000..2e3fd35
--- /dev/null
+++ b/hostnix/mojo/packages/claude-code/default.nix
@@ -0,0 +1,21 @@
+{ writeShellScriptBin, claude-code }:
+
+writeShellScriptBin "claude" ''
+ if [[ $HOME/ = ''${PWD%/}/* ]]; then
+ echo "fatal: refusing to allow access to $PWD" >&2
+ exit 1
+ fi
+
+ git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null)"
+ jj_root="$(jj root --ignore-working-copy 2>/dev/null)"
+
+ exec /usr/bin/sandbox-exec -f ${./claude.sb} \
+ -D TARGET_DIR="$(realpath "$PWD")" \
+ -D TMP_DIR=/tmp \
+ -D HOME_DIR="$HOME" \
+ -D CACHE_DIR="$HOME/.cache" \
+ -D GIT_DIR="''${git_dir:-$PWD/.git}" \
+ -D JJ_DIR="''${jj_root:-$PWD}/.jj" \
+ ${claude-code}/bin/claude \
+ --allow-dangerously-skip-permissions "$@"
+''
diff --git a/hostnix/weebnix/Makefile b/hostnix/weebnix/Makefile
new file mode 100644
index 0000000..90591a9
--- /dev/null
+++ b/hostnix/weebnix/Makefile
@@ -0,0 +1,7 @@
+push:
+ rsync --rsync-path='sudo rsync' *.nix weebnix.lan:/etc/nixos/
+
+switch: push
+ ssh weebnix.lan sudo nixos-rebuild switch
+
+.PHONY: push switch
diff --git a/hostnix/weebnix/boot/config.txt b/hostnix/weebnix/boot/config.txt
new file mode 100644
index 0000000..b407ad7
--- /dev/null
+++ b/hostnix/weebnix/boot/config.txt
@@ -0,0 +1,36 @@
+[pi3]
+kernel=u-boot-rpi3.bin
+
+[pi02]
+kernel=u-boot-rpi3.bin
+
+[pi4]
+kernel=u-boot-rpi4.bin
+enable_gic=1
+armstub=armstub8-gic.bin
+
+# Otherwise the resolution will be weird in most cases, compared to
+# what the pi3 firmware does by default.
+disable_overscan=1
+
+# Supported in newer board revisions
+arm_boost=1
+
+[cm4]
+# Enable host mode on the 2711 built-in XHCI USB controller.
+# This line should be removed if the legacy DWC2 controller is required
+# (e.g. for USB device mode) or if USB support is not required.
+otg_mode=1
+
+[all]
+# Boot in 64-bit mode.
+arm_64bit=1
+
+# U-Boot needs this to work, regardless of whether UART is actually used or not.
+# Look in arch/arm/mach-bcm283x/Kconfig in the U-Boot tree to see if this is still
+# a requirement in the future.
+enable_uart=1
+
+# Prevent the firmware from smashing the framebuffer setup done by the mainline kernel
+# when attempting to show low-voltage or overtemperature warnings.
+avoid_warnings=1
diff --git a/hostnix/weebnix/configuration.nix b/hostnix/weebnix/configuration.nix
new file mode 100644
index 0000000..a0166f5
--- /dev/null
+++ b/hostnix/weebnix/configuration.nix
@@ -0,0 +1,93 @@
+{ config, pkgs, ... }:
+
+{
+ imports = [
+ ./dyndns.nix
+ ./hardware-configuration.nix
+ ./home-assistant.nix
+ ./oidc.nix
+ ./privacy-frontends.nix
+ ./syncthing.nix
+ ./system.nix
+ ];
+
+ nix.settings.experimental-features = [ "nix-command" "flakes" ];
+ nix.settings.trusted-users = [ "joe" ];
+
+ security.sudo.wheelNeedsPassword = false;
+
+ security.acme.acceptTerms = true;
+ security.acme.defaults.email = "hostmaster@mou.fo";
+ # TODO switch to production certs
+ security.acme.defaults.server = "https://acme-staging-v02.api.letsencrypt.org/directory";
+
+ users.users.joe = {
+ isNormalUser = true;
+ extraGroups = [ "wheel" "syncthing" ];
+ openssh.authorizedKeys.keys = [
+ "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDHcqQDnH0LcUWFV+cd9aABfM55+72UAn1ZY5x5bHwPL4IEgT1nagbi1X3FwCJkh2rEcDsHl4FflcNfh+IyU2VyuQfRIXl7CKP040ovXmPVzgU8JiEDrrRebpZ7aHCKk+4Q6cJ8dzAI1sRogVyYrV0hLB4yeMnVexkIcsQMt5pXQ1zodlzHfW1W7OWQDbKjIyh4pv1KJ/IIjiBIt7l+OCqi7wZK4ubmxyMhmZx1xpRC5mz2/29Mpt+1VgCmxC7Hv1SG7yu+U0s4eJ3kbzFKNcZ/bCYqFxK48dT66qhS3qZFg09omumG8IbEUcx+WqM1V7WhiJYig9x5ovmMToQXTJ5uxLtcovnxO/53IgwV7INvmr8+NhD+LlkQfSjjUAW09bhVj9sKIW2bbrIxlOT8z6CQY9zGXNXi8u1oxPt3Q7d3rMxoWhWnr2l3Ok7OIkWooOK3e0NdhgIAxZKlYJAmIYaX391sGTyiuwUy96mg5JcYsLYMfkEg/Sv+2vQp8kkvB0E= joe@sparky"
+ ];
+ };
+
+ environment.systemPackages = with pkgs; [
+ dig
+ file
+ gitFull
+ jq
+ libraspberrypi
+ sqlite-interactive
+ tmux
+ tree
+ ];
+
+ programs.vim.defaultEditor = true;
+ programs.nano.enable = false;
+
+ services.openssh.enable = true;
+
+ services.nginx = {
+ enable = true;
+ recommendedGzipSettings = true;
+ recommendedOptimisation = true;
+ recommendedProxySettings = true;
+ recommendedTlsSettings = true;
+ # Slightly crazy setup to SNI reverse proxy HTTPS to multiple upstreams.
+ # We displace ourselves onto port 8443, and send requests that are not
+ # intended for us to weeber. This is done because Apache running on weeber
+ # cannot SNI reverse proxy, so we put weebnix in front of weeber on IPv4.
+ # TODO get rid of all this when replacing weeber or maybe consider HAProxy
+ defaultSSLListenPort = 8443;
+ streamConfig = ''
+ map $ssl_preread_server_name $selected_upstream {
+ hostnames;
+ weebnix.mou.fo self;
+ *.weebnix.mou.fo self;
+ default weeber;
+ }
+ upstream self { server 127.0.0.1:8443; }
+ upstream weeber { server 192.168.0.168:443; }
+ server {
+ listen 0.0.0.0:443;
+ listen [::0]:443;
+ proxy_pass $selected_upstream;
+ ssl_preread on;
+ }
+ '';
+ };
+
+ # TODO remove upon switching to production certs
+ services.oauth2_proxy.extraConfig = {
+ "ssl-insecure-skip-verify" = true;
+ "ssl-upstream-insecure-skip-verify" = true;
+ };
+
+ networking.firewall.allowedTCPPorts = [ 80 443 ];
+
+ # This value determines the NixOS release from which the default
+ # settings for stateful data, like file locations and database versions
+ # on your system were taken. It's perfectly fine and recommended to leave
+ # this value at the release version of the first install of this system.
+ # Before changing this value read the documentation for this option
+ # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
+ system.stateVersion = "23.05"; # Did you read the comment?
+}
diff --git a/hostnix/weebnix/dyndns.nix b/hostnix/weebnix/dyndns.nix
new file mode 100644
index 0000000..a59c665
--- /dev/null
+++ b/hostnix/weebnix/dyndns.nix
@@ -0,0 +1,78 @@
+{ config, pkgs, ... }:
+
+{
+ # Needs to be started manually, and the key added to nameservers.
+ # Based on https://nixos.org/manual/nixos/stable/index.html#module-security-acme-config-dns
+ systemd.services.sig0-keygen = {
+ unitConfig = {
+ ConditionPathExists = "!/var/lib/secrets/${config.networking.fqdn}.id";
+ };
+ serviceConfig = {
+ Type = "oneshot";
+ };
+ path = [ pkgs.bind ];
+ scriptArgs = config.networking.fqdn;
+ script = ''
+ mkdir -p /var/lib/secrets
+ chmod 755 /var/lib/secrets
+ cd /var/lib/secrets
+ dnssec-keygen -a RSASHA512 -b 2048 -n HOST -T KEY $1. > $1.id
+ '';
+ };
+
+ systemd.services.dyndns = {
+ requires = [ "network-online.target" ];
+ after = [ "network-online.target" ];
+ unitConfig = {
+ AssertPathExists = "/var/lib/secrets/${config.networking.fqdn}.id";
+ # Defer errors for ~45min, throttle e-mails to ~hourly.
+ StartLimitIntervalSec = "1hr";
+ StartLimitBurst = "45";
+ };
+ serviceConfig = {
+ Type = "oneshot";
+ Restart = "on-failure";
+ RestartSec = "1min";
+ };
+ path = [ pkgs.dnsutils pkgs.gawk pkgs.iproute2 ];
+ scriptArgs = config.networking.fqdn;
+ script = ''
+ RR=''${1%%.*}.dynamic.''${1#*.}
+
+ IP4=`dig +short @1.1.1.1 ch txt whoami.cloudflare | tr -d \"`
+ if [ -z "$IP4" ]; then
+ echo "Missing IP: $IP4" >&2
+ exit 100
+ fi
+
+ # Follow some RFC 6724 default address guidance, excluding ULA.
+ # It might be more robust to bind a public source socket (RFC 5014).
+ IP6=`ip -6 address show scope global -deprecated | awk -F'[ /]+' '$2 == "inet6" && $3 !~ /^f[cd]/ { print $3; exit }'`
+
+ OLDIP4=`dig +short @popfresh.mou.fo $RR A 2> /dev/null`
+ OLDIP6=`dig +short @popfresh.mou.fo $RR AAAA 2> /dev/null`
+ # [ "x$IP" = "x$OLDIP4" ] && exit 0 # no update
+ if [ "x$IP4" = "x$OLDIP4" -a "x$IP6" = "x$OLDIP6" ]; then
+ exit 0
+ fi
+
+ nsupdate -v -k /var/lib/secrets/`< /var/lib/secrets/$1.id`.private <<.
+ update delete $RR. A
+ update add $RR. 300 A $IP4
+ update delete $RR. AAAA
+ ''${IP6:+update add $RR. 300 AAAA $IP6}
+ update delete $RR. TXT
+ update add $RR. 300 TXT "v=spf1 include:_spf.mou.fo ~all"
+ send
+ .
+ '';
+ };
+
+ systemd.timers.dyndns = {
+ wantedBy = [ "multi-user.target" ];
+ timerConfig = {
+ OnStartupSec = "10";
+ OnUnitActiveSec = "1min";
+ };
+ };
+}
diff --git a/hostnix/weebnix/hardware-configuration.nix b/hostnix/weebnix/hardware-configuration.nix
new file mode 100644
index 0000000..d7ce9dc
--- /dev/null
+++ b/hostnix/weebnix/hardware-configuration.nix
@@ -0,0 +1,51 @@
+# Do not modify this file! It was generated by ‘nixos-generate-config’
+# and may be overwritten by future invocations. Please make changes
+# to /etc/nixos/configuration.nix instead.
+{ config, lib, pkgs, modulesPath, ... }:
+
+{
+ imports =
+ [ (modulesPath + "/installer/scan/not-detected.nix")
+ ];
+
+ boot.initrd.availableKernelModules = [ "xhci_pci" "usbhid" "usb_storage" ];
+ boot.initrd.kernelModules = [ ];
+ boot.kernelModules = [ ];
+ boot.extraModulePackages = [ ];
+
+ fileSystems."/" =
+ { device = "/dev/disk/by-uuid/574841f9-3d47-419b-a431-e0c0c0c4ee9d";
+ fsType = "btrfs";
+ options = [ "subvol=nixos-root" ];
+ };
+
+ fileSystems."/nix" =
+ { device = "/dev/disk/by-uuid/574841f9-3d47-419b-a431-e0c0c0c4ee9d";
+ fsType = "btrfs";
+ options = [ "subvol=nix,noatime" ];
+ };
+
+ fileSystems."/home" =
+ { device = "/dev/disk/by-uuid/574841f9-3d47-419b-a431-e0c0c0c4ee9d";
+ fsType = "btrfs";
+ options = [ "subvol=home" ];
+ };
+
+ fileSystems."/boot" =
+ { device = "/dev/disk/by-uuid/8D56-48CD";
+ fsType = "vfat";
+ };
+
+ swapDevices = [ ];
+
+ # Enables DHCP on each ethernet and wireless interface. In case of scripted networking
+ # (the default) this is the recommended approach. When using systemd-networkd it's
+ # still possible to use this option, but it's recommended to use it in conjunction
+ # with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
+ networking.useDHCP = lib.mkDefault true;
+ # networking.interfaces.end0.useDHCP = lib.mkDefault true;
+ # networking.interfaces.wlan0.useDHCP = lib.mkDefault true;
+
+ nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
+ powerManagement.cpuFreqGovernor = lib.mkDefault "ondemand";
+}
diff --git a/hostnix/weebnix/home-assistant.nix b/hostnix/weebnix/home-assistant.nix
new file mode 100644
index 0000000..77c7fa8
--- /dev/null
+++ b/hostnix/weebnix/home-assistant.nix
@@ -0,0 +1,116 @@
+{ pkgs, ... }:
+
+{
+ services.postgresql = {
+ enable = true;
+ ensureDatabases = [ "hass" ];
+ ensureUsers = [{
+ name = "hass";
+ ensureDBOwnership = true;
+ }];
+ };
+
+ services.home-assistant = {
+ enable = true;
+ extraPackages = ps: with ps; [ psycopg2 ];
+ extraComponents = [
+ "androidtv_remote"
+ "apple_tv"
+ "cast"
+ "homekit_controller"
+ "hue"
+ "spotify"
+ "esphome"
+ "met"
+ "radio_browser"
+ ];
+ customComponents = [
+ (
+ pkgs.buildHomeAssistantComponent rec {
+ owner = "BeryJu";
+ domain = "auth_header";
+ version = "1.10";
+ src = pkgs.fetchFromGitHub {
+ inherit owner;
+ repo = "hass-auth-header";
+ rev = "refs/tags/v${version}";
+ hash = "sha256-dSmY3d8Kx0pXl+20dTGAYgjSH6OhNh53jPX7VLCZs7Y=";
+ };
+ dontBuild = true;
+ }
+ )
+ (
+ pkgs.buildHomeAssistantComponent rec {
+ owner = "make-all";
+ domain = "tuya_local";
+ version = "2023.12.1";
+ src = pkgs.fetchFromGitHub {
+ inherit owner;
+ repo = "tuya-local";
+ rev = "refs/tags/${version}";
+ hash = "sha256-vi5EmtXAyXaUbJl+yAT5EL0yYb3XFRaAj6fybQRCM4A=";
+ };
+ propagatedBuildInputs = with pkgs.home-assistant.python.pkgs; [
+ (
+ buildPythonPackage rec {
+ pname = "tinytuya";
+ version = "1.13.1";
+ format = "wheel";
+ src = pkgs.fetchPypi {
+ inherit pname version format;
+ hash = "sha256-j7t4P4U9iuVHyb6HASkf7LmBheHN32IjdKE60HUbjIE=";
+ };
+ }
+ )
+ colorama
+ ];
+ dontBuild = true;
+ }
+ )
+ ];
+ config = {
+ default_config = { };
+ http = {
+ server_host = "::1";
+ trusted_proxies = [ "::1" ];
+ use_x_forwarded_for = true;
+ };
+ recorder.db_url = "postgresql://@/hass";
+ auth_header = { };
+ };
+ };
+
+ services.nginx.virtualHosts."ha.weebnix.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/" = {
+ proxyPass = "http://[::1]:8123";
+ proxyWebsockets = true;
+ extraConfig = ''
+ # This is frequently used in examples but without clear explanation. It
+ # might help with WebSockets.
+ proxy_buffering off;
+ # oauth2_proxy NixOS module sets some non-standard headers, but we need
+ # the preferred_username claim.
+ auth_request_set $preferred_username $upstream_http_x_auth_request_preferred_username;
+ proxy_set_header X-Forwarded-Preferred-Username $preferred_username;
+ '';
+ };
+ # Duplicate relevant parts of root route to skip oauth2-proxy module magic.
+ locations."/api/" = {
+ proxyPass = "http://[::1]:8123";
+ proxyWebsockets = true;
+ extraConfig = ''
+ proxy_buffering off;
+ '';
+ };
+ # Disable service worker caching that works improperly with reverse proxy.
+ # https://github.com/home-assistant/frontend/issues/14836
+ # https://community.home-assistant.io/t/disabling-service-worker-reverse-proxy-auth-causes-issues/167082
+ locations."/service_worker.js" = {
+ return = ''410 "Service worker disabled: https://github.com/home-assistant/frontend/issues/14836"'';
+ };
+ };
+
+ services.oauth2_proxy.nginx.virtualHosts = [ "ha.weebnix.mou.fo" ];
+}
diff --git a/hostnix/weebnix/oidc.nix b/hostnix/weebnix/oidc.nix
new file mode 100644
index 0000000..bf70882
--- /dev/null
+++ b/hostnix/weebnix/oidc.nix
@@ -0,0 +1,51 @@
+{ ... }:
+
+{
+ services.keycloak = {
+ enable = true;
+ database.passwordFile = "/var/lib/secrets/keycloak.dbpass";
+ settings = {
+ hostname = "kc.weebnix.mou.fo";
+ http-host = "127.0.0.1";
+ http-port = 7567;
+ proxy = "edge";
+ };
+ };
+
+ services.nginx.virtualHosts."kc.weebnix.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/".proxyPass = "http://127.0.0.1:7567";
+ # We can handle oauth2-proxy callbacks on any subdomain, but the Keycloak
+ # subdomain is the least arbitrary.
+ locations."/oauth2/".proxyPass = "http://127.0.0.1:4180";
+ };
+
+ # Work around "upstream sent too big header" because of large tokens.
+ services.nginx.appendHttpConfig = ''
+ proxy_buffers 8 16k;
+ proxy_buffer_size 16k;
+ '';
+
+ # The oauth2_proxy module has a magic nginx.virtualHosts option that rewrites
+ # nginx configs. It's mostly unhelpful, but we use it for brevity. In
+ # particular, it configures Traefik-like ForwardAuth authentication with
+ # auth_request. Note if this resource is missing for whatever reason, the
+ # module magic will fail open (auth_request unset).
+ services.oauth2_proxy = {
+ enable = true;
+ cookie.domain = "weebnix.mou.fo";
+ setXauthrequest = true; # include claims
+ email.domains = [ "*" ]; # allow any authenticated user
+ # https://oauth2-proxy.github.io/oauth2-proxy/docs/configuration/oauth_provider/#keycloak-oidc-auth-provider
+ provider = "keycloak-oidc";
+ clientID = "weebnix.mou.fo";
+ # Sets OAUTH2_PROXY_COOKIE_SECRET and OAUTH2_PROXY_CLIENT_SECRET.
+ keyFile = "/var/lib/secrets/oauth2-proxy.env";
+ redirectURL = "https://kc.weebnix.mou.fo/oauth2/callback";
+ extraConfig = {
+ "oidc-issuer-url" = "https://kc.weebnix.mou.fo/realms/staging";
+ "whitelist-domain" = ".weebnix.mou.fo";
+ };
+ };
+}
diff --git a/hostnix/weebnix/privacy-frontends.nix b/hostnix/weebnix/privacy-frontends.nix
new file mode 100644
index 0000000..b312155
--- /dev/null
+++ b/hostnix/weebnix/privacy-frontends.nix
@@ -0,0 +1,15 @@
+{ ... }:
+
+{
+ services.libreddit = {
+ enable = true;
+ address = "[::1]";
+ port = 7682;
+ };
+
+ services.nginx.virtualHosts."lr.weebnix.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/".proxyPass = "http://[::1]:7682";
+ };
+}
diff --git a/hostnix/weebnix/syncthing.nix b/hostnix/weebnix/syncthing.nix
new file mode 100644
index 0000000..a0a09be
--- /dev/null
+++ b/hostnix/weebnix/syncthing.nix
@@ -0,0 +1,136 @@
+{ ... }:
+
+let
+ staggeredVersioning = {
+ type = "staggered";
+ params = {
+ cleanInterval = "3600";
+ maxAge = "31536000";
+ };
+ };
+in
+{
+ systemd.tmpfiles.rules = [
+ "d /var/lib/syncthing 0775 syncthing syncthing"
+ ];
+
+ systemd.services.syncthing = {
+ serviceConfig.UMask = "0002";
+ };
+
+ services.syncthing = {
+ enable = true;
+ openDefaultPorts = true;
+ # Syncthing supports named sockets but the NixOS module assumes network.
+ guiAddress = "[::1]:8384";
+ settings = {
+ devices = {
+ "Asus Nexus 7" = {
+ id = "BVZARYC-2D56A6I-V6L2VQF-MU7IVCT-ITJTCGQ-IGMZG2W-RZRCTOT-K4GDHAY";
+ };
+ "DESKTOP-SFVBFBU" = {
+ id = "FQEK2MG-2AVMHEM-H6KASQ3-RTA3Z3F-A4R4MUY-YELZVRQ-6QUDSHA-DZZN7AJ";
+ autoAcceptFolders = true;
+ };
+ "Joes-iPhone-6" = {
+ id = "F5APH5K-XXO454B-6YU4BTT-YPHF4KT-OD7YF5Y-JTWJRSU-UNJ2KZK-IVJZNQT";
+ autoAcceptFolders = true;
+ };
+ "iPad" = {
+ id = "U7D7667-RFEFHXX-TUJGGII-CE62S6P-YC6MWNV-4LBJ4YJ-5ZUZVPT-GBC5PQH";
+ autoAcceptFolders = true;
+ };
+ "maxsettings-C6554E6AF22F" = {
+ id = "HO3QQZO-RDWYDB6-U74ZQRC-FP7YPB2-IPB2EBC-KIQ5JII-FD4KBXR-J3GCOQ5";
+ autoAcceptFolders = true;
+ };
+ "penguin" = {
+ id = "5BEB6SZ-YAPV3CC-54RZF3V-HQXXP3Y-TTVDWDU-SHHNVCH-IXKZ3O2-6RXG6QL";
+ autoAcceptFolders = true;
+ };
+ "sparky" = {
+ id = "FE43LDI-33WS467-LIGFWCC-5PPSKN6-GYODEWJ-KLQZLN7-H3GYGLG-IJ2JGQW";
+ autoAcceptFolders = true;
+ };
+ "steamdeck" = {
+ id = "SCUAABL-XU6AS5H-IZZE4PN-LY5J4LH-OYZMXTU-2UMTVUL-I7B7QKE-ZBPSAQ5";
+ autoAcceptFolders = true;
+ };
+ "weeber.mou.fo" = {
+ id = "PRE6XCX-7JDMJGJ-6TPMHOS-TP2AT3S-T6CAR3Z-URL5EEU-HVXUDJ4-C5UJ2AV";
+ autoAcceptFolders = true;
+ };
+ };
+ folders = {
+ "Documents" = {
+ id = "bhemx-9nh3v";
+ path = "~/Documents";
+ versioning = staggeredVersioning;
+ devices = [ "sparky" "weeber.mou.fo" ];
+ };
+ "Downloads" = {
+ id = "kvq6q-axjhu";
+ path = "~/Downloads";
+ versioning = staggeredVersioning;
+ devices = [ "sparky" "weeber.mou.fo" ];
+ };
+ "Game/Documents/Bioshock" = {
+ id = "7zhqz-x6uvw";
+ path = "~/Game/Documents/Bioshock";
+ versioning = staggeredVersioning;
+ devices = [ "weeber.mou.fo" ];
+ };
+ "Game/Epic Games/TheTalosPrinciple/UserData" = {
+ id = "vek7u-iausx";
+ path = "~/Game/Epic Games/TheTalosPrinciple/UserData";
+ versioning = staggeredVersioning;
+ devices = [ "DESKTOP-SFVBFBU" "maxsettings-C6554E6AF22F" "weeber.mou.fo" ];
+ };
+ "Game/PCSX2" = {
+ id = "chxsg-hpqgm";
+ path = "~/Game/PCSX2";
+ versioning = staggeredVersioning;
+ devices = [ "maxsettings-C6554E6AF22F" "weeber.mou.fo" ];
+ };
+ "Pictures" = {
+ id = "vfjsd-4fczh";
+ path = "~/Pictures";
+ versioning = staggeredVersioning;
+ devices = [ "sparky" "weeber.mou.fo" ];
+ };
+ "Sync" = {
+ id = "7thks-5badk";
+ path = "~/Sync";
+ versioning = staggeredVersioning;
+ devices = [
+ "Asus Nexus 7"
+ "DESKTOP-SFVBFBU"
+ "Joes-iPhone-6"
+ "iPad"
+ "penguin"
+ "sparky"
+ "weeber.mou.fo"
+ ];
+ };
+ "iPad" = {
+ id = "qtzmu-fqdrs";
+ path = "~/iPad";
+ versioning = staggeredVersioning;
+ devices = [ "iPad" "weeber.mou.fo" ];
+ };
+ };
+ };
+ };
+
+ services.nginx.virtualHosts."st.weebnix.mou.fo" = {
+ enableACME = true;
+ forceSSL = true;
+ locations."/" = {
+ proxyPass = "http://[::1]:8384";
+ # https://docs.syncthing.net/users/faq.html#why-do-i-get-host-check-error-in-the-gui-api
+ recommendedProxySettings = false;
+ };
+ };
+
+ services.oauth2_proxy.nginx.virtualHosts = [ "st.weebnix.mou.fo" ];
+}
diff --git a/hostnix/weebnix/system.nix b/hostnix/weebnix/system.nix
new file mode 100644
index 0000000..8c80708
--- /dev/null
+++ b/hostnix/weebnix/system.nix
@@ -0,0 +1,49 @@
+{ pkgs, lib, ... }:
+
+{
+ boot.loader.systemd-boot.enable = true;
+ # Raspberry Pi has no NVRAM.
+ boot.loader.efi.canTouchEfiVariables = false;
+
+ boot.kernelPackages = pkgs.linuxPackages_rpi4;
+ # https://github.com/NixOS/nixpkgs/issues/122130#issuecomment-1568815007
+ # It's unclear if these are strictly necessary with the downstream kernel,
+ # but let's leave them in to keep working with mainline.
+ boot.initrd.availableKernelModules = [ "uas" "pcie-brcmstb" "reset-raspberrypi" ];
+
+ networking.hostName = "weebnix";
+ networking.domain = "mou.fo";
+ # TODO secrets management or switch to wired
+ networking.wireless = {
+ enable = true;
+ networks."oldschool".psk = builtins.readFile /var/lib/secrets/oldschool.wpa-psk;
+ };
+
+ systemd.network.enable = true;
+ networking.useNetworkd = true;
+ networking.dhcpcd.enable = false;
+ networking.tempAddresses = "disabled";
+
+ systemd.network.networks = let
+ default = {
+ networkConfig = {
+ DHCP = "yes";
+ MulticastDNS = "yes";
+ };
+ ipv6AcceptRAConfig.Token = "prefixstable"; # RFC 7217
+ };
+ in {
+ "10-wlan" = lib.recursiveUpdate default {
+ matchConfig.Name = "wlan0";
+ };
+ "10-eth" = lib.recursiveUpdate default {
+ matchConfig.Name = "end0";
+ linkConfig.RequiredForOnline = "no";
+ };
+ };
+
+ # Problematic when the clock is unreliable (Pi has no RTC).
+ services.resolved.dnssec = "false";
+
+ time.timeZone = "America/New_York";
+}