Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nixos/stunnel: Make free-form #152065

Merged
merged 3 commits into from Jul 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
158 changes: 57 additions & 101 deletions nixos/modules/services/networking/stunnel.nix
Expand Up @@ -7,80 +7,27 @@ let
cfg = config.services.stunnel;
yesNo = val: if val then "yes" else "no";

verifyRequiredField = type: field: n: c: {
assertion = hasAttr field c;
message = "stunnel: \"${n}\" ${type} configuration - Field ${field} is required.";
};

verifyChainPathAssert = n: c: {
assertion = c.verifyHostname == null || (c.verifyChain || c.verifyPeer);
assertion = (c.verifyHostname or null) == null || (c.verifyChain || c.verifyPeer);
message = "stunnel: \"${n}\" client configuration - hostname verification " +
"is not possible without either verifyChain or verifyPeer enabled";
};

serverConfig = {
options = {
accept = mkOption {
type = types.either types.str types.int;
description = ''
On which [host:]port stunnel should listen for incoming TLS connections.
Note that unlike other softwares stunnel ipv6 address need no brackets,
so to listen on all IPv6 addresses on port 1234 one would use ':::1234'.
'';
};

connect = mkOption {
type = types.either types.str types.int;
description = "Port or IP:Port to which the decrypted connection should be forwarded.";
};

cert = mkOption {
type = types.path;
description = "File containing both the private and public keys.";
};
};
};

clientConfig = {
options = {
accept = mkOption {
type = types.str;
description = "IP:Port on which connections should be accepted.";
};

connect = mkOption {
type = types.str;
description = "IP:Port destination to connect to.";
};

verifyChain = mkOption {
type = types.bool;
default = true;
description = "Check if the provided certificate has a valid certificate chain (against CAPath).";
};

verifyPeer = mkOption {
type = types.bool;
default = false;
description = "Check if the provided certificate is contained in CAPath.";
};

CAPath = mkOption {
type = types.nullOr types.path;
default = null;
description = "Path to a directory containing certificates to validate against.";
};

CAFile = mkOption {
type = types.nullOr types.path;
default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
defaultText = literalExpression ''"''${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"'';
description = "Path to a file containing certificates to validate against.";
};

verifyHostname = mkOption {
type = with types; nullOr str;
default = null;
description = "If set, stunnel checks if the provided certificate is valid for the given hostname.";
};
};
};

removeNulls = mapAttrs (_: filterAttrs (_: v: v != null));
mkValueString = v:
if v == true then "yes"
else if v == false then "no"
else generators.mkValueStringDefault {} v;
generateConfig = c:
generators.toINI {
mkSectionName = id;
mkKeyValue = k: v: "${k} = ${mkValueString v}";
} (removeNulls c);

in

Expand Down Expand Up @@ -130,8 +77,13 @@ in


servers = mkOption {
description = "Define the server configuations.";
type = with types; attrsOf (submodule serverConfig);
description = ''
Define the server configuations.

See "SERVICE-LEVEL OPTIONS" in <citerefentry><refentrytitle>stunnel</refentrytitle>
<manvolnum>8</manvolnum></citerefentry>.
'';
type = with types; attrsOf (attrsOf (nullOr (oneOf [bool int str])));
example = {
fancyWebserver = {
accept = 443;
Expand All @@ -143,8 +95,33 @@ in
};

clients = mkOption {
description = "Define the client configurations.";
type = with types; attrsOf (submodule clientConfig);
description = ''
Define the client configurations.

By default, verifyChain and OCSPaia are enabled and a CAFile is provided from pkgs.cacert.

See "SERVICE-LEVEL OPTIONS" in <citerefentry><refentrytitle>stunnel</refentrytitle>
<manvolnum>8</manvolnum></citerefentry>.
'';
type = with types; attrsOf (attrsOf (nullOr (oneOf [bool int str])));

apply = let
applyDefaults = c:
{
CAFile = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
OCSPaia = true;
verifyChain = true;
} // c;
setCheckHostFromVerifyHostname = c:
# To preserve backward-compatibility with the old NixOS stunnel module
# definition, allow "verifyHostname" as an alias for "checkHost".
c // {
checkHost = c.checkHost or c.verifyHostname or null;
verifyHostname = null; # Not a real stunnel configuration setting
};
forceClient = c: c // { client = true; };
in mapAttrs (_: c: forceClient (setCheckHostFromVerifyHostname (applyDefaults c)));

example = {
foobar = {
accept = "0.0.0.0:8080";
Expand All @@ -169,6 +146,11 @@ in
})

(mapAttrsToList verifyChainPathAssert cfg.clients)
(mapAttrsToList (verifyRequiredField "client" "accept") cfg.clients)
(mapAttrsToList (verifyRequiredField "client" "connect") cfg.clients)
(mapAttrsToList (verifyRequiredField "server" "accept") cfg.servers)
(mapAttrsToList (verifyRequiredField "server" "cert") cfg.servers)
(mapAttrsToList (verifyRequiredField "server" "connect") cfg.servers)
];

environment.systemPackages = [ pkgs.stunnel ];
Expand All @@ -183,36 +165,10 @@ in
${ optionalString cfg.enableInsecureSSLv3 "options = -NO_SSLv3" }

; ----- SERVER CONFIGURATIONS -----
${ lib.concatStringsSep "\n"
(lib.mapAttrsToList
(n: v: ''
[${n}]
accept = ${toString v.accept}
connect = ${toString v.connect}
cert = ${v.cert}

'')
cfg.servers)
}
${ generateConfig cfg.servers }

; ----- CLIENT CONFIGURATIONS -----
${ lib.concatStringsSep "\n"
(lib.mapAttrsToList
(n: v: ''
[${n}]
client = yes
accept = ${v.accept}
connect = ${v.connect}
verifyChain = ${yesNo v.verifyChain}
verifyPeer = ${yesNo v.verifyPeer}
${optionalString (v.CAPath != null) "CApath = ${v.CAPath}"}
${optionalString (v.CAFile != null) "CAFile = ${v.CAFile}"}
${optionalString (v.verifyHostname != null) "checkHost = ${v.verifyHostname}"}
OCSPaia = yes

'')
cfg.clients)
}
${ generateConfig cfg.clients }
'';

systemd.services.stunnel = {
Expand Down
1 change: 1 addition & 0 deletions nixos/tests/all-tests.nix
Expand Up @@ -483,6 +483,7 @@ in
starship = handleTest ./starship.nix {};
step-ca = handleTestOn ["x86_64-linux"] ./step-ca.nix {};
strongswan-swanctl = handleTest ./strongswan-swanctl.nix {};
stunnel = handleTest ./stunnel.nix {};
sudo = handleTest ./sudo.nix {};
sway = handleTest ./sway.nix {};
switchTest = handleTest ./switch-test.nix {};
Expand Down
174 changes: 174 additions & 0 deletions nixos/tests/stunnel.nix
@@ -0,0 +1,174 @@
{ system ? builtins.currentSystem, config ? { }
, pkgs ? import ../.. { inherit system config; } }:

with import ../lib/testing-python.nix { inherit system pkgs; };
with pkgs.lib;

let
stunnelCommon = {
services.stunnel = {
enable = true;
user = "stunnel";
};
users.groups.stunnel = { };
users.users.stunnel = {
isSystemUser = true;
group = "stunnel";
};
};
makeCert = { config, pkgs, ... }: {
system.activationScripts.create-test-cert = stringAfter [ "users" ] ''
${pkgs.openssl}/bin/openssl req -batch -x509 -newkey rsa -nodes -out /test-cert.pem -keyout /test-key.pem -subj /CN=${config.networking.hostName}
( umask 077; cat /test-key.pem /test-cert.pem > /test-key-and-cert.pem )
chown stunnel /test-key.pem /test-key-and-cert.pem
'';
};
serverCommon = { pkgs, ... }: {
networking.firewall.allowedTCPPorts = [ 443 ];
services.stunnel.servers.https = {
accept = "443";
connect = 80;
cert = "/test-key-and-cert.pem";
};
systemd.services.simple-webserver = {
wantedBy = [ "multi-user.target" ];
script = ''
cd /etc/webroot
${pkgs.python3}/bin/python -m http.server 80
'';
};
};
copyCert = src: dest: filename: ''
from shlex import quote
${src}.wait_for_file("/test-key-and-cert.pem")
server_cert = ${src}.succeed("cat /test-cert.pem")
${dest}.succeed("echo %s > ${filename}" % quote(server_cert))
'';

in {
basicServer = makeTest {
name = "basicServer";

nodes = {
client = { };
server = {
imports = [ makeCert serverCommon stunnelCommon ];
environment.etc."webroot/index.html".text = "well met";
};
};

testScript = ''
start_all()

${copyCert "server" "client" "/authorized-server-cert.crt"}

server.wait_for_unit("simple-webserver")
server.wait_for_unit("stunnel")

client.succeed("curl --fail --cacert /authorized-server-cert.crt https://server/ > out")
client.succeed('[[ "$(< out)" == "well met" ]]')
'';
};

serverAndClient = makeTest {
name = "serverAndClient";

nodes = {
client = {
imports = [ stunnelCommon ];
services.stunnel.clients = {
httpsClient = {
accept = "80";
connect = "server:443";
CAFile = "/authorized-server-cert.crt";
};
httpsClientWithHostVerify = {
accept = "81";
connect = "server:443";
CAFile = "/authorized-server-cert.crt";
verifyHostname = "server";
};
httpsClientWithHostVerifyFail = {
accept = "82";
connect = "server:443";
CAFile = "/authorized-server-cert.crt";
verifyHostname = "wronghostname";
};
};
};
server = {
imports = [ makeCert serverCommon stunnelCommon ];
environment.etc."webroot/index.html".text = "hello there";
};
};

testScript = ''
start_all()

${copyCert "server" "client" "/authorized-server-cert.crt"}

server.wait_for_unit("simple-webserver")
server.wait_for_unit("stunnel")

# In case stunnel came up before we got the server's cert copied over
client.succeed("systemctl reload-or-restart stunnel")

client.succeed("curl --fail http://localhost/ > out")
client.succeed('[[ "$(< out)" == "hello there" ]]')

client.succeed("curl --fail http://localhost:81/ > out")
client.succeed('[[ "$(< out)" == "hello there" ]]')

client.fail("curl --fail http://localhost:82/ > out")
client.succeed('[[ "$(< out)" == "" ]]')
'';
};

mutualAuth = makeTest {
name = "mutualAuth";

nodes = rec {
client = {
imports = [ makeCert stunnelCommon ];
services.stunnel.clients.authenticated-https = {
accept = "80";
connect = "server:443";
verifyPeer = true;
CAFile = "/authorized-server-cert.crt";
cert = "/test-cert.pem";
key = "/test-key.pem";
};
};
wrongclient = client;
server = {
imports = [ makeCert serverCommon stunnelCommon ];
services.stunnel.servers.https = {
CAFile = "/authorized-client-certs.crt";
verifyPeer = true;
};
environment.etc."webroot/index.html".text = "secret handshake";
};
};

testScript = ''
start_all()

${copyCert "server" "client" "/authorized-server-cert.crt"}
${copyCert "client" "server" "/authorized-client-certs.crt"}
${copyCert "server" "wrongclient" "/authorized-server-cert.crt"}

# In case stunnel came up before we got the cross-certs in place
client.succeed("systemctl reload-or-restart stunnel")
server.succeed("systemctl reload-or-restart stunnel")
wrongclient.succeed("systemctl reload-or-restart stunnel")

server.wait_for_unit("simple-webserver")
client.fail("curl --fail --insecure https://server/ > out")
client.succeed('[[ "$(< out)" == "" ]]')
client.succeed("curl --fail http://localhost/ > out")
client.succeed('[[ "$(< out)" == "secret handshake" ]]')
wrongclient.fail("curl --fail http://localhost/ > out")
wrongclient.succeed('[[ "$(< out)" == "" ]]')
'';
};
}
6 changes: 5 additions & 1 deletion pkgs/tools/networking/stunnel/default.nix
@@ -1,4 +1,4 @@
{ lib, stdenv, fetchurl, openssl }:
{ lib, stdenv, fetchurl, openssl, nixosTests }:

stdenv.mkDerivation rec {
pname = "stunnel";
Expand Down Expand Up @@ -28,6 +28,10 @@ stdenv.mkDerivation rec {
"localstatedir=\${TMPDIR}"
];

passthru.tests = {
stunnel = nixosTests.stunnel;
};

meta = {
description = "Universal tls/ssl wrapper";
homepage = "https://www.stunnel.org/";
Expand Down