From 2aaf924e82914cc2668cb9a6c29f9f82a8646132 Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb <542154+lorenzleutgeb@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:34:09 +0100 Subject: [PATCH] radicle: init (#5409) Co-authored-by: Matthias Beyer Co-authored-by: Austin Horstman --- .../misc/news/2025/09/2025-09-08_20-49-30.nix | 12 + modules/programs/radicle.nix | 269 ++++++++++++++++++ modules/services/radicle.nix | 245 ++++++++++++++++ .../programs/radicle/basic-configuration.json | 7 + .../programs/radicle/basic-configuration.nix | 13 + tests/modules/programs/radicle/default.nix | 1 + 6 files changed, 547 insertions(+) create mode 100644 modules/misc/news/2025/09/2025-09-08_20-49-30.nix create mode 100644 modules/programs/radicle.nix create mode 100644 modules/services/radicle.nix create mode 100644 tests/modules/programs/radicle/basic-configuration.json create mode 100644 tests/modules/programs/radicle/basic-configuration.nix create mode 100644 tests/modules/programs/radicle/default.nix diff --git a/modules/misc/news/2025/09/2025-09-08_20-49-30.nix b/modules/misc/news/2025/09/2025-09-08_20-49-30.nix new file mode 100644 index 000000000..8e3d7f96d --- /dev/null +++ b/modules/misc/news/2025/09/2025-09-08_20-49-30.nix @@ -0,0 +1,12 @@ +{ pkgs, ... }: +{ + time = "2025-09-08T18:49:30+00:00"; + condition = pkgs.stdenv.hostPlatform.isLinux; + message = '' + A new module is available: 'programs.radicle'. + A new service is available: 'services.radicle.node'. + + Radicle is a distributed code forge built on Git. + Since it is possible to interact with Radicle storage without running the service, two modules were introduced. + ''; +} diff --git a/modules/programs/radicle.nix b/modules/programs/radicle.nix new file mode 100644 index 000000000..17ef564e3 --- /dev/null +++ b/modules/programs/radicle.nix @@ -0,0 +1,269 @@ +{ + config, + options, + lib, + pkgs, + ... +}: +let + inherit (lib) + attrValues + filter + filterAttrs + getExe + hasSuffix + head + id + length + listToAttrs + mkDefault + mkEnableOption + mkIf + mkOption + mkPackageOption + replaceStrings + ; + + inherit (lib.types) + listOf + nullOr + str + submodule + ; + + cfg = config.programs.radicle; + opt = options.programs.radicle; + + configFile = rec { + format = pkgs.formats.json { }; + name = "config.json"; + path = pkgs.runCommand name { nativeBuildInputs = [ cfg.cli.package ]; } '' + mkdir keys + echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID/this/is/not/a/real/key/only/a/placeholder" \ + > keys/radicle.pub + cp ${format.generate name cfg.settings} ${name} + RAD_HOME=$PWD rad config + cp ${name} $out + ''; + }; + + publicExplorerSuffix = "$rid$path"; +in +{ + options = { + programs.radicle = { + enable = mkEnableOption "Radicle"; + + cli.package = mkPackageOption pkgs "radicle-node" { }; + + uri = { + rad = { + browser = { + enable = mkOption { + description = "Whether to enable `rad:`-URI handling by web browser"; + default = + (hasSuffix publicExplorerSuffix cfg.settings.publicExplorer) && pkgs.stdenv.hostPlatform.isLinux; + defaultText = "`true` if a suitable public explorer is detected."; + example = false; + }; + preferredNode = mkOption { + type = str; + description = "The hostname of an instance of `radicle-node`, reachable via HTTPS."; + default = "iris.radicle.xyz"; + example = "radicle-node.example.com"; + }; + }; + vscode = { + enable = mkEnableOption "`rad:`-URI handling by VSCode"; + extension = mkOption { + type = str; + description = "The unique identifier of the VSCode extension that should handle `rad:`-URIs."; + default = "radicle-ide-plugins-team.radicle"; + }; + }; + }; + web-rad = + let + detected = + let + detectionList = attrValues ( + filterAttrs (n: _: config.programs.${n}.enable) { + librewolf = "librewolf.desktop"; + firefox = "firefox.desktop"; + chromium = "chromium-browser.desktop"; + } + ); + in + lib.optionals (detectionList == [ ]) (head detectionList); + in + { + enable = mkEnableOption "`web+rad:`-URI handling by web browser"; + browser = mkOption { + description = '' + Name of the XDG Desktop Entry for your browser. + LibreWolf, Firefox and Chromium configured via home-manager will + be detected automatically. The value of this option should likely + be the same as the output of + `xdg-mime query default x-scheme-handler/https`. + ''; + type = nullOr str; + default = detected; + defaultText = "Automatically detected browser."; + example = "brave.desktop"; + }; + }; + }; + + settings = mkOption { + default = { }; + description = "Radicle configuration, written to `~/.radicle/config.json."; + + type = submodule { + freeformType = configFile.format.type; + options = { + publicExplorer = mkOption { + type = str; + description = "HTTPS URL pattern used to generate links to view content on Radicle via the browser."; + default = "https://app.radicle.xyz/nodes/$host/$rid$path"; + example = "https://radicle.example.com/nodes/seed.example.com/$rid$path"; + }; + + node = { + alias = mkOption { + type = str; + description = "Human readable alias for your node."; + default = config.home.username; + defaultText = lib.literalExpression "config.home.username"; + }; + listen = mkOption { + type = listOf str; + description = "Addresses to bind to and listen for inbound connections."; + default = [ ]; + example = [ "127.0.0.1:58776" ]; + }; + }; + }; + }; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = + !pkgs.hostPlatform.isLinux + -> ( + (filter id [ + cfg.uri.rad.browser.enable + cfg.uri.rad.vscode.enable + ]) == [ ] + ); + message = "`rad:`-URI handlers are only supported on Linux."; + } + { + assertion = cfg.uri.web-rad.enable -> cfg.uri.web-rad.browser != null; + message = "Could not detect preferred browser. Please set `${builtins.toString opt.uri.web-rad.browser}`."; + } + { + assertion = + 1 >= length ( + filter id [ + cfg.uri.rad.browser.enable + cfg.uri.rad.vscode.enable + ] + ); + message = "At most one `rad:`-URI handler may be enabled."; + } + { + assertion = + cfg.uri.rad.browser.enable -> hasSuffix publicExplorerSuffix cfg.settings.publicExplorer; + message = "${opt.uri.rad.browser.enable} is only compatible with a public explorer URL ending in '${publicExplorerSuffix}' but '${cfg.settings.publicExplorer}' does not end with '${publicExplorerSuffix}'."; + } + ]; + + home = { + packages = [ cfg.cli.package ]; + file.".radicle/${configFile.name}".source = configFile.path; + }; + + xdg = { + mimeApps.defaultApplications = { + "x-scheme-handler/rad" = + let + isEnabled = cfg.uri.rad.browser.enable || cfg.uri.rad.vscode.enable; + + handlerTarget = + if cfg.uri.rad.browser.enable then + "rad-to-browser.desktop" + else if cfg.uri.rad.vscode.enable then + "rad-to-vscode.desktop" + else + throw "unreachable"; + + handler = mkDefault handlerTarget; + in + mkIf isEnabled handler; + + "x-scheme-handler/web+rad" = mkIf cfg.uri.web-rad.enable (mkDefault cfg.uri.web-rad.browser); + }; + desktopEntries = + let + mkHandler = + { + name, + shortName, + prefix, + }: + { + name = "Open Radicle URIs with ${name}"; + genericName = "Code Forge"; + categories = [ + "Development" + "RevisionControl" + ]; + exec = getExe ( + pkgs.writeShellApplication { + name = "rad-to-${shortName}"; + meta.mainProgram = "rad-to-${shortName}"; + text = ''xdg-open "${prefix}$1"''; + } + ); + mimeType = [ "x-scheme-handler/rad" ]; + noDisplay = true; + }; + + toHandler = v: { + name = "rad-to-${v.shortName}"; + value = mkIf cfg.uri.rad.${v.shortName}.enable (mkHandler v); + }; + in + listToAttrs ( + map toHandler [ + { + name = "Web Browser"; + shortName = "browser"; + prefix = + replaceStrings + [ "$host" publicExplorerSuffix ] + [ + cfg.uri.rad.browser.preferredNode + "" + ] + cfg.settings.publicExplorer; + } + { + name = "VSCode"; + shortName = "vscode"; + prefix = "vscode://${cfg.uri.rad.vscode.extension}/"; + } + ] + ); + }; + }; + + meta.maintainers = with lib.maintainers; [ + lorenzleutgeb + matthiasbeyer + ]; +} diff --git a/modules/services/radicle.nix b/modules/services/radicle.nix new file mode 100644 index 000000000..511382761 --- /dev/null +++ b/modules/services/radicle.nix @@ -0,0 +1,245 @@ +{ + config, + lib, + pkgs, + options, + ... +}: +let + inherit (lib) + generators + getBin + getExe' + last + mapAttrsToList + mkDefault + mkEnableOption + mkIf + mkOption + mkMerge + mkPackageOption + splitString + ; + + inherit (lib.types) + attrsOf + nullOr + oneOf + package + path + str + ; + + cfg = config.services.radicle; + + radicleHome = config.home.homeDirectory + "/.radicle"; + + gitPath = [ "PATH=${getBin pkgs.gitMinimal}/bin" ]; + env = attrs: (mapAttrsToList (generators.mkKeyValueDefault { } "=") attrs) ++ gitPath; +in +{ + options = { + services.radicle = { + node = { + enable = mkEnableOption "Radicle Node"; + package = mkPackageOption pkgs "radicle-node" { }; + args = mkOption { + type = str; + description = "Additional command line arguments to pass when executing `radicle-node`."; + default = ""; + example = "--force"; + }; + environment = mkOption { + type = attrsOf ( + nullOr (oneOf [ + str + path + package + ]) + ); + description = "Environment to set when executing `radicle-node`."; + default = { }; + example = { + "RUST_BACKTRACE" = "full"; + }; + }; + lazy = { + enable = mkEnableOption "a proxy service to lazily start and stop Radicle Node on demand"; + exitIdleTime = mkOption { + type = str; + description = "The idle time after which no interaction with Radicle Node via the `rad` CLI should be stopped, in a format that {manpage}`systemd-socket-proxyd(8)` understands for its `--exit-idle-time` argument."; + default = "30min"; + example = "1h"; + }; + }; + }; + }; + }; + + config = mkIf cfg.node.enable { + systemd.user = { + services = { + "radicle-node" = + let + keyFile = name: "${radicleHome}/keys/${name}"; + keyPair = name: [ + (keyFile name) + (keyFile (name + ".pub")) + ]; + radicleKeyPair = keyPair "radicle"; + in + { + Unit = { + Description = "Radicle Node"; + Documentation = [ + "https://radicle.xyz/guides" + "man:radicle-node(1)" + ]; + StopWhenUnneeded = cfg.node.lazy.enable; + ConditionPathExists = radicleKeyPair; + }; + Service = mkMerge ([ + { + Slice = "session.slice"; + ExecStart = "${getExe' cfg.node.package "radicle-node"} ${cfg.node.args}"; + Environment = env cfg.node.environment; + KillMode = "process"; + Restart = "no"; + RestartSec = "2"; + RestartSteps = "100"; + RestartMaxDelaySec = "1min"; + } + { + # Hardening + + BindPaths = [ + "${radicleHome}/storage" + "${radicleHome}/node" + "${radicleHome}/cobs" + ]; + + BindReadOnlyPaths = [ + "${radicleHome}/config.json" + "${radicleHome}/keys" + "-/etc/resolv.conf" + "/run/systemd" + ]; + + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ]; + + AmbientCapabilities = ""; + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + + DeviceAllow = ""; # ProtectClock= adds DeviceAllow=char-rtc r + KeyringMode = "private"; + LockPersonality = true; + MemoryDenyWriteExecute = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = "self"; + + ProcSubset = "pid"; + ProtectClock = true; + ProtectHome = "tmpfs"; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + + RuntimeDirectoryMode = "0700"; + + SocketBindDeny = [ "any" ]; + SocketBindAllow = map ( + addr: "tcp:${last (splitString ":" addr)}" + ) config.programs.radicle.settings.node.listen; + + StateDirectoryMode = "0750"; + UMask = "0067"; + + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@aio" + "~@chown" + "~@keyring" + "~@memlock" + "~@privileged" + "~@resources" + "~@setuid" + ]; + } + ]); + }; + "radicle-node-proxy" = mkIf cfg.node.lazy.enable { + Unit = { + Description = "Radicle Node Proxy"; + BindsTo = [ + "radicle-node-proxy.socket" + "radicle-node.service" + ]; + After = [ + "radicle-node-proxy.socket" + "radicle-node.service" + ]; + Documentation = [ "man:systemd-socket-proxyd(8)" ]; + }; + Service = { + ExecSearchPath = "${pkgs.systemd}/lib/systemd"; + ExecStart = "systemd-socket-proxyd --exit-idle-time=${cfg.node.lazy.exitIdleTime} %t/radicle-node/proxy.sock"; + PrivateTmp = "yes"; + PrivateNetwork = "yes"; + RuntimeDirectory = "radicle"; + RuntimeDirectoryPreserve = "yes"; + }; + }; + }; + sockets = mkIf cfg.node.lazy.enable { + "radicle-node-control" = { + Unit = { + Description = "Radicle Node Control Socket"; + Documentation = [ "man:radicle-node(1)" ]; + }; + Socket = { + Service = "radicle-node-proxy.service"; + ListenStream = "%t/radicle-node/control.sock"; + RuntimeDirectory = "radicle-node"; + RuntimeDirectoryPreserve = "yes"; + }; + Install.WantedBy = [ "sockets.target" ]; + }; + "radicle-node-proxy" = { + Unit = { + Description = "Radicle Node Proxy Socket"; + Documentation = [ "man:systemd-socket-proxyd(8)" ]; + }; + Socket = { + Service = "radicle-node.service"; + FileDescriptorName = "control"; + ListenStream = "%t/radicle-node/proxy.sock"; + RuntimeDirectory = "radicle-node"; + RuntimeDirectoryPreserve = "yes"; + }; + Install.WantedBy = [ "sockets.target" ]; + }; + }; + }; + programs.radicle.enable = mkDefault true; + home.sessionVariables = mkIf cfg.node.lazy.enable { + RAD_SOCKET = "\${XDG_RUNTIME_DIR:-/run/user/$UID}/radicle-node/control.sock"; + }; + }; + + meta.maintainers = with lib.maintainers; [ + lorenzleutgeb + matthiasbeyer + ]; +} diff --git a/tests/modules/programs/radicle/basic-configuration.json b/tests/modules/programs/radicle/basic-configuration.json new file mode 100644 index 000000000..4d2f1879f --- /dev/null +++ b/tests/modules/programs/radicle/basic-configuration.json @@ -0,0 +1,7 @@ +{ + "node": { + "alias": "hm-user", + "listen": [] + }, + "publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path" +} diff --git a/tests/modules/programs/radicle/basic-configuration.nix b/tests/modules/programs/radicle/basic-configuration.nix new file mode 100644 index 000000000..378cb8449 --- /dev/null +++ b/tests/modules/programs/radicle/basic-configuration.nix @@ -0,0 +1,13 @@ +{ config, pkgs, ... }: + +{ + config = { + programs.radicle.enable = true; + + nmt.script = '' + assertFileContent \ + home-files/.radicle/config.json \ + ${./basic-configuration.json} + ''; + }; +} diff --git a/tests/modules/programs/radicle/default.nix b/tests/modules/programs/radicle/default.nix new file mode 100644 index 000000000..589794fc6 --- /dev/null +++ b/tests/modules/programs/radicle/default.nix @@ -0,0 +1 @@ +{ radicle-basic-configuration = ./basic-configuration.nix; }