summaryrefslogtreecommitdiff
path: root/hostnix/elmo
diff options
context:
space:
mode:
Diffstat (limited to 'hostnix/elmo')
-rw-r--r--hostnix/elmo/Makefile12
-rw-r--r--hostnix/elmo/acme.nix49
-rw-r--r--hostnix/elmo/backup.nix61
-rw-r--r--hostnix/elmo/bjj-booker.nix47
-rw-r--r--hostnix/elmo/cal.nix35
-rw-r--r--hostnix/elmo/cgithub.nix11
-rw-r--r--hostnix/elmo/clippersnip.nix62
-rw-r--r--hostnix/elmo/configuration.nix110
-rw-r--r--hostnix/elmo/dns.nix22
-rw-r--r--hostnix/elmo/dyndns.nix76
-rw-r--r--hostnix/elmo/email.nix101
-rw-r--r--hostnix/elmo/flake.lock27
-rw-r--r--hostnix/elmo/flake.nix12
-rw-r--r--hostnix/elmo/garage.nix29
-rw-r--r--hostnix/elmo/git.nix38
-rw-r--r--hostnix/elmo/hardware-configuration.nix74
-rw-r--r--hostnix/elmo/home-assistant.nix473
-rw-r--r--hostnix/elmo/media.nix141
-rw-r--r--hostnix/elmo/oidc.nix75
-rw-r--r--hostnix/elmo/pinchflat.nix87
-rw-r--r--hostnix/elmo/rss.nix34
-rw-r--r--hostnix/elmo/syncthing.nix193
-rw-r--r--hostnix/elmo/system.nix79
-rw-r--r--hostnix/elmo/typetype.nix158
-rw-r--r--hostnix/elmo/usenet.nix52
-rw-r--r--hostnix/elmo/web.nix34
-rw-r--r--hostnix/elmo/wireguard.nix32
-rw-r--r--hostnix/elmo/yakatak.nix41
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";
+ };
+ };
+}