diff --git a/nixos/doc/manual/release-notes/rl-2103.xml b/nixos/doc/manual/release-notes/rl-2103.xml
index 6d1a581114b..6ef522e9fce 100644
--- a/nixos/doc/manual/release-notes/rl-2103.xml
+++ b/nixos/doc/manual/release-notes/rl-2103.xml
@@ -185,6 +185,30 @@
with mkfs.xfs -m reflink=0.
+
+
+ The uWSGI server is now built with POSIX capabilities. As a consequence,
+ root is no longer required in emperor mode and the service defaults to
+ running as the unprivileged uwsgi user. Any additional
+ capability can be added via the new option
+ .
+ The previous behaviour can be restored by setting:
+
+ = "root";
+ = "root";
+ =
+ {
+ uid = "uwsgi";
+ gid = "uwsgi";
+ };
+
+
+
+ Another incompatibility from the previous release is that vassals running under a
+ different user or group need to use immediate-{uid,gid}
+ instead of the usual uid,gid options.
+
+
btc1 has been abandoned upstream, and removed.
diff --git a/nixos/modules/services/web-apps/ihatemoney/default.nix b/nixos/modules/services/web-apps/ihatemoney/default.nix
index 68769ac8c03..b4987fa4702 100644
--- a/nixos/modules/services/web-apps/ihatemoney/default.nix
+++ b/nixos/modules/services/web-apps/ihatemoney/default.nix
@@ -44,7 +44,7 @@ let
in
{
options.services.ihatemoney = {
- enable = mkEnableOption "ihatemoney webapp. Note that this will set uwsgi to emperor mode running as root";
+ enable = mkEnableOption "ihatemoney webapp. Note that this will set uwsgi to emperor mode";
backend = mkOption {
type = types.enum [ "sqlite" "postgresql" ];
default = "sqlite";
@@ -116,16 +116,13 @@ in
services.uwsgi = {
enable = true;
plugins = [ "python3" ];
- # the vassal needs to be able to setuid
- user = "root";
- group = "root";
instance = {
type = "emperor";
vassals.ihatemoney = {
type = "normal";
strict = true;
- uid = user;
- gid = group;
+ immediate-uid = user;
+ immediate-gid = group;
# apparently flask uses threads: https://github.com/spiral-project/ihatemoney/commit/c7815e48781b6d3a457eaff1808d179402558f8c
enable-threads = true;
module = "wsgi:application";
diff --git a/nixos/modules/services/web-servers/uwsgi.nix b/nixos/modules/services/web-servers/uwsgi.nix
index 936e211ec71..506cd364a65 100644
--- a/nixos/modules/services/web-servers/uwsgi.nix
+++ b/nixos/modules/services/web-servers/uwsgi.nix
@@ -5,11 +5,24 @@ with lib;
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"
+ 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 != [];
@@ -18,7 +31,7 @@ let
python =
if hasPython2 && hasPython3 then
- throw "`plugins` attribute in UWSGI configuration shouldn't contain both python2 and python3"
+ 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;
@@ -43,7 +56,7 @@ let
oldPaths = filter (x: x != null) (map getPath env');
in env' ++ [ "PATH=${optionalString (oldPaths != []) "${last oldPaths}:"}${pythonEnv}/bin" ];
}
- else if c.type == "emperor"
+ else if isEmperor
then {
emperor = if builtins.typeOf c.vassals != "set" then c.vassals
else pkgs.buildEnv {
@@ -51,7 +64,7 @@ let
paths = mapAttrsToList buildCfg c.vassals;
};
} // removeAttrs c [ "type" "vassals" ]
- else throw "`type` attribute in UWSGI configuration should be either 'normal' or 'emperor'";
+ else throw "`type` attribute in uWSGI configuration should be either 'normal' or 'emperor'";
};
in pkgs.writeTextDir "${name}.json" (builtins.toJSON uwsgiCfg);
@@ -79,7 +92,7 @@ in {
};
instance = mkOption {
- type = with lib.types; let
+ type = with types; let
valueType = nullOr (oneOf [
bool
int
@@ -137,31 +150,65 @@ in {
user = mkOption {
type = types.str;
default = "uwsgi";
- description = "User account under which uwsgi runs.";
+ description = "User account under which uWSGI runs.";
};
group = mkOption {
type = types.str;
default = "uwsgi";
- description = "Group account under which uwsgi runs.";
+ 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
+ capabilities(7) for available values.
+
+
+ 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.
+
+
+ When in Emperor mode, any capability to be inherited by a vassal must
+ be specified again in the vassal configuration using cap.
+ See the uWSGI docs
+ for more information.
+
+
+ '';
};
};
};
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" ];
- preStart = ''
- mkdir -p ${cfg.runDir}
- chown ${cfg.user}:${cfg.group} ${cfg.runDir}
- '';
serviceConfig = {
+ User = cfg.user;
+ Group = cfg.group;
Type = "notify";
- ExecStart = "${cfg.package}/bin/uwsgi --uid ${cfg.user} --gid ${cfg.group} --json ${buildCfg "server" cfg.instance}/server.json";
+ 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;
};
};
diff --git a/nixos/tests/uwsgi.nix b/nixos/tests/uwsgi.nix
index 1d3db469c37..80dcde324aa 100644
--- a/nixos/tests/uwsgi.nix
+++ b/nixos/tests/uwsgi.nix
@@ -6,31 +6,48 @@ import ./make-test-python.nix ({ pkgs, ... }:
};
machine = { pkgs, ... }: {
- services.uwsgi.enable = true;
- services.uwsgi.plugins = [ "python3" "php" ];
- services.uwsgi.instance = {
- type = "emperor";
- vassals.python = {
+ 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";
- master = true;
- workers = 2;
- http = ":8000";
+ 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!"
+ 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()
'';
- pythonPackages = self: with self; [ flask ];
};
- vassals.php = {
+
+ instance.vassals.php = {
type = "normal";
master = true;
workers = 2;
- http-socket = ":8001";
+ http-socket = ":8000";
http-socket-modifier1 = 14;
php-index = "index.php";
php-docroot = pkgs.writeTextDir "index.php" ''
@@ -44,9 +61,21 @@ import ./make-test-python.nix ({ pkgs, ... }:
''
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")
+
+ 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")
'';
})
diff --git a/pkgs/servers/uwsgi/default.nix b/pkgs/servers/uwsgi/default.nix
index d9ed810cdf4..449b97c58e7 100644
--- a/pkgs/servers/uwsgi/default.nix
+++ b/pkgs/servers/uwsgi/default.nix
@@ -3,6 +3,7 @@
, plugins ? []
, pam, withPAM ? stdenv.isLinux
, systemd, withSystemd ? stdenv.isLinux
+, libcap, withCap ? stdenv.isLinux
, python2, python3, ncurses
, ruby, php, libmysqlclient
}:
@@ -75,6 +76,7 @@ stdenv.mkDerivation rec {
buildInputs = [ jansson pcre ]
++ lib.optional withPAM pam
++ lib.optional withSystemd systemd
+ ++ lib.optional withCap libcap
++ lib.concatMap (x: x.inputs) needed
;
@@ -83,6 +85,8 @@ stdenv.mkDerivation rec {
++ lib.optional withSystemd "systemd_logger"
);
+ UWSGI_INCLUDES = lib.optionalString withCap "${libcap.dev}/include";
+
passthru = {
inherit python2 python3;
};