Merge pull request #314440 from ju1m/radicle

Sandro 51fcc2c9 7d965995

Changed files
+573
nixos
doc
manual
release-notes
modules
tests
pkgs
by-name
ra
radicle-node
+2
nixos/doc/manual/release-notes/rl-2411.section.md
···
- [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
nixos/modules/module-list.nix
···
./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
nixos/modules/services/misc/radicle.nix
···
+
{ 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
nixos/tests/all-tests.nix
···
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
nixos/tests/radicle.nix
···
+
# 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.'"
+
)
+
'';
+
}
+15
pkgs/by-name/ra/radicle-node/package.nix
···
, lib
, makeWrapper
, man-db
+
, nixos
+
, nixosTests
, openssh
, radicle-node
, runCommand
···
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 = {