inputs: { config, lib, pkgs, ... }: with lib; let cfg = config.services.serverphone; # taken in verbatim from: # https://github.com/nix-community/home-manager/blob/28614ed7a1e3ace824c122237bdc0e5e0b62c5c3/modules/programs/gpg.nix publicKeyOpts = {config, ...}: { options = { text = mkOption { type = types.nullOr types.str; default = null; description = '' Text of an OpenPGP public key. ''; }; source = mkOption { type = types.path; description = '' Path of an OpenPGP public key file. ''; }; trust = mkOption { type = types.nullOr (types.enum [ "unknown" 1 "never" 2 "marginal" 3 "full" 4 "ultimate" 5 ]); default = null; apply = v: if isString v then { unknown = 1; never = 2; marginal = 3; full = 4; ultimate = 5; } .${v} else v; description = '' The amount of trust you have in the key ownership and the care the owner puts into signing other keys. The available levels are unknown or 1 I don't know or won't say. never or 2 I do NOT trust. marginal or 3 I trust marginally. full or 4 I trust fully. ultimate or 5 I trust ultimately. See for more. ''; }; }; config = { source = mkIf (config.text != null) (pkgs.writeText "gpg-pubkey" config.text); }; }; importTrustBashFunctions = let gpg = "${pkgs.gnupg}/bin/gpg"; in '' function gpgKeyId() { ${gpg} --show-key --with-colons "$1" \ | grep ^pub: \ | cut -d: -f5 } function importTrust() { local keyIds trust IFS='\n' read -ra keyIds <<< "$(gpgKeyId "$1")" trust="$2" for id in "''${keyIds[@]}" ; do { echo trust; echo "$trust"; (( trust == 5 )) && echo y; echo quit; } \ | ${gpg} --no-tty --command-fd 0 --edit-key "$id" done } ''; keyringFiles = let gpg = "${pkgs.gnupg}/bin/gpg"; importKey = { source, trust, ... }: '' ${gpg} --import ${source} ${optionalString (trust != null) ''importTrust "${source}" ${toString trust}''} ''; importKeys = concatMapStringsSep "\n" importKey cfg.authorized.acceptedGpgKeys; in pkgs.runCommand "gpg-pubring" {buildInputs = [pkgs.gnupg];} '' export GNUPGHOME GNUPGHOME=$(mktemp -d) ${importTrustBashFunctions} ${importKeys} mkdir $out cp $GNUPGHOME/pubring.kbx $out/pubring.kbx if [[ -e $GNUPGHOME/trustdb.gpg ]] ; then cp $GNUPGHOME/trustdb.gpg $out/trustdb.gpg fi ''; in { options = { services.serverphone = { enable = mkEnableOption (lib.mdDoc "Severphone"); domain = mkOption { type = types.str; example = "sp.vhack.eu"; description = lib.mdDoc '' The domain to bind to. ''; }; user = mkOption { type = types.str; default = "serverphone"; description = lib.mdDoc "User account under which serverphone runs."; }; group = mkOption { type = types.str; default = "serverphone"; description = lib.mdDoc "Group account under which serverphone runs."; }; certificate = mkOption { type = types.str; example = "/var/lib/acme/sp.vhack.eu/certificate.pem"; description = lib.mdDoc '' The certificate use for tls server authentication, see also the [caCertificate](#cmdoption-arg-services.serverphone.caCertificate) option for client authentication. ''; }; privateKey = mkOption { type = types.str; example = "/var/lib/acme/sp.vhack.eu/key.pem"; description = lib.mdDoc '' The private key use for tls server authentication, see also the [caPrivateKey](cmdoption-arg-services.serverphone.certificateRequest.caPrivateKey) option for client authentication. ''; }; acceptedSshKeys = mkOption { type = types.listOf types.str; example = literalExpression '' [ "AAAAC3NzaC1lZDI1NTE5AAAAIBCtvfOw/74uibWuLbwyH+vjvgbWlt7g6y36b5ai13w2" ] ''; description = lib.mdDoc '' list of the accepted public ssh keys from the clients to verify. Please only add the base64 part of the key, i.e., leave out the part in brackets: (ssh-ed25519 )AAAAC3NzaC1lZDI1NTE5AAAAIBCtvfOw/74uibWuLbwyH+vjvgbWlt7g6y36b5ai13w2( user@host) ''; }; caCertificate = mkOption { type = types.str; example = "/var/lib/serverphone/ca-certificate.pem"; description = lib.mdDoc '' Path to the ca certificate, generated with the `sptools generate ca-certificate` command. ''; }; package = mkOption { type = types.package; #default = pkgs.serverphone; description = lib.mdDoc '' Serverphone package to use. ''; }; configureSudo = mkOption { type = types.bool; default = false; description = lib.mdDoc '' Configure serverphone to use sudo when running nixos-rebuild in the deploy command. ''; }; configureDoas = mkOption { type = types.bool; default = true; description = lib.mdDoc '' Configure serverphone to use doas when running nixos-rebuild in the deploy command. ''; }; authorized = mkOption { type = types.submodule { options = { port = mkOption { type = types.port; default = 9312; description = lib.mdDoc '' Port to listen on for commands ''; }; configRepositoryDirectory = mkOption { type = types.str; default = "/etc/nixos"; description = lib.mdDoc '' The path to the nixos configuration directory ''; }; acceptedGpgKeys = mkOption { type = types.listOf (types.submodule publicKeyOpts); default = []; example = literalExpression "[ { source = ./pubkeys.txt; } ]"; description = lib.mdDoc '' The public keys to use to verify the last commit, when running deploy command. ''; }; }; }; description = lib.mdDoc '' Configure options for the authorized part of serverphone, i.e. the client needs to provide a client certificate, but can execute commands (deploy, etc.). ''; }; certificateRequest = mkOption { type = types.submodule { options = { port = mkOption { type = types.port; default = 9311; description = lib.mdDoc '' Port to listen on for certificate request ''; }; caPrivateKey = mkOption { type = types.str; example = "/var/lib/serverphone/ca-key.pem"; description = lib.mdDoc '' Path to the ca private key, generated with the `sptools generate ca-certificate` command. ''; }; duration = mkOption { type = types.ints.positive; example = 132; default = 365; description = lib.mdDoc '' How long should the signed client certificat be valid? Value is in days. ''; }; acceptedUsers = mkOption { type = types.listOf types.str; example = literalExpression '' [ "USERNAME PASSWORD_HASH" ] ''; description = lib.mdDoc '' List of accepted users, these values are required to get an certificate, and should afterwards be removed. The password hash is generated by `sptools hash USERNAME`. The username must not contain a space. ''; }; }; }; description = lib.mdDoc '' Configure options for the certificate request part of serverphone, i.e. the client just needs to provide a username and password, but can only get a signed certificate, which is required for the authorized part (deploy, etc.). ''; }; }; }; config = mkIf cfg.enable { assertions = [ { assertion = !(!cfg.configureSudo && !cfg.configureDoas); message = "Serverphone won't be able to run the deploy command without either sudo or doas configured!"; } ]; systemd.services.serverphone = let dependencies = builtins.attrValues {inherit (pkgs) git gnupg nixos-rebuild;}; authenticationCommandName = if cfg.configureSudo then "sudo" else if cfg.configureDoas then "doas" else ""; # unreachable esa = lib.strings.escapeShellArg; execCommandPreScript = lib.strings.concatStringsSep " ; " [ "${pkgs.coreutils}/bin/mkdir /run/serverphone/gpg" "${pkgs.coreutils}/bin/cp ${esa "${keyringFiles}/trustdb.gpg"} ${esa "${keyringFiles}/pubring.kbx"} /run/serverphone/gpg" "${pkgs.coreutils}/bin/chmod 0600 /run/serverphone/gpg/pubring.kbx /run/serverphone/gpg/trustdb.gpg" "${pkgs.coreutils}/bin/chmod 0700 /run/serverphone/gpg" ]; execCommand = '' ${cfg.package}/bin/spd \ --authentication-command ${esa authenticationCommandName} \ --domain ${esa cfg.domain} \ --certificate ${esa cfg.certificate} \ --key ${esa cfg.privateKey} \ --authorized-port ${esa cfg.authorized.port} \ --config-repo-dir ${esa cfg.authorized.configRepositoryDirectory} \ --ca-certificate ${esa cfg.caCertificate} \ --certificate-request-port ${esa cfg.certificateRequest.port} \ --ca-key ${esa cfg.certificateRequest.caPrivateKey} \ --duration ${esa cfg.certificateRequest.duration} \ '' + (lib.strings.concatMapStrings (string: " --accepted-ssh-key " + (esa string)) cfg.acceptedSshKeys) + (lib.strings.concatMapStrings (string: " --accepted-user " + (esa string)) cfg.certificateRequest.acceptedUsers) + " daemon"; in { description = "Serverphone Server"; wantedBy = ["multi-user.target"]; after = ["network.target"]; startLimitIntervalSec = 60; environment = { GNUPGHOME = "/run/serverphone/gpg"; PATH = lib.mkForce "/run/wrappers/bin/:${lib.strings.makeBinPath dependencies}"; }; path = dependencies; serviceConfig = { ExecStartPre = "${execCommandPreScript}"; ExecStart = execCommand; # User and group User = cfg.user; Group = cfg.group; # Runtime directory and mode RuntimeDirectory = "serverphone"; RuntimeDirectoryMode = "0700"; # Capabilities AmbientCapabilities = [ "CAP_SYS_ADMIN" "CAP_SETUID" "CAP_SETGID" "CAP_CHOWN" "CAP_SETFCAP" "CAP_FOWNER" #the next one could be used to allow ports < 1024 "CAP_NET_BIND_SERVICE" ]; CapabilityBoundingSet = [ "CAP_SYS_ADMIN" "CAP_SETUID" "CAP_SETGID" "CAP_CHOWN" "CAP_SETFCAP" "CAP_FOWNER" #the next one could be used to allow ports < 1024 "CAP_NET_BIND_SERVICE" ]; }; }; security.doas = mkIf cfg.configureDoas { enable = true; extraRules = [ { users = ["${cfg.user}"]; cmd = "nixos-rebuild"; noPass = true; setEnv = ["PATH"]; args = ["switch"]; } ]; }; security.sudo = mkIf cfg.configureSudo { enable = true; extraRules = [ { users = ["${cfg.user}"]; commands = [ { command = "nixos-rebuild switch"; options = ["SETENV" "NOPASSWD"]; } ]; } ]; }; }; } # vim: ts=2