selfhostblocks/test/common.nix
ibizaman 5b40cf3707 tests: wait automatically on authelia and lldap to start
I had issues with race conditions happening where authelia didn't start yet
when the test script was already testing the integration.
2025-10-22 03:33:17 +02:00

512 lines
17 KiB
Nix

{ pkgs, lib }:
let
inherit (lib) hasAttr mkOption optionalString;
inherit (lib.types) bool enum listOf nullOr submodule str;
baseImports = {
imports = [
(pkgs.path + "/nixos/modules/profiles/headless.nix")
(pkgs.path + "/nixos/modules/profiles/qemu-guest.nix")
];
};
accessScript = lib.makeOverridable ({
hasSSL
, waitForServices ? s: []
, waitForPorts ? p: []
, waitForUnixSocket ? u: []
, waitForUrls ? u: []
, extraScript ? {...}: ""
, redirectSSO ? false
}: { nodes, ... }:
let
cfg = nodes.server.test;
fqdn = "${cfg.subdomain}.${cfg.domain}";
proto_fqdn = if hasSSL args then "https://${fqdn}" else "http://${fqdn}";
args = {
node.name = "server";
node.config = nodes.server;
inherit fqdn proto_fqdn;
};
autheliaEnabled = (hasAttr "authelia" nodes.server.shb) && nodes.server.shb.authelia.enable;
lldapEnabled = (hasAttr "lldap" nodes.server.shb) && nodes.server.shb.lldap.enable;
in
''
import json
import os
import pathlib
start_all()
def curl(target, format, endpoint, data="", extra=""):
cmd = ("curl --show-error --location"
+ " --cookie-jar cookie.txt"
+ " --cookie cookie.txt"
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
# Client must be able to resolve talking to auth server
+ " --connect-to auth.${cfg.domain}:443:server:443"
+ (f" --data '{data}'" if data != "" else "")
+ (f" --silent --output /dev/null --write-out '{format}'" if format != "" else "")
+ (f" {extra}" if extra != "" else "")
+ f" {endpoint}")
print(cmd)
_, r = target.execute(cmd)
print(r)
try:
return json.loads(r)
except:
return r
def unline_with(j, s):
return j.join((x.strip() for x in s.split("\n")))
''
+ lib.strings.concatMapStrings (s: ''server.wait_for_unit("${s}")'' + "\n") (
waitForServices args
++ (lib.optionals autheliaEnabled [ "authelia-auth.${cfg.domain}.service" ])
++ (lib.optionals lldapEnabled [ "lldap.service" ])
)
+ lib.strings.concatMapStrings (p: ''server.wait_for_open_port(${toString p})'' + "\n") (
waitForPorts args
# TODO: when the SSO block exists, replace this hardcoded port.
++ (lib.optionals autheliaEnabled [ 9091 /* nodes.server.services.authelia.instances."auth.${domain}".settings.server.port */ ])
)
+ lib.strings.concatMapStrings (u: ''server.wait_for_open_unix_socket("${u}")'' + "\n") (waitForUnixSocket args)
+ ''
if ${if hasSSL args then "True" else "False"}:
server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt")
client.succeed("rm -r /etc/ssl/certs")
client.copy_from_host(str(pathlib.Path(os.environ.get("out", os.getcwd())) / "ca-certificates.crt"), "/etc/ssl/certs/ca-certificates.crt")
''
# Making a curl request to an URL needs to happen after we copied the certificates over,
# otherwise curl will not be able to verify the "legitimacy of the server".
+ lib.strings.concatMapStrings (u: ''
import time
done = False
count = 15
while not done and count > 0:
response = curl(client, """{"code":%{response_code}}""", "${u}")
time.sleep(5)
count -= 1
if isinstance(response, dict):
done = response.get('code') == 200
if not done:
raise Exception(f"Response was never 200, got last: {response}")
'' + "\n") (
waitForUrls args
)
+ (if (! redirectSSO) then ''
with subtest("access"):
response = curl(client, """{"code":%{response_code}}""", "${proto_fqdn}")
if response['code'] != 200:
raise Exception(f"Code is {response['code']}")
'' else ''
with subtest("unauthenticated access is not granted"):
response = curl(client, """{"code":%{response_code},"auth_host":"%{urle.host}","auth_query":"%{urle.query}","all":%{json}}""", "${proto_fqdn}")
if response['code'] != 200:
raise Exception(f"Code is {response['code']}")
if response['auth_host'] != "auth.${cfg.domain}":
raise Exception(f"auth host should be auth.${cfg.domain} but is {response['auth_host']}")
if response['auth_query'] != "rd=${proto_fqdn}/":
raise Exception(f"auth query should be rd=${proto_fqdn}/ but is {response['auth_query']}")
'')
+ (let
script = extraScript args;
in
lib.optionalString (script != "") script)
+ (optionalString (hasAttr "test" nodes.server && hasAttr "login" nodes.server.test) ''
with subtest("Login from server"):
code, logs = server.execute("login_playwright")
print(logs)
try:
server.copy_from_vm("trace")
except:
print("No trace found on server")
if code != 0:
raise Exception("login_playwright did not succeed")
'')
+ (optionalString (hasAttr "test" nodes.client && hasAttr "login" nodes.client.test) ''
with subtest("Login from client"):
code, logs = client.execute("login_playwright")
print(logs)
try:
client.copy_from_vm("trace")
except:
print("No trace found on client")
if code != 0:
raise Exception("login_playwright did not succeed")
'')
);
backupScript = args: (accessScript args).override {
extraScript = { proto_fqdn, ... }: ''
with subtest("backup"):
server.succeed("systemctl start restic-backups-testinstance_opt_repos_A")
'';
};
in
{
inherit baseImports accessScript;
runNixOSTest = args: pkgs.testers.runNixOSTest ({
interactive.sshBackdoor.enable = true;
} // args);
mkScripts = args:
{
access = accessScript args;
backup = backupScript args;
};
baseModule = { config, ... }: {
options.test = {
domain = mkOption {
type = str;
default = "example.com";
};
subdomain = mkOption {
type = str;
};
fqdn = mkOption {
type = str;
readOnly = true;
default = "${config.test.subdomain}.${config.test.domain}";
};
hasSSL = mkOption {
type = bool;
default = false;
};
proto = mkOption {
type = str;
readOnly = true;
default = if config.test.hasSSL then "https" else "http";
};
proto_fqdn = mkOption {
type = str;
readOnly = true;
default = "${config.test.proto}://${config.test.fqdn}";
};
};
imports = [
baseImports
../modules/blocks/authelia.nix
../modules/blocks/hardcodedsecret.nix
../modules/blocks/mitmdump.nix
../modules/blocks/nginx.nix
../modules/blocks/postgresql.nix
];
config = {
# HTTP(s) server port.
networking.firewall.allowedTCPPorts = [ 80 443 ];
shb.nginx.accessLog = true;
networking.hosts = {
"192.168.1.2" = [ config.test.fqdn "auth.${config.test.domain}" ];
};
};
};
clientLoginModule = { config, pkgs, ... }: let
cfg = config.test.login;
in {
options.test.login = {
browser = mkOption {
type = enum [ "firefox" "chromium" "webkit" ];
default = "firefox";
};
usernameFieldLabelRegex = mkOption {
type = str;
default = "[Uu]sername";
};
usernameFieldSelector = mkOption {
type = str;
default = "get_by_label(re.compile('${cfg.usernameFieldLabelRegex}'))";
};
passwordFieldLabelRegex = mkOption {
type = str;
default = "[Pp]assword";
};
passwordFieldSelector = mkOption {
type = str;
default = "get_by_label(re.compile('${cfg.passwordFieldLabelRegex}'))";
};
loginButtonNameRegex = mkOption {
type = str;
default = "[Ll]ogin";
};
testLoginWith = mkOption {
type = listOf (submodule {
options = {
username = mkOption {
type = nullOr str;
default = null;
};
password = mkOption {
type = nullOr str;
default = null;
};
nextPageExpect = mkOption {
type = listOf str;
};
};
});
};
startUrl = mkOption {
type = str;
default = "http://${config.test.fqdn}";
};
beforeHook = mkOption {
type = str;
default = "";
};
};
config = {
networking.hosts = {
"192.168.1.2" = [ config.test.fqdn "auth.${config.test.domain}" ];
};
environment.variables = {
PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.browsers;
};
environment.systemPackages = [
(pkgs.writers.writePython3Bin "login_playwright"
{
libraries = [ pkgs.python3Packages.playwright ];
flakeIgnore = [ "F401" "E501" ];
}
(let
testCfg = pkgs.writeText "users.json" (builtins.toJSON cfg);
in ''
import json
import re
import sys
from playwright.sync_api import expect
from playwright.sync_api import sync_playwright
browsers = {
"chromium": {'args': ["--headless", "--disable-gpu"], 'channel': 'chromium'},
"firefox": {'args': ["--reporter", "html"]},
"webkit": {},
}
with open("${testCfg}") as f:
testCfg = json.load(f)
browser_name = testCfg['browser']
browser_args = browsers.get(browser_name)
print(f"Running test on {browser_name} {' '.join(browser_args)}")
with sync_playwright() as p:
browser = getattr(p, browser_name).launch(**browser_args)
for i, u in enumerate(testCfg["testLoginWith"]):
print(f"Testing for user {u['username']} and password {u['password']}")
context = browser.new_context(ignore_https_errors=True)
context.set_default_navigation_timeout(2 * 60 * 1000)
context.tracing.start(screenshots=True, snapshots=True, sources=True)
try:
page = context.new_page()
print(f"Going to {testCfg['startUrl']}")
page.goto(testCfg['startUrl'])
if testCfg.get("beforeHook") is not None:
exec(testCfg.get("beforeHook"))
if u['username'] is not None:
print(f"Filling field username with {u['username']}")
page.${cfg.usernameFieldSelector}.fill(u['username'])
if u['password'] is not None:
print(f"Filling field password with {u['password']}")
page.${cfg.passwordFieldSelector}.fill(u['password'])
# Assumes we don't need to login, so skip this.
if u['username'] is not None or u['password'] is not None:
print(f"Clicking button {testCfg['loginButtonNameRegex']}")
page.get_by_role("button", name=re.compile(testCfg['loginButtonNameRegex'])).click()
for line in u['nextPageExpect']:
print(f"Running: {line}")
print(f"Page has title: {page.title()}")
exec(line)
finally:
print(f'Saving trace at trace/{i}.zip')
context.tracing.stop(path=f"trace/{i}.zip")
browser.close()
'')
)
];
};
};
backup = backupOption: { config, ... }: {
imports = [
../modules/blocks/restic.nix
];
shb.restic.instances."testinstance" = {
request = backupOption.request;
settings = {
enable = true;
passphrase.result = config.shb.hardcodedsecret.backupPassphrase.result;
repository = {
path = "/opt/repos/A";
timerConfig = {
OnCalendar = "00:00:00";
RandomizedDelaySec = "5h";
};
};
};
};
shb.hardcodedsecret.backupPassphrase = {
request = config.shb.restic.instances."testinstance".settings.passphrase.request;
settings.content = "PassPhrase";
};
};
certs = { config, ... }: {
imports = [
../modules/blocks/ssl.nix
];
shb.certs = {
cas.selfsigned.myca = {
name = "My CA";
};
certs.selfsigned = {
n = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "*.${config.test.domain}";
group = "nginx";
};
};
};
systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ];
systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ];
};
ldap = { config, pkgs, ... }: {
imports = [
../modules/blocks/lldap.nix
];
networking.hosts = {
"127.0.0.1" = [ "ldap.${config.test.domain}" ];
};
shb.hardcodedsecret.ldapUserPassword = {
request = config.shb.lldap.ldapUserPassword.request;
settings.content = "ldapUserPassword";
};
shb.hardcodedsecret.jwtSecret = {
request = config.shb.lldap.jwtSecret.request;
settings.content = "jwtSecrets";
};
shb.lldap = {
enable = true;
inherit (config.test) domain;
subdomain = "ldap";
ldapPort = 3890;
webUIListenPort = 17170;
dcdomain = "dc=example,dc=com";
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;
debug = false; # Enable this if needed, but beware it is _very_ verbose.
ensureUsers = {
alice = {
email = "alice@example.com";
groups = [ "user_group" ];
password.result.path = pkgs.writeText "alicePassword" "AlicePassword";
};
bob = {
email = "bob@example.com";
groups = [ "user_group" "admin_group" ];
password.result.path = pkgs.writeText "bobPassword" "BobPassword";
};
charlie = {
email = "charlie@example.com";
groups = [ "other_group" ];
password.result.path = pkgs.writeText "charliePassword" "CharliePassword";
};
};
ensureGroups = {
user_group = {};
admin_group = {};
other_group = {};
};
};
};
sso = ssl: { config, pkgs, ... }: {
imports = [
../modules/blocks/authelia.nix
];
networking.hosts = {
"127.0.0.1" = [ "${config.shb.authelia.subdomain}.${config.shb.authelia.domain}" ];
};
shb.authelia = {
enable = true;
inherit (config.test) domain;
subdomain = "auth";
ssl = config.shb.certs.certs.selfsigned.n;
debug = true;
ldapHostname = "127.0.0.1";
ldapPort = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
secrets = {
jwtSecret.result = config.shb.hardcodedsecret.autheliaJwtSecret.result;
ldapAdminPassword.result = config.shb.hardcodedsecret.ldapAdminPassword.result;
sessionSecret.result = config.shb.hardcodedsecret.sessionSecret.result;
storageEncryptionKey.result = config.shb.hardcodedsecret.storageEncryptionKey.result;
identityProvidersOIDCHMACSecret.result = config.shb.hardcodedsecret.identityProvidersOIDCHMACSecret.result;
identityProvidersOIDCIssuerPrivateKey.result = config.shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey.result;
};
};
shb.hardcodedsecret.autheliaJwtSecret = {
request = config.shb.authelia.secrets.jwtSecret.request;
settings.content = "jwtSecret";
};
shb.hardcodedsecret.ldapAdminPassword = {
request = config.shb.authelia.secrets.ldapAdminPassword.request;
settings.content = "ldapUserPassword";
};
shb.hardcodedsecret.sessionSecret = {
request = config.shb.authelia.secrets.sessionSecret.request;
settings.content = "sessionSecret";
};
shb.hardcodedsecret.storageEncryptionKey = {
request = config.shb.authelia.secrets.storageEncryptionKey.request;
settings.content = "storageEncryptionKey";
};
shb.hardcodedsecret.identityProvidersOIDCHMACSecret = {
request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request;
settings.content = "identityProvidersOIDCHMACSecret";
};
shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey = {
request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request;
settings.source = (pkgs.runCommand "gen-private-key" {} ''
mkdir $out
${pkgs.openssl}/bin/openssl genrsa -out $out/private.pem 4096
'') + "/private.pem";
};
};
}