Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
feat(nixos): Support running rad commands for service
Archived did:key:z6Mkf8A8...FoXE opened 1 year ago

radicle-node’s user (radicle) couldn’t easily interact with the systemd service. This made maintenance and administration very difficult. Stuff like getting the node ID, listing repositories, connected seed repositories, and so was a chore.

With RAD_HOME now set to the defaul ~/.radicle, the public key and the config readable by rad, all the above operations (and more) are available. the configuration still cannot be modified, but given it’s NixOS, that is desirable as it should be declarative.

2 files changed +409 -0 3b5fac17 b1114959
added resources/nixos/README.md
@@ -0,0 +1,33 @@
+
# Changes to the official service
+

+
The official service makes it very difficult to do any kind of `rad` operations.
+
Its config, public and private keys are world-readable in the store, but **not** in the user's
+
`HOME`.
+

+
With this service, you are able to
+

+
```sh
+
# Enter the radicle user
+
sudo -s -u radicle
+
rad self # or any other read-only radicle command
+
```
+

+
# How to use
+

+
Simply import [`service.nix`][service.nix] into your configuration and configure it as you would
+
the official one. See the [quickstart guide][seeding quickstart] for more information
+
on configuration.
+

+
```nix
+
{
+
  imports = [ /path/to/repository/resources/nixos/service.nix ];
+

+
  services.radicle = {
+
    enable = true;
+
    # The rest of the configuration
+
  };
+
}
+
```
+

+
[seeding quickstart]: https://radicle.xyz/guides/quickstart/seeding
+
[service.nix]: ./service.nix
added resources/nixos/service.nix
@@ -0,0 +1,376 @@
+
{ config, lib, pkgs, ... }:
+
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}/.radicle";
+
  };
+

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

+
  commonServiceConfig = serviceName: {
+
    environment = env // {
+
      RUST_LOG = lib.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 = lib.mkMerge [
+
      {
+
        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
+
        (lib.getLib pkgs.nss)
+
        pkgs.tzdata
+
      ];
+
    };
+
  };
+
in
+
{
+
  # Replace the existing module
+
  disabledModules = [
+
    "services/misc/radicle.nix"
+
  ];
+
  
+
  options = {
+
    services.radicle = {
+
      enable = lib.mkEnableOption "Radicle Seed Node";
+
      package = lib.mkPackageOption pkgs "radicle-node" { };
+
      privateKeyFile = lib.mkOption {
+
        # Note that a key encrypted by systemd-creds is not a path but a str.
+
        type = with lib.types; either path str;
+
        description = ''
+
          Absolute file path to an SSH private key,
+
          usually 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`.
+
        '';
+
      };
+
      publicKey = lib.mkOption {
+
        type = with lib.types; either path str;
+
        description = ''
+
          An SSH public key (as an absolute file path or directly as a string),
+
          usually generated by `rad auth`.
+
        '';
+
      };
+
      node = {
+
        listenAddress = lib.mkOption {
+
          type = lib.types.str;
+
          default = "[::]";
+
          example = "127.0.0.1";
+
          description = "The IP address on which `radicle-node` listens.";
+
        };
+
        listenPort = lib.mkOption {
+
          type = lib.types.port;
+
          default = 8776;
+
          description = "The port on which `radicle-node` listens.";
+
        };
+
        openFirewall = lib.mkEnableOption "opening the firewall for `radicle-node`";
+
        extraArgs = lib.mkOption {
+
          type = with lib.types; listOf str;
+
          default = [ ];
+
          description = "Extra arguments for `radicle-node`";
+
        };
+
      };
+
      configFile = lib.mkOption {
+
        type = lib.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 + lib.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
+
            ${lib.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 = lib.mkEnableOption "checking the {file}`config.json` file resulting from {option}`services.radicle.settings`" // { default = true; };
+
      settings = lib.mkOption {
+
        description = ''
+
          See https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/radicle/src/node/config.rs#L275
+
        '';
+
        default = { };
+
        example = lib.literalExpression ''
+
          {
+
            web.pinned.repositories = [
+
              "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5" # heartwood
+
              "rad:z3trNYnLWS11cJWC6BbxDs5niGo82" # rips
+
            ];
+
          }
+
        '';
+
        type = lib.types.submodule {
+
          freeformType = json.type;
+
        };
+
      };
+
      httpd = {
+
        enable = lib.mkEnableOption "Radicle HTTP gateway to radicle-node";
+
        package = lib.mkPackageOption pkgs "radicle-httpd" { };
+
        listenAddress = lib.mkOption {
+
          type = lib.types.str;
+
          default = "127.0.0.1";
+
          description = "The IP address on which `radicle-httpd` listens.";
+
        };
+
        listenPort = lib.mkOption {
+
          type = lib.types.port;
+
          default = 8080;
+
          description = "The port on which `radicle-httpd` listens.";
+
        };
+
        nginx = lib.mkOption {
+
          # Type of a single virtual host, or null.
+
          type = lib.types.nullOr (lib.types.submodule (
+
            lib.recursiveUpdate (import "${toString <nixos>}/nixos/modules/services/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 = lib.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 = lib.mkOption {
+
          type = with lib.types; listOf str;
+
          default = [ ];
+
          description = "Extra arguments for `radicle-httpd`";
+
        };
+
      };
+
    };
+
  };
+

+
  config = lib.mkIf cfg.enable (lib.mkMerge [
+
    {
+
      systemd.mounts = [
+
         {
+
          description = "Radicle node configuration";
+
          what = "${cfg.configFile}";
+
          where = "${env.RAD_HOME}/config.json";
+
          type = "none";
+
          options = "bind";
+
          wantedBy = [ "radicle-node.service" ] ++ lib.optional cfg.httpd.enable "radicle-httpd.service";
+
        }
+
        {
+
          description = "Radicle node public key";
+
          what = "${if lib.types.path.check cfg.publicKey then cfg.publicKey else pkgs.writeText "radicle.pub" cfg.publicKey}";
+
          where = "${env.RAD_HOME}/keys/radicle.pub";
+
          type = "none";
+
          options = "bind";
+
          wantedBy = [ "radicle-node.service" ] ++ lib.optional cfg.httpd.enable "radicle-httpd.service";
+
        }
+
      ];
+
      systemd.services.radicle-node = lib.mkMerge [
+
        (commonServiceConfig "radicle-node")
+
        {
+
          description = "Radicle Node";
+
          documentation = [ "man:radicle-node(1)" ];
+
          serviceConfig = {
+
            ExecStart = "${lib.getExe' cfg.package "radicle-node"} --force --listen ${cfg.node.listenAddress}:${toString cfg.node.listenPort} ${lib.escapeShellArgs cfg.node.extraArgs}";
+
            Restart = lib.mkDefault "on-failure";
+
            RestartSec = "30";
+
            SocketBindAllow = [ "tcp:${toString cfg.node.listenPort}" ];
+
            SystemCallFilter = lib.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 lib.length keyCred > 1
+
            then {
+
              LoadCredentialEncrypted = [ cfg.privateKeyFile ];
+
              # Note that neither %d nor ${CREDENTIALS_DIRECTORY} works in BindReadOnlyPaths=
+
              BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/${lib.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 = lib.mkIf cfg.node.openFirewall {
+
        allowedTCPPorts = [ cfg.node.listenPort ];
+
      };
+

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

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

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

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

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