nixos/uwsgi: add support for POSIX capabilities

rnhmjoj c00240e4 01c7c281

Changed files
+104 -28
nixos
modules
services
web-servers
tests
+59 -12
nixos/modules/services/web-servers/uwsgi.nix
···
let
cfg = config.services.uwsgi;
buildCfg = name: c:
let
plugins =
if any (n: !any (m: m == n) cfg.plugins) (c.plugins or [])
-
then throw "`plugins` attribute in UWSGI configuration contains plugins not in config.services.uwsgi.plugins"
else c.plugins or cfg.plugins;
hasPython = v: filter (n: n == "python${v}") plugins != [];
···
python =
if hasPython2 && hasPython3 then
-
throw "`plugins` attribute in UWSGI configuration shouldn't contain both python2 and python3"
else if hasPython2 then cfg.package.python2
else if hasPython3 then cfg.package.python3
else null;
···
oldPaths = filter (x: x != null) (map getPath env');
in env' ++ [ "PATH=${optionalString (oldPaths != []) "${last oldPaths}:"}${pythonEnv}/bin" ];
}
-
else if c.type == "emperor"
then {
emperor = if builtins.typeOf c.vassals != "set" then c.vassals
else pkgs.buildEnv {
···
paths = mapAttrsToList buildCfg c.vassals;
};
} // removeAttrs c [ "type" "vassals" ]
-
else throw "`type` attribute in UWSGI configuration should be either 'normal' or 'emperor'";
};
in pkgs.writeTextDir "${name}.json" (builtins.toJSON uwsgiCfg);
···
};
instance = mkOption {
-
type = with lib.types; let
valueType = nullOr (oneOf [
bool
int
···
user = mkOption {
type = types.str;
default = "uwsgi";
-
description = "User account under which uwsgi runs.";
};
group = mkOption {
type = types.str;
default = "uwsgi";
-
description = "Group account under which uwsgi runs.";
};
};
};
config = mkIf cfg.enable {
systemd.services.uwsgi = {
wantedBy = [ "multi-user.target" ];
-
preStart = ''
-
mkdir -p ${cfg.runDir}
-
chown ${cfg.user}:${cfg.group} ${cfg.runDir}
-
'';
serviceConfig = {
Type = "notify";
-
ExecStart = "${cfg.package}/bin/uwsgi --uid ${cfg.user} --gid ${cfg.group} --json ${buildCfg "server" cfg.instance}/server.json";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
ExecStop = "${pkgs.coreutils}/bin/kill -INT $MAINPID";
NotifyAccess = "main";
KillSignal = "SIGQUIT";
};
};
···
let
cfg = config.services.uwsgi;
+
isEmperor = cfg.instance.type == "emperor";
+
+
imperialPowers =
+
[
+
# spawn other user processes
+
"CAP_SETUID" "CAP_SETGID"
+
"CAP_SYS_CHROOT"
+
# transfer capabilities
+
"CAP_SETPCAP"
+
# create other user sockets
+
"CAP_CHOWN"
+
];
+
buildCfg = name: c:
let
plugins =
if any (n: !any (m: m == n) cfg.plugins) (c.plugins or [])
+
then throw "`plugins` attribute in uWSGI configuration contains plugins not in config.services.uwsgi.plugins"
else c.plugins or cfg.plugins;
hasPython = v: filter (n: n == "python${v}") plugins != [];
···
python =
if hasPython2 && hasPython3 then
+
throw "`plugins` attribute in uWSGI configuration shouldn't contain both python2 and python3"
else if hasPython2 then cfg.package.python2
else if hasPython3 then cfg.package.python3
else null;
···
oldPaths = filter (x: x != null) (map getPath env');
in env' ++ [ "PATH=${optionalString (oldPaths != []) "${last oldPaths}:"}${pythonEnv}/bin" ];
}
+
else if isEmperor
then {
emperor = if builtins.typeOf c.vassals != "set" then c.vassals
else pkgs.buildEnv {
···
paths = mapAttrsToList buildCfg c.vassals;
};
} // removeAttrs c [ "type" "vassals" ]
+
else throw "`type` attribute in uWSGI configuration should be either 'normal' or 'emperor'";
};
in pkgs.writeTextDir "${name}.json" (builtins.toJSON uwsgiCfg);
···
};
instance = mkOption {
+
type = with types; let
valueType = nullOr (oneOf [
bool
int
···
user = mkOption {
type = types.str;
default = "uwsgi";
+
description = "User account under which uWSGI runs.";
};
group = mkOption {
type = types.str;
default = "uwsgi";
+
description = "Group account under which uWSGI runs.";
+
};
+
+
capabilities = mkOption {
+
type = types.listOf types.str;
+
apply = caps: caps ++ optionals isEmperor imperialPowers;
+
default = [ ];
+
example = literalExample ''
+
[
+
"CAP_NET_BIND_SERVICE" # bind on ports <1024
+
"CAP_NET_RAW" # open raw sockets
+
]
+
'';
+
description = ''
+
Grant capabilities to the uWSGI instance. See the
+
<literal>capabilities(7)</literal> for available values.
+
<note>
+
<para>
+
uWSGI runs as an unprivileged user (even as Emperor) with the minimal
+
capabilities required. This option can be used to add fine-grained
+
permissions without running the service as root.
+
</para>
+
<para>
+
When in Emperor mode, any capability to be inherited by a vassal must
+
be specified again in the vassal configuration using <literal>cap</literal>.
+
See the uWSGI <link
+
xlink:href="https://uwsgi-docs.readthedocs.io/en/latest/Capabilities.html">docs</link>
+
for more information.
+
</para>
+
</note>
+
'';
};
};
};
config = mkIf cfg.enable {
+
systemd.tmpfiles.rules = optional (cfg.runDir != "/run/uwsgi") ''
+
d ${cfg.runDir} 775 ${cfg.user} ${cfg.group}
+
'';
+
systemd.services.uwsgi = {
wantedBy = [ "multi-user.target" ];
serviceConfig = {
+
User = cfg.user;
+
Group = cfg.group;
Type = "notify";
+
ExecStart = "${cfg.package}/bin/uwsgi --json ${buildCfg "server" cfg.instance}/server.json";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
ExecStop = "${pkgs.coreutils}/bin/kill -INT $MAINPID";
NotifyAccess = "main";
KillSignal = "SIGQUIT";
+
AmbientCapabilities = cfg.capabilities;
+
CapabilityBoundingSet = cfg.capabilities;
};
};
+45 -16
nixos/tests/uwsgi.nix
···
};
machine = { pkgs, ... }: {
-
services.uwsgi.enable = true;
-
services.uwsgi.plugins = [ "python3" "php" ];
-
services.uwsgi.instance = {
-
type = "emperor";
-
vassals.python = {
type = "normal";
-
master = true;
-
workers = 2;
-
http = ":8000";
module = "wsgi:application";
chdir = pkgs.writeTextDir "wsgi.py" ''
from flask import Flask
application = Flask(__name__)
@application.route("/")
def hello():
-
return "Hello World!"
'';
-
pythonPackages = self: with self; [ flask ];
};
-
vassals.php = {
type = "normal";
master = true;
workers = 2;
-
http-socket = ":8001";
http-socket-modifier1 = 14;
php-index = "index.php";
php-docroot = pkgs.writeTextDir "index.php" ''
···
''
machine.wait_for_unit("multi-user.target")
machine.wait_for_unit("uwsgi.service")
-
machine.wait_for_open_port(8000)
-
machine.wait_for_open_port(8001)
-
assert "Hello World" in machine.succeed("curl -fv 127.0.0.1:8000")
-
assert "Hello World" in machine.succeed("curl -fv 127.0.0.1:8001")
'';
})
···
};
machine = { pkgs, ... }: {
+
users.users.hello =
+
{ isSystemUser = true;
+
group = "hello";
+
};
+
users.groups.hello = { };
+
+
services.uwsgi = {
+
enable = true;
+
plugins = [ "python3" "php" ];
+
capabilities = [ "CAP_NET_BIND_SERVICE" ];
+
instance.type = "emperor";
+
+
instance.vassals.hello = {
type = "normal";
+
immediate-uid = "hello";
+
immediate-gid = "hello";
module = "wsgi:application";
+
http = ":80";
+
cap = "net_bind_service";
+
pythonPackages = self: [ self.flask ];
chdir = pkgs.writeTextDir "wsgi.py" ''
from flask import Flask
+
import subprocess
application = Flask(__name__)
@application.route("/")
def hello():
+
return "Hello, World!"
+
+
@application.route("/whoami")
+
def whoami():
+
whoami = "${pkgs.coreutils}/bin/whoami"
+
proc = subprocess.run(whoami, capture_output=True)
+
return proc.stdout.decode().strip()
'';
};
+
+
instance.vassals.php = {
type = "normal";
master = true;
workers = 2;
+
http-socket = ":8000";
http-socket-modifier1 = 14;
php-index = "index.php";
php-docroot = pkgs.writeTextDir "index.php" ''
···
''
machine.wait_for_unit("multi-user.target")
machine.wait_for_unit("uwsgi.service")
+
+
with subtest("uWSGI has started"):
+
machine.wait_for_unit("uwsgi.service")
+
+
with subtest("Vassal can bind on port <1024"):
+
machine.wait_for_open_port(80)
+
hello = machine.succeed("curl -f http://machine").strip()
+
assert "Hello, World!" in hello, f"Excepted 'Hello, World!', got '{hello}'"
+
+
with subtest("Vassal is running as dedicated user"):
+
username = machine.succeed("curl -f http://machine/whoami").strip()
+
assert username == "hello", f"Excepted 'hello', got '{username}'"
+
+
with subtest("PHP plugin is working"):
+
machine.wait_for_open_port(8000)
+
assert "Hello World" in machine.succeed("curl -fv http://machine:8000")
'';
})