Unverified Commit 21bd30ea authored by Ryan Lahfa's avatar Ryan Lahfa Committed by GitHub
Browse files

Merge pull request #247118 from Tom-Hubrecht/netbird-server

nixos/netbird-server: init module
parents 0faf57ff 6e480a8a
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -137,6 +137,8 @@ The pre-existing [services.ankisyncd](#opt-services.ankisyncd.enable) has been m

- [Suwayomi Server](https://github.com/Suwayomi/Suwayomi-Server), a free and open source manga reader server that runs extensions built for [Tachiyomi](https://tachiyomi.org). Available as [services.suwayomi-server](#opt-services.suwayomi-server.enable).

- A self-hosted management server for the [Netbird](https://netbird.io). Available as [services.netbird.server](#opt-services.netbird.server.enable).

- [ping_exporter](https://github.com/czerwonk/ping_exporter), a Prometheus exporter for ICMP echo requests. Available as [services.prometheus.exporters.ping](#opt-services.prometheus.exporters.ping.enable).

- [Prometheus DNSSEC Exporter](https://github.com/chrj/prometheus-dnssec-exporter), check for validity and expiration in DNSSEC signatures and expose metrics for Prometheus. Available as [services.prometheus.exporters.dnssec](#opt-services.prometheus.exporters.dnssec.enable).
+1 −0
Original line number Diff line number Diff line
@@ -1070,6 +1070,7 @@
  ./services/networking/ndppd.nix
  ./services/networking/nebula.nix
  ./services/networking/netbird.nix
  ./services/networking/netbird/server.nix
  ./services/networking/netclient.nix
  ./services/networking/networkd-dispatcher.nix
  ./services/networking/networkmanager.nix
+160 −0
Original line number Diff line number Diff line
{
  config,
  lib,
  pkgs,
  ...
}:

let
  inherit (lib)
    getExe
    literalExpression
    mkAfter
    mkEnableOption
    mkIf
    mkMerge
    mkOption
    optionalAttrs
    optionalString
    ;

  inherit (lib.types)
    bool
    listOf
    nullOr
    path
    port
    str
    ;

  cfg = config.services.netbird.server.coturn;
in

{
  options.services.netbird.server.coturn = {
    enable = mkEnableOption "a Coturn server for Netbird, will also open the firewall on the configured range";

    useAcmeCertificates = mkOption {
      type = bool;
      default = false;
      description = ''
        Whether to use ACME certificates corresponding to the given domain for the server.
      '';
    };

    domain = mkOption {
      type = str;
      description = "The domain under which the coturn server runs.";
    };

    user = mkOption {
      type = str;
      default = "netbird";
      description = ''
        The username used by netbird to connect to the coturn server.
      '';
    };

    password = mkOption {
      type = nullOr str;
      default = null;
      description = ''
        The password of the user used by netbird to connect to the coturn server.
      '';
    };

    passwordFile = mkOption {
      type = nullOr path;
      default = null;
      description = ''
        The path to a file containing the password of the user used by netbird to connect to the coturn server.
      '';
    };

    openPorts = mkOption {
      type = listOf port;
      default = with config.services.coturn; [
        listening-port
        alt-listening-port
        tls-listening-port
        alt-tls-listening-port
      ];
      defaultText = literalExpression ''
        with config.services.coturn; [
          listening-port
          alt-listening-port
          tls-listening-port
          alt-tls-listening-port
        ];
      '';

      description = ''
        The list of ports used by coturn for listening to open in the firewall.
      '';
    };
  };

  config = mkIf cfg.enable (mkMerge [
    {
      assertions = [
        {
          assertion = (cfg.password == null) != (cfg.passwordFile == null);
          message = "Exactly one of `password` or `passwordFile` must be given for the coturn setup.";
        }
      ];

      services.coturn =
        {
          enable = true;

          realm = cfg.domain;
          lt-cred-mech = true;
          no-cli = true;

          extraConfig = ''
            fingerprint
            user=${cfg.user}:${if cfg.password != null then cfg.password else "@password@"}
            no-software-attribute
          '';
        }
        // (optionalAttrs cfg.useAcmeCertificates {
          cert = "@cert@";
          pkey = "@pkey@";
        });

      systemd.services.coturn =
        let
          dir = config.security.acme.certs.${cfg.domain}.directory;
          preStart' =
            (optionalString (cfg.passwordFile != null) ''
              ${getExe pkgs.replace-secret} @password@ ${cfg.passwordFile} /run/coturn/turnserver.cfg
            '')
            + (optionalString cfg.useAcmeCertificates ''
              ${getExe pkgs.replace-secret} @cert@ "$CREDENTIALS_DIRECTORY/cert.pem" /run/coturn/turnserver.cfg
              ${getExe pkgs.replace-secret} @pkey@ "$CREDENTIALS_DIRECTORY/pkey.pem" /run/coturn/turnserver.cfg
            '');
        in
        (optionalAttrs (preStart' != "") { preStart = mkAfter preStart'; })
        // (optionalAttrs cfg.useAcmeCertificates {
          serviceConfig.LoadCredential = [
            "cert.pem:${dir}/fullchain.pem"
            "pkey.pem:${dir}/key.pem"
          ];
        });

      security.acme.certs.${cfg.domain}.postRun = optionalString cfg.useAcmeCertificates "systemctl restart coturn.service";

      networking.firewall = {
        allowedUDPPorts = cfg.openPorts;
        allowedTCPPorts = cfg.openPorts;

        allowedUDPPortRanges = [
          {
            from = cfg.minPort;
            to = cfg.maxPort;
          }
        ];
      };
    }
  ]);
}
+186 −0
Original line number Diff line number Diff line
{
  config,
  lib,
  pkgs,
  ...
}:

let
  inherit (lib)
    boolToString
    concatStringsSep
    hasAttr
    isBool
    mapAttrs
    mkDefault
    mkEnableOption
    mkIf
    mkOption
    mkPackageOption
    ;

  inherit (lib.types)
    attrsOf
    bool
    either
    package
    str
    submodule
    ;

  toStringEnv = value: if isBool value then boolToString value else toString value;

  cfg = config.services.netbird.server.dashboard;
in

{
  options.services.netbird.server.dashboard = {
    enable = mkEnableOption "the static netbird dashboard frontend";

    package = mkPackageOption pkgs "netbird-dashboard" { };

    enableNginx = mkEnableOption "Nginx reverse-proxy to serve the dashboard.";

    domain = mkOption {
      type = str;
      default = "localhost";
      description = "The domain under which the dashboard runs.";
    };

    managementServer = mkOption {
      type = str;
      description = "The address of the management server, used for the API endpoints.";
    };

    settings = mkOption {
      type = submodule { freeformType = attrsOf (either str bool); };

      defaultText = ''
        {
          AUTH_AUDIENCE = "netbird";
          AUTH_CLIENT_ID = "netbird";
          AUTH_SUPPORTED_SCOPES = "openid profile email";
          NETBIRD_TOKEN_SOURCE = "idToken";
          USE_AUTH0 = false;
        }
      '';

      description = ''
        An attribute set that will be used to substitute variables when building the dashboard.
        Any values set here will be templated into the frontend and be public for anyone that can reach your website.
        The exact values sadly aren't documented anywhere.
        A starting point when searching for valid values is this [script](https://github.com/netbirdio/dashboard/blob/main/docker/init_react_envs.sh)
        The only mandatory value is 'AUTH_AUTHORITY' as we cannot set a default value here.
      '';
    };

    finalDrv = mkOption {
      readOnly = true;
      type = package;
      description = ''
        The derivation containing the final templated dashboard.
      '';
    };
  };

  config = mkIf cfg.enable {
    assertions = [
      {
        assertion = hasAttr "AUTH_AUTHORITY" cfg.settings;
        message = "The setting AUTH_AUTHORITY is required for the dasboard to function.";
      }
    ];

    services.netbird.server.dashboard = {
      settings =
        {
          # Due to how the backend and frontend work this secret will be templated into the backend
          # and then served statically from your website
          # This enables you to login without the normally needed indirection through the backend
          # but this also means anyone that can reach your website can
          # fetch this secret, which is why there is no real need to put it into
          # special options as its public anyway
          # As far as I know leaking this secret is just
          # an information leak as one can fetch some basic app
          # informations from the IDP
          # To actually do something one still needs to have login
          # data and this secret so this being public will not
          # suffice for anything just decreasing security
          AUTH_CLIENT_SECRET = "";

          NETBIRD_MGMT_API_ENDPOINT = cfg.managementServer;
          NETBIRD_MGMT_GRPC_API_ENDPOINT = cfg.managementServer;
        }
        // (mapAttrs (_: mkDefault) {
          # Those values have to be easily overridable
          AUTH_AUDIENCE = "netbird"; # must be set for your devices to be able to log in
          AUTH_CLIENT_ID = "netbird";
          AUTH_SUPPORTED_SCOPES = "openid profile email";
          NETBIRD_TOKEN_SOURCE = "idToken";
          USE_AUTH0 = false;
        });

      # The derivation containing the templated dashboard
      finalDrv =
        pkgs.runCommand "netbird-dashboard"
          {
            nativeBuildInputs = [ pkgs.gettext ];
            env = {
              ENV_STR = concatStringsSep " " [
                "$AUTH_AUDIENCE"
                "$AUTH_AUTHORITY"
                "$AUTH_CLIENT_ID"
                "$AUTH_CLIENT_SECRET"
                "$AUTH_REDIRECT_URI"
                "$AUTH_SILENT_REDIRECT_URI"
                "$AUTH_SUPPORTED_SCOPES"
                "$NETBIRD_DRAG_QUERY_PARAMS"
                "$NETBIRD_GOOGLE_ANALYTICS_ID"
                "$NETBIRD_HOTJAR_TRACK_ID"
                "$NETBIRD_MGMT_API_ENDPOINT"
                "$NETBIRD_MGMT_GRPC_API_ENDPOINT"
                "$NETBIRD_TOKEN_SOURCE"
                "$USE_AUTH0"
              ];
            } // (mapAttrs (_: toStringEnv) cfg.settings);
          }
          ''
            cp -R ${cfg.package} build

            find build -type d -exec chmod 755 {} \;
            OIDC_TRUSTED_DOMAINS="build/OidcTrustedDomains.js"

            envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS.tmpl" > "$OIDC_TRUSTED_DOMAINS"

            for f in $(grep -R -l AUTH_SUPPORTED_SCOPES build/); do
              mv "$f" "$f.copy"
              envsubst "$ENV_STR" < "$f.copy" > "$f"
              rm "$f.copy"
            done

            cp -R build $out
          '';
    };

    services.nginx = mkIf cfg.enableNginx {
      enable = true;

      virtualHosts.${cfg.domain} = {
        locations = {
          "/" = {
            root = cfg.finalDrv;
            tryFiles = "$uri $uri.html $uri/ =404";
          };

          "/404.html".extraConfig = ''
            internal;
          '';
        };

        extraConfig = ''
          error_page 404 /404.html;
        '';
      };
    };
  };
}
+460 −0
Original line number Diff line number Diff line
{
  config,
  lib,
  pkgs,
  utils,
  ...
}:

let
  inherit (lib)
    any
    concatMap
    getExe'
    literalExpression
    mkEnableOption
    mkIf
    mkOption
    mkPackageOption
    optional
    recursiveUpdate
    ;

  inherit (lib.types)
    bool
    enum
    listOf
    port
    str
    ;

  inherit (utils) escapeSystemdExecArgs genJqSecretsReplacementSnippet;

  stateDir = "/var/lib/netbird-mgmt";

  settingsFormat = pkgs.formats.json { };

  defaultSettings = {
    Stuns = [
      {
        Proto = "udp";
        URI = "stun:${cfg.turnDomain}:3478";
        Username = "";
        Password = null;
      }
    ];

    TURNConfig = {
      Turns = [
        {
          Proto = "udp";
          URI = "turn:${cfg.turnDomain}:${builtins.toString cfg.turnPort}";
          Username = "netbird";
          Password = "netbird";
        }
      ];

      CredentialsTTL = "12h";
      Secret = "not-secure-secret";
      TimeBasedCredentials = false;
    };

    Signal = {
      Proto = "https";
      URI = "${cfg.domain}:443";
      Username = "";
      Password = null;
    };

    ReverseProxy = {
      TrustedHTTPProxies = [ ];
      TrustedHTTPProxiesCount = 0;
      TrustedPeers = [ "0.0.0.0/0" ];
    };

    Datadir = "${stateDir}/data";
    DataStoreEncryptionKey = "very-insecure-key";
    StoreConfig = {
      Engine = "sqlite";
    };

    HttpConfig = {
      Address = "127.0.0.1:${builtins.toString cfg.port}";
      IdpSignKeyRefreshEnabled = true;
      OIDCConfigEndpoint = cfg.oidcConfigEndpoint;
    };

    IdpManagerConfig = {
      ManagerType = "none";
      ClientConfig = {
        Issuer = "";
        TokenEndpoint = "";
        ClientID = "netbird";
        ClientSecret = "";
        GrantType = "client_credentials";
      };

      ExtraConfig = { };
      Auth0ClientCredentials = null;
      AzureClientCredentials = null;
      KeycloakClientCredentials = null;
      ZitadelClientCredentials = null;
    };

    DeviceAuthorizationFlow = {
      Provider = "none";
      ProviderConfig = {
        Audience = "netbird";
        Domain = null;
        ClientID = "netbird";
        TokenEndpoint = null;
        DeviceAuthEndpoint = "";
        Scope = "openid profile email";
        UseIDToken = false;
      };
    };

    PKCEAuthorizationFlow = {
      ProviderConfig = {
        Audience = "netbird";
        ClientID = "netbird";
        ClientSecret = "";
        AuthorizationEndpoint = "";
        TokenEndpoint = "";
        Scope = "openid profile email";
        RedirectURLs = [ "http://localhost:53000" ];
        UseIDToken = false;
      };
    };
  };

  managementConfig = recursiveUpdate defaultSettings cfg.settings;

  managementFile = settingsFormat.generate "config.json" managementConfig;

  cfg = config.services.netbird.server.management;
in

{
  options.services.netbird.server.management = {
    enable = mkEnableOption "Netbird Management Service.";

    package = mkPackageOption pkgs "netbird" { };

    domain = mkOption {
      type = str;
      description = "The domain under which the management API runs.";
    };

    turnDomain = mkOption {
      type = str;
      description = "The domain of the TURN server to use.";
    };

    turnPort = mkOption {
      type = port;
      default = 3478;
      description = ''
        The port of the TURN server to use.
      '';
    };

    dnsDomain = mkOption {
      type = str;
      default = "netbird.selfhosted";
      description = "Domain used for peer resolution.";
    };

    singleAccountModeDomain = mkOption {
      type = str;
      default = "netbird.selfhosted";
      description = ''
        Enables single account mode.
        This means that all the users will be under the same account grouped by the specified domain.
        If the installation has more than one account, the property is ineffective.
      '';
    };

    disableAnonymousMetrics = mkOption {
      type = bool;
      default = true;
      description = "Disables push of anonymous usage metrics to NetBird.";
    };

    disableSingleAccountMode = mkOption {
      type = bool;
      default = false;
      description = ''
        If set to true, disables single account mode.
        The `singleAccountModeDomain` property will be ignored and every new user will have a separate NetBird account.
      '';
    };

    port = mkOption {
      type = port;
      default = 8011;
      description = "Internal port of the management server.";
    };

    extraOptions = mkOption {
      type = listOf str;
      default = [ ];
      description = ''
        Additional options given to netbird-mgmt as commandline arguments.
      '';
    };

    oidcConfigEndpoint = mkOption {
      type = str;
      description = "The oidc discovery endpoint.";
      example = "https://example.eu.auth0.com/.well-known/openid-configuration";
    };

    settings = mkOption {
      inherit (settingsFormat) type;

      defaultText = literalExpression ''
        defaultSettings = {
          Stuns = [
            {
              Proto = "udp";
              URI = "stun:''${cfg.turnDomain}:3478";
              Username = "";
              Password = null;
            }
          ];

          TURNConfig = {
            Turns = [
              {
                Proto = "udp";
                URI = "turn:''${cfg.turnDomain}:3478";
                Username = "netbird";
                Password = "netbird";
              }
            ];

            CredentialsTTL = "12h";
            Secret = "not-secure-secret";
            TimeBasedCredentials = false;
          };

          Signal = {
            Proto = "https";
            URI = "''${cfg.domain}:443";
            Username = "";
            Password = null;
          };

          ReverseProxy = {
            TrustedHTTPProxies = [ ];
            TrustedHTTPProxiesCount = 0;
            TrustedPeers = [ "0.0.0.0/0" ];
          };

          Datadir = "''${stateDir}/data";
          DataStoreEncryptionKey = "genEVP6j/Yp2EeVujm0zgqXrRos29dQkpvX0hHdEUlQ=";
          StoreConfig = { Engine = "sqlite"; };

          HttpConfig = {
            Address = "127.0.0.1:''${builtins.toString cfg.port}";
            IdpSignKeyRefreshEnabled = true;
            OIDCConfigEndpoint = cfg.oidcConfigEndpoint;
          };

          IdpManagerConfig = {
            ManagerType = "none";
            ClientConfig = {
              Issuer = "";
              TokenEndpoint = "";
              ClientID = "netbird";
              ClientSecret = "";
              GrantType = "client_credentials";
            };

            ExtraConfig = { };
            Auth0ClientCredentials = null;
            AzureClientCredentials = null;
            KeycloakClientCredentials = null;
            ZitadelClientCredentials = null;
          };

          DeviceAuthorizationFlow = {
            Provider = "none";
            ProviderConfig = {
              Audience = "netbird";
              Domain = null;
              ClientID = "netbird";
              TokenEndpoint = null;
              DeviceAuthEndpoint = "";
              Scope = "openid profile email offline_access api";
              UseIDToken = false;
            };
          };

          PKCEAuthorizationFlow = {
            ProviderConfig = {
              Audience = "netbird";
              ClientID = "netbird";
              ClientSecret = "";
              AuthorizationEndpoint = "";
              TokenEndpoint = "";
              Scope = "openid profile email offline_access api";
              RedirectURLs = "http://localhost:53000";
              UseIDToken = false;
            };
          };
        };
      '';

      default = { };

      description = ''
        Configuration of the netbird management server.
        Options containing secret data should be set to an attribute set containing the attribute _secret
        - a string pointing to a file containing the value the option should be set to.
        See the example to get a better picture of this: in the resulting management.json file,
        the `DataStoreEncryptionKey` key will be set to the contents of the /run/agenix/netbird_mgmt-data_store_encryption_key file.
      '';

      example = {
        DataStoreEncryptionKey = {
          _secret = "/run/agenix/netbird_mgmt-data_store_encryption_key";
        };
      };
    };

    logLevel = mkOption {
      type = enum [
        "ERROR"
        "WARN"
        "INFO"
        "DEBUG"
      ];
      default = "INFO";
      description = "Log level of the netbird services.";
    };

    enableNginx = mkEnableOption "Nginx reverse-proxy for the netbird management service.";
  };

  config = mkIf cfg.enable {
    warnings =
      concatMap
        (
          { check, name }:
          optional check "${name} is world-readable in the Nix Store, you should provide it as a _secret."
        )
        [
          {
            check = builtins.isString managementConfig.TURNConfig.Secret;
            name = "The TURNConfig.secret";
          }
          {
            check = builtins.isString managementConfig.DataStoreEncryptionKey;
            name = "The DataStoreEncryptionKey";
          }
          {
            check = any (T: (T ? Password) && builtins.isString T.Password) managementConfig.TURNConfig.Turns;
            name = "A Turn configuration's password";
          }
        ];

    systemd.services.netbird-management = {
      description = "The management server for Netbird, a wireguard VPN";
      documentation = [ "https://netbird.io/docs/" ];

      after = [ "network.target" ];
      wantedBy = [ "multi-user.target" ];
      restartTriggers = [ managementFile ];

      preStart = genJqSecretsReplacementSnippet managementConfig "${stateDir}/management.json";

      serviceConfig = {
        ExecStart = escapeSystemdExecArgs (
          [
            (getExe' cfg.package "netbird-mgmt")
            "management"
            # Config file
            "--config"
            "${stateDir}/management.json"
            # Data directory
            "--datadir"
            "${stateDir}/data"
            # DNS domain
            "--dns-domain"
            cfg.dnsDomain
            # Port to listen on
            "--port"
            cfg.port
            # Log to stdout
            "--log-file"
            "console"
            # Log level
            "--log-level"
            cfg.logLevel
            #
            "--idp-sign-key-refresh-enabled"
            # Domain for internal resolution
            "--single-account-mode-domain"
            cfg.singleAccountModeDomain
          ]
          ++ (optional cfg.disableAnonymousMetrics "--disable-anonymous-metrics")
          ++ (optional cfg.disableSingleAccountMode "--disable-single-account-mode")
          ++ cfg.extraOptions
        );
        Restart = "always";
        RuntimeDirectory = "netbird-mgmt";
        StateDirectory = [
          "netbird-mgmt"
          "netbird-mgmt/data"
        ];
        WorkingDirectory = stateDir;

        # hardening
        LockPersonality = true;
        MemoryDenyWriteExecute = true;
        NoNewPrivileges = true;
        PrivateMounts = true;
        PrivateTmp = true;
        ProtectClock = true;
        ProtectControlGroups = true;
        ProtectHome = true;
        ProtectHostname = true;
        ProtectKernelLogs = true;
        ProtectKernelModules = true;
        ProtectKernelTunables = true;
        ProtectSystem = true;
        RemoveIPC = true;
        RestrictNamespaces = true;
        RestrictRealtime = true;
        RestrictSUIDSGID = true;
      };

      stopIfChanged = false;
    };

    services.nginx = mkIf cfg.enableNginx {
      enable = true;

      virtualHosts.${cfg.domain} = {
        locations = {
          "/api".proxyPass = "http://localhost:${builtins.toString cfg.port}";

          "/management.ManagementService/".extraConfig = ''
            # This is necessary so that grpc connections do not get closed early
            # see https://stackoverflow.com/a/67805465
            client_body_timeout 1d;

            grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

            grpc_pass grpc://localhost:${builtins.toString cfg.port};
            grpc_read_timeout 1d;
            grpc_send_timeout 1d;
            grpc_socket_keepalive on;
          '';
        };
      };
    };
  };
}
Loading