From da020a8dfe71ac1d50b29c9c18b6b9627d359e6f Mon Sep 17 00:00:00 2001 From: Aner Zakobar Date: Sun, 27 Sep 2026 09:12:57 +0300 Subject: [PATCH] Added homeassistant, more ldap, transmission fixed. --- flake.nix | 1 + hosts/pi-main/default.nix | 3 + modules/services/ha-ldap-auth.py | 56 +++++++++ modules/services/home-assistant.nix | 183 ++++++++++++++++++++++++++++ modules/services/transmission.nix | 6 +- modules/services/uptime-kuma.nix | 2 +- modules/storage.nix | 8 +- secrets/secrets.yaml | 4 +- 8 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 modules/services/ha-ldap-auth.py create mode 100644 modules/services/home-assistant.nix diff --git a/flake.nix b/flake.nix index ac866ce..4a0f012 100644 --- a/flake.nix +++ b/flake.nix @@ -88,6 +88,7 @@ ./modules/monitoring.nix eurovote.nixosModules.default ./modules/services/eurovote.nix + ./modules/services/home-assistant.nix ] ++ extraModules; }; diff --git a/hosts/pi-main/default.nix b/hosts/pi-main/default.nix index 038abb6..24b2d8d 100644 --- a/hosts/pi-main/default.nix +++ b/hosts/pi-main/default.nix @@ -116,6 +116,9 @@ # Eurovision voting app homey.eurovote.enable = true; + # Home automation + homey.homeAssistant.enable = true; + # Monitoring stack homey.uptimeKuma.enable = true; homey.ntfy.enable = true; diff --git a/modules/services/ha-ldap-auth.py b/modules/services/ha-ldap-auth.py new file mode 100644 index 0000000..a8da84e --- /dev/null +++ b/modules/services/ha-ldap-auth.py @@ -0,0 +1,56 @@ +import sys +import json +import os +from ldap3 import Server, Connection, SUBTREE +from ldap3.utils.conv import escape_filter_chars + +username = os.environ.get("username", "").strip() +password = os.environ.get("password", "") + +if not username or not password: + sys.exit(1) + +LDAP_URL = "ldap://127.0.0.1" +LDAP_PORT = 389 +BASE_DN = "ou=users,dc=zakobar,dc=com" +RO_DN = "cn=readonly,dc=zakobar,dc=com" +RO_PASS_FILE = "/run/secrets/ldap-ro-password" + +try: + with open(RO_PASS_FILE) as f: + ro_password = f.read().strip() + if not ro_password: + sys.exit(1) + + server = Server(LDAP_URL, port=LDAP_PORT) + + # Step 1: search-bind as readonly to find user DN + display name + ro_conn = Connection(server, user=RO_DN, password=ro_password) + if not ro_conn.bind(): + sys.exit(1) + + ro_conn.search( + BASE_DN, + f"(uid={escape_filter_chars(username)})", + search_scope=SUBTREE, + attributes=["cn", "uid"], + ) + + if not ro_conn.entries: + sys.exit(1) + + user_dn = ro_conn.entries[0].entry_dn + display_name = str(ro_conn.entries[0].cn) + ro_conn.unbind() + + # Step 2: bind as user to verify password + user_conn = Connection(server, user=user_dn, password=password) + if not user_conn.bind(): + sys.exit(1) + user_conn.unbind() + + print(json.dumps({"name": display_name})) + sys.exit(0) + +except Exception: + sys.exit(1) diff --git a/modules/services/home-assistant.nix b/modules/services/home-assistant.nix new file mode 100644 index 0000000..4c34e81 --- /dev/null +++ b/modules/services/home-assistant.nix @@ -0,0 +1,183 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Home Assistant — home automation hub. +# +# Auth model: command_line auth provider → OpenLDAP (primary). +# The auth script (ha-ldap-auth.py) does a search-bind as the readonly +# service account, then re-binds as the user to verify the password. +# A local homeassistant provider is kept as break-glass fallback. +# No Authelia forward_auth — HA handles its own sessions and tokens. +# +# Network: --network=host so mDNS/device-discovery works on the LAN. +# Caddy proxies https://home. → http://localhost:8123. +# LAN devices can also reach HA directly at 192.168.1.100:8123. +# +# Volume layout: +# /home-assistant/config/ → /config (configuration.yaml, automations…) +# +# Secrets consumed from sops: +# openldap/ro_password (shared with openldap/mealie modules) + +let + cfg = config.homey.homeAssistant; + dataDir = config.homey.storage.mountPoint; + domain = homeyConfig.domain; + + # ldap3 and pyasn1 are pure Python. Extract their .py source files from + # the PyPI tarballs so the HA container's Python (any version) can import + # them. The Nix-compiled site-packages approach fails because everything in + # there is a symlink into /nix/store, which doesn't exist inside the container. + ldap3Libs = pkgs.runCommand "ldap3-py-libs" {} '' + mkdir -p $out + cd $TMPDIR + + mkdir ldap3-extract && cd ldap3-extract + tar -xf ${pkgs.python3Packages.ldap3.src} --strip-components=1 + cp -r ldap3 $out/ + cd $TMPDIR + + mkdir pyasn1-extract && cd pyasn1-extract + tar -xf ${pkgs.python3Packages.pyasn1.src} --strip-components=1 + cp -r pyasn1 $out/ + ''; + + # Auth script: shebang uses the container's own Python (/usr/bin/env python3) + # and prepends the mounted ldap3 source tree to sys.path at runtime. + haLdapAuth = pkgs.writeTextFile { + name = "ha-ldap-auth"; + executable = true; + text = '' + #!/usr/bin/env python3 + import sys + sys.path.insert(0, "/auth/site-packages") + '' + builtins.readFile ./ha-ldap-auth.py; + }; + + # Placed in the Nix store; copied to /config only on first boot. + initialConfig = pkgs.writeText "ha-initial-configuration.yaml" '' + homeassistant: + auth_providers: + - type: command_line + command: /auth/ha-ldap-auth + args: [] + meta: true + - type: homeassistant + + http: + use_x_forwarded_for: true + trusted_proxies: + - 127.0.0.1 + - "::1" + + mobile_app: + ''; +in +{ + options.homey.homeAssistant = { + enable = lib.mkEnableOption "Home Assistant"; + + image = lib.mkOption { + type = lib.types.str; + default = "ghcr.io/home-assistant/home-assistant:stable"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8123; + description = "Port HA listens on (host network, so this is a real host port)."; + }; + }; + + config = lib.mkIf cfg.enable { + + # openldap/ro_password is also declared by mealie.nix — NixOS merges them. + sops.secrets."openldap/ro_password" = { owner = "root"; }; + + # ------------------------------------------------------------------------- + # Container + # ------------------------------------------------------------------------- + virtualisation.oci-containers.containers.home-assistant = { + image = cfg.image; + environment = { TZ = homeyConfig.timezone; }; + volumes = [ + "${dataDir}/home-assistant/config:/config" + "${haLdapAuth}:/auth/ha-ldap-auth:ro" + "${ldap3Libs}:/auth/site-packages:ro" + "${config.sops.secrets."openldap/ro_password".path}:/run/secrets/ldap-ro-password:ro" + ]; + # host network: mDNS / Matter / direct LAN access all require this. + # No ports[] mapping needed — HA binds to 0.0.0.0:8123 on the host. + extraOptions = [ + "--network=host" + "--cap-add=NET_ADMIN" + "--cap-add=NET_RAW" + ]; + }; + + # ------------------------------------------------------------------------- + # Systemd service: ordering + first-boot config init + # ------------------------------------------------------------------------- + systemd.services."podman-home-assistant" = { + after = lib.mkAfter [ "mnt-data.mount" ]; + requires = lib.mkAfter [ "mnt-data.mount" ]; + serviceConfig.ExecStartPre = [ + (pkgs.writeShellScript "ha-init-config" '' + CFG="${dataDir}/home-assistant/config/configuration.yaml" + if [ ! -f "$CFG" ]; then + mkdir -p "$(dirname "$CFG")" + install -m 644 ${initialConfig} "$CFG" + fi + '') + ]; + }; + + # Open 8123 in the NixOS firewall for direct LAN access. + # The HA mobile app connects here directly; Caddy also proxies via this port. + networking.firewall.allowedTCPPorts = [ cfg.port ]; + + # ------------------------------------------------------------------------- + # Caddy virtual host — no forward_auth; HA handles its own sessions + # ------------------------------------------------------------------------- + homey.caddy.virtualHosts = [{ + subdomain = "home"; + port = cfg.port; + auth = false; + }]; + + # ------------------------------------------------------------------------- + # Authelia: bypass entirely for home.domain + # Priority 18 — blanket bypass range (10–19) + # ------------------------------------------------------------------------- + homey.authelia.accessControlRules = [{ + priority = 18; + domain = [ "home.${domain}" ]; + policy = "bypass"; + }]; + + # ------------------------------------------------------------------------- + # Storage directories + # ------------------------------------------------------------------------- + homey.storage.extraDirs = [ + { path = "home-assistant"; } + { path = "home-assistant/config"; mode = "0755"; } + ]; + + # ------------------------------------------------------------------------- + # Backup + # ------------------------------------------------------------------------- + homey.backup.extraPaths = [ "${dataDir}/home-assistant/config" ]; + # Uncomment to exclude the recorder SQLite DB if it grows too large: + # homey.backup.extraExcludePaths = [ + # "${dataDir}/home-assistant/config/home-assistant_v2.db" + # ]; + + # ------------------------------------------------------------------------- + # Uptime Kuma monitor + # ------------------------------------------------------------------------- + homey.monitoring.monitors = [{ + name = "Home Assistant"; + url = "https://home.${domain}"; + interval = 60; + }]; + }; +} diff --git a/modules/services/transmission.nix b/modules/services/transmission.nix index cb38b87..ade3b72 100644 --- a/modules/services/transmission.nix +++ b/modules/services/transmission.nix @@ -56,6 +56,7 @@ in "${dataDir}/media/tvshows:/downloads/tvshows" "${dataDir}/media/general:/downloads/general" "${dataDir}/media/complete:/downloads/complete" + "${dataDir}/media/incomplete:/downloads/incomplete" ]; extraOptions = [ "--network=homey" ]; @@ -87,8 +88,9 @@ in # Storage directories # ----------------------------------------------------------------------- homey.storage.extraDirs = [ - { path = "transmission"; } - { path = "transmission/config"; } + { path = "transmission"; user = "1000"; group = "1000"; mode = "0755"; } + { path = "transmission/config"; user = "1000"; group = "1000"; mode = "0755"; } + { path = "media/incomplete"; user = "1000"; group = "1000"; mode = "0755"; } ]; # ----------------------------------------------------------------------- diff --git a/modules/services/uptime-kuma.nix b/modules/services/uptime-kuma.nix index 20ec519..4d7c462 100644 --- a/modules/services/uptime-kuma.nix +++ b/modules/services/uptime-kuma.nix @@ -113,7 +113,7 @@ let from uptime_kuma_api import UptimeKumaApi, MonitorType - api = UptimeKumaApi(KUMA_URL) + api = UptimeKumaApi(KUMA_URL, timeout=30) # Initial admin setup via Socket.IO — idempotent (raises if already done, ignore it) try: diff --git a/modules/storage.nix b/modules/storage.nix index 59ab83d..d11e9c1 100644 --- a/modules/storage.nix +++ b/modules/storage.nix @@ -102,10 +102,10 @@ in "d ${cfg.mountPoint} 0755 root root -" # Shared media directories used by both Jellyfin and Transmission. "d ${cfg.mountPoint}/media 0755 root root -" - "d ${cfg.mountPoint}/media/movies 0755 root root -" - "d ${cfg.mountPoint}/media/tvshows 0755 root root -" - "d ${cfg.mountPoint}/media/general 0755 root root -" - "d ${cfg.mountPoint}/media/complete 0755 root root -" + "d ${cfg.mountPoint}/media/movies 0755 1000 1000 -" + "d ${cfg.mountPoint}/media/tvshows 0755 1000 1000 -" + "d ${cfg.mountPoint}/media/general 0755 1000 1000 -" + "d ${cfg.mountPoint}/media/complete 0755 1000 1000 -" "d ${cfg.mountPoint}/restic-cache 0700 root root -" ] ++ (map (d: "d ${cfg.mountPoint}/${d.path} ${d.mode} ${d.user} ${d.group} -") diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml index 9c505c0..b7b253c 100644 --- a/secrets/secrets.yaml +++ b/secrets/secrets.yaml @@ -54,8 +54,8 @@ sops: b0RlaHcyNUJSUHRXRlhEUG1NWXBlb28KS5RrYXQL1Joc5I3IJGsa+mfWKtvtpVvc w9uLPKkQDROeS8dBqWQf4YVqtIDHU81X+P5i0dVr7roDNzhN+3RLXQ== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-07-29T09:55:26Z" - mac: ENC[AES256_GCM,data:VgiX5YeFYFcanGzBRbnwJGcS1JcguoKU2MaBZOG/m9xI7lIO8BAJU9mwuGOWq2vRCq5lQIiWVlDZmHksyBntrcRlQGF4ByKZO5F76QlXJ4u6DynJ1mIl2CWt6+VIfJolaIoYf1TL3B1dDu9xThSDd3qakm0NU52NqiZoeUv9QNc=,iv:dsvJlzuoVH2xsx3AkIM7sy0GuXqvOe02/xbOVC5O/FU=,tag:YQEUsD4zDWFYBZGcuArKPg==,type:str] + lastmodified: "2026-09-15T19:40:07Z" + mac: ENC[AES256_GCM,data:BXt3np3LPBzMmGDPPPbIEeNGIckC1IWDDo/ha8e5wTyBHAw9o8RN2Q0amMo/tNpH3qeQbAy2y9/ZHNnzU4I/iG2mTI7go/tlR2g3tFioBcL0Fb/1EBOH6P1TMaVNIukuLzTs1AJ/66lrd7eOI+ftUIZOe6GAbjdEBSHU12zmqEg=,iv:TE9/uYu7CxGM4WOxMjMFQvCjDATjowifksEgiF67BdE=,tag:Taqp2p2muqkMEQ81RIeAig==,type:str] pgp: - created_at: "2026-07-29T09:53:25Z" enc: |-