diff options
Diffstat (limited to 'hostnix')
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"; +} |
