diff options
Diffstat (limited to 'hostnix/elmo')
28 files changed, 2165 insertions, 0 deletions
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"; + }; + }; +} |
