Merge pull request #106082 from rnhmjoj/uwsgi

nixos/uwsgi: run with capabilities instead of root
gstqt5
Guillaume Girol 2021-01-10 21:51:37 +00:00 committed by GitHub
commit 0fbc0976db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 135 additions and 34 deletions

View File

@ -185,6 +185,30 @@
with <literal>mkfs.xfs -m reflink=0</literal>. with <literal>mkfs.xfs -m reflink=0</literal>.
</para> </para>
</listitem> </listitem>
<listitem>
<para>
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 <literal>uwsgi</literal> user. Any additional
capability can be added via the new option
<xref linkend="opt-services.uwsgi.capabilities"/>.
The previous behaviour can be restored by setting:
<programlisting>
<xref linkend="opt-services.uwsgi.user"/> = "root";
<xref linkend="opt-services.uwsgi.group"/> = "root";
<xref linkend="opt-services.uwsgi.instance"/> =
{
uid = "uwsgi";
gid = "uwsgi";
};
</programlisting>
</para>
<para>
Another incompatibility from the previous release is that vassals running under a
different user or group need to use <literal>immediate-{uid,gid}</literal>
instead of the usual <literal>uid,gid</literal> options.
</para>
</listitem>
<listitem> <listitem>
<para> <para>
<package>btc1</package> has been abandoned upstream, and removed. <package>btc1</package> has been abandoned upstream, and removed.

View File

@ -44,7 +44,7 @@ let
in in
{ {
options.services.ihatemoney = { 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 { backend = mkOption {
type = types.enum [ "sqlite" "postgresql" ]; type = types.enum [ "sqlite" "postgresql" ];
default = "sqlite"; default = "sqlite";
@ -116,16 +116,13 @@ in
services.uwsgi = { services.uwsgi = {
enable = true; enable = true;
plugins = [ "python3" ]; plugins = [ "python3" ];
# the vassal needs to be able to setuid
user = "root";
group = "root";
instance = { instance = {
type = "emperor"; type = "emperor";
vassals.ihatemoney = { vassals.ihatemoney = {
type = "normal"; type = "normal";
strict = true; strict = true;
uid = user; immediate-uid = user;
gid = group; immediate-gid = group;
# apparently flask uses threads: https://github.com/spiral-project/ihatemoney/commit/c7815e48781b6d3a457eaff1808d179402558f8c # apparently flask uses threads: https://github.com/spiral-project/ihatemoney/commit/c7815e48781b6d3a457eaff1808d179402558f8c
enable-threads = true; enable-threads = true;
module = "wsgi:application"; module = "wsgi:application";

View File

@ -5,11 +5,24 @@ with lib;
let let
cfg = config.services.uwsgi; 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: buildCfg = name: c:
let let
plugins = plugins =
if any (n: !any (m: m == n) cfg.plugins) (c.plugins or []) 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; else c.plugins or cfg.plugins;
hasPython = v: filter (n: n == "python${v}") plugins != []; hasPython = v: filter (n: n == "python${v}") plugins != [];
@ -18,7 +31,7 @@ let
python = python =
if hasPython2 && hasPython3 then 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 hasPython2 then cfg.package.python2
else if hasPython3 then cfg.package.python3 else if hasPython3 then cfg.package.python3
else null; else null;
@ -43,7 +56,7 @@ let
oldPaths = filter (x: x != null) (map getPath env'); oldPaths = filter (x: x != null) (map getPath env');
in env' ++ [ "PATH=${optionalString (oldPaths != []) "${last oldPaths}:"}${pythonEnv}/bin" ]; in env' ++ [ "PATH=${optionalString (oldPaths != []) "${last oldPaths}:"}${pythonEnv}/bin" ];
} }
else if c.type == "emperor" else if isEmperor
then { then {
emperor = if builtins.typeOf c.vassals != "set" then c.vassals emperor = if builtins.typeOf c.vassals != "set" then c.vassals
else pkgs.buildEnv { else pkgs.buildEnv {
@ -51,7 +64,7 @@ let
paths = mapAttrsToList buildCfg c.vassals; paths = mapAttrsToList buildCfg c.vassals;
}; };
} // removeAttrs c [ "type" "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); in pkgs.writeTextDir "${name}.json" (builtins.toJSON uwsgiCfg);
@ -79,7 +92,7 @@ in {
}; };
instance = mkOption { instance = mkOption {
type = with lib.types; let type = with types; let
valueType = nullOr (oneOf [ valueType = nullOr (oneOf [
bool bool
int int
@ -137,31 +150,65 @@ in {
user = mkOption { user = mkOption {
type = types.str; type = types.str;
default = "uwsgi"; default = "uwsgi";
description = "User account under which uwsgi runs."; description = "User account under which uWSGI runs.";
}; };
group = mkOption { group = mkOption {
type = types.str; type = types.str;
default = "uwsgi"; 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
<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 { config = mkIf cfg.enable {
systemd.tmpfiles.rules = optional (cfg.runDir != "/run/uwsgi") ''
d ${cfg.runDir} 775 ${cfg.user} ${cfg.group}
'';
systemd.services.uwsgi = { systemd.services.uwsgi = {
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
preStart = ''
mkdir -p ${cfg.runDir}
chown ${cfg.user}:${cfg.group} ${cfg.runDir}
'';
serviceConfig = { serviceConfig = {
User = cfg.user;
Group = cfg.group;
Type = "notify"; 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"; ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
ExecStop = "${pkgs.coreutils}/bin/kill -INT $MAINPID"; ExecStop = "${pkgs.coreutils}/bin/kill -INT $MAINPID";
NotifyAccess = "main"; NotifyAccess = "main";
KillSignal = "SIGQUIT"; KillSignal = "SIGQUIT";
AmbientCapabilities = cfg.capabilities;
CapabilityBoundingSet = cfg.capabilities;
}; };
}; };

View File

@ -6,31 +6,48 @@ import ./make-test-python.nix ({ pkgs, ... }:
}; };
machine = { pkgs, ... }: { machine = { pkgs, ... }: {
services.uwsgi.enable = true; users.users.hello =
services.uwsgi.plugins = [ "python3" "php" ]; { isSystemUser = true;
services.uwsgi.instance = { group = "hello";
type = "emperor"; };
vassals.python = { users.groups.hello = { };
services.uwsgi = {
enable = true;
plugins = [ "python3" "php" ];
capabilities = [ "CAP_NET_BIND_SERVICE" ];
instance.type = "emperor";
instance.vassals.hello = {
type = "normal"; type = "normal";
master = true; immediate-uid = "hello";
workers = 2; immediate-gid = "hello";
http = ":8000";
module = "wsgi:application"; module = "wsgi:application";
http = ":80";
cap = "net_bind_service";
pythonPackages = self: [ self.flask ];
chdir = pkgs.writeTextDir "wsgi.py" '' chdir = pkgs.writeTextDir "wsgi.py" ''
from flask import Flask from flask import Flask
import subprocess
application = Flask(__name__) application = Flask(__name__)
@application.route("/") @application.route("/")
def hello(): 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"; type = "normal";
master = true; master = true;
workers = 2; workers = 2;
http-socket = ":8001"; http-socket = ":8000";
http-socket-modifier1 = 14; http-socket-modifier1 = 14;
php-index = "index.php"; php-index = "index.php";
php-docroot = pkgs.writeTextDir "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("multi-user.target")
machine.wait_for_unit("uwsgi.service") machine.wait_for_unit("uwsgi.service")
machine.wait_for_open_port(8000)
machine.wait_for_open_port(8001) with subtest("uWSGI has started"):
assert "Hello World" in machine.succeed("curl -fv 127.0.0.1:8000") machine.wait_for_unit("uwsgi.service")
assert "Hello World" in machine.succeed("curl -fv 127.0.0.1:8001")
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")
''; '';
}) })

View File

@ -3,6 +3,7 @@
, plugins ? [] , plugins ? []
, pam, withPAM ? stdenv.isLinux , pam, withPAM ? stdenv.isLinux
, systemd, withSystemd ? stdenv.isLinux , systemd, withSystemd ? stdenv.isLinux
, libcap, withCap ? stdenv.isLinux
, python2, python3, ncurses , python2, python3, ncurses
, ruby, php, libmysqlclient , ruby, php, libmysqlclient
}: }:
@ -75,6 +76,7 @@ stdenv.mkDerivation rec {
buildInputs = [ jansson pcre ] buildInputs = [ jansson pcre ]
++ lib.optional withPAM pam ++ lib.optional withPAM pam
++ lib.optional withSystemd systemd ++ lib.optional withSystemd systemd
++ lib.optional withCap libcap
++ lib.concatMap (x: x.inputs) needed ++ lib.concatMap (x: x.inputs) needed
; ;
@ -83,6 +85,8 @@ stdenv.mkDerivation rec {
++ lib.optional withSystemd "systemd_logger" ++ lib.optional withSystemd "systemd_logger"
); );
UWSGI_INCLUDES = lib.optionalString withCap "${libcap.dev}/include";
passthru = { passthru = {
inherit python2 python3; inherit python2 python3;
}; };