nixos/sourcehut: full rewrite, with fixes and hardening

This commit is contained in:
Julien Moutinho 2021-08-14 12:39:21 +02:00 committed by Tom Bereknyei
parent ddaef72e49
commit 8ed7fd0f3a
3 changed files with 1662 additions and 201 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,66 +1,375 @@
{ config, pkgs, lib }:
serviceCfg: serviceDrv: iniKey: attrs:
srv:
{ configIniOfService
, srvsrht ? "${srv}srht" # Because "buildsrht" does not follow that pattern (missing an "s").
, iniKey ? "${srv}.sr.ht"
, webhooks ? false
, extraTimers ? {}
, mainService ? {}
, extraServices ? {}
, extraConfig ? {}
, port
}:
{ config, lib, pkgs, ... }:
with lib;
let
inherit (config.services) postgresql;
redis = config.services.redis.servers."sourcehut-${srvsrht}";
inherit (config.users) users;
cfg = config.services.sourcehut;
cfgIni = cfg.settings."${iniKey}";
pgSuperUser = config.services.postgresql.superUser;
setupDB = pkgs.writeScript "${serviceDrv.pname}-gen-db" ''
#! ${cfg.python}/bin/python
from ${serviceDrv.pname}.app import db
db.create()
'';
configIni = configIniOfService srv;
srvCfg = cfg.${srv};
baseService = serviceName: { allowStripe ? false }: extraService: let
runDir = "/run/sourcehut/${serviceName}";
rootDir = "/run/sourcehut/chroots/${serviceName}";
in
mkMerge [ extraService {
after = [ "network.target" ] ++
optional cfg.postgresql.enable "postgresql.service" ++
optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service";
requires =
optional cfg.postgresql.enable "postgresql.service" ++
optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service";
path = [ pkgs.gawk ];
environment.HOME = runDir;
serviceConfig = {
User = mkDefault srvCfg.user;
Group = mkDefault srvCfg.group;
RuntimeDirectory = [
"sourcehut/${serviceName}"
# Used by *srht-keys which reads ../config.ini
"sourcehut/${serviceName}/subdir"
"sourcehut/chroots/${serviceName}"
];
RuntimeDirectoryMode = "2750";
# No need for the chroot path once inside the chroot
InaccessiblePaths = [ "-+${rootDir}" ];
# g+rx is for group members (eg. fcgiwrap or nginx)
# to read Git/Mercurial repositories, buildlogs, etc.
# o+x is for intermediate directories created by BindPaths= and like,
# as they're owned by root:root.
UMask = "0026";
RootDirectory = rootDir;
RootDirectoryStartOnly = true;
PrivateTmp = true;
MountAPIVFS = true;
# config.ini is looked up in there, before /etc/srht/config.ini
# Note that it fails to be set in ExecStartPre=
WorkingDirectory = mkDefault ("-"+runDir);
BindReadOnlyPaths = [
builtins.storeDir
"/etc"
"/run/booted-system"
"/run/current-system"
"/run/systemd"
] ++
optional cfg.postgresql.enable "/run/postgresql" ++
optional cfg.redis.enable "/run/redis-sourcehut-${srvsrht}";
# LoadCredential= are unfortunately not available in ExecStartPre=
# Hence this one is run as root (the +) with RootDirectoryStartOnly=
# to reach credentials wherever they are.
# Note that each systemd service gets its own ${runDir}/config.ini file.
ExecStartPre = mkBefore [("+"+pkgs.writeShellScript "${serviceName}-credentials" ''
set -x
# Replace values begining with a '<' by the content of the file whose name is after.
gawk '{ if (match($0,/^([^=]+=)<(.+)/,m)) { getline f < m[2]; print m[1] f } else print $0 }' ${configIni} |
${optionalString (!allowStripe) "gawk '!/^stripe-secret-key=/' |"}
install -o ${srvCfg.user} -g root -m 400 /dev/stdin ${runDir}/config.ini
'')];
# The following options are only for optimizing:
# systemd-analyze security
AmbientCapabilities = "";
CapabilityBoundingSet = "";
# ProtectClock= adds DeviceAllow=char-rtc r
DeviceAllow = "";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateNetwork = mkDefault false;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
#SocketBindAllow = [ "tcp:${toString srvCfg.port}" "tcp:${toString srvCfg.prometheusPort}" ];
#SocketBindDeny = "any";
SystemCallFilter = [
"@system-service"
"~@aio" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@timer"
"@chown" "@setuid"
];
SystemCallArchitectures = "native";
};
} ];
in
with serviceCfg; with lib; recursiveUpdate
{
environment.HOME = statePath;
path = [ config.services.postgresql.package ] ++ (attrs.path or [ ]);
restartTriggers = [ config.environment.etc."sr.ht/config.ini".source ];
serviceConfig = {
Type = "simple";
User = user;
Group = user;
Restart = "always";
WorkingDirectory = statePath;
} // (if (cfg.statePath == "/var/lib/sourcehut/${serviceDrv.pname}") then {
StateDirectory = [ "sourcehut/${serviceDrv.pname}" ];
} else {})
;
options.services.sourcehut.${srv} = {
enable = mkEnableOption "${srv} service";
preStart = ''
if ! test -e ${statePath}/db; then
# Setup the initial database
${setupDB}
user = mkOption {
type = types.str;
default = srvsrht;
description = ''
User for ${srv}.sr.ht.
'';
};
# Set the initial state of the database for future database upgrades
if test -e ${cfg.python}/bin/${serviceDrv.pname}-migrate; then
# Run alembic stamp head once to tell alembic the schema is up-to-date
${cfg.python}/bin/${serviceDrv.pname}-migrate stamp head
fi
group = mkOption {
type = types.str;
default = srvsrht;
description = ''
Group for ${srv}.sr.ht.
Membership grants access to the Git/Mercurial repositories by default,
but not to the config.ini file (where secrets are).
'';
};
printf "%s" "${serviceDrv.version}" > ${statePath}/db
fi
port = mkOption {
type = types.port;
default = port;
description = ''
Port on which the "${srv}" backend should listen.
'';
};
# Update copy of each users' profile to the latest
# See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain>
if ! test -e ${statePath}/webhook; then
# Update ${iniKey}'s users' profile copy to the latest
${cfg.python}/bin/srht-update-profiles ${iniKey}
redis = {
host = mkOption {
type = types.str;
default = "unix:/run/redis-sourcehut-${srvsrht}/redis.sock?db=0";
example = "redis://shared.wireguard:6379/0";
description = ''
The redis host URL. This is used for caching and temporary storage, and must
be shared between nodes (e.g. git1.sr.ht and git2.sr.ht), but need not be
shared between services. It may be shared between services, however, with no
ill effect, if this better suits your infrastructure.
'';
};
};
touch ${statePath}/webhook
fi
postgresql = {
database = mkOption {
type = types.str;
default = "${srv}.sr.ht";
description = ''
PostgreSQL database name for the ${srv}.sr.ht service,
used if <xref linkend="opt-services.sourcehut.postgresql.enable"/> is <literal>true</literal>.
'';
};
};
${optionalString (builtins.hasAttr "migrate-on-upgrade" cfgIni && cfgIni.migrate-on-upgrade == "yes") ''
if [ "$(cat ${statePath}/db)" != "${serviceDrv.version}" ]; then
# Manage schema migrations using alembic
${cfg.python}/bin/${serviceDrv.pname}-migrate -a upgrade head
gunicorn = {
extraArgs = mkOption {
type = with types; listOf str;
default = ["--timeout 120" "--workers 1" "--log-level=info"];
description = "Extra arguments passed to Gunicorn.";
};
};
} // optionalAttrs webhooks {
webhooks = {
extraArgs = mkOption {
type = with types; listOf str;
default = ["--loglevel DEBUG" "--pool eventlet" "--without-heartbeat"];
description = "Extra arguments passed to the Celery responsible for webhooks.";
};
celeryConfig = mkOption {
type = types.lines;
default = "";
description = "Content of the <literal>celeryconfig.py</literal> used by the Celery responsible for webhooks.";
};
};
};
# Mark down current package version
printf "%s" "${serviceDrv.version}" > ${statePath}/db
fi
''}
config = lib.mkIf (cfg.enable && srvCfg.enable) (mkMerge [ extraConfig {
users = {
users = {
"${srvCfg.user}" = {
isSystemUser = true;
group = mkDefault srvCfg.group;
description = mkDefault "sourcehut user for ${srv}.sr.ht";
};
};
groups = {
"${srvCfg.group}" = { };
} // optionalAttrs (cfg.postgresql.enable
&& hasSuffix "0" (postgresql.settings.unix_socket_permissions or "")) {
"postgres".members = [ srvCfg.user ];
} // optionalAttrs (cfg.redis.enable
&& hasSuffix "0" (redis.settings.unixsocketperm or "")) {
"redis-sourcehut-${srvsrht}".members = [ srvCfg.user ];
};
};
${attrs.preStart or ""}
'';
services.nginx = mkIf cfg.nginx.enable {
virtualHosts."${srv}.${cfg.settings."sr.ht".global-domain}" = mkMerge [ {
forceSSL = true;
locations."/".proxyPass = "http://${cfg.listenAddress}:${toString srvCfg.port}";
locations."/static" = {
root = "${pkgs.sourcehut.${srvsrht}}/${pkgs.sourcehut.python.sitePackages}/${srvsrht}";
extraConfig = mkDefault ''
expires 30d;
'';
};
} cfg.nginx.virtualHost ];
};
services.postgresql = mkIf cfg.postgresql.enable {
authentication = ''
local ${srvCfg.postgresql.database} ${srvCfg.user} trust
'';
ensureDatabases = [ srvCfg.postgresql.database ];
ensureUsers = map (name: {
inherit name;
ensurePermissions = { "DATABASE \"${srvCfg.postgresql.database}\"" = "ALL PRIVILEGES"; };
}) [srvCfg.user];
};
services.sourcehut.services = mkDefault (filter (s: cfg.${s}.enable)
[ "builds" "dispatch" "git" "hg" "hub" "lists" "man" "meta" "pages" "paste" "todo" ]);
services.sourcehut.settings = mkMerge [
{
"${srv}.sr.ht".origin = mkDefault "https://${srv}.${cfg.settings."sr.ht".global-domain}";
}
(mkIf cfg.postgresql.enable {
"${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.postgresql.database}?user=${srvCfg.user}&host=/run/postgresql";
})
];
services.redis.servers."sourcehut-${srvsrht}" = mkIf cfg.redis.enable {
enable = true;
databases = 3;
syslog = true;
# TODO: set a more informed value
save = mkDefault [ [1800 10] [300 100] ];
settings = {
# TODO: set a more informed value
maxmemory = "128MB";
maxmemory-policy = "volatile-ttl";
};
};
systemd.services = mkMerge [
{
"${srvsrht}" = baseService srvsrht { allowStripe = srv == "meta"; } (mkMerge [
{
description = "sourcehut ${srv}.sr.ht website service";
before = optional cfg.nginx.enable "nginx.service";
wants = optional cfg.nginx.enable "nginx.service";
wantedBy = [ "multi-user.target" ];
path = optional cfg.postgresql.enable postgresql.package;
# Beware: change in credentials' content will not trigger restart.
restartTriggers = [ configIni ];
serviceConfig = {
Type = "simple";
Restart = mkDefault "always";
#RestartSec = mkDefault "2min";
StateDirectory = [ "sourcehut/${srvsrht}" ];
StateDirectoryMode = "2750";
ExecStart = "${cfg.python}/bin/gunicorn ${srvsrht}.app:app --name ${srvsrht} --bind ${cfg.listenAddress}:${toString srvCfg.port} " + concatStringsSep " " srvCfg.gunicorn.extraArgs;
};
preStart = let
version = pkgs.sourcehut.${srvsrht}.version;
stateDir = "/var/lib/sourcehut/${srvsrht}";
in mkBefore ''
set -x
# Use the /run/sourcehut/${srvsrht}/config.ini
# installed by a previous ExecStartPre= in baseService
cd /run/sourcehut/${srvsrht}
if test ! -e ${stateDir}/db; then
# Setup the initial database.
# Note that it stamps the alembic head afterward
${cfg.python}/bin/${srvsrht}-initdb
echo ${version} >${stateDir}/db
fi
${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
if [ "$(cat ${stateDir}/db)" != "${version}" ]; then
# Manage schema migrations using alembic
${cfg.python}/bin/${srvsrht}-migrate -a upgrade head
echo ${version} >${stateDir}/db
fi
''}
# Update copy of each users' profile to the latest
# See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain>
if test ! -e ${stateDir}/webhook; then
# Update ${iniKey}'s users' profile copy to the latest
${cfg.python}/bin/srht-update-profiles ${iniKey}
touch ${stateDir}/webhook
fi
'';
} mainService ]);
}
(mkIf webhooks {
"${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" {}
{
description = "sourcehut ${srv}.sr.ht webhooks service";
after = [ "${srvsrht}.service" ];
wantedBy = [ "${srvsrht}.service" ];
partOf = [ "${srvsrht}.service" ];
preStart = ''
cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" srvCfg.webhooks.celeryConfig} \
/run/sourcehut/${srvsrht}-webhooks/celeryconfig.py
'';
serviceConfig = {
Type = "simple";
Restart = "always";
ExecStart = "${cfg.python}/bin/celery --app ${srvsrht}.webhooks worker --hostname ${srvsrht}-webhooks@%%h " + concatStringsSep " " srvCfg.webhooks.extraArgs;
# Avoid crashing: os.getloadavg()
ProcSubset = mkForce "all";
};
};
})
(mapAttrs (timerName: timer: (baseService timerName {} (mkMerge [
{
description = "sourcehut ${timerName} service";
after = [ "network.target" "${srvsrht}.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${cfg.python}/bin/${timerName}";
};
}
(timer.service or {})
]))) extraTimers)
(mapAttrs (serviceName: extraService: baseService serviceName {} (mkMerge [
{
description = "sourcehut ${serviceName} service";
# So that extraServices have the PostgreSQL database initialized.
after = [ "${srvsrht}.service" ];
wantedBy = [ "${srvsrht}.service" ];
partOf = [ "${srvsrht}.service" ];
serviceConfig = {
Type = "simple";
Restart = mkDefault "always";
};
}
extraService
])) extraServices)
];
systemd.timers = mapAttrs (timerName: timer:
{
description = "sourcehut timer for ${timerName}";
wantedBy = [ "timers.target" ];
inherit (timer) timerConfig;
}) extraTimers;
} ]);
}
(builtins.removeAttrs attrs [ "path" "preStart" ])

View file

@ -14,13 +14,12 @@
<title>Basic usage</title>
<para>
Sourcehut is a Python and Go based set of applications.
<literal><link linkend="opt-services.sourcehut.enable">services.sourcehut</link></literal>
by default will use
This NixOS module also provides basic configuration integrating Sourcehut into locally running
<literal><link linkend="opt-services.nginx.enable">services.nginx</link></literal>,
<literal><link linkend="opt-services.nginx.enable">services.redis</link></literal>,
<literal><link linkend="opt-services.nginx.enable">services.cron</link></literal>,
<literal><link linkend="opt-services.redis.servers">services.redis.servers.sourcehut</link></literal>,
<literal><link linkend="opt-services.postfix.enable">services.postfix</link></literal>
and
<literal><link linkend="opt-services.postgresql.enable">services.postgresql</link></literal>.
<literal><link linkend="opt-services.postgresql.enable">services.postgresql</link></literal> services.
</para>
<para>
@ -42,18 +41,23 @@ in {
services.sourcehut = {
<link linkend="opt-services.sourcehut.enable">enable</link> = true;
<link linkend="opt-services.sourcehut.originBase">originBase</link> = fqdn;
<link linkend="opt-services.sourcehut.services">services</link> = [ "meta" "man" "git" ];
<link linkend="opt-services.sourcehut.git.enable">git.enable</link> = true;
<link linkend="opt-services.sourcehut.man.enable">man.enable</link> = true;
<link linkend="opt-services.sourcehut.meta.enable">meta.enable</link> = true;
<link linkend="opt-services.sourcehut.nginx.enable">nginx.enable</link> = true;
<link linkend="opt-services.sourcehut.postfix.enable">postfix.enable</link> = true;
<link linkend="opt-services.sourcehut.postgresql.enable">postgresql.enable</link> = true;
<link linkend="opt-services.sourcehut.redis.enable">redis.enable</link> = true;
<link linkend="opt-services.sourcehut.settings">settings</link> = {
"sr.ht" = {
environment = "production";
global-domain = fqdn;
origin = "https://${fqdn}";
# Produce keys with srht-keygen from <package>sourcehut.coresrht</package>.
network-key = "SECRET";
service-key = "SECRET";
network-key = "/run/keys/path/to/network-key";
service-key = "/run/keys/path/to/service-key";
};
webhooks.private-key= "SECRET";
webhooks.private-key= "/run/keys/path/to/webhook-key";
};
};