Unverified Commit cc6c2d32 authored by Lorenz Leutgeb's avatar Lorenz Leutgeb Committed by GitHub
Browse files

rosenpass: refactor, add module and test (#254813)

parent 924c6826
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -121,6 +121,8 @@

- [Soft Serve](https://github.com/charmbracelet/soft-serve), a tasty, self-hostable Git server for the command line. Available as [services.soft-serve](#opt-services.soft-serve.enable).

- [Rosenpass](https://rosenpass.eu/), a service for post-quantum-secure VPNs with WireGuard. Available as [services.rosenpass](#opt-services.rosenpass.enable).

## Backward Incompatibilities {#sec-release-23.11-incompatibilities}

- `network-online.target` has been fixed to no longer time out for systems with `networking.useDHCP = true` and `networking.useNetworkd = true`.
+1 −0
Original line number Diff line number Diff line
@@ -1047,6 +1047,7 @@
  ./services/networking/redsocks.nix
  ./services/networking/resilio.nix
  ./services/networking/robustirc-bridge.nix
  ./services/networking/rosenpass.nix
  ./services/networking/routedns.nix
  ./services/networking/rpcbind.nix
  ./services/networking/rxe.nix
+233 −0
Original line number Diff line number Diff line
{ config
, lib
, options
, pkgs
, ...
}:
let
  inherit (lib)
    attrValues
    concatLines
    concatMap
    filter
    filterAttrsRecursive
    flatten
    getExe
    mdDoc
    mkIf
    optional
    ;

  cfg = config.services.rosenpass;
  opt = options.services.rosenpass;
  settingsFormat = pkgs.formats.toml { };
in
{
  options.services.rosenpass =
    let
      inherit (lib)
        literalExpression
        mdDoc
        mkOption
        ;
      inherit (lib.types)
        enum
        listOf
        nullOr
        path
        str
        submodule
        ;
    in
    {
      enable = lib.mkEnableOption (mdDoc "Rosenpass");

      package = lib.mkPackageOption pkgs "rosenpass" { };

      defaultDevice = mkOption {
        type = nullOr str;
        description = mdDoc "Name of the network interface to use for all peers by default.";
        example = "wg0";
      };

      settings = mkOption {
        type = submodule {
          freeformType = settingsFormat.type;

          options = {
            public_key = mkOption {
              type = path;
              description = mdDoc "Path to a file containing the public key of the local Rosenpass peer. Generate this by running {command}`rosenpass gen-keys`.";
            };

            secret_key = mkOption {
              type = path;
              description = mdDoc "Path to a file containing the secret key of the local Rosenpass peer. Generate this by running {command}`rosenpass gen-keys`.";
            };

            listen = mkOption {
              type = listOf str;
              description = mdDoc "List of local endpoints to listen for connections.";
              default = [ ];
              example = literalExpression "[ \"0.0.0.0:10000\" ]";
            };

            verbosity = mkOption {
              type = enum [ "Verbose" "Quiet" ];
              default = "Quiet";
              description = mdDoc "Verbosity of output produced by the service.";
            };

            peers =
              let
                peer = submodule {
                  freeformType = settingsFormat.type;

                  options = {
                    public_key = mkOption {
                      type = path;
                      description = mdDoc "Path to a file containing the public key of the remote Rosenpass peer.";
                    };

                    endpoint = mkOption {
                      type = nullOr str;
                      default = null;
                      description = mdDoc "Endpoint of the remote Rosenpass peer.";
                    };

                    device = mkOption {
                      type = str;
                      default = cfg.defaultDevice;
                      defaultText = literalExpression "config.${opt.defaultDevice}";
                      description = mdDoc "Name of the local WireGuard interface to use for this peer.";
                    };

                    peer = mkOption {
                      type = str;
                      description = mdDoc "WireGuard public key corresponding to the remote Rosenpass peer.";
                    };
                  };
                };
              in
              mkOption {
                type = listOf peer;
                description = mdDoc "List of peers to exchange keys with.";
                default = [ ];
              };
          };
        };
        default = { };
        description = mdDoc "Configuration for Rosenpass, see <https://rosenpass.eu/> for further information.";
      };
    };

  config = mkIf cfg.enable {
    warnings =
      let
        # NOTE: In the descriptions below, we tried to refer to e.g.
        # options.systemd.network.netdevs."<name>".wireguardPeers.*.PublicKey
        # directly, but don't know how to traverse "<name>" and * in this path.
        extractions = [
          {
            relevant = config.systemd.network.enable;
            root = config.systemd.network.netdevs;
            peer = (x: x.wireguardPeers);
            key = (x: if x.wireguardPeerConfig ? PublicKey then x.wireguardPeerConfig.PublicKey else null);
            description = mdDoc "${options.systemd.network.netdevs}.\"<name>\".wireguardPeers.*.wireguardPeerConfig.PublicKey";
          }
          {
            relevant = config.networking.wireguard.enable;
            root = config.networking.wireguard.interfaces;
            peer = (x: x.peers);
            key = (x: x.publicKey);
            description = mdDoc "${options.networking.wireguard.interfaces}.\"<name>\".peers.*.publicKey";
          }
          rec {
            relevant = root != { };
            root = config.networking.wg-quick.interfaces;
            peer = (x: x.peers);
            key = (x: x.publicKey);
            description = mdDoc "${options.networking.wg-quick.interfaces}.\"<name>\".peers.*.publicKey";
          }
        ];
        relevantExtractions = filter (x: x.relevant) extractions;
        extract = { root, peer, key, ... }:
          filter (x: x != null) (flatten (concatMap (x: (map key (peer x))) (attrValues root)));
        configuredKeys = flatten (map extract relevantExtractions);
        itemize = xs: concatLines (map (x: " - ${x}") xs);
        descriptions = map (x: "`${x.description}`");
        missingKeys = filter (key: !builtins.elem key configuredKeys) (map (x: x.peer) cfg.settings.peers);
        unusual = ''
          While this may work as expected, e.g. you want to manually configure WireGuard,
          such a scenario is unusual. Please double-check your configuration.
        '';
      in
      (optional (relevantExtractions != [ ] && missingKeys != [ ]) ''
        You have configured Rosenpass peers with the WireGuard public keys:
        ${itemize missingKeys}
        But there is no corresponding active Wireguard peer configuration in any of:
        ${itemize (descriptions relevantExtractions)}
        ${unusual}
      '')
      ++
      optional (relevantExtractions == [ ]) ''
        You have configured Rosenpass, but you have not configured Wireguard via any of:
        ${itemize (descriptions extractions)}
        ${unusual}
      '';

    environment.systemPackages = [ cfg.package pkgs.wireguard-tools ];

    systemd.services.rosenpass =
      let
        filterNonNull = filterAttrsRecursive (_: v: v != null);
        config = settingsFormat.generate "config.toml" (
          filterNonNull (cfg.settings
            //
            (
              let
                credentialPath = id: "$CREDENTIALS_DIRECTORY/${id}";
                # NOTE: We would like to remove all `null` values inside `cfg.settings`
                # recursively, since `settingsFormat.generate` cannot handle `null`.
                # This would require to traverse both attribute sets and lists recursively.
                # `filterAttrsRecursive` only recurses into attribute sets, but not
                # into values that might contain other attribute sets (such as lists,
                # e.g. `cfg.settings.peers`). Here, we just specialize on `cfg.settings.peers`,
                # and this may break unexpectedly whenever a `null` value is contained
                # in a list in `cfg.settings`, other than `cfg.settings.peers`.
                peersWithoutNulls = map filterNonNull cfg.settings.peers;
              in
              {
                secret_key = credentialPath "pqsk";
                public_key = credentialPath "pqpk";
                peers = peersWithoutNulls;
              }
            )
          )
        );
      in
      rec {
        wantedBy = [ "multi-user.target" ];
        after = [ "network-online.target" ];
        path = [ cfg.package pkgs.wireguard-tools ];

        serviceConfig = {
          User = "rosenpass";
          Group = "rosenpass";
          RuntimeDirectory = "rosenpass";
          DynamicUser = true;
          AmbientCapabilities = [ "CAP_NET_ADMIN" ];
          LoadCredential = [
            "pqsk:${cfg.settings.secret_key}"
            "pqpk:${cfg.settings.public_key}"
          ];
        };

        # See <https://www.freedesktop.org/software/systemd/man/systemd.unit.html#Specifiers>
        environment.CONFIG = "%t/${serviceConfig.RuntimeDirectory}/config.toml";

        preStart = "${getExe pkgs.envsubst} -i ${config} -o \"$CONFIG\"";
        script = "rosenpass exchange-config \"$CONFIG\"";
      };
  };
}
+1 −0
Original line number Diff line number Diff line
@@ -703,6 +703,7 @@ in {
  rkvm = handleTest ./rkvm {};
  robustirc-bridge = handleTest ./robustirc-bridge.nix {};
  roundcube = handleTest ./roundcube.nix {};
  rosenpass = handleTest ./rosenpass.nix {};
  rshim = handleTest ./rshim.nix {};
  rspamd = handleTest ./rspamd.nix {};
  rss2email = handleTest ./rss2email.nix {};
+217 −0
Original line number Diff line number Diff line
import ./make-test-python.nix ({ pkgs, ... }:
let
  deviceName = "rp0";

  server = {
    ip = "fe80::1";
    wg = {
      public = "mQufmDFeQQuU/fIaB2hHgluhjjm1ypK4hJr1cW3WqAw=";
      secret = "4N5Y1dldqrpsbaEiY8O0XBUGUFf8vkvtBtm8AoOX7Eo=";
      listen = 10000;
    };
  };
  client = {
    ip = "fe80::2";
    wg = {
      public = "Mb3GOlT7oS+F3JntVKiaD7SpHxLxNdtEmWz/9FMnRFU=";
      secret = "uC5dfGMv7Oxf5UDfdPkj6rZiRZT2dRWp5x8IQxrNcUE=";
    };
  };
in
{
  name = "rosenpass";

  nodes =
    let
      shared = peer: { config, modulesPath, ... }: {
        imports = [ "${modulesPath}/services/networking/rosenpass.nix" ];

        boot.kernelModules = [ "wireguard" ];

        services.rosenpass = {
          enable = true;
          defaultDevice = deviceName;
          settings = {
            verbosity = "Verbose";
            public_key = "/etc/rosenpass/pqpk";
            secret_key = "/etc/rosenpass/pqsk";
          };
        };

        networking.firewall.allowedUDPPorts = [ 9999 ];

        systemd.network = {
          enable = true;
          networks."rosenpass" = {
            matchConfig.Name = deviceName;
            networkConfig.IPForward = true;
            address = [ "${peer.ip}/64" ];
          };

          netdevs."10-rp0" = {
            netdevConfig = {
              Kind = "wireguard";
              Name = deviceName;
            };
            wireguardConfig.PrivateKeyFile = "/etc/wireguard/wgsk";
          };
        };

        environment.etc."wireguard/wgsk" = {
          text = peer.wg.secret;
          user = "systemd-network";
          group = "systemd-network";
        };
      };
    in
    {
      server = {
        imports = [ (shared server) ];

        networking.firewall.allowedUDPPorts = [ server.wg.listen ];

        systemd.network.netdevs."10-${deviceName}" = {
          wireguardConfig.ListenPort = server.wg.listen;
          wireguardPeers = [
            {
              wireguardPeerConfig = {
                AllowedIPs = [ "::/0" ];
                PublicKey = client.wg.public;
              };
            }
          ];
        };

        services.rosenpass.settings = {
          listen = [ "0.0.0.0:9999" ];
          peers = [
            {
              public_key = "/etc/rosenpass/peers/client/pqpk";
              peer = client.wg.public;
            }
          ];
        };
      };
      client = {
        imports = [ (shared client) ];

        systemd.network.netdevs."10-${deviceName}".wireguardPeers = [
          {
            wireguardPeerConfig = {
              AllowedIPs = [ "::/0" ];
              PublicKey = server.wg.public;
              Endpoint = "server:${builtins.toString server.wg.listen}";
            };
          }
        ];

        services.rosenpass.settings.peers = [
          {
            public_key = "/etc/rosenpass/peers/server/pqpk";
            endpoint = "server:9999";
            peer = server.wg.public;
          }
        ];
      };
    };

  testScript = { ... }: ''
    from os import system

    # Full path to rosenpass in the store, to avoid fiddling with `$PATH`.
    rosenpass = "${pkgs.rosenpass}/bin/rosenpass"

    # Path in `/etc` where keys will be placed.
    etc = "/etc/rosenpass"

    start_all()

    for machine in [server, client]:
        machine.wait_for_unit("multi-user.target")

    # Gently stop Rosenpass to avoid crashes during key generation/distribution.
    for machine in [server, client]:
        machine.execute("systemctl stop rosenpass.service")

    for (name, machine, remote) in [("server", server, client), ("client", client, server)]:
        pk, sk = f"{name}.pqpk", f"{name}.pqsk"
        system(f"{rosenpass} gen-keys --force --secret-key {sk} --public-key {pk}")
        machine.copy_from_host(sk, f"{etc}/pqsk")
        machine.copy_from_host(pk, f"{etc}/pqpk")
        remote.copy_from_host(pk, f"{etc}/peers/{name}/pqpk")

    for machine in [server, client]:
        machine.execute("systemctl start rosenpass.service")

    for machine in [server, client]:
        machine.wait_for_unit("rosenpass.service")

    with subtest("ping"):
        client.succeed("ping -c 2 -i 0.5 ${server.ip}%${deviceName}")

    with subtest("preshared-keys"):
        # Rosenpass works by setting the WireGuard preshared key at regular intervals.
        # Thus, if it is not active, then no key will be set, and the output of `wg show` will contain "none".
        # Otherwise, if it is active, then the key will be set and "none" will not be found in the output of `wg show`.
        for machine in [server, client]:
            machine.wait_until_succeeds("wg show all preshared-keys | grep --invert-match none", timeout=5)
  '';

  # NOTE: Below configuration is for "interactive" (=developing/debugging) only.
  interactive.nodes =
    let
      inherit (import ./ssh-keys.nix pkgs) snakeOilPublicKey snakeOilPrivateKey;

      sshAndKeyGeneration = {
        services.openssh.enable = true;
        users.users.root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ];
        environment.systemPackages = [
          (pkgs.writeShellApplication {
            name = "gen-keys";
            runtimeInputs = [ pkgs.rosenpass ];
            text = ''
              HOST="$(hostname)"
              if [ "$HOST" == "server" ]
              then
                PEER="client"
              else
                PEER="server"
              fi

              # Generate keypair.
              mkdir -vp /etc/rosenpass/peers/$PEER
              rosenpass gen-keys --force --secret-key /etc/rosenpass/pqsk --public-key /etc/rosenpass/pqpk

              # Set up SSH key.
              mkdir -p /root/.ssh
              cp ${snakeOilPrivateKey} /root/.ssh/id_ecdsa
              chmod 0400 /root/.ssh/id_ecdsa

              # Copy public key to other peer.
              # shellcheck disable=SC2029
              ssh -o StrictHostKeyChecking=no $PEER "mkdir -pv /etc/rosenpass/peers/$HOST"
              scp /etc/rosenpass/pqpk "$PEER:/etc/rosenpass/peers/$HOST/pqpk"
            '';
          })
        ];
      };

      # Use kmscon <https://www.freedesktop.org/wiki/Software/kmscon/>
      # to provide a slightly nicer console, and while we're at it,
      # also use a nice font.
      # With kmscon, we can for example zoom in/out using [Ctrl] + [+]
      # and [Ctrl] + [-]
      niceConsoleAndAutologin.services.kmscon = {
        enable = true;
        autologinUser = "root";
        fonts = [{
          name = "Fira Code";
          package = pkgs.fira-code;
        }];
      };
    in
    {
      server = sshAndKeyGeneration // niceConsoleAndAutologin;
      client = sshAndKeyGeneration // niceConsoleAndAutologin;
    };
})
Loading