From 3d4505275f30b849df13b9e4b2583f1aa0d169fd Mon Sep 17 00:00:00 2001 From: johnspade Date: Wed, 18 Jun 2025 02:46:27 +0200 Subject: [PATCH 1/6] feat(extractors/oci-container): Add OCI container extractor --- examples/oci-containers/flake.nix | 70 +++ nixos/builtin-service-defs.nix | 813 +++++++++++++++++++++++++++ nixos/extractors/oci-container.nix | 160 ++++++ nixos/extractors/services.nix | 848 +++-------------------------- nixos/module.nix | 3 + options/services-registry.nix | 101 ++++ options/services.nix | 42 +- topology/default.nix | 1 + topology/renderers/svg/default.nix | 125 +++-- 9 files changed, 1311 insertions(+), 852 deletions(-) create mode 100644 examples/oci-containers/flake.nix create mode 100644 nixos/builtin-service-defs.nix create mode 100644 nixos/extractors/oci-container.nix create mode 100644 options/services-registry.nix diff --git a/examples/oci-containers/flake.nix b/examples/oci-containers/flake.nix new file mode 100644 index 0000000..45ab118 --- /dev/null +++ b/examples/oci-containers/flake.nix @@ -0,0 +1,70 @@ +{ + inputs = { + flake-utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + nix-topology.url = "github:oddlama/nix-topology"; + nix-topology.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = { + self, + nixpkgs, + nix-topology, + flake-utils, + ... + }: + { + nixosConfigurations.host1 = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + ({config, ...}: { + networking.hostName = "host1"; + + # Network interfaces from systemd are detected automatically: + systemd.network.enable = true; + systemd.network.networks.eth0 = { + matchConfig.Name = "eth0"; + }; + + # This node hosts a Jellyfin container + virtualisation.oci-containers.containers.jellyfin = { + image = "lscr.io/linuxserver/jellyfin:10.10.3"; + labels = { + "traefik.http.routers.jellyfin.rule" = "Host(`jellyfin.example.com`)"; + }; + }; + + # Use a built-in function to extract the host information from the container labels + topology.extractors.oci-container.infoFn = config.topology.extractors.oci-container.lib.traefikHostInfoFn; + + # Define a custom details function to extract and show additional information + topology.extractors.oci-container.detailsFn = c: {image.text = c.image;}; + }) + nix-topology.nixosModules.default + ]; + }; + } + // flake-utils.lib.eachDefaultSystem (system: rec { + pkgs = import nixpkgs { + inherit system; + overlays = [nix-topology.overlays.default]; + }; + + # This is the global topology module. + topology = import nix-topology { + inherit pkgs; + modules = [ + ({config, ...}: let + inherit (config.lib.topology) mkInternet mkConnection; + in { + inherit (self) nixosConfigurations; + + # Add a node for the internet + nodes.internet = mkInternet { + connections = mkConnection "host1" "eth0"; + }; + }) + ]; + }; + }); +} diff --git a/nixos/builtin-service-defs.nix b/nixos/builtin-service-defs.nix new file mode 100644 index 0000000..8d7aaed --- /dev/null +++ b/nixos/builtin-service-defs.nix @@ -0,0 +1,813 @@ +{lib}: let + inherit + (lib) + attrNames + concatLines + concatStringsSep + elemAt + fail2ban + filter + filterAttrs + flatten + flip + forEach + genAttrs + hasPrefix + head + imap0 + length + listToAttrs + map + mapAttrsToList + mkIf + mkMerge + optional + optionalAttrs + optionalString + remove + removePrefix + removeSuffix + replaceStrings + splitString + tail + ; +in { + adguardhome = { + name = "AdGuard Home"; + icon = "services.adguardhome"; + nixos = { + path = ["services" "adguardhome"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: let + address = cfg.host or null; + port = cfg.port or null; + in + mkIf (address != null && port != null) {listen = {text = "${address}:${toString port}";};}; + }; + oci = { + repos = ["adguard/adguardhome"]; + }; + }; + anki = { + name = "Anki"; + icon = "services.anki"; + nixos = { + path = ["services" "anki-sync-server"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.address}:${toString cfg.port}";};}; + }; + }; + alloy = { + name = "Alloy"; + icon = "services.alloy"; + nixos = { + path = ["services" "alloy"]; + enabled = cfg: cfg.enable or false; + detailsFn = _: {}; + }; + oci = { + repos = ["grafana/alloy"]; + }; + }; + atuin = { + name = "Atuin"; + icon = "services.atuin"; + nixos = { + path = ["services" "atuin"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.host}:${toString cfg.port}";};}; + }; + oci = { + repos = ["atuinsh/atuin"]; + }; + }; + authelia = { + name = "Authelia"; + icon = "services.authelia"; + nixos = { + path = ["services" "authelia"]; + enabled = cfg: (cfg.instances or {}) != {}; + detailsFn = cfg: let + instances = filterAttrs (_: v: v.enable) cfg.instances; + in + listToAttrs (mapAttrsToList (name: v: { + inherit name; + value = {text = "${v.settings.server.host}:${toString v.settings.server.port}";}; + }) + instances); + }; + oci = { + repos = ["authelia/authelia"]; + }; + }; + blocky = { + name = "Blocky"; + icon = "services.blocky"; + nixos = { + path = ["services" "blocky"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: + listToAttrs (mapAttrsToList (n: v: { + name = "listen.${n}"; + value = {text = toString v;}; + }) (cfg.settings.ports or {})); + }; + oci = { + repos = ["spx01/blocky" "0xerr0r/blocky"]; + }; + }; + caddy = { + name = "Caddy"; + icon = "services.caddy"; + nixos = { + path = ["services" "caddy"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: + genAttrs (mapAttrsToList (name: _: name) (cfg.virtualHosts or {})) (name: { + text = concatStringsSep " " (map (line: removePrefix "reverse_proxy " (removeSuffix " {" line)) (filter (line: hasPrefix "reverse_proxy " line) (splitString "\n" (cfg.virtualHosts.${name}.extraConfig or "")))); + }); + }; + oci = { + repos = ["caddy"]; + }; + }; + dnsmasq = { + name = "Dnsmasq"; + icon = "services.dnsmasq"; + nixos = { + path = ["services" "dnsmasq"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: let + addresses = cfg.settings.address or []; + in + listToAttrs (forEach (forEach addresses (x: splitString "/" (removePrefix "/" x))) (x: { + name = head x; + value = {text = head (tail x);}; + })); + }; + }; + esphome = { + name = "ESPHome"; + icon = "services.esphome"; + nixos = { + path = ["services" "esphome"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: { + listen = { + text = + if cfg.enableUnixSocket or false + then "/run/esphome/esphome.sock" + else "${cfg.address}:${toString cfg.port}"; + }; + }; + }; + oci = { + repos = ["esphome/esphome"]; + }; + }; + fail2ban = { + name = "Fail2Ban"; + icon = "services.fail2ban"; + nixos = { + path = ["services" "fail2ban"]; + enabled = cfg: cfg.enable or false; + detailsFn = _: {}; + }; + oci = { + repos = ["linuxserver/fail2ban" "crazymax/fail2ban"]; + }; + }; + firefox-syncserver = { + name = "Firefox Syncserver"; + icon = "services.firefox-syncserver"; + nixos = { + path = ["services" "firefox-syncserver"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: mkIf (cfg.singleNode.enable or false) cfg.singleNode.url; + detailsFn = cfg: { + listen = {text = "${cfg.settings.host or "127.0.0.1"}:${toString cfg.settings.port}";}; + }; + }; + }; + forgejo = { + name = "Forgejo"; + icon = "services.forgejo"; + nixos = { + path = ["services" "forgejo"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: mkIf (cfg.settings ? server.ROOT_URL) cfg.settings.server.ROOT_URL; + detailsFn = cfg: { + name = + if cfg.settings ? DEFAULT.APP_NAME + then "Forgejo (${cfg.settings.DEFAULT.APP_NAME})" + else "Forgejo"; + listen = mkIf ((cfg.settings.server.HTTP_ADDR or null) != null && (cfg.settings.server.HTTP_PORT or null) != null) {text = "${cfg.settings.server.HTTP_ADDR}:${toString cfg.settings.server.HTTP_PORT}";}; + }; + }; + oci = { + repos = ["forgejo/forgejo"]; + }; + }; + gitea = { + name = "gitea"; + icon = "services.gitea"; + nixos = { + path = ["services" "gitea"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: mkIf (cfg.settings ? server.ROOT_URL) cfg.settings.server.ROOT_URL; + detailsFn = cfg: { + name = + if cfg.settings ? DEFAULT.APP_NAME + then "gitea (${cfg.settings.DEFAULT.APP_NAME})" + else "gitea"; + listen = mkIf ((cfg.settings.server.HTTP_ADDR or null) != null && (cfg.settings.server.HTTP_PORT or null) != null) {text = "${cfg.settings.server.HTTP_ADDR}:${toString cfg.settings.server.HTTP_PORT}";}; + }; + }; + oci = { + repos = ["gitea" "gitea/gitea" "linuxserver/gitea"]; + }; + }; + glance = { + name = "Glance"; + icon = "services.glance"; + nixos = { + path = ["services" "glance"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.settings.server.host}:${toString cfg.settings.server.port}";};}; + }; + oci = { + repos = ["glanceapp/glance"]; + }; + }; + grafana = { + name = "Grafana"; + icon = "services.grafana"; + nixos = { + path = ["services" "grafana"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: cfg.settings.server.root_url; + detailsFn = cfg: { + listen = mkIf ((cfg.settings.server.http_addr or null) != null && (cfg.settings.server.http_port or null) != null) {text = "${cfg.settings.server.http_addr}:${toString cfg.settings.server.http_port}";}; + plugins = mkIf ((cfg.declarativePlugins or null) != null) {text = concatStringsSep "\n" (map (p: p.name) cfg.declarativePlugins);}; + }; + }; + oci = { + repos = ["grafana/grafana" "grafana/grafana-enterprise" "grafana/grafana-oss"]; + }; + }; + headscale = { + name = "Headscale"; + icon = "services.headscale"; + nixos = { + path = ["services" "headscale"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: cfg.settings.server_url; + detailsFn = cfg: { + listen = {text = "${cfg.address}:${toString cfg.port}";}; + }; + }; + oci = { + repos = ["headscale/headscale" "juanfont/headscale"]; + }; + }; + home-assistant = { + name = "Home Assistant"; + icon = "services.home-assistant"; + nixos = { + path = ["services" "home-assistant"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: { + listen = {text = toString (flip map cfg.config.http.server_host (addr: "${addr}:${toString cfg.config.http.server_port}"));}; + }; + }; + oci = { + repos = ["home-assistant/home-assistant" "linuxserver/homeassistant"]; + }; + }; + hydra = { + name = "Hydra"; + icon = "devices.nixos"; + nixos = { + path = ["services" "hydra"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: cfg.hydraURL; + detailsFn = cfg: { + listen = {text = "${cfg.listenHost}:${toString cfg.port}";}; + }; + }; + }; + i2p = { + name = "I2P"; + icon = "services.i2p"; + nixos = { + path = ["services" "i2pd"]; + enabled = cfg: cfg.enable or false; + detailsFn = _: {}; + }; + oci = { + repos = ["purplei2p/i2pd"]; + }; + }; + immich = { + name = "Immich"; + icon = "services.immich"; + nixos = { + path = ["services" "immich"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.host}:${toString cfg.port}";};}; + }; + oci = { + repos = ["immich-app/immich-server" "imagegenius/immich"]; + }; + }; + influxdb2 = { + name = "InfluxDB v2"; + icon = "services.influxdb2"; + nixos = { + path = ["services" "influxdb2"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: {listen = {text = cfg.settings.http-bind-address or "localhost:8086";};}; + }; + oci = { + repos = ["influxdb"]; + }; + }; + invidious = { + name = "Invidious"; + icon = "services.invidious"; + nixos = { + path = ["services" "invidious"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: {listen = {text = "${cfg.address}:${toString cfg.port}";};}; + }; + oci = { + repos = ["invidious/invidious"]; + }; + }; + jellyfin = { + name = "Jellyfin"; + icon = "services.jellyfin"; + nixos = { + path = ["services" "jellyfin"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: + listToAttrs (mapAttrsToList (n: v: { + name = "listen.${n}"; + value = {text = "0.0.0.0:${toString v}";}; + }) (optionalAttrs cfg.openFirewall { + http = 8096; + https = 8920; + service-discovery = 1900; + client-discovery = 7359; + })); + }; + oci = { + repos = ["jellyfin/jellyfin" "linuxserver/jellyfin"]; + }; + }; + jellyseerr = { + name = "Jellyseerr"; + icon = "services.jellyseerr"; + nixos = { + path = ["services" "jellyseerr"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "0.0.0.0:${toString cfg.port}";};}; + }; + oci = { + repos = ["fallenbagel/jellyseerr"]; + }; + }; + kanidm = { + name = "Kanidm"; + icon = "services.kanidm"; + nixos = { + path = ["services" "kanidm"]; + enabled = cfg: cfg.enableServer or false; + infoFn = cfg: cfg.serverSettings.origin; + detailsFn = cfg: { + listen = {text = cfg.serverSettings.bindaddress;}; + }; + }; + oci = { + repos = ["kanidm/server"]; + }; + }; + languagetool = { + name = "Languagetool"; + icon = "services.languagetool"; + nixos = { + path = ["services" "languagetool"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: {listen = {text = "127.0.0.1:${toString cfg.port}";};}; + }; + oci = { + repos = ["erikvl87/languagetool"]; + }; + }; + lidarr = { + name = "Lidarr"; + icon = "services.lidarr"; + nixos = { + path = ["services" "lidarr"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "0.0.0.0:8686";};}; + }; + oci = { + repos = ["linuxserver/lidarr" "hotio/lidarr"]; + }; + }; + loki = { + name = "Loki"; + icon = "services.loki"; + nixos = { + path = ["services" "loki"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf ((cfg.configuration.server.http_listen_address or null) != null && (cfg.configuration.server.http_listen_port or null) != null) {listen = {text = "${cfg.configuration.server.http_listen_address}:${toString cfg.configuration.server.http_listen_port}";};}; + }; + oci = { + repos = ["grafana/loki"]; + }; + }; + mastodon = { + name = "Mastodon"; + icon = "services.mastodon"; + nixos = { + path = ["services" "mastodon"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: "https://${cfg.localDomain}"; + detailsFn = _: {}; + }; + oci = { + repos = ["mastodon/mastodon" "linuxserver/mastodon" "tootsuite/mastodon"]; + }; + }; + mosquitto = { + name = "Mosquitto"; + icon = "services.mosquitto"; + nixos = { + path = ["services" "mosquitto"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: + listToAttrs (flip imap0 (flip map cfg.listeners (l: rec { + address = + if l.address == null + then "[::]" + else l.address; + listen = + if l.port == 0 + then address + else "${address}:${toString l.port}"; + })) (i: l: { + name = "listen[${toString i}]"; + value = {text = l.listen;}; + })); + }; + oci = { + repos = ["eclipse-mosquitto"]; + }; + }; + nextcloud = { + name = "Nextcloud"; + icon = "services.nextcloud"; + nixos = { + path = ["services" "nextcloud"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: "http${optionalString cfg.https "s"}://${cfg.hostName}"; + detailsFn = _: {}; + }; + oci = { + repos = ["nextcloud" "linuxserver/nextcloud"]; + }; + }; + nix-serve = { + name = "Nix Serve"; + icon = "devices.nixos"; + nixos = { + path = ["services" "nix-serve"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: {listen = {text = "${cfg.bindAddress}:${toString cfg.port}";};}; + }; + }; + nginx = { + name = "NGINX"; + icon = "services.nginx"; + nixos = { + path = ["services" "nginx"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: let + reverseProxies = flatten (flip mapAttrsToList (cfg.virtualHosts or {}) (server: vh: + flip mapAttrsToList (vh.locations or {}) (path: location: let + upstreamName = replaceStrings ["http://" "https://"] ["" ""] location.proxyPass; + passTo = + if (cfg.upstreams or {}) ? ${upstreamName} + then toString (attrNames cfg.upstreams.${upstreamName}.servers) + else location.proxyPass; + in + optional (path == "/" && location.proxyPass != null) {${server} = {text = passTo;};}))); + in + mkMerge reverseProxies; + }; + oci = { + repos = ["nginx"]; + }; + }; + oauth2-proxy = { + name = "OAuth2 Proxy"; + icon = "services.oauth2-proxy"; + nixos = { + path = ["services" "oauth2-proxy"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: cfg.httpAddress; + detailsFn = _: {}; + }; + oci = { + repos = ["oauth2-proxy/oauth2-proxy"]; + }; + }; + ollama = { + name = "Ollama"; + icon = "services.ollama"; + nixos = { + path = ["services" "ollama"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.host}:${toString cfg.port}";};}; + }; + oci = { + repos = ["ollama/ollama"]; + }; + }; + open-webui = { + name = "Open Webui"; + icon = "services.open-webui"; + nixos = { + path = ["services" "open-webui"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.host}:${toString cfg.port}";};}; + }; + oci = { + repos = ["open-webui/open-webui"]; + }; + }; + openssh = { + name = "OpenSSH"; + icon = "services.openssh"; + hidden = true; + nixos = { + path = ["services" "openssh"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: "port: ${concatStringsSep ", " (map toString cfg.ports)}"; + detailsFn = _: {}; + }; + }; + paperless-ngx = { + name = "Paperless-ngx"; + icon = "services.paperless-ngx"; + nixos = { + path = ["services" "paperless"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: mkIf ((cfg.settings.PAPERLESS_URL or null) != null) cfg.settings.PAPERLESS_URL; + detailsFn = cfg: { + listen = {text = "${cfg.address}:${toString cfg.port}";}; + }; + }; + oci = { + repos = ["paperless-ngx/paperless-ngx" "linuxserver/paperless-ngx"]; + }; + }; + prometheus = { + name = "Prometheus"; + icon = "services.prometheus"; + nixos = { + path = ["services" "prometheus"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: {listen = {text = "${cfg.listenAddress}:${toString cfg.port}";};}; + }; + oci = { + repos = ["prom/prometheus"]; + }; + }; + prowlarr = { + name = "Prowlarr"; + icon = "services.prowlarr"; + nixos = { + path = ["services" "prowlarr"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "0.0.0.0:9696";};}; + }; + oci = { + repos = ["linuxserver/prowlarr" "hotio/prowlarr"]; + }; + }; + radarr = { + name = "Radarr"; + icon = "services.radarr"; + nixos = { + path = ["services" "radarr"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "0.0.0.0:7878";};}; + }; + oci = { + repos = ["linuxserver/radarr" "hotio/radarr"]; + }; + }; + radicale = { + name = "Radicale"; + icon = "services.radicale"; + nixos = { + path = ["services" "radicale"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf (cfg.settings ? server.hosts) {listen = {text = toString cfg.settings.server.hosts;};}; + }; + oci = { + repos = ["kozea/radicale"]; + }; + }; + redlib = { + name = "Redlib"; + icon = "services.redlib"; + nixos = { + path = ["services" "redlib"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.address}:${toString cfg.port}";};}; + }; + oci = { + repos = ["redlib/redlib"]; + }; + }; + samba = { + name = "Samba"; + icon = "services.samba"; + nixos = { + path = ["services" "samba"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: let shares = remove "global" (attrNames cfg.settings); in mkIf (shares != []) {shares = {text = concatLines shares;};}; + }; + }; + searxng = { + name = "SearXNG"; + icon = "services.searxng"; + nixos = { + path = ["services" "searx"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: let + address = cfg.settings.server.bind_address or null; + port = cfg.settings.server.port or null; + in + mkIf (address != null && port != null) {listen = {text = "${address}:${toString port}";};}; + }; + oci = { + repos = ["searxng/searxng"]; + }; + }; + sonarr = { + name = "Sonarr"; + icon = "services.sonarr"; + nixos = { + path = ["services" "sonarr"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "0.0.0.0:8989";};}; + }; + oci = { + repos = ["linuxserver/sonarr" "hotio/sonarr"]; + }; + }; + static-web-server = { + name = "Static Web Server"; + icon = "devices.nixos"; + nixos = { + path = ["services" "static-web-server"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: { + listen = {text = toString cfg.listen;}; + root = {text = toString cfg.root;}; + }; + }; + oci = { + repos = ["static-web-server/static-web-server"]; + }; + }; + stirling-pdf = { + name = "Stirling-PDF"; + icon = "services.stirling-pdf"; + nixos = { + path = ["services" "stirling-pdf"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: let + address = cfg.environment.SERVER_HOST or null; + port = cfg.environment.SERVER_PORT or null; + in + mkIf (address != null && port != null) {listen = {text = "${address}:${toString port}";};}; + }; + oci = { + repos = ["stirling-tools/stirling-pdf"]; + }; + }; + tabby = { + name = "Tabby"; + icon = "services.tabby"; + nixos = { + path = ["services" "tabby"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: {listen = {text = "${cfg.host}:${toString cfg.port}";};}; + }; + oci = { + repos = ["tabbyml/tabby"]; + }; + }; + tor = { + name = "Tor"; + icon = "services.tor"; + nixos = { + path = ["services" "tor"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: mkIf (cfg.relay.enable or false) "Role: ${cfg.relay.role}"; + detailsFn = _: {}; + }; + }; + traefik = { + name = "Traefik"; + icon = "services.traefik"; + nixos = { + path = ["services" "traefik"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: let + dynCfg = cfg.dynamicConfigOptions; + in + mkIf (length (attrNames dynCfg) > 0) (let + formatOutput = mapAttrsToList (routerName: routerAttrs: let + getServiceUrl = serviceName: let + service = dynCfg.http.services.${toString serviceName}.loadBalancer.servers or []; + in + if length service > 0 + then (elemAt service 0).url + else "invalid service"; + passText = toString (getServiceUrl routerAttrs.service); + in {${toString routerName} = {text = passText;};}) + dynCfg.http.routers; + in + mkMerge formatOutput); + }; + oci = { + repos = ["traefik"]; + }; + }; + transmission = { + name = "Transmission"; + icon = "services.transmission"; + nixos = { + path = ["services" "transmission"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: let + address = cfg.settings.rpc-bind-address or null; + port = cfg.settings.rpc-port or null; + in + mkIf (address != null && port != null) {listen = {text = "${address}:${toString port}";};}; + }; + oci = { + repos = ["linuxserver/transmission"]; + }; + }; + vaultwarden = { + name = "Vaultwarden"; + icon = "services.vaultwarden"; + nixos = { + path = ["services" "vaultwarden"]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: let domain = cfg.config.domain or cfg.config.DOMAIN or null; in mkIf (domain != null) domain; + detailsFn = cfg: let + address = cfg.config.rocketAddress or cfg.config.ROCKET_ADDRESS or null; + port = cfg.config.rocketPort or cfg.config.ROCKET_PORT or null; + in { + listen = mkIf (address != null && port != null) {text = "${address}:${toString port}";}; + }; + }; + oci = { + repos = ["vaultwarden/server" "dani-garcia/vaultwarden"]; + }; + }; + zigbee2mqtt = { + name = "Zigbee2MQTT"; + icon = "services.zigbee2mqtt"; + nixos = { + path = ["services" "zigbee2mqtt"]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: let + mqttServer = cfg.settings.mqtt.server or null; + address = cfg.settings.frontend.host or null; + port = cfg.settings.frontend.port or null; + listen = + if address == null + then null + else if port == null + then address + else "${address}:${toString port}"; + in { + listen = mkIf (listen != null) {text = listen;}; + mqtt = mkIf (mqttServer != null) {text = mqttServer;}; + }; + }; + oci = { + repos = ["koenkk/zigbee2mqtt"]; + }; + }; +} diff --git a/nixos/extractors/oci-container.nix b/nixos/extractors/oci-container.nix new file mode 100644 index 0000000..d795386 --- /dev/null +++ b/nixos/extractors/oci-container.nix @@ -0,0 +1,160 @@ +{ + config, + lib, + options, + ... +}: let + inherit + (lib) + attrNames + attrValues + concatStringsSep + filter + flatten + head + hasSuffix + length + listToAttrs + mapAttrs + mapAttrsToList + mkDefault + mkEnableOption + mkIf + mkOption + optionalAttrs + seq + strings + tail + toLower + types + ; + + inherit (config.topology) serviceRegistry; + + containers = + mapAttrsToList (name: cfg: cfg // {_name = name;}) + config.virtualisation.oci-containers.containers; + + # Remove both registry AND tag/digest + canonical = img: let + noTag = head (strings.splitString ":" img); + noDigest = head (strings.splitString "@" noTag); + parts = strings.splitString "/" (strings.toLower noDigest); + + # If there are three or more components, + # the leftmost one is the registry. If there are only + # two, the first might be a registry or a namespace + # (e.g. "library/nginx"). + repoParts = + if (length parts) > 2 + then tail parts + else parts; + in + concatStringsSep "/" repoParts; + suffixMatch = img: repo: let + canon = canonical img; + repoLower = strings.toLower repo; + in + canon == repoLower || strings.hasSuffix ("/" + repoLower) canon; + matchesRepos = img: repos: builtins.any (suffixMatch img) repos; + + matchesById = mapAttrs (_: spec: let + oci = spec.oci or null; + in + if oci == null + then [] + else filter (c: matchesRepos c.image oci.repos) containers) + serviceRegistry; + + mkServiceFromHits = id: spec: hits: + mkIf (hits != []) (let + firstHit = head hits; + defaultInfoFn = config.topology.extractors.oci-container.infoFn; + serviceInfoFn = spec.oci.infoFn or null; + infoVal = + ( + if serviceInfoFn != null + then serviceInfoFn + else defaultInfoFn + ) + firstHit; + defaultDetailsFn = config.topology.extractors.oci-container.detailsFn; + serviceDetailsFn = spec.oci.detailsFn or (_: {}); + detailsVal = (defaultDetailsFn firstHit) // (serviceDetailsFn firstHit); + in + { + serviceId = id; + source = "oci"; + name = mkDefault spec.name; + info = mkDefault infoVal; + details = mkDefault detailsVal; + hidden = mkDefault (spec.hidden or false); + } + // optionalAttrs (spec ? icon) {icon = mkDefault spec.icon;}); + + traefikHostInfoFn = cfg: let + labels = cfg.labels or {}; + ruleKeys = + filter (k: hasSuffix ".rule" k && strings.hasPrefix "traefik.http.routers." k) + (attrNames labels); + hosts = concatStringsSep " " (map (k: let + m = builtins.match "Host\\(`([^`]*)`\\)" labels.${k}; + in + if m == null + then "" + else head m) + ruleKeys); + in + hosts; + + generated = listToAttrs (mapAttrsToList + (id: spec: { + name = id + "-oci"; + value = mkServiceFromHits id spec matchesById.${id}; + }) + serviceRegistry); + + matchedContainers = flatten (attrValues matchesById); + + unmatched = filter (c: !(builtins.elem c matchedContainers)) containers; + + _warnUnmatched = + if unmatched == [] + then null + else + builtins.trace + ("oci-container extractor: UNMATCHED containers → " + + (concatStringsSep ", " + (map (c: "${c._name} (${c.image})") unmatched))) + null; +in { + options.topology.extractors.oci-container = { + enable = mkEnableOption "OCI container extractor" // {default = true;}; + + infoFn = mkOption { + type = types.functionTo types.lines; + default = _: ""; + description = "Custom container info extractor function"; + }; + + detailsFn = mkOption { + type = types.functionTo types.raw; + default = _: {}; + description = "Custom additional detail sections extractor function"; + }; + + lib = mkOption { + type = types.attrs; + default = { + inherit traefikHostInfoFn; + imageDetailsFn = c: {image.text = c.image;}; + }; + readOnly = true; + description = "Helper functions that can be reused in infoFn/detailsFn"; + }; + }; + + config.topology.self = mkIf config.topology.extractors.oci-container.enable { + services = seq _warnUnmatched generated; + }; +} diff --git a/nixos/extractors/services.nix b/nixos/extractors/services.nix index cd9ee7f..a37e64f 100644 --- a/nixos/extractors/services.nix +++ b/nixos/extractors/services.nix @@ -1,802 +1,76 @@ -{ config, lib, ... }: -let - inherit (lib) - attrNames - genAttrs - concatLines - concatStringsSep - elemAt - filterAttrs - flatten - flip - forEach - head - imap0 - length +{ + config, + lib, + ... +}: let + inherit + (lib) + attrByPath listToAttrs - mapAttrs mapAttrsToList mkDefault mkEnableOption mkIf - mkMerge - optional - optionalString - optionalAttrs - replaceStrings - splitString - removePrefix - removeSuffix - hasPrefix - filter - tail ; -in -{ - options.topology.extractors.services.enable = mkEnableOption "topology service extractor" // { - default = true; - }; - config.topology.self.services = mkIf config.topology.extractors.services.enable ( - { - adguardhome = - let - address = config.services.adguardhome.host or null; - port = config.services.adguardhome.port or null; - in - mkIf config.services.adguardhome.enable { - name = "AdGuard Home"; - icon = "services.adguardhome"; - details.listen = mkIf (address != null && port != null) { text = "${address}:${toString port}"; }; - }; + inherit (config.topology) serviceRegistry; - anki = mkIf config.services.anki-sync-server.enable { - name = "Anki"; - icon = "services.anki"; - details.listen = mkIf config.services.anki-sync-server.openFirewall { - text = "${config.services.anki-sync-server.address}:${toString config.services.anki-sync-server.port}"; - }; - }; - - alloy = mkIf config.services.alloy.enable { - name = "Alloy"; - icon = "services.alloy"; - }; + # Generate restic backup services dynamically + resticBackupServices = mapAttrsToList ( + backupName: cfg: { + name = "restic-backup-" + backupName; + value = { + serviceId = "restic-backup-" + backupName; + source = "nixos"; - atuin = mkIf config.services.atuin.enable { - name = "Atuin"; - icon = "services.atuin"; - details.listen = mkIf config.services.atuin.openFirewall { - text = "${config.services.atuin.host}:${toString config.services.atuin.port}"; - }; - }; - - authelia = - let - instances = filterAttrs (_: v: v.enable) config.services.authelia.instances; - in - mkIf (instances != { }) { - name = "Authelia"; - icon = "services.authelia"; - details = listToAttrs ( - mapAttrsToList (name: v: { - inherit name; - value.text = "${lib.strings.removeSuffix "/" ( - lib.strings.removePrefix "tcp://" v.settings.server.address - )}"; - }) instances - ); - }; - - blocky = mkIf config.services.blocky.enable { - name = "Blocky"; - icon = "services.blocky"; - details = listToAttrs ( - mapAttrsToList (n: v: { - name = "listen.${n}"; - value.text = toString v; - }) config.services.blocky.settings.ports - ); - }; - - caddy = mkIf config.services.caddy.enable { - name = "Caddy"; - icon = "services.caddy"; - details = genAttrs (mapAttrsToList (name: _: name) config.services.caddy.virtualHosts) (name: { - text = - # Turn the (possibly multiple) strings in the list into a single string - concatStringsSep " " ( - # Remove the prefix and suffix, so only the list of hosts are left - builtins.map (line: removePrefix "reverse_proxy " (removeSuffix " {" line)) ( - # Filter out lines that don't start with reverse_proxy - filter (line: hasPrefix "reverse_proxy " line) ( - splitString "\n" config.services.caddy.virtualHosts.${name}.extraConfig - ) - ) - ); # Separate lines of string into list - }); - }; - - coder = mkIf config.services.coder.enable { - name = "Coder"; - icon = "services.coder"; - info = config.services.coder.accessUrl; - details.listen.text = config.services.coder.listenAddress; - }; + name = "Restic backup '${backupName}'"; + icon = "services.restic"; - code-server = mkIf config.services.code-server.enable { - name = "Code Server"; - icon = "services.code-server"; - details.listen.text = "${config.services.code-server.host}:${toString config.services.code-server.port}"; + info = mkIf (cfg.repository != null) cfg.repository; + details = {paths = {text = toString cfg.paths;};}; }; - - dnsmasq = mkIf config.services.dnsmasq.enable { - name = "Dnsmasq"; - icon = "services.dnsmasq"; - details = - let - addresses = config.services.dnsmasq.settings.address or [ ]; + } + ) (config.services.restic.backups or {}); + + mkServiceFor = id: spec: let + specNix = spec.nixos or null; + in + mkIf (specNix != null && specNix.path != []) ( + let + cfg = attrByPath specNix.path {} config; + enabled = specNix.enabled cfg; + in + mkIf enabled { + serviceId = id; + source = "nixos"; + + name = mkDefault spec.name; + icon = mkDefault spec.icon; + hidden = mkDefault (spec.hidden or false); + info = let + val = + if specNix ? infoFn + then specNix.infoFn cfg + else null; in - listToAttrs ( - forEach (forEach addresses (x: (splitString "/" (removePrefix "/" x)))) (x: { - name = head x; - value.text = head (tail x); - }) - ); - }; - - esphome = mkIf config.services.esphome.enable { - name = "ESPHome"; - icon = "services.esphome"; - details.listen.text = - if config.services.esphome.enableUnixSocket then - "/run/esphome/esphome.sock" - else - "${config.services.esphome.address}:${toString config.services.esphome.port}"; - }; - - fail2ban = mkIf config.services.fail2ban.enable { - name = "Fail2Ban"; - icon = "services.fail2ban"; - }; - - firefox-syncserver = mkIf config.services.firefox-syncserver.enable ( - { - name = "Firefox Syncserver"; - icon = "services.firefox-syncserver"; - details.listen.text = "${ - config.services.firefox-syncserversettings.host or "127.0.0.1" - }:${toString config.services.firefox-syncserver.settings.port}"; + if val == null + then "" + else val; + details = specNix.detailsFn cfg; } - // (optionalAttrs config.services.firefox-syncserver.singleNode.enable { - info = config.services.firefox-syncserver.singleNode.url; - }) - ); - - forgejo = - let - address = config.services.forgejo.settings.server.HTTP_ADDR or null; - port = config.services.forgejo.settings.server.HTTP_PORT or null; - in - mkIf config.services.forgejo.enable { - name = - if config.services.forgejo.settings ? DEFAULT.APP_NAME then - "Forgejo (${config.services.forgejo.settings.DEFAULT.APP_NAME})" - else - "Forgejo"; - icon = "services.forgejo"; - info = mkIf ( - config.services.forgejo.settings ? server.ROOT_URL - ) config.services.forgejo.settings.server.ROOT_URL; - details.listen = mkIf (address != null && port != null) { text = "${address}:${toString port}"; }; - }; - - gitea = - let - address = config.services.gitea.settings.server.HTTP_ADDR or null; - port = config.services.gitea.settings.server.HTTP_PORT or null; - in - mkIf config.services.gitea.enable { - name = - if config.services.gitea.settings ? DEFAULT.APP_NAME then - "gitea (${config.services.gitea.settings.DEFAULT.APP_NAME})" - else - "gitea"; - icon = "services.gitea"; - info = mkIf ( - config.services.gitea.settings ? server.ROOT_URL - ) config.services.gitea.settings.server.ROOT_URL; - details.listen = mkIf (address != null && port != null) { text = "${address}:${toString port}"; }; - }; - - glance = mkIf config.services.glance.enable { - name = "Glance"; - icon = "services.glance"; - details.listen = mkIf config.services.glance.openFirewall { - text = "${config.services.glance.settings.server.host}:${toString config.services.glance.settings.server.port}"; - }; - }; - - grafana = - let - address = config.services.grafana.settings.server.http_addr or null; - port = config.services.grafana.settings.server.http_port or null; - plugins = config.services.grafana.declarativePlugins; - in - mkIf config.services.grafana.enable { - name = "Grafana"; - icon = "services.grafana"; - info = config.services.grafana.settings.server.root_url; - details = { - listen = mkIf (address != null && port != null) { text = "${address}:${toString port}"; }; - plugins = mkIf (plugins != null) { - text = concatStringsSep "\n" (builtins.map (p: p.name) plugins); - }; - }; - }; - - harmonia = - mkIf (config.services.harmonia.enable || (config.services.harmonia-dev.cache.enable or false)) - { - name = "Harmonia"; - icon = "services.not-available"; - }; - - headscale = mkIf config.services.headscale.enable { - name = "Headscale"; - icon = "services.headscale"; - info = config.services.headscale.settings.server_url; - details = { - listen.text = "${config.services.headscale.address}:${toString config.services.headscale.port}"; - }; - }; - - hickory-dns = - let - hickorySettings = config.services.hickory-dns.settings; - concatWithPort = addr: "${addr}:${toString hickorySettings.listen_port}"; - in - mkIf config.services.hickory-dns.enable { - name = "Hickory DNS"; - icon = "services.hickory-dns"; - details = { - listen_ipv4.text = toString (map concatWithPort hickorySettings.listen_addrs_ipv4); - listen_ipv6.text = toString (map concatWithPort hickorySettings.listen_addrs_ipv6); - }; - }; - - home-assistant = mkIf config.services.home-assistant.enable { - name = "Home Assistant"; - icon = "services.home-assistant"; - details.listen.text = toString ( - flip map config.services.home-assistant.config.http.server_host ( - addr: "${addr}:${toString config.services.home-assistant.config.http.server_port}" - ) - ); - }; - - hydra = mkIf config.services.hydra.enable { - name = "Hydra"; - icon = "devices.nixos"; - info = config.services.hydra.hydraURL; - details.listen.text = "${config.services.hydra.listenHost}:${toString config.services.hydra.port}"; - }; - - i2p = mkIf config.services.i2pd.enable { - name = "I2P"; - icon = "services.i2p"; - }; - - immich = mkIf (config.services.immich.enable or false) { - name = "Immich"; - icon = "services.immich"; - details.listen = mkIf config.services.immich.openFirewall { - text = "${config.services.immich.host}:${toString config.services.immich.port}"; - }; - }; - - influxdb2 = mkIf config.services.influxdb2.enable { - name = "InfluxDB v2"; - icon = "services.influxdb2"; - details.listen.text = config.services.influxdb2.settings.http-bind-address or "localhost:8086"; - }; - - invidious = mkIf config.services.invidious.enable { - name = "Invidious"; - icon = "services.invidious"; - details.listen.text = "${config.services.invidious.address}:${toString config.services.invidious.port}"; - }; - - jellyfin = mkIf config.services.jellyfin.enable { - name = "Jellyfin"; - icon = "services.jellyfin"; - details = listToAttrs ( - mapAttrsToList - (n: v: { - name = "listen.${n}"; - value.text = "0.0.0.0:${toString v}"; - }) - ( - optionalAttrs config.services.jellyfin.openFirewall { - http = 8096; - https = 8920; - service-discovery = 1900; - client-discovery = 7359; - } - ) - ); - }; - - jellyseerr = mkIf config.services.jellyseerr.enable { - name = "Jellyseerr"; - icon = "services.jellyseerr"; - details.listen = mkIf config.services.jellyseerr.openFirewall { - text = "0.0.0.0:${toString config.services.jellyseerr.port}"; - }; - }; - - kanidm = mkIf config.services.kanidm.enableServer { - name = "Kanidm"; - icon = "services.kanidm"; - info = config.services.kanidm.serverSettings.origin; - details.listen.text = config.services.kanidm.serverSettings.bindaddress; - }; - - kavita = mkIf config.services.kavita.enable { - name = "Kavita"; - icon = "services.kavita"; - details.listen.text = "0.0.0.0:${toString config.services.kavita.settings.Port}"; - }; - - komga = mkIf config.services.komga.enable { - name = "Komga"; - icon = "services.komga"; - details.listen = mkIf config.services.komga.openFirewall { - text = "0.0.0.0:${toString config.services.komga.settings.server.port}"; - }; - }; - - karakeep = mkIf config.services.karakeep.enable { - name = "Karakeep"; - icon = "services.karakeep"; - }; - - languagetool = mkIf config.services.languagetool.enable { - name = "Languagetool"; - icon = "services.languagetool"; - details.listen.text = "127.0.0.1:${toString config.services.languagetool.port}"; - }; - - libretranslate = mkIf config.services.libretranslate.enable { - name = "Libretranslate"; - icon = "services.libretranslate"; - details.listen.text = "${config.services.libretranslate.host}:${toString config.services.libretranslate.port}"; - }; - - lidarr = mkIf config.services.lidarr.enable { - name = "Lidarr"; - icon = "services.lidarr"; - details.listen = mkIf config.services.lidarr.openFirewall { text = "0.0.0.0:8686"; }; - }; - - loki = - let - address = config.services.loki.configuration.server.http_listen_address or null; - port = config.services.loki.configuration.server.http_listen_port or null; - in - mkIf config.services.loki.enable { - name = "Loki"; - icon = "services.loki"; - details.listen = mkIf (address != null && port != null) { text = "${address}:${toString port}"; }; - }; - - mastodon = mkIf config.services.mastodon.enable { - name = "Mastodon"; - icon = "services.mastodon"; - info = "https://${config.services.mastodon.localDomain}"; - }; - - matrix-synapse = - let - address = config.services.matrix-synapse.settings.public_baseurl or null; - listener = builtins.head config.services.matrix-synapse.settings.listeners or null; - in - mkIf config.services.matrix-synapse.enable { - name = "Matrix (Synapse)"; - icon = "services.matrix"; - info = mkIf (address != null) address; - details.listen.text = mkIf ( - listener != null - ) "${builtins.head listener.bind_addresses}:${builtins.toString listener.port}"; - }; - - mautrix-signal = - let - address = config.services.mautrix-signal.settings.appservice.hostname or null; - port = config.services.mautrix-signal.settings.appservice.port or null; - in - mkIf config.services.mautrix-signal.enable { - name = "mautrix-signal"; - icon = "services.mautrix-signal"; - details.listen.text = mkIf (address != null && port != null) "${address}:${builtins.toString port}"; - }; - - mautrix-whatsapp = - let - address = config.services.mautrix-whatsapp.settings.appservice.hostname or null; - port = config.services.mautrix-whatsapp.settings.appservice.port or null; - in - mkIf config.services.mautrix-whatsapp.enable { - name = "mautrix-whatsapp"; - icon = "services.mautrix-whatsapp"; - details.listen.text = mkIf (address != null && port != null) "${address}:${builtins.toString port}"; - }; - - meilisearch = mkIf config.services.meilisearch.enable { - name = "Meilisearch"; - icon = "services.meilisearch"; - details.listen.text = "${config.services.meilisearch.listenAddress}:${toString config.services.meilisearch.listenPort}"; - }; - - mosquitto = - let - listeners = flip map config.services.mosquitto.listeners (l: rec { - address = if l.address == null then "[::]" else l.address; - listen = if l.port == 0 then address else "${address}:${toString l.port}"; - }); - in - mkIf config.services.mosquitto.enable { - name = "Mosquitto"; - icon = "services.mosquitto"; - details = listToAttrs ( - flip imap0 listeners ( - i: l: { - name = "listen[${toString i}]"; - value.text = l.listen; - } - ) - ); - }; - - mpd = mkIf config.services.mpd.enable { - name = "MPD"; - icon = "services.mpd"; - details.listen.text = "${ - if (config.services.mpd.settings.bind_to_address == "any") then - "0.0.0.0" - else - config.services.mpd.settings.bind_to_address - }:${toString config.services.mpd.settings.port}"; - }; - - navidrome = mkIf config.services.navidrome.enable { - name = "Navidrome"; - icon = "services.navidrome"; - info = mkIf ( - config.services.navidrome.settings ? BaseUrl - ) config.services.navidrome.settings.BaseUrl; - details.listen = mkIf config.services.navidrome.openFirewall { - text = "${config.services.navidrome.settings.Address}:${toString config.services.navidrome.settings.Port}"; - }; - }; - - nextcloud = mkIf config.services.nextcloud.enable { - name = "Nextcloud"; - icon = "services.nextcloud"; - info = "http${optionalString config.services.nextcloud.https "s"}://${config.services.nextcloud.hostName}"; - }; - - nix-serve = mkIf config.services.nix-serve.enable { - name = "Nix Serve"; - icon = "devices.nixos"; - details.listen.text = "${config.services.nix-serve.bindAddress}:${toString config.services.nix-serve.port}"; - }; - - nginx = mkIf config.services.nginx.enable { - name = "NGINX"; - icon = "services.nginx"; - details = - let - reverseProxies = flatten ( - flip mapAttrsToList config.services.nginx.virtualHosts ( - server: vh: - flip mapAttrsToList vh.locations ( - path: location: - let - upstreamName = replaceStrings [ "http://" "https://" ] [ "" "" ] location.proxyPass; - passTo = - if config.services.nginx.upstreams ? ${upstreamName} then - toString (attrNames config.services.nginx.upstreams.${upstreamName}.servers) - else - location.proxyPass; - in - optional (path == "/" && location.proxyPass != null) { - ${server} = { - text = passTo; - }; - } - ) - ) - ); - in - mkMerge reverseProxies; - }; - - oauth2-proxy = mkIf config.services.oauth2-proxy.enable { - name = "OAuth2 Proxy"; - icon = "services.oauth2-proxy"; - info = config.services.oauth2-proxy.httpAddress; - }; - - ollama = mkIf config.services.ollama.enable { - name = "Ollama"; - icon = "services.ollama"; - details.listen = mkIf config.services.ollama.openFirewall { - text = "${config.services.ollama.host}:${toString config.services.ollama.port}"; - }; - }; - - open-webui = mkIf config.services.open-webui.enable { - name = "Open Webui"; - icon = "services.open-webui"; - details.listen = mkIf config.services.open-webui.openFirewall { - text = "${config.services.open-webui.host}:${toString config.services.open-webui.port}"; - }; - }; - - openssh = mkIf config.services.openssh.enable { - hidden = mkDefault true; # Causes a lot of clutter - name = "OpenSSH"; - icon = "services.openssh"; - info = "port: ${concatStringsSep ", " (map toString config.services.openssh.ports)}"; - }; - - owncast = mkIf config.services.owncast.enable { - name = "Owncast"; - icon = "services.owncast"; - details.listen = mkIf config.services.owncast.openFirewall { - text = "${config.services.owncast.listen}:${toString config.services.owncast.port}"; - }; - }; - - paperless-ngx = - let - url = config.services.paperless.settings.PAPERLESS_URL or null; - in - mkIf config.services.paperless.enable { - name = "Paperless-ngx"; - icon = "services.paperless-ngx"; - info = mkIf (url != null) url; - details.listen.text = "${config.services.paperless.address}:${toString config.services.paperless.port}"; - }; - - plausible = mkIf config.services.plausible.enable { - name = "Plausible"; - icon = "services.plausible"; - info = config.services.plausible.server.baseUrl; - details.listen.text = "${config.services.plausible.server.listenAddress}:${toString config.services.plausible.server.port}"; - }; - - postgresql = mkIf config.services.postgresql.enable { - name = "PostgreSQL"; - icon = "services.postgresql"; - details.listen = mkIf config.services.postgresql.enableTCPIP { - text = "0.0.0.0:${toString config.services.postgresql.settings.port}"; - }; - }; - - prometheus = mkIf config.services.prometheus.enable { - name = "Prometheus"; - icon = "services.prometheus"; - details.listen.text = "${config.services.prometheus.listenAddress}:${toString config.services.prometheus.port}"; - }; - - prowlarr = mkIf config.services.prowlarr.enable { - name = "Prowlarr"; - icon = "services.prowlarr"; - details.listen = mkIf config.services.prowlarr.openFirewall { text = "0.0.0.0:9696"; }; - }; - - radarr = mkIf config.services.radarr.enable { - name = "Radarr"; - icon = "services.radarr"; - details.listen = mkIf config.services.radarr.openFirewall { text = "0.0.0.0:7878"; }; - }; - - radicale = mkIf config.services.radicale.enable { - name = "Radicale"; - icon = "services.radicale"; - details.listen = mkIf (config.services.radicale.settings ? server.hosts) { - text = toString config.services.radicale.settings.server.hosts; - }; - }; - - redlib = mkIf config.services.redlib.enable { - name = "Redlib"; - icon = "services.redlib"; - details.listen = mkIf config.services.redlib.openFirewall { - text = "${config.services.redlib.address}:${toString config.services.redlib.port}"; - }; - }; - - samba = mkIf config.services.samba.enable { - name = "Samba"; - icon = "services.samba"; - details.shares = - let - shares = lib.remove "global" (attrNames config.services.samba.settings); - in - mkIf (shares != [ ]) { text = concatLines shares; }; - }; - - scrutiny = - let - inherit (config.services.scrutiny.settings) web; - in - mkIf config.services.scrutiny.enable { - name = "Scrutiny"; - icon = "services.scrutiny"; - details.listen = lib.mkIf config.services.scrutiny.openFirewall { - text = "${web.listen.host}:${toString web.listen.port}"; - }; - }; - - searxng = - let - address = config.services.searx.settings.server.bind_address or null; - port = config.services.searx.settings.server.port or null; - in - mkIf config.services.searx.enable { - name = "SearXNG"; - icon = "services.searxng"; - details.listen = lib.mkIf (address != null && port != null) { - text = "${address}:${toString port}"; - }; - }; - - sonarr = mkIf config.services.sonarr.enable { - name = "Sonarr"; - icon = "services.sonarr"; - details.listen = mkIf config.services.sonarr.openFirewall { text = "0.0.0.0:8989"; }; - }; - - static-web-server = mkIf config.services.static-web-server.enable { - name = "Static Web Server"; - icon = "devices.nixos"; - details = { - listen.text = toString config.services.static-web-server.listen; - root.text = toString config.services.static-web-server.root; - }; - }; - - step-ca = mkIf config.services.step-ca.enable { - name = "step-ca"; - icon = "services.step-ca"; - details.listen = mkIf config.services.step-ca.openFirewall { - text = "${config.services.step-ca.address}:${toString config.services.step-ca.port}"; - }; - }; - - stirling-pdf = - let - address = config.services.stirling-pdf.environment.SERVER_HOST or null; - port = config.services.stirling-pdf.environment.SERVER_PORT or null; - in - mkIf config.services.stirling-pdf.enable { - name = "Stirling-PDF"; - icon = "services.stirling-pdf"; - details.listen = mkIf (address != null && port != null) { text = "${address}:${toString port}"; }; - }; - - tabby = mkIf config.services.tabby.enable { - name = "Tabby"; - icon = "services.tabby"; - details.listen.text = "${config.services.tabby.host}:${toString config.services.tabby.port}"; - }; - - tor = - let - _tor = config.services.tor; - in - mkIf _tor.enable { - name = "Tor"; - icon = "services.tor"; - info = mkIf _tor.relay.enable "Role: ${_tor.relay.role}"; - }; - - traefik = - let - dynCfg = config.services.traefik.dynamicConfigOptions; - in - mkIf config.services.traefik.enable { - name = "Traefik"; - icon = "services.traefik"; - details = mkIf (length (attrNames dynCfg) > 0) ( - let - formatOutput = mapAttrsToList ( - routerName: routerAttrs: - let - getServiceUrl = - serviceName: - let - service = dynCfg.http.services.${toString serviceName}.loadBalancer.servers or [ ]; - in - if length service > 0 then (elemAt service 0).url else "invalid service"; - passText = toString (getServiceUrl routerAttrs.service); - in - { - ${toString routerName} = { - text = passText; - }; - } - ) dynCfg.http.routers; - in - mkMerge formatOutput - ); - }; - - transmission = - let - address = config.services.transmission.settings.rpc-bind-address or null; - port = config.services.transmission.settings.rpc-port or null; - in - mkIf config.services.transmission.enable { - name = "Transmission"; - icon = "services.transmission"; - details.listen = mkIf (address != null && port != null) { text = "${address}:${toString port}"; }; - }; - - vaultwarden = - let - domain = - config.services.vaultwarden.config.domain or config.services.vaultwarden.config.DOMAIN or null; - address = - config.services.vaultwarden.config.rocketAddress - or config.services.vaultwarden.config.ROCKET_ADDRESS or null; - port = - config.services.vaultwarden.config.rocketPort or config.services.vaultwarden.config.ROCKET_PORT - or null; - in - mkIf config.services.vaultwarden.enable { - name = "Vaultwarden"; - icon = "services.vaultwarden"; - info = mkIf (domain != null) domain; - details.listen = mkIf (address != null && port != null) { text = "${address}:${toString port}"; }; - }; - - zigbee2mqtt = - let - mqttServer = config.services.zigbee2mqtt.settings.mqtt.server or null; - address = config.services.zigbee2mqtt.settings.frontend.host or null; - port = config.services.zigbee2mqtt.settings.frontend.port or null; - listen = - if address == null then - null - else if port == null then - address - else - "${address}:${toString port}"; - in - mkIf config.services.zigbee2mqtt.enable { - name = "Zigbee2MQTT"; - icon = "services.zigbee2mqtt"; - details.listen = mkIf (listen != null) { text = listen; }; - details.mqtt = mkIf (mqttServer != null) { text = mqttServer; }; - }; - - zipline = mkIf config.services.zipline.enable { - name = "Zipline"; - icon = "services.zipline"; - details.listen.text = "${config.services.zipline.settings.CORE_HOSTNAME}:${toString config.services.zipline.settings.CORE_PORT}"; - }; - } - // flip mapAttrs config.services.restic.backups ( - backupName: cfg: { - name = "Restic backup '${backupName}'"; - icon = "services.restic"; - info = mkIf (cfg.repository != null) cfg.repository; - details.paths.text = toString cfg.paths; - } - ) + ); + + generated = listToAttrs ( + (mapAttrsToList (id: spec: { + name = id; + value = mkServiceFor id spec; + }) + serviceRegistry) + ++ resticBackupServices ); +in { + options.topology.extractors.services.enable = mkEnableOption "topology service extractor" // {default = true;}; + + config.topology.self.services = + mkIf config.topology.extractors.services.enable generated; } diff --git a/nixos/module.nix b/nixos/module.nix index bf36dc6..8936ead 100644 --- a/nixos/module.nix +++ b/nixos/module.nix @@ -21,7 +21,9 @@ let "nodes" "networks" "icons" + "serviceRegistry" ]; + serviceDefs = import ./builtin-service-defs.nix { inherit lib; }; in { imports = [ @@ -44,6 +46,7 @@ in # The config should only be applied on the toplevel topology module, # not for each nixos node. config = { }; + config.topology.serviceRegistry = serviceDefs; } ) ); diff --git a/options/services-registry.nix b/options/services-registry.nix new file mode 100644 index 0000000..d1af4a1 --- /dev/null +++ b/options/services-registry.nix @@ -0,0 +1,101 @@ +f: {lib, ...}: let + inherit + (lib) + lists + mkOption + mkDefault + types + ; + serviceDefs = import ./builtin-service-defs.nix {inherit lib;}; + serviceSubmodule = {name, ...}: { + options = { + serviceId = mkOption { + type = types.str; + default = name; + description = "The unique identifier for this service. Defaults to the attribute name."; + }; + + name = mkOption { + type = types.str; + description = "The name of this service"; + default = ""; + }; + + icon = mkOption { + type = types.nullOr (types.either types.path types.str); + description = "The icon for this service. Must be a path to an image or a valid icon name (.)."; + default = null; + }; + + hidden = mkOption { + type = types.bool; + default = false; + description = "Whether this service should be hidden from graphs"; + }; + + nixos = mkOption { + type = types.submodule { + options = { + path = mkOption { + type = types.listOf types.str; + default = []; + description = "The NixOS options path for this service"; + apply = lists.unique; + }; + enabled = mkOption { + type = types.anything; + default = _: true; + description = "A function that determines if this service is enabled for a given NixOS configuration"; + }; + infoFn = mkOption { + type = types.anything; + default = _: ""; + description = "A function that returns a string with additional high-profile information about this service, usually the url or listen address. Most likely shown directly below the name."; + }; + detailsFn = mkOption { + type = types.anything; + default = _: {}; + description = "A function that returns additional detail sections that should be shown to the user"; + }; + }; + }; + default = {}; + description = "NixOS-specific metadata for this service"; + }; + + oci = mkOption { + type = types.submodule { + options = { + repos = mkOption { + type = types.listOf types.str; + default = []; + description = "A list of container image repositories that provide this service"; + apply = lists.unique; + }; + infoFn = mkOption { + type = types.anything; + default = null; + description = "A function that returns a string with additional high-profile information about the container instance, such as a URL or address. Most likely shown directly below the name."; + }; + detailsFn = mkOption { + type = types.anything; + default = _: {}; + description = "A function that returns additional detail sections that should be shown to the user"; + }; + }; + }; + default = {}; + description = "OCI (container) specific metadata for this service"; + }; + }; + }; +in + f { + options.serviceRegistry = mkOption { + description = "A registry of known services containing all metadata (name, icon, visibility flags) plus NixOS- and OCI-specific discovery, info, and details functions used by the graph renderers."; + type = types.attrsOf (types.submodule serviceSubmodule); + default = {}; + }; + + config.serviceRegistry = mkDefault serviceDefs; + } diff --git a/options/services.nix b/options/services.nix index 4f2525a..52a5919 100644 --- a/options/services.nix +++ b/options/services.nix @@ -5,6 +5,7 @@ let attrValues flatten flip + hasPrefix mkDefault mkIf mkOption @@ -29,9 +30,19 @@ f { default = submod.config._module.args.name; }; + serviceId = mkOption { + description = "The id of the service as defined in topology.serviceRegistry."; + type = types.nullOr types.str; + default = null; + }; + name = mkOption { description = "The name of this service"; type = types.str; + default = + if submod.config.serviceId != null + then config.serviceRegistry.${submod.config.serviceId}.name + else submod.config.id; }; hidden = mkOption { @@ -43,7 +54,16 @@ f { icon = mkOption { description = "The icon for this service. Must be a path to an image or a valid icon name (.)."; type = types.nullOr (types.either types.path types.str); - default = null; + default = + if submod.config.serviceId != null + then (config.serviceRegistry.${submod.config.serviceId}.icon or null) + else null; + }; + + source = mkOption { + description = "The source of this service, e.g. the extractor that provides it"; + type = types.enum ["nixos" "oci"]; + default = "nixos"; }; info = mkOption { @@ -99,10 +119,16 @@ f { assertions = flatten ( flip map (attrValues config.nodes) ( node: - flip map (attrValues node.services) (service: [ - (config.lib.assertions.iconValid service.icon "nodes.${node.id}.services.${service.id}") - ]) - ) - ); - }; -} + flip map (attrValues node.services) ( + service: [ + (config.lib.assertions.iconValid + service.icon "nodes.${node.id}.services.${service.id}") + (mkIf (service.serviceId != null && !hasPrefix "restic-backup-" service.serviceId) { + assertion = config.serviceRegistry ? ${service.serviceId}; + message = "serviceId '${service.serviceId}' for nodes.${node.id}.services.${service.id} is not defined in topology.serviceRegistry"; + }) + ] + ) + )); + }; + } diff --git a/topology/default.nix b/topology/default.nix index 019edb2..29088a0 100644 --- a/topology/default.nix +++ b/topology/default.nix @@ -160,6 +160,7 @@ in nodes = aggregate [ "nodes" ]; networks = aggregate [ "networks" ]; icons = aggregate [ "icons" ]; + serviceRegistry = aggregate [ "serviceRegistry" ]; lib.topology = import ./helpers.nix lib; }; diff --git a/topology/renderers/svg/default.nix b/topology/renderers/svg/default.nix index 54e3512..6e345ed 100644 --- a/topology/renderers/svg/default.nix +++ b/topology/renderers/svg/default.nix @@ -17,6 +17,7 @@ let length mkOption optionalString + partition sort splitString tail @@ -295,67 +296,77 @@ let node; }; - services.mkOverview = { - html = mkCardContainer /* html */ '' -
-

Services Overview

-
+ services.mkOverview = + let + allServices = concatMap ( + node: + let + visible = filter (x: !x.hidden) (attrValues node.services); + in + map (svc: { inherit node svc; }) visible + ) (attrValues config.nodes); - ${ + # Deduplicate services by serviceId in overview + deduped = let - allServices = concatMap ( - node: - let - nodeServices = filter (s: !s.hidden) (attrValues node.services); - in - map (s: s // { nodeName = node.name; }) nodeServices - ) (attrValues config.nodes); - groupedServices = groupBy (s: s.name) allServices; - sortedNames = sort (a: b: a < b) (lib.attrNames groupedServices); + parts = partition (p: p.svc.serviceId != null) allServices; + withId = parts.right; + withoutId = parts.wrong; + grouped = groupBy (p: p.svc.serviceId) withId; + uniqueById = map head (attrValues grouped); in - concatLines ( - flip map sortedNames ( - name: - let - services = groupedServices.${name}; - count = length services; - in - if count > 1 then - let - sortedServices = sort (a: b: a.nodeName < b.nodeName) services; - mainService = (head services) // { - info = ""; - }; - rows = flip map sortedServices (s: /* html */ '' -
- ${s.nodeName} - - ${optionalString ( - s.info != "" - ) ''${s.info}''} -
- ''); - additionalInfo = concatLines rows; - in - flip html.node.mkService mainService { - inherit additionalInfo; - includeDetails = false; - } - else - let - service = head services; - in - flip html.node.mkService service { - additionalInfo = /* html */ ''

${service.nodeName}

''; - includeDetails = false; - } - ) - ) - } + uniqueById ++ withoutId; - ${spacingMt2} - ''; - }; + groupedByName = groupBy (p: p.svc.name) deduped; + sortedNames = sort (a: b: a < b) (lib.attrNames groupedByName); + + renderServiceGroup = + name: + let + services = groupedByName.${name}; + count = length services; + in + if count > 1 then + let + sortedServices = sort (a: b: a.node.name < b.node.name) services; + mainService = (head services).svc // { + info = ""; + }; + rows = flip map sortedServices (p: /* html */ '' +
+ ${p.node.name} + + ${optionalString ( + p.svc.info != "" + ) ''${p.svc.info}''} +
+ ''); + additionalInfo = concatLines rows; + in + flip html.node.mkService mainService { + inherit additionalInfo; + includeDetails = false; + } + else + let + pair = head services; + in + flip html.node.mkService pair.svc { + additionalInfo = /* html */ ''

${pair.node.name}

''; + includeDetails = false; + }; + in + { + html = mkCardContainer /* html */ '' +
+

Services Overview

+
+ + ${concatLines (map renderServiceGroup sortedNames)} + + ${spacingMt2} + ''; + }; }; in { From 066dadc36f3a9d1546ccd5447978bf083ca05910 Mon Sep 17 00:00:00 2001 From: johnspade Date: Tue, 3 Feb 2026 12:40:40 +0100 Subject: [PATCH 2/6] Add new services --- nixos/builtin-service-defs.nix | 1454 +++++++++++++++++++++++++------- 1 file changed, 1170 insertions(+), 284 deletions(-) diff --git a/nixos/builtin-service-defs.nix b/nixos/builtin-service-defs.nix index 8d7aaed..8d1465d 100644 --- a/nixos/builtin-service-defs.nix +++ b/nixos/builtin-service-defs.nix @@ -1,11 +1,10 @@ -{lib}: let - inherit - (lib) +{ lib }: +let + inherit (lib) attrNames concatLines concatStringsSep elemAt - fail2ban filter filterAttrs flatten @@ -31,161 +30,298 @@ splitString tail ; -in { +in +{ adguardhome = { name = "AdGuard Home"; icon = "services.adguardhome"; nixos = { - path = ["services" "adguardhome"]; + path = [ + "services" + "adguardhome" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: let - address = cfg.host or null; - port = cfg.port or null; - in - mkIf (address != null && port != null) {listen = {text = "${address}:${toString port}";};}; + detailsFn = + cfg: + let + address = cfg.host or null; + port = cfg.port or null; + in + mkIf (address != null && port != null) { + listen = { + text = "${address}:${toString port}"; + }; + }; }; oci = { - repos = ["adguard/adguardhome"]; - }; - }; - anki = { - name = "Anki"; - icon = "services.anki"; - nixos = { - path = ["services" "anki-sync-server"]; - enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.address}:${toString cfg.port}";};}; + repos = [ "adguard/adguardhome" ]; }; }; alloy = { name = "Alloy"; icon = "services.alloy"; nixos = { - path = ["services" "alloy"]; + path = [ + "services" + "alloy" + ]; enabled = cfg: cfg.enable or false; - detailsFn = _: {}; + detailsFn = _: { }; }; oci = { - repos = ["grafana/alloy"]; + repos = [ "grafana/alloy" ]; + }; + }; + anki = { + name = "Anki"; + icon = "services.anki"; + nixos = { + path = [ + "services" + "anki-sync-server" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.address}:${toString cfg.port}"; + }; + }; }; }; atuin = { name = "Atuin"; icon = "services.atuin"; nixos = { - path = ["services" "atuin"]; + path = [ + "services" + "atuin" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.host}:${toString cfg.port}";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.host}:${toString cfg.port}"; + }; + }; }; oci = { - repos = ["atuinsh/atuin"]; + repos = [ "atuinsh/atuin" ]; }; }; authelia = { name = "Authelia"; icon = "services.authelia"; nixos = { - path = ["services" "authelia"]; - enabled = cfg: (cfg.instances or {}) != {}; - detailsFn = cfg: let - instances = filterAttrs (_: v: v.enable) cfg.instances; - in - listToAttrs (mapAttrsToList (name: v: { + path = [ + "services" + "authelia" + ]; + enabled = cfg: (cfg.instances or { }) != { }; + detailsFn = + cfg: + let + instances = filterAttrs (_: v: v.enable) cfg.instances; + in + listToAttrs ( + mapAttrsToList (name: v: { inherit name; - value = {text = "${v.settings.server.host}:${toString v.settings.server.port}";}; - }) - instances); + value = { + text = "${v.settings.server.host}:${toString v.settings.server.port}"; + }; + }) instances + ); }; oci = { - repos = ["authelia/authelia"]; + repos = [ "authelia/authelia" ]; }; }; blocky = { name = "Blocky"; icon = "services.blocky"; nixos = { - path = ["services" "blocky"]; + path = [ + "services" + "blocky" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: - listToAttrs (mapAttrsToList (n: v: { - name = "listen.${n}"; - value = {text = toString v;}; - }) (cfg.settings.ports or {})); + detailsFn = + cfg: + listToAttrs ( + mapAttrsToList (n: v: { + name = "listen.${n}"; + value = { + text = toString v; + }; + }) (cfg.settings.ports or { }) + ); }; oci = { - repos = ["spx01/blocky" "0xerr0r/blocky"]; + repos = [ + "spx01/blocky" + "0xerr0r/blocky" + ]; }; }; caddy = { name = "Caddy"; icon = "services.caddy"; nixos = { - path = ["services" "caddy"]; + path = [ + "services" + "caddy" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: - genAttrs (mapAttrsToList (name: _: name) (cfg.virtualHosts or {})) (name: { - text = concatStringsSep " " (map (line: removePrefix "reverse_proxy " (removeSuffix " {" line)) (filter (line: hasPrefix "reverse_proxy " line) (splitString "\n" (cfg.virtualHosts.${name}.extraConfig or "")))); + detailsFn = + cfg: + genAttrs (mapAttrsToList (name: _: name) (cfg.virtualHosts or { })) (name: { + text = concatStringsSep " " ( + map (line: removePrefix "reverse_proxy " (removeSuffix " {" line)) ( + filter (line: hasPrefix "reverse_proxy " line) ( + splitString "\n" (cfg.virtualHosts.${name}.extraConfig or "") + ) + ) + ); }); }; oci = { - repos = ["caddy"]; + repos = [ "caddy" ]; + }; + }; + code-server = { + name = "Code Server"; + icon = "services.code-server"; + nixos = { + path = [ + "services" + "code-server" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + let + host = cfg.host or null; + port = cfg.port or null; + in + mkIf (host != null && port != null) { + listen = { + text = "${host}:${toString port}"; + }; + }; + }; + oci = { + repos = [ + "codercom/code-server" + "linuxserver/code-server" + ]; + }; + }; + coder = { + name = "Coder"; + icon = "services.coder"; + nixos = { + path = [ + "services" + "coder" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + let + listenAddress = cfg.listenAddress or null; + in + mkIf (listenAddress != null) { + listen = { + text = listenAddress; + }; + }; + infoFn = cfg: cfg.accessUrl; + }; + oci = { + repos = [ "coder/coder" ]; }; }; dnsmasq = { name = "Dnsmasq"; icon = "services.dnsmasq"; nixos = { - path = ["services" "dnsmasq"]; + path = [ + "services" + "dnsmasq" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: let - addresses = cfg.settings.address or []; - in - listToAttrs (forEach (forEach addresses (x: splitString "/" (removePrefix "/" x))) (x: { - name = head x; - value = {text = head (tail x);}; - })); + detailsFn = + cfg: + let + addresses = cfg.settings.address or [ ]; + in + listToAttrs ( + forEach (forEach addresses (x: splitString "/" (removePrefix "/" x))) (x: { + name = head x; + value = { + text = head (tail x); + }; + }) + ); }; }; esphome = { name = "ESPHome"; icon = "services.esphome"; nixos = { - path = ["services" "esphome"]; + path = [ + "services" + "esphome" + ]; enabled = cfg: cfg.enable or false; detailsFn = cfg: { listen = { text = - if cfg.enableUnixSocket or false - then "/run/esphome/esphome.sock" - else "${cfg.address}:${toString cfg.port}"; + if cfg.enableUnixSocket or false then + "/run/esphome/esphome.sock" + else + "${cfg.address}:${toString cfg.port}"; }; }; }; oci = { - repos = ["esphome/esphome"]; + repos = [ "esphome/esphome" ]; }; }; fail2ban = { name = "Fail2Ban"; icon = "services.fail2ban"; nixos = { - path = ["services" "fail2ban"]; + path = [ + "services" + "fail2ban" + ]; enabled = cfg: cfg.enable or false; - detailsFn = _: {}; + detailsFn = _: { }; }; oci = { - repos = ["linuxserver/fail2ban" "crazymax/fail2ban"]; + repos = [ + "linuxserver/fail2ban" + "crazymax/fail2ban" + ]; }; }; firefox-syncserver = { name = "Firefox Syncserver"; icon = "services.firefox-syncserver"; nixos = { - path = ["services" "firefox-syncserver"]; + path = [ + "services" + "firefox-syncserver" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: mkIf (cfg.singleNode.enable or false) cfg.singleNode.url; detailsFn = cfg: { - listen = {text = "${cfg.settings.host or "127.0.0.1"}:${toString cfg.settings.port}";}; + listen = { + text = "${cfg.settings.host or "127.0.0.1"}:${toString cfg.settings.port}"; + }; }; }; }; @@ -193,106 +329,197 @@ in { name = "Forgejo"; icon = "services.forgejo"; nixos = { - path = ["services" "forgejo"]; + path = [ + "services" + "forgejo" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: mkIf (cfg.settings ? server.ROOT_URL) cfg.settings.server.ROOT_URL; detailsFn = cfg: { name = - if cfg.settings ? DEFAULT.APP_NAME - then "Forgejo (${cfg.settings.DEFAULT.APP_NAME})" - else "Forgejo"; - listen = mkIf ((cfg.settings.server.HTTP_ADDR or null) != null && (cfg.settings.server.HTTP_PORT or null) != null) {text = "${cfg.settings.server.HTTP_ADDR}:${toString cfg.settings.server.HTTP_PORT}";}; + if cfg.settings ? DEFAULT.APP_NAME then "Forgejo (${cfg.settings.DEFAULT.APP_NAME})" else "Forgejo"; + listen = mkIf ( + (cfg.settings.server.HTTP_ADDR or null) != null && (cfg.settings.server.HTTP_PORT or null) != null + ) { text = "${cfg.settings.server.HTTP_ADDR}:${toString cfg.settings.server.HTTP_PORT}"; }; }; }; oci = { - repos = ["forgejo/forgejo"]; + repos = [ "forgejo/forgejo" ]; }; }; gitea = { name = "gitea"; icon = "services.gitea"; nixos = { - path = ["services" "gitea"]; + path = [ + "services" + "gitea" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: mkIf (cfg.settings ? server.ROOT_URL) cfg.settings.server.ROOT_URL; detailsFn = cfg: { name = - if cfg.settings ? DEFAULT.APP_NAME - then "gitea (${cfg.settings.DEFAULT.APP_NAME})" - else "gitea"; - listen = mkIf ((cfg.settings.server.HTTP_ADDR or null) != null && (cfg.settings.server.HTTP_PORT or null) != null) {text = "${cfg.settings.server.HTTP_ADDR}:${toString cfg.settings.server.HTTP_PORT}";}; + if cfg.settings ? DEFAULT.APP_NAME then "gitea (${cfg.settings.DEFAULT.APP_NAME})" else "gitea"; + listen = mkIf ( + (cfg.settings.server.HTTP_ADDR or null) != null && (cfg.settings.server.HTTP_PORT or null) != null + ) { text = "${cfg.settings.server.HTTP_ADDR}:${toString cfg.settings.server.HTTP_PORT}"; }; }; }; oci = { - repos = ["gitea" "gitea/gitea" "linuxserver/gitea"]; + repos = [ + "gitea" + "gitea/gitea" + "linuxserver/gitea" + ]; }; }; glance = { name = "Glance"; icon = "services.glance"; nixos = { - path = ["services" "glance"]; + path = [ + "services" + "glance" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.settings.server.host}:${toString cfg.settings.server.port}";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.settings.server.host}:${toString cfg.settings.server.port}"; + }; + }; }; oci = { - repos = ["glanceapp/glance"]; + repos = [ "glanceapp/glance" ]; }; }; grafana = { name = "Grafana"; icon = "services.grafana"; nixos = { - path = ["services" "grafana"]; + path = [ + "services" + "grafana" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: cfg.settings.server.root_url; detailsFn = cfg: { - listen = mkIf ((cfg.settings.server.http_addr or null) != null && (cfg.settings.server.http_port or null) != null) {text = "${cfg.settings.server.http_addr}:${toString cfg.settings.server.http_port}";}; - plugins = mkIf ((cfg.declarativePlugins or null) != null) {text = concatStringsSep "\n" (map (p: p.name) cfg.declarativePlugins);}; + listen = mkIf ( + (cfg.settings.server.http_addr or null) != null && (cfg.settings.server.http_port or null) != null + ) { text = "${cfg.settings.server.http_addr}:${toString cfg.settings.server.http_port}"; }; + plugins = mkIf ((cfg.declarativePlugins or null) != null) { + text = concatStringsSep "\n" (map (p: p.name) cfg.declarativePlugins); + }; }; }; oci = { - repos = ["grafana/grafana" "grafana/grafana-enterprise" "grafana/grafana-oss"]; + repos = [ + "grafana/grafana" + "grafana/grafana-enterprise" + "grafana/grafana-oss" + ]; + }; + }; + harmonia = { + name = "Harmonia"; + icon = "services.not-available"; + nixos = { + path = [ + "services" + "harmonia" + ]; + enabled = cfg: cfg.enable or (cfg.cache.enable or false); + detailsFn = _: { }; }; }; headscale = { name = "Headscale"; icon = "services.headscale"; nixos = { - path = ["services" "headscale"]; + path = [ + "services" + "headscale" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: cfg.settings.server_url; detailsFn = cfg: { - listen = {text = "${cfg.address}:${toString cfg.port}";}; + listen = { + text = "${cfg.address}:${toString cfg.port}"; + }; }; }; oci = { - repos = ["headscale/headscale" "juanfont/headscale"]; + repos = [ + "headscale/headscale" + "juanfont/headscale" + ]; + }; + }; + hickory-dns = { + name = "Hickory DNS"; + icon = "services.hickory-dns"; + nixos = { + path = [ + "services" + "hickory-dns" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: { + listen_ipv4 = { + text = toString ( + map (addr: "${addr}:${toString cfg.settings.listen_port}") cfg.settings.listen_addrs_ipv4 + ); + }; + listen_ipv6 = { + text = toString ( + map (addr: "${addr}:${toString cfg.settings.listen_port}") cfg.settings.listen_addrs_ipv6 + ); + }; + }; + }; + oci = { + repos = [ "hickorydns/hickory-dns" ]; }; }; home-assistant = { name = "Home Assistant"; icon = "services.home-assistant"; nixos = { - path = ["services" "home-assistant"]; + path = [ + "services" + "home-assistant" + ]; enabled = cfg: cfg.enable or false; detailsFn = cfg: { - listen = {text = toString (flip map cfg.config.http.server_host (addr: "${addr}:${toString cfg.config.http.server_port}"));}; + listen = { + text = toString ( + flip map cfg.config.http.server_host (addr: "${addr}:${toString cfg.config.http.server_port}") + ); + }; }; }; oci = { - repos = ["home-assistant/home-assistant" "linuxserver/homeassistant"]; + repos = [ + "home-assistant/home-assistant" + "linuxserver/homeassistant" + ]; }; }; hydra = { name = "Hydra"; icon = "devices.nixos"; nixos = { - path = ["services" "hydra"]; + path = [ + "services" + "hydra" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: cfg.hydraURL; detailsFn = cfg: { - listen = {text = "${cfg.listenHost}:${toString cfg.port}";}; + listen = { + text = "${cfg.listenHost}:${toString cfg.port}"; + }; }; }; }; @@ -300,252 +527,619 @@ in { name = "I2P"; icon = "services.i2p"; nixos = { - path = ["services" "i2pd"]; + path = [ + "services" + "i2pd" + ]; enabled = cfg: cfg.enable or false; - detailsFn = _: {}; + detailsFn = _: { }; }; oci = { - repos = ["purplei2p/i2pd"]; + repos = [ "purplei2p/i2pd" ]; }; }; immich = { name = "Immich"; icon = "services.immich"; nixos = { - path = ["services" "immich"]; + path = [ + "services" + "immich" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.host}:${toString cfg.port}";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.host}:${toString cfg.port}"; + }; + }; }; oci = { - repos = ["immich-app/immich-server" "imagegenius/immich"]; + repos = [ + "immich-app/immich-server" + "imagegenius/immich" + ]; }; }; influxdb2 = { name = "InfluxDB v2"; icon = "services.influxdb2"; nixos = { - path = ["services" "influxdb2"]; + path = [ + "services" + "influxdb2" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: {listen = {text = cfg.settings.http-bind-address or "localhost:8086";};}; + detailsFn = cfg: { + listen = { + text = cfg.settings.http-bind-address or "localhost:8086"; + }; + }; }; oci = { - repos = ["influxdb"]; + repos = [ "influxdb" ]; }; }; invidious = { name = "Invidious"; icon = "services.invidious"; nixos = { - path = ["services" "invidious"]; + path = [ + "services" + "invidious" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: {listen = {text = "${cfg.address}:${toString cfg.port}";};}; + detailsFn = cfg: { + listen = { + text = "${cfg.address}:${toString cfg.port}"; + }; + }; }; oci = { - repos = ["invidious/invidious"]; + repos = [ "invidious/invidious" ]; }; }; jellyfin = { name = "Jellyfin"; icon = "services.jellyfin"; nixos = { - path = ["services" "jellyfin"]; + path = [ + "services" + "jellyfin" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: - listToAttrs (mapAttrsToList (n: v: { - name = "listen.${n}"; - value = {text = "0.0.0.0:${toString v}";}; - }) (optionalAttrs cfg.openFirewall { - http = 8096; - https = 8920; - service-discovery = 1900; - client-discovery = 7359; - })); + detailsFn = + cfg: + listToAttrs ( + mapAttrsToList + (n: v: { + name = "listen.${n}"; + value = { + text = "0.0.0.0:${toString v}"; + }; + }) + ( + optionalAttrs cfg.openFirewall { + http = 8096; + https = 8920; + service-discovery = 1900; + client-discovery = 7359; + } + ) + ); }; oci = { - repos = ["jellyfin/jellyfin" "linuxserver/jellyfin"]; + repos = [ + "jellyfin/jellyfin" + "linuxserver/jellyfin" + ]; }; }; jellyseerr = { name = "Jellyseerr"; icon = "services.jellyseerr"; nixos = { - path = ["services" "jellyseerr"]; + path = [ + "services" + "jellyseerr" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "0.0.0.0:${toString cfg.port}";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "0.0.0.0:${toString cfg.port}"; + }; + }; }; oci = { - repos = ["fallenbagel/jellyseerr"]; + repos = [ "fallenbagel/jellyseerr" ]; }; }; kanidm = { name = "Kanidm"; icon = "services.kanidm"; nixos = { - path = ["services" "kanidm"]; + path = [ + "services" + "kanidm" + ]; enabled = cfg: cfg.enableServer or false; infoFn = cfg: cfg.serverSettings.origin; detailsFn = cfg: { - listen = {text = cfg.serverSettings.bindaddress;}; + listen = { + text = cfg.serverSettings.bindaddress; + }; }; }; oci = { - repos = ["kanidm/server"]; + repos = [ "kanidm/server" ]; + }; + }; + karakeep = { + name = "Karakeep"; + icon = "services.karakeep"; + nixos = { + path = [ + "services" + "karakeep" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = _: { }; + }; + oci = { + repos = [ "karakeep-app/karakeep" ]; + }; + }; + kavita = { + name = "Kavita"; + icon = "services.kavita"; + nixos = { + path = [ + "services" + "kavita" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: { + listen = { + text = "0.0.0.0:${toString cfg.settings.Port}"; + }; + }; + }; + oci = { + repos = [ + "kareadita/kavita" + "linuxserver/kavita" + "jvmilazz0/kavita" + ]; + }; + }; + komga = { + name = "Komga"; + icon = "services.komga"; + nixos = { + path = [ + "services" + "komga" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "0.0.0.0:${toString cfg.settings.server.port}"; + }; + }; + }; + oci = { + repos = [ "gotson/komga" ]; }; }; languagetool = { name = "Languagetool"; icon = "services.languagetool"; nixos = { - path = ["services" "languagetool"]; + path = [ + "services" + "languagetool" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: { + listen = { + text = "127.0.0.1:${toString cfg.port}"; + }; + }; + }; + oci = { + repos = [ "erikvl87/languagetool" ]; + }; + }; + libretranslate = { + name = "Libretranslate"; + icon = "services.libretranslate"; + nixos = { + path = [ + "services" + "libretranslate" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: {listen = {text = "127.0.0.1:${toString cfg.port}";};}; + detailsFn = cfg: { + listen = { + text = "${cfg.host}:${toString cfg.port}"; + }; + }; }; oci = { - repos = ["erikvl87/languagetool"]; + repos = [ "libretranslate/libretranslate" ]; }; }; lidarr = { name = "Lidarr"; icon = "services.lidarr"; nixos = { - path = ["services" "lidarr"]; + path = [ + "services" + "lidarr" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "0.0.0.0:8686";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "0.0.0.0:8686"; + }; + }; }; oci = { - repos = ["linuxserver/lidarr" "hotio/lidarr"]; + repos = [ + "linuxserver/lidarr" + "hotio/lidarr" + ]; }; }; loki = { name = "Loki"; icon = "services.loki"; nixos = { - path = ["services" "loki"]; + path = [ + "services" + "loki" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf ((cfg.configuration.server.http_listen_address or null) != null && (cfg.configuration.server.http_listen_port or null) != null) {listen = {text = "${cfg.configuration.server.http_listen_address}:${toString cfg.configuration.server.http_listen_port}";};}; + detailsFn = + cfg: + mkIf + ( + (cfg.configuration.server.http_listen_address or null) != null + && (cfg.configuration.server.http_listen_port or null) != null + ) + { + listen = { + text = "${cfg.configuration.server.http_listen_address}:${toString cfg.configuration.server.http_listen_port}"; + }; + }; }; oci = { - repos = ["grafana/loki"]; + repos = [ "grafana/loki" ]; }; }; mastodon = { name = "Mastodon"; icon = "services.mastodon"; nixos = { - path = ["services" "mastodon"]; + path = [ + "services" + "mastodon" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: "https://${cfg.localDomain}"; - detailsFn = _: {}; + detailsFn = _: { }; }; oci = { - repos = ["mastodon/mastodon" "linuxserver/mastodon" "tootsuite/mastodon"]; + repos = [ + "mastodon/mastodon" + "linuxserver/mastodon" + "tootsuite/mastodon" + ]; + }; + }; + matrix-synapse = { + name = "Matrix (Synapse)"; + icon = "services.matrix"; + nixos = { + path = [ + "services" + "matrix-synapse" + ]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: cfg.settings.public_baseurl; + detailsFn = + cfg: + let + listener = head (cfg.settings.listeners or [ ]); + in + mkIf (listener != null) { + listen = { + text = "${head listener.bind_addresses}:${toString listener.port}"; + }; + }; + }; + oci = { + repos = [ "matrixdotorg/synapse" ]; + }; + }; + mautrix-signal = { + name = "mautrix-signal"; + icon = "services.mautrix-signal"; + nixos = { + path = [ + "services" + "mautrix-signal" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + let + address = cfg.settings.appservice.hostname or null; + port = cfg.settings.appservice.port or null; + in + mkIf (address != null && port != null) { + listen = { + text = "${address}:${toString port}"; + }; + }; + }; + oci = { + repos = [ "mautrix/signal" ]; + }; + }; + mautrix-whatsapp = { + name = "mautrix-whatsapp"; + icon = "services.mautrix-whatsapp"; + nixos = { + path = [ + "services" + "mautrix-whatsapp" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + let + address = cfg.settings.appservice.hostname or null; + port = cfg.settings.appservice.port or null; + in + mkIf (address != null && port != null) { + listen = { + text = "${address}:${toString port}"; + }; + }; + }; + oci = { + repos = [ "dock.mau.fi/mautrix/whatsapp" ]; + }; + }; + meilisearch = { + name = "Meilisearch"; + icon = "services.meilisearch"; + nixos = { + path = [ + "services" + "meilisearch" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: { + listen = { + text = "${cfg.listenAddress}:${toString cfg.listenPort}"; + }; + }; + }; + oci = { + repos = [ "getmeili/meilisearch" ]; }; }; mosquitto = { name = "Mosquitto"; icon = "services.mosquitto"; nixos = { - path = ["services" "mosquitto"]; + path = [ + "services" + "mosquitto" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: - listToAttrs (flip imap0 (flip map cfg.listeners (l: rec { - address = - if l.address == null - then "[::]" - else l.address; - listen = - if l.port == 0 - then address - else "${address}:${toString l.port}"; - })) (i: l: { - name = "listen[${toString i}]"; - value = {text = l.listen;}; - })); + detailsFn = + cfg: + listToAttrs ( + flip imap0 + (flip map cfg.listeners (l: rec { + address = if l.address == null then "[::]" else l.address; + listen = if l.port == 0 then address else "${address}:${toString l.port}"; + })) + ( + i: l: { + name = "listen[${toString i}]"; + value = { + text = l.listen; + }; + } + ) + ); }; oci = { - repos = ["eclipse-mosquitto"]; + repos = [ "eclipse-mosquitto" ]; }; }; - nextcloud = { - name = "Nextcloud"; - icon = "services.nextcloud"; + mpd = { + name = "MPD"; + icon = "services.mpd"; nixos = { - path = ["services" "nextcloud"]; + path = [ + "services" + "mpd" + ]; enabled = cfg: cfg.enable or false; - infoFn = cfg: "http${optionalString cfg.https "s"}://${cfg.hostName}"; - detailsFn = _: {}; + detailsFn = cfg: { + listen = { + text = "${ + if cfg.settings.bind_to_address == "any" then "0.0.0.0" else cfg.settings.bind_to_address + }:${toString cfg.settings.port}"; + }; + }; + }; + }; + navidrome = { + name = "Navidrome"; + icon = "services.navidrome"; + nixos = { + path = [ + "services" + "navidrome" + ]; + enabled = cfg: cfg.enable or false; + infoFn = cfg: mkIf (cfg.settings ? BaseUrl) cfg.settings.BaseUrl; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.settings.Address}:${toString cfg.settings.Port}"; + }; + }; }; oci = { - repos = ["nextcloud" "linuxserver/nextcloud"]; + repos = [ "deluan/navidrome" ]; }; }; - nix-serve = { - name = "Nix Serve"; - icon = "devices.nixos"; + nextcloud = { + name = "Nextcloud"; + icon = "services.nextcloud"; nixos = { - path = ["services" "nix-serve"]; + path = [ + "services" + "nextcloud" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: {listen = {text = "${cfg.bindAddress}:${toString cfg.port}";};}; + infoFn = cfg: "http${optionalString cfg.https "s"}://${cfg.hostName}"; + detailsFn = _: { }; + }; + oci = { + repos = [ + "nextcloud" + "linuxserver/nextcloud" + ]; }; }; nginx = { name = "NGINX"; icon = "services.nginx"; nixos = { - path = ["services" "nginx"]; + path = [ + "services" + "nginx" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: let - reverseProxies = flatten (flip mapAttrsToList (cfg.virtualHosts or {}) (server: vh: - flip mapAttrsToList (vh.locations or {}) (path: location: let - upstreamName = replaceStrings ["http://" "https://"] ["" ""] location.proxyPass; - passTo = - if (cfg.upstreams or {}) ? ${upstreamName} - then toString (attrNames cfg.upstreams.${upstreamName}.servers) - else location.proxyPass; - in - optional (path == "/" && location.proxyPass != null) {${server} = {text = passTo;};}))); - in + detailsFn = + cfg: + let + reverseProxies = flatten ( + flip mapAttrsToList (cfg.virtualHosts or { }) ( + server: vh: + flip mapAttrsToList (vh.locations or { }) ( + path: location: + let + upstreamName = replaceStrings [ "http://" "https://" ] [ "" "" ] location.proxyPass; + passTo = + if (cfg.upstreams or { }) ? ${upstreamName} then + toString (attrNames cfg.upstreams.${upstreamName}.servers) + else + location.proxyPass; + in + optional (path == "/" && location.proxyPass != null) { + ${server} = { + text = passTo; + }; + } + ) + ) + ); + in mkMerge reverseProxies; }; oci = { - repos = ["nginx"]; + repos = [ "nginx" ]; + }; + }; + nix-serve = { + name = "Nix Serve"; + icon = "devices.nixos"; + nixos = { + path = [ + "services" + "nix-serve" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: { + listen = { + text = "${cfg.bindAddress}:${toString cfg.port}"; + }; + }; }; }; oauth2-proxy = { name = "OAuth2 Proxy"; icon = "services.oauth2-proxy"; nixos = { - path = ["services" "oauth2-proxy"]; + path = [ + "services" + "oauth2-proxy" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: cfg.httpAddress; - detailsFn = _: {}; + detailsFn = _: { }; }; oci = { - repos = ["oauth2-proxy/oauth2-proxy"]; + repos = [ "oauth2-proxy/oauth2-proxy" ]; }; }; ollama = { name = "Ollama"; icon = "services.ollama"; nixos = { - path = ["services" "ollama"]; + path = [ + "services" + "ollama" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.host}:${toString cfg.port}";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.host}:${toString cfg.port}"; + }; + }; }; oci = { - repos = ["ollama/ollama"]; + repos = [ "ollama/ollama" ]; }; }; open-webui = { name = "Open Webui"; icon = "services.open-webui"; nixos = { - path = ["services" "open-webui"]; + path = [ + "services" + "open-webui" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.host}:${toString cfg.port}";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.host}:${toString cfg.port}"; + }; + }; }; oci = { - repos = ["open-webui/open-webui"]; + repos = [ "open-webui/open-webui" ]; }; }; openssh = { @@ -553,261 +1147,553 @@ in { icon = "services.openssh"; hidden = true; nixos = { - path = ["services" "openssh"]; + path = [ + "services" + "openssh" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: "port: ${concatStringsSep ", " (map toString cfg.ports)}"; - detailsFn = _: {}; + detailsFn = _: { }; + }; + }; + owncast = { + name = "Owncast"; + icon = "services.owncast"; + nixos = { + path = [ + "services" + "owncast" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.listen}:${toString cfg.port}"; + }; + }; + }; + oci = { + repos = [ "owncast/owncast" ]; }; }; paperless-ngx = { name = "Paperless-ngx"; icon = "services.paperless-ngx"; nixos = { - path = ["services" "paperless"]; + path = [ + "services" + "paperless" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: mkIf ((cfg.settings.PAPERLESS_URL or null) != null) cfg.settings.PAPERLESS_URL; detailsFn = cfg: { - listen = {text = "${cfg.address}:${toString cfg.port}";}; + listen = { + text = "${cfg.address}:${toString cfg.port}"; + }; }; }; oci = { - repos = ["paperless-ngx/paperless-ngx" "linuxserver/paperless-ngx"]; + repos = [ + "paperless-ngx/paperless-ngx" + "linuxserver/paperless-ngx" + ]; + }; + }; + plausible = { + name = "Plausible"; + icon = "services.plausible"; + nixos = { + path = [ + "services" + "plausible" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + let + listenAddress = cfg.server.listenAddress or null; + port = cfg.server.port or null; + in + mkIf (listenAddress != null && port != null) { + listen = { + text = "${listenAddress}:${toString port}"; + }; + }; + infoFn = cfg: cfg.server.baseUrl; + }; + oci = { + repos = [ "plausible/analytics" ]; + }; + }; + postgresql = { + name = "PostgreSQL"; + icon = "services.postgresql"; + nixos = { + path = [ + "services" + "postgresql" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + let + port = cfg.settings.port or null; + in + mkIf (port != null) { + listen = { + text = "0.0.0.0:${toString port}"; + }; + }; }; }; prometheus = { name = "Prometheus"; icon = "services.prometheus"; nixos = { - path = ["services" "prometheus"]; + path = [ + "services" + "prometheus" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: {listen = {text = "${cfg.listenAddress}:${toString cfg.port}";};}; + detailsFn = cfg: { + listen = { + text = "${cfg.listenAddress}:${toString cfg.port}"; + }; + }; }; oci = { - repos = ["prom/prometheus"]; + repos = [ "prom/prometheus" ]; }; }; prowlarr = { name = "Prowlarr"; icon = "services.prowlarr"; nixos = { - path = ["services" "prowlarr"]; + path = [ + "services" + "prowlarr" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "0.0.0.0:9696";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "0.0.0.0:9696"; + }; + }; }; oci = { - repos = ["linuxserver/prowlarr" "hotio/prowlarr"]; + repos = [ + "linuxserver/prowlarr" + "hotio/prowlarr" + ]; }; }; radarr = { name = "Radarr"; icon = "services.radarr"; nixos = { - path = ["services" "radarr"]; + path = [ + "services" + "radarr" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "0.0.0.0:7878";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "0.0.0.0:7878"; + }; + }; }; oci = { - repos = ["linuxserver/radarr" "hotio/radarr"]; + repos = [ + "linuxserver/radarr" + "hotio/radarr" + ]; }; }; radicale = { name = "Radicale"; icon = "services.radicale"; nixos = { - path = ["services" "radicale"]; + path = [ + "services" + "radicale" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf (cfg.settings ? server.hosts) {listen = {text = toString cfg.settings.server.hosts;};}; + detailsFn = + cfg: + mkIf (cfg.settings ? server.hosts) { + listen = { + text = toString cfg.settings.server.hosts; + }; + }; }; oci = { - repos = ["kozea/radicale"]; + repos = [ "kozea/radicale" ]; }; }; redlib = { name = "Redlib"; icon = "services.redlib"; nixos = { - path = ["services" "redlib"]; + path = [ + "services" + "redlib" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "${cfg.address}:${toString cfg.port}";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.address}:${toString cfg.port}"; + }; + }; }; oci = { - repos = ["redlib/redlib"]; + repos = [ "redlib/redlib" ]; }; }; samba = { name = "Samba"; icon = "services.samba"; nixos = { - path = ["services" "samba"]; + path = [ + "services" + "samba" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: let shares = remove "global" (attrNames cfg.settings); in mkIf (shares != []) {shares = {text = concatLines shares;};}; + detailsFn = + cfg: + let + shares = remove "global" (attrNames cfg.settings); + in + mkIf (shares != [ ]) { + shares = { + text = concatLines shares; + }; + }; + }; + }; + scrutiny = { + name = "Scrutiny"; + icon = "services.scrutiny"; + nixos = { + path = [ + "services" + "scrutiny" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.settings.web.listen.host}:${toString cfg.settings.web.listen.port}"; + }; + }; + }; + oci = { + repos = [ "analogj/scrutiny" ]; }; }; searxng = { name = "SearXNG"; icon = "services.searxng"; nixos = { - path = ["services" "searx"]; + path = [ + "services" + "searx" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: let - address = cfg.settings.server.bind_address or null; - port = cfg.settings.server.port or null; - in - mkIf (address != null && port != null) {listen = {text = "${address}:${toString port}";};}; + detailsFn = + cfg: + let + address = cfg.settings.server.bind_address or null; + port = cfg.settings.server.port or null; + in + mkIf (address != null && port != null) { + listen = { + text = "${address}:${toString port}"; + }; + }; }; oci = { - repos = ["searxng/searxng"]; + repos = [ "searxng/searxng" ]; }; }; sonarr = { name = "Sonarr"; icon = "services.sonarr"; nixos = { - path = ["services" "sonarr"]; + path = [ + "services" + "sonarr" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: mkIf cfg.openFirewall {listen = {text = "0.0.0.0:8989";};}; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "0.0.0.0:8989"; + }; + }; }; oci = { - repos = ["linuxserver/sonarr" "hotio/sonarr"]; + repos = [ + "linuxserver/sonarr" + "hotio/sonarr" + ]; }; }; static-web-server = { name = "Static Web Server"; icon = "devices.nixos"; nixos = { - path = ["services" "static-web-server"]; + path = [ + "services" + "static-web-server" + ]; enabled = cfg: cfg.enable or false; detailsFn = cfg: { - listen = {text = toString cfg.listen;}; - root = {text = toString cfg.root;}; + listen = { + text = toString cfg.listen; + }; + root = { + text = toString cfg.root; + }; }; }; oci = { - repos = ["static-web-server/static-web-server"]; + repos = [ "static-web-server/static-web-server" ]; + }; + }; + step-ca = { + name = "step-ca"; + icon = "services.step-ca"; + nixos = { + path = [ + "services" + "step-ca" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + mkIf cfg.openFirewall { + listen = { + text = "${cfg.address}:${toString cfg.port}"; + }; + }; + }; + oci = { + repos = [ "smallstep/step-ca" ]; }; }; stirling-pdf = { name = "Stirling-PDF"; icon = "services.stirling-pdf"; nixos = { - path = ["services" "stirling-pdf"]; + path = [ + "services" + "stirling-pdf" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: let - address = cfg.environment.SERVER_HOST or null; - port = cfg.environment.SERVER_PORT or null; - in - mkIf (address != null && port != null) {listen = {text = "${address}:${toString port}";};}; + detailsFn = + cfg: + let + address = cfg.environment.SERVER_HOST or null; + port = cfg.environment.SERVER_PORT or null; + in + mkIf (address != null && port != null) { + listen = { + text = "${address}:${toString port}"; + }; + }; }; oci = { - repos = ["stirling-tools/stirling-pdf"]; + repos = [ "stirling-tools/stirling-pdf" ]; }; }; tabby = { name = "Tabby"; icon = "services.tabby"; nixos = { - path = ["services" "tabby"]; + path = [ + "services" + "tabby" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: {listen = {text = "${cfg.host}:${toString cfg.port}";};}; + detailsFn = cfg: { + listen = { + text = "${cfg.host}:${toString cfg.port}"; + }; + }; }; oci = { - repos = ["tabbyml/tabby"]; + repos = [ "tabbyml/tabby" ]; }; }; tor = { name = "Tor"; icon = "services.tor"; nixos = { - path = ["services" "tor"]; + path = [ + "services" + "tor" + ]; enabled = cfg: cfg.enable or false; infoFn = cfg: mkIf (cfg.relay.enable or false) "Role: ${cfg.relay.role}"; - detailsFn = _: {}; + detailsFn = _: { }; }; }; traefik = { name = "Traefik"; icon = "services.traefik"; nixos = { - path = ["services" "traefik"]; - enabled = cfg: cfg.enable or false; - detailsFn = cfg: let - dynCfg = cfg.dynamicConfigOptions; - in - mkIf (length (attrNames dynCfg) > 0) (let - formatOutput = mapAttrsToList (routerName: routerAttrs: let - getServiceUrl = serviceName: let - service = dynCfg.http.services.${toString serviceName}.loadBalancer.servers or []; - in - if length service > 0 - then (elemAt service 0).url - else "invalid service"; - passText = toString (getServiceUrl routerAttrs.service); - in {${toString routerName} = {text = passText;};}) - dynCfg.http.routers; + path = [ + "services" + "traefik" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + let + dynCfg = cfg.dynamicConfigOptions; in - mkMerge formatOutput); + mkIf (length (attrNames dynCfg) > 0) ( + let + formatOutput = mapAttrsToList ( + routerName: routerAttrs: + let + getServiceUrl = + serviceName: + let + service = dynCfg.http.services.${toString serviceName}.loadBalancer.servers or [ ]; + in + if length service > 0 then (elemAt service 0).url else "invalid service"; + passText = toString (getServiceUrl routerAttrs.service); + in + { + ${toString routerName} = { + text = passText; + }; + } + ) dynCfg.http.routers; + in + mkMerge formatOutput + ); }; oci = { - repos = ["traefik"]; + repos = [ "traefik" ]; }; }; transmission = { name = "Transmission"; icon = "services.transmission"; nixos = { - path = ["services" "transmission"]; + path = [ + "services" + "transmission" + ]; enabled = cfg: cfg.enable or false; - detailsFn = cfg: let - address = cfg.settings.rpc-bind-address or null; - port = cfg.settings.rpc-port or null; - in - mkIf (address != null && port != null) {listen = {text = "${address}:${toString port}";};}; + detailsFn = + cfg: + let + address = cfg.settings.rpc-bind-address or null; + port = cfg.settings.rpc-port or null; + in + mkIf (address != null && port != null) { + listen = { + text = "${address}:${toString port}"; + }; + }; }; oci = { - repos = ["linuxserver/transmission"]; + repos = [ "linuxserver/transmission" ]; }; }; vaultwarden = { name = "Vaultwarden"; icon = "services.vaultwarden"; nixos = { - path = ["services" "vaultwarden"]; + path = [ + "services" + "vaultwarden" + ]; enabled = cfg: cfg.enable or false; - infoFn = cfg: let domain = cfg.config.domain or cfg.config.DOMAIN or null; in mkIf (domain != null) domain; - detailsFn = cfg: let - address = cfg.config.rocketAddress or cfg.config.ROCKET_ADDRESS or null; - port = cfg.config.rocketPort or cfg.config.ROCKET_PORT or null; - in { - listen = mkIf (address != null && port != null) {text = "${address}:${toString port}";}; - }; + infoFn = + cfg: + let + domain = cfg.config.domain or cfg.config.DOMAIN or null; + in + mkIf (domain != null) domain; + detailsFn = + cfg: + let + address = cfg.config.rocketAddress or cfg.config.ROCKET_ADDRESS or null; + port = cfg.config.rocketPort or cfg.config.ROCKET_PORT or null; + in + { + listen = mkIf (address != null && port != null) { text = "${address}:${toString port}"; }; + }; }; oci = { - repos = ["vaultwarden/server" "dani-garcia/vaultwarden"]; + repos = [ + "vaultwarden/server" + "dani-garcia/vaultwarden" + ]; }; }; zigbee2mqtt = { name = "Zigbee2MQTT"; icon = "services.zigbee2mqtt"; nixos = { - path = ["services" "zigbee2mqtt"]; - enabled = cfg: cfg.enable or false; - detailsFn = cfg: let - mqttServer = cfg.settings.mqtt.server or null; - address = cfg.settings.frontend.host or null; - port = cfg.settings.frontend.port or null; - listen = - if address == null - then null - else if port == null - then address - else "${address}:${toString port}"; - in { - listen = mkIf (listen != null) {text = listen;}; - mqtt = mkIf (mqttServer != null) {text = mqttServer;}; + path = [ + "services" + "zigbee2mqtt" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = + cfg: + let + mqttServer = cfg.settings.mqtt.server or null; + address = cfg.settings.frontend.host or null; + port = cfg.settings.frontend.port or null; + listen = + if address == null then + null + else if port == null then + address + else + "${address}:${toString port}"; + in + { + listen = mkIf (listen != null) { text = listen; }; + mqtt = mkIf (mqttServer != null) { text = mqttServer; }; + }; + }; + oci = { + repos = [ "koenkk/zigbee2mqtt" ]; + }; + }; + zipline = { + name = "Zipline"; + icon = "services.zipline"; + nixos = { + path = [ + "services" + "zipline" + ]; + enabled = cfg: cfg.enable or false; + detailsFn = cfg: { + listen = { + text = "${cfg.settings.CORE_HOSTNAME}:${toString cfg.settings.CORE_PORT}"; + }; }; }; oci = { - repos = ["koenkk/zigbee2mqtt"]; + repos = [ "diced/zipline" ]; }; }; } From 9618fd9c141798022571b6aca23e5e4ca8fc6248 Mon Sep 17 00:00:00 2001 From: johnspade Date: Tue, 3 Feb 2026 15:41:08 +0100 Subject: [PATCH 3/6] Format files --- examples/oci-containers/flake.nix | 107 ++++++++++--------- nixos/builtin-service-defs.nix | 2 +- nixos/extractors/oci-container.nix | 158 ++++++++++++++------------- nixos/extractors/services.nix | 93 ++++++++-------- options/services-registry.nix | 165 +++++++++++++++-------------- options/services.nix | 43 ++++---- 6 files changed, 290 insertions(+), 278 deletions(-) diff --git a/examples/oci-containers/flake.nix b/examples/oci-containers/flake.nix index 45ab118..f3c62a7 100644 --- a/examples/oci-containers/flake.nix +++ b/examples/oci-containers/flake.nix @@ -1,70 +1,81 @@ { inputs = { - flake-utils.url = "github:numtide/flake-utils"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nix-topology.url = "github:oddlama/nix-topology"; nix-topology.inputs.nixpkgs.follows = "nixpkgs"; }; - outputs = { - self, - nixpkgs, - nix-topology, - flake-utils, - ... - }: + outputs = + { + self, + nixpkgs, + nix-topology, + ... + }: { nixosConfigurations.host1 = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ - ({config, ...}: { - networking.hostName = "host1"; + ( + { config, ... }: + { + networking.hostName = "host1"; - # Network interfaces from systemd are detected automatically: - systemd.network.enable = true; - systemd.network.networks.eth0 = { - matchConfig.Name = "eth0"; - }; + # Network interfaces from systemd are detected automatically: + systemd.network.enable = true; + systemd.network.networks.eth0 = { + matchConfig.Name = "eth0"; + }; - # This node hosts a Jellyfin container - virtualisation.oci-containers.containers.jellyfin = { - image = "lscr.io/linuxserver/jellyfin:10.10.3"; - labels = { - "traefik.http.routers.jellyfin.rule" = "Host(`jellyfin.example.com`)"; + # This node hosts a Jellyfin container + virtualisation.oci-containers.containers.jellyfin = { + image = "lscr.io/linuxserver/jellyfin:10.10.3"; + labels = { + "traefik.http.routers.jellyfin.rule" = "Host(`jellyfin.example.com`)"; + }; }; - }; - # Use a built-in function to extract the host information from the container labels - topology.extractors.oci-container.infoFn = config.topology.extractors.oci-container.lib.traefikHostInfoFn; + # Use a built-in function to extract the host information from the container labels + topology.extractors.oci-container.infoFn = + config.topology.extractors.oci-container.lib.traefikHostInfoFn; - # Define a custom details function to extract and show additional information - topology.extractors.oci-container.detailsFn = c: {image.text = c.image;}; - }) + # Define a custom details function to extract and show additional information + topology.extractors.oci-container.detailsFn = c: { image.text = c.image; }; + } + ) nix-topology.nixosModules.default ]; }; } - // flake-utils.lib.eachDefaultSystem (system: rec { - pkgs = import nixpkgs { - inherit system; - overlays = [nix-topology.overlays.default]; - }; - - # This is the global topology module. - topology = import nix-topology { - inherit pkgs; - modules = [ - ({config, ...}: let - inherit (config.lib.topology) mkInternet mkConnection; - in { - inherit (self) nixosConfigurations; - - # Add a node for the internet - nodes.internet = mkInternet { - connections = mkConnection "host1" "eth0"; + // ( + let + forAllSystems = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed; + in + { + topology = forAllSystems ( + system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ nix-topology.overlays.default ]; }; - }) - ]; - }; - }); + in + import nix-topology { + inherit pkgs; + modules = [ + ( + { config, ... }: + let + inherit (config.lib.topology) mkInternet mkConnection; + in + { + inherit (self) nixosConfigurations; + nodes.internet = mkInternet { connections = mkConnection "host1" "eth0"; }; + } + ) + ]; + } + ); + } + ); } diff --git a/nixos/builtin-service-defs.nix b/nixos/builtin-service-defs.nix index 8d1465d..765bb72 100644 --- a/nixos/builtin-service-defs.nix +++ b/nixos/builtin-service-defs.nix @@ -996,7 +996,7 @@ in "navidrome" ]; enabled = cfg: cfg.enable or false; - infoFn = cfg: mkIf (cfg.settings ? BaseUrl) cfg.settings.BaseUrl; + infoFn = cfg: mkIf (cfg.settings ? Address) cfg.settings.Address; detailsFn = cfg: mkIf cfg.openFirewall { diff --git a/nixos/extractors/oci-container.nix b/nixos/extractors/oci-container.nix index d795386..f632c7d 100644 --- a/nixos/extractors/oci-container.nix +++ b/nixos/extractors/oci-container.nix @@ -1,11 +1,6 @@ -{ - config, - lib, - options, - ... -}: let - inherit - (lib) +{ config, lib, ... }: +let + inherit (lib) attrNames attrValues concatStringsSep @@ -25,63 +20,59 @@ seq strings tail - toLower types ; inherit (config.topology) serviceRegistry; - containers = - mapAttrsToList (name: cfg: cfg // {_name = name;}) - config.virtualisation.oci-containers.containers; + containers = mapAttrsToList ( + name: cfg: cfg // { _name = name; } + ) config.virtualisation.oci-containers.containers; # Remove both registry AND tag/digest - canonical = img: let - noTag = head (strings.splitString ":" img); - noDigest = head (strings.splitString "@" noTag); - parts = strings.splitString "/" (strings.toLower noDigest); + canonical = + img: + let + noTag = head (strings.splitString ":" img); + noDigest = head (strings.splitString "@" noTag); + parts = strings.splitString "/" (strings.toLower noDigest); - # If there are three or more components, - # the leftmost one is the registry. If there are only - # two, the first might be a registry or a namespace - # (e.g. "library/nginx"). - repoParts = - if (length parts) > 2 - then tail parts - else parts; - in + # If there are three or more components, + # the leftmost one is the registry. If there are only + # two, the first might be a registry or a namespace + # (e.g. "library/nginx"). + repoParts = if (length parts) > 2 then tail parts else parts; + in concatStringsSep "/" repoParts; - suffixMatch = img: repo: let - canon = canonical img; - repoLower = strings.toLower repo; - in + suffixMatch = + img: repo: + let + canon = canonical img; + repoLower = strings.toLower repo; + in canon == repoLower || strings.hasSuffix ("/" + repoLower) canon; matchesRepos = img: repos: builtins.any (suffixMatch img) repos; - matchesById = mapAttrs (_: spec: let - oci = spec.oci or null; - in - if oci == null - then [] - else filter (c: matchesRepos c.image oci.repos) containers) - serviceRegistry; - - mkServiceFromHits = id: spec: hits: - mkIf (hits != []) (let - firstHit = head hits; - defaultInfoFn = config.topology.extractors.oci-container.infoFn; - serviceInfoFn = spec.oci.infoFn or null; - infoVal = - ( - if serviceInfoFn != null - then serviceInfoFn - else defaultInfoFn - ) - firstHit; - defaultDetailsFn = config.topology.extractors.oci-container.detailsFn; - serviceDetailsFn = spec.oci.detailsFn or (_: {}); - detailsVal = (defaultDetailsFn firstHit) // (serviceDetailsFn firstHit); + matchesById = mapAttrs ( + _: spec: + let + oci = spec.oci or null; in + if oci == null then [ ] else filter (c: matchesRepos c.image oci.repos) containers + ) serviceRegistry; + + mkServiceFromHits = + id: spec: hits: + mkIf (hits != [ ]) ( + let + firstHit = head hits; + defaultInfoFn = config.topology.extractors.oci-container.infoFn; + serviceInfoFn = spec.oci.infoFn or null; + infoVal = (if serviceInfoFn != null then serviceInfoFn else defaultInfoFn) firstHit; + defaultDetailsFn = config.topology.extractors.oci-container.detailsFn; + serviceDetailsFn = spec.oci.detailsFn or (_: { }); + detailsVal = (defaultDetailsFn firstHit) // (serviceDetailsFn firstHit); + in { serviceId = id; source = "oci"; @@ -90,46 +81,53 @@ details = mkDefault detailsVal; hidden = mkDefault (spec.hidden or false); } - // optionalAttrs (spec ? icon) {icon = mkDefault spec.icon;}); + // optionalAttrs (spec ? icon) { icon = mkDefault spec.icon; } + ); - traefikHostInfoFn = cfg: let - labels = cfg.labels or {}; - ruleKeys = - filter (k: hasSuffix ".rule" k && strings.hasPrefix "traefik.http.routers." k) - (attrNames labels); - hosts = concatStringsSep " " (map (k: let - m = builtins.match "Host\\(`([^`]*)`\\)" labels.${k}; + traefikHostInfoFn = + cfg: + let + labels = cfg.labels or { }; + ruleKeys = filter (k: hasSuffix ".rule" k && strings.hasPrefix "traefik.http.routers." k) ( + attrNames labels + ); + hosts = concatStringsSep " " ( + map ( + k: + let + m = builtins.match "Host\\(`([^`]*)`\\)" labels.${k}; + in + if m == null then "" else head m + ) ruleKeys + ); in - if m == null - then "" - else head m) - ruleKeys); - in hosts; - generated = listToAttrs (mapAttrsToList - (id: spec: { + generated = listToAttrs ( + mapAttrsToList (id: spec: { name = id + "-oci"; value = mkServiceFromHits id spec matchesById.${id}; - }) - serviceRegistry); + }) serviceRegistry + ); matchedContainers = flatten (attrValues matchesById); unmatched = filter (c: !(builtins.elem c matchedContainers)) containers; _warnUnmatched = - if unmatched == [] - then null + if unmatched == [ ] then + null else - builtins.trace - ("oci-container extractor: UNMATCHED containers → " - + (concatStringsSep ", " - (map (c: "${c._name} (${c.image})") unmatched))) - null; -in { + builtins.trace ( + "oci-container extractor: UNMATCHED containers → " + + (concatStringsSep ", " (map (c: "${c._name} (${c.image})") unmatched)) + ) null; +in +{ options.topology.extractors.oci-container = { - enable = mkEnableOption "OCI container extractor" // {default = true;}; + enable = mkEnableOption "OCI container extractor" // { + default = true; + }; infoFn = mkOption { type = types.functionTo types.lines; @@ -139,7 +137,7 @@ in { detailsFn = mkOption { type = types.functionTo types.raw; - default = _: {}; + default = _: { }; description = "Custom additional detail sections extractor function"; }; @@ -147,7 +145,7 @@ in { type = types.attrs; default = { inherit traefikHostInfoFn; - imageDetailsFn = c: {image.text = c.image;}; + imageDetailsFn = c: { image.text = c.image; }; }; readOnly = true; description = "Helper functions that can be reused in infoFn/detailsFn"; diff --git a/nixos/extractors/services.nix b/nixos/extractors/services.nix index a37e64f..0a1484b 100644 --- a/nixos/extractors/services.nix +++ b/nixos/extractors/services.nix @@ -1,10 +1,6 @@ -{ - config, - lib, - ... -}: let - inherit - (lib) +{ config, lib, ... }: +let + inherit (lib) attrByPath listToAttrs mapAttrsToList @@ -16,61 +12,62 @@ inherit (config.topology) serviceRegistry; # Generate restic backup services dynamically - resticBackupServices = mapAttrsToList ( - backupName: cfg: { - name = "restic-backup-" + backupName; - value = { - serviceId = "restic-backup-" + backupName; - source = "nixos"; + resticBackupServices = mapAttrsToList (backupName: cfg: { + name = "restic-backup-" + backupName; + value = { + serviceId = "restic-backup-" + backupName; + source = "nixos"; - name = "Restic backup '${backupName}'"; - icon = "services.restic"; + name = "Restic backup '${backupName}'"; + icon = "services.restic"; - info = mkIf (cfg.repository != null) cfg.repository; - details = {paths = {text = toString cfg.paths;};}; + info = mkIf (cfg.repository != null) cfg.repository; + details = { + paths = { + text = toString cfg.paths; + }; }; - } - ) (config.services.restic.backups or {}); + }; + }) (config.services.restic.backups or { }); - mkServiceFor = id: spec: let - specNix = spec.nixos or null; - in - mkIf (specNix != null && specNix.path != []) ( + mkServiceFor = + id: spec: + let + specNix = spec.nixos or null; + in + mkIf (specNix != null && specNix.path != [ ]) ( let - cfg = attrByPath specNix.path {} config; + cfg = attrByPath specNix.path { } config; enabled = specNix.enabled cfg; in - mkIf enabled { - serviceId = id; - source = "nixos"; + mkIf enabled { + serviceId = id; + source = "nixos"; - name = mkDefault spec.name; - icon = mkDefault spec.icon; - hidden = mkDefault (spec.hidden or false); - info = let - val = - if specNix ? infoFn - then specNix.infoFn cfg - else null; + name = mkDefault spec.name; + icon = mkDefault spec.icon; + hidden = mkDefault (spec.hidden or false); + info = + let + val = if specNix ? infoFn then specNix.infoFn cfg else null; in - if val == null - then "" - else val; - details = specNix.detailsFn cfg; - } + if val == null then "" else val; + details = specNix.detailsFn cfg; + } ); generated = listToAttrs ( (mapAttrsToList (id: spec: { - name = id; - value = mkServiceFor id spec; - }) - serviceRegistry) + name = id; + value = mkServiceFor id spec; + }) serviceRegistry) ++ resticBackupServices ); -in { - options.topology.extractors.services.enable = mkEnableOption "topology service extractor" // {default = true;}; +in +{ + options.topology.extractors.services.enable = mkEnableOption "topology service extractor" // { + default = true; + }; - config.topology.self.services = - mkIf config.topology.extractors.services.enable generated; + config.topology.self.services = mkIf config.topology.extractors.services.enable generated; } diff --git a/options/services-registry.nix b/options/services-registry.nix index d1af4a1..87c2051 100644 --- a/options/services-registry.nix +++ b/options/services-registry.nix @@ -1,101 +1,104 @@ -f: {lib, ...}: let - inherit - (lib) +f: +{ lib, ... }: +let + inherit (lib) lists mkOption mkDefault types ; - serviceDefs = import ./builtin-service-defs.nix {inherit lib;}; - serviceSubmodule = {name, ...}: { - options = { - serviceId = mkOption { - type = types.str; - default = name; - description = "The unique identifier for this service. Defaults to the attribute name."; - }; + serviceDefs = import ./builtin-service-defs.nix { inherit lib; }; + serviceSubmodule = + { name, ... }: + { + options = { + serviceId = mkOption { + type = types.str; + default = name; + description = "The unique identifier for this service. Defaults to the attribute name."; + }; - name = mkOption { - type = types.str; - description = "The name of this service"; - default = ""; - }; + name = mkOption { + type = types.str; + description = "The name of this service"; + default = ""; + }; - icon = mkOption { - type = types.nullOr (types.either types.path types.str); - description = "The icon for this service. Must be a path to an image or a valid icon name (.)."; - default = null; - }; + icon = mkOption { + type = types.nullOr (types.either types.path types.str); + description = "The icon for this service. Must be a path to an image or a valid icon name (.)."; + default = null; + }; - hidden = mkOption { - type = types.bool; - default = false; - description = "Whether this service should be hidden from graphs"; - }; + hidden = mkOption { + type = types.bool; + default = false; + description = "Whether this service should be hidden from graphs"; + }; - nixos = mkOption { - type = types.submodule { - options = { - path = mkOption { - type = types.listOf types.str; - default = []; - description = "The NixOS options path for this service"; - apply = lists.unique; - }; - enabled = mkOption { - type = types.anything; - default = _: true; - description = "A function that determines if this service is enabled for a given NixOS configuration"; - }; - infoFn = mkOption { - type = types.anything; - default = _: ""; - description = "A function that returns a string with additional high-profile information about this service, usually the url or listen address. Most likely shown directly below the name."; - }; - detailsFn = mkOption { - type = types.anything; - default = _: {}; - description = "A function that returns additional detail sections that should be shown to the user"; + nixos = mkOption { + type = types.submodule { + options = { + path = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "The NixOS options path for this service"; + apply = lists.unique; + }; + enabled = mkOption { + type = types.anything; + default = _: true; + description = "A function that determines if this service is enabled for a given NixOS configuration"; + }; + infoFn = mkOption { + type = types.anything; + default = _: ""; + description = "A function that returns a string with additional high-profile information about this service, usually the url or listen address. Most likely shown directly below the name."; + }; + detailsFn = mkOption { + type = types.anything; + default = _: { }; + description = "A function that returns additional detail sections that should be shown to the user"; + }; }; }; + default = { }; + description = "NixOS-specific metadata for this service"; }; - default = {}; - description = "NixOS-specific metadata for this service"; - }; - oci = mkOption { - type = types.submodule { - options = { - repos = mkOption { - type = types.listOf types.str; - default = []; - description = "A list of container image repositories that provide this service"; - apply = lists.unique; - }; - infoFn = mkOption { - type = types.anything; - default = null; - description = "A function that returns a string with additional high-profile information about the container instance, such as a URL or address. Most likely shown directly below the name."; - }; - detailsFn = mkOption { - type = types.anything; - default = _: {}; - description = "A function that returns additional detail sections that should be shown to the user"; + oci = mkOption { + type = types.submodule { + options = { + repos = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "A list of container image repositories that provide this service"; + apply = lists.unique; + }; + infoFn = mkOption { + type = types.anything; + default = null; + description = "A function that returns a string with additional high-profile information about the container instance, such as a URL or address. Most likely shown directly below the name."; + }; + detailsFn = mkOption { + type = types.anything; + default = _: { }; + description = "A function that returns additional detail sections that should be shown to the user"; + }; }; }; + default = { }; + description = "OCI (container) specific metadata for this service"; }; - default = {}; - description = "OCI (container) specific metadata for this service"; }; }; - }; in - f { - options.serviceRegistry = mkOption { - description = "A registry of known services containing all metadata (name, icon, visibility flags) plus NixOS- and OCI-specific discovery, info, and details functions used by the graph renderers."; - type = types.attrsOf (types.submodule serviceSubmodule); - default = {}; - }; +f { + options.serviceRegistry = mkOption { + description = "A registry of known services containing all metadata (name, icon, visibility flags) plus NixOS- and OCI-specific discovery, info, and details functions used by the graph renderers."; + type = types.attrsOf (types.submodule serviceSubmodule); + default = { }; + }; - config.serviceRegistry = mkDefault serviceDefs; - } + config.serviceRegistry = mkDefault serviceDefs; +} diff --git a/options/services.nix b/options/services.nix index 52a5919..7c61feb 100644 --- a/options/services.nix +++ b/options/services.nix @@ -40,9 +40,10 @@ f { description = "The name of this service"; type = types.str; default = - if submod.config.serviceId != null - then config.serviceRegistry.${submod.config.serviceId}.name - else submod.config.id; + if submod.config.serviceId != null then + config.serviceRegistry.${submod.config.serviceId}.name + else + submod.config.id; }; hidden = mkOption { @@ -55,14 +56,18 @@ f { description = "The icon for this service. Must be a path to an image or a valid icon name (.)."; type = types.nullOr (types.either types.path types.str); default = - if submod.config.serviceId != null - then (config.serviceRegistry.${submod.config.serviceId}.icon or null) - else null; + if submod.config.serviceId != null then + (config.serviceRegistry.${submod.config.serviceId}.icon or null) + else + null; }; source = mkOption { description = "The source of this service, e.g. the extractor that provides it"; - type = types.enum ["nixos" "oci"]; + type = types.enum [ + "nixos" + "oci" + ]; default = "nixos"; }; @@ -119,16 +124,14 @@ f { assertions = flatten ( flip map (attrValues config.nodes) ( node: - flip map (attrValues node.services) ( - service: [ - (config.lib.assertions.iconValid - service.icon "nodes.${node.id}.services.${service.id}") - (mkIf (service.serviceId != null && !hasPrefix "restic-backup-" service.serviceId) { - assertion = config.serviceRegistry ? ${service.serviceId}; - message = "serviceId '${service.serviceId}' for nodes.${node.id}.services.${service.id} is not defined in topology.serviceRegistry"; - }) - ] - ) - )); - }; - } + flip map (attrValues node.services) (service: [ + (config.lib.assertions.iconValid service.icon "nodes.${node.id}.services.${service.id}") + (mkIf (service.serviceId != null && !hasPrefix "restic-backup-" service.serviceId) { + assertion = config.serviceRegistry ? ${service.serviceId}; + message = "serviceId '${service.serviceId}' for nodes.${node.id}.services.${service.id} is not defined in topology.serviceRegistry"; + }) + ]) + ) + ); + }; +} From 573ef9b87d07b67b7adbe64fe037a7f03a1aee9d Mon Sep 17 00:00:00 2001 From: johnspade Date: Wed, 4 Feb 2026 01:46:03 +0100 Subject: [PATCH 4/6] Fix forgejo/gitea details --- nixos/builtin-service-defs.nix | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nixos/builtin-service-defs.nix b/nixos/builtin-service-defs.nix index 765bb72..9608a3b 100644 --- a/nixos/builtin-service-defs.nix +++ b/nixos/builtin-service-defs.nix @@ -336,8 +336,10 @@ in enabled = cfg: cfg.enable or false; infoFn = cfg: mkIf (cfg.settings ? server.ROOT_URL) cfg.settings.server.ROOT_URL; detailsFn = cfg: { - name = - if cfg.settings ? DEFAULT.APP_NAME then "Forgejo (${cfg.settings.DEFAULT.APP_NAME})" else "Forgejo"; + name = { + text = + if cfg.settings ? DEFAULT.APP_NAME then "Forgejo (${cfg.settings.DEFAULT.APP_NAME})" else "Forgejo"; + }; listen = mkIf ( (cfg.settings.server.HTTP_ADDR or null) != null && (cfg.settings.server.HTTP_PORT or null) != null ) { text = "${cfg.settings.server.HTTP_ADDR}:${toString cfg.settings.server.HTTP_PORT}"; }; @@ -358,8 +360,10 @@ in enabled = cfg: cfg.enable or false; infoFn = cfg: mkIf (cfg.settings ? server.ROOT_URL) cfg.settings.server.ROOT_URL; detailsFn = cfg: { - name = - if cfg.settings ? DEFAULT.APP_NAME then "gitea (${cfg.settings.DEFAULT.APP_NAME})" else "gitea"; + name = { + text = + if cfg.settings ? DEFAULT.APP_NAME then "gitea (${cfg.settings.DEFAULT.APP_NAME})" else "gitea"; + }; listen = mkIf ( (cfg.settings.server.HTTP_ADDR or null) != null && (cfg.settings.server.HTTP_PORT or null) != null ) { text = "${cfg.settings.server.HTTP_ADDR}:${toString cfg.settings.server.HTTP_PORT}"; }; From 7df4845c0577a11acf7f0e10ef7eac40b808de5a Mon Sep 17 00:00:00 2001 From: johnspade Date: Wed, 4 Feb 2026 03:29:14 +0100 Subject: [PATCH 5/6] Add service definition tests --- nixos/builtin-service-defs.nix | 61 +-- tests/service-defs/flake.lock | 78 ++++ tests/service-defs/flake.nix | 746 +++++++++++++++++++++++++++++++++ 3 files changed, 861 insertions(+), 24 deletions(-) create mode 100644 tests/service-defs/flake.lock create mode 100644 tests/service-defs/flake.nix diff --git a/nixos/builtin-service-defs.nix b/nixos/builtin-service-defs.nix index 9608a3b..0cd5227 100644 --- a/nixos/builtin-service-defs.nix +++ b/nixos/builtin-service-defs.nix @@ -129,7 +129,7 @@ in mapAttrsToList (name: v: { inherit name; value = { - text = "${v.settings.server.host}:${toString v.settings.server.port}"; + text = v.settings.server.address or "${v.settings.server.host}:${toString v.settings.server.port}"; }; }) instances ); @@ -340,9 +340,12 @@ in text = if cfg.settings ? DEFAULT.APP_NAME then "Forgejo (${cfg.settings.DEFAULT.APP_NAME})" else "Forgejo"; }; - listen = mkIf ( - (cfg.settings.server.HTTP_ADDR or null) != null && (cfg.settings.server.HTTP_PORT or null) != null - ) { text = "${cfg.settings.server.HTTP_ADDR}:${toString cfg.settings.server.HTTP_PORT}"; }; + listen = + let + addr = cfg.settings.server.HTTP_ADDR or null; + port = cfg.settings.server.HTTP_PORT or null; + in + mkIf (addr != null && port != null) { text = "${addr}:${toString port}"; }; }; }; oci = { @@ -364,9 +367,12 @@ in text = if cfg.settings ? DEFAULT.APP_NAME then "gitea (${cfg.settings.DEFAULT.APP_NAME})" else "gitea"; }; - listen = mkIf ( - (cfg.settings.server.HTTP_ADDR or null) != null && (cfg.settings.server.HTTP_PORT or null) != null - ) { text = "${cfg.settings.server.HTTP_ADDR}:${toString cfg.settings.server.HTTP_PORT}"; }; + listen = + let + addr = cfg.settings.server.HTTP_ADDR or null; + port = cfg.settings.server.HTTP_PORT or null; + in + mkIf (addr != null && port != null) { text = "${addr}:${toString port}"; }; }; }; oci = { @@ -408,14 +414,17 @@ in ]; enabled = cfg: cfg.enable or false; infoFn = cfg: cfg.settings.server.root_url; - detailsFn = cfg: { - listen = mkIf ( - (cfg.settings.server.http_addr or null) != null && (cfg.settings.server.http_port or null) != null - ) { text = "${cfg.settings.server.http_addr}:${toString cfg.settings.server.http_port}"; }; - plugins = mkIf ((cfg.declarativePlugins or null) != null) { - text = concatStringsSep "\n" (map (p: p.name) cfg.declarativePlugins); + detailsFn = + cfg: + let + addr = cfg.settings.server.http_addr or null; + port = cfg.settings.server.http_port or null; + plugins = cfg.declarativePlugins or null; + in + { + listen = mkIf (addr != null && port != null) { text = "${addr}:${toString port}"; }; + plugins = mkIf (plugins != null) { text = concatStringsSep "\n" (map (p: p.name) plugins); }; }; - }; }; oci = { repos = [ @@ -813,16 +822,15 @@ in enabled = cfg: cfg.enable or false; detailsFn = cfg: - mkIf - ( - (cfg.configuration.server.http_listen_address or null) != null - && (cfg.configuration.server.http_listen_port or null) != null - ) - { - listen = { - text = "${cfg.configuration.server.http_listen_address}:${toString cfg.configuration.server.http_listen_port}"; - }; + let + addr = cfg.configuration.server.http_listen_address or null; + port = cfg.configuration.server.http_listen_port or null; + in + mkIf (addr != null && port != null) { + listen = { + text = "${addr}:${toString port}"; }; + }; }; oci = { repos = [ "grafana/loki" ]; @@ -1190,7 +1198,12 @@ in "paperless" ]; enabled = cfg: cfg.enable or false; - infoFn = cfg: mkIf ((cfg.settings.PAPERLESS_URL or null) != null) cfg.settings.PAPERLESS_URL; + infoFn = + cfg: + let + url = cfg.settings.PAPERLESS_URL or null; + in + mkIf (url != null) url; detailsFn = cfg: { listen = { text = "${cfg.address}:${toString cfg.port}"; diff --git a/tests/service-defs/flake.lock b/tests/service-defs/flake.lock new file mode 100644 index 0000000..24dc51c --- /dev/null +++ b/tests/service-defs/flake.lock @@ -0,0 +1,78 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1765835352, + "narHash": "sha256-XswHlK/Qtjasvhd1nOa1e8MgZ8GS//jBoTqWtrS1Giw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "a34fae9c08a15ad73f295041fec82323541400a9", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nix-topology": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "path": "../..", + "type": "path" + }, + "original": { + "path": "../..", + "type": "path" + }, + "parent": [] + }, + "nixpkgs": { + "locked": { + "lastModified": 1770115704, + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1765674936, + "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "nix-topology": "nix-topology", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/tests/service-defs/flake.nix b/tests/service-defs/flake.nix new file mode 100644 index 0000000..23efbf1 --- /dev/null +++ b/tests/service-defs/flake.nix @@ -0,0 +1,746 @@ +{ + description = "Test flake to verify all service definitions evaluate correctly"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + nix-topology.url = "../.."; + nix-topology.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = + { + self, + nixpkgs, + nix-topology, + ... + }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { + inherit system; + overlays = [ nix-topology.overlays.default ]; + config.allowUnfree = true; + }; + + # Helper to create dummy secret files + dummySecret = pkgs.writeText "dummy-secret" "dummy-secret-value"; + dummySecretKey = pkgs.writeText "dummy-key" "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + dummyPrivateKey = pkgs.writeText "dummy-private-key" '' + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW + QyNTUxOQAAACBQcE4kBIxAo+pB3gJIr8kIkz1n8X7b7Y7YqM9Pz8LzNwAAAJgUCgCcFAoA + nAAAAAtzc2gtZWQyNTUxOQAAACBQcE4kBIxAo+pB3gJIr8kIkz1n8X7b7Y7YqM9Pz8LzNw + AAAECdj/I5MZy39NYMvJJCQk0s5kAcN7x5Z5e7A1d5A0VgU1BwTiQEjECj6kHeAkivyQiT + PWfxftvtjthoz0/PwvM3AAAAEXJvb3RAbml4b3MtdGVzdGVyAQIDBA== + -----END OPENSSH PRIVATE KEY----- + ''; + dummyCert = pkgs.writeText "dummy-cert" '' + -----BEGIN CERTIFICATE----- + MIIBkTCB+wIJALQ2tZT1k6qwMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBmR1 + bW15MTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM + BmR1bW15MTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96HtiKQFxmBvB3LtmGW + fL+NtYH5gxLqT3C8kE6l3LxPz7t0H+Y6x4Q3Y8L3G8S3Z5D4E5s7Y7P2Q3K3KNXX + AgMBAAGjUzBRMB0GA1UdDgQWBBRJFMRRqVJ3pDYJ2r8p2G5D0qr2QDAfBgNVHSME + GDAWgBRJFMRRqVJ3pDYJ2r8p2G5D0qr2QDAPBgNVHRMBAf8EBTADAQH/MA0GCSqG + SIb3DQEBCwUAA0EAT+2B3iF7V6q3nN6Q3V8E0W5B2M0Q3Y8L3G8S3Z5D4E5s7Y7P + 2Q3K3KNXX7o96HtiKQFxmBvB3LtmGWfL+NtYH5gxLqT3C8kE6l3LxPw== + -----END CERTIFICATE----- + ''; + in + { + nixosConfigurations.test-services = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + nix-topology.nixosModules.default + ( + { pkgs, ... }: + { + # Basic system configuration + networking.hostName = "test-services"; + system.stateVersion = "24.05"; + boot.loader.grub.device = "nodev"; + fileSystems."/" = { + device = "/dev/null"; + fsType = "tmpfs"; + }; + + # Allow unfree packages (needed for coder/terraform) + nixpkgs.config.allowUnfree = true; + # Allow insecure packages (olm needed by mautrix-signal) + nixpkgs.config.permittedInsecurePackages = [ "olm-3.2.16" ]; + + # PostgreSQL - needed by several services + services.postgresql = { + enable = true; + settings.port = 5432; + }; + + # MySQL - needed by some services (mastodon) + services.mysql.package = pkgs.mariadb; + + services.alloy.enable = true; + services.fail2ban.enable = true; + services.harmonia.enable = true; + services.karakeep.enable = true; + services.i2pd.enable = true; + services.openssh.enable = true; + services.tor = { + enable = true; + relay.role = "relay"; + }; + + services.mastodon = { + enable = true; + localDomain = "mastodon.example.com"; + configureNginx = false; + smtp.fromAddress = "noreply@example.com"; + secretKeyBaseFile = "/var/lib/mastodon/secret_key_base"; + vapidPublicKeyFile = "/var/lib/mastodon/vapid_public_key"; + vapidPrivateKeyFile = "/var/lib/mastodon/vapid_private_key"; + streamingProcesses = 1; + }; + + services.adguardhome = { + enable = true; + host = "0.0.0.0"; + port = 3000; + }; + + services.code-server = { + enable = true; + host = "0.0.0.0"; + port = 4444; + }; + + services.esphome = { + enable = true; + address = "0.0.0.0"; + port = 6052; + }; + + services.headscale = { + enable = true; + address = "0.0.0.0"; + port = 8080; + settings = { + server_url = "http://headscale.example.com"; + dns.base_domain = "example.com"; + }; + }; + + services.invidious = { + enable = true; + address = "0.0.0.0"; + port = 3001; + }; + + services.languagetool = { + enable = true; + port = 8081; + }; + + services.libretranslate = { + enable = true; + host = "0.0.0.0"; + port = 5000; + }; + + services.meilisearch = { + enable = true; + listenAddress = "0.0.0.0"; + listenPort = 7700; + }; + + services.mautrix-signal = { + enable = true; + settings = { + homeserver.address = "http://localhost:8008"; + appservice = { + hostname = "0.0.0.0"; + port = 29328; + database.type = "sqlite3"; + }; + bridge.permissions."*" = "relay"; + signal.socket_path = "/var/run/signald/signald.sock"; + }; + }; + + services.mautrix-whatsapp = { + enable = true; + settings = { + homeserver.address = "http://localhost:8008"; + appservice = { + hostname = "0.0.0.0"; + port = 29329; + database.type = "sqlite3"; + }; + bridge.permissions."*" = "relay"; + }; + }; + + services.nix-serve = { + enable = true; + bindAddress = "0.0.0.0"; + port = 5000; + }; + + services.ollama = { + enable = true; + host = "0.0.0.0"; + port = 11434; + openFirewall = true; + }; + + services.open-webui = { + enable = true; + host = "0.0.0.0"; + port = 8082; + openFirewall = true; + }; + + services.owncast = { + enable = true; + listen = "0.0.0.0"; + port = 8083; + openFirewall = true; + }; + + services.paperless = { + enable = true; + address = "0.0.0.0"; + port = 28981; + settings.PAPERLESS_URL = "http://paperless.example.com"; + }; + + services.redlib = { + enable = true; + address = "0.0.0.0"; + port = 8084; + openFirewall = true; + }; + + services.tabby = { + enable = true; + host = "0.0.0.0"; + port = 8085; + }; + + services.zipline = { + enable = true; + settings = { + CORE_HOSTNAME = "0.0.0.0"; + CORE_PORT = 3002; + CORE_SECRET = "dummy-secret-32-chars-long-xxxxx"; + }; + }; + + services.immich = { + enable = true; + host = "0.0.0.0"; + port = 2283; + openFirewall = true; + secretsFile = null; + }; + + services.coder = { + enable = true; + listenAddress = "0.0.0.0:3003"; + accessUrl = "http://coder.example.com"; + }; + + services.prometheus = { + enable = true; + listenAddress = "0.0.0.0"; + port = 9090; + }; + + services.anki-sync-server = { + enable = true; + address = "0.0.0.0"; + port = 27701; + openFirewall = true; + users = [ + { + username = "testuser"; + password = "testpass"; + } + ]; + }; + + services.atuin = { + enable = true; + host = "0.0.0.0"; + port = 8888; + openFirewall = true; + }; + + services.glance = { + enable = true; + openFirewall = true; + settings.server = { + host = "0.0.0.0"; + port = 8086; + }; + }; + + services.jellyseerr = { + enable = true; + port = 5055; + openFirewall = true; + }; + + services.komga = { + enable = true; + openFirewall = true; + settings.server.port = 8087; + }; + + services.lidarr = { + enable = true; + openFirewall = true; + }; + + services.navidrome = { + enable = true; + openFirewall = true; + settings = { + Address = "0.0.0.0"; + Port = 4533; + }; + }; + + services.prowlarr = { + enable = true; + openFirewall = true; + }; + + services.radarr = { + enable = true; + openFirewall = true; + }; + + services.scrutiny = { + enable = true; + openFirewall = true; + settings.web.listen = { + host = "0.0.0.0"; + port = 8088; + }; + }; + + services.sonarr = { + enable = true; + openFirewall = true; + }; + + services.step-ca = { + enable = true; + address = "0.0.0.0"; + port = 8443; + openFirewall = true; + intermediatePasswordFile = "/var/lib/step-ca/intermediate_password"; + settings = { + root = dummyCert; + crt = dummyCert; + key = dummyPrivateKey; + dnsNames = [ "ca.example.com" ]; + db = { + type = "badger"; + dataSource = "/var/lib/step-ca/db"; + }; + authority.provisioners = [ ]; + }; + }; + + services.forgejo = { + enable = true; + settings = { + DEFAULT.APP_NAME = "Test Forgejo"; + server = { + ROOT_URL = "http://forgejo.example.com"; + HTTP_ADDR = "0.0.0.0"; + HTTP_PORT = 3004; + }; + }; + }; + + services.gitea = { + enable = true; + settings = { + DEFAULT.APP_NAME = "Test Gitea"; + server = { + ROOT_URL = "http://gitea.example.com"; + HTTP_ADDR = "0.0.0.0"; + HTTP_PORT = 3005; + }; + }; + }; + + services.grafana = { + enable = true; + settings.server = { + root_url = "http://grafana.example.com"; + http_addr = "0.0.0.0"; + http_port = 3006; + }; + }; + + services.loki = { + enable = true; + configuration = { + auth_enabled = false; + server = { + http_listen_address = "0.0.0.0"; + http_listen_port = 3100; + }; + common = { + ring = { + instance_addr = "127.0.0.1"; + kvstore.store = "inmemory"; + }; + replication_factor = 1; + path_prefix = "/var/lib/loki"; + }; + schema_config.configs = [ + { + from = "2024-01-01"; + store = "tsdb"; + object_store = "filesystem"; + schema = "v13"; + index = { + prefix = "index_"; + period = "24h"; + }; + } + ]; + storage_config.filesystem.directory = "/var/lib/loki/chunks"; + }; + }; + + services.searx = { + enable = true; + settings = { + server = { + bind_address = "0.0.0.0"; + port = 8089; + secret_key = "dummy-secret-key"; + }; + }; + }; + + services.kavita = { + enable = true; + settings.Port = 5001; + tokenKeyFile = dummySecretKey; + }; + + services.influxdb2 = { + enable = true; + settings.http-bind-address = "0.0.0.0:8086"; + }; + + services.firefox-syncserver = { + enable = true; + singleNode = { + enable = true; + url = "http://firefox-sync.example.com"; + hostname = "firefox-sync.example.com"; + }; + secrets = dummySecret; + settings = { + host = "0.0.0.0"; + port = 5002; + }; + }; + + services.radicale = { + enable = true; + settings = { + server.hosts = [ "0.0.0.0:5232" ]; + auth.type = "none"; + }; + }; + + services.authelia.instances.main = { + enable = true; + secrets = { + jwtSecretFile = dummySecret; + storageEncryptionKeyFile = dummySecretKey; + }; + settings = { + theme = "light"; + server.address = "tcp://0.0.0.0:9091"; + authentication_backend.file.path = "/var/lib/authelia-main/users.yml"; + storage.local.path = "/var/lib/authelia-main/db.sqlite3"; + access_control.default_policy = "deny"; + session.domain = "example.com"; + notifier.filesystem.filename = "/var/lib/authelia-main/notifications.txt"; + }; + }; + + services.blocky = { + enable = true; + settings = { + ports = { + dns = 5353; + http = 4000; + }; + upstreams.groups.default = [ "1.1.1.1" ]; + }; + }; + + services.caddy = { + enable = true; + virtualHosts."example.com".extraConfig = '' + reverse_proxy localhost:8080 { + } + ''; + }; + + services.dnsmasq = { + enable = true; + settings.address = [ "/example.com/192.168.1.1" ]; + }; + + services.mosquitto = { + enable = true; + listeners = [ + { + address = "0.0.0.0"; + port = 1883; + users = { }; + omitPasswordAuth = true; + settings.allow_anonymous = true; + } + ]; + }; + + services.nginx = { + enable = true; + virtualHosts."nginx.example.com" = { + locations."/" = { + proxyPass = "http://localhost:8080"; + }; + }; + }; + + services.traefik = { + enable = true; + dynamicConfigOptions = { + http = { + routers.test-router = { + rule = "Host(`test.example.com`)"; + service = "test-service"; + }; + services.test-service.loadBalancer.servers = [ { url = "http://localhost:8080"; } ]; + }; + }; + }; + + services.hickory-dns = { + enable = true; + settings = { + listen_addrs_ipv4 = [ "0.0.0.0" ]; + listen_addrs_ipv6 = [ "::" ]; + listen_port = 8053; + zones = [ ]; + }; + }; + + services.home-assistant = { + enable = true; + config = { + homeassistant = { + name = "Test Home"; + unit_system = "metric"; + time_zone = "UTC"; + }; + http = { + server_host = [ "0.0.0.0" ]; + server_port = 8123; + }; + }; + }; + + services.hydra = { + enable = true; + hydraURL = "http://hydra.example.com"; + listenHost = "0.0.0.0"; + port = 3007; + notificationSender = "hydra@example.com"; + }; + + services.kanidm = { + enableServer = true; + package = pkgs.kanidm_1_8; + serverSettings = { + origin = "https://kanidm.example.com"; + bindaddress = "0.0.0.0:8443"; + domain = "kanidm.example.com"; + tls_chain = "/var/lib/kanidm/chain.pem"; + tls_key = "/var/lib/kanidm/key.pem"; + }; + }; + + services.matrix-synapse = { + enable = true; + settings = { + server_name = "matrix.example.com"; + public_baseurl = "https://matrix.example.com"; + listeners = [ + { + port = 8008; + bind_addresses = [ "0.0.0.0" ]; + type = "http"; + tls = false; + resources = [ + { + names = [ + "client" + "federation" + ]; + compress = true; + } + ]; + } + ]; + }; + }; + + services.mpd = { + enable = true; + settings.music_directory = "/var/lib/mpd/music"; + }; + + services.plausible = { + enable = true; + server = { + baseUrl = "http://plausible.example.com"; + listenAddress = "0.0.0.0"; + port = 8000; + secretKeybaseFile = dummySecret; + }; + }; + + services.static-web-server = { + enable = true; + listen = "[::]:8787"; + root = "/var/www"; + }; + + services.stirling-pdf = { + enable = true; + environment = { + SERVER_HOST = "0.0.0.0"; + SERVER_PORT = "8088"; + }; + }; + + services.transmission = { + enable = true; + package = pkgs.transmission_4; + settings = { + rpc-bind-address = "0.0.0.0"; + rpc-port = 9091; + }; + }; + + services.vaultwarden = { + enable = true; + config = { + rocketAddress = "0.0.0.0"; + rocketPort = 8012; + domain = "https://vault.example.com"; + }; + }; + + services.zigbee2mqtt = { + enable = true; + settings = { + permit_join = false; + mqtt.server = "mqtt://localhost:1883"; + serial.port = "/dev/null"; + frontend = { + host = "0.0.0.0"; + port = 8092; + }; + }; + }; + + services.nextcloud = { + enable = true; + package = pkgs.nextcloud32; + hostName = "nextcloud.example.com"; + https = true; + config = { + adminpassFile = null; + adminuser = null; + dbtype = "sqlite"; + }; + }; + + services.jellyfin = { + enable = true; + openFirewall = true; + }; + + services.oauth2-proxy = { + enable = true; + httpAddress = "http://0.0.0.0:4180"; + cookie.secret = "0123456789abcdef0123456789abcdef"; + clientID = "test-client-id"; + clientSecret = "test-client-secret"; + provider = "google"; + email.domains = [ "*" ]; + keyFile = dummySecret; + }; + + services.samba = { + enable = true; + settings = { + global = { + workgroup = "WORKGROUP"; + "server string" = "Test Samba"; + }; + public = { + path = "/srv/public"; + browseable = "yes"; + "read only" = "no"; + }; + }; + }; + } + ) + ]; + }; + + topology.${system} = + let + topologyPkgs = import nixpkgs { + inherit system; + overlays = [ nix-topology.overlays.default ]; + }; + in + import nix-topology { + pkgs = topologyPkgs; + modules = [ { inherit (self) nixosConfigurations; } ]; + }; + + # Evaluation check - forces topology to be evaluated + # This derivation depends on the actual topology output, forcing full evaluation + checks.${system}.service-defs = + let + topologyOutput = self.topology.${system}.config.output; + in + pkgs.runCommand "check-service-defs" + { + # Reference the topology output to force evaluation + inherit topologyOutput; + } + '' + echo "Topology evaluation successful" + echo "Services extracted from configuration" + touch $out + ''; + }; +} From 74ddc6dfbca0bff3c050aca5343494f9b8e00932 Mon Sep 17 00:00:00 2001 From: johnspade Date: Sat, 7 Feb 2026 02:16:07 +0100 Subject: [PATCH 6/6] Run service definition tests with nix flake check --- dev/default.nix | 34 ++ dev/flake.lock | 18 +- options/networks.nix | 2 +- tests/service-defs/flake.nix | 689 +---------------------------- tests/service-defs/test-module.nix | 675 ++++++++++++++++++++++++++++ topology/default.nix | 2 +- topology/renderers/svg/default.nix | 2 +- 7 files changed, 724 insertions(+), 698 deletions(-) create mode 100644 tests/service-defs/test-module.nix diff --git a/dev/default.nix b/dev/default.nix index 89b8f97..2307b95 100644 --- a/dev/default.nix +++ b/dev/default.nix @@ -93,5 +93,39 @@ }; devShells.default = config.pre-commit.devShell; + + checks = inputs.nixpkgs.lib.optionalAttrs (system == "x86_64-linux") { + service-defs = + let + checkPkgs = import inputs.nixpkgs { + inherit system; + overlays = [ self.overlays.default ]; + config.allowUnfree = true; + }; + + nixos = inputs.nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + self.nixosModules.default + ../tests/service-defs/test-module.nix + ]; + }; + + topology = import self { + pkgs = checkPkgs; + modules = [ { nixosConfigurations.test-services = nixos; } ]; + }; + in + checkPkgs.runCommand "check-service-defs" + { + topologyOutput = topology.config.output; + nixosToplevel = nixos.config.system.build.toplevel; + } + '' + echo "Topology output: $topologyOutput" + ls -la "$topologyOutput" + touch $out + ''; + }; }; } diff --git a/dev/flake.lock b/dev/flake.lock index 200386c..9c506d5 100644 --- a/dev/flake.lock +++ b/dev/flake.lock @@ -25,11 +25,11 @@ ] }, "locked": { - "lastModified": 1767281941, - "narHash": "sha256-6MkqajPICgugsuZ92OMoQcgSHnD6sJHwk8AxvMcIgTE=", + "lastModified": 1769939035, + "narHash": "sha256-Fok2AmefgVA0+eprw2NDwqKkPGEI5wvR+twiZagBvrg=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "f0927703b7b1c8d97511c4116eb9b4ec6645a0fa", + "rev": "a8ca480175326551d6c4121498316261cbb5b260", "type": "github" }, "original": { @@ -61,11 +61,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1767892417, - "narHash": "sha256-dhhvQY67aboBk8b0/u0XB6vwHdgbROZT3fJAjyNh5Ww=", + "lastModified": 1770197578, + "narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2", "type": "github" }, "original": { @@ -89,11 +89,11 @@ ] }, "locked": { - "lastModified": 1767801790, - "narHash": "sha256-QfX6g3Wj2vQe7oBJEbTf0npvC6sJoDbF9hb2+gM5tf8=", + "lastModified": 1770228511, + "narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "778a1d691f1ef45dd68c661715c5bf8cbf131c80", + "rev": "337a4fe074be1042a35086f15481d763b8ddc0e7", "type": "github" }, "original": { diff --git a/options/networks.nix b/options/networks.nix index a095712..f156c8a 100644 --- a/options/networks.nix +++ b/options/networks.nix @@ -130,7 +130,7 @@ f { } // optionalAttrs config.topology.isMainModule { default = lazyValue computedStyles.${networkSubmod.config.id}; - defaultText = ''''; + defaultText = ""; }; cidrv4 = mkOption { diff --git a/tests/service-defs/flake.nix b/tests/service-defs/flake.nix index 23efbf1..0756c5d 100644 --- a/tests/service-defs/flake.nix +++ b/tests/service-defs/flake.nix @@ -21,696 +21,13 @@ overlays = [ nix-topology.overlays.default ]; config.allowUnfree = true; }; - - # Helper to create dummy secret files - dummySecret = pkgs.writeText "dummy-secret" "dummy-secret-value"; - dummySecretKey = pkgs.writeText "dummy-key" "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - dummyPrivateKey = pkgs.writeText "dummy-private-key" '' - -----BEGIN OPENSSH PRIVATE KEY----- - b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW - QyNTUxOQAAACBQcE4kBIxAo+pB3gJIr8kIkz1n8X7b7Y7YqM9Pz8LzNwAAAJgUCgCcFAoA - nAAAAAtzc2gtZWQyNTUxOQAAACBQcE4kBIxAo+pB3gJIr8kIkz1n8X7b7Y7YqM9Pz8LzNw - AAAECdj/I5MZy39NYMvJJCQk0s5kAcN7x5Z5e7A1d5A0VgU1BwTiQEjECj6kHeAkivyQiT - PWfxftvtjthoz0/PwvM3AAAAEXJvb3RAbml4b3MtdGVzdGVyAQIDBA== - -----END OPENSSH PRIVATE KEY----- - ''; - dummyCert = pkgs.writeText "dummy-cert" '' - -----BEGIN CERTIFICATE----- - MIIBkTCB+wIJALQ2tZT1k6qwMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBmR1 - bW15MTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM - BmR1bW15MTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96HtiKQFxmBvB3LtmGW - fL+NtYH5gxLqT3C8kE6l3LxPz7t0H+Y6x4Q3Y8L3G8S3Z5D4E5s7Y7P2Q3K3KNXX - AgMBAAGjUzBRMB0GA1UdDgQWBBRJFMRRqVJ3pDYJ2r8p2G5D0qr2QDAfBgNVHSME - GDAWgBRJFMRRqVJ3pDYJ2r8p2G5D0qr2QDAPBgNVHRMBAf8EBTADAQH/MA0GCSqG - SIb3DQEBCwUAA0EAT+2B3iF7V6q3nN6Q3V8E0W5B2M0Q3Y8L3G8S3Z5D4E5s7Y7P - 2Q3K3KNXX7o96HtiKQFxmBvB3LtmGWfL+NtYH5gxLqT3C8kE6l3LxPw== - -----END CERTIFICATE----- - ''; in { nixosConfigurations.test-services = nixpkgs.lib.nixosSystem { inherit system; modules = [ nix-topology.nixosModules.default - ( - { pkgs, ... }: - { - # Basic system configuration - networking.hostName = "test-services"; - system.stateVersion = "24.05"; - boot.loader.grub.device = "nodev"; - fileSystems."/" = { - device = "/dev/null"; - fsType = "tmpfs"; - }; - - # Allow unfree packages (needed for coder/terraform) - nixpkgs.config.allowUnfree = true; - # Allow insecure packages (olm needed by mautrix-signal) - nixpkgs.config.permittedInsecurePackages = [ "olm-3.2.16" ]; - - # PostgreSQL - needed by several services - services.postgresql = { - enable = true; - settings.port = 5432; - }; - - # MySQL - needed by some services (mastodon) - services.mysql.package = pkgs.mariadb; - - services.alloy.enable = true; - services.fail2ban.enable = true; - services.harmonia.enable = true; - services.karakeep.enable = true; - services.i2pd.enable = true; - services.openssh.enable = true; - services.tor = { - enable = true; - relay.role = "relay"; - }; - - services.mastodon = { - enable = true; - localDomain = "mastodon.example.com"; - configureNginx = false; - smtp.fromAddress = "noreply@example.com"; - secretKeyBaseFile = "/var/lib/mastodon/secret_key_base"; - vapidPublicKeyFile = "/var/lib/mastodon/vapid_public_key"; - vapidPrivateKeyFile = "/var/lib/mastodon/vapid_private_key"; - streamingProcesses = 1; - }; - - services.adguardhome = { - enable = true; - host = "0.0.0.0"; - port = 3000; - }; - - services.code-server = { - enable = true; - host = "0.0.0.0"; - port = 4444; - }; - - services.esphome = { - enable = true; - address = "0.0.0.0"; - port = 6052; - }; - - services.headscale = { - enable = true; - address = "0.0.0.0"; - port = 8080; - settings = { - server_url = "http://headscale.example.com"; - dns.base_domain = "example.com"; - }; - }; - - services.invidious = { - enable = true; - address = "0.0.0.0"; - port = 3001; - }; - - services.languagetool = { - enable = true; - port = 8081; - }; - - services.libretranslate = { - enable = true; - host = "0.0.0.0"; - port = 5000; - }; - - services.meilisearch = { - enable = true; - listenAddress = "0.0.0.0"; - listenPort = 7700; - }; - - services.mautrix-signal = { - enable = true; - settings = { - homeserver.address = "http://localhost:8008"; - appservice = { - hostname = "0.0.0.0"; - port = 29328; - database.type = "sqlite3"; - }; - bridge.permissions."*" = "relay"; - signal.socket_path = "/var/run/signald/signald.sock"; - }; - }; - - services.mautrix-whatsapp = { - enable = true; - settings = { - homeserver.address = "http://localhost:8008"; - appservice = { - hostname = "0.0.0.0"; - port = 29329; - database.type = "sqlite3"; - }; - bridge.permissions."*" = "relay"; - }; - }; - - services.nix-serve = { - enable = true; - bindAddress = "0.0.0.0"; - port = 5000; - }; - - services.ollama = { - enable = true; - host = "0.0.0.0"; - port = 11434; - openFirewall = true; - }; - - services.open-webui = { - enable = true; - host = "0.0.0.0"; - port = 8082; - openFirewall = true; - }; - - services.owncast = { - enable = true; - listen = "0.0.0.0"; - port = 8083; - openFirewall = true; - }; - - services.paperless = { - enable = true; - address = "0.0.0.0"; - port = 28981; - settings.PAPERLESS_URL = "http://paperless.example.com"; - }; - - services.redlib = { - enable = true; - address = "0.0.0.0"; - port = 8084; - openFirewall = true; - }; - - services.tabby = { - enable = true; - host = "0.0.0.0"; - port = 8085; - }; - - services.zipline = { - enable = true; - settings = { - CORE_HOSTNAME = "0.0.0.0"; - CORE_PORT = 3002; - CORE_SECRET = "dummy-secret-32-chars-long-xxxxx"; - }; - }; - - services.immich = { - enable = true; - host = "0.0.0.0"; - port = 2283; - openFirewall = true; - secretsFile = null; - }; - - services.coder = { - enable = true; - listenAddress = "0.0.0.0:3003"; - accessUrl = "http://coder.example.com"; - }; - - services.prometheus = { - enable = true; - listenAddress = "0.0.0.0"; - port = 9090; - }; - - services.anki-sync-server = { - enable = true; - address = "0.0.0.0"; - port = 27701; - openFirewall = true; - users = [ - { - username = "testuser"; - password = "testpass"; - } - ]; - }; - - services.atuin = { - enable = true; - host = "0.0.0.0"; - port = 8888; - openFirewall = true; - }; - - services.glance = { - enable = true; - openFirewall = true; - settings.server = { - host = "0.0.0.0"; - port = 8086; - }; - }; - - services.jellyseerr = { - enable = true; - port = 5055; - openFirewall = true; - }; - - services.komga = { - enable = true; - openFirewall = true; - settings.server.port = 8087; - }; - - services.lidarr = { - enable = true; - openFirewall = true; - }; - - services.navidrome = { - enable = true; - openFirewall = true; - settings = { - Address = "0.0.0.0"; - Port = 4533; - }; - }; - - services.prowlarr = { - enable = true; - openFirewall = true; - }; - - services.radarr = { - enable = true; - openFirewall = true; - }; - - services.scrutiny = { - enable = true; - openFirewall = true; - settings.web.listen = { - host = "0.0.0.0"; - port = 8088; - }; - }; - - services.sonarr = { - enable = true; - openFirewall = true; - }; - - services.step-ca = { - enable = true; - address = "0.0.0.0"; - port = 8443; - openFirewall = true; - intermediatePasswordFile = "/var/lib/step-ca/intermediate_password"; - settings = { - root = dummyCert; - crt = dummyCert; - key = dummyPrivateKey; - dnsNames = [ "ca.example.com" ]; - db = { - type = "badger"; - dataSource = "/var/lib/step-ca/db"; - }; - authority.provisioners = [ ]; - }; - }; - - services.forgejo = { - enable = true; - settings = { - DEFAULT.APP_NAME = "Test Forgejo"; - server = { - ROOT_URL = "http://forgejo.example.com"; - HTTP_ADDR = "0.0.0.0"; - HTTP_PORT = 3004; - }; - }; - }; - - services.gitea = { - enable = true; - settings = { - DEFAULT.APP_NAME = "Test Gitea"; - server = { - ROOT_URL = "http://gitea.example.com"; - HTTP_ADDR = "0.0.0.0"; - HTTP_PORT = 3005; - }; - }; - }; - - services.grafana = { - enable = true; - settings.server = { - root_url = "http://grafana.example.com"; - http_addr = "0.0.0.0"; - http_port = 3006; - }; - }; - - services.loki = { - enable = true; - configuration = { - auth_enabled = false; - server = { - http_listen_address = "0.0.0.0"; - http_listen_port = 3100; - }; - common = { - ring = { - instance_addr = "127.0.0.1"; - kvstore.store = "inmemory"; - }; - replication_factor = 1; - path_prefix = "/var/lib/loki"; - }; - schema_config.configs = [ - { - from = "2024-01-01"; - store = "tsdb"; - object_store = "filesystem"; - schema = "v13"; - index = { - prefix = "index_"; - period = "24h"; - }; - } - ]; - storage_config.filesystem.directory = "/var/lib/loki/chunks"; - }; - }; - - services.searx = { - enable = true; - settings = { - server = { - bind_address = "0.0.0.0"; - port = 8089; - secret_key = "dummy-secret-key"; - }; - }; - }; - - services.kavita = { - enable = true; - settings.Port = 5001; - tokenKeyFile = dummySecretKey; - }; - - services.influxdb2 = { - enable = true; - settings.http-bind-address = "0.0.0.0:8086"; - }; - - services.firefox-syncserver = { - enable = true; - singleNode = { - enable = true; - url = "http://firefox-sync.example.com"; - hostname = "firefox-sync.example.com"; - }; - secrets = dummySecret; - settings = { - host = "0.0.0.0"; - port = 5002; - }; - }; - - services.radicale = { - enable = true; - settings = { - server.hosts = [ "0.0.0.0:5232" ]; - auth.type = "none"; - }; - }; - - services.authelia.instances.main = { - enable = true; - secrets = { - jwtSecretFile = dummySecret; - storageEncryptionKeyFile = dummySecretKey; - }; - settings = { - theme = "light"; - server.address = "tcp://0.0.0.0:9091"; - authentication_backend.file.path = "/var/lib/authelia-main/users.yml"; - storage.local.path = "/var/lib/authelia-main/db.sqlite3"; - access_control.default_policy = "deny"; - session.domain = "example.com"; - notifier.filesystem.filename = "/var/lib/authelia-main/notifications.txt"; - }; - }; - - services.blocky = { - enable = true; - settings = { - ports = { - dns = 5353; - http = 4000; - }; - upstreams.groups.default = [ "1.1.1.1" ]; - }; - }; - - services.caddy = { - enable = true; - virtualHosts."example.com".extraConfig = '' - reverse_proxy localhost:8080 { - } - ''; - }; - - services.dnsmasq = { - enable = true; - settings.address = [ "/example.com/192.168.1.1" ]; - }; - - services.mosquitto = { - enable = true; - listeners = [ - { - address = "0.0.0.0"; - port = 1883; - users = { }; - omitPasswordAuth = true; - settings.allow_anonymous = true; - } - ]; - }; - - services.nginx = { - enable = true; - virtualHosts."nginx.example.com" = { - locations."/" = { - proxyPass = "http://localhost:8080"; - }; - }; - }; - - services.traefik = { - enable = true; - dynamicConfigOptions = { - http = { - routers.test-router = { - rule = "Host(`test.example.com`)"; - service = "test-service"; - }; - services.test-service.loadBalancer.servers = [ { url = "http://localhost:8080"; } ]; - }; - }; - }; - - services.hickory-dns = { - enable = true; - settings = { - listen_addrs_ipv4 = [ "0.0.0.0" ]; - listen_addrs_ipv6 = [ "::" ]; - listen_port = 8053; - zones = [ ]; - }; - }; - - services.home-assistant = { - enable = true; - config = { - homeassistant = { - name = "Test Home"; - unit_system = "metric"; - time_zone = "UTC"; - }; - http = { - server_host = [ "0.0.0.0" ]; - server_port = 8123; - }; - }; - }; - - services.hydra = { - enable = true; - hydraURL = "http://hydra.example.com"; - listenHost = "0.0.0.0"; - port = 3007; - notificationSender = "hydra@example.com"; - }; - - services.kanidm = { - enableServer = true; - package = pkgs.kanidm_1_8; - serverSettings = { - origin = "https://kanidm.example.com"; - bindaddress = "0.0.0.0:8443"; - domain = "kanidm.example.com"; - tls_chain = "/var/lib/kanidm/chain.pem"; - tls_key = "/var/lib/kanidm/key.pem"; - }; - }; - - services.matrix-synapse = { - enable = true; - settings = { - server_name = "matrix.example.com"; - public_baseurl = "https://matrix.example.com"; - listeners = [ - { - port = 8008; - bind_addresses = [ "0.0.0.0" ]; - type = "http"; - tls = false; - resources = [ - { - names = [ - "client" - "federation" - ]; - compress = true; - } - ]; - } - ]; - }; - }; - - services.mpd = { - enable = true; - settings.music_directory = "/var/lib/mpd/music"; - }; - - services.plausible = { - enable = true; - server = { - baseUrl = "http://plausible.example.com"; - listenAddress = "0.0.0.0"; - port = 8000; - secretKeybaseFile = dummySecret; - }; - }; - - services.static-web-server = { - enable = true; - listen = "[::]:8787"; - root = "/var/www"; - }; - - services.stirling-pdf = { - enable = true; - environment = { - SERVER_HOST = "0.0.0.0"; - SERVER_PORT = "8088"; - }; - }; - - services.transmission = { - enable = true; - package = pkgs.transmission_4; - settings = { - rpc-bind-address = "0.0.0.0"; - rpc-port = 9091; - }; - }; - - services.vaultwarden = { - enable = true; - config = { - rocketAddress = "0.0.0.0"; - rocketPort = 8012; - domain = "https://vault.example.com"; - }; - }; - - services.zigbee2mqtt = { - enable = true; - settings = { - permit_join = false; - mqtt.server = "mqtt://localhost:1883"; - serial.port = "/dev/null"; - frontend = { - host = "0.0.0.0"; - port = 8092; - }; - }; - }; - - services.nextcloud = { - enable = true; - package = pkgs.nextcloud32; - hostName = "nextcloud.example.com"; - https = true; - config = { - adminpassFile = null; - adminuser = null; - dbtype = "sqlite"; - }; - }; - - services.jellyfin = { - enable = true; - openFirewall = true; - }; - - services.oauth2-proxy = { - enable = true; - httpAddress = "http://0.0.0.0:4180"; - cookie.secret = "0123456789abcdef0123456789abcdef"; - clientID = "test-client-id"; - clientSecret = "test-client-secret"; - provider = "google"; - email.domains = [ "*" ]; - keyFile = dummySecret; - }; - - services.samba = { - enable = true; - settings = { - global = { - workgroup = "WORKGROUP"; - "server string" = "Test Samba"; - }; - public = { - path = "/srv/public"; - browseable = "yes"; - "read only" = "no"; - }; - }; - }; - } - ) + ./test-module.nix ]; }; @@ -738,8 +55,8 @@ inherit topologyOutput; } '' - echo "Topology evaluation successful" - echo "Services extracted from configuration" + echo "Topology output: $topologyOutput" + ls -la "$topologyOutput" touch $out ''; }; diff --git a/tests/service-defs/test-module.nix b/tests/service-defs/test-module.nix new file mode 100644 index 0000000..02b0ffc --- /dev/null +++ b/tests/service-defs/test-module.nix @@ -0,0 +1,675 @@ +{ pkgs, ... }: +let + # Helper to create dummy secret files + dummySecret = pkgs.writeText "dummy-secret" "dummy-secret-value"; + dummySecretKey = pkgs.writeText "dummy-key" "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + dummyPrivateKey = pkgs.writeText "dummy-private-key" "not-a-real-key"; + dummyCert = pkgs.writeText "dummy-cert" '' + -----BEGIN CERTIFICATE----- + MIIBkTCB+wIJALQ2tZT1k6qwMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBmR1 + bW15MTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM + BmR1bW15MTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96HtiKQFxmBvB3LtmGW + fL+NtYH5gxLqT3C8kE6l3LxPz7t0H+Y6x4Q3Y8L3G8S3Z5D4E5s7Y7P2Q3K3KNXX + AgMBAAGjUzBRMB0GA1UdDgQWBBRJFMRRqVJ3pDYJ2r8p2G5D0qr2QDAfBgNVHSME + GDAWgBRJFMRRqVJ3pDYJ2r8p2G5D0qr2QDAPBgNVHRMBAf8EBTADAQH/MA0GCSqG + SIb3DQEBCwUAA0EAT+2B3iF7V6q3nN6Q3V8E0W5B2M0Q3Y8L3G8S3Z5D4E5s7Y7P + 2Q3K3KNXX7o96HtiKQFxmBvB3LtmGWfL+NtYH5gxLqT3C8kE6l3LxPw== + -----END CERTIFICATE----- + ''; +in +{ + # Basic system configuration + networking.hostName = "test-services"; + system.stateVersion = "24.05"; + boot.loader.grub.device = "nodev"; + fileSystems."/" = { + device = "/dev/null"; + fsType = "tmpfs"; + }; + + # Allow unfree packages (needed for coder/terraform) + nixpkgs.config.allowUnfree = true; + # Allow insecure packages (olm needed by mautrix-signal) + nixpkgs.config.permittedInsecurePackages = [ "olm-3.2.16" ]; + + # PostgreSQL - needed by several services + services.postgresql = { + enable = true; + settings.port = 5432; + }; + + # MySQL - needed by some services (mastodon) + services.mysql.package = pkgs.mariadb; + + services.alloy.enable = true; + services.fail2ban.enable = true; + services.harmonia.enable = true; + services.karakeep.enable = true; + services.i2pd.enable = true; + services.openssh.enable = true; + services.tor = { + enable = true; + relay.role = "relay"; + }; + + services.mastodon = { + enable = true; + localDomain = "mastodon.example.com"; + configureNginx = false; + smtp.fromAddress = "noreply@example.com"; + secretKeyBaseFile = "/var/lib/mastodon/secret_key_base"; + vapidPublicKeyFile = "/var/lib/mastodon/vapid_public_key"; + vapidPrivateKeyFile = "/var/lib/mastodon/vapid_private_key"; + streamingProcesses = 1; + }; + + services.adguardhome = { + enable = true; + host = "0.0.0.0"; + port = 3000; + }; + + services.code-server = { + enable = true; + host = "0.0.0.0"; + port = 4444; + }; + + services.esphome = { + enable = true; + address = "0.0.0.0"; + port = 6052; + }; + + services.headscale = { + enable = true; + address = "0.0.0.0"; + port = 8080; + settings = { + server_url = "http://headscale.example.com"; + dns.base_domain = "example.com"; + }; + }; + + services.invidious = { + enable = true; + address = "0.0.0.0"; + port = 3001; + }; + + services.languagetool = { + enable = true; + port = 8081; + }; + + services.libretranslate = { + enable = true; + host = "0.0.0.0"; + port = 5000; + }; + + services.meilisearch = { + enable = true; + listenAddress = "0.0.0.0"; + listenPort = 7700; + }; + + services.mautrix-signal = { + enable = true; + settings = { + homeserver.address = "http://localhost:8008"; + appservice = { + hostname = "0.0.0.0"; + port = 29328; + database.type = "sqlite3"; + }; + bridge.permissions."*" = "relay"; + signal.socket_path = "/var/run/signald/signald.sock"; + }; + }; + + services.mautrix-whatsapp = { + enable = true; + settings = { + homeserver.address = "http://localhost:8008"; + appservice = { + hostname = "0.0.0.0"; + port = 29329; + database.type = "sqlite3"; + }; + bridge.permissions."*" = "relay"; + }; + }; + + services.nix-serve = { + enable = true; + bindAddress = "0.0.0.0"; + port = 5000; + }; + + services.ollama = { + enable = true; + host = "0.0.0.0"; + port = 11434; + openFirewall = true; + }; + + services.open-webui = { + enable = true; + host = "0.0.0.0"; + port = 8082; + openFirewall = true; + }; + + services.owncast = { + enable = true; + listen = "0.0.0.0"; + port = 8083; + openFirewall = true; + }; + + services.paperless = { + enable = true; + address = "0.0.0.0"; + port = 28981; + settings.PAPERLESS_URL = "http://paperless.example.com"; + }; + + services.redlib = { + enable = true; + address = "0.0.0.0"; + port = 8084; + openFirewall = true; + }; + + services.tabby = { + enable = true; + host = "0.0.0.0"; + port = 8085; + }; + + services.zipline = { + enable = true; + settings = { + CORE_HOSTNAME = "0.0.0.0"; + CORE_PORT = 3002; + CORE_SECRET = "dummy-secret-32-chars-long-xxxxx"; + }; + }; + + services.immich = { + enable = true; + host = "0.0.0.0"; + port = 2283; + openFirewall = true; + secretsFile = null; + }; + + services.coder = { + enable = true; + listenAddress = "0.0.0.0:3003"; + accessUrl = "http://coder.example.com"; + }; + + services.prometheus = { + enable = true; + listenAddress = "0.0.0.0"; + port = 9090; + }; + + services.anki-sync-server = { + enable = true; + address = "0.0.0.0"; + port = 27701; + openFirewall = true; + users = [ + { + username = "testuser"; + password = "testpass"; + } + ]; + }; + + services.atuin = { + enable = true; + host = "0.0.0.0"; + port = 8888; + openFirewall = true; + }; + + services.glance = { + enable = true; + openFirewall = true; + settings.server = { + host = "0.0.0.0"; + port = 8086; + }; + }; + + services.jellyseerr = { + enable = true; + port = 5055; + openFirewall = true; + }; + + services.komga = { + enable = true; + openFirewall = true; + settings.server.port = 8087; + }; + + services.lidarr = { + enable = true; + openFirewall = true; + }; + + services.navidrome = { + enable = true; + openFirewall = true; + settings = { + Address = "0.0.0.0"; + Port = 4533; + }; + }; + + services.prowlarr = { + enable = true; + openFirewall = true; + }; + + services.radarr = { + enable = true; + openFirewall = true; + }; + + services.scrutiny = { + enable = true; + openFirewall = true; + settings.web.listen = { + host = "0.0.0.0"; + port = 8088; + }; + }; + + services.sonarr = { + enable = true; + openFirewall = true; + }; + + services.step-ca = { + enable = true; + address = "0.0.0.0"; + port = 8443; + openFirewall = true; + intermediatePasswordFile = "/var/lib/step-ca/intermediate_password"; + settings = { + root = dummyCert; + crt = dummyCert; + key = dummyPrivateKey; + dnsNames = [ "ca.example.com" ]; + db = { + type = "badger"; + dataSource = "/var/lib/step-ca/db"; + }; + authority.provisioners = [ ]; + }; + }; + + services.forgejo = { + enable = true; + settings = { + DEFAULT.APP_NAME = "Test Forgejo"; + server = { + ROOT_URL = "http://forgejo.example.com"; + HTTP_ADDR = "0.0.0.0"; + HTTP_PORT = 3004; + }; + }; + }; + + services.gitea = { + enable = true; + settings = { + DEFAULT.APP_NAME = "Test Gitea"; + server = { + ROOT_URL = "http://gitea.example.com"; + HTTP_ADDR = "0.0.0.0"; + HTTP_PORT = 3005; + }; + }; + }; + + services.grafana = { + enable = true; + settings.server = { + root_url = "http://grafana.example.com"; + http_addr = "0.0.0.0"; + http_port = 3006; + }; + }; + + services.loki = { + enable = true; + configuration = { + auth_enabled = false; + server = { + http_listen_address = "0.0.0.0"; + http_listen_port = 3100; + }; + common = { + ring = { + instance_addr = "127.0.0.1"; + kvstore.store = "inmemory"; + }; + replication_factor = 1; + path_prefix = "/var/lib/loki"; + }; + schema_config.configs = [ + { + from = "2024-01-01"; + store = "tsdb"; + object_store = "filesystem"; + schema = "v13"; + index = { + prefix = "index_"; + period = "24h"; + }; + } + ]; + storage_config.filesystem.directory = "/var/lib/loki/chunks"; + }; + }; + + services.searx = { + enable = true; + settings = { + server = { + bind_address = "0.0.0.0"; + port = 8089; + secret_key = "dummy-secret-key"; + }; + }; + }; + + services.kavita = { + enable = true; + settings.Port = 5001; + tokenKeyFile = dummySecretKey; + }; + + services.influxdb2 = { + enable = true; + settings.http-bind-address = "0.0.0.0:8086"; + }; + + services.firefox-syncserver = { + enable = true; + singleNode = { + enable = true; + url = "http://firefox-sync.example.com"; + hostname = "firefox-sync.example.com"; + }; + secrets = dummySecret; + settings = { + host = "0.0.0.0"; + port = 5002; + }; + }; + + services.radicale = { + enable = true; + settings = { + server.hosts = [ "0.0.0.0:5232" ]; + auth.type = "none"; + }; + }; + + services.authelia.instances.main = { + enable = true; + secrets = { + jwtSecretFile = dummySecret; + storageEncryptionKeyFile = dummySecretKey; + }; + settings = { + theme = "light"; + server.address = "tcp://0.0.0.0:9091"; + authentication_backend.file.path = "/var/lib/authelia-main/users.yml"; + storage.local.path = "/var/lib/authelia-main/db.sqlite3"; + access_control.default_policy = "deny"; + session.domain = "example.com"; + notifier.filesystem.filename = "/var/lib/authelia-main/notifications.txt"; + }; + }; + + services.blocky = { + enable = true; + settings = { + ports = { + dns = 5353; + http = 4000; + }; + upstreams.groups.default = [ "1.1.1.1" ]; + }; + }; + + services.caddy = { + enable = true; + virtualHosts."example.com".extraConfig = '' + reverse_proxy localhost:8080 { + } + ''; + }; + + services.dnsmasq = { + enable = true; + settings.address = [ "/example.com/192.168.1.1" ]; + }; + + services.mosquitto = { + enable = true; + listeners = [ + { + address = "0.0.0.0"; + port = 1883; + users = { }; + omitPasswordAuth = true; + settings.allow_anonymous = true; + } + ]; + }; + + services.nginx = { + enable = true; + virtualHosts."nginx.example.com" = { + locations."/" = { + proxyPass = "http://localhost:8080"; + }; + }; + }; + + services.traefik = { + enable = true; + dynamicConfigOptions = { + http = { + routers.test-router = { + rule = "Host(`test.example.com`)"; + service = "test-service"; + }; + services.test-service.loadBalancer.servers = [ { url = "http://localhost:8080"; } ]; + }; + }; + }; + + services.hickory-dns = { + enable = true; + settings = { + listen_addrs_ipv4 = [ "0.0.0.0" ]; + listen_addrs_ipv6 = [ "::" ]; + listen_port = 8053; + zones = [ ]; + }; + }; + + services.home-assistant = { + enable = true; + config = { + homeassistant = { + name = "Test Home"; + unit_system = "metric"; + time_zone = "UTC"; + }; + http = { + server_host = [ "0.0.0.0" ]; + server_port = 8123; + }; + }; + }; + + services.hydra = { + enable = true; + hydraURL = "http://hydra.example.com"; + listenHost = "0.0.0.0"; + port = 3007; + notificationSender = "hydra@example.com"; + }; + + services.kanidm = { + enableServer = true; + package = pkgs.kanidm_1_8; + serverSettings = { + origin = "https://kanidm.example.com"; + bindaddress = "0.0.0.0:8443"; + domain = "kanidm.example.com"; + tls_chain = "/var/lib/kanidm/chain.pem"; + tls_key = "/var/lib/kanidm/key.pem"; + }; + }; + + services.matrix-synapse = { + enable = true; + settings = { + server_name = "matrix.example.com"; + public_baseurl = "https://matrix.example.com"; + listeners = [ + { + port = 8008; + bind_addresses = [ "0.0.0.0" ]; + type = "http"; + tls = false; + resources = [ + { + names = [ + "client" + "federation" + ]; + compress = true; + } + ]; + } + ]; + }; + }; + + services.mpd = { + enable = true; + settings.music_directory = "/var/lib/mpd/music"; + }; + + services.plausible = { + enable = true; + server = { + baseUrl = "http://plausible.example.com"; + listenAddress = "0.0.0.0"; + port = 8000; + secretKeybaseFile = dummySecret; + }; + }; + + services.static-web-server = { + enable = true; + listen = "[::]:8787"; + root = "/var/www"; + }; + + services.stirling-pdf = { + enable = true; + environment = { + SERVER_HOST = "0.0.0.0"; + SERVER_PORT = "8088"; + }; + }; + + services.transmission = { + enable = true; + package = pkgs.transmission_4; + settings = { + rpc-bind-address = "0.0.0.0"; + rpc-port = 9091; + }; + }; + + services.vaultwarden = { + enable = true; + config = { + rocketAddress = "0.0.0.0"; + rocketPort = 8012; + domain = "https://vault.example.com"; + }; + }; + + services.zigbee2mqtt = { + enable = true; + settings = { + permit_join = false; + mqtt.server = "mqtt://localhost:1883"; + serial.port = "/dev/null"; + frontend = { + host = "0.0.0.0"; + port = 8092; + }; + }; + }; + + services.nextcloud = { + enable = true; + package = pkgs.nextcloud32; + hostName = "nextcloud.example.com"; + https = true; + config = { + adminpassFile = null; + adminuser = null; + dbtype = "sqlite"; + }; + }; + + services.jellyfin = { + enable = true; + openFirewall = true; + }; + + services.oauth2-proxy = { + enable = true; + httpAddress = "http://0.0.0.0:4180"; + cookie.secret = "0123456789abcdef0123456789abcdef"; + clientID = "test-client-id"; + clientSecret = "test-client-secret"; + provider = "google"; + email.domains = [ "*" ]; + keyFile = dummySecret; + }; + + services.samba = { + enable = true; + settings = { + global = { + workgroup = "WORKGROUP"; + "server string" = "Test Samba"; + }; + public = { + path = "/srv/public"; + browseable = "yes"; + "read only" = "no"; + }; + }; + }; +} diff --git a/topology/default.nix b/topology/default.nix index 29088a0..aca23b9 100644 --- a/topology/default.nix +++ b/topology/default.nix @@ -58,7 +58,7 @@ in description = "The derivation containing the rendered output"; type = types.path; readOnly = true; - defaultText = literalExpression ''config.renderers.${config.renderer}.output''; + defaultText = literalExpression "config.renderers.${config.renderer}.output"; }; lib = lib.mkOption { diff --git a/topology/renderers/svg/default.nix b/topology/renderers/svg/default.nix index 6e345ed..9de64b1 100644 --- a/topology/renderers/svg/default.nix +++ b/topology/renderers/svg/default.nix @@ -246,7 +246,7 @@ let node.interfaces != { } ) ''
''} ${concatLines (map mkInterface (attrValues node.interfaces))} - ${optionalString (node.interfaces != { }) ''
''} + ${optionalString (node.interfaces != { }) ""} ${concatLines (map mkGuest guests)} ${optionalString (guests != [ ]) spacingMt2}