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 1I don't know or won't say.never or 2I do NOT trust.marginal or 3I trust marginally.full or 4I trust fully.ultimate or 5I 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