diff --git a/configs/nixos/AGENTS.md b/configs/nixos/AGENTS.md index c127e3a..198aa9b 100644 --- a/configs/nixos/AGENTS.md +++ b/configs/nixos/AGENTS.md @@ -55,5 +55,7 @@ - On Pikachu, `nix copy --to ssh://pikachu` accepted a small `nix store add-file` probe, but rejected locally built system paths that lacked trusted signatures. Use a sudo `nix-store --import` path for unsigned deploy closures. +- Public DNS entries are not managed through the NixOS configuration. + Do not describe NixOS route choices as public-DNS management. - Do not hand-author `hardware-configuration.nix` for a host that has not been generated on that machine. Generate it on the target system or live ISO, then stage it before evaluating the flake. diff --git a/configs/nixos/guests/actualbudget.nix b/configs/nixos/guests/actualbudget.nix index 4310651..10a973a 100644 --- a/configs/nixos/guests/actualbudget.nix +++ b/configs/nixos/guests/actualbudget.nix @@ -25,6 +25,14 @@ in } ]; + registry.homepage.${config.networking.hostName}.actualbudget = { + tab = "Apps"; + section = "Other"; + name = "Actual Budget"; + description = "Finance"; + icon = "actual-budget.png"; + }; + guests.actualbudget = { interior = { modules.services.actualbudget = { diff --git a/configs/nixos/guests/copyparty.nix b/configs/nixos/guests/copyparty.nix index 17de0f9..df3ceb5 100644 --- a/configs/nixos/guests/copyparty.nix +++ b/configs/nixos/guests/copyparty.nix @@ -90,6 +90,15 @@ in } ]; + registry.homepage.${config.networking.hostName}.copyparty = { + tab = "Apps"; + section = "Files"; + name = "Copyparty"; + description = "Files on ${config.networking.hostName}"; + icon = "copyparty.svg"; + href = lib.mkIf cfg.reverseProxy.enable "https://${cfg.reverseProxy.route.domain}/?h"; + }; + guests.copyparty = { interior.modules.services.copyparty = { enable = true; diff --git a/configs/nixos/guests/homepage.nix b/configs/nixos/guests/homepage.nix new file mode 100644 index 0000000..da3e0b8 --- /dev/null +++ b/configs/nixos/guests/homepage.nix @@ -0,0 +1,46 @@ +{ + config, + lib, + ... +}: +let + cfg = config.guests.homepage; + environmentFile = "/run/secrets/homepage.env"; +in +{ + config = lib.mkIf cfg.enable { + guests.homepage = { + interior.modules.services.homepage = { + enable = true; + hostName = config.networking.hostName; + inherit environmentFile; + }; + + mounts.${environmentFile} = { + file = config.sops.templates."homepage.env".path; + readOnly = true; + }; + + limits = { + memory = lib.mkDefault "1G"; + cpu = lib.mkDefault "100%"; + tasksMax = lib.mkDefault 256; + }; + }; + + sops.secrets = { + "services/metadata/latitude" = { }; + "services/metadata/longitude" = { }; + }; + + sops.templates."homepage.env" = { + owner = "root"; + group = "storage"; + mode = "0440"; + content = '' + HOMEPAGE_VAR_LATITUDE=${config.sops.placeholder."services/metadata/latitude"} + HOMEPAGE_VAR_LONGITUDE=${config.sops.placeholder."services/metadata/longitude"} + ''; + }; + }; +} diff --git a/configs/nixos/guests/navidrome.nix b/configs/nixos/guests/navidrome.nix index fa66667..5f15023 100644 --- a/configs/nixos/guests/navidrome.nix +++ b/configs/nixos/guests/navidrome.nix @@ -48,6 +48,14 @@ in } ]; + registry.homepage.${config.networking.hostName}.navidrome = { + tab = "Apps"; + section = "Media"; + name = "Navidrome"; + description = "Music"; + icon = "navidrome.png"; + }; + guests.navidrome = { interior.modules.services.navidrome.enable = true; diff --git a/configs/nixos/hosts/pikachu/default.nix b/configs/nixos/hosts/pikachu/default.nix index 6ed01b0..47ff374 100644 --- a/configs/nixos/hosts/pikachu/default.nix +++ b/configs/nixos/hosts/pikachu/default.nix @@ -144,6 +144,19 @@ address = "10.23.20.109"; }; + guests.homepage = { + enable = true; + vlan = 20; + address = "10.23.20.110"; + reverseProxy = { + enable = true; + route = { + domain = "home"; + http.port = 8082; + }; + }; + }; + guests.copyparty = { enable = true; vlan = 20; diff --git a/configs/nixos/modules/services/homepage.nix b/configs/nixos/modules/services/homepage.nix new file mode 100644 index 0000000..6386ebc --- /dev/null +++ b/configs/nixos/modules/services/homepage.nix @@ -0,0 +1,219 @@ +{ + config, + lib, + registry, + ... +}: +let + cfg = config.modules.services.homepage; + + mkRegistryPath = registryName: host: entry: "registry.${registryName}.${host}.${entry}"; + + homepageEntries = lib.concatLists ( + lib.mapAttrsToList ( + host: entries: + lib.mapAttrsToList (entry: homepage: { + inherit host entry homepage; + composite = registry.join host entry; + }) entries + ) registry.homepage + ); + + mkHref = item: + if item.homepage.href != null then + item.homepage.href + else if (item.composite.route or null) != null then + if item.composite.route.port != null then + "https://${item.composite.route.domain}" + else if item.composite.route.http.port != null then + "http://${item.composite.route.domain}" + else + throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching TCP route entry" + else + throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching route entry"; + + mkServiceAttrs = item: + lib.filterAttrs (_: value: value != null) { + href = mkHref item; + inherit (item.homepage) description icon; + }; + + sortedEntries = lib.sort (left: right: left.homepage.name < right.homepage.name) homepageEntries; + + insertEntry = acc: item: + acc + // { + ${item.homepage.tab} = (acc.${item.homepage.tab} or { }) + // { + ${item.homepage.section} = (acc.${item.homepage.tab}.${item.homepage.section} or [ ]) ++ [ + { ${item.homepage.name} = mkServiceAttrs item; } + ]; + }; + }; + + entriesByTab = lib.foldl' insertEntry { } sortedEntries; + + tabOrder = [ + "Apps" + "Download" + "System" + ]; + + orderedTabs = tabOrder ++ lib.filter (tab: !lib.elem tab tabOrder) (lib.attrNames entriesByTab); + + sectionOrderByTab = { + Apps = [ + "Media" + "Files" + "Other" + ]; + Download = [ + "Library Managers" + "Other Utilities" + "Download Clients" + ]; + System = [ + "Monitoring" + "Network" + "Machines" + "Docker" + ]; + }; + + orderedSections = tab: sections: + let + sectionOrder = sectionOrderByTab.${tab} or [ ]; + in + sectionOrder ++ lib.filter (section: !lib.elem section sectionOrder) (lib.attrNames sections); + + mkTab = tab: + let + sections = entriesByTab.${tab}; + in + { + ${tab} = map (section: { ${section} = sections.${section}; }) (orderedSections tab sections); + }; + + homepageServices = map mkTab (lib.filter (tab: lib.hasAttr tab entriesByTab) orderedTabs); + + selfEntry = registry.join cfg.hostName cfg.entryName; + selfAddress = selfEntry.address or null; + selfRoute = selfEntry.route or null; + + derivedAllowedHosts = lib.unique ( + lib.optional (selfRoute != null) selfRoute.domain + ++ lib.optionals (selfAddress != null) [ + selfAddress + "${selfAddress}:${toString cfg.listenPort}" + ] + ); +in +{ + options.modules.services.homepage = { + enable = lib.mkEnableOption "Homepage Dashboard"; + + listenPort = lib.mkOption { + type = lib.types.port; + default = 8082; + description = "Port for Homepage to bind to."; + }; + + hostName = lib.mkOption { + type = lib.types.str; + description = "Host registry key that owns the Homepage guest entry."; + }; + + entryName = lib.mkOption { + type = lib.types.str; + default = "homepage"; + description = "Registry entry name for this Homepage service."; + }; + + allowedHosts = lib.mkOption { + type = lib.types.nullOr (lib.types.listOf lib.types.str); + default = null; + description = "Host headers accepted by Homepage. Defaults to matching address and route registry entries."; + }; + + environmentFile = lib.mkOption { + type = lib.types.path; + default = "/run/secrets/homepage.env"; + description = "Environment file passed to Homepage."; + }; + }; + + config = lib.mkIf cfg.enable { + services.homepage-dashboard = { + enable = true; + openFirewall = true; + inherit (cfg) listenPort; + environmentFiles = [ cfg.environmentFile ]; + allowedHosts = lib.concatStringsSep "," (if cfg.allowedHosts == null then derivedAllowedHosts else cfg.allowedHosts); + services = homepageServices; + widgets = [ + { + greeting = { + text_size = "xl"; + text = "tracion"; + }; + } + { + openmeteo = { + units = "metric"; + latitude = "{{HOMEPAGE_VAR_LATITUDE}}"; + longitude = "{{HOMEPAGE_VAR_LONGITUDE}}"; + cache = 5; + }; + } + { + datetime = { + text_size = "xl"; + locale = "en"; + format = { + dateStyle = "full"; + timeStyle = "medium"; + hour12 = false; + }; + }; + } + ]; + settings = { + title = "Tracion"; + theme = "dark"; + color = "slate"; + showStats = false; + hideVersion = true; + bookmarksStyle = "icons"; + useEqualHeights = true; + layout = { + Apps = { + style = "row"; + columns = 1; + tab = "Apps"; + Media = { + style = "row"; + columns = 2; + }; + Files = { + style = "row"; + columns = 2; + }; + Other = { + style = "row"; + columns = 3; + iconsOnly = true; + }; + }; + Download = { + style = "row"; + columns = 1; + tab = "Download"; + }; + System = { + tab = "System"; + }; + }; + }; + }; + }; +} diff --git a/configs/nixos/modules/services/reverse-proxy.nix b/configs/nixos/modules/services/reverse-proxy.nix index 916ca29..3ca985a 100644 --- a/configs/nixos/modules/services/reverse-proxy.nix +++ b/configs/nixos/modules/services/reverse-proxy.nix @@ -9,33 +9,47 @@ let mkRegistryPath = registryName: host: entry: "registry.${registryName}.${host}.${entry}"; - mkBackend = address: route: "http://${address}:${toString route.port}"; + mkBackend = address: port: "http://${address}:${toString port}"; - mkVirtualHost = host: entry: + mkVirtualHost = host: entry: mode: let compositeEntry = registry.join host entry; route = compositeEntry.route; address = compositeEntry.address or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}"); + isHttp = mode == "http"; + port = if isHttp then route.http.port else route.port; in { inherit host entry; + registryName = "route"; name = route.domain; value = { - forceSSL = true; - enableACME = true; - locations."/".proxyPass = mkBackend address route; + forceSSL = !isHttp; + enableACME = !isHttp; + locations."/".proxyPass = mkBackend address port; }; }; - generatedVirtualHostList = lib.concatLists ( + generatedHttpsVirtualHostList = lib.concatLists ( lib.mapAttrsToList ( host: routes: - lib.mapAttrsToList (entry: _route: mkVirtualHost host entry) ( + lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "https") ( lib.filterAttrs (_entry: route: route.port != null) routes ) ) registry.route ); + generatedHttpVirtualHostList = lib.concatLists ( + lib.mapAttrsToList ( + host: routes: + lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "http") ( + lib.filterAttrs (_entry: route: route.http.port != null) routes + ) + ) registry.route + ); + + generatedVirtualHostList = generatedHttpsVirtualHostList ++ generatedHttpVirtualHostList; + virtualHostsByDomain = lib.foldl' ( acc: virtualHost: acc @@ -49,7 +63,7 @@ let domain: virtualHosts: if lib.length virtualHosts > 1 then [ - "duplicate route domain ${domain} in ${lib.concatMapStringsSep " and " (virtualHost: mkRegistryPath "route" virtualHost.host virtualHost.entry) virtualHosts}" + "duplicate route domain ${domain} in ${lib.concatMapStringsSep " and " (virtualHost: mkRegistryPath virtualHost.registryName virtualHost.host virtualHost.entry) virtualHosts}" ] else [ ] diff --git a/configs/nixos/registry/homepage.nix b/configs/nixos/registry/homepage.nix new file mode 100644 index 0000000..78926cb --- /dev/null +++ b/configs/nixos/registry/homepage.nix @@ -0,0 +1,45 @@ +{ lib, ... }: +{ + type = lib.types.submodule { + options = { + tab = lib.mkOption { + type = lib.types.str; + example = "Apps"; + description = "Homepage tab that contains this entry."; + }; + + section = lib.mkOption { + type = lib.types.str; + example = "Media"; + description = "Homepage section that contains this entry."; + }; + + name = lib.mkOption { + type = lib.types.str; + example = "Navidrome"; + description = "Homepage display name for this entry."; + }; + + description = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "Music"; + description = "Homepage description for this entry."; + }; + + icon = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "navidrome.png"; + description = "Homepage icon for this entry."; + }; + + href = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "https://music.alexion.dev"; + description = "Homepage link override for this entry."; + }; + }; + }; +} diff --git a/configs/nixos/registry/route.nix b/configs/nixos/registry/route.nix index b4ff6dc..50d728c 100644 --- a/configs/nixos/registry/route.nix +++ b/configs/nixos/registry/route.nix @@ -12,6 +12,10 @@ ); message = "Route UDP ports must be unique."; } + { + assertion = entries: lib.all (entry: entry.value.port == null || entry.value.http.port == null) entries; + message = "Route entries must not set both port and http.port."; + } ]; type = lib.types.submodule { @@ -19,14 +23,21 @@ domain = lib.mkOption { type = lib.types.str; example = "files.alexion.dev"; - description = "Public domain served by this route entry."; + description = "Domain served by this route entry."; }; port = lib.mkOption { type = lib.types.nullOr lib.types.port; default = null; example = 3923; - description = "Backend TCP port served by the matching address registry entry."; + description = "HTTPS backend TCP port served by the matching address registry entry."; + }; + + http.port = lib.mkOption { + type = lib.types.nullOr lib.types.port; + default = null; + example = 8082; + description = "HTTP backend TCP port served by the matching address registry entry."; }; udp.ports = lib.mkOption { diff --git a/configs/nixos/secrets/shared.yaml b/configs/nixos/secrets/shared.yaml index 4e8dc80..b2f5bf0 100644 --- a/configs/nixos/secrets/shared.yaml +++ b/configs/nixos/secrets/shared.yaml @@ -1,5 +1,8 @@ alexion-password: ENC[AES256_GCM,data:EGTsJvW4/zA+FfhUtZD4E6HIsI0JQ3LLL511XjI9hDl3NaHNe4g+wWy7P5DQnW29G5W4TaPZIetUcV7OO+1btuRxTuwbWVOulA==,iv:Ihf1kz5DFtvy1bUnfQGAU0/BQrwUvqKwqnK1JbKnWHU=,tag:K0N/6H/IWesNMxzXhQQO/w==,type:str] services: + metadata: + latitude: ENC[AES256_GCM,data:AB6HQjDVghSv,iv:lDTrdg5Bc0xCygOLPlDHrm/eFoHW9rmZ35CEcVXqusg=,tag:BOTXwWYNL7erD9aH6x3wGQ==,type:str] + longitude: ENC[AES256_GCM,data:+iOmY92f7QQ+Ew==,iv:7fSpaHTSB1l9EbhRR+GrErmuZxfTxeEKy5rEWW6Th0I=,tag:zFtou1itlH4Iec8obgXV6g==,type:str] copyparty: users: alexion: @@ -46,7 +49,7 @@ sops: 28uWUpKzzgErq1aEbKNgh4cU5TH2RlAHjtzRC3ARUu9hlW6lnB4pSw== -----END AGE ENCRYPTED FILE----- recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh - lastmodified: "2026-09-10T18:32:33Z" - mac: ENC[AES256_GCM,data:g3xIylSvyn5Z0JBA8siFmMrZxoM3a3U5c+f9qFmb3zXDLF03/feCUlvG2jjB7P2N9phIGSIEOPk1dzZDBMwv6D2hK6MppupV+MIe9diIt0gGARIOJNphrxLDxMHtwcWg6Z20D6tbPl0ECtLI0K9GOtwvI0OXeyYzu5VgFaFlZHw=,iv:TAUVJRXFTKNVA0ORMD7x56WVGjG+zimj3iqEn7urh64=,tag:SeOOV9ZUY4x5Ns0A16F1uw==,type:str] + lastmodified: "2026-09-16T00:35:15Z" + mac: ENC[AES256_GCM,data:VdYFSoulJMU0QjfzdBUVnXHruLbMkyYA4mirYwl2y4RijyfSBjtgv+bn+p6PiSQSAkpe7kQ8qJhfZVVm0MtgJvaRm3r3fj2lMUH930UhyMCK2F8uypBychwZJvn+Jo3m1rBOJTicd0qQvSA9heVBxQ+TnVGCwhnQWWFj/GsIV5s=,iv:kr7LtcDdT7VVV78qIBtFZYLSoq2x0AOltlKjCUMxPuM=,tag:+MXD2DbI34gv8gpaDhobmg==,type:str] unencrypted_suffix: _unencrypted version: 3.13.3 diff --git a/docs/TODO.md b/docs/TODO.md index d096a55..3a01d21 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -50,10 +50,9 @@ - immich - romm - gitea -- vikunja (?) - nginxproxymanager (gradual migration to nginx guest) - adguard -- homepage + - manage DNS rewrites for local service names such as `home` ## raichu proxmox -> nixos - dwn torrent (vpn)