diff --git a/nixos/doc/manual/release-notes/rl-2411.section.md b/nixos/doc/manual/release-notes/rl-2411.section.md index e70483dbb2c4..583b78c4d2a2 100644 --- a/nixos/doc/manual/release-notes/rl-2411.section.md +++ b/nixos/doc/manual/release-notes/rl-2411.section.md @@ -22,6 +22,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). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 2eb623bee36b..f9aa672dc418 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -797,6 +797,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 diff --git a/nixos/modules/services/misc/radicle.nix b/nixos/modules/services/misc/radicle.nix new file mode 100644 index 000000000000..69cac81ee65f --- /dev/null +++ b/nixos/modules/services/misc/radicle.nix @@ -0,0 +1,347 @@ +{ 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 + ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 1f6826e13b5c..95be711d7b69 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -804,6 +804,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 {}; diff --git a/nixos/tests/radicle.nix b/nixos/tests/radicle.nix new file mode 100644 index 000000000000..2deff7487d80 --- /dev/null +++ b/nixos/tests/radicle.nix @@ -0,0 +1,207 @@ +# 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 /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 /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.'" + ) + ''; +} diff --git a/pkgs/by-name/ra/radicle-node/package.nix b/pkgs/by-name/ra/radicle-node/package.nix index 05324f6ff44a..a7eab0b0a1e9 100644 --- a/pkgs/by-name/ra/radicle-node/package.nix +++ b/pkgs/by-name/ra/radicle-node/package.nix @@ -7,6 +7,8 @@ , lib , makeWrapper , man-db +, nixos +, nixosTests , openssh , radicle-node , runCommand @@ -86,6 +88,19 @@ touch $out ''; + nixos-build = lib.recurseIntoAttrs { + checkConfig-success = (nixos { + services.radicle.settings = { + node.alias = "foo"; + }; + }).config.services.radicle.configFile; + checkConfig-failure = testers.testBuildFailure (nixos { + services.radicle.settings = { + node.alias = null; + }; + }).config.services.radicle.configFile; + }; + nixos-run = nixosTests.radicle; }; meta = {