Unverified Commit 51fcc2c9 authored by Sandro Jäckel's avatar Sandro Jäckel Committed by GitHub
Browse files

Merge pull request #314440 from ju1m/radicle

parents 7d965995 88fb6d37
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -24,6 +24,8 @@

- [Eintopf](https://eintopf.info), community event and calendar web application. Available as [services.eintopf](options.html#opt-services.eintopf).

- [Radicle](https://radicle.xyz), an open source, peer-to-peer code collaboration stack built on Git. Available as [services.radicle](#opt-services.radicle.enable).

- [Renovate](https://github.com/renovatebot/renovate), a dependency updating tool for various git forges and language ecosystems. Available as [services.renovate](#opt-services.renovate.enable).

- [wg-access-server](https://github.com/freifunkMUC/wg-access-server/), an all-in-one WireGuard VPN solution with a web ui for connecting devices. Available at [services.wg-access-server](#opt-services.wg-access-server.enable).
+1 −0
Original line number Diff line number Diff line
@@ -803,6 +803,7 @@
  ./services/misc/pufferpanel.nix
  ./services/misc/pykms.nix
  ./services/misc/radarr.nix
  ./services/misc/radicle.nix
  ./services/misc/readarr.nix
  ./services/misc/redmine.nix
  ./services/misc/renovate.nix
+347 −0
Original line number Diff line number Diff line
{ config, lib, pkgs, ... }:
with lib;
let
  cfg = config.services.radicle;

  json = pkgs.formats.json { };

  env = rec {
    # rad fails if it cannot stat $HOME/.gitconfig
    HOME = "/var/lib/radicle";
    RAD_HOME = HOME;
  };

  # Convenient wrapper to run `rad` in the namespaces of `radicle-node.service`
  rad-system = pkgs.writeShellScriptBin "rad-system" ''
    set -o allexport
    ${toShellVars env}
    # Note that --env is not used to preserve host's envvars like $TERM
    exec ${getExe' pkgs.util-linux "nsenter"} -a \
      -t "$(${getExe' config.systemd.package "systemctl"} show -P MainPID radicle-node.service)" \
      -S "$(${getExe' config.systemd.package "systemctl"} show -P UID radicle-node.service)" \
      -G "$(${getExe' config.systemd.package "systemctl"} show -P GID radicle-node.service)" \
      ${getExe' cfg.package "rad"} "$@"
  '';

  commonServiceConfig = serviceName: {
    environment = env // {
      RUST_LOG = mkDefault "info";
    };
    path = [
      pkgs.gitMinimal
    ];
    documentation = [
      "https://docs.radicle.xyz/guides/seeder"
    ];
    after = [
      "network.target"
      "network-online.target"
    ];
    requires = [
      "network-online.target"
    ];
    wantedBy = [ "multi-user.target" ];
    serviceConfig = mkMerge [
      {
        BindReadOnlyPaths = [
          "${cfg.configFile}:${env.RAD_HOME}/config.json"
          "${if isPath cfg.publicKeyFile then cfg.publicKeyFile else pkgs.writeText "radicle.pub" cfg.publicKeyFile}:${env.RAD_HOME}/keys/radicle.pub"
        ];
        KillMode = "process";
        StateDirectory = [ "radicle" ];
        User = config.users.users.radicle.name;
        Group = config.users.groups.radicle.name;
        WorkingDirectory = env.HOME;
      }
      # The following options are only for optimizing:
      # systemd-analyze security ${serviceName}
      {
        BindReadOnlyPaths = [
          "-/etc/resolv.conf"
          "/etc/ssl/certs/ca-certificates.crt"
          "/run/systemd"
        ];
        AmbientCapabilities = "";
        CapabilityBoundingSet = "";
        DeviceAllow = ""; # ProtectClock= adds DeviceAllow=char-rtc r
        LockPersonality = true;
        MemoryDenyWriteExecute = true;
        NoNewPrivileges = true;
        PrivateTmp = true;
        ProcSubset = "pid";
        ProtectClock = true;
        ProtectHome = true;
        ProtectHostname = true;
        ProtectKernelLogs = true;
        ProtectProc = "invisible";
        ProtectSystem = "strict";
        RemoveIPC = true;
        RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
        RestrictNamespaces = true;
        RestrictRealtime = true;
        RestrictSUIDSGID = true;
        RuntimeDirectoryMode = "700";
        SocketBindDeny = [ "any" ];
        StateDirectoryMode = "0750";
        SystemCallFilter = [
          "@system-service"
          "~@aio"
          "~@chown"
          "~@keyring"
          "~@memlock"
          "~@privileged"
          "~@resources"
          "~@setuid"
          "~@timer"
        ];
        SystemCallArchitectures = "native";
        # This is for BindPaths= and BindReadOnlyPaths=
        # to allow traversal of directories they create inside RootDirectory=
        UMask = "0066";
      }
    ];
    confinement = {
      enable = true;
      mode = "full-apivfs";
      packages = [
        pkgs.gitMinimal
        cfg.package
        pkgs.iana-etc
        (getLib pkgs.nss)
        pkgs.tzdata
      ];
    };
  };
in
{
  options = {
    services.radicle = {
      enable = mkEnableOption "Radicle Seed Node";
      package = mkPackageOption pkgs "radicle-node" { };
      privateKeyFile = mkOption {
        type = with types; either path str;
        description = ''
          SSH private key generated by `rad auth`.

          If it contains a colon (`:`) the string before the colon
          is taken as the credential name
          and the string after as a path encrypted with `systemd-creds`.
        '';
      };
      publicKeyFile = mkOption {
        type = with types; either path str;
        description = ''
          SSH public key generated by `rad auth`.
        '';
      };
      node = {
        listenAddress = mkOption {
          type = types.str;
          default = "0.0.0.0";
          example = "127.0.0.1";
          description = "The IP address on which `radicle-node` listens.";
        };
        listenPort = mkOption {
          type = types.port;
          default = 8776;
          description = "The port on which `radicle-node` listens.";
        };
        openFirewall = mkEnableOption "opening the firewall for `radicle-node`";
        extraArgs = mkOption {
          type = with types; listOf str;
          default = [ ];
          description = "Extra arguments for `radicle-node`";
        };
      };
      configFile = mkOption {
        type = types.package;
        internal = true;
        default = (json.generate "config.json" cfg.settings).overrideAttrs (previousAttrs: {
          preferLocalBuild = true;
          # None of the usual phases are run here because runCommandWith uses buildCommand,
          # so just append to buildCommand what would usually be a checkPhase.
          buildCommand = previousAttrs.buildCommand + optionalString cfg.checkConfig ''
            ln -s $out config.json
            install -D -m 644 /dev/stdin keys/radicle.pub <<<"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBgFMhajUng+Rjj/sCFXI9PzG8BQjru2n7JgUVF1Kbv5 snakeoil"
            export RAD_HOME=$PWD
            ${getExe' pkgs.buildPackages.radicle-node "rad"} config >/dev/null || {
              cat -n config.json
              echo "Invalid config.json according to rad."
              echo "Please double-check your services.radicle.settings (producing the config.json above),"
              echo "some settings may be missing or have the wrong type."
              exit 1
            } >&2
          '';
        });
      };
      checkConfig = mkEnableOption "checking the {file}`config.json` file resulting from {option}`services.radicle.settings`" // { default = true; };
      settings = mkOption {
        description = ''
          See https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/radicle/src/node/config.rs#L275
        '';
        default = { };
        type = types.submodule {
          freeformType = json.type;
        };
      };
      httpd = {
        enable = mkEnableOption "Radicle HTTP gateway to radicle-node";
        package = mkPackageOption pkgs "radicle-httpd" { };
        listenAddress = mkOption {
          type = types.str;
          default = "127.0.0.1";
          description = "The IP address on which `radicle-httpd` listens.";
        };
        listenPort = mkOption {
          type = types.port;
          default = 8080;
          description = "The port on which `radicle-httpd` listens.";
        };
        nginx = mkOption {
          # Type of a single virtual host, or null.
          type = types.nullOr (types.submodule (
            recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {
              options.serverName = {
                default = "radicle-${config.networking.hostName}.${config.networking.domain}";
                defaultText = "radicle-\${config.networking.hostName}.\${config.networking.domain}";
              };
            }
          ));
          default = null;
          example = literalExpression ''
            {
              serverAliases = [
                "seed.''${config.networking.domain}"
              ];
              enableACME = false;
              useACMEHost = config.networking.domain;
            }
          '';
          description = ''
            With this option, you can customize an nginx virtual host which already has sensible defaults for `radicle-httpd`.
            Set to `{}` if you do not need any customization to the virtual host.
            If enabled, then by default, the {option}`serverName` is
            `radicle-''${config.networking.hostName}.''${config.networking.domain}`,
            TLS is active, and certificates are acquired via ACME.
            If this is set to null (the default), no nginx virtual host will be configured.
          '';
        };
        extraArgs = mkOption {
          type = with types; listOf str;
          default = [ ];
          description = "Extra arguments for `radicle-httpd`";
        };
      };
    };
  };

  config = mkIf cfg.enable (mkMerge [
    {
      systemd.services.radicle-node = mkMerge [
        (commonServiceConfig "radicle-node")
        {
          description = "Radicle Node";
          documentation = [ "man:radicle-node(1)" ];
          serviceConfig = {
            ExecStart = "${getExe' cfg.package "radicle-node"} --force --listen ${cfg.node.listenAddress}:${toString cfg.node.listenPort} ${escapeShellArgs cfg.node.extraArgs}";
            Restart = mkDefault "on-failure";
            RestartSec = "30";
            SocketBindAllow = [ "tcp:${toString cfg.node.listenPort}" ];
            SystemCallFilter = mkAfter [
              # Needed by git upload-pack which calls alarm() and setitimer() when providing a rad clone
              "@timer"
            ];
          };
          confinement.packages = [
            cfg.package
          ];
        }
        # Give only access to the private key to radicle-node.
        {
          serviceConfig =
            let keyCred = builtins.split ":" "${cfg.privateKeyFile}"; in
            if length keyCred > 1
            then {
              LoadCredentialEncrypted = [ cfg.privateKeyFile ];
              # Note that neither %d nor ${CREDENTIALS_DIRECTORY} works in BindReadOnlyPaths=
              BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/${head keyCred}:${env.RAD_HOME}/keys/radicle" ];
            }
            else {
              LoadCredential = [ "radicle:${cfg.privateKeyFile}" ];
              BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/radicle:${env.RAD_HOME}/keys/radicle" ];
            };
        }
      ];

      environment.systemPackages = [
        rad-system
      ];

      networking.firewall = mkIf cfg.node.openFirewall {
        allowedTCPPorts = [ cfg.node.listenPort ];
      };

      users = {
        users.radicle = {
          description = "Radicle";
          group = "radicle";
          home = env.HOME;
          isSystemUser = true;
        };
        groups.radicle = {
        };
      };
    }

    (mkIf cfg.httpd.enable (mkMerge [
      {
        systemd.services.radicle-httpd = mkMerge [
          (commonServiceConfig "radicle-httpd")
          {
            description = "Radicle HTTP gateway to radicle-node";
            documentation = [ "man:radicle-httpd(1)" ];
            serviceConfig = {
              ExecStart = "${getExe' cfg.httpd.package "radicle-httpd"} --listen ${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort} ${escapeShellArgs cfg.httpd.extraArgs}";
              Restart = mkDefault "on-failure";
              RestartSec = "10";
              SocketBindAllow = [ "tcp:${toString cfg.httpd.listenPort}" ];
              SystemCallFilter = mkAfter [
                # Needed by git upload-pack which calls alarm() and setitimer() when providing a git clone
                "@timer"
              ];
            };
          confinement.packages = [
            cfg.httpd.package
          ];
          }
        ];
      }

      (mkIf (cfg.httpd.nginx != null) {
        services.nginx.virtualHosts.${cfg.httpd.nginx.serverName} = lib.mkMerge [
          cfg.httpd.nginx
          {
            forceSSL = mkDefault true;
            enableACME = mkDefault true;
            locations."/" = {
              proxyPass = "http://${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort}";
              recommendedProxySettings = true;
            };
          }
        ];

        services.radicle.settings = {
          node.alias = mkDefault cfg.httpd.nginx.serverName;
          node.externalAddresses = mkDefault [
            "${cfg.httpd.nginx.serverName}:${toString cfg.node.listenPort}"
          ];
        };
      })
    ]))
  ]);

  meta.maintainers = with lib.maintainers; [
    julm
    lorenzleutgeb
  ];
}
+1 −0
Original line number Diff line number Diff line
@@ -813,6 +813,7 @@ in {
  rabbitmq = handleTest ./rabbitmq.nix {};
  radarr = handleTest ./radarr.nix {};
  radicale = handleTest ./radicale.nix {};
  radicle = runTest ./radicle.nix;
  ragnarwm = handleTest ./ragnarwm.nix {};
  rasdaemon = handleTest ./rasdaemon.nix {};
  readarr = handleTest ./readarr.nix {};
+207 −0
Original line number Diff line number Diff line
# This test runs the radicle-node and radicle-httpd services on a seed host,
# and verifies that an alice peer can host a repository on the seed,
# and that a bob peer can send alice a patch via the seed.

{ pkgs, ... }:

let
  # The Node ID depends on nodes.seed.services.radicle.privateKeyFile
  seed-nid = "z6Mkg52RcwDrPKRzzHaYgBkHH3Gi5p4694fvPstVE9HTyMB6";
  seed-ssh-keys = import ./ssh-keys.nix pkgs;
  seed-tls-certs = import common/acme/server/snakeoil-certs.nix;

  commonHostConfig = { nodes, config, pkgs, ... }: {
    environment.systemPackages = [
      config.services.radicle.package
      pkgs.curl
      pkgs.gitMinimal
      pkgs.jq
    ];
    environment.etc."gitconfig".text = ''
      [init]
        defaultBranch = main
      [user]
        email = root@${config.networking.hostName}
        name = ${config.networking.hostName}
    '';
    networking = {
      extraHosts = ''
        ${nodes.seed.networking.primaryIPAddress} ${nodes.seed.services.radicle.httpd.nginx.serverName}
      '';
    };
    security.pki.certificateFiles = [
      seed-tls-certs.ca.cert
    ];
  };

  radicleConfig = { nodes, ... }: alias:
    pkgs.writeText "config.json" (builtins.toJSON {
      preferredSeeds = [
        "${seed-nid}@seed:${toString nodes.seed.services.radicle.node.listenPort}"
      ];
      node = {
        inherit alias;
        relay = "never";
        seedingPolicy = {
          default = "block";
        };
      };
    });
in

{
  name = "radicle";

  meta = with pkgs.lib.maintainers; {
    maintainers = [
      julm
      lorenzleutgeb
    ];
  };

  nodes = {
    seed = { pkgs, config, ... }: {
      imports = [ commonHostConfig ];

      services.radicle = {
        enable = true;
        privateKeyFile = seed-ssh-keys.snakeOilEd25519PrivateKey;
        publicKeyFile = seed-ssh-keys.snakeOilEd25519PublicKey;
        node = {
          openFirewall = true;
        };
        httpd = {
          enable = true;
          nginx = {
            serverName = seed-tls-certs.domain;
            addSSL = true;
            sslCertificate = seed-tls-certs.${seed-tls-certs.domain}.cert;
            sslCertificateKey = seed-tls-certs.${seed-tls-certs.domain}.key;
          };
        };
        settings = {
          preferredSeeds = [];
          node = {
            relay = "always";
            seedingPolicy = {
              default = "allow";
              scope = "all";
            };
          };
        };
      };

      services.nginx = {
        enable = true;
      };

      networking.firewall.allowedTCPPorts = [ 443 ];
    };

    alice = {
      imports = [ commonHostConfig ];
    };

    bob = {
      imports = [ commonHostConfig ];
    };
  };

  testScript = { nodes, ... }@args: ''
    start_all()

    with subtest("seed can run radicle-node"):
      # The threshold and/or hardening may have to be changed with new features/checks
      print(seed.succeed("systemd-analyze security radicle-node.service --threshold=10 --no-pager"))
      seed.wait_for_unit("radicle-node.service")
      seed.wait_for_open_port(${toString nodes.seed.services.radicle.node.listenPort})

    with subtest("seed can run radicle-httpd"):
      # The threshold and/or hardening may have to be changed with new features/checks
      print(seed.succeed("systemd-analyze security radicle-httpd.service --threshold=10 --no-pager"))
      seed.wait_for_unit("radicle-httpd.service")
      seed.wait_for_open_port(${toString nodes.seed.services.radicle.httpd.listenPort})
      seed.wait_for_open_port(443)
      assert alice.succeed("curl -sS 'https://${nodes.seed.services.radicle.httpd.nginx.serverName}/api/v1' | jq -r .nid") == "${seed-nid}\n"
      assert bob.succeed("curl -sS 'https://${nodes.seed.services.radicle.httpd.nginx.serverName}/api/v1' | jq -r .nid") == "${seed-nid}\n"

    with subtest("alice can create a Node ID"):
      alice.succeed("rad auth --alias alice --stdin </dev/null")
      alice.copy_from_host("${radicleConfig args "alice"}", "/root/.radicle/config.json")
    with subtest("alice can run a node"):
      alice.succeed("rad node start")
    with subtest("alice can create a Git repository"):
      alice.succeed(
        "mkdir /tmp/repo",
        "git -C /tmp/repo init",
        "echo hello world > /tmp/repo/testfile",
        "git -C /tmp/repo add .",
        "git -C /tmp/repo commit -m init"
      )
    with subtest("alice can create a Repository ID"):
      alice.succeed(
        "cd /tmp/repo && rad init --name repo --description descr --default-branch main --public"
      )
    alice_repo_rid=alice.succeed("cd /tmp/repo && rad inspect --rid").rstrip("\n")
    with subtest("alice can send a repository to the seed"):
      alice.succeed(f"rad sync --seed ${seed-nid} {alice_repo_rid}")

    with subtest(f"seed can receive the repository {alice_repo_rid}"):
      seed.wait_until_succeeds("test 1 = \"$(rad-system stats | jq .local.repos)\"")

    with subtest("bob can create a Node ID"):
      bob.succeed("rad auth --alias bob --stdin </dev/null")
      bob.copy_from_host("${radicleConfig args "bob"}", "/root/.radicle/config.json")
      bob.succeed("rad node start")
    with subtest("bob can clone alice's repository from the seed"):
      bob.succeed(f"rad clone {alice_repo_rid} /tmp/repo")
      assert bob.succeed("cat /tmp/repo/testfile") == "hello world\n"

    with subtest("bob can clone alice's repository from the seed through the HTTP gateway"):
      bob.succeed(f"git clone https://${nodes.seed.services.radicle.httpd.nginx.serverName}/{alice_repo_rid[4:]}.git /tmp/repo-http")
      assert bob.succeed("cat /tmp/repo-http/testfile") == "hello world\n"

    with subtest("alice can push the main branch to the rad remote"):
      alice.succeed(
        "echo hello bob > /tmp/repo/testfile",
        "git -C /tmp/repo add .",
        "git -C /tmp/repo commit -m 'hello to bob'",
        "git -C /tmp/repo push rad main"
      )
    with subtest("bob can sync bob's repository from the seed"):
      bob.succeed(
        "cd /tmp/repo && rad sync --seed ${seed-nid}",
        "cd /tmp/repo && git pull"
      )
      assert bob.succeed("cat /tmp/repo/testfile") == "hello bob\n"

    with subtest("bob can push a patch"):
      bob.succeed(
        "echo hello alice > /tmp/repo/testfile",
        "git -C /tmp/repo checkout -b for-alice",
        "git -C /tmp/repo add .",
        "git -C /tmp/repo commit -m 'hello to alice'",
        "git -C /tmp/repo push -o patch.message='hello for alice' rad HEAD:refs/patches"
      )

    bob_repo_patch1_pid=bob.succeed("cd /tmp/repo && git branch --remotes | sed -ne 's:^ *rad/patches/::'p").rstrip("\n")
    with subtest("alice can receive the patch"):
      alice.wait_until_succeeds("test 1 = \"$(rad stats | jq .local.patches)\"")
      alice.succeed(
        f"cd /tmp/repo && rad patch show {bob_repo_patch1_pid} | grep 'opened by bob'",
        f"cd /tmp/repo && rad patch checkout {bob_repo_patch1_pid}"
      )
      assert alice.succeed("cat /tmp/repo/testfile") == "hello alice\n"
    with subtest("alice can comment the patch"):
      alice.succeed(
        f"cd /tmp/repo && rad patch comment {bob_repo_patch1_pid} -m thank-you"
      )
    with subtest("alice can merge the patch"):
      alice.succeed(
        "git -C /tmp/repo checkout main",
        f"git -C /tmp/repo merge patch/{bob_repo_patch1_pid[:7]}",
        "git -C /tmp/repo push rad main",
        "cd /tmp/repo && rad patch list | grep -qxF 'Nothing to show.'"
      )
  '';
}
Loading