chore: format all files

This commit is contained in:
ibizaman 2025-10-28 20:27:25 +01:00 committed by Pierre Penninckx
parent 16fcb94897
commit 48802553de
77 changed files with 10265 additions and 8177 deletions

View file

@ -5,17 +5,17 @@ let
targetPort = 2222; targetPort = 2222;
in in
{ {
imports = imports = [
[ # Include the results of the hardware scan. # Include the results of the hardware scan.
./hardware-configuration.nix ./hardware-configuration.nix
]; ];
boot.loader.grub.enable = true; boot.loader.grub.enable = true;
boot.kernelModules = [ "kvm-intel" ]; boot.kernelModules = [ "kvm-intel" ];
system.stateVersion = "22.11"; system.stateVersion = "22.11";
# Options above are generate by running nixos-generate-config on the VM. # Options above are generate by running nixos-generate-config on the VM.
# Needed otherwise deploy will say system won't be able to boot. # Needed otherwise deploy will say system won't be able to boot.
boot.loader.grub.device = "/dev/vdb"; boot.loader.grub.device = "/dev/vdb";
# Needed to avoid getting into not available disk space in /boot. # Needed to avoid getting into not available disk space in /boot.
@ -26,7 +26,10 @@ in
# Options above are needed to deploy in a VM. # Options above are needed to deploy in a VM.
nix.settings.experimental-features = [ "nix-command" "flakes" ]; nix.settings.experimental-features = [
"nix-command"
"flakes"
];
# We need to create the user we will deploy with. # We need to create the user we will deploy with.
users.users.${targetUser} = { users.users.${targetUser} = {
@ -41,9 +44,11 @@ in
# The user we're deploying with must be able to run sudo without password. # The user we're deploying with must be able to run sudo without password.
security.sudo.extraRules = [ security.sudo.extraRules = [
{ users = [ targetUser ]; {
users = [ targetUser ];
commands = [ commands = [
{ command = "ALL"; {
command = "ALL";
options = [ "NOPASSWD" ]; options = [ "NOPASSWD" ];
} }
]; ];

View file

@ -6,148 +6,161 @@
sops-nix.url = "github:Mic92/sops-nix"; sops-nix.url = "github:Mic92/sops-nix";
}; };
outputs = inputs@{ self, selfhostblocks, sops-nix }: outputs =
inputs@{
self,
selfhostblocks,
sops-nix,
}:
let let
system = "x86_64-linux"; system = "x86_64-linux";
nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;
inherit (selfhostblocks.lib.${system}) pkgs; inherit (selfhostblocks.lib.${system}) pkgs;
basic = { config, ... }: { basic =
imports = [ { config, ... }:
./configuration.nix {
selfhostblocks.nixosModules.authelia imports = [
selfhostblocks.nixosModules.home-assistant ./configuration.nix
selfhostblocks.nixosModules.sops selfhostblocks.nixosModules.authelia
selfhostblocks.nixosModules.ssl selfhostblocks.nixosModules.home-assistant
sops-nix.nixosModules.default selfhostblocks.nixosModules.sops
]; selfhostblocks.nixosModules.ssl
sops-nix.nixosModules.default
];
sops.defaultSopsFile = ./secrets.yaml; sops.defaultSopsFile = ./secrets.yaml;
shb.home-assistant = { shb.home-assistant = {
enable = true; enable = true;
domain = "example.com"; domain = "example.com";
subdomain = "ha"; subdomain = "ha";
config = { config = {
name = "SHB Home Assistant"; name = "SHB Home Assistant";
country.source = config.shb.sops.secret."home-assistant/country".result.path; country.source = config.shb.sops.secret."home-assistant/country".result.path;
latitude.source = config.shb.sops.secret."home-assistant/latitude".result.path; latitude.source = config.shb.sops.secret."home-assistant/latitude".result.path;
longitude.source = config.shb.sops.secret."home-assistant/longitude".result.path; longitude.source = config.shb.sops.secret."home-assistant/longitude".result.path;
time_zone.source = config.shb.sops.secret."home-assistant/time_zone".result.path; time_zone.source = config.shb.sops.secret."home-assistant/time_zone".result.path;
unit_system = "metric"; unit_system = "metric";
};
};
shb.sops.secret."home-assistant/country".request = {
mode = "0440";
owner = "hass";
group = "hass";
restartUnits = [ "home-assistant.service" ];
};
shb.sops.secret."home-assistant/latitude".request = {
mode = "0440";
owner = "hass";
group = "hass";
restartUnits = [ "home-assistant.service" ];
};
shb.sops.secret."home-assistant/longitude".request = {
mode = "0440";
owner = "hass";
group = "hass";
restartUnits = [ "home-assistant.service" ];
};
shb.sops.secret."home-assistant/time_zone".request = {
mode = "0440";
owner = "hass";
group = "hass";
restartUnits = [ "home-assistant.service" ];
};
nixpkgs.config.permittedInsecurePackages = [
"openssl-1.1.1w"
];
};
ldap =
{ config, ... }:
{
shb.lldap = {
enable = true;
domain = "example.com";
subdomain = "ldap";
ldapPort = 3890;
webUIListenPort = 17170;
dcdomain = "dc=example,dc=com";
ldapUserPassword.result = config.shb.sops.secret."lldap/user_password".result;
jwtSecret.result = config.shb.sops.secret."lldap/jwt_secret".result;
};
shb.sops.secret."lldap/user_password".request = config.shb.lldap.ldapUserPassword.request;
shb.sops.secret."lldap/jwt_secret".request = config.shb.lldap.jwtSecret.request;
shb.home-assistant.ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.webUIListenPort;
userGroup = "homeassistant_user";
}; };
}; };
shb.sops.secret."home-assistant/country".request = {
mode = "0440";
owner = "hass";
group = "hass";
restartUnits = [ "home-assistant.service" ];
};
shb.sops.secret."home-assistant/latitude".request = {
mode = "0440";
owner = "hass";
group = "hass";
restartUnits = [ "home-assistant.service" ];
};
shb.sops.secret."home-assistant/longitude".request = {
mode = "0440";
owner = "hass";
group = "hass";
restartUnits = [ "home-assistant.service" ];
};
shb.sops.secret."home-assistant/time_zone".request = {
mode = "0440";
owner = "hass";
group = "hass";
restartUnits = [ "home-assistant.service" ];
};
nixpkgs.config.permittedInsecurePackages = [
"openssl-1.1.1w"
];
};
ldap = { config, ... }: {
shb.lldap = {
enable = true;
domain = "example.com";
subdomain = "ldap";
ldapPort = 3890;
webUIListenPort = 17170;
dcdomain = "dc=example,dc=com";
ldapUserPassword.result = config.shb.sops.secret."lldap/user_password".result;
jwtSecret.result = config.shb.sops.secret."lldap/jwt_secret".result;
};
shb.sops.secret."lldap/user_password".request = config.shb.lldap.ldapUserPassword.request;
shb.sops.secret."lldap/jwt_secret".request = config.shb.lldap.jwtSecret.request;
shb.home-assistant.ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.webUIListenPort;
userGroup = "homeassistant_user";
};
};
sopsConfig = { sopsConfig = {
sops.age.keyFile = "/etc/sops/my_key"; sops.age.keyFile = "/etc/sops/my_key";
environment.etc."sops/my_key".source = ./keys.txt; environment.etc."sops/my_key".source = ./keys.txt;
}; };
in in
{ {
nixosConfigurations = { nixosConfigurations = {
basic = pkgs.nixosSystem { basic = pkgs.nixosSystem {
system = "x86_64-linux"; system = "x86_64-linux";
modules = [ modules = [
basic basic
sopsConfig sopsConfig
]; ];
};
ldap = pkgs.nixosSystem {
system = "x86_64-linux";
modules = [
basic
ldap
sopsConfig
];
};
}; };
colmena = { ldap = pkgs.nixosSystem {
meta = { system = "x86_64-linux";
nixpkgs = import nixpkgs' { modules = [
system = "x86_64-linux"; basic
}; ldap
specialArgs = inputs; sopsConfig
}; ];
basic = { config, ... }: {
imports = [
basic
];
# Used by colmena to know which target host to deploy to.
deployment = {
targetHost = "example";
targetUser = "nixos";
targetPort = 2222;
};
};
ldap = { config, ... }: {
imports = [
basic
ldap
];
# Used by colmena to know which target host to deploy to.
deployment = {
targetHost = "example";
targetUser = "nixos";
targetPort = 2222;
};
};
}; };
}; };
colmena = {
meta = {
nixpkgs = import nixpkgs' {
system = "x86_64-linux";
};
specialArgs = inputs;
};
basic =
{ config, ... }:
{
imports = [
basic
];
# Used by colmena to know which target host to deploy to.
deployment = {
targetHost = "example";
targetUser = "nixos";
targetPort = 2222;
};
};
ldap =
{ config, ... }:
{
imports = [
basic
ldap
];
# Used by colmena to know which target host to deploy to.
deployment = {
targetHost = "example";
targetUser = "nixos";
targetPort = 2222;
};
};
};
};
} }

View file

@ -3,52 +3,65 @@
# Do not modify this file! It was generated by nixos-generate-config # Do not modify this file! It was generated by nixos-generate-config
# and may be overwritten by future invocations. Please make changes # and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead. # to /etc/nixos/configuration.nix instead.
{ config, lib, pkgs, modulesPath, ... }: {
config,
lib,
pkgs,
modulesPath,
...
}:
{ {
imports = imports = [
[ (modulesPath + "/profiles/qemu-guest.nix") (modulesPath + "/profiles/qemu-guest.nix")
]; ];
boot.initrd.availableKernelModules = [ "ata_piix" "uhci_hcd" "virtio_pci" "floppy" "sr_mod" "virtio_blk" ]; boot.initrd.availableKernelModules = [
"ata_piix"
"uhci_hcd"
"virtio_pci"
"floppy"
"sr_mod"
"virtio_blk"
];
boot.initrd.kernelModules = [ ]; boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ]; boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ]; boot.extraModulePackages = [ ];
fileSystems."/" = fileSystems."/" = {
{ device = "/dev/vda"; device = "/dev/vda";
fsType = "ext4"; fsType = "ext4";
}; };
fileSystems."/nix/.ro-store" = fileSystems."/nix/.ro-store" = {
{ device = "nix-store"; device = "nix-store";
fsType = "9p"; fsType = "9p";
}; };
fileSystems."/nix/.rw-store" = fileSystems."/nix/.rw-store" = {
{ device = "tmpfs"; device = "tmpfs";
fsType = "tmpfs"; fsType = "tmpfs";
}; };
fileSystems."/tmp/shared" = fileSystems."/tmp/shared" = {
{ device = "shared"; device = "shared";
fsType = "9p"; fsType = "9p";
}; };
fileSystems."/tmp/xchg" = fileSystems."/tmp/xchg" = {
{ device = "xchg"; device = "xchg";
fsType = "9p"; fsType = "9p";
}; };
fileSystems."/nix/store" = fileSystems."/nix/store" = {
{ device = "overlay"; device = "overlay";
fsType = "overlay"; fsType = "overlay";
}; };
fileSystems."/boot" = fileSystems."/boot" = {
{ device = "/dev/vdb2"; device = "/dev/vdb2";
fsType = "vfat"; fsType = "vfat";
}; };
swapDevices = [ ]; swapDevices = [ ];

View file

@ -5,17 +5,17 @@ let
targetPort = 2222; targetPort = 2222;
in in
{ {
imports = imports = [
[ # Include the results of the hardware scan. # Include the results of the hardware scan.
./hardware-configuration.nix ./hardware-configuration.nix
]; ];
boot.loader.grub.enable = true; boot.loader.grub.enable = true;
boot.kernelModules = [ "kvm-intel" ]; boot.kernelModules = [ "kvm-intel" ];
system.stateVersion = "22.11"; system.stateVersion = "22.11";
# Options above are generate by running nixos-generate-config on the VM. # Options above are generate by running nixos-generate-config on the VM.
# Needed otherwise deploy will say system won't be able to boot. # Needed otherwise deploy will say system won't be able to boot.
boot.loader.grub.device = "/dev/vdb"; boot.loader.grub.device = "/dev/vdb";
# Needed to avoid getting into not available disk space in /boot. # Needed to avoid getting into not available disk space in /boot.
@ -27,7 +27,10 @@ in
# Options above are needed to deploy in a VM. # Options above are needed to deploy in a VM.
nix.settings.experimental-features = [ "nix-command" "flakes" ]; nix.settings.experimental-features = [
"nix-command"
"flakes"
];
# We need to create the user we will deploy with. # We need to create the user we will deploy with.
users.users.${targetUser} = { users.users.${targetUser} = {
@ -42,9 +45,11 @@ in
# The user we're deploying with must be able to run sudo without password. # The user we're deploying with must be able to run sudo without password.
security.sudo.extraRules = [ security.sudo.extraRules = [
{ users = [ targetUser ]; {
users = [ targetUser ];
commands = [ commands = [
{ command = "ALL"; {
command = "ALL";
options = [ "NOPASSWD" ]; options = [ "NOPASSWD" ];
} }
]; ];

View file

@ -6,236 +6,256 @@
sops-nix.url = "github:Mic92/sops-nix"; sops-nix.url = "github:Mic92/sops-nix";
}; };
outputs = inputs@{ self, selfhostblocks, sops-nix }: outputs =
inputs@{
self,
selfhostblocks,
sops-nix,
}:
let let
system = "x86_64-linux"; system = "x86_64-linux";
nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;
inherit (selfhostblocks.lib.${system}) pkgs; inherit (selfhostblocks.lib.${system}) pkgs;
basic = { config, ... }: { basic =
imports = [ { config, ... }:
./configuration.nix {
selfhostblocks.nixosModules.authelia imports = [
selfhostblocks.nixosModules.nextcloud-server ./configuration.nix
selfhostblocks.nixosModules.nginx selfhostblocks.nixosModules.authelia
selfhostblocks.nixosModules.sops selfhostblocks.nixosModules.nextcloud-server
selfhostblocks.nixosModules.ssl selfhostblocks.nixosModules.nginx
sops-nix.nixosModules.default selfhostblocks.nixosModules.sops
]; selfhostblocks.nixosModules.ssl
sops-nix.nixosModules.default
];
sops.defaultSopsFile = ./secrets.yaml; sops.defaultSopsFile = ./secrets.yaml;
shb.nextcloud = { shb.nextcloud = {
enable = true; enable = true;
domain = "example.com"; domain = "example.com";
subdomain = "n"; subdomain = "n";
dataDir = "/var/lib/nextcloud"; dataDir = "/var/lib/nextcloud";
tracing = null; tracing = null;
defaultPhoneRegion = "US"; defaultPhoneRegion = "US";
# This option is only needed because we do not access Nextcloud at the default port in the VM. # This option is only needed because we do not access Nextcloud at the default port in the VM.
port = 8080; port = 8080;
adminPass.result = config.shb.sops.secret."nextcloud/adminpass".result; adminPass.result = config.shb.sops.secret."nextcloud/adminpass".result;
apps = { apps = {
previewgenerator.enable = true; previewgenerator.enable = true;
};
};
shb.sops.secret."nextcloud/adminpass".request = config.shb.nextcloud.adminPass.request;
# Set to true for more debug info with `journalctl -f -u nginx`.
shb.nginx.accessLog = true;
shb.nginx.debugLog = false;
};
ldap = { config, ... }: {
shb.lldap = {
enable = true;
domain = "example.com";
subdomain = "ldap";
ldapPort = 3890;
webUIListenPort = 17170;
dcdomain = "dc=example,dc=com";
ldapUserPassword.result = config.shb.sops.secret."lldap/user_password".result;
jwtSecret.result = config.shb.sops.secret."lldap/jwt_secret".result;
};
shb.sops.secret."lldap/user_password".request = config.shb.lldap.ldapUserPassword.request;
shb.sops.secret."lldap/jwt_secret".request = config.shb.lldap.jwtSecret.request;
shb.nextcloud.apps.ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
adminName = "admin";
adminPassword.result = config.shb.sops.secret."nextcloud/ldap_admin_password".result;
userGroup = "nextcloud_user";
};
shb.sops.secret."nextcloud/ldap_admin_password" = {
request = config.shb.nextcloud.apps.ldap.adminPassword.request;
settings.key = "lldap/user_password";
};
};
sso = { config, lib, ... }: {
shb.certs = {
cas.selfsigned.myca = {
name = "My CA";
};
certs.selfsigned = {
n = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "*.example.com";
group = "nginx";
}; };
}; };
}; shb.sops.secret."nextcloud/adminpass".request = config.shb.nextcloud.adminPass.request;
shb.nextcloud = {
port = lib.mkForce null;
ssl = config.shb.certs.certs.selfsigned.n;
};
shb.lldap.ssl = config.shb.certs.certs.selfsigned.n;
services.dnsmasq = { # Set to true for more debug info with `journalctl -f -u nginx`.
enable = true; shb.nginx.accessLog = true;
settings = { shb.nginx.debugLog = false;
domain-needed = true; };
# no-resolv = true;
bogus-priv = true; ldap =
address = { config, ... }:
map (hostname: "/${hostname}/127.0.0.1") [ {
shb.lldap = {
enable = true;
domain = "example.com";
subdomain = "ldap";
ldapPort = 3890;
webUIListenPort = 17170;
dcdomain = "dc=example,dc=com";
ldapUserPassword.result = config.shb.sops.secret."lldap/user_password".result;
jwtSecret.result = config.shb.sops.secret."lldap/jwt_secret".result;
};
shb.sops.secret."lldap/user_password".request = config.shb.lldap.ldapUserPassword.request;
shb.sops.secret."lldap/jwt_secret".request = config.shb.lldap.jwtSecret.request;
shb.nextcloud.apps.ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
adminName = "admin";
adminPassword.result = config.shb.sops.secret."nextcloud/ldap_admin_password".result;
userGroup = "nextcloud_user";
};
shb.sops.secret."nextcloud/ldap_admin_password" = {
request = config.shb.nextcloud.apps.ldap.adminPassword.request;
settings.key = "lldap/user_password";
};
};
sso =
{ config, lib, ... }:
{
shb.certs = {
cas.selfsigned.myca = {
name = "My CA";
};
certs.selfsigned = {
n = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "*.example.com";
group = "nginx";
};
};
};
shb.nextcloud = {
port = lib.mkForce null;
ssl = config.shb.certs.certs.selfsigned.n;
};
shb.lldap.ssl = config.shb.certs.certs.selfsigned.n;
services.dnsmasq = {
enable = true;
settings = {
domain-needed = true;
# no-resolv = true;
bogus-priv = true;
address = map (hostname: "/${hostname}/127.0.0.1") [
"example.com" "example.com"
"n.example.com" "n.example.com"
"ldap.example.com" "ldap.example.com"
"auth.example.com" "auth.example.com"
]; ];
};
};
shb.authelia = {
enable = true;
domain = "example.com";
subdomain = "auth";
ssl = config.shb.certs.certs.selfsigned.n;
ldapPort = config.shb.lldap.ldapPort;
ldapHostname = "127.0.0.1";
dcdomain = config.shb.lldap.dcdomain;
secrets = {
jwtSecret.result = config.shb.sops.secret."authelia/jwt_secret".result;
ldapAdminPassword.result = config.shb.sops.secret."authelia/ldap_admin_password".result;
sessionSecret.result = config.shb.sops.secret."authelia/session_secret".result;
storageEncryptionKey.result = config.shb.sops.secret."authelia/storage_encryption_key".result;
identityProvidersOIDCHMACSecret.result = config.shb.sops.secret."authelia/hmac_secret".result;
identityProvidersOIDCIssuerPrivateKey.result = config.shb.sops.secret."authelia/private_key".result;
};
};
shb.sops.secret."authelia/jwt_secret".request = config.shb.authelia.secrets.jwtSecret.request;
shb.sops.secret."authelia/ldap_admin_password" = {
request = config.shb.authelia.secrets.ldapAdminPassword.request;
settings.key = "lldap/user_password";
};
shb.sops.secret."authelia/session_secret".request =
config.shb.authelia.secrets.sessionSecret.request;
shb.sops.secret."authelia/storage_encryption_key".request =
config.shb.authelia.secrets.storageEncryptionKey.request;
shb.sops.secret."authelia/hmac_secret".request =
config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request;
shb.sops.secret."authelia/private_key".request =
config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request;
shb.nextcloud.apps.sso = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "nextcloud";
fallbackDefaultAuth = true;
secret.result = config.shb.sops.secret."nextcloud/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secret."authelia/nextcloud_sso_secret".result;
};
shb.sops.secret."nextcloud/sso/secret".request = config.shb.nextcloud.apps.sso.secret.request;
shb.sops.secret."authelia/nextcloud_sso_secret" = {
request = config.shb.nextcloud.apps.sso.secretForAuthelia.request;
settings.key = "nextcloud/sso/secret";
}; };
}; };
shb.authelia = {
enable = true;
domain = "example.com";
subdomain = "auth";
ssl = config.shb.certs.certs.selfsigned.n;
ldapPort = config.shb.lldap.ldapPort;
ldapHostname = "127.0.0.1";
dcdomain = config.shb.lldap.dcdomain;
secrets = {
jwtSecret.result = config.shb.sops.secret."authelia/jwt_secret".result;
ldapAdminPassword.result = config.shb.sops.secret."authelia/ldap_admin_password".result;
sessionSecret.result = config.shb.sops.secret."authelia/session_secret".result;
storageEncryptionKey.result = config.shb.sops.secret."authelia/storage_encryption_key".result;
identityProvidersOIDCHMACSecret.result = config.shb.sops.secret."authelia/hmac_secret".result;
identityProvidersOIDCIssuerPrivateKey.result = config.shb.sops.secret."authelia/private_key".result;
};
};
shb.sops.secret."authelia/jwt_secret".request = config.shb.authelia.secrets.jwtSecret.request;
shb.sops.secret."authelia/ldap_admin_password" = {
request = config.shb.authelia.secrets.ldapAdminPassword.request;
settings.key = "lldap/user_password";
};
shb.sops.secret."authelia/session_secret".request = config.shb.authelia.secrets.sessionSecret.request;
shb.sops.secret."authelia/storage_encryption_key".request = config.shb.authelia.secrets.storageEncryptionKey.request;
shb.sops.secret."authelia/hmac_secret".request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request;
shb.sops.secret."authelia/private_key".request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request;
shb.nextcloud.apps.sso = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "nextcloud";
fallbackDefaultAuth = true;
secret.result = config.shb.sops.secret."nextcloud/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secret."authelia/nextcloud_sso_secret".result;
};
shb.sops.secret."nextcloud/sso/secret".request = config.shb.nextcloud.apps.sso.secret.request;
shb.sops.secret."authelia/nextcloud_sso_secret" = {
request = config.shb.nextcloud.apps.sso.secretForAuthelia.request;
settings.key = "nextcloud/sso/secret";
};
};
sopsConfig = { sopsConfig = {
sops.age.keyFile = "/etc/sops/my_key"; sops.age.keyFile = "/etc/sops/my_key";
environment.etc."sops/my_key".source = ./keys.txt; environment.etc."sops/my_key".source = ./keys.txt;
}; };
in in
{ {
nixosConfigurations = { nixosConfigurations = {
basic = pkgs.nixosSystem { basic = pkgs.nixosSystem {
system = "x86_64-linux"; system = "x86_64-linux";
modules = [ modules = [
sopsConfig sopsConfig
basic basic
]; ];
};
ldap = pkgs.nixosSystem {
system = "x86_64-linux";
modules = [
sopsConfig
basic
ldap
];
};
sso = pkgs.nixosSystem {
system = "x86_64-linux";
modules = [
sopsConfig
basic
ldap
sso
];
};
}; };
ldap = pkgs.nixosSystem {
colmena = { system = "x86_64-linux";
meta = { modules = [
nixpkgs = import nixpkgs' { sopsConfig
system = "x86_64-linux"; basic
}; ldap
specialArgs = inputs; ];
}; };
sso = pkgs.nixosSystem {
basic = { config, ... }: { system = "x86_64-linux";
imports = [ modules = [
basic sopsConfig
]; basic
ldap
deployment = { sso
targetHost = "example"; ];
targetUser = "nixos";
targetPort = 2222;
};
};
ldap = { config, ... }: {
imports = [
basic
ldap
];
deployment = {
targetHost = "example";
targetUser = "nixos";
targetPort = 2222;
};
};
sso = { config, ... }: {
imports = [
basic
ldap
sso
];
deployment = {
targetHost = "example";
targetUser = "nixos";
targetPort = 2222;
};
};
}; };
}; };
colmena = {
meta = {
nixpkgs = import nixpkgs' {
system = "x86_64-linux";
};
specialArgs = inputs;
};
basic =
{ config, ... }:
{
imports = [
basic
];
deployment = {
targetHost = "example";
targetUser = "nixos";
targetPort = 2222;
};
};
ldap =
{ config, ... }:
{
imports = [
basic
ldap
];
deployment = {
targetHost = "example";
targetUser = "nixos";
targetPort = 2222;
};
};
sso =
{ config, ... }:
{
imports = [
basic
ldap
sso
];
deployment = {
targetHost = "example";
targetUser = "nixos";
targetPort = 2222;
};
};
};
};
} }

View file

@ -3,52 +3,65 @@
# Do not modify this file! It was generated by nixos-generate-config # Do not modify this file! It was generated by nixos-generate-config
# and may be overwritten by future invocations. Please make changes # and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead. # to /etc/nixos/configuration.nix instead.
{ config, lib, pkgs, modulesPath, ... }: {
config,
lib,
pkgs,
modulesPath,
...
}:
{ {
imports = imports = [
[ (modulesPath + "/profiles/qemu-guest.nix") (modulesPath + "/profiles/qemu-guest.nix")
]; ];
boot.initrd.availableKernelModules = [ "ata_piix" "uhci_hcd" "virtio_pci" "floppy" "sr_mod" "virtio_blk" ]; boot.initrd.availableKernelModules = [
"ata_piix"
"uhci_hcd"
"virtio_pci"
"floppy"
"sr_mod"
"virtio_blk"
];
boot.initrd.kernelModules = [ ]; boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ]; boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ]; boot.extraModulePackages = [ ];
fileSystems."/" = fileSystems."/" = {
{ device = "/dev/vda"; device = "/dev/vda";
fsType = "ext4"; fsType = "ext4";
}; };
fileSystems."/nix/.ro-store" = fileSystems."/nix/.ro-store" = {
{ device = "nix-store"; device = "nix-store";
fsType = "9p"; fsType = "9p";
}; };
fileSystems."/nix/.rw-store" = fileSystems."/nix/.rw-store" = {
{ device = "tmpfs"; device = "tmpfs";
fsType = "tmpfs"; fsType = "tmpfs";
}; };
fileSystems."/tmp/shared" = fileSystems."/tmp/shared" = {
{ device = "shared"; device = "shared";
fsType = "9p"; fsType = "9p";
}; };
fileSystems."/tmp/xchg" = fileSystems."/tmp/xchg" = {
{ device = "xchg"; device = "xchg";
fsType = "9p"; fsType = "9p";
}; };
fileSystems."/nix/store" = fileSystems."/nix/store" = {
{ device = "overlay"; device = "overlay";
fsType = "overlay"; fsType = "overlay";
}; };
fileSystems."/boot" = fileSystems."/boot" = {
{ device = "/dev/vdb2"; device = "/dev/vdb2";
fsType = "vfat"; fsType = "vfat";
}; };
swapDevices = [ ]; swapDevices = [ ];

View file

@ -1,40 +1,49 @@
# Taken nearly verbatim from https://github.com/nix-community/home-manager/pull/4673 # Taken nearly verbatim from https://github.com/nix-community/home-manager/pull/4673
# Read these docs online at https://shb.skarabox.com. # Read these docs online at https://shb.skarabox.com.
{ pkgs {
, buildPackages pkgs,
, lib buildPackages,
, nmdsrc lib,
, stdenv nmdsrc,
, documentation-highlighter stdenv,
, nixos-render-docs documentation-highlighter,
nixos-render-docs,
, release release,
, allModules allModules,
, version ? builtins.readFile ../VERSION version ? builtins.readFile ../VERSION,
, substituteVersionIn substituteVersionIn,
, modules modules,
}: }:
let let
shbPath = toString ./..; shbPath = toString ./..;
gitHubDeclaration = user: repo: subpath: gitHubDeclaration =
let urlRef = "main"; user: repo: subpath:
end = if subpath == "" then "" else "/" + subpath; let
in { urlRef = "main";
end = if subpath == "" then "" else "/" + subpath;
in
{
url = "https://github.com/${user}/${repo}/blob/${urlRef}${end}"; url = "https://github.com/${user}/${repo}/blob/${urlRef}${end}";
name = "<${repo}${end}>"; name = "<${repo}${end}>";
}; };
ghRoot = (gitHubDeclaration "ibizaman" "selfhostblocks" "").url; ghRoot = (gitHubDeclaration "ibizaman" "selfhostblocks" "").url;
buildOptionsDocs = { modules, filterOptionPath ? null }: args: buildOptionsDocs =
{
modules,
filterOptionPath ? null,
}:
args:
let let
config = { config = {
_module.check = false; _module.check = false;
_module.args = {}; _module.args = { };
system.stateVersion = "22.11"; system.stateVersion = "22.11";
}; };
@ -52,41 +61,56 @@ let
}; };
options = lib.setAttrByPath filterOptionPath (lib.getAttrFromPath filterOptionPath eval.options); options = lib.setAttrByPath filterOptionPath (lib.getAttrFromPath filterOptionPath eval.options);
in buildPackages.nixosOptionsDoc ({ in
inherit options; buildPackages.nixosOptionsDoc (
{
inherit options;
transformOptions = opt: transformOptions =
opt // { opt:
# Clean up declaration sites to not refer to the Home Manager opt
# source tree. // {
declarations = map (decl: # Clean up declaration sites to not refer to the Home Manager
gitHubDeclaration "ibizaman" "selfhostblocks" # source tree.
(lib.removePrefix "/" (lib.removePrefix shbPath (toString decl)))) opt.declarations; declarations = map (
}; decl:
} // builtins.removeAttrs args [ "includeModuleSystemOptions" ]); gitHubDeclaration "ibizaman" "selfhostblocks" (
lib.removePrefix "/" (lib.removePrefix shbPath (toString decl))
)
) opt.declarations;
};
}
// builtins.removeAttrs args [ "includeModuleSystemOptions" ]
);
scrubbedModule = { scrubbedModule = {
_module.args.pkgs = lib.mkForce (nmd.scrubDerivations "pkgs" pkgs); _module.args.pkgs = lib.mkForce (nmd.scrubDerivations "pkgs" pkgs);
_module.check = false; _module.check = false;
}; };
allOptionsDocs = paths: (buildOptionsDocs allOptionsDocs =
{ paths:
modules = paths ++ allModules ++ [ scrubbedModule ]; (buildOptionsDocs
filterOptionPath = [ "shb" ]; {
} modules = paths ++ allModules ++ [ scrubbedModule ];
{ filterOptionPath = [ "shb" ];
variablelistId = "selfhostblocks-options"; }
}).optionsJSON; {
variablelistId = "selfhostblocks-options";
}
).optionsJSON;
individualModuleOptionsDocs = filterOptionPath: paths: (buildOptionsDocs individualModuleOptionsDocs =
{ filterOptionPath: paths:
modules = paths ++ [ scrubbedModule ]; (buildOptionsDocs
inherit filterOptionPath; {
} modules = paths ++ [ scrubbedModule ];
{ inherit filterOptionPath;
variablelistId = "selfhostblocks-options"; }
}).optionsJSON; {
variablelistId = "selfhostblocks-options";
}
).optionsJSON;
nmd = import nmdsrc { nmd = import nmdsrc {
inherit lib; inherit lib;
@ -94,15 +118,15 @@ let
# `nmd` uses to work around the broken stylesheets in # `nmd` uses to work around the broken stylesheets in
# `docbook-xsl-ns`, so we restore the patched version here. # `docbook-xsl-ns`, so we restore the patched version here.
pkgs = pkgs // { pkgs = pkgs // {
docbook-xsl-ns = docbook-xsl-ns = pkgs.docbook-xsl-ns.override { withManOptDedupPatch = true; };
pkgs.docbook-xsl-ns.override { withManOptDedupPatch = true; };
}; };
}; };
outputPath = "share/doc/selfhostblocks"; outputPath = "share/doc/selfhostblocks";
manpage-urls = pkgs.writeText "manpage-urls.json" ''{}''; manpage-urls = pkgs.writeText "manpage-urls.json" ''{}'';
in stdenv.mkDerivation { in
stdenv.mkDerivation {
name = "self-host-blocks-manual"; name = "self-host-blocks-manual";
nativeBuildInputs = [ nixos-render-docs ]; nativeBuildInputs = [ nixos-render-docs ];
@ -135,28 +159,41 @@ in stdenv.mkDerivation {
${nmdsrc}/static/highlightjs/highlight.load.js ${nmdsrc}/static/highlightjs/highlight.load.js
'' ''
+ lib.concatStringsSep "\n" (map (m: '' + lib.concatStringsSep "\n" (
substituteInPlace ${m} --replace '@VERSION@' ${version} map (m: ''
'') substituteVersionIn) substituteInPlace ${m} --replace '@VERSION@' ${version}
'') substituteVersionIn
)
+ '' + ''
substituteInPlace ./options.md \ substituteInPlace ./options.md \
--replace \ --replace \
'@OPTIONS_JSON@' \ '@OPTIONS_JSON@' \
${allOptionsDocs [ ${
(pkgs.path + "/nixos/modules/services/misc/forgejo.nix") allOptionsDocs [
]}/share/doc/nixos/options.json (pkgs.path + "/nixos/modules/services/misc/forgejo.nix")
]
}/share/doc/nixos/options.json
'' ''
+ lib.concatStringsSep "\n" (lib.mapAttrsToList (name: cfg': + lib.concatStringsSep "\n" (
let lib.mapAttrsToList (
cfg = if builtins.isAttrs cfg' then cfg' else { module = cfg'; }; name: cfg':
module = if builtins.isList cfg.module then cfg.module else [ cfg.module ]; let
optionRoot = cfg.optionRoot or [ "shb" (lib.last (lib.splitString "/" name)) ]; cfg = if builtins.isAttrs cfg' then cfg' else { module = cfg'; };
in '' module = if builtins.isList cfg.module then cfg.module else [ cfg.module ];
substituteInPlace ./modules/${name}/docs/default.md \ optionRoot =
--replace-fail \ cfg.optionRoot or [
'@OPTIONS_JSON@' \ "shb"
${individualModuleOptionsDocs optionRoot module}/share/doc/nixos/options.json (lib.last (lib.splitString "/" name))
'') modules) ];
in
''
substituteInPlace ./modules/${name}/docs/default.md \
--replace-fail \
'@OPTIONS_JSON@' \
${individualModuleOptionsDocs optionRoot module}/share/doc/nixos/options.json
''
) modules
)
+ '' + ''
find . -name "*.md" -print0 | \ find . -name "*.md" -print0 | \
while IFS= read -r -d ''' f; do while IFS= read -r -d ''' f; do

555
flake.nix
View file

@ -11,66 +11,89 @@
}; };
}; };
outputs = inputs@{ self, nixpkgs, nix-flake-tests, flake-utils, nmdsrc, ... }: flake-utils.lib.eachDefaultSystem (system: outputs =
let inputs@{
originPkgs = nixpkgs.legacyPackages.${system}; self,
shbPatches = originPkgs.lib.optionals (system == "x86_64-linux") [ nixpkgs,
# Get rid of lldap patches when https://github.com/NixOS/nixpkgs/pull/425923 is merged. nix-flake-tests,
./patches/lldap.patch flake-utils,
nmdsrc,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
originPkgs = nixpkgs.legacyPackages.${system};
shbPatches = originPkgs.lib.optionals (system == "x86_64-linux") [
# Get rid of lldap patches when https://github.com/NixOS/nixpkgs/pull/425923 is merged.
./patches/lldap.patch
# Leaving commented out as an example. # Leaving commented out as an example.
# (originPkgs.fetchpatch { # (originPkgs.fetchpatch {
# url = "https://github.com/NixOS/nixpkgs/pull/317107.patch"; # url = "https://github.com/NixOS/nixpkgs/pull/317107.patch";
# hash = "sha256-hoLrqV7XtR1hP/m0rV9hjYUBtrSjay0qcPUYlKKuVWk="; # hash = "sha256-hoLrqV7XtR1hP/m0rV9hjYUBtrSjay0qcPUYlKKuVWk=";
# }) # })
];
patchNixpkgs = {
nixpkgs,
patches,
system,
}: nixpkgs.legacyPackages.${system}.applyPatches {
name = "nixpkgs-patched";
src = nixpkgs;
inherit patches;
};
patchedNixpkgs = (patchNixpkgs {
nixpkgs = inputs.nixpkgs;
patches = shbPatches;
inherit system;
});
pkgs = import patchedNixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [
(final: prev: {
lib = prev.lib // {
shb = self.lib.${system};
evalModules = args: ((prev.lib.makeOverridable prev.lib.evalModules) args).override (prevAttrs: {
specialArgs = (prevAttrs.specialArgs or {}) // { inherit (pkgs) lib; };
});
};
nixosSystem = args: ((prev.lib.makeOverridable (import "${patchedNixpkgs}/nixos/lib/eval-config.nix")) args).override (prevAttrs: {
inherit (pkgs) lib;
});
})
]; ];
}; patchNixpkgs =
{
nixpkgs,
patches,
system,
}:
nixpkgs.legacyPackages.${system}.applyPatches {
name = "nixpkgs-patched";
src = nixpkgs;
inherit patches;
};
patchedNixpkgs = (
patchNixpkgs {
nixpkgs = inputs.nixpkgs;
patches = shbPatches;
inherit system;
}
);
pkgs = import patchedNixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [
(final: prev: {
lib = prev.lib // {
shb = self.lib.${system};
evalModules =
args:
((prev.lib.makeOverridable prev.lib.evalModules) args).override (prevAttrs: {
specialArgs = (prevAttrs.specialArgs or { }) // {
inherit (pkgs) lib;
};
});
};
nixosSystem =
args:
((prev.lib.makeOverridable (import "${patchedNixpkgs}/nixos/lib/eval-config.nix")) args).override
(prevAttrs: {
inherit (pkgs) lib;
});
})
];
};
# The contract dummies are used to show options for contracts. # The contract dummies are used to show options for contracts.
contractDummyModules = [ contractDummyModules = [
modules/contracts/backup/dummyModule.nix modules/contracts/backup/dummyModule.nix
modules/contracts/ssl/dummyModule.nix modules/contracts/ssl/dummyModule.nix
]; ];
in in
{ {
formatter = pkgs.nixfmt-tree; formatter = pkgs.nixfmt-tree;
packages.manualHtml = pkgs.callPackage ./docs { packages.manualHtml = pkgs.callPackage ./docs {
inherit nmdsrc; inherit nmdsrc;
allModules = self.nixosModules.default.imports allModules =
++ [ self.nixosModules.default.imports
self.nixosModules.sops ++ [
] ++ contractDummyModules; self.nixosModules.sops
]
++ contractDummyModules;
release = builtins.readFile ./VERSION; release = builtins.readFile ./VERSION;
substituteVersionIn = [ substituteVersionIn = [
@ -82,7 +105,10 @@
"blocks/lldap" = ./modules/blocks/lldap.nix; "blocks/lldap" = ./modules/blocks/lldap.nix;
"blocks/ssl" = { "blocks/ssl" = {
module = ./modules/blocks/ssl.nix; module = ./modules/blocks/ssl.nix;
optionRoot = [ "shb" "certs" ]; optionRoot = [
"shb"
"certs"
];
}; };
"blocks/mitmdump" = ./modules/blocks/mitmdump.nix; "blocks/mitmdump" = ./modules/blocks/mitmdump.nix;
"blocks/monitoring" = ./modules/blocks/monitoring.nix; "blocks/monitoring" = ./modules/blocks/monitoring.nix;
@ -99,154 +125,196 @@
"services/karakeep" = ./modules/services/karakeep.nix; "services/karakeep" = ./modules/services/karakeep.nix;
"services/nextcloud-server" = { "services/nextcloud-server" = {
module = ./modules/services/nextcloud-server.nix; module = ./modules/services/nextcloud-server.nix;
optionRoot = [ "shb" "nextcloud" ]; optionRoot = [
"shb"
"nextcloud"
];
}; };
"services/open-webui" = ./modules/services/open-webui.nix; "services/open-webui" = ./modules/services/open-webui.nix;
"services/pinchflat" = ./modules/services/pinchflat.nix; "services/pinchflat" = ./modules/services/pinchflat.nix;
"services/vaultwarden" = ./modules/services/vaultwarden.nix; "services/vaultwarden" = ./modules/services/vaultwarden.nix;
"contracts/backup" = { "contracts/backup" = {
module = ./modules/contracts/backup/dummyModule.nix; module = ./modules/contracts/backup/dummyModule.nix;
optionRoot = [ "shb" "contracts" "backup" ]; optionRoot = [
"shb"
"contracts"
"backup"
];
}; };
"contracts/databasebackup" = { "contracts/databasebackup" = {
module = ./modules/contracts/databasebackup/dummyModule.nix; module = ./modules/contracts/databasebackup/dummyModule.nix;
optionRoot = [ "shb" "contracts" "databasebackup" ]; optionRoot = [
"shb"
"contracts"
"databasebackup"
];
}; };
"contracts/secret" = { "contracts/secret" = {
module = ./modules/contracts/secret/dummyModule.nix; module = ./modules/contracts/secret/dummyModule.nix;
optionRoot = [ "shb" "contracts" "secret" ]; optionRoot = [
"shb"
"contracts"
"secret"
];
}; };
"contracts/ssl" = { "contracts/ssl" = {
module = ./modules/contracts/ssl/dummyModule.nix; module = ./modules/contracts/ssl/dummyModule.nix;
optionRoot = [ "shb" "contracts" "ssl" ]; optionRoot = [
"shb"
"contracts"
"ssl"
];
}; };
}; };
}; };
# Documentation redirect generation tool - scans HTML files for anchor mappings # Documentation redirect generation tool - scans HTML files for anchor mappings
packages.generateRedirects = packages.generateRedirects =
let let
# Python patch to inject redirect collector # Python patch to inject redirect collector
pythonPatch = pkgs.writeText "nixos-render-docs-patch.py" '' pythonPatch = pkgs.writeText "nixos-render-docs-patch.py" ''
# Load redirect collector patch # Load redirect collector patch
try: try:
import sys, os import sys, os
sys.path.insert(0, os.path.dirname(__file__) + '/..') sys.path.insert(0, os.path.dirname(__file__) + '/..')
import missing_refs_collector import missing_refs_collector
except Exception as e: except Exception as e:
print(f"Warning: Failed to load redirect collector: {e}", file=sys.stderr) print(f"Warning: Failed to load redirect collector: {e}", file=sys.stderr)
'';
# Patched nixos-render-docs that collects redirects during HTML generation
nixos-render-docs-patched = pkgs.writeShellApplication {
name = "nixos-render-docs";
runtimeInputs = [ pkgs.nixos-render-docs ];
text = ''
TEMP_DIR=$(mktemp -d); trap 'rm -rf "$TEMP_DIR"' EXIT
cp -r ${pkgs.nixos-render-docs}/${pkgs.python3.sitePackages}/nixos_render_docs "$TEMP_DIR/"
chmod -R +w "$TEMP_DIR"
cp ${./docs/generate-redirects-nixos-render-docs.py} "$TEMP_DIR/missing_refs_collector.py"
echo '{}' > "$TEMP_DIR/empty_redirects.json"
cat ${pythonPatch} >> "$TEMP_DIR/nixos_render_docs/__init__.py"
ARGS=()
while [[ $# -gt 0 ]]; do
case $1 in
--redirects) ARGS+=("$1" "$TEMP_DIR/empty_redirects.json"); shift 2 ;;
*) ARGS+=("$1"); shift ;;
esac
done
export PYTHONPATH="$TEMP_DIR:''${PYTHONPATH:-}"
nixos-render-docs "''${ARGS[@]}"
''; '';
};
in # Patched nixos-render-docs that collects redirects during HTML generation
(self.packages.${system}.manualHtml.override { nixos-render-docs-patched = pkgs.writeShellApplication {
nixos-render-docs = nixos-render-docs-patched; name = "nixos-render-docs";
}).overrideAttrs (old: { runtimeInputs = [ pkgs.nixos-render-docs ];
installPhase = '' text = ''
${old.installPhase} TEMP_DIR=$(mktemp -d); trap 'rm -rf "$TEMP_DIR"' EXIT
ln -sf share/doc/selfhostblocks/redirects.json $out/redirects.json
''; cp -r ${pkgs.nixos-render-docs}/${pkgs.python3.sitePackages}/nixos_render_docs "$TEMP_DIR/"
}); chmod -R +w "$TEMP_DIR"
cp ${./docs/generate-redirects-nixos-render-docs.py} "$TEMP_DIR/missing_refs_collector.py"
echo '{}' > "$TEMP_DIR/empty_redirects.json"
cat ${pythonPatch} >> "$TEMP_DIR/nixos_render_docs/__init__.py"
ARGS=()
while [[ $# -gt 0 ]]; do
case $1 in
--redirects) ARGS+=("$1" "$TEMP_DIR/empty_redirects.json"); shift 2 ;;
*) ARGS+=("$1"); shift ;;
esac
done
export PYTHONPATH="$TEMP_DIR:''${PYTHONPATH:-}"
nixos-render-docs "''${ARGS[@]}"
'';
};
in
(self.packages.${system}.manualHtml.override {
nixos-render-docs = nixos-render-docs-patched;
}).overrideAttrs
(old: {
installPhase = ''
${old.installPhase}
ln -sf share/doc/selfhostblocks/redirects.json $out/redirects.json
'';
});
lib = lib =
(pkgs.callPackage ./lib {}) (pkgs.callPackage ./lib { })
// (pkgs.callPackage ./test/common.nix {}) // (pkgs.callPackage ./test/common.nix { })
// { // {
contracts = pkgs.callPackage ./modules/contracts {}; contracts = pkgs.callPackage ./modules/contracts { };
patches = shbPatches; patches = shbPatches;
inherit patchNixpkgs patchedNixpkgs pkgs; inherit patchNixpkgs patchedNixpkgs pkgs;
}; };
checks = checks =
let let
inherit (pkgs.lib) foldl foldlAttrs removeAttrs mergeAttrs optionalAttrs; inherit (pkgs.lib)
foldl
foldlAttrs
removeAttrs
mergeAttrs
optionalAttrs
;
importFiles = files: importFiles = files: map (m: pkgs.callPackage m { }) files;
map (m: pkgs.callPackage m {}) files;
mergeTests = foldl mergeAttrs {}; mergeTests = foldl mergeAttrs { };
flattenAttrs = root: attrset: foldlAttrs (acc: name: value: acc // { flattenAttrs =
"${root}_${name}" = value; root: attrset:
}) {} attrset; foldlAttrs (
acc: name: value:
acc
// {
"${root}_${name}" = value;
}
) { } attrset;
vm_test = name: path: flattenAttrs "vm_${name}" ( vm_test =
removeAttrs (pkgs.callPackage path {}) [ "override" "overrideDerivation" ] name: path:
); flattenAttrs "vm_${name}" (
in (optionalAttrs (system == "x86_64-linux") ({ removeAttrs (pkgs.callPackage path { }) [
modules = pkgs.lib.shb.check { "override"
inherit pkgs; "overrideDerivation"
tests = ]
mergeTests (importFiles [ );
in
(optionalAttrs (system == "x86_64-linux") (
{
modules = pkgs.lib.shb.check {
inherit pkgs;
tests = mergeTests (importFiles [
./test/modules/davfs.nix ./test/modules/davfs.nix
# TODO: Make this not use IFD # TODO: Make this not use IFD
./test/modules/lib.nix ./test/modules/lib.nix
]); ]);
}; };
# TODO: Make this not use IFD # TODO: Make this not use IFD
lib = nix-flake-tests.lib.check { lib = nix-flake-tests.lib.check {
inherit pkgs; inherit pkgs;
tests = pkgs.callPackage ./test/modules/lib.nix {}; tests = pkgs.callPackage ./test/modules/lib.nix { };
}; };
} }
// (vm_test "arr" ./test/services/arr.nix) // (vm_test "arr" ./test/services/arr.nix)
// (vm_test "audiobookshelf" ./test/services/audiobookshelf.nix) // (vm_test "audiobookshelf" ./test/services/audiobookshelf.nix)
// (vm_test "deluge" ./test/services/deluge.nix) // (vm_test "deluge" ./test/services/deluge.nix)
// (vm_test "forgejo" ./test/services/forgejo.nix) // (vm_test "forgejo" ./test/services/forgejo.nix)
// (vm_test "grocy" ./test/services/grocy.nix) // (vm_test "grocy" ./test/services/grocy.nix)
// (vm_test "hledger" ./test/services/hledger.nix) // (vm_test "hledger" ./test/services/hledger.nix)
// (vm_test "immich" ./test/services/immich.nix) // (vm_test "immich" ./test/services/immich.nix)
// (vm_test "homeassistant" ./test/services/home-assistant.nix) // (vm_test "homeassistant" ./test/services/home-assistant.nix)
// (vm_test "jellyfin" ./test/services/jellyfin.nix) // (vm_test "jellyfin" ./test/services/jellyfin.nix)
// (vm_test "karakeep" ./test/services/karakeep.nix) // (vm_test "karakeep" ./test/services/karakeep.nix)
// (vm_test "monitoring" ./test/services/monitoring.nix) // (vm_test "monitoring" ./test/services/monitoring.nix)
// (vm_test "nextcloud" ./test/services/nextcloud.nix) // (vm_test "nextcloud" ./test/services/nextcloud.nix)
// (vm_test "open-webui" ./test/services/open-webui.nix) // (vm_test "open-webui" ./test/services/open-webui.nix)
// (vm_test "pinchflat" ./test/services/pinchflat.nix) // (vm_test "pinchflat" ./test/services/pinchflat.nix)
// (vm_test "vaultwarden" ./test/services/vaultwarden.nix) // (vm_test "vaultwarden" ./test/services/vaultwarden.nix)
// (vm_test "authelia" ./test/blocks/authelia.nix) // (vm_test "authelia" ./test/blocks/authelia.nix)
// (vm_test "lldap" ./test/blocks/lldap.nix) // (vm_test "lldap" ./test/blocks/lldap.nix)
// (vm_test "lib" ./test/blocks/lib.nix) // (vm_test "lib" ./test/blocks/lib.nix)
// (vm_test "mitmdump" ./test/blocks/mitmdump.nix) // (vm_test "mitmdump" ./test/blocks/mitmdump.nix)
// (vm_test "postgresql" ./test/blocks/postgresql.nix) // (vm_test "postgresql" ./test/blocks/postgresql.nix)
// (vm_test "restic" ./test/blocks/restic.nix) // (vm_test "restic" ./test/blocks/restic.nix)
// (vm_test "ssl" ./test/blocks/ssl.nix) // (vm_test "ssl" ./test/blocks/ssl.nix)
// (vm_test "contracts-backup" ./test/contracts/backup.nix) // (vm_test "contracts-backup" ./test/contracts/backup.nix)
// (vm_test "contracts-databasebackup" ./test/contracts/databasebackup.nix) // (vm_test "contracts-databasebackup" ./test/contracts/databasebackup.nix)
// (vm_test "contracts-secret" ./test/contracts/secret.nix) // (vm_test "contracts-secret" ./test/contracts/secret.nix)
)); ));
# To see the traces, run: # To see the traces, run:
# nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip # nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip
packages.playwright = packages.playwright = pkgs.callPackage (
pkgs.callPackage ({ stdenvNoCC, makeWrapper, playwright }: stdenvNoCC.mkDerivation { {
stdenvNoCC,
makeWrapper,
playwright,
}:
stdenvNoCC.mkDerivation {
name = "playwright"; name = "playwright";
src = playwright; src = playwright;
@ -261,97 +329,104 @@
--set PLAYWRIGHT_BROWSERS_PATH ${pkgs.playwright-driver.browsers} \ --set PLAYWRIGHT_BROWSERS_PATH ${pkgs.playwright-driver.browsers} \
--set PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS true --set PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS true
''; '';
}) {}; }
) { };
# Run "nix run .#update-redirects" to regenerate docs/redirects.json # Run "nix run .#update-redirects" to regenerate docs/redirects.json
apps.update-redirects = { apps.update-redirects = {
type = "app"; type = "app";
program = "${pkgs.writeShellApplication { program = "${
name = "update-redirects"; pkgs.writeShellApplication {
runtimeInputs = [ pkgs.nix pkgs.jq ]; name = "update-redirects";
text = '' runtimeInputs = [
echo "=== SelfHostBlocks Redirects Updater ===" pkgs.nix
echo "Generating fresh ./docs/redirects.json..." pkgs.jq
];
nix build .#generateRedirects || { echo "Error: Failed to generate redirects" >&2; exit 1; } text = ''
[[ -f result/redirects.json ]] || { echo "Error: Generated redirects file not found" >&2; exit 1; } echo "=== SelfHostBlocks Redirects Updater ==="
echo "Generating fresh ./docs/redirects.json..."
echo "Generated $(jq 'keys | length' result/redirects.json) redirects"
nix build .#generateRedirects || { echo "Error: Failed to generate redirects" >&2; exit 1; }
[[ -f docs/redirects.json ]] && cp docs/redirects.json docs/redirects.json.backup && echo "Created backup" [[ -f result/redirects.json ]] || { echo "Error: Generated redirects file not found" >&2; exit 1; }
cp result/redirects.json docs/redirects.json
echo " Updated docs/redirects.json" echo "Generated $(jq 'keys | length' result/redirects.json) redirects"
echo "To verify: nix build .#manualHtml"
''; [[ -f docs/redirects.json ]] && cp docs/redirects.json docs/redirects.json.backup && echo "Created backup"
}}/bin/update-redirects"; cp result/redirects.json docs/redirects.json
echo " Updated docs/redirects.json"
echo "To verify: nix build .#manualHtml"
'';
}
}/bin/update-redirects";
}; };
} }
) // { )
herculesCI.ciSystems = [ "x86_64-linux" ]; // {
herculesCI.ciSystems = [ "x86_64-linux" ];
nixosModules.default = { nixosModules.default = {
imports = [ imports = [
# blocks # blocks
self.nixosModules.authelia self.nixosModules.authelia
self.nixosModules.davfs self.nixosModules.davfs
self.nixosModules.hardcodedsecret self.nixosModules.hardcodedsecret
self.nixosModules.lldap self.nixosModules.lldap
self.nixosModules.mitmdump self.nixosModules.mitmdump
self.nixosModules.monitoring self.nixosModules.monitoring
self.nixosModules.nginx self.nixosModules.nginx
self.nixosModules.postgresql self.nixosModules.postgresql
self.nixosModules.restic self.nixosModules.restic
self.nixosModules.ssl self.nixosModules.ssl
self.nixosModules.tinyproxy self.nixosModules.tinyproxy
self.nixosModules.vpn self.nixosModules.vpn
self.nixosModules.zfs self.nixosModules.zfs
# services # services
self.nixosModules.arr self.nixosModules.arr
self.nixosModules.audiobookshelf self.nixosModules.audiobookshelf
self.nixosModules.deluge self.nixosModules.deluge
self.nixosModules.forgejo self.nixosModules.forgejo
self.nixosModules.grocy self.nixosModules.grocy
self.nixosModules.hledger self.nixosModules.hledger
self.nixosModules.immich self.nixosModules.immich
self.nixosModules.home-assistant self.nixosModules.home-assistant
self.nixosModules.jellyfin self.nixosModules.jellyfin
self.nixosModules.karakeep self.nixosModules.karakeep
self.nixosModules.nextcloud-server self.nixosModules.nextcloud-server
self.nixosModules.open-webui self.nixosModules.open-webui
self.nixosModules.pinchflat self.nixosModules.pinchflat
self.nixosModules.vaultwarden self.nixosModules.vaultwarden
]; ];
};
nixosModules.authelia = modules/blocks/authelia.nix;
nixosModules.davfs = modules/blocks/davfs.nix;
nixosModules.hardcodedsecret = modules/blocks/hardcodedsecret.nix;
nixosModules.lldap = modules/blocks/lldap.nix;
nixosModules.mitmdump = modules/blocks/mitmdump.nix;
nixosModules.monitoring = modules/blocks/monitoring.nix;
nixosModules.nginx = modules/blocks/nginx.nix;
nixosModules.postgresql = modules/blocks/postgresql.nix;
nixosModules.restic = modules/blocks/restic.nix;
nixosModules.ssl = modules/blocks/ssl.nix;
nixosModules.sops = modules/blocks/sops.nix;
nixosModules.tinyproxy = modules/blocks/tinyproxy.nix;
nixosModules.vpn = modules/blocks/vpn.nix;
nixosModules.zfs = modules/blocks/zfs.nix;
nixosModules.arr = modules/services/arr.nix;
nixosModules.audiobookshelf = modules/services/audiobookshelf.nix;
nixosModules.deluge = modules/services/deluge.nix;
nixosModules.forgejo = modules/services/forgejo.nix;
nixosModules.grocy = modules/services/grocy.nix;
nixosModules.hledger = modules/services/hledger.nix;
nixosModules.immich = modules/services/immich.nix;
nixosModules.home-assistant = modules/services/home-assistant.nix;
nixosModules.jellyfin = modules/services/jellyfin.nix;
nixosModules.karakeep = modules/services/karakeep.nix;
nixosModules.nextcloud-server = modules/services/nextcloud-server.nix;
nixosModules.open-webui = modules/services/open-webui.nix;
nixosModules.pinchflat = modules/services/pinchflat.nix;
nixosModules.vaultwarden = modules/services/vaultwarden.nix;
}; };
nixosModules.authelia = modules/blocks/authelia.nix;
nixosModules.davfs = modules/blocks/davfs.nix;
nixosModules.hardcodedsecret = modules/blocks/hardcodedsecret.nix;
nixosModules.lldap = modules/blocks/lldap.nix;
nixosModules.mitmdump = modules/blocks/mitmdump.nix;
nixosModules.monitoring = modules/blocks/monitoring.nix;
nixosModules.nginx = modules/blocks/nginx.nix;
nixosModules.postgresql = modules/blocks/postgresql.nix;
nixosModules.restic = modules/blocks/restic.nix;
nixosModules.ssl = modules/blocks/ssl.nix;
nixosModules.sops = modules/blocks/sops.nix;
nixosModules.tinyproxy = modules/blocks/tinyproxy.nix;
nixosModules.vpn = modules/blocks/vpn.nix;
nixosModules.zfs = modules/blocks/zfs.nix;
nixosModules.arr = modules/services/arr.nix;
nixosModules.audiobookshelf = modules/services/audiobookshelf.nix;
nixosModules.deluge = modules/services/deluge.nix;
nixosModules.forgejo = modules/services/forgejo.nix;
nixosModules.grocy = modules/services/grocy.nix;
nixosModules.hledger = modules/services/hledger.nix;
nixosModules.immich = modules/services/immich.nix;
nixosModules.home-assistant = modules/services/home-assistant.nix;
nixosModules.jellyfin = modules/services/jellyfin.nix;
nixosModules.karakeep = modules/services/karakeep.nix;
nixosModules.nextcloud-server = modules/services/nextcloud-server.nix;
nixosModules.open-webui = modules/services/open-webui.nix;
nixosModules.pinchflat = modules/services/pinchflat.nix;
nixosModules.vaultwarden = modules/services/vaultwarden.nix;
};
} }

View file

@ -9,7 +9,14 @@ rec {
# - resultPath is the location the config file should have on the filesystem. # - resultPath is the location the config file should have on the filesystem.
# - generator is a function taking two arguments name and value and returning path in the nix # - generator is a function taking two arguments name and value and returning path in the nix
# nix store where the # nix store where the
replaceSecrets = { userConfig, resultPath, generator, user ? null, permissions ? "u=r,g=r,o=" }: replaceSecrets =
{
userConfig,
resultPath,
generator,
user ? null,
permissions ? "u=r,g=r,o=",
}:
let let
configWithTemplates = withReplacements userConfig; configWithTemplates = withReplacements userConfig;
@ -17,28 +24,47 @@ rec {
replacements = getReplacements userConfig; replacements = getReplacements userConfig;
in in
replaceSecretsScript { replaceSecretsScript {
file = nonSecretConfigFile; file = nonSecretConfigFile;
inherit resultPath replacements; inherit resultPath replacements;
inherit user permissions; inherit user permissions;
}; };
replaceSecretsFormatAdapter = format: format.generate; replaceSecretsFormatAdapter = format: format.generate;
replaceSecretsGeneratorAdapter = generator: name: value: pkgs.writeText "generator " (generator value); replaceSecretsGeneratorAdapter =
toEnvVar = replaceSecretsGeneratorAdapter (v: (lib.generators.toINIWithGlobalSection {} { globalSection = v; })); generator: name: value:
pkgs.writeText "generator " (generator value);
toEnvVar = replaceSecretsGeneratorAdapter (
v: (lib.generators.toINIWithGlobalSection { } { globalSection = v; })
);
template = file: newPath: replacements: replaceSecretsScript { template =
inherit file replacements; file: newPath: replacements:
resultPath = newPath; replaceSecretsScript {
}; inherit file replacements;
resultPath = newPath;
};
genReplacement = secret: genReplacement =
secret:
let let
t = { transform ? null, ... }: if isNull transform then x: x else transform; t =
{
transform ? null,
...
}:
if isNull transform then x: x else transform;
in in
lib.attrsets.nameValuePair (secretName secret.name) ((t secret) "$(cat ${toString secret.source})"); lib.attrsets.nameValuePair (secretName secret.name) ((t secret) "$(cat ${toString secret.source})");
replaceSecretsScript = { file, resultPath, replacements, user ? null, permissions ? "u=r,g=r,o=" }: replaceSecretsScript =
{
file,
resultPath,
replacements,
user ? null,
permissions ? "u=r,g=r,o=",
}:
let let
templatePath = resultPath + ".template"; templatePath = resultPath + ".template";
@ -47,15 +73,17 @@ rec {
# step. Otherwise, the $(cat ...) commands inside the sed # step. Otherwise, the $(cat ...) commands inside the sed
# replacements could fail but not fail individually but # replacements could fail but not fail individually but
# not fail the whole script. # not fail the whole script.
checkPermissions = concatMapStringsSep "\n" (pattern: "cat ${pattern.source} > /dev/null") replacements; checkPermissions = concatMapStringsSep "\n" (
pattern: "cat ${pattern.source} > /dev/null"
) replacements;
sedPatterns = concatMapStringsSep " " (pattern: "-e \"s|${pattern.name}|${pattern.value}|\"") (map genReplacement replacements); sedPatterns = concatMapStringsSep " " (pattern: "-e \"s|${pattern.name}|${pattern.value}|\"") (
map genReplacement replacements
);
sedCmd = if replacements == [] sedCmd = if replacements == [ ] then "cat" else "${pkgs.gnused}/bin/sed ${sedPatterns}";
then "cat"
else "${pkgs.gnused}/bin/sed ${sedPatterns}";
in in
'' ''
set -euo pipefail set -euo pipefail
${checkPermissions} ${checkPermissions}
@ -64,12 +92,14 @@ rec {
ln -fs ${file} ${templatePath} ln -fs ${file} ${templatePath}
rm -f ${resultPath} rm -f ${resultPath}
touch ${resultPath} touch ${resultPath}
'' + (lib.optionalString (user != null) '' ''
+ (lib.optionalString (user != null) ''
chown ${user} ${resultPath} chown ${user} ${resultPath}
'') + '' '')
+ ''
${sedCmd} ${templatePath} > ${resultPath} ${sedCmd} ${templatePath} > ${resultPath}
chmod ${permissions} ${resultPath} chmod ${permissions} ${resultPath}
''; '';
secretFileType = lib.types.submodule { secretFileType = lib.types.submodule {
options = { options = {
@ -83,35 +113,34 @@ rec {
description = "An optional function to transform the secret."; description = "An optional function to transform the secret.";
default = null; default = null;
example = lib.literalExpression '' example = lib.literalExpression ''
v: "prefix-$${v}-suffix" v: "prefix-$${v}-suffix"
''; '';
}; };
}; };
}; };
secretName = names: secretName =
"%SECRET${lib.strings.toUpper (lib.strings.concatMapStrings (s: "_" + s) names)}%"; names: "%SECRET${lib.strings.toUpper (lib.strings.concatMapStrings (s: "_" + s) names)}%";
withReplacements = attrs: withReplacements =
attrs:
let let
valueOrReplacement = name: value: valueOrReplacement =
if !(builtins.isAttrs value && value ? "source") name: value: if !(builtins.isAttrs value && value ? "source") then value else secretName name;
then value
else secretName name;
in in
mapAttrsRecursiveCond (v: ! v ? "source") valueOrReplacement attrs; mapAttrsRecursiveCond (v: !v ? "source") valueOrReplacement attrs;
getReplacements = attrs: getReplacements =
attrs:
let let
addNameField = name: value: addNameField =
if !(builtins.isAttrs value && value ? "source") name: value:
then value if !(builtins.isAttrs value && value ? "source") then value else value // { name = name; };
else value // { name = name; };
secretsWithName = mapAttrsRecursiveCond (v: ! v ? "source") addNameField attrs; secretsWithName = mapAttrsRecursiveCond (v: !v ? "source") addNameField attrs;
in in
collect (v: builtins.isAttrs v && v ? "source") secretsWithName; collect (v: builtins.isAttrs v && v ? "source") secretsWithName;
# Inspired lib.attrsets.mapAttrsRecursiveCond but also recurses on lists. # Inspired lib.attrsets.mapAttrsRecursiveCond but also recurses on lists.
mapAttrsRecursiveCond = mapAttrsRecursiveCond =
# A function, given the attribute set the recursion is currently at, determine if to recurse deeper into that attribute set. # A function, given the attribute set the recursion is currently at, determine if to recurse deeper into that attribute set.
@ -121,20 +150,23 @@ rec {
# Attribute set or list to recursively map over. # Attribute set or list to recursively map over.
set: set:
let let
recurse = path: val: recurse =
if builtins.isAttrs val && cond val path: val:
then lib.attrsets.mapAttrs (n: v: recurse (path ++ [n]) v) val if builtins.isAttrs val && cond val then
else if builtins.isList val && cond val lib.attrsets.mapAttrs (n: v: recurse (path ++ [ n ]) v) val
then lib.lists.imap0 (i: v: recurse (path ++ [(builtins.toString i)]) v) val else if builtins.isList val && cond val then
else f path val; lib.lists.imap0 (i: v: recurse (path ++ [ (builtins.toString i) ]) v) val
in recurse [] set; else
f path val;
in
recurse [ ] set;
# Like lib.attrsets.collect but also recurses on lists. # Like lib.attrsets.collect but also recurses on lists.
collect = collect =
# Given an attribute's value, determine if recursion should stop. # Given an attribute's value, determine if recursion should stop.
pred: pred:
# The attribute set to recursively collect. # The attribute set to recursively collect.
attrs: attrs:
if pred attrs then if pred attrs then
[ attrs ] [ attrs ]
else if builtins.isAttrs attrs then else if builtins.isAttrs attrs then
@ -142,111 +174,158 @@ rec {
else if builtins.isList attrs then else if builtins.isList attrs then
lib.lists.concatMap (collect pred) attrs lib.lists.concatMap (collect pred) attrs
else else
[]; [ ];
indent = i: str: lib.concatMapStringsSep "\n" (x: (lib.strings.replicate i " ") + x) (lib.splitString "\n" str); indent =
i: str:
lib.concatMapStringsSep "\n" (x: (lib.strings.replicate i " ") + x) (lib.splitString "\n" str);
# Generator for XML # Generator for XML
formatXML = { formatXML =
enclosingRoot ? null {
}: { enclosingRoot ? null,
type = with lib.types; let }:
valueType = nullOr (oneOf [ {
bool type =
int with lib.types;
float let
str valueType =
path nullOr (oneOf [
(attrsOf valueType) bool
(listOf valueType) int
]) // { float
description = "XML value"; str
}; path
in valueType; (attrsOf valueType)
(listOf valueType)
])
// {
description = "XML value";
};
in
valueType;
generate = name: value: pkgs.callPackage ({ runCommand, python3 }: runCommand "config" { generate =
value = builtins.toJSON ( name: value:
if enclosingRoot == null then pkgs.callPackage (
value { runCommand, python3 }:
else runCommand "config"
{ ${enclosingRoot} = value; }); {
passAsFile = [ "value" ]; value = builtins.toJSON (if enclosingRoot == null then value else { ${enclosingRoot} = value; });
} (pkgs.writers.writePython3 "dict2xml" { passAsFile = [ "value" ];
libraries = with python3.pkgs; [ python dict2xml ]; }
} '' (
import os pkgs.writers.writePython3 "dict2xml"
import json {
from dict2xml import dict2xml libraries = with python3.pkgs; [
python
dict2xml
];
}
''
import os
import json
from dict2xml import dict2xml
with open(os.environ["valuePath"]) as f: with open(os.environ["valuePath"]) as f:
content = json.loads(f.read()) content = json.loads(f.read())
if content is None: if content is None:
print("Could not parse env var valuePath as json") print("Could not parse env var valuePath as json")
os.exit(2) os.exit(2)
with open(os.environ["out"], "w") as out: with open(os.environ["out"], "w") as out:
out.write(dict2xml(content)) out.write(dict2xml(content))
'')) {}; ''
)
) { };
}; };
parseXML = xml: parseXML =
xml:
let let
xmlToJsonFile = pkgs.callPackage ({ runCommand, python3 }: runCommand "config" { xmlToJsonFile = pkgs.callPackage (
inherit xml; { runCommand, python3 }:
passAsFile = [ "xml" ]; runCommand "config"
} (pkgs.writers.writePython3 "xml2json" { {
libraries = with python3.pkgs; [ python ]; inherit xml;
} '' passAsFile = [ "xml" ];
import os }
import json (
from collections import ChainMap pkgs.writers.writePython3 "xml2json"
from xml.etree import ElementTree {
libraries = with python3.pkgs; [ python ];
}
def xml_to_dict_recursive(root): ''
all_descendants = list(root) import os
if len(all_descendants) == 0: import json
return {root.tag: root.text} from collections import ChainMap
else: from xml.etree import ElementTree
merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants))
return {root.tag: dict(merged_dict)}
with open(os.environ["xmlPath"]) as f: def xml_to_dict_recursive(root):
root = ElementTree.XML(f.read()) all_descendants = list(root)
xml = xml_to_dict_recursive(root) if len(all_descendants) == 0:
j = json.dumps(xml) return {root.tag: root.text}
else:
merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants))
return {root.tag: dict(merged_dict)}
with open(os.environ["out"], "w") as out:
out.write(j) with open(os.environ["xmlPath"]) as f:
'')) {}; root = ElementTree.XML(f.read())
xml = xml_to_dict_recursive(root)
j = json.dumps(xml)
with open(os.environ["out"], "w") as out:
out.write(j)
''
)
) { };
in in
builtins.fromJSON (builtins.readFile xmlToJsonFile); builtins.fromJSON (builtins.readFile xmlToJsonFile);
renameAttrName = attrset: from: to: renameAttrName =
(lib.attrsets.filterAttrs (name: v: name == from) attrset) // { attrset: from: to:
(lib.attrsets.filterAttrs (name: v: name == from) attrset)
// {
${to} = attrset.${from}; ${to} = attrset.${from};
}; };
# Taken from https://github.com/antifuchs/nix-flake-tests/blob/main/default.nix # Taken from https://github.com/antifuchs/nix-flake-tests/blob/main/default.nix
# with a nicer diff display function. # with a nicer diff display function.
check = { pkgs, tests }: check =
{ pkgs, tests }:
let let
formatValue = val: formatValue =
if (builtins.isList val || builtins.isAttrs val) then builtins.toJSON val val:
else builtins.toString val; if (builtins.isList val || builtins.isAttrs val) then
builtins.toJSON val
else
builtins.toString val;
resultToString = { name, expected, result }: resultToString =
builtins.readFile (pkgs.runCommand "nix-flake-tests-error" { {
expected = formatValue expected; name,
result = formatValue result; expected,
passAsFile = [ "expected" "result" ]; result,
} '' }:
echo "${name} failed (- expected, + result)" > $out builtins.readFile (
cp ''${expectedPath} ''${expectedPath}.json pkgs.runCommand "nix-flake-tests-error"
cp ''${resultPath} ''${resultPath}.json {
${pkgs.deepdiff}/bin/deep diff ''${expectedPath}.json ''${resultPath}.json >> $out expected = formatValue expected;
''); result = formatValue result;
passAsFile = [
"expected"
"result"
];
}
''
echo "${name} failed (- expected, + result)" > $out
cp ''${expectedPath} ''${expectedPath}.json
cp ''${resultPath} ''${resultPath}.json
${pkgs.deepdiff}/bin/deep diff ''${expectedPath}.json ''${resultPath}.json >> $out
''
);
results = pkgs.lib.runTests tests; results = pkgs.lib.runTests tests;
in in
@ -255,8 +334,14 @@ rec {
else else
pkgs.runCommand "nix-flake-tests-success" { } "echo > $out"; pkgs.runCommand "nix-flake-tests-success" { } "echo > $out";
genConfigOutOfBandSystemd =
genConfigOutOfBandSystemd = { config, configLocation, generator, user ? null, permissions ? "u=r,g=r,o=" }: {
config,
configLocation,
generator,
user ? null,
permissions ? "u=r,g=r,o=",
}:
{ {
loadCredentials = getLoadCredentials "source" config; loadCredentials = getLoadCredentials "source" config;
preStart = lib.mkBefore (replaceSecrets { preStart = lib.mkBefore (replaceSecrets {
@ -267,34 +352,35 @@ rec {
}); });
}; };
updateToLoadCredentials = sourceField: rootDir: attrs: updateToLoadCredentials =
sourceField: rootDir: attrs:
let let
hasPlaceholderField = v: isAttrs v && hasAttr sourceField v; hasPlaceholderField = v: isAttrs v && hasAttr sourceField v;
valueOrLoadCredential = path: value: valueOrLoadCredential =
if ! (hasPlaceholderField value) path: value:
then value if !(hasPlaceholderField value) then
else value // { ${sourceField} = rootDir + "/" + concatStringsSep "_" path; }; value
else
value // { ${sourceField} = rootDir + "/" + concatStringsSep "_" path; };
in in
mapAttrsRecursiveCond (v: ! (hasPlaceholderField v)) valueOrLoadCredential attrs; mapAttrsRecursiveCond (v: !(hasPlaceholderField v)) valueOrLoadCredential attrs;
getLoadCredentials = sourceField: attrs: getLoadCredentials =
sourceField: attrs:
let let
hasPlaceholderField = v: isAttrs v && hasAttr sourceField v; hasPlaceholderField = v: isAttrs v && hasAttr sourceField v;
addPathField = path: value: addPathField =
if ! (hasPlaceholderField value) path: value: if !(hasPlaceholderField value) then value else value // { inherit path; };
then value
else value // { inherit path; };
secretsWithPath = mapAttrsRecursiveCond (v: ! (hasPlaceholderField v)) addPathField attrs; secretsWithPath = mapAttrsRecursiveCond (v: !(hasPlaceholderField v)) addPathField attrs;
allSecrets = collect (v: hasPlaceholderField v) secretsWithPath; allSecrets = collect (v: hasPlaceholderField v) secretsWithPath;
genLoadCredentials = secret: genLoadCredentials = secret: "${concatStringsSep "_" secret.path}:${secret.${sourceField}}";
"${concatStringsSep "_" secret.path}:${secret.${sourceField}}";
in in
map genLoadCredentials allSecrets; map genLoadCredentials allSecrets;
anyNotNull = any (x: x != null); anyNotNull = any (x: x != null);
} }

View file

@ -1,10 +1,16 @@
{ config, options, pkgs, lib, ... }: {
config,
options,
pkgs,
lib,
...
}:
let let
cfg = config.shb.authelia; cfg = config.shb.authelia;
opt = options.shb.authelia; opt = options.shb.authelia;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
fqdnWithPort = if isNull cfg.port then fqdn else "${fqdn}:${toString cfg.port}"; fqdnWithPort = if isNull cfg.port then fqdn else "${fqdn}:${toString cfg.port}";
@ -148,25 +154,25 @@ in
extraOidcClaimsPolicies = lib.mkOption { extraOidcClaimsPolicies = lib.mkOption {
description = "Extra OIDC claims policies."; description = "Extra OIDC claims policies.";
type = lib.types.attrsOf lib.types.attrs; type = lib.types.attrsOf lib.types.attrs;
default = {}; default = { };
}; };
extraOidcScopes = lib.mkOption { extraOidcScopes = lib.mkOption {
description = "Extra OIDC scopes."; description = "Extra OIDC scopes.";
type = lib.types.attrsOf lib.types.attrs; type = lib.types.attrsOf lib.types.attrs;
default = {}; default = { };
}; };
extraOidcAuthorizationPolicies = lib.mkOption { extraOidcAuthorizationPolicies = lib.mkOption {
description = "Extra OIDC authorization policies."; description = "Extra OIDC authorization policies.";
type = lib.types.attrsOf lib.types.attrs; type = lib.types.attrsOf lib.types.attrs;
default = {}; default = { };
}; };
extraDefinitions = lib.mkOption { extraDefinitions = lib.mkOption {
description = "Extra definitions."; description = "Extra definitions.";
type = lib.types.attrsOf lib.types.attrs; type = lib.types.attrsOf lib.types.attrs;
default = {}; default = { };
}; };
oidcClients = lib.mkOption { oidcClients = lib.mkOption {
@ -178,79 +184,92 @@ in
client_secret.source = pkgs.writeText "dummy.secret" "dummy_client_secret"; client_secret.source = pkgs.writeText "dummy.secret" "dummy_client_secret";
public = false; public = false;
authorization_policy = "one_factor"; authorization_policy = "one_factor";
redirect_uris = []; redirect_uris = [ ];
} }
]; ];
type = lib.types.listOf (lib.types.submodule { type = lib.types.listOf (
freeformType = lib.types.attrsOf lib.types.anything; lib.types.submodule {
freeformType = lib.types.attrsOf lib.types.anything;
options = { options = {
client_id = lib.mkOption { client_id = lib.mkOption {
type = lib.types.str; type = lib.types.str;
description = "Unique identifier of the OIDC client."; description = "Unique identifier of the OIDC client.";
};
client_name = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Human readable description of the OIDC client.";
default = null;
};
client_secret = lib.mkOption {
type = lib.shb.secretFileType;
description = ''
File containing the shared secret with the OIDC client.
Generate with:
```
nix run nixpkgs#authelia -- \
crypto hash generate pbkdf2 \
--variant sha512 \
--random \
--random.length 72 \
--random.charset rfc3986
```
'';
};
public = lib.mkOption {
type = lib.types.bool;
description = "If the OIDC client is public or not.";
default = false;
apply = v: if v then "true" else "false";
};
authorization_policy = lib.mkOption {
type = lib.types.enum (
[
"one_factor"
"two_factor"
]
++ lib.attrNames cfg.extraOidcAuthorizationPolicies
);
description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor";
};
redirect_uris = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "List of uris that are allowed to be redirected to.";
};
scopes = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Scopes to ask for. See https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims";
example = [
"openid"
"profile"
"email"
"groups"
];
default = [ ];
};
claims_policy = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Claim policy.
Defaults to 'default' to provide a backwards compatible experience.
Read [this document](https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter) for more information.
'';
default = "default";
};
}; };
}
client_name = lib.mkOption { );
type = lib.types.nullOr lib.types.str;
description = "Human readable description of the OIDC client.";
default = null;
};
client_secret = lib.mkOption {
type = lib.shb.secretFileType;
description = ''
File containing the shared secret with the OIDC client.
Generate with:
```
nix run nixpkgs#authelia -- \
crypto hash generate pbkdf2 \
--variant sha512 \
--random \
--random.length 72 \
--random.charset rfc3986
```
'';
};
public = lib.mkOption {
type = lib.types.bool;
description = "If the OIDC client is public or not.";
default = false;
apply = v: if v then "true" else "false";
};
authorization_policy = lib.mkOption {
type = lib.types.enum ([ "one_factor" "two_factor" ] ++ lib.attrNames cfg.extraOidcAuthorizationPolicies);
description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor";
};
redirect_uris = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "List of uris that are allowed to be redirected to.";
};
scopes = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Scopes to ask for. See https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims";
example = [ "openid" "profile" "email" "groups" ];
default = [];
};
claims_policy = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Claim policy.
Defaults to 'default' to provide a backwards compatible experience.
Read [this document](https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter) for more information.
'';
default = "default";
};
};
});
}; };
smtp = lib.mkOption { smtp = lib.mkOption {
@ -263,50 +282,52 @@ in
default = "/tmp/authelia-notifications"; default = "/tmp/authelia-notifications";
type = lib.types.oneOf [ type = lib.types.oneOf [
lib.types.str lib.types.str
(lib.types.nullOr (lib.types.submodule { (lib.types.nullOr (
options = { lib.types.submodule {
from_address = lib.mkOption { options = {
type = lib.types.str; from_address = lib.mkOption {
description = "SMTP address from which the emails originate."; type = lib.types.str;
example = "authelia@mydomain.com"; description = "SMTP address from which the emails originate.";
}; example = "authelia@mydomain.com";
from_name = lib.mkOption { };
type = lib.types.str; from_name = lib.mkOption {
description = "SMTP name from which the emails originate."; type = lib.types.str;
default = "Authelia"; description = "SMTP name from which the emails originate.";
}; default = "Authelia";
host = lib.mkOption { };
type = lib.types.str; host = lib.mkOption {
description = "SMTP host to send the emails to."; type = lib.types.str;
}; description = "SMTP host to send the emails to.";
port = lib.mkOption { };
type = lib.types.port; port = lib.mkOption {
description = "SMTP port to send the emails to."; type = lib.types.port;
default = 25; description = "SMTP port to send the emails to.";
}; default = 25;
username = lib.mkOption { };
type = lib.types.str; username = lib.mkOption {
description = "Username to connect to the SMTP host."; type = lib.types.str;
}; description = "Username to connect to the SMTP host.";
password = lib.mkOption { };
description = "File containing the password to connect to the SMTP host."; password = lib.mkOption {
type = lib.types.submodule { description = "File containing the password to connect to the SMTP host.";
options = contracts.secret.mkRequester { type = lib.types.submodule {
mode = "0400"; options = contracts.secret.mkRequester {
owner = cfg.autheliaUser; mode = "0400";
restartUnits = [ "authelia-${fqdn}" ]; owner = cfg.autheliaUser;
restartUnits = [ "authelia-${fqdn}" ];
};
}; };
}; };
}; };
}; }
})) ))
]; ];
}; };
rules = lib.mkOption { rules = lib.mkOption {
type = lib.types.listOf lib.types.anything; type = lib.types.listOf lib.types.anything;
description = "Rule based clients"; description = "Rule based clients";
default = []; default = [ ];
}; };
mount = lib.mkOption { mount = lib.mkOption {
@ -324,8 +345,12 @@ in
``` ```
''; '';
readOnly = true; readOnly = true;
default = { path = "/var/lib/authelia-authelia.${cfg.domain}"; }; default = {
defaultText = { path = "/var/lib/authelia-authelia.example.com"; }; path = "/var/lib/authelia-authelia.${cfg.domain}";
};
defaultText = {
path = "/var/lib/authelia-authelia.example.com";
};
}; };
mountRedis = lib.mkOption { mountRedis = lib.mkOption {
@ -343,7 +368,9 @@ in
``` ```
''; '';
readOnly = true; readOnly = true;
default = { path = "/var/lib/redis-authelia"; }; default = {
path = "/var/lib/redis-authelia";
};
}; };
debug = lib.mkOption { debug = lib.mkOption {
@ -370,7 +397,7 @@ in
# Overriding the user name so we don't allow any weird characters anywhere. For example, postgres users do not accept the '.'. # Overriding the user name so we don't allow any weird characters anywhere. For example, postgres users do not accept the '.'.
users = { users = {
groups.${autheliaCfg.user} = {}; groups.${autheliaCfg.user} = { };
users.${autheliaCfg.user} = { users.${autheliaCfg.user} = {
isSystemUser = true; isSystemUser = true;
group = autheliaCfg.user; group = autheliaCfg.user;
@ -395,7 +422,9 @@ in
AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE = toString cfg.secrets.identityProvidersOIDCHMACSecret.result.path; AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE = toString cfg.secrets.identityProvidersOIDCHMACSecret.result.path;
AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE = toString cfg.secrets.identityProvidersOIDCIssuerPrivateKey.result.path; AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE = toString cfg.secrets.identityProvidersOIDCIssuerPrivateKey.result.path;
AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE = lib.mkIf (!(builtins.isString cfg.smtp)) (toString cfg.smtp.password.result.path); AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE = lib.mkIf (!(builtins.isString cfg.smtp)) (
toString cfg.smtp.password.result.path
);
}; };
settings = { settings = {
server.address = "tcp://127.0.0.1:${toString listenPort}"; server.address = "tcp://127.0.0.1:${toString listenPort}";
@ -428,10 +457,12 @@ in
# Inspired from https://www.authelia.com/configuration/session/introduction/ and https://www.authelia.com/configuration/session/redis # Inspired from https://www.authelia.com/configuration/session/introduction/ and https://www.authelia.com/configuration/session/redis
session = { session = {
name = "authelia_session"; name = "authelia_session";
cookies = [{ cookies = [
domain = if isNull cfg.port then cfg.domain else "${cfg.domain}:${toString cfg.port}"; {
authelia_url = "https://${cfg.subdomain}.${cfg.domain}"; domain = if isNull cfg.port then cfg.domain else "${cfg.domain}:${toString cfg.port}";
}]; authelia_url = "https://${cfg.subdomain}.${cfg.domain}";
}
];
same_site = "lax"; same_site = "lax";
expiration = "1h"; expiration = "1h";
inactivity = "5m"; inactivity = "5m";
@ -468,7 +499,11 @@ in
networks = [ networks = [
{ {
name = "internal"; name = "internal";
networks = [ "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/18" ]; networks = [
"10.0.0.0/8"
"172.16.0.0/12"
"192.168.0.0/18"
];
} }
]; ];
rules = [ rules = [
@ -479,7 +514,8 @@ in
"^/api/.*" "^/api/.*"
]; ];
} }
] ++ cfg.rules; ]
++ cfg.rules;
}; };
telemetry = { telemetry = {
metrics = { metrics = {
@ -489,17 +525,25 @@ in
}; };
log.level = if cfg.debug then "debug" else "info"; log.level = if cfg.debug then "debug" else "info";
} // { }
// {
identity_providers.oidc = { identity_providers.oidc = {
claims_policies = { claims_policies = {
# This default claim should go away at some point. # This default claim should go away at some point.
# https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter # https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter
default.id_token = [ "email" "preferred_username" "name" "groups" ]; default.id_token = [
} // cfg.extraOidcClaimsPolicies; "email"
"preferred_username"
"name"
"groups"
];
}
// cfg.extraOidcClaimsPolicies;
scopes = cfg.extraOidcScopes; scopes = cfg.extraOidcScopes;
authorization_policies = cfg.extraOidcAuthorizationPolicies; authorization_policies = cfg.extraOidcAuthorizationPolicies;
}; };
} // lib.optionalAttrs (cfg.extraDefinitions != {}) { }
// lib.optionalAttrs (cfg.extraDefinitions != { }) {
definitions = cfg.extraDefinitions; definitions = cfg.extraDefinitions;
}; };
@ -508,18 +552,22 @@ in
systemd.services."authelia-${fqdn}".preStart = systemd.services."authelia-${fqdn}".preStart =
let let
mkCfg = clients: mkCfg =
clients:
lib.shb.replaceSecrets { lib.shb.replaceSecrets {
userConfig = { userConfig = {
identity_providers.oidc.clients = clients; identity_providers.oidc.clients = clients;
}; };
resultPath = "/var/lib/authelia-${fqdn}/oidc_clients.yaml"; resultPath = "/var/lib/authelia-${fqdn}/oidc_clients.yaml";
generator = lib.shb.replaceSecretsGeneratorAdapter (lib.generators.toYAML {}); generator = lib.shb.replaceSecretsGeneratorAdapter (lib.generators.toYAML { });
}; };
in in
lib.mkBefore (mkCfg cfg.oidcClients + '' lib.mkBefore (
${pkgs.bash}/bin/bash -c '(while ! ${pkgs.netcat-openbsd}/bin/nc -z -v -w1 ${cfg.ldapHostname} ${toString cfg.ldapPort}; do echo "Waiting for port ${cfg.ldapHostname}:${toString cfg.ldapPort} to open..."; sleep 2; done); sleep 2' mkCfg cfg.oidcClients
''); + ''
${pkgs.bash}/bin/bash -c '(while ! ${pkgs.netcat-openbsd}/bin/nc -z -v -w1 ${cfg.ldapHostname} ${toString cfg.ldapPort}; do echo "Waiting for port ${cfg.ldapHostname}:${toString cfg.ldapPort} to open..."; sleep 2; done); sleep 2'
''
);
services.nginx.virtualHosts.${fqdn} = { services.nginx.virtualHosts.${fqdn} = {
forceSSL = !(isNull cfg.ssl); forceSSL = !(isNull cfg.ssl);
@ -554,7 +602,7 @@ in
error_page 403 = /error/403; error_page 403 = /error/403;
error_page 404 = /error/404; error_page 404 = /error/404;
} }
''; '';
locations."/api/verify".extraConfig = '' locations."/api/verify".extraConfig = ''
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
@ -567,7 +615,7 @@ in
proxy_set_header Host $http_x_forwarded_host; proxy_set_header Host $http_x_forwarded_host;
proxy_pass http://127.0.0.1:9091; proxy_pass http://127.0.0.1:9091;
''; '';
}; };
# I would like this to live outside of the Authelia module. # I would like this to live outside of the Authelia module.
@ -579,7 +627,8 @@ in
after = [ "authelia-${fqdn}.service" ]; after = [ "authelia-${fqdn}.service" ];
enabledAddons = [ config.shb.mitmdump.addons.logger ]; enabledAddons = [ config.shb.mitmdump.addons.logger ];
extraArgs = [ extraArgs = [
"--set" "verbose_pattern=/api" "--set"
"verbose_pattern=/api"
]; ];
}; };
@ -600,7 +649,7 @@ in
job_name = "authelia"; job_name = "authelia";
static_configs = [ static_configs = [
{ {
targets = ["127.0.0.1:9959"]; targets = [ "127.0.0.1:9959" ];
labels = { labels = {
"hostname" = config.networking.hostName; "hostname" = config.networking.hostName;
"domain" = cfg.domain; "domain" = cfg.domain;
@ -610,17 +659,20 @@ in
} }
]; ];
systemd.targets."authelia-${fqdn}" = let systemd.targets."authelia-${fqdn}" =
services = [ let
"authelia-${fqdn}.service" services = [
] ++ lib.optionals cfg.debug [ "authelia-${fqdn}.service"
config.shb.mitmdump.instances."authelia-${fqdn}".serviceName ]
]; ++ lib.optionals cfg.debug [
in { config.shb.mitmdump.instances."authelia-${fqdn}".serviceName
after = services; ];
requires = services; in
{
after = services;
requires = services;
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
}; };
}; };
} }

View file

@ -1,4 +1,10 @@
{ config, pkgs, lib, utils, ... }: {
config,
pkgs,
lib,
utils,
...
}:
let let
cfg = config.shb.borgbackup; cfg = config.shb.borgbackup;
@ -17,7 +23,9 @@ let
type = lib.types.path; type = lib.types.path;
}; };
encryption_passcommand = "cat /run/secrets/borgmatic/passphrases/${if isNull instance.secretName then name else instance.secretName}"; encryption_passcommand = "cat /run/secrets/borgmatic/passphrases/${
if isNull instance.secretName then name else instance.secretName
}";
borg_keys_directory = "/run/secrets/borgmatic/keys"; borg_keys_directory = "/run/secrets/borgmatic/keys";
sourceDirectories = lib.mkOption { sourceDirectories = lib.mkOption {
@ -28,7 +36,7 @@ let
excludePatterns = lib.mkOption { excludePatterns = lib.mkOption {
description = "Exclude patterns."; description = "Exclude patterns.";
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
default = []; default = [ ];
}; };
secretName = lib.mkOption { secretName = lib.mkOption {
@ -39,33 +47,40 @@ let
repositories = lib.mkOption { repositories = lib.mkOption {
description = "Repositories to back this instance to."; description = "Repositories to back this instance to.";
type = lib.types.nonEmptyListOf (lib.types.submodule { type = lib.types.nonEmptyListOf (
options = { lib.types.submodule {
path = lib.mkOption { options = {
type = lib.types.str; path = lib.mkOption {
description = "Repository location"; type = lib.types.str;
}; description = "Repository location";
};
timerConfig = lib.mkOption { timerConfig = lib.mkOption {
type = lib.types.attrsOf utils.systemdUtils.unitOptions.unitOption; type = lib.types.attrsOf utils.systemdUtils.unitOptions.unitOption;
default = { default = {
OnCalendar = "daily"; OnCalendar = "daily";
Persistent = true; Persistent = true;
}; };
description = ''When to run the backup. See {manpage}`systemd.timer(5)` for details.''; description = ''When to run the backup. See {manpage}`systemd.timer(5)` for details.'';
example = { example = {
OnCalendar = "00:05"; OnCalendar = "00:05";
RandomizedDelaySec = "5h"; RandomizedDelaySec = "5h";
Persistent = true; Persistent = true;
};
}; };
}; };
}; }
}); );
}; };
retention = lib.mkOption { retention = lib.mkOption {
description = "Retention options."; description = "Retention options.";
type = lib.types.attrsOf (lib.types.oneOf [ lib.types.int lib.types.nonEmptyStr ]); type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.int
lib.types.nonEmptyStr
]
);
default = { default = {
keep_within = "1d"; keep_within = "1d";
keep_hourly = 24; keep_hourly = 24;
@ -78,7 +93,7 @@ let
consistency = lib.mkOption { consistency = lib.mkOption {
description = "Consistency frequency options."; description = "Consistency frequency options.";
type = lib.types.attrsOf lib.types.nonEmptyStr; type = lib.types.attrsOf lib.types.nonEmptyStr;
default = {}; default = { };
example = { example = {
repository = "2 weeks"; repository = "2 weeks";
archives = "1 month"; archives = "1 month";
@ -87,19 +102,19 @@ let
hooks = lib.mkOption { hooks = lib.mkOption {
description = "Hooks to run before or after the backup."; description = "Hooks to run before or after the backup.";
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {
beforeBackup = lib.mkOption { beforeBackup = lib.mkOption {
description = "Hooks to run before backup"; description = "Hooks to run before backup";
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
default = []; default = [ ];
}; };
afterBackup = lib.mkOption { afterBackup = lib.mkOption {
description = "Hooks to run after backup"; description = "Hooks to run after backup";
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
default = []; default = [ ];
}; };
}; };
}; };
@ -113,7 +128,8 @@ let
}; };
}; };
repoSlugName = name: builtins.replaceStrings ["/" ":"] ["_" "_"] (lib.strings.removePrefix "/" name); repoSlugName =
name: builtins.replaceStrings [ "/" ":" ] [ "_" "_" ] (lib.strings.removePrefix "/" name);
in in
{ {
@ -132,10 +148,12 @@ in
instances = lib.mkOption { instances = lib.mkOption {
description = "Each instance is a backup setting"; description = "Each instance is a backup setting";
default = {}; default = { };
type = lib.types.attrsOf (lib.types.submodule { type = lib.types.attrsOf (
options = instanceOptions; lib.types.submodule {
}); options = instanceOptions;
}
);
}; };
borgServer = lib.mkOption { borgServer = lib.mkOption {
@ -148,7 +166,7 @@ in
# Taken from https://github.com/HubbeKing/restic-kubernetes/blob/73bfbdb0ba76939a4c52173fa2dbd52070710008/README.md?plain=1#L23 # Taken from https://github.com/HubbeKing/restic-kubernetes/blob/73bfbdb0ba76939a4c52173fa2dbd52070710008/README.md?plain=1#L23
performance = lib.mkOption { performance = lib.mkOption {
description = "Reduce performance impact of backup jobs."; description = "Reduce performance impact of backup jobs.";
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {
niceness = lib.mkOption { niceness = lib.mkOption {
@ -157,7 +175,11 @@ in
default = 15; default = 15;
}; };
ioSchedulingClass = lib.mkOption { ioSchedulingClass = lib.mkOption {
type = lib.types.enum [ "idle" "best-effort" "realtime" ]; type = lib.types.enum [
"idle"
"best-effort"
"realtime"
];
description = "ionice scheduling class, defaults to best-effort IO."; description = "ionice scheduling class, defaults to best-effort IO.";
default = "best-effort"; default = "best-effort";
}; };
@ -171,10 +193,11 @@ in
}; };
}; };
config = lib.mkIf (cfg.instances != {}) ( config = lib.mkIf (cfg.instances != { }) (
let let
enabledInstances = lib.attrsets.filterAttrs (k: i: i.enable) cfg.instances; enabledInstances = lib.attrsets.filterAttrs (k: i: i.enable) cfg.instances;
in lib.mkMerge [ in
lib.mkMerge [
# Secrets configuration # Secrets configuration
{ {
users.users = { users.users = {
@ -195,74 +218,101 @@ in
sops.secrets = sops.secrets =
let let
mkSopsSecret = name: instance: ( mkSopsSecret =
[ name: instance:
{ (
"${instance.backend}/passphrases/${if isNull instance.secretName then name else instance.secretName}" = { [
sopsFile = instance.keySopsFile; {
mode = "0440"; "${instance.backend}/passphrases/${
owner = cfg.user; if isNull instance.secretName then name else instance.secretName
group = cfg.group; }" =
}; {
} sopsFile = instance.keySopsFile;
] ++ lib.optional ((lib.filter ({path, ...}: lib.strings.hasPrefix "s3" path) instance.repositories) != []) { mode = "0440";
"${instance.backend}/environmentfiles/${if isNull instance.secretName then name else instance.secretName}" = { owner = cfg.user;
sopsFile = instance.keySopsFile; group = cfg.group;
mode = "0440"; };
owner = cfg.user; }
group = cfg.group; ]
}; ++
} ++ lib.optionals (instance.backend == "borgmatic") (lib.flatten (map ({path, ...}: { lib.optional
"${instance.backend}/keys/${repoSlugName path}" = { ((lib.filter ({ path, ... }: lib.strings.hasPrefix "s3" path) instance.repositories) != [ ])
key = "${instance.backend}/keys/${if isNull instance.secretName then name else instance.secretName}"; {
sopsFile = instance.keySopsFile; "${instance.backend}/environmentfiles/${
mode = "0440"; if isNull instance.secretName then name else instance.secretName
owner = cfg.user; }" =
group = cfg.group; {
}; sopsFile = instance.keySopsFile;
}) instance.repositories)) mode = "0440";
); owner = cfg.user;
group = cfg.group;
};
}
++ lib.optionals (instance.backend == "borgmatic") (
lib.flatten (
map (
{ path, ... }:
{
"${instance.backend}/keys/${repoSlugName path}" = {
key = "${instance.backend}/keys/${
if isNull instance.secretName then name else instance.secretName
}";
sopsFile = instance.keySopsFile;
mode = "0440";
owner = cfg.user;
group = cfg.group;
};
}
) instance.repositories
)
)
);
in in
lib.mkMerge (lib.flatten (lib.attrsets.mapAttrsToList mkSopsSecret enabledInstances)); lib.mkMerge (lib.flatten (lib.attrsets.mapAttrsToList mkSopsSecret enabledInstances));
} }
# Borgmatic configuration # Borgmatic configuration
{ {
systemd.timers.borgmatic = lib.mkIf (enabledInstances != {}) { systemd.timers.borgmatic = lib.mkIf (enabledInstances != { }) {
timerConfig = { timerConfig = {
OnCalendar = "hourly"; OnCalendar = "hourly";
}; };
}; };
systemd.services.borgmatic = lib.mkIf (enabledInstances != {}) { systemd.services.borgmatic = lib.mkIf (enabledInstances != { }) {
serviceConfig = { serviceConfig = {
User = cfg.user; User = cfg.user;
Group = cfg.group; Group = cfg.group;
ExecStartPre = [ "" ]; # Do not sleep before starting. ExecStartPre = [ "" ]; # Do not sleep before starting.
ExecStart = [ "" "${pkgs.borgmatic}/bin/borgmatic --verbosity -1 --syslog-verbosity 1" ]; ExecStart = [
""
"${pkgs.borgmatic}/bin/borgmatic --verbosity -1 --syslog-verbosity 1"
];
# For borgmatic, since we have only one service, we need to merge all environmentFile # For borgmatic, since we have only one service, we need to merge all environmentFile
# from all instances. # from all instances.
EnvironmentFile = lib.mapAttrsToList (name: value: value.environmentFile) enabledInstances; EnvironmentFile = lib.mapAttrsToList (name: value: value.environmentFile) enabledInstances;
}; };
}; };
systemd.packages = lib.mkIf (enabledInstances != {}) [ pkgs.borgmatic ]; systemd.packages = lib.mkIf (enabledInstances != { }) [ pkgs.borgmatic ];
environment.systemPackages = ( environment.systemPackages = (
lib.optionals cfg.borgServer [ pkgs.borgbackup ] lib.optionals cfg.borgServer [ pkgs.borgbackup ]
++ lib.optionals (enabledInstances != {}) [ pkgs.borgbackup pkgs.borgmatic ] ++ lib.optionals (enabledInstances != { }) [
pkgs.borgbackup
pkgs.borgmatic
]
); );
environment.etc = environment.etc =
let let
mkSettings = name: instance: { mkSettings = name: instance: {
"borgmatic.d/${name}.yaml".text = lib.generators.toYAML {} { "borgmatic.d/${name}.yaml".text = lib.generators.toYAML { } {
location = location = {
{ source_directories = instance.sourceDirectories;
source_directories = instance.sourceDirectories; repositories = map ({ path, ... }: path) instance.repositories;
repositories = map ({path, ...}: path) instance.repositories; }
} // (lib.attrsets.optionalAttrs (builtins.length instance.excludePatterns > 0) {
// (lib.attrsets.optionalAttrs (builtins.length instance.excludePatterns > 0) { excludePatterns = instance.excludePatterns;
excludePatterns = instance.excludePatterns; });
});
storage = { storage = {
encryption_passcommand = "cat ${instance.encryptionKeyFile}"; encryption_passcommand = "cat ${instance.encryptionKeyFile}";
@ -276,7 +326,7 @@ in
inherit name frequency; inherit name frequency;
}; };
in in
lib.attrsets.mapAttrsToList mkCheck instance.consistency; lib.attrsets.mapAttrsToList mkCheck instance.consistency;
# hooks = lib.mkMerge [ # hooks = lib.mkMerge [
# lib.optionalAttrs (builtins.length instance.hooks.beforeBackup > 0) { # lib.optionalAttrs (builtins.length instance.hooks.beforeBackup > 0) {
@ -289,7 +339,8 @@ in
}; };
}; };
in in
lib.mkMerge (lib.attrsets.mapAttrsToList mkSettings enabledInstances); lib.mkMerge (lib.attrsets.mapAttrsToList mkSettings enabledInstances);
} }
]); ]
);
} }

View file

@ -7,66 +7,68 @@ in
options.shb.davfs = { options.shb.davfs = {
mounts = lib.mkOption { mounts = lib.mkOption {
description = "List of mounts."; description = "List of mounts.";
default = []; default = [ ];
type = lib.types.listOf (lib.types.submodule { type = lib.types.listOf (
options = { lib.types.submodule {
remoteUrl = lib.mkOption { options = {
type = lib.types.str; remoteUrl = lib.mkOption {
description = "Webdav endpoint to connect to."; type = lib.types.str;
example = "https://my.domain.com/dav"; description = "Webdav endpoint to connect to.";
}; example = "https://my.domain.com/dav";
};
mountPoint = lib.mkOption { mountPoint = lib.mkOption {
type = lib.types.str; type = lib.types.str;
description = "Mount point to mount the webdav endpoint on."; description = "Mount point to mount the webdav endpoint on.";
example = "/mnt"; example = "/mnt";
}; };
username = lib.mkOption { username = lib.mkOption {
type = lib.types.str; type = lib.types.str;
description = "Username to connect to the webdav endpoint."; description = "Username to connect to the webdav endpoint.";
}; };
passwordFile = lib.mkOption { passwordFile = lib.mkOption {
type = lib.types.str; type = lib.types.str;
description = "Password to connect to the webdav endpoint."; description = "Password to connect to the webdav endpoint.";
}; };
uid = lib.mkOption { uid = lib.mkOption {
type = lib.types.nullOr lib.types.int; type = lib.types.nullOr lib.types.int;
description = "User owner of the mount point."; description = "User owner of the mount point.";
example = 1000; example = 1000;
default = null; default = null;
}; };
gid = lib.mkOption { gid = lib.mkOption {
type = lib.types.nullOr lib.types.int; type = lib.types.nullOr lib.types.int;
description = "Group owner of the mount point."; description = "Group owner of the mount point.";
example = 1000; example = 1000;
default = null; default = null;
}; };
fileMode = lib.mkOption { fileMode = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
description = "File creation mode"; description = "File creation mode";
example = "0664"; example = "0664";
default = null; default = null;
}; };
directoryMode = lib.mkOption { directoryMode = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
description = "Directory creation mode"; description = "Directory creation mode";
example = "2775"; example = "2775";
default = null; default = null;
}; };
automount = lib.mkOption { automount = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
description = "Create a systemd automount unit"; description = "Create a systemd automount unit";
default = true; default = true;
};
}; };
}; }
}); );
}; };
}; };
@ -93,6 +95,6 @@ in
mountConfig.TimeoutSec = 15; mountConfig.TimeoutSec = 15;
}; };
in in
map mkMountCfg cfg.mounts; map mkMountCfg cfg.mounts;
}; };
} }

View file

@ -1,84 +1,102 @@
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
...
}:
let let
cfg = config.shb.hardcodedsecret; cfg = config.shb.hardcodedsecret;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
inherit (lib) mapAttrs' mkOption nameValuePair; inherit (lib) mapAttrs' mkOption nameValuePair;
inherit (lib.types) attrsOf nullOr str submodule; inherit (lib.types)
attrsOf
nullOr
str
submodule
;
inherit (pkgs) writeText; inherit (pkgs) writeText;
in in
{ {
options.shb.hardcodedsecret = mkOption { options.shb.hardcodedsecret = mkOption {
default = {}; default = { };
description = '' description = ''
Hardcoded secrets. These should only be used in tests. Hardcoded secrets. These should only be used in tests.
''; '';
example = lib.literalExpression '' example = lib.literalExpression ''
{ {
mySecret = { mySecret = {
request = { request = {
user = "me"; user = "me";
mode = "0400"; mode = "0400";
restartUnits = [ "myservice.service" ]; restartUnits = [ "myservice.service" ];
};
settings.content = "My Secret";
}; };
settings.content = "My Secret"; }
};
}
''; '';
type = attrsOf (submodule ({ name, ... }: { type = attrsOf (
options = contracts.secret.mkProvider { submodule (
settings = mkOption { { name, ... }:
description = '' {
Settings specific to the hardcoded secret module. options = contracts.secret.mkProvider {
settings = mkOption {
description = ''
Settings specific to the hardcoded secret module.
Give either `content` or `source`. Give either `content` or `source`.
''; '';
type = submodule { type = submodule {
options = { options = {
content = mkOption { content = mkOption {
type = nullOr str; type = nullOr str;
description = '' description = ''
Content of the secret as a string. Content of the secret as a string.
This will be stored in the nix store and should only be used for testing or maybe in dev. This will be stored in the nix store and should only be used for testing or maybe in dev.
''; '';
default = null; default = null;
}; };
source = mkOption { source = mkOption {
type = nullOr str; type = nullOr str;
description = '' description = ''
Source of the content of the secret as a path in the nix store. Source of the content of the secret as a path in the nix store.
''; '';
default = null; default = null;
};
};
}; };
}; };
};
};
resultCfg = { resultCfg = {
path = "/run/hardcodedsecrets/hardcodedsecret_${name}"; path = "/run/hardcodedsecrets/hardcodedsecret_${name}";
}; };
}; };
})); }
)
);
}; };
config = { config = {
system.activationScripts = mapAttrs' (n: cfg': system.activationScripts = mapAttrs' (
n: cfg':
let let
source = if cfg'.settings.source != null source =
then cfg'.settings.source if cfg'.settings.source != null then
else writeText "hardcodedsecret_${n}_content" cfg'.settings.content; cfg'.settings.source
else
writeText "hardcodedsecret_${n}_content" cfg'.settings.content;
in in
nameValuePair "hardcodedsecret_${n}" '' nameValuePair "hardcodedsecret_${n}" ''
mkdir -p "$(dirname "${cfg'.result.path}")" mkdir -p "$(dirname "${cfg'.result.path}")"
touch "${cfg'.result.path}" touch "${cfg'.result.path}"
chmod ${cfg'.request.mode} "${cfg'.result.path}" chmod ${cfg'.request.mode} "${cfg'.result.path}"
chown ${cfg'.request.owner}:${cfg'.request.group} "${cfg'.result.path}" chown ${cfg'.request.owner}:${cfg'.request.group} "${cfg'.result.path}"
cp ${source} "${cfg'.result.path}" cp ${source} "${cfg'.result.path}"
'' ''
) cfg; ) cfg;
}; };
} }

View file

@ -1,9 +1,14 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.lldap; cfg = config.shb.lldap;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
@ -145,7 +150,9 @@ in
``` ```
''; '';
readOnly = true; readOnly = true;
default = { path = "/var/lib/lldap"; }; default = {
path = "/var/lib/lldap";
};
}; };
backup = lib.mkOption { backup = lib.mkOption {
@ -327,7 +334,7 @@ in
default = true; default = true;
}; };
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
services.nginx = { services.nginx = {
@ -340,10 +347,16 @@ in
locations."/" = { locations."/" = {
extraConfig = '' extraConfig = ''
proxy_set_header Host $host; proxy_set_header Host $host;
'' + (if isNull cfg.restrictAccessIPRange then "" else '' ''
allow ${cfg.restrictAccessIPRange}; + (
deny all; if isNull cfg.restrictAccessIPRange then
''); ""
else
''
allow ${cfg.restrictAccessIPRange};
deny all;
''
);
proxyPass = "http://${toString config.services.lldap.settings.http_host}:${toString config.shb.lldap.webUIListenPort}/"; proxyPass = "http://${toString config.services.lldap.settings.http_host}:${toString config.shb.lldap.webUIListenPort}/";
}; };
}; };
@ -354,7 +367,7 @@ in
group = "lldap"; group = "lldap";
isSystemUser = true; isSystemUser = true;
}; };
users.groups.lldap = {}; users.groups.lldap = { };
services.lldap = { services.lldap = {
enable = true; enable = true;
@ -382,9 +395,13 @@ in
}; };
inherit (cfg) ensureGroups ensureUserFields ensureGroupFields; inherit (cfg) ensureGroups ensureUserFields ensureGroupFields;
ensureUsers = lib.mapAttrs (n: v: (lib.removeAttrs v [ "password" ]) // { ensureUsers = lib.mapAttrs (
"password_file" = toString v.password.result.path; n: v:
}) cfg.ensureUsers; (lib.removeAttrs v [ "password" ])
// {
"password_file" = toString v.password.result.path;
}
) cfg.ensureUsers;
}; };
shb.mitmdump.instances."lldap-web" = lib.mkIf cfg.debug { shb.mitmdump.instances."lldap-web" = lib.mkIf cfg.debug {
@ -393,7 +410,8 @@ in
after = [ "lldap.service" ]; after = [ "lldap.service" ];
enabledAddons = [ config.shb.mitmdump.addons.logger ]; enabledAddons = [ config.shb.mitmdump.addons.logger ];
extraArgs = [ extraArgs = [
"--set" "verbose_pattern=/api" "--set"
"verbose_pattern=/api"
]; ];
}; };
}; };

View file

@ -1,143 +1,163 @@
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
...
}:
let let
inherit (lib) mapAttrs' mkOption nameValuePair types; inherit (lib)
inherit (types) attrsOf listOf port submodule str; mapAttrs'
mkOption
nameValuePair
types
;
inherit (types)
attrsOf
listOf
port
submodule
str
;
cfg = config.shb.mitmdump; cfg = config.shb.mitmdump;
mitmdumpScript = pkgs.writers.writePython3Bin "mitmdump" mitmdumpScript =
{ pkgs.writers.writePython3Bin "mitmdump"
libraries = let {
p = pkgs.python3Packages; libraries =
in [ let
p.systemd p = pkgs.python3Packages;
p.mitmproxy in
];
flakeIgnore = [ "E501" ];
}
''
from systemd.daemon import notify
import argparse
import logging
import os
import subprocess
import socket
import sys
import time
logging.basicConfig(level=logging.INFO, format='%(message)s')
def wait_for_port(host, port, timeout=10):
deadline = time.time() + timeout
while time.time() < deadline:
try:
with socket.create_connection((host, port), timeout=0.5):
return True
except Exception:
time.sleep(0.1)
return False
def flatten(xss):
return [x for xs in xss for x in xs]
parser = argparse.ArgumentParser()
parser.add_argument("--listen_host", default="127.0.0.1", help="Host mitmdump will listen on")
parser.add_argument("--listen_port", required=True, help="Port mitmdump will listen on")
parser.add_argument("--upstream_host", default="http://127.0.0.1", help="Host mitmdump will connect to for upstream. Example: http://127.0.0.1 or https://otherhost")
parser.add_argument("--upstream_port", required=True, help="Port mitmdump will connect to for upstream")
args, rest = parser.parse_known_args()
MITMDUMP_BIN = os.environ.get("MITMDUMP_BIN")
if MITMDUMP_BIN is None:
raise Exception("MITMDUMP_BIN env var must be set to the path of the mitmdump binary")
logging.info(f"Waiting for upstream address '{args.upstream_host}:{args.upstream_port}' to be up.")
wait_for_port(args.upstream_host, args.upstream_port, timeout=10)
logging.info(f"Upstream address '{args.upstream_host}:{args.upstream_port}' is up.")
proc = subprocess.Popen(
[ [
MITMDUMP_BIN, p.systemd
"--listen-host", args.listen_host, p.mitmproxy
"-p", args.listen_port, ];
"--mode", f"reverse:{args.upstream_host}:{args.upstream_port}", flakeIgnore = [ "E501" ];
] + rest, }
stdout=sys.stdout, ''
stderr=sys.stderr, from systemd.daemon import notify
) import argparse
import logging
logging.info(f"Waiting for mitmdump instance to start on port {args.listen_port}.") import os
if wait_for_port("127.0.0.1", args.listen_port, timeout=10): import subprocess
logging.info(f"Mitmdump is started on port {args.listen_port}.") import socket
notify("READY=1") import sys
else: import time
proc.terminate()
exit(1)
proc.wait()
'';
logger = toString (pkgs.writers.writeText "loggerAddon.py"
''
import logging
from collections.abc import Sequence
from mitmproxy import ctx, http
import re
logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO, format='%(message)s')
class RegexLogger: def wait_for_port(host, port, timeout=10):
def __init__(self): deadline = time.time() + timeout
self.verbose_patterns = None while time.time() < deadline:
try:
def load(self, loader): with socket.create_connection((host, port), timeout=0.5):
loader.add_option( return True
name="verbose_pattern", except Exception:
typespec=Sequence[str], time.sleep(0.1)
default=[], return False
help="Regex patterns for verbose logging",
)
def response(self, flow: http.HTTPFlow):
if self.verbose_patterns is None:
self.verbose_patterns = [re.compile(p) for p in ctx.options.verbose_pattern]
matched = any(p.search(flow.request.path) for p in self.verbose_patterns)
if matched:
logger.info(format_flow(flow))
def format_flow(flow: http.HTTPFlow) -> str: def flatten(xss):
return ( return [x for xs in xss for x in xs]
"\n"
"RequestHeaders:\n"
f" {format_headers(flow.request.headers.items())}\n" parser = argparse.ArgumentParser()
f"RequestBody: {flow.request.get_text()}\n" parser.add_argument("--listen_host", default="127.0.0.1", help="Host mitmdump will listen on")
f"Status: {flow.response.data.status_code}\n" parser.add_argument("--listen_port", required=True, help="Port mitmdump will listen on")
"ResponseHeaders:\n" parser.add_argument("--upstream_host", default="http://127.0.0.1", help="Host mitmdump will connect to for upstream. Example: http://127.0.0.1 or https://otherhost")
f" {format_headers(flow.response.headers.items())}\n" parser.add_argument("--upstream_port", required=True, help="Port mitmdump will connect to for upstream")
f"ResponseBody: {flow.response.get_text()}\n" args, rest = parser.parse_known_args()
MITMDUMP_BIN = os.environ.get("MITMDUMP_BIN")
if MITMDUMP_BIN is None:
raise Exception("MITMDUMP_BIN env var must be set to the path of the mitmdump binary")
logging.info(f"Waiting for upstream address '{args.upstream_host}:{args.upstream_port}' to be up.")
wait_for_port(args.upstream_host, args.upstream_port, timeout=10)
logging.info(f"Upstream address '{args.upstream_host}:{args.upstream_port}' is up.")
proc = subprocess.Popen(
[
MITMDUMP_BIN,
"--listen-host", args.listen_host,
"-p", args.listen_port,
"--mode", f"reverse:{args.upstream_host}:{args.upstream_port}",
] + rest,
stdout=sys.stdout,
stderr=sys.stderr,
) )
logging.info(f"Waiting for mitmdump instance to start on port {args.listen_port}.")
if wait_for_port("127.0.0.1", args.listen_port, timeout=10):
logging.info(f"Mitmdump is started on port {args.listen_port}.")
notify("READY=1")
else:
proc.terminate()
exit(1)
def format_headers(headers) -> str: proc.wait()
return "\n ".join(k + ": " + v for k, v in headers) '';
logger = toString (
pkgs.writers.writeText "loggerAddon.py" ''
import logging
from collections.abc import Sequence
from mitmproxy import ctx, http
import re
addons = [RegexLogger()] logger = logging.getLogger(__name__)
'');
class RegexLogger:
def __init__(self):
self.verbose_patterns = None
def load(self, loader):
loader.add_option(
name="verbose_pattern",
typespec=Sequence[str],
default=[],
help="Regex patterns for verbose logging",
)
def response(self, flow: http.HTTPFlow):
if self.verbose_patterns is None:
self.verbose_patterns = [re.compile(p) for p in ctx.options.verbose_pattern]
matched = any(p.search(flow.request.path) for p in self.verbose_patterns)
if matched:
logger.info(format_flow(flow))
def format_flow(flow: http.HTTPFlow) -> str:
return (
"\n"
"RequestHeaders:\n"
f" {format_headers(flow.request.headers.items())}\n"
f"RequestBody: {flow.request.get_text()}\n"
f"Status: {flow.response.data.status_code}\n"
"ResponseHeaders:\n"
f" {format_headers(flow.response.headers.items())}\n"
f"ResponseBody: {flow.response.get_text()}\n"
)
def format_headers(headers) -> str:
return "\n ".join(k + ": " + v for k, v in headers)
addons = [RegexLogger()]
''
);
in in
{ {
options.shb.mitmdump = { options.shb.mitmdump = {
addons = mkOption { addons = mkOption {
type = attrsOf str; type = attrsOf str;
default = []; default = [ ];
description = '' description = ''
Addons available to the be added to the mitmdump instance. Addons available to the be added to the mitmdump instance.
@ -146,120 +166,129 @@ in
}; };
instances = mkOption { instances = mkOption {
default = {}; default = { };
description = "Mitmdump instance."; description = "Mitmdump instance.";
type = attrsOf (submodule ({ name, ... }: { type = attrsOf (
options = { submodule (
package = lib.mkPackageOption pkgs "mitmproxy" {}; { name, ... }:
{
options = {
package = lib.mkPackageOption pkgs "mitmproxy" { };
serviceName = mkOption { serviceName = mkOption {
type = str; type = str;
description = '' description = ''
Name of the mitmdump system service. Name of the mitmdump system service.
''; '';
default = "mitmdump-${name}.service"; default = "mitmdump-${name}.service";
readOnly = true; readOnly = true;
}; };
listenHost = mkOption { listenHost = mkOption {
type = str; type = str;
default = "127.0.0.1"; default = "127.0.0.1";
description = '' description = ''
Host the mitmdump instance will connect on. Host the mitmdump instance will connect on.
''; '';
}; };
listenPort = mkOption { listenPort = mkOption {
type = port; type = port;
description = '' description = ''
Port the mitmdump instance will listen on. Port the mitmdump instance will listen on.
The upstream port from the client's perspective. The upstream port from the client's perspective.
''; '';
}; };
upstreamHost = mkOption { upstreamHost = mkOption {
type = str; type = str;
default = "http://127.0.0.1"; default = "http://127.0.0.1";
description = '' description = ''
Host the mitmdump instance will connect to. Host the mitmdump instance will connect to.
If only an IP or domain is provided, If only an IP or domain is provided,
mitmdump will default to connect using HTTPS. mitmdump will default to connect using HTTPS.
If this is not wanted, prefix the IP or domain with the 'http://' protocol. If this is not wanted, prefix the IP or domain with the 'http://' protocol.
''; '';
}; };
upstreamPort = mkOption { upstreamPort = mkOption {
type = port; type = port;
description = '' description = ''
Port the mitmdump instance will connect to. Port the mitmdump instance will connect to.
The port the server is listening on. The port the server is listening on.
''; '';
}; };
after = mkOption { after = mkOption {
type = listOf str; type = listOf str;
default = []; default = [ ];
description = '' description = ''
Systemd services that must be started before this mitmdump proxy instance. Systemd services that must be started before this mitmdump proxy instance.
You are guaranteed the mitmdump is listening on the `listenPort` You are guaranteed the mitmdump is listening on the `listenPort`
when its systemd service has started. when its systemd service has started.
''; '';
}; };
enabledAddons = mkOption { enabledAddons = mkOption {
type = listOf str; type = listOf str;
default = []; default = [ ];
description = '' description = ''
Addons to enable on this mitmdump instance. Addons to enable on this mitmdump instance.
''; '';
example = lib.literalExpression ''[ config.shb.mitmdump.addons.logger ]''; example = lib.literalExpression ''[ config.shb.mitmdump.addons.logger ]'';
}; };
extraArgs = mkOption { extraArgs = mkOption {
type = listOf str; type = listOf str;
default = []; default = [ ];
description = '' description = ''
Extra arguments to pass to the mitmdump instance. Extra arguments to pass to the mitmdump instance.
See upstream [manual](https://docs.mitmproxy.org/stable/concepts/options/#flow_detail) for all possible options. See upstream [manual](https://docs.mitmproxy.org/stable/concepts/options/#flow_detail) for all possible options.
''; '';
example = lib.literalExpression ''[ "--set" "verbose_pattern=/api" ]''; example = lib.literalExpression ''[ "--set" "verbose_pattern=/api" ]'';
}; };
}; };
})); }
)
);
}; };
}; };
config = { config = {
systemd.services = mapAttrs' (name: cfg': nameValuePair "mitmdump-${name}" { systemd.services = mapAttrs' (
environment = { name: cfg':
"HOME" = "/var/lib/private/mitmdump-${name}"; nameValuePair "mitmdump-${name}" {
"MITMDUMP_BIN" = "${cfg'.package}/bin/mitmdump"; environment = {
}; "HOME" = "/var/lib/private/mitmdump-${name}";
serviceConfig = { "MITMDUMP_BIN" = "${cfg'.package}/bin/mitmdump";
Type = "notify"; };
Restart = "on-failure"; serviceConfig = {
StandardOutput = "journal"; Type = "notify";
StandardError = "journal"; Restart = "on-failure";
StandardOutput = "journal";
StandardError = "journal";
DynamicUser = true; DynamicUser = true;
WorkingDirectory = "/var/lib/mitmdump-${name}"; WorkingDirectory = "/var/lib/mitmdump-${name}";
StateDirectory = "mitmdump-${name}"; StateDirectory = "mitmdump-${name}";
ExecStart = let ExecStart =
addons = lib.concatMapStringsSep " " (addon: "-s ${addon}") cfg'.enabledAddons; let
extraArgs = lib.concatStringsSep " " cfg'.extraArgs; addons = lib.concatMapStringsSep " " (addon: "-s ${addon}") cfg'.enabledAddons;
in extraArgs = lib.concatStringsSep " " cfg'.extraArgs;
"${lib.getExe mitmdumpScript} --listen_host ${cfg'.listenHost} --listen_port ${toString cfg'.listenPort} --upstream_host ${cfg'.upstreamHost} --upstream_port ${toString cfg'.upstreamPort} ${addons} ${extraArgs}"; in
}; "${lib.getExe mitmdumpScript} --listen_host ${cfg'.listenHost} --listen_port ${toString cfg'.listenPort} --upstream_host ${cfg'.upstreamHost} --upstream_port ${toString cfg'.upstreamPort} ${addons} ${extraArgs}";
requires = cfg'.after; };
after = cfg'.after; requires = cfg'.after;
wantedBy = [ "multi-user.target" ]; after = cfg'.after;
}) cfg.instances; wantedBy = [ "multi-user.target" ];
}
) cfg.instances;
shb.mitmdump.addons = { shb.mitmdump.addons = {
inherit logger; inherit logger;

View file

@ -1,9 +1,14 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.monitoring; cfg = config.shb.monitoring;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
@ -53,7 +58,10 @@ in
}; };
lokiMajorVersion = lib.mkOption { lokiMajorVersion = lib.mkOption {
type = lib.types.enum [ 2 3 ]; type = lib.types.enum [
2
3
];
description = '' description = ''
Switching from version 2 to 3 requires manual intervention Switching from version 2 to 3 requires manual intervention
https://grafana.com/docs/loki/latest/setup/upgrade/#main--unreleased. So this let's the user https://grafana.com/docs/loki/latest/setup/upgrade/#main--unreleased. So this let's the user
@ -84,7 +92,7 @@ in
contactPoints = lib.mkOption { contactPoints = lib.mkOption {
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
description = "List of email addresses to send alerts to"; description = "List of email addresses to send alerts to";
default = []; default = [ ];
}; };
adminPassword = lib.mkOption { adminPassword = lib.mkOption {
@ -114,37 +122,39 @@ in
smtp = lib.mkOption { smtp = lib.mkOption {
description = "SMTP options."; description = "SMTP options.";
default = null; default = null;
type = lib.types.nullOr (lib.types.submodule { type = lib.types.nullOr (
options = { lib.types.submodule {
from_address = lib.mkOption { options = {
type = lib.types.str; from_address = lib.mkOption {
description = "SMTP address from which the emails originate."; type = lib.types.str;
example = "vaultwarden@mydomain.com"; description = "SMTP address from which the emails originate.";
example = "vaultwarden@mydomain.com";
};
from_name = lib.mkOption {
type = lib.types.str;
description = "SMTP name from which the emails originate.";
default = "Grafana";
};
host = lib.mkOption {
type = lib.types.str;
description = "SMTP host to send the emails to.";
};
port = lib.mkOption {
type = lib.types.port;
description = "SMTP port to send the emails to.";
default = 25;
};
username = lib.mkOption {
type = lib.types.str;
description = "Username to connect to the SMTP host.";
};
passwordFile = lib.mkOption {
type = lib.types.str;
description = "File containing the password to connect to the SMTP host.";
};
}; };
from_name = lib.mkOption { }
type = lib.types.str; );
description = "SMTP name from which the emails originate.";
default = "Grafana";
};
host = lib.mkOption {
type = lib.types.str;
description = "SMTP host to send the emails to.";
};
port = lib.mkOption {
type = lib.types.port;
description = "SMTP port to send the emails to.";
default = 25;
};
username = lib.mkOption {
type = lib.types.str;
description = "Username to connect to the SMTP host.";
};
passwordFile = lib.mkOption {
type = lib.types.str;
description = "File containing the password to connect to the SMTP host.";
};
};
});
}; };
}; };
@ -204,12 +214,14 @@ in
services.grafana.provision = { services.grafana.provision = {
dashboards.settings = lib.mkIf cfg.provisionDashboards { dashboards.settings = lib.mkIf cfg.provisionDashboards {
apiVersion = 1; apiVersion = 1;
providers = [{ providers = [
folder = "Self Host Blocks"; {
options.path = ./monitoring/dashboards; folder = "Self Host Blocks";
allowUiUpdates = true; options.path = ./monitoring/dashboards;
disableDeletion = true; allowUiUpdates = true;
}]; disableDeletion = true;
}
];
}; };
datasources.settings = { datasources.settings = {
apiVersion = 1; apiVersion = 1;
@ -245,26 +257,35 @@ in
}; };
alerting.contactPoints.settings = { alerting.contactPoints.settings = {
apiVersion = 1; apiVersion = 1;
contactPoints = [{ contactPoints = [
inherit (cfg) orgId; {
name = "grafana-default-email"; inherit (cfg) orgId;
receivers = lib.optionals ((builtins.length cfg.contactPoints) > 0) [{ name = "grafana-default-email";
uid = "sysadmin"; receivers = lib.optionals ((builtins.length cfg.contactPoints) > 0) [
type = "email"; {
settings.addresses = lib.concatStringsSep ";" cfg.contactPoints; uid = "sysadmin";
}]; type = "email";
}]; settings.addresses = lib.concatStringsSep ";" cfg.contactPoints;
}
];
}
];
}; };
alerting.policies.settings = { alerting.policies.settings = {
apiVersion = 1; apiVersion = 1;
policies = [{ policies = [
inherit (cfg) orgId; {
receiver = "grafana-default-email"; inherit (cfg) orgId;
group_by = [ "grafana_folder" "alertname" ]; receiver = "grafana-default-email";
group_wait = "30s"; group_by = [
group_interval = "5m"; "grafana_folder"
repeat_interval = "4h"; "alertname"
}]; ];
group_wait = "30s";
group_interval = "5m";
repeat_interval = "4h";
}
];
# resetPolicies seems to happen after setting the above policies, effectively rolling back # resetPolicies seems to happen after setting the above policies, effectively rolling back
# any updates. # any updates.
}; };
@ -273,18 +294,20 @@ in
rules = builtins.fromJSON (builtins.readFile ./monitoring/rules.json); rules = builtins.fromJSON (builtins.readFile ./monitoring/rules.json);
ruleIds = map (r: r.uid) rules; ruleIds = map (r: r.uid) rules;
in in
{ {
apiVersion = 1; apiVersion = 1;
groups = [{ groups = [
{
inherit (cfg) orgId; inherit (cfg) orgId;
name = "SysAdmin"; name = "SysAdmin";
folder = "Self Host Blocks"; folder = "Self Host Blocks";
interval = "10m"; interval = "10m";
inherit rules; inherit rules;
}]; }
# deleteRules seems to happen after creating the above rules, effectively rolling back ];
# any updates. # deleteRules seems to happen after creating the above rules, effectively rolling back
}; # any updates.
};
}; };
services.prometheus = { services.prometheus = {
@ -295,36 +318,45 @@ in
services.loki = { services.loki = {
enable = true; enable = true;
dataDir = "/var/lib/loki"; dataDir = "/var/lib/loki";
package = if cfg.lokiMajorVersion == 3 then pkgs.grafana-loki else package =
# Comes from https://github.com/NixOS/nixpkgs/commit/8f95320f39d7e4e4a29ee70b8718974295a619f4 if cfg.lokiMajorVersion == 3 then
(pkgs.grafana-loki.overrideAttrs (finalAttrs: previousAttrs: rec { pkgs.grafana-loki
version = "2.9.6"; else
# Comes from https://github.com/NixOS/nixpkgs/commit/8f95320f39d7e4e4a29ee70b8718974295a619f4
(pkgs.grafana-loki.overrideAttrs (
finalAttrs: previousAttrs: rec {
version = "2.9.6";
src = pkgs.fetchFromGitHub { src = pkgs.fetchFromGitHub {
owner = "grafana"; owner = "grafana";
repo = "loki"; repo = "loki";
rev = "v${version}"; rev = "v${version}";
hash = "sha256-79hK7axHf6soku5DvdXkE/0K4WKc4pnS9VMbVc1FS2I="; hash = "sha256-79hK7axHf6soku5DvdXkE/0K4WKc4pnS9VMbVc1FS2I=";
}; };
subPackages = [ subPackages = [
"cmd/loki" "cmd/loki"
"cmd/loki-canary" "cmd/loki-canary"
"clients/cmd/promtail" "clients/cmd/promtail"
"cmd/logcli" "cmd/logcli"
# Removes "cmd/lokitool" # Removes "cmd/lokitool"
]; ];
ldflags = let t = "github.com/grafana/loki/pkg/util/build"; in [ ldflags =
"-s" let
"-w" t = "github.com/grafana/loki/pkg/util/build";
"-X ${t}.Version=${version}" in
"-X ${t}.BuildUser=nix@nixpkgs" [
"-X ${t}.BuildDate=unknown" "-s"
"-X ${t}.Branch=unknown" "-w"
"-X ${t}.Revision=unknown" "-X ${t}.Version=${version}"
]; "-X ${t}.BuildUser=nix@nixpkgs"
})); "-X ${t}.BuildDate=unknown"
"-X ${t}.Branch=unknown"
"-X ${t}.Revision=unknown"
];
}
));
configuration = { configuration = {
auth_enabled = false; auth_enabled = false;
@ -439,7 +471,7 @@ in
proxyPass = "http://${toString config.services.grafana.settings.server.http_addr}:${toString config.services.grafana.settings.server.http_port}"; proxyPass = "http://${toString config.services.grafana.settings.server.http_addr}:${toString config.services.grafana.settings.server.http_port}";
proxyWebsockets = true; proxyWebsockets = true;
extraConfig = '' extraConfig = ''
proxy_set_header Host $host; proxy_set_header Host $host;
''; '';
}; };
}; };
@ -448,61 +480,75 @@ in
services.prometheus.scrapeConfigs = [ services.prometheus.scrapeConfigs = [
{ {
job_name = "node"; job_name = "node";
static_configs = [{ static_configs = [
targets = ["127.0.0.1:${toString config.services.prometheus.exporters.node.port}"]; {
labels = commonLabels; targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.node.port}" ];
}]; labels = commonLabels;
}
];
} }
{ {
job_name = "netdata"; job_name = "netdata";
metrics_path = "/api/v1/allmetrics"; metrics_path = "/api/v1/allmetrics";
params.format = [ "prometheus" ]; params.format = [ "prometheus" ];
honor_labels = true; honor_labels = true;
static_configs = [{ static_configs = [
targets = [ "127.0.0.1:19999" ]; {
labels = commonLabels; targets = [ "127.0.0.1:19999" ];
}]; labels = commonLabels;
}
];
} }
{ {
job_name = "smartctl"; job_name = "smartctl";
static_configs = [{ static_configs = [
targets = ["127.0.0.1:${toString config.services.prometheus.exporters.smartctl.port}"]; {
labels = commonLabels; targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.smartctl.port}" ];
}]; labels = commonLabels;
}
];
} }
{ {
job_name = "prometheus_internal"; job_name = "prometheus_internal";
static_configs = [{ static_configs = [
targets = ["127.0.0.1:${toString config.services.prometheus.port}"]; {
labels = commonLabels; targets = [ "127.0.0.1:${toString config.services.prometheus.port}" ];
}]; labels = commonLabels;
}
];
} }
] ++ (lib.lists.optional config.services.nginx.enable { ]
job_name = "nginx"; ++ (lib.lists.optional config.services.nginx.enable {
static_configs = [{ job_name = "nginx";
targets = ["127.0.0.1:${toString config.services.prometheus.exporters.nginx.port}"]; static_configs = [
{
targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.nginx.port}" ];
labels = commonLabels; labels = commonLabels;
}]; }
# }) ++ (lib.optional (builtins.length (lib.attrNames config.services.redis.servers) > 0) { ];
# job_name = "redis"; # }) ++ (lib.optional (builtins.length (lib.attrNames config.services.redis.servers) > 0) {
# static_configs = [ # job_name = "redis";
# { # static_configs = [
# targets = ["127.0.0.1:${toString config.services.prometheus.exporters.redis.port}"]; # {
# } # targets = ["127.0.0.1:${toString config.services.prometheus.exporters.redis.port}"];
# ]; # }
# }) ++ (lib.optional (builtins.length (lib.attrNames config.services.openvpn.servers) > 0) { # ];
# job_name = "openvpn"; # }) ++ (lib.optional (builtins.length (lib.attrNames config.services.openvpn.servers) > 0) {
# static_configs = [ # job_name = "openvpn";
# { # static_configs = [
# targets = ["127.0.0.1:${toString config.services.prometheus.exporters.openvpn.port}"]; # {
# } # targets = ["127.0.0.1:${toString config.services.prometheus.exporters.openvpn.port}"];
# ]; # }
}) ++ (lib.optional config.services.dnsmasq.enable { # ];
job_name = "dnsmasq"; })
static_configs = [{ ++ (lib.optional config.services.dnsmasq.enable {
targets = ["127.0.0.1:${toString config.services.prometheus.exporters.dnsmasq.port}"]; job_name = "dnsmasq";
static_configs = [
{
targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.dnsmasq.port}" ];
labels = commonLabels; labels = commonLabels;
}]; }
];
}); });
services.prometheus.exporters.nginx = lib.mkIf config.services.nginx.enable { services.prometheus.exporters.nginx = lib.mkIf config.services.nginx.enable {
enable = true; enable = true;
@ -513,7 +559,7 @@ in
services.prometheus.exporters.node = { services.prometheus.exporters.node = {
enable = true; enable = true;
# https://github.com/prometheus/node_exporter#collectors # https://github.com/prometheus/node_exporter#collectors
enabledCollectors = ["ethtool"]; enabledCollectors = [ "ethtool" ];
port = 9112; port = 9112;
listenAddress = "127.0.0.1"; listenAddress = "127.0.0.1";
}; };

View file

@ -1,9 +1,14 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.nginx; cfg = config.shb.nginx;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = c: "${c.subdomain}.${c.domain}"; fqdn = c: "${c.subdomain}.${c.domain}";
@ -42,12 +47,13 @@ let
autheliaRules = lib.mkOption { autheliaRules = lib.mkOption {
type = lib.types.listOf (lib.types.attrsOf lib.types.anything); type = lib.types.listOf (lib.types.attrsOf lib.types.anything);
default = []; default = [ ];
description = "Authelia rule configuration"; description = "Authelia rule configuration";
example = lib.literalExpression ''[{ example = lib.literalExpression ''
policy = "two_factor"; [{
subject = ["group:service_user"]; policy = "two_factor";
}]''; subject = ["group:service_user"];
}]'';
}; };
extraConfig = lib.mkOption { extraConfig = lib.mkOption {
@ -77,41 +83,44 @@ in
vhosts = lib.mkOption { vhosts = lib.mkOption {
description = "Endpoints to be protected by authelia."; description = "Endpoints to be protected by authelia.";
type = lib.types.listOf vhostConfig; type = lib.types.listOf vhostConfig;
default = []; default = [ ];
}; };
}; };
config = { config = {
networking.firewall.allowedTCPPorts = [ 80 443 ]; networking.firewall.allowedTCPPorts = [
80
443
];
services.nginx.enable = true; services.nginx.enable = true;
services.nginx.logError = lib.mkIf cfg.debugLog "stderr warn"; services.nginx.logError = lib.mkIf cfg.debugLog "stderr warn";
services.nginx.appendHttpConfig = lib.mkIf cfg.accessLog '' services.nginx.appendHttpConfig = lib.mkIf cfg.accessLog ''
log_format apm log_format apm
'{' '{'
'"remote_addr":"$remote_addr",' '"remote_addr":"$remote_addr",'
'"remote_user":"$remote_user",' '"remote_user":"$remote_user",'
'"time_local":"$time_local",' '"time_local":"$time_local",'
'"request":"$request",' '"request":"$request",'
'"request_length":"$request_length",' '"request_length":"$request_length",'
'"server_name":"$server_name",' '"server_name":"$server_name",'
'"status":"$status",' '"status":"$status",'
'"bytes_sent":"$bytes_sent",' '"bytes_sent":"$bytes_sent",'
'"body_bytes_sent":"$body_bytes_sent",' '"body_bytes_sent":"$body_bytes_sent",'
'"referrer":"$http_referrer",' '"referrer":"$http_referrer",'
'"user_agent":"$http_user_agent",' '"user_agent":"$http_user_agent",'
'"gzip_ration":"$gzip_ratio",' '"gzip_ration":"$gzip_ratio",'
'"post":"$request_body",' '"post":"$request_body",'
'"upstream_addr":"$upstream_addr",' '"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",' '"upstream_status":"$upstream_status",'
'"request_time":"$request_time",' '"request_time":"$request_time",'
'"upstream_response_time":"$upstream_response_time",' '"upstream_response_time":"$upstream_response_time",'
'"upstream_connect_time":"$upstream_connect_time",' '"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time"' '"upstream_header_time":"$upstream_header_time"'
'}'; '}';
access_log syslog:server=unix:/dev/log apm; access_log syslog:server=unix:/dev/log apm;
''; '';
services.nginx.virtualHosts = services.nginx.virtualHosts =
let let
@ -189,13 +198,13 @@ in
}; };
}; };
in in
lib.mkMerge (map vhostCfg cfg.vhosts); lib.mkMerge (map vhostCfg cfg.vhosts);
shb.authelia.rules = shb.authelia.rules =
let let
authConfig = c: map (r: r // { domain = fqdn c; }) c.autheliaRules; authConfig = c: map (r: r // { domain = fqdn c; }) c.autheliaRules;
in in
lib.flatten (map authConfig cfg.vhosts); lib.flatten (map authConfig cfg.vhosts);
security.acme.defaults.reloadServices = [ security.acme.defaults.reloadServices = [
"nginx.service" "nginx.service"

View file

@ -1,9 +1,15 @@
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
...
}:
let let
cfg = config.shb.postgresql; cfg = config.shb.postgresql;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
upgrade-script = old: new: upgrade-script =
old: new:
let let
oldStr = builtins.toString old; oldStr = builtins.toString old;
newStr = builtins.toString new; newStr = builtins.toString new;
@ -11,37 +17,37 @@ let
oldPkg = pkgs.${"postgresql_${oldStr}"}; oldPkg = pkgs.${"postgresql_${oldStr}"};
newPkg = pkgs.${"postgresql_${newStr}"}; newPkg = pkgs.${"postgresql_${newStr}"};
in in
pkgs.writeScriptBin "upgrade-pg-cluster-${oldStr}-${newStr}" '' pkgs.writeScriptBin "upgrade-pg-cluster-${oldStr}-${newStr}" ''
set -eux set -eux
# XXX it's perhaps advisable to stop all services that depend on postgresql # XXX it's perhaps advisable to stop all services that depend on postgresql
systemctl stop postgresql systemctl stop postgresql
export NEWDATA="/var/lib/postgresql/${newPkg.psqlSchema}" export NEWDATA="/var/lib/postgresql/${newPkg.psqlSchema}"
export NEWBIN="${newPkg}/bin" export NEWBIN="${newPkg}/bin"
export OLDDATA="/var/lib/postgresql/${oldPkg.psqlSchema}" export OLDDATA="/var/lib/postgresql/${oldPkg.psqlSchema}"
export OLDBIN="${oldPkg}/bin" export OLDBIN="${oldPkg}/bin"
install -d -m 0700 -o postgres -g postgres "$NEWDATA" install -d -m 0700 -o postgres -g postgres "$NEWDATA"
cd "$NEWDATA" cd "$NEWDATA"
sudo -u postgres $NEWBIN/initdb -D "$NEWDATA" sudo -u postgres $NEWBIN/initdb -D "$NEWDATA"
sudo -u postgres $NEWBIN/pg_upgrade \ sudo -u postgres $NEWBIN/pg_upgrade \
--old-datadir "$OLDDATA" --new-datadir "$NEWDATA" \ --old-datadir "$OLDDATA" --new-datadir "$NEWDATA" \
--old-bindir $OLDBIN --new-bindir $NEWBIN \ --old-bindir $OLDBIN --new-bindir $NEWBIN \
"$@" "$@"
''; '';
in in
{ {
options.shb.postgresql = { options.shb.postgresql = {
debug = lib.mkOption { debug = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
description = '' description = ''
Enable debugging options. Enable debugging options.
Currently enables shared_preload_libraries = "auto_explain, pg_stat_statements" Currently enables shared_preload_libraries = "auto_explain, pg_stat_statements"
See https://www.postgresql.org/docs/current/pgstatstatements.html''; See https://www.postgresql.org/docs/current/pgstatstatements.html'';
default = false; default = false;
}; };
enableTCPIP = lib.mkOption { enableTCPIP = lib.mkOption {
@ -55,7 +61,7 @@ in
Backup configuration. Backup configuration.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = contracts.databasebackup.mkRequester { options = contracts.databasebackup.mkRequester {
user = "postgres"; user = "postgres";
@ -75,27 +81,29 @@ in
ensures = lib.mkOption { ensures = lib.mkOption {
description = "List of username, database and/or passwords that should be created."; description = "List of username, database and/or passwords that should be created.";
type = lib.types.listOf (lib.types.submodule { type = lib.types.listOf (
options = { lib.types.submodule {
username = lib.mkOption { options = {
type = lib.types.str; username = lib.mkOption {
description = "Postgres user name."; type = lib.types.str;
}; description = "Postgres user name.";
};
database = lib.mkOption { database = lib.mkOption {
type = lib.types.str; type = lib.types.str;
description = "Postgres database."; description = "Postgres database.";
}; };
passwordFile = lib.mkOption { passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
description = "Optional password file for the postgres user. If not given, only peer auth is accepted for this user, otherwise password auth is allowed."; description = "Optional password file for the postgres user. If not given, only peer auth is accepted for this user, otherwise password auth is allowed.";
default = null; default = null;
example = "/run/secrets/postgresql/password"; example = "/run/secrets/postgresql/password";
};
}; };
}; }
}); );
default = []; default = [ ];
}; };
}; };
@ -123,51 +131,61 @@ in
dbConfig = ensureCfgs: { dbConfig = ensureCfgs: {
services.postgresql.enable = lib.mkDefault ((builtins.length ensureCfgs) > 0); services.postgresql.enable = lib.mkDefault ((builtins.length ensureCfgs) > 0);
services.postgresql.ensureDatabases = map ({ database, ... }: database) ensureCfgs; services.postgresql.ensureDatabases = map ({ database, ... }: database) ensureCfgs;
services.postgresql.ensureUsers = map ({ username, database, ... }: { services.postgresql.ensureUsers = map (
name = username; { username, database, ... }:
ensureDBOwnership = true; {
ensureClauses.login = true; name = username;
}) ensureCfgs; ensureDBOwnership = true;
ensureClauses.login = true;
}
) ensureCfgs;
}; };
pwdConfig = ensureCfgs: { pwdConfig = ensureCfgs: {
systemd.services.postgresql-setup.script = lib.mkAfter systemd.services.postgresql-setup.script = lib.mkAfter (
(let let
prefix = '' prefix = ''
psql -tA <<'EOF' psql -tA <<'EOF'
DO $$ DO $$
DECLARE password TEXT; DECLARE password TEXT;
BEGIN BEGIN
''; '';
suffix = '' suffix = ''
END $$; END $$;
EOF EOF
'';
exec = { username, passwordFile, ... }: ''
password := trim(both from replace(pg_read_file('${passwordFile}'), E'\n', '''));
EXECUTE format('ALTER ROLE ${username} WITH PASSWORD '''%s''';', password);
''; '';
exec =
{ username, passwordFile, ... }:
''
password := trim(both from replace(pg_read_file('${passwordFile}'), E'\n', '''));
EXECUTE format('ALTER ROLE ${username} WITH PASSWORD '''%s''';', password);
'';
cfgsWithPasswords = builtins.filter (cfg: cfg.passwordFile != null) ensureCfgs; cfgsWithPasswords = builtins.filter (cfg: cfg.passwordFile != null) ensureCfgs;
in in
if (builtins.length cfgsWithPasswords) == 0 then "" else if (builtins.length cfgsWithPasswords) == 0 then
prefix + (lib.concatStrings (map exec cfgsWithPasswords)) + suffix); ""
else
prefix + (lib.concatStrings (map exec cfgsWithPasswords)) + suffix
);
}; };
debugConfig = enableDebug: lib.mkIf enableDebug { debugConfig =
services.postgresql.settings.shared_preload_libraries = "auto_explain, pg_stat_statements"; enableDebug:
}; lib.mkIf enableDebug {
services.postgresql.settings.shared_preload_libraries = "auto_explain, pg_stat_statements";
};
in in
lib.mkMerge ([ lib.mkMerge ([
commonConfig commonConfig
(dbConfig cfg.ensures) (dbConfig cfg.ensures)
(pwdConfig cfg.ensures) (pwdConfig cfg.ensures)
(lib.mkIf cfg.enableTCPIP tcpConfig) (lib.mkIf cfg.enableTCPIP tcpConfig)
(debugConfig cfg.debug) (debugConfig cfg.debug)
{ {
environment.systemPackages = lib.mkIf config.services.postgresql.enable [ environment.systemPackages = lib.mkIf config.services.postgresql.enable [
(upgrade-script 15 16) (upgrade-script 15 16)
(upgrade-script 16 17) (upgrade-script 16 17)
]; ];
} }
]); ]);
} }

View file

@ -1,167 +1,226 @@
{ config, pkgs, lib, utils, ... }: {
config,
pkgs,
lib,
utils,
...
}:
let let
cfg = config.shb.restic; cfg = config.shb.restic;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
inherit (lib) concatStringsSep filterAttrs flatten literalExpression optionals listToAttrs mapAttrsToList mkEnableOption mkOption mkMerge; inherit (lib)
inherit (lib) hasPrefix mkIf nameValuePair optionalAttrs removePrefix; concatStringsSep
inherit (lib.types) attrsOf enum int ints oneOf nonEmptyStr nullOr str submodule; filterAttrs
flatten
literalExpression
optionals
listToAttrs
mapAttrsToList
mkEnableOption
mkOption
mkMerge
;
inherit (lib)
hasPrefix
mkIf
nameValuePair
optionalAttrs
removePrefix
;
inherit (lib.types)
attrsOf
enum
int
ints
oneOf
nonEmptyStr
nullOr
str
submodule
;
commonOptions = { name, prefix, config, ... }: { commonOptions =
enable = mkEnableOption '' {
this backup intance. name,
prefix,
config,
...
}:
{
enable = mkEnableOption ''
this backup intance.
A disabled instance will not backup data anymore A disabled instance will not backup data anymore
but still provides the helper tool to restore snapshots but still provides the helper tool to restore snapshots
''; '';
passphrase = lib.mkOption { passphrase = lib.mkOption {
description = "Encryption key for the backup repository."; description = "Encryption key for the backup repository.";
type = lib.types.submodule { type = lib.types.submodule {
options = contracts.secret.mkRequester { options = contracts.secret.mkRequester {
mode = "0400"; mode = "0400";
owner = config.request.user; owner = config.request.user;
ownerText = "[shb.restic.${prefix}.<name>.request.user](#blocks-restic-options-shb.restic.${prefix}._name_.request.user)"; ownerText = "[shb.restic.${prefix}.<name>.request.user](#blocks-restic-options-shb.restic.${prefix}._name_.request.user)";
restartUnits = [ (fullName name config.settings.repository) ]; restartUnits = [ (fullName name config.settings.repository) ];
restartUnitsText = "[ [shb.restic.${prefix}.<name>.settings.repository](#blocks-restic-options-shb.restic.${prefix}._name_.settings.repository) ]"; restartUnitsText = "[ [shb.restic.${prefix}.<name>.settings.repository](#blocks-restic-options-shb.restic.${prefix}._name_.settings.repository) ]";
};
}; };
}; };
};
repository = mkOption { repository = mkOption {
description = "Repositories to back this instance to."; description = "Repositories to back this instance to.";
type = submodule { type = submodule {
options = { options = {
path = mkOption { path = mkOption {
type = str; type = str;
description = "Repository location"; description = "Repository location";
};
secrets = mkOption {
type = attrsOf lib.shb.secretFileType;
default = {};
description = ''
Secrets needed to access the repository where the backups will be stored.
See [s3 config](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#amazon-s3) for an example
and [list](https://restic.readthedocs.io/en/latest/040_backup.html#environment-variables) for the list of all secrets.
'';
example = literalExpression ''
{
AWS_ACCESS_KEY_ID.source = <path/to/secret>;
AWS_SECRET_ACCESS_KEY.source = <path/to/secret>;
}
'';
};
timerConfig = mkOption {
type = attrsOf utils.systemdUtils.unitOptions.unitOption;
default = {
OnCalendar = "daily";
Persistent = true;
}; };
description = ''When to run the backup. See {manpage}`systemd.timer(5)` for details.'';
example = { secrets = mkOption {
OnCalendar = "00:05"; type = attrsOf lib.shb.secretFileType;
RandomizedDelaySec = "5h"; default = { };
Persistent = true; description = ''
Secrets needed to access the repository where the backups will be stored.
See [s3 config](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#amazon-s3) for an example
and [list](https://restic.readthedocs.io/en/latest/040_backup.html#environment-variables) for the list of all secrets.
'';
example = literalExpression ''
{
AWS_ACCESS_KEY_ID.source = <path/to/secret>;
AWS_SECRET_ACCESS_KEY.source = <path/to/secret>;
}
'';
};
timerConfig = mkOption {
type = attrsOf utils.systemdUtils.unitOptions.unitOption;
default = {
OnCalendar = "daily";
Persistent = true;
};
description = ''When to run the backup. See {manpage}`systemd.timer(5)` for details.'';
example = {
OnCalendar = "00:05";
RandomizedDelaySec = "5h";
Persistent = true;
};
}; };
}; };
}; };
}; };
};
retention = mkOption { retention = mkOption {
description = "For how long to keep backup files."; description = "For how long to keep backup files.";
type = attrsOf (oneOf [ int nonEmptyStr ]); type = attrsOf (oneOf [
default = { int
keep_within = "1d"; nonEmptyStr
keep_hourly = 24; ]);
keep_daily = 7; default = {
keep_weekly = 4; keep_within = "1d";
keep_monthly = 6; keep_hourly = 24;
keep_daily = 7;
keep_weekly = 4;
keep_monthly = 6;
};
};
limitUploadKiBs = mkOption {
type = nullOr int;
description = "Limit upload bandwidth to the given KiB/s amount.";
default = null;
example = 8000;
};
limitDownloadKiBs = mkOption {
type = nullOr int;
description = "Limit download bandwidth to the given KiB/s amount.";
default = null;
example = 8000;
}; };
}; };
limitUploadKiBs = mkOption { repoSlugName = name: builtins.replaceStrings [ "/" ":" ] [ "_" "_" ] (removePrefix "/" name);
type = nullOr int;
description = "Limit upload bandwidth to the given KiB/s amount.";
default = null;
example = 8000;
};
limitDownloadKiBs = mkOption {
type = nullOr int;
description = "Limit download bandwidth to the given KiB/s amount.";
default = null;
example = 8000;
};
};
repoSlugName = name: builtins.replaceStrings ["/" ":"] ["_" "_"] (removePrefix "/" name);
fullName = name: repository: "restic-backups-${name}_${repoSlugName repository.path}"; fullName = name: repository: "restic-backups-${name}_${repoSlugName repository.path}";
in in
{ {
options.shb.restic = { options.shb.restic = {
instances = mkOption { instances = mkOption {
description = "Files to backup following the [backup contract](./contracts-backup.html)."; description = "Files to backup following the [backup contract](./contracts-backup.html).";
default = {}; default = { };
type = attrsOf (submodule ({ name, config, ... }: { type = attrsOf (
options = contracts.backup.mkProvider { submodule (
settings = mkOption { { name, config, ... }:
description = '' {
Settings specific to the Restic provider. options = contracts.backup.mkProvider {
''; settings = mkOption {
description = ''
Settings specific to the Restic provider.
'';
type = submodule { type = submodule {
options = commonOptions { inherit name config; prefix = "instances"; }; options = commonOptions {
inherit name config;
prefix = "instances";
};
};
};
resultCfg = {
restoreScript = fullName name config.settings.repository;
restoreScriptText = "${fullName "<name>" { path = "path/to/repository"; }}";
backupService = "${fullName name config.settings.repository}.service";
backupServiceText = "${fullName "<name>" { path = "path/to/repository"; }}.service";
};
}; };
}; }
)
resultCfg = { );
restoreScript = fullName name config.settings.repository;
restoreScriptText = "${fullName "<name>" { path = "path/to/repository"; }}";
backupService = "${fullName name config.settings.repository}.service";
backupServiceText = "${fullName "<name>" { path = "path/to/repository"; }}.service";
};
};
}));
}; };
databases = mkOption { databases = mkOption {
description = "Databases to backup following the [database backup contract](./contracts-databasebackup.html)."; description = "Databases to backup following the [database backup contract](./contracts-databasebackup.html).";
default = {}; default = { };
type = attrsOf (submodule ({ name, config, ... }: { type = attrsOf (
options = contracts.databasebackup.mkProvider { submodule (
settings = mkOption { { name, config, ... }:
description = '' {
Settings specific to the Restic provider. options = contracts.databasebackup.mkProvider {
''; settings = mkOption {
description = ''
Settings specific to the Restic provider.
'';
type = submodule { type = submodule {
options = commonOptions { inherit name config; prefix = "databases"; }; options = commonOptions {
inherit name config;
prefix = "databases";
};
};
};
resultCfg = {
restoreScript = fullName name config.settings.repository;
restoreScriptText = "${fullName "<name>" { path = "path/to/repository"; }}";
backupService = "${fullName name config.settings.repository}.service";
backupServiceText = "${fullName "<name>" { path = "path/to/repository"; }}.service";
};
}; };
}; }
)
resultCfg = { );
restoreScript = fullName name config.settings.repository;
restoreScriptText = "${fullName "<name>" { path = "path/to/repository"; }}";
backupService = "${fullName name config.settings.repository}.service";
backupServiceText = "${fullName "<name>" { path = "path/to/repository"; }}.service";
};
};
}));
}; };
# Taken from https://github.com/HubbeKing/restic-kubernetes/blob/73bfbdb0ba76939a4c52173fa2dbd52070710008/README.md?plain=1#L23 # Taken from https://github.com/HubbeKing/restic-kubernetes/blob/73bfbdb0ba76939a4c52173fa2dbd52070710008/README.md?plain=1#L23
performance = mkOption { performance = mkOption {
description = "Reduce performance impact of backup jobs."; description = "Reduce performance impact of backup jobs.";
default = {}; default = { };
type = submodule { type = submodule {
options = { options = {
niceness = mkOption { niceness = mkOption {
@ -170,7 +229,11 @@ in
default = 15; default = 15;
}; };
ioSchedulingClass = mkOption { ioSchedulingClass = mkOption {
type = enum [ "idle" "best-effort" "realtime" ]; type = enum [
"idle"
"best-effort"
"realtime"
];
description = "ionice scheduling class, defaults to best-effort IO. Only used for `restic backup`, `restic forget` and `restic check` commands."; description = "ionice scheduling class, defaults to best-effort IO. Only used for `restic backup`, `restic forget` and `restic check` commands.";
default = "best-effort"; default = "best-effort";
}; };
@ -184,23 +247,28 @@ in
}; };
}; };
config = mkIf (cfg.instances != {} || cfg.databases != {}) ( config = mkIf (cfg.instances != { } || cfg.databases != { }) (
let let
enabledInstances = filterAttrs (k: i: i.settings.enable) cfg.instances; enabledInstances = filterAttrs (k: i: i.settings.enable) cfg.instances;
enabledDatabases = filterAttrs (k: i: i.settings.enable) cfg.databases; enabledDatabases = filterAttrs (k: i: i.settings.enable) cfg.databases;
in mkMerge [ in
mkMerge [
{ {
environment.systemPackages = optionals (enabledInstances != {} || enabledDatabases != {}) [ pkgs.restic ]; environment.systemPackages = optionals (enabledInstances != { } || enabledDatabases != { }) [
pkgs.restic
];
} }
{ {
# Create repository if it is a local path. # Create repository if it is a local path.
systemd.tmpfiles.rules = systemd.tmpfiles.rules =
let let
mkSettings = name: instance: optionals (hasPrefix "/" instance.settings.repository.path) [ mkSettings =
"d '${instance.settings.repository.path}' 0750 ${instance.request.user} root - -" name: instance:
]; optionals (hasPrefix "/" instance.settings.repository.path) [
"d '${instance.settings.repository.path}' 0750 ${instance.request.user} root - -"
];
in in
flatten (mapAttrsToList mkSettings (cfg.instances // cfg.databases)); flatten (mapAttrsToList mkSettings (cfg.instances // cfg.databases));
} }
{ {
services.restic.backups = services.restic.backups =
@ -219,8 +287,8 @@ in
inherit (instance.settings.repository) timerConfig; inherit (instance.settings.repository) timerConfig;
pruneOpts = mapAttrsToList (name: value: pruneOpts = mapAttrsToList (
"--${builtins.replaceStrings ["_"] ["-"] name} ${builtins.toString value}" name: value: "--${builtins.replaceStrings [ "_" ] [ "-" ] name} ${builtins.toString value}"
) instance.settings.retention; ) instance.settings.retention;
backupPrepareCommand = concatStringsSep "\n" instance.request.hooks.beforeBackup; backupPrepareCommand = concatStringsSep "\n" instance.request.hooks.beforeBackup;
@ -234,12 +302,13 @@ in
++ (optionals (instance.settings.limitDownloadKiBs != null) [ ++ (optionals (instance.settings.limitDownloadKiBs != null) [
"--limit-download=${toString instance.settings.limitDownloadKiBs}" "--limit-download=${toString instance.settings.limitDownloadKiBs}"
]); ]);
} // optionalAttrs (builtins.length instance.request.excludePatterns > 0) { }
// optionalAttrs (builtins.length instance.request.excludePatterns > 0) {
exclude = instance.request.excludePatterns; exclude = instance.request.excludePatterns;
}; };
}; };
in in
mkMerge (flatten (mapAttrsToList mkSettings enabledInstances)); mkMerge (flatten (mapAttrsToList mkSettings enabledInstances));
} }
{ {
services.restic.backups = services.restic.backups =
@ -258,8 +327,8 @@ in
inherit (instance.settings.repository) timerConfig; inherit (instance.settings.repository) timerConfig;
pruneOpts = mapAttrsToList (name: value: pruneOpts = mapAttrsToList (
"--${builtins.replaceStrings ["_"] ["-"] name} ${builtins.toString value}" name: value: "--${builtins.replaceStrings [ "_" ] [ "-" ] name} ${builtins.toString value}"
) instance.settings.retention; ) instance.settings.retention;
extraBackupArgs = extraBackupArgs =
@ -269,120 +338,131 @@ in
++ (optionals (instance.settings.limitDownloadKiBs != null) [ ++ (optionals (instance.settings.limitDownloadKiBs != null) [
"--limit-download=${toString instance.settings.limitDownloadKiBs}" "--limit-download=${toString instance.settings.limitDownloadKiBs}"
]) ])
++ ++ (
(let let
cmd = pkgs.writeShellScriptBin "dump.sh" instance.request.backupCmd; cmd = pkgs.writeShellScriptBin "dump.sh" instance.request.backupCmd;
in in
[ [
"--stdin-filename ${instance.request.backupName} --stdin-from-command -- ${cmd}/bin/dump.sh" "--stdin-filename ${instance.request.backupName} --stdin-from-command -- ${cmd}/bin/dump.sh"
]); ]
);
}; };
}; };
in in
mkMerge (flatten (mapAttrsToList mkSettings enabledDatabases)); mkMerge (flatten (mapAttrsToList mkSettings enabledDatabases));
} }
{ {
systemd.services = systemd.services =
let let
mkSettings = name: instance: mkSettings =
name: instance:
let let
serviceName = fullName name instance.settings.repository; serviceName = fullName name instance.settings.repository;
in in
{ {
${serviceName} = mkMerge [ ${serviceName} = mkMerge [
{ {
serviceConfig = { serviceConfig = {
Nice = cfg.performance.niceness; Nice = cfg.performance.niceness;
IOSchedulingClass = cfg.performance.ioSchedulingClass; IOSchedulingClass = cfg.performance.ioSchedulingClass;
IOSchedulingPriority = cfg.performance.ioPriority; IOSchedulingPriority = cfg.performance.ioPriority;
# BindReadOnlyPaths = instance.sourceDirectories; # BindReadOnlyPaths = instance.sourceDirectories;
}; };
} }
(optionalAttrs (instance.settings.repository.secrets != {}) (optionalAttrs (instance.settings.repository.secrets != { }) {
{ serviceConfig.EnvironmentFile = [
serviceConfig.EnvironmentFile = [ "/run/secrets_restic/${serviceName}"
"/run/secrets_restic/${serviceName}" ];
]; after = [ "${serviceName}-pre.service" ];
after = [ "${serviceName}-pre.service" ]; requires = [ "${serviceName}-pre.service" ];
requires = [ "${serviceName}-pre.service" ]; })
}) ];
];
"${serviceName}-pre" = mkIf (instance.settings.repository.secrets != {}) "${serviceName}-pre" = mkIf (instance.settings.repository.secrets != { }) (
(let let
script = lib.shb.genConfigOutOfBandSystemd { script = lib.shb.genConfigOutOfBandSystemd {
config = instance.settings.repository.secrets; config = instance.settings.repository.secrets;
configLocation = "/run/secrets_restic/${serviceName}"; configLocation = "/run/secrets_restic/${serviceName}";
generator = lib.shb.toEnvVar; generator = lib.shb.toEnvVar;
user = instance.request.user; user = instance.request.user;
}; };
in in
{ {
script = script.preStart; script = script.preStart;
serviceConfig.Type = "oneshot"; serviceConfig.Type = "oneshot";
serviceConfig.LoadCredential = script.loadCredentials; serviceConfig.LoadCredential = script.loadCredentials;
}); }
}; );
};
in in
mkMerge (flatten (mapAttrsToList mkSettings (enabledInstances // enabledDatabases))); mkMerge (flatten (mapAttrsToList mkSettings (enabledInstances // enabledDatabases)));
} }
{ {
systemd.services = let systemd.services =
mkEnv = name: instance: let
nameValuePair "${fullName name instance.settings.repository}_restore_gen" { mkEnv =
enable = true; name: instance:
wantedBy = [ "multi-user.target" ]; nameValuePair "${fullName name instance.settings.repository}_restore_gen" {
serviceConfig.Type = "oneshot"; enable = true;
script = (lib.shb.replaceSecrets { wantedBy = [ "multi-user.target" ];
userConfig = instance.settings.repository.secrets // { serviceConfig.Type = "oneshot";
RESTIC_PASSWORD_FILE = toString instance.settings.passphrase.result.path; script = (
RESTIC_REPOSITORY = instance.settings.repository.path; lib.shb.replaceSecrets {
}; userConfig = instance.settings.repository.secrets // {
resultPath = "/run/secrets_restic_env/${fullName name instance.settings.repository}"; RESTIC_PASSWORD_FILE = toString instance.settings.passphrase.result.path;
generator = lib.shb.toEnvVar; RESTIC_REPOSITORY = instance.settings.repository.path;
user = instance.request.user; };
}); resultPath = "/run/secrets_restic_env/${fullName name instance.settings.repository}";
}; generator = lib.shb.toEnvVar;
in user = instance.request.user;
}
);
};
in
listToAttrs (flatten (mapAttrsToList mkEnv (cfg.instances // cfg.databases))); listToAttrs (flatten (mapAttrsToList mkEnv (cfg.instances // cfg.databases)));
} }
{ {
environment.systemPackages = let environment.systemPackages =
mkResticBinary = name: instance: let
pkgs.writeShellScriptBin (fullName name instance.settings.repository) '' mkResticBinary =
set -euo pipefail name: instance:
pkgs.writeShellScriptBin (fullName name instance.settings.repository) ''
set -euo pipefail
export $(grep -v '^#' "/run/secrets_restic_env/${fullName name instance.settings.repository}" \ export $(grep -v '^#' "/run/secrets_restic_env/${fullName name instance.settings.repository}" \
| xargs -d '\n') | xargs -d '\n')
if ! [ "$1" = "restore" ]; then if ! [ "$1" = "restore" ]; then
sudo --preserve-env -u ${instance.request.user} ${pkgs.restic}/bin/restic $@ sudo --preserve-env -u ${instance.request.user} ${pkgs.restic}/bin/restic $@
else else
shift shift
sudo --preserve-env -u ${instance.request.user} sh -c "${pkgs.restic}/bin/restic restore $@ --target /" sudo --preserve-env -u ${instance.request.user} sh -c "${pkgs.restic}/bin/restic restore $@ --target /"
fi fi
''; '';
in in
flatten (mapAttrsToList mkResticBinary cfg.instances); flatten (mapAttrsToList mkResticBinary cfg.instances);
} }
{ {
environment.systemPackages = let environment.systemPackages =
mkResticBinary = name: instance: let
pkgs.writeShellScriptBin (fullName name instance.settings.repository) '' mkResticBinary =
set -euo pipefail name: instance:
pkgs.writeShellScriptBin (fullName name instance.settings.repository) ''
set -euo pipefail
export $(grep -v '^#' "/run/secrets_restic_env/${fullName name instance.settings.repository}" \ export $(grep -v '^#' "/run/secrets_restic_env/${fullName name instance.settings.repository}" \
| xargs -d '\n') | xargs -d '\n')
if ! [ "$1" = "restore" ]; then if ! [ "$1" = "restore" ]; then
sudo --preserve-env -u ${instance.request.user} ${pkgs.restic}/bin/restic $@ sudo --preserve-env -u ${instance.request.user} ${pkgs.restic}/bin/restic $@
else else
shift shift
sudo --preserve-env -u ${instance.request.user} sh -c "${pkgs.restic}/bin/restic dump $@ ${instance.request.backupName} | ${instance.request.restoreCmd}" sudo --preserve-env -u ${instance.request.user} sh -c "${pkgs.restic}/bin/restic dump $@ ${instance.request.backupName} | ${instance.request.restoreCmd}"
fi fi
''; '';
in in
flatten (mapAttrsToList mkResticBinary cfg.databases); flatten (mapAttrsToList mkResticBinary cfg.databases);
} }
]); ]
);
} }

View file

@ -1,9 +1,14 @@
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
...
}:
let let
inherit (lib) mapAttrs mkOption; inherit (lib) mapAttrs mkOption;
inherit (lib.types) attrsOf anything submodule; inherit (lib.types) attrsOf anything submodule;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
cfg = config.shb.sops; cfg = config.shb.sops;
in in
@ -11,35 +16,42 @@ in
options.shb.sops = { options.shb.sops = {
secret = mkOption { secret = mkOption {
description = "Secret following the [secret contract](./contracts-secret.html)."; description = "Secret following the [secret contract](./contracts-secret.html).";
default = {}; default = { };
type = attrsOf (submodule ({ name, options, ... }: { type = attrsOf (
options = contracts.secret.mkProvider { submodule (
settings = mkOption { { name, options, ... }:
description = '' {
Settings specific to the Sops provider. options = contracts.secret.mkProvider {
settings = mkOption {
description = ''
Settings specific to the Sops provider.
This is a passthrough option to set [sops-nix options](https://github.com/Mic92/sops-nix/blob/master/modules/sops/default.nix). This is a passthrough option to set [sops-nix options](https://github.com/Mic92/sops-nix/blob/master/modules/sops/default.nix).
Note though that the `mode`, `owner`, `group`, and `restartUnits` Note though that the `mode`, `owner`, `group`, and `restartUnits`
are managed by the [shb.sops.secret.<name>.request](#blocks-sops-options-shb.sops.secret._name_.request) option. are managed by the [shb.sops.secret.<name>.request](#blocks-sops-options-shb.sops.secret._name_.request) option.
''; '';
type = attrsOf anything; type = attrsOf anything;
default = {}; default = { };
}; };
resultCfg = { resultCfg = {
path = "/run/secrets/${name}"; path = "/run/secrets/${name}";
pathText = "/run/secrets/<name>"; pathText = "/run/secrets/<name>";
}; };
}; };
})); }
)
);
}; };
}; };
config = { config = {
sops.secrets = let sops.secrets =
mkSecret = n: secretCfg: secretCfg.request // secretCfg.settings; let
in mapAttrs mkSecret cfg.secret; mkSecret = n: secretCfg: secretCfg.request // secretCfg.settings;
in
mapAttrs mkSecret cfg.secret;
}; };
} }

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,41 @@
# Inspired from https://github.com/NixOS/nixpkgs/pull/231152 but made it so we can have multiple instances. # Inspired from https://github.com/NixOS/nixpkgs/pull/231152 but made it so we can have multiple instances.
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
...
}:
with lib; with lib;
let let
cfg = config.shb.tinyproxy; cfg = config.shb.tinyproxy;
mkValueStringTinyproxy = with lib; v: mkValueStringTinyproxy =
if true == v then "yes" with lib;
else if false == v then "no" v:
else generators.mkValueStringDefault {} v; if true == v then
"yes"
else if false == v then
"no"
else
generators.mkValueStringDefault { } v;
mkKeyValueTinyproxy = { mkKeyValueTinyproxy =
mkValueString ? mkValueStringDefault {} {
}: sep: k: v: mkValueString ? mkValueStringDefault { },
if null == v then "" }:
else "${lib.strings.escape [sep] k}${sep}${mkValueString v}"; sep: k: v:
if null == v then "" else "${lib.strings.escape [ sep ] k}${sep}${mkValueString v}";
settingsFormat = (pkgs.formats.keyValue { settingsFormat = (
pkgs.formats.keyValue {
mkKeyValue = mkKeyValueTinyproxy { mkKeyValue = mkKeyValueTinyproxy {
mkValueString = mkValueStringTinyproxy; mkValueString = mkValueStringTinyproxy;
} " "; } " ";
listsAsDuplicateKeys= true; listsAsDuplicateKeys = true;
}); }
);
configFile = name: cfg: settingsFormat.generate "tinyproxy-${name}.conf" cfg.settings; configFile = name: cfg: settingsFormat.generate "tinyproxy-${name}.conf" cfg.settings;
in in
@ -33,115 +46,124 @@ in
options = { options = {
enable = mkEnableOption "Tinyproxy daemon"; enable = mkEnableOption "Tinyproxy daemon";
package = mkPackageOption pkgs "tinyproxy" {}; package = mkPackageOption pkgs "tinyproxy" { };
dynamicBindFile = mkOption { dynamicBindFile = mkOption {
description = '' description = ''
File holding the IP to bind to. File holding the IP to bind to.
''; '';
default = ""; default = "";
}; };
settings = mkOption { settings = mkOption {
description = '' description = ''
Configuration for [tinyproxy](https://tinyproxy.github.io/). Configuration for [tinyproxy](https://tinyproxy.github.io/).
''; '';
default = { }; default = { };
example = literalExpression ''{ example = literalExpression ''
Port 8888; {
Listen 127.0.0.1; Port 8888;
Timeout 600; Listen 127.0.0.1;
Allow 127.0.0.1; Timeout 600;
Anonymous = ['"Host"' '"Authorization"']; Allow 127.0.0.1;
ReversePath = '"/example/" "http://www.example.com/"'; Anonymous = ['"Host"' '"Authorization"'];
}''; ReversePath = '"/example/" "http://www.example.com/"';
type = types.submodule ({name, ...}: { }'';
freeformType = settingsFormat.type; type = types.submodule (
options = { { name, ... }:
Listen = mkOption { {
type = types.str; freeformType = settingsFormat.type;
default = "127.0.0.1"; options = {
description = '' Listen = mkOption {
Specify which address to listen to. type = types.str;
''; default = "127.0.0.1";
description = ''
Specify which address to listen to.
'';
};
Port = mkOption {
type = types.int;
default = 8888;
description = ''
Specify which port to listen to.
'';
};
Anonymous = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
If an `Anonymous` keyword is present, then anonymous proxying is enabled. The
headers listed with `Anonymous` are allowed through, while all others are denied.
If no Anonymous keyword is present, then all headers are allowed through. You must
include quotes around the headers.
'';
};
Filter = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Tinyproxy supports filtering of web sites based on URLs or domains. This option
specifies the location of the file containing the filter rules, one rule per line.
'';
};
}; };
Port = mkOption { }
type = types.int; );
default = 8888;
description = ''
Specify which port to listen to.
'';
};
Anonymous = mkOption {
type = types.listOf types.str;
default = [];
description = ''
If an `Anonymous` keyword is present, then anonymous proxying is enabled. The
headers listed with `Anonymous` are allowed through, while all others are denied.
If no Anonymous keyword is present, then all headers are allowed through. You must
include quotes around the headers.
'';
};
Filter = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Tinyproxy supports filtering of web sites based on URLs or domains. This option
specifies the location of the file containing the filter rules, one rule per line.
'';
};
};
});
}; };
}; };
}; };
in in
{ {
shb.tinyproxy = mkOption { shb.tinyproxy = mkOption {
description = "Tinyproxy instances."; description = "Tinyproxy instances.";
default = {}; default = { };
type = types.attrsOf instanceOption; type = types.attrsOf instanceOption;
};
}; };
};
config = { config = {
systemd.services = systemd.services =
let let
instanceConfig = name: c: mkIf c.enable { instanceConfig =
"tinyproxy-${name}" = { name: c:
description = "TinyProxy daemon - instance ${name}"; mkIf c.enable {
after = [ "network.target" ]; "tinyproxy-${name}" = {
wantedBy = [ "multi-user.target" ]; description = "TinyProxy daemon - instance ${name}";
serviceConfig = { after = [ "network.target" ];
User = "tinyproxy"; wantedBy = [ "multi-user.target" ];
Group = "tinyproxy"; serviceConfig = {
Type = "simple"; User = "tinyproxy";
ExecStart = "${getExe c.package} -d -c /etc/tinyproxy/${name}.conf"; Group = "tinyproxy";
ExecReload = "${pkgs.coreutils}/bin/kill -SIGHUP $MAINPID"; Type = "simple";
KillSignal = "SIGINT"; ExecStart = "${getExe c.package} -d -c /etc/tinyproxy/${name}.conf";
TimeoutStopSec = "30s"; ExecReload = "${pkgs.coreutils}/bin/kill -SIGHUP $MAINPID";
Restart = "on-failure"; KillSignal = "SIGINT";
RestartSec = "1s"; TimeoutStopSec = "30s";
RestartSteps = "3"; Restart = "on-failure";
RestartMaxDelaySec = "10s"; RestartSec = "1s";
ConfigurationDirectory = "tinyproxy"; RestartSteps = "3";
RestartMaxDelaySec = "10s";
ConfigurationDirectory = "tinyproxy";
};
preStart = concatStringsSep "\n" (
[
"cat ${configFile name c} > /etc/tinyproxy/${name}.conf"
]
++ optionals (c.dynamicBindFile != "") [
"echo -n 'Bind ' >> /etc/tinyproxy/${name}.conf"
"cat ${c.dynamicBindFile} >> /etc/tinyproxy/${name}.conf"
]
);
}; };
preStart = concatStringsSep "\n" ([
"cat ${configFile name c} > /etc/tinyproxy/${name}.conf"
] ++ optionals (c.dynamicBindFile != "") [
"echo -n 'Bind ' >> /etc/tinyproxy/${name}.conf"
"cat ${c.dynamicBindFile} >> /etc/tinyproxy/${name}.conf"
]);
}; };
};
in in
mkMerge (mapAttrsToList instanceConfig cfg); mkMerge (mapAttrsToList instanceConfig cfg);
users.users.tinyproxy = { users.users.tinyproxy = {
group = "tinyproxy"; group = "tinyproxy";
isSystemUser = true; isSystemUser = true;
}; };
users.groups.tinyproxy = {}; users.groups.tinyproxy = { };
}; };
meta.maintainers = with maintainers; [ tcheronneau ]; meta.maintainers = with maintainers; [ tcheronneau ];

View file

@ -1,4 +1,9 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.vpn; cfg = config.shb.vpn;
@ -6,197 +11,212 @@ let
quoteEach = lib.concatMapStrings (x: ''"${x}"''); quoteEach = lib.concatMapStrings (x: ''"${x}"'');
nordvpnConfig = nordvpnConfig =
{ name {
, dev name,
, authFile dev,
, remoteServerIP authFile,
, dependentServices ? [] remoteServerIP,
}: '' dependentServices ? [ ],
client }:
dev ${dev} ''
proto tcp client
remote ${remoteServerIP} 443 dev ${dev}
resolv-retry infinite proto tcp
remote-random remote ${remoteServerIP} 443
nobind resolv-retry infinite
tun-mtu 1500 remote-random
tun-mtu-extra 32 nobind
mssfix 1450 tun-mtu 1500
persist-key tun-mtu-extra 32
persist-tun mssfix 1450
ping 15 persist-key
ping-restart 0 persist-tun
ping-timer-rem ping 15
reneg-sec 0 ping-restart 0
comp-lzo no ping-timer-rem
reneg-sec 0
comp-lzo no
status /tmp/openvpn/${name}.status status /tmp/openvpn/${name}.status
remote-cert-tls server remote-cert-tls server
auth-user-pass ${authFile} auth-user-pass ${authFile}
verb 3 verb 3
pull pull
fast-io fast-io
cipher AES-256-CBC cipher AES-256-CBC
auth SHA512 auth SHA512
script-security 2 script-security 2
route-noexec route-noexec
route-up ${routeUp name dependentServices}/bin/routeUp.sh route-up ${routeUp name dependentServices}/bin/routeUp.sh
down ${routeDown name dependentServices}/bin/routeDown.sh down ${routeDown name dependentServices}/bin/routeDown.sh
<ca> <ca>
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIFCjCCAvKgAwIBAgIBATANBgkqhkiG9w0BAQ0FADA5MQswCQYDVQQGEwJQQTEQ MIIFCjCCAvKgAwIBAgIBATANBgkqhkiG9w0BAQ0FADA5MQswCQYDVQQGEwJQQTEQ
MA4GA1UEChMHTm9yZFZQTjEYMBYGA1UEAxMPTm9yZFZQTiBSb290IENBMB4XDTE2 MA4GA1UEChMHTm9yZFZQTjEYMBYGA1UEAxMPTm9yZFZQTiBSb290IENBMB4XDTE2
MDEwMTAwMDAwMFoXDTM1MTIzMTIzNTk1OVowOTELMAkGA1UEBhMCUEExEDAOBgNV MDEwMTAwMDAwMFoXDTM1MTIzMTIzNTk1OVowOTELMAkGA1UEBhMCUEExEDAOBgNV
BAoTB05vcmRWUE4xGDAWBgNVBAMTD05vcmRWUE4gUm9vdCBDQTCCAiIwDQYJKoZI BAoTB05vcmRWUE4xGDAWBgNVBAMTD05vcmRWUE4gUm9vdCBDQTCCAiIwDQYJKoZI
hvcNAQEBBQADggIPADCCAgoCggIBAMkr/BYhyo0F2upsIMXwC6QvkZps3NN2/eQF hvcNAQEBBQADggIPADCCAgoCggIBAMkr/BYhyo0F2upsIMXwC6QvkZps3NN2/eQF
kfQIS1gql0aejsKsEnmY0Kaon8uZCTXPsRH1gQNgg5D2gixdd1mJUvV3dE3y9FJr kfQIS1gql0aejsKsEnmY0Kaon8uZCTXPsRH1gQNgg5D2gixdd1mJUvV3dE3y9FJr
XMoDkXdCGBodvKJyU6lcfEVF6/UxHcbBguZK9UtRHS9eJYm3rpL/5huQMCppX7kU XMoDkXdCGBodvKJyU6lcfEVF6/UxHcbBguZK9UtRHS9eJYm3rpL/5huQMCppX7kU
eQ8dpCwd3iKITqwd1ZudDqsWaU0vqzC2H55IyaZ/5/TnCk31Q1UP6BksbbuRcwOV eQ8dpCwd3iKITqwd1ZudDqsWaU0vqzC2H55IyaZ/5/TnCk31Q1UP6BksbbuRcwOV
skEDsm6YoWDnn/IIzGOYnFJRzQH5jTz3j1QBvRIuQuBuvUkfhx1FEwhwZigrcxXu skEDsm6YoWDnn/IIzGOYnFJRzQH5jTz3j1QBvRIuQuBuvUkfhx1FEwhwZigrcxXu
MP+QgM54kezgziJUaZcOM2zF3lvrwMvXDMfNeIoJABv9ljw969xQ8czQCU5lMVmA MP+QgM54kezgziJUaZcOM2zF3lvrwMvXDMfNeIoJABv9ljw969xQ8czQCU5lMVmA
37ltv5Ec9U5hZuwk/9QO1Z+d/r6Jx0mlurS8gnCAKJgwa3kyZw6e4FZ8mYL4vpRR 37ltv5Ec9U5hZuwk/9QO1Z+d/r6Jx0mlurS8gnCAKJgwa3kyZw6e4FZ8mYL4vpRR
hPdvRTWCMJkeB4yBHyhxUmTRgJHm6YR3D6hcFAc9cQcTEl/I60tMdz33G6m0O42s hPdvRTWCMJkeB4yBHyhxUmTRgJHm6YR3D6hcFAc9cQcTEl/I60tMdz33G6m0O42s
Qt/+AR3YCY/RusWVBJB/qNS94EtNtj8iaebCQW1jHAhvGmFILVR9lzD0EzWKHkvy Qt/+AR3YCY/RusWVBJB/qNS94EtNtj8iaebCQW1jHAhvGmFILVR9lzD0EzWKHkvy
WEjmUVRgCDd6Ne3eFRNS73gdv/C3l5boYySeu4exkEYVxVRn8DhCxs0MnkMHWFK6 WEjmUVRgCDd6Ne3eFRNS73gdv/C3l5boYySeu4exkEYVxVRn8DhCxs0MnkMHWFK6
MyzXCCn+JnWFDYPfDKHvpff/kLDobtPBf+Lbch5wQy9quY27xaj0XwLyjOltpiST MyzXCCn+JnWFDYPfDKHvpff/kLDobtPBf+Lbch5wQy9quY27xaj0XwLyjOltpiST
LWae/Q4vAgMBAAGjHTAbMAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgEGMA0GCSqG LWae/Q4vAgMBAAGjHTAbMAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgEGMA0GCSqG
SIb3DQEBDQUAA4ICAQC9fUL2sZPxIN2mD32VeNySTgZlCEdVmlq471o/bDMP4B8g SIb3DQEBDQUAA4ICAQC9fUL2sZPxIN2mD32VeNySTgZlCEdVmlq471o/bDMP4B8g
nQesFRtXY2ZCjs50Jm73B2LViL9qlREmI6vE5IC8IsRBJSV4ce1WYxyXro5rmVg/ nQesFRtXY2ZCjs50Jm73B2LViL9qlREmI6vE5IC8IsRBJSV4ce1WYxyXro5rmVg/
k6a10rlsbK/eg//GHoJxDdXDOokLUSnxt7gk3QKpX6eCdh67p0PuWm/7WUJQxH2S k6a10rlsbK/eg//GHoJxDdXDOokLUSnxt7gk3QKpX6eCdh67p0PuWm/7WUJQxH2S
DxsT9vB/iZriTIEe/ILoOQF0Aqp7AgNCcLcLAmbxXQkXYCCSB35Vp06u+eTWjG0/ DxsT9vB/iZriTIEe/ILoOQF0Aqp7AgNCcLcLAmbxXQkXYCCSB35Vp06u+eTWjG0/
pyS5V14stGtw+fA0DJp5ZJV4eqJ5LqxMlYvEZ/qKTEdoCeaXv2QEmN6dVqjDoTAo pyS5V14stGtw+fA0DJp5ZJV4eqJ5LqxMlYvEZ/qKTEdoCeaXv2QEmN6dVqjDoTAo
k0t5u4YRXzEVCfXAC3ocplNdtCA72wjFJcSbfif4BSC8bDACTXtnPC7nD0VndZLp k0t5u4YRXzEVCfXAC3ocplNdtCA72wjFJcSbfif4BSC8bDACTXtnPC7nD0VndZLp
+RiNLeiENhk0oTC+UVdSc+n2nJOzkCK0vYu0Ads4JGIB7g8IB3z2t9ICmsWrgnhd +RiNLeiENhk0oTC+UVdSc+n2nJOzkCK0vYu0Ads4JGIB7g8IB3z2t9ICmsWrgnhd
NdcOe15BincrGA8avQ1cWXsfIKEjbrnEuEk9b5jel6NfHtPKoHc9mDpRdNPISeVa NdcOe15BincrGA8avQ1cWXsfIKEjbrnEuEk9b5jel6NfHtPKoHc9mDpRdNPISeVa
wDBM1mJChneHt59Nh8Gah74+TM1jBsw4fhJPvoc7Atcg740JErb904mZfkIEmojC wDBM1mJChneHt59Nh8Gah74+TM1jBsw4fhJPvoc7Atcg740JErb904mZfkIEmojC
VPhBHVQ9LHBAdM8qFI2kRK0IynOmAZhexlP/aT/kpEsEPyaZQlnBn3An1CRz8h0S VPhBHVQ9LHBAdM8qFI2kRK0IynOmAZhexlP/aT/kpEsEPyaZQlnBn3An1CRz8h0S
PApL8PytggYKeQmRhl499+6jLxcZ2IegLfqq41dzIjwHwTMplg+1pKIOVojpWA== PApL8PytggYKeQmRhl499+6jLxcZ2IegLfqq41dzIjwHwTMplg+1pKIOVojpWA==
-----END CERTIFICATE----- -----END CERTIFICATE-----
</ca> </ca>
key-direction 1 key-direction 1
<tls-auth> <tls-auth>
# #
# 2048 bit OpenVPN static key # 2048 bit OpenVPN static key
# #
-----BEGIN OpenVPN Static key V1----- -----BEGIN OpenVPN Static key V1-----
e685bdaf659a25a200e2b9e39e51ff03 e685bdaf659a25a200e2b9e39e51ff03
0fc72cf1ce07232bd8b2be5e6c670143 0fc72cf1ce07232bd8b2be5e6c670143
f51e937e670eee09d4f2ea5a6e4e6996 f51e937e670eee09d4f2ea5a6e4e6996
5db852c275351b86fc4ca892d78ae002 5db852c275351b86fc4ca892d78ae002
d6f70d029bd79c4d1c26cf14e9588033 d6f70d029bd79c4d1c26cf14e9588033
cf639f8a74809f29f72b9d58f9b8f5fe cf639f8a74809f29f72b9d58f9b8f5fe
fc7938eade40e9fed6cb92184abb2cc1 fc7938eade40e9fed6cb92184abb2cc1
0eb1a296df243b251df0643d53724cdb 0eb1a296df243b251df0643d53724cdb
5a92a1d6cb817804c4a9319b57d53be5 5a92a1d6cb817804c4a9319b57d53be5
80815bcfcb2df55018cc83fc43bc7ff8 80815bcfcb2df55018cc83fc43bc7ff8
2d51f9b88364776ee9d12fc85cc7ea5b 2d51f9b88364776ee9d12fc85cc7ea5b
9741c4f598c485316db066d52db4540e 9741c4f598c485316db066d52db4540e
212e1518a9bd4828219e24b20d88f598 212e1518a9bd4828219e24b20d88f598
a196c9de96012090e333519ae18d3509 a196c9de96012090e333519ae18d3509
9427e7b372d348d352dc4c85e18cd4b9 9427e7b372d348d352dc4c85e18cd4b9
3f8a56ddb2e64eb67adfc9b337157ff4 3f8a56ddb2e64eb67adfc9b337157ff4
-----END OpenVPN Static key V1----- -----END OpenVPN Static key V1-----
</tls-auth> </tls-auth>
''; '';
routeUp = name: dependentServices: pkgs.writeShellApplication { routeUp =
name = "routeUp.sh"; name: dependentServices:
pkgs.writeShellApplication {
name = "routeUp.sh";
runtimeInputs = [ pkgs.iproute2 pkgs.systemd pkgs.nettools ]; runtimeInputs = [
pkgs.iproute2
pkgs.systemd
pkgs.nettools
];
text = '' text = ''
echo "Running route-up..." echo "Running route-up..."
echo "dev=''${dev:?}" echo "dev=''${dev:?}"
echo "ifconfig_local=''${ifconfig_local:?}" echo "ifconfig_local=''${ifconfig_local:?}"
echo "route_vpn_gateway=''${route_vpn_gateway:?}" echo "route_vpn_gateway=''${route_vpn_gateway:?}"
set -x set -x
ip rule ip rule
ip rule add from "''${ifconfig_local:?}/32" table ${name} ip rule add from "''${ifconfig_local:?}/32" table ${name}
ip rule add to "''${route_vpn_gateway:?}/32" table ${name} ip rule add to "''${route_vpn_gateway:?}/32" table ${name}
ip rule ip rule
ip route list table ${name} || : ip route list table ${name} || :
retVal=$? retVal=$?
if [ $retVal -eq 2 ]; then if [ $retVal -eq 2 ]; then
echo "table is empty" echo "table is empty"
elif [ $retVal -ne 0 ]; then elif [ $retVal -ne 0 ]; then
exit 1 exit 1
fi fi
ip route add default via "''${route_vpn_gateway:?}" dev "''${dev:?}" table ${name} ip route add default via "''${route_vpn_gateway:?}" dev "''${dev:?}" table ${name}
ip route flush cache ip route flush cache
ip route list table ${name} || : ip route list table ${name} || :
retVal=$? retVal=$?
if [ $retVal -eq 2 ]; then if [ $retVal -eq 2 ]; then
echo "table is empty" echo "table is empty"
elif [ $retVal -ne 0 ]; then elif [ $retVal -ne 0 ]; then
exit 1 exit 1
fi fi
echo "''${ifconfig_local:?}" > /run/openvpn/${name}/ifconfig_local echo "''${ifconfig_local:?}" > /run/openvpn/${name}/ifconfig_local
dependencies=(${quoteEach dependentServices}) dependencies=(${quoteEach dependentServices})
for i in "''${dependencies[@]}"; do for i in "''${dependencies[@]}"; do
systemctl restart "$i" || : systemctl restart "$i" || :
done done
echo "Running route-up DONE" echo "Running route-up DONE"
''; '';
}; };
routeDown = name: dependentServices: pkgs.writeShellApplication { routeDown =
name = "routeDown.sh"; name: dependentServices:
pkgs.writeShellApplication {
name = "routeDown.sh";
runtimeInputs = [ pkgs.iproute2 pkgs.systemd pkgs.nettools pkgs.coreutils ]; runtimeInputs = [
pkgs.iproute2
pkgs.systemd
pkgs.nettools
pkgs.coreutils
];
text = '' text = ''
echo "Running route-down..." echo "Running route-down..."
echo "dev=''${dev:?}" echo "dev=''${dev:?}"
echo "ifconfig_local=''${ifconfig_local:?}" echo "ifconfig_local=''${ifconfig_local:?}"
echo "route_vpn_gateway=''${route_vpn_gateway:?}" echo "route_vpn_gateway=''${route_vpn_gateway:?}"
set -x set -x
ip rule ip rule
ip rule del from "''${ifconfig_local:?}/32" table ${name} ip rule del from "''${ifconfig_local:?}/32" table ${name}
ip rule del to "''${route_vpn_gateway:?}/32" table ${name} ip rule del to "''${route_vpn_gateway:?}/32" table ${name}
ip rule ip rule
# This will probably fail because the dev is already gone. # This will probably fail because the dev is already gone.
ip route list table ${name} || : ip route list table ${name} || :
retVal=$? retVal=$?
if [ $retVal -eq 2 ]; then if [ $retVal -eq 2 ]; then
echo "table is empty" echo "table is empty"
elif [ $retVal -ne 0 ]; then elif [ $retVal -ne 0 ]; then
exit 1 exit 1
fi fi
ip route del default via "''${route_vpn_gateway:?}" dev "''${dev:?}" table ${name} || : ip route del default via "''${route_vpn_gateway:?}" dev "''${dev:?}" table ${name} || :
ip route flush cache ip route flush cache
ip route list table ${name} || : ip route list table ${name} || :
retVal=$? retVal=$?
if [ $retVal -eq 2 ]; then if [ $retVal -eq 2 ]; then
echo "table is empty" echo "table is empty"
elif [ $retVal -ne 0 ]; then elif [ $retVal -ne 0 ]; then
exit 1 exit 1
fi fi
rm /run/openvpn/${name}/ifconfig_local rm /run/openvpn/${name}/ifconfig_local
dependencies=(${quoteEach dependentServices}) dependencies=(${quoteEach dependentServices})
for i in "''${dependencies[@]}"; do for i in "''${dependencies[@]}"; do
systemctl stop "$i" || : systemctl stop "$i" || :
done done
echo "Running route-down DONE" echo "Running route-down DONE"
''; '';
}; };
in in
{ {
options = options =
@ -205,7 +225,7 @@ in
options = { options = {
enable = lib.mkEnableOption "OpenVPN config"; enable = lib.mkEnableOption "OpenVPN config";
package = lib.mkPackageOption pkgs "openvpn" {}; package = lib.mkPackageOption pkgs "openvpn" { };
provider = lib.mkOption { provider = lib.mkOption {
description = "VPN provider, if given uses ready-made configuration."; description = "VPN provider, if given uses ready-made configuration.";
@ -243,68 +263,76 @@ in
}; };
}; };
in in
{ {
shb.vpn = lib.mkOption { shb.vpn = lib.mkOption {
description = "OpenVPN instances."; description = "OpenVPN instances.";
default = {}; default = { };
type = lib.types.attrsOf instanceOption; type = lib.types.attrsOf instanceOption;
};
}; };
};
config = { config = {
services.openvpn.servers = services.openvpn.servers =
let let
instanceConfig = name: c: lib.mkIf c.enable { instanceConfig =
${name} = { name: c:
autoStart = true; lib.mkIf c.enable {
${name} = {
autoStart = true;
up = "mkdir -p /run/openvpn/${name}"; up = "mkdir -p /run/openvpn/${name}";
config = nordvpnConfig { config = nordvpnConfig {
inherit name; inherit name;
inherit (c) dev remoteServerIP authFile; inherit (c) dev remoteServerIP authFile;
dependentServices = lib.optional (c.proxyPort != null) "tinyproxy-${name}.service"; dependentServices = lib.optional (c.proxyPort != null) "tinyproxy-${name}.service";
};
}; };
}; };
};
in in
lib.mkMerge (lib.mapAttrsToList instanceConfig cfg); lib.mkMerge (lib.mapAttrsToList instanceConfig cfg);
systemd.tmpfiles.rules = map (name: systemd.tmpfiles.rules = map (name: "d /tmp/openvpn/${name}.status 0700 root root") (
"d /tmp/openvpn/${name}.status 0700 root root" lib.attrNames cfg
) (lib.attrNames cfg); );
networking.iproute2.enable = true; networking.iproute2.enable = true;
networking.iproute2.rttablesExtraConfig = networking.iproute2.rttablesExtraConfig = lib.concatStringsSep "\n" (
lib.concatStringsSep "\n" (lib.mapAttrsToList (name: c: "${toString c.routingNumber} ${name}") cfg); lib.mapAttrsToList (name: c: "${toString c.routingNumber} ${name}") cfg
);
shb.tinyproxy = shb.tinyproxy =
let let
instanceConfig = name: c: lib.mkIf (c.enable && c.proxyPort != null) { instanceConfig =
${name} = { name: c:
enable = true; lib.mkIf (c.enable && c.proxyPort != null) {
# package = pkgs.tinyproxy.overrideAttrs (old: { ${name} = {
# withDebug = false; enable = true;
# patches = old.patches ++ [ # package = pkgs.tinyproxy.overrideAttrs (old: {
# (pkgs.fetchpatch { # withDebug = false;
# name = ""; # patches = old.patches ++ [
# url = "https://github.com/tinyproxy/tinyproxy/pull/494/commits/2532ba09896352b31f3538d7819daa1fc3f829f1.patch"; # (pkgs.fetchpatch {
# sha256 = "sha256-Q0MkHnttW8tH3+hoCt9ACjHjmmZQgF6pC/menIrU0Co="; # name = "";
# }) # url = "https://github.com/tinyproxy/tinyproxy/pull/494/commits/2532ba09896352b31f3538d7819daa1fc3f829f1.patch";
# ]; # sha256 = "sha256-Q0MkHnttW8tH3+hoCt9ACjHjmmZQgF6pC/menIrU0Co=";
# }); # })
dynamicBindFile = "/run/openvpn/${name}/ifconfig_local"; # ];
settings = { # });
Port = c.proxyPort; dynamicBindFile = "/run/openvpn/${name}/ifconfig_local";
Listen = "127.0.0.1"; settings = {
Syslog = "On"; Port = c.proxyPort;
LogLevel = "Info"; Listen = "127.0.0.1";
Allow = [ "127.0.0.1" "::1" ]; Syslog = "On";
ViaProxyName = ''"tinyproxy"''; LogLevel = "Info";
Allow = [
"127.0.0.1"
"::1"
];
ViaProxyName = ''"tinyproxy"'';
};
}; };
}; };
};
in in
lib.mkMerge (lib.mapAttrsToList instanceConfig cfg); lib.mkMerge (lib.mapAttrsToList instanceConfig cfg);
}; };
} }

View file

@ -1,4 +1,9 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.zfs; cfg = config.shb.zfs;
@ -21,38 +26,43 @@ in
This block implements the following contracts: This block implements the following contracts:
- mount - mount
''; '';
default = {}; default = { };
example = lib.literalExpression '' example = lib.literalExpression ''
shb.zfs."safe/postgresql".path = "/var/lib/postgresql"; shb.zfs."safe/postgresql".path = "/var/lib/postgresql";
''; '';
type = lib.types.attrsOf (lib.types.submodule { type = lib.types.attrsOf (
options = { lib.types.submodule {
enable = lib.mkEnableOption "shb.zfs.datasets"; options = {
enable = lib.mkEnableOption "shb.zfs.datasets";
poolName = lib.mkOption { poolName = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;
description = "ZFS pool name this dataset should be created on. Overrides the defaultPoolName."; description = "ZFS pool name this dataset should be created on. Overrides the defaultPoolName.";
}; };
path = lib.mkOption { path = lib.mkOption {
type = lib.types.str; type = lib.types.str;
description = "Path this dataset should be mounted on."; description = "Path this dataset should be mounted on.";
};
}; };
}; }
}); );
}; };
}; };
config = { config = {
assertions = [ assertions = [
{ {
assertion = lib.any (x: x.poolName == null) (lib.mapAttrsToList (n: v: v) cfg.datasets) -> cfg.defaultPoolName != null; assertion =
lib.any (x: x.poolName == null) (lib.mapAttrsToList (n: v: v) cfg.datasets)
-> cfg.defaultPoolName != null;
message = "Cannot have both datasets.poolName and defaultPoolName set to null"; message = "Cannot have both datasets.poolName and defaultPoolName set to null";
} }
]; ];
system.activationScripts = lib.mapAttrs' (name: cfg': system.activationScripts = lib.mapAttrs' (
name: cfg':
let let
dataset = (if cfg'.poolName != null then cfg'.poolName else cfg.defaultPoolName) + "/" + name; dataset = (if cfg'.poolName != null then cfg'.poolName else cfg.defaultPoolName) + "/" + name;
in in
@ -68,6 +78,7 @@ in
mountpoint=${cfg'.path} \ mountpoint=${cfg'.path} \
${dataset} ${dataset}
''; '';
}) cfg.datasets; }
) cfg.datasets;
}; };
} }

View file

@ -1,22 +1,35 @@
{ lib, ... }: { lib, ... }:
let let
inherit (lib) concatStringsSep literalMD mkOption optionalAttrs optionalString; inherit (lib)
inherit (lib.types) listOf nonEmptyListOf submodule str; concatStringsSep
literalMD
mkOption
optionalAttrs
optionalString
;
inherit (lib.types)
listOf
nonEmptyListOf
submodule
str
;
inherit (lib.shb) anyNotNull; inherit (lib.shb) anyNotNull;
in in
{ {
mkRequest = mkRequest =
{ user ? "", {
user ? "",
userText ? null, userText ? null,
sourceDirectories ? [ "/var/lib/example" ], sourceDirectories ? [ "/var/lib/example" ],
sourceDirectoriesText ? null, sourceDirectoriesText ? null,
excludePatterns ? [], excludePatterns ? [ ],
excludePatternsText ? null, excludePatternsText ? null,
beforeBackup ? [], beforeBackup ? [ ],
beforeBackupText ? null, beforeBackupText ? null,
afterBackup ? [], afterBackup ? [ ],
afterBackupText ? null, afterBackupText ? null,
}: mkOption { }:
mkOption {
description = '' description = ''
Request part of the backup contract. Request part of the backup contract.
@ -31,72 +44,102 @@ in
}; };
}; };
defaultText = optionalString (anyNotNull [ defaultText =
userText optionalString
sourceDirectoriesText (anyNotNull [
excludePatternsText userText
beforeBackupText sourceDirectoriesText
afterBackupText excludePatternsText
]) (literalMD '' beforeBackupText
{ afterBackupText
user = ${if userText != null then userText else user}; ])
sourceDirectories = ${if sourceDirectoriesText != null then sourceDirectoriesText else "[ " + concatStringsSep " " sourceDirectories + " ]"}; (literalMD ''
excludePatterns = ${if excludePatternsText != null then excludePatternsText else "[ " + concatStringsSep " " excludePatterns + " ]"}; {
hooks.beforeBackup = ${if beforeBackupText != null then beforeBackupText else "[ " + concatStringsSep " " beforeBackup + " ]"}; user = ${if userText != null then userText else user};
hooks.afterBackup = ${if afterBackupText != null then afterBackupText else "[ " + concatStringsSep " " afterBackup + " ]"}; sourceDirectories = ${
}; if sourceDirectoriesText != null then
''); sourceDirectoriesText
else
"[ " + concatStringsSep " " sourceDirectories + " ]"
};
excludePatterns = ${
if excludePatternsText != null then
excludePatternsText
else
"[ " + concatStringsSep " " excludePatterns + " ]"
};
hooks.beforeBackup = ${
if beforeBackupText != null then
beforeBackupText
else
"[ " + concatStringsSep " " beforeBackup + " ]"
};
hooks.afterBackup = ${
if afterBackupText != null then afterBackupText else "[ " + concatStringsSep " " afterBackup + " ]"
};
};
'');
type = submodule { type = submodule {
options = { options = {
user = mkOption { user =
description = '' mkOption {
Unix user doing the backups. description = ''
''; Unix user doing the backups.
type = str; '';
example = "vaultwarden"; type = str;
default = user; example = "vaultwarden";
} // optionalAttrs (userText != null) { default = user;
defaultText = literalMD userText; }
}; // optionalAttrs (userText != null) {
defaultText = literalMD userText;
};
sourceDirectories = mkOption { sourceDirectories =
description = "Directories to backup."; mkOption {
type = nonEmptyListOf str; description = "Directories to backup.";
example = "/var/lib/vaultwarden"; type = nonEmptyListOf str;
default = sourceDirectories; example = "/var/lib/vaultwarden";
} // optionalAttrs (sourceDirectoriesText != null) { default = sourceDirectories;
defaultText = literalMD sourceDirectoriesText; }
}; // optionalAttrs (sourceDirectoriesText != null) {
defaultText = literalMD sourceDirectoriesText;
};
excludePatterns = mkOption { excludePatterns =
description = "File patterns to exclude."; mkOption {
type = listOf str; description = "File patterns to exclude.";
default = excludePatterns; type = listOf str;
} // optionalAttrs (excludePatternsText != null) { default = excludePatterns;
defaultText = literalMD excludePatternsText; }
}; // optionalAttrs (excludePatternsText != null) {
defaultText = literalMD excludePatternsText;
};
hooks = mkOption { hooks = mkOption {
description = "Hooks to run around the backup."; description = "Hooks to run around the backup.";
default = {}; default = { };
type = submodule { type = submodule {
options = { options = {
beforeBackup = mkOption { beforeBackup =
description = "Hooks to run before backup."; mkOption {
type = listOf str; description = "Hooks to run before backup.";
default = beforeBackup; type = listOf str;
} // optionalAttrs (beforeBackupText != null) { default = beforeBackup;
defaultText = literalMD beforeBackupText; }
}; // optionalAttrs (beforeBackupText != null) {
defaultText = literalMD beforeBackupText;
};
afterBackup = mkOption { afterBackup =
description = "Hooks to run after backup."; mkOption {
type = listOf str; description = "Hooks to run after backup.";
default = afterBackup; type = listOf str;
} // optionalAttrs (afterBackupText != null) { default = afterBackup;
defaultText = literalMD afterBackupText; }
}; // optionalAttrs (afterBackupText != null) {
defaultText = literalMD afterBackupText;
};
}; };
}; };
}; };
@ -104,70 +147,79 @@ in
}; };
}; };
mkResult = { mkResult =
restoreScript ? "restore",
restoreScriptText ? null,
backupService ? "backup.service",
backupServiceText ? null,
}: mkOption {
description = ''
Result part of the backup contract.
Options set by the provider module that indicates the name of the backup and restor scripts.
'';
default = {
inherit restoreScript backupService;
};
defaultText = optionalString (anyNotNull [
restoreScriptText
backupServiceText
]) (literalMD ''
{ {
restoreScript = ${if restoreScriptText != null then restoreScriptText else restoreScript}; restoreScript ? "restore",
backupService = ${if backupServiceText != null then backupServiceText else backupService}; restoreScriptText ? null,
} backupService ? "backup.service",
''); backupServiceText ? null,
}:
mkOption {
description = ''
Result part of the backup contract.
type = submodule { Options set by the provider module that indicates the name of the backup and restor scripts.
options = { '';
restoreScript = mkOption { default = {
description = '' inherit restoreScript backupService;
Name of script that can restore the database. };
One can then list snapshots with:
```bash defaultText =
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots optionalString
``` (anyNotNull [
restoreScriptText
backupServiceText
])
(literalMD ''
{
restoreScript = ${if restoreScriptText != null then restoreScriptText else restoreScript};
backupService = ${if backupServiceText != null then backupServiceText else backupService};
}
'');
And restore the database with: type = submodule {
options = {
restoreScript =
mkOption {
description = ''
Name of script that can restore the database.
One can then list snapshots with:
```bash ```bash
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore latest $ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots
``` ```
'';
type = str;
default = restoreScript;
} // optionalAttrs (restoreScriptText != null) {
defaultText = literalMD restoreScriptText;
};
backupService = mkOption { And restore the database with:
description = ''
Name of service backing up the database.
This script can be ran manually to backup the database: ```bash
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore latest
```
'';
type = str;
default = restoreScript;
}
// optionalAttrs (restoreScriptText != null) {
defaultText = literalMD restoreScriptText;
};
```bash backupService =
$ systemctl start ${if backupServiceText != null then backupServiceText else backupService} mkOption {
``` description = ''
''; Name of service backing up the database.
type = str;
default = backupService; This script can be ran manually to backup the database:
} // optionalAttrs (backupServiceText != null) {
defaultText = literalMD backupServiceText; ```bash
$ systemctl start ${if backupServiceText != null then backupServiceText else backupService}
```
'';
type = str;
default = backupService;
}
// optionalAttrs (backupServiceText != null) {
defaultText = literalMD backupServiceText;
};
}; };
}; };
}; };
};
} }

View file

@ -1,6 +1,6 @@
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let let
contracts = pkgs.callPackage ../. {}; contracts = pkgs.callPackage ../. { };
inherit (lib) mkOption; inherit (lib) mkOption;
inherit (lib.types) submodule; inherit (lib.types) submodule;

View file

@ -1,10 +1,17 @@
{ pkgs, lib }: { pkgs, lib }:
let let
inherit (lib) concatMapStringsSep getAttrFromPath mkIf optionalAttrs setAttrByPath; inherit (lib)
concatMapStringsSep
getAttrFromPath
mkIf
optionalAttrs
setAttrByPath
;
in in
{ name, {
name,
providerRoot, providerRoot,
modules ? [], modules ? [ ],
username ? "me", username ? "me",
sourceDirectories ? [ sourceDirectories ? [
"/opt/files/A" "/opt/files/A"
@ -12,107 +19,115 @@ in
], ],
settings, # { repository, config } -> attrset settings, # { repository, config } -> attrset
extraConfig ? null, # { username, config } -> attrset extraConfig ? null, # { username, config } -> attrset
}: lib.shb.runNixOSTest { }:
lib.shb.runNixOSTest {
inherit name; inherit name;
nodes.machine = { config, ... }: { nodes.machine =
imports = [ lib.shb.baseImports ] ++ modules; { config, ... }:
{
imports = [ lib.shb.baseImports ] ++ modules;
config = lib.mkMerge [ config = lib.mkMerge [
(setAttrByPath providerRoot { (setAttrByPath providerRoot {
request = { request = {
inherit sourceDirectories; inherit sourceDirectories;
user = username; user = username;
}; };
settings = settings { settings = settings {
inherit config; inherit config;
repository = "/opt/repos/${name}"; repository = "/opt/repos/${name}";
}; };
}) })
(mkIf (username != "root") { (mkIf (username != "root") {
users.users.${username} = { users.users.${username} = {
isSystemUser = true; isSystemUser = true;
extraGroups = [ "sudoers" ]; extraGroups = [ "sudoers" ];
group = "root"; group = "root";
}; };
}) })
(optionalAttrs (extraConfig != null) (extraConfig { inherit username config; })) (optionalAttrs (extraConfig != null) (extraConfig {
]; inherit username config;
}; }))
];
};
extraPythonPackages = p: [ p.dictdiffer ]; extraPythonPackages = p: [ p.dictdiffer ];
skipTypeCheck = true; skipTypeCheck = true;
testScript = { nodes, ... }: let testScript =
provider = (getAttrFromPath providerRoot nodes.machine).result; { nodes, ... }:
in '' let
from dictdiffer import diff provider = (getAttrFromPath providerRoot nodes.machine).result;
in
''
from dictdiffer import diff
username = "${username}" username = "${username}"
sourceDirectories = [ ${concatMapStringsSep ", " (x: ''"${x}"'') sourceDirectories} ] sourceDirectories = [ ${concatMapStringsSep ", " (x: ''"${x}"'') sourceDirectories} ]
def list_files(dir): def list_files(dir):
files_and_content = {} files_and_content = {}
files = machine.succeed(f"""find {dir} -type f""").split("\n")[:-1] files = machine.succeed(f"""find {dir} -type f""").split("\n")[:-1]
for f in files: for f in files:
content = machine.succeed(f"""cat {f}""").strip() content = machine.succeed(f"""cat {f}""").strip()
files_and_content[f] = content files_and_content[f] = content
return files_and_content return files_and_content
def assert_files(dir, files): def assert_files(dir, files):
result = list(diff(list_files(dir), files)) result = list(diff(list_files(dir), files))
if len(result) > 0: if len(result) > 0:
raise Exception("Unexpected files:", result) raise Exception("Unexpected files:", result)
with subtest("Create initial content"): with subtest("Create initial content"):
for path in sourceDirectories: for path in sourceDirectories:
machine.succeed(f""" machine.succeed(f"""
mkdir -p {path} mkdir -p {path}
echo repo_fileA_1 > {path}/fileA echo repo_fileA_1 > {path}/fileA
echo repo_fileB_1 > {path}/fileB echo repo_fileB_1 > {path}/fileB
chown {username}: -R {path} chown {username}: -R {path}
chmod go-rwx -R {path} chmod go-rwx -R {path}
""")
for path in sourceDirectories:
assert_files(path, {
f'{path}/fileA': 'repo_fileA_1',
f'{path}/fileB': 'repo_fileB_1',
})
with subtest("First backup in repo"):
print(machine.succeed("systemctl cat ${provider.backupService}"))
machine.succeed("systemctl start ${provider.backupService}")
with subtest("New content"):
for path in sourceDirectories:
machine.succeed(f"""
echo repo_fileA_2 > {path}/fileA
echo repo_fileB_2 > {path}/fileB
""") """)
assert_files(path, { for path in sourceDirectories:
f'{path}/fileA': 'repo_fileA_2', assert_files(path, {
f'{path}/fileB': 'repo_fileB_2', f'{path}/fileA': 'repo_fileA_1',
}) f'{path}/fileB': 'repo_fileB_1',
})
with subtest("Delete content"): with subtest("First backup in repo"):
for path in sourceDirectories: print(machine.succeed("systemctl cat ${provider.backupService}"))
machine.succeed(f"""rm -r {path}/*""") machine.succeed("systemctl start ${provider.backupService}")
assert_files(path, {}) with subtest("New content"):
for path in sourceDirectories:
machine.succeed(f"""
echo repo_fileA_2 > {path}/fileA
echo repo_fileB_2 > {path}/fileB
""")
with subtest("Restore initial content from repo"): assert_files(path, {
machine.succeed("""${provider.restoreScript} restore latest""") f'{path}/fileA': 'repo_fileA_2',
f'{path}/fileB': 'repo_fileB_2',
})
for path in sourceDirectories: with subtest("Delete content"):
assert_files(path, { for path in sourceDirectories:
f'{path}/fileA': 'repo_fileA_1', machine.succeed(f"""rm -r {path}/*""")
f'{path}/fileB': 'repo_fileB_1',
}) assert_files(path, {})
with subtest("Restore initial content from repo"):
machine.succeed("""${provider.restoreScript} restore latest""")
for path in sourceDirectories:
assert_files(path, {
f'{path}/fileA': 'repo_fileA_1',
f'{path}/fileB': 'repo_fileB_1',
})
''; '';
} }

View file

@ -1,12 +1,19 @@
{ lib, ... }: { lib, ... }:
let let
inherit (lib) mkOption literalExpression literalMD optionalAttrs optionalString; inherit (lib)
mkOption
literalExpression
literalMD
optionalAttrs
optionalString
;
inherit (lib.types) submodule str; inherit (lib.types) submodule str;
inherit (lib.shb) anyNotNull; inherit (lib.shb) anyNotNull;
in in
{ {
mkRequest = mkRequest =
{ user ? "root", {
user ? "root",
userText ? null, userText ? null,
backupName ? "dump", backupName ? "dump",
backupNameText ? null, backupNameText ? null,
@ -14,7 +21,8 @@ in
backupCmdText ? null, backupCmdText ? null,
restoreCmd ? "", restoreCmd ? "",
restoreCmdText ? null, restoreCmdText ? null,
}: mkOption { }:
mkOption {
description = '' description = ''
Request part of the backup contract. Request part of the backup contract.
@ -23,137 +31,161 @@ in
''; '';
default = { default = {
inherit user backupName backupCmd restoreCmd; inherit
user
backupName
backupCmd
restoreCmd
;
}; };
defaultText = optionalString (anyNotNull [ defaultText =
userText optionalString
backupNameText (anyNotNull [
backupCmdText userText
restoreCmdText backupNameText
]) (literalMD '' backupCmdText
{ restoreCmdText
user = ${if userText != null then userText else user}; ])
backupName = ${if backupNameText != null then backupNameText else backupName}; (literalMD ''
backupCmd = ${if backupCmdText != null then backupCmdText else backupCmd}; {
restoreCmd = ${if restoreCmdText != null then restoreCmdText else restoreCmd}; user = ${if userText != null then userText else user};
} backupName = ${if backupNameText != null then backupNameText else backupName};
''); backupCmd = ${if backupCmdText != null then backupCmdText else backupCmd};
restoreCmd = ${if restoreCmdText != null then restoreCmdText else restoreCmd};
}
'');
type = submodule { type = submodule {
options = { options = {
user = mkOption { user =
description = '' mkOption {
Unix user doing the backups. description = ''
Unix user doing the backups.
This should be an admin user having access to all databases. This should be an admin user having access to all databases.
''; '';
type = str; type = str;
example = "postgres"; example = "postgres";
default = user; default = user;
} // optionalAttrs (userText != null) { }
defaultText = literalMD userText; // optionalAttrs (userText != null) {
}; defaultText = literalMD userText;
};
backupName = mkOption { backupName =
description = "Name of the backup in the repository."; mkOption {
type = str; description = "Name of the backup in the repository.";
example = "postgresql.sql"; type = str;
default = backupName; example = "postgresql.sql";
} // optionalAttrs (backupNameText != null) { default = backupName;
defaultText = literalMD backupNameText; }
}; // optionalAttrs (backupNameText != null) {
defaultText = literalMD backupNameText;
};
backupCmd = mkOption { backupCmd =
description = "Command that produces the database dump on stdout."; mkOption {
type = str; description = "Command that produces the database dump on stdout.";
example = literalExpression '' type = str;
''${pkgs.postgresql}/bin/pg_dumpall | ''${pkgs.gzip}/bin/gzip --rsyncable example = literalExpression ''
''; ''${pkgs.postgresql}/bin/pg_dumpall | ''${pkgs.gzip}/bin/gzip --rsyncable
default = backupCmd; '';
} // optionalAttrs (backupCmdText != null) { default = backupCmd;
defaultText = literalMD backupCmdText; }
}; // optionalAttrs (backupCmdText != null) {
defaultText = literalMD backupCmdText;
};
restoreCmd = mkOption { restoreCmd =
description = "Command that reads the database dump on stdin and restores the database."; mkOption {
type = str; description = "Command that reads the database dump on stdin and restores the database.";
example = literalExpression '' type = str;
''${pkgs.gzip}/bin/gunzip | ''${pkgs.postgresql}/bin/psql postgres example = literalExpression ''
''; ''${pkgs.gzip}/bin/gunzip | ''${pkgs.postgresql}/bin/psql postgres
default = restoreCmd; '';
} // optionalAttrs (restoreCmdText != null) { default = restoreCmd;
defaultText = literalMD restoreCmdText; }
}; // optionalAttrs (restoreCmdText != null) {
defaultText = literalMD restoreCmdText;
};
}; };
}; };
}; };
mkResult =
mkResult = {
restoreScript ? "restore",
restoreScriptText ? null,
backupService ? "backup.service",
backupServiceText ? null,
}: mkOption {
description = ''
Result part of the backup contract.
Options set by the provider module that indicates the name of the backup and restor scripts.
'';
default = {
inherit restoreScript backupService;
};
defaultText = optionalString (anyNotNull [
restoreScriptText
backupServiceText
]) (literalMD ''
{ {
restoreScript = ${if restoreScriptText != null then restoreScriptText else restoreScript}; restoreScript ? "restore",
backupService = ${if backupServiceText != null then backupServiceText else backupService}; restoreScriptText ? null,
} backupService ? "backup.service",
''); backupServiceText ? null,
}:
mkOption {
description = ''
Result part of the backup contract.
type = submodule { Options set by the provider module that indicates the name of the backup and restor scripts.
options = { '';
restoreScript = mkOption { default = {
description = '' inherit restoreScript backupService;
Name of script that can restore the database. };
One can then list snapshots with:
```bash defaultText =
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots optionalString
``` (anyNotNull [
restoreScriptText
backupServiceText
])
(literalMD ''
{
restoreScript = ${if restoreScriptText != null then restoreScriptText else restoreScript};
backupService = ${if backupServiceText != null then backupServiceText else backupService};
}
'');
And restore the database with: type = submodule {
options = {
restoreScript =
mkOption {
description = ''
Name of script that can restore the database.
One can then list snapshots with:
```bash ```bash
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore latest $ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots
``` ```
'';
type = str;
default = restoreScript;
} // optionalAttrs (restoreScriptText != null) {
defaultText = literalMD restoreScriptText;
};
backupService = mkOption { And restore the database with:
description = ''
Name of service backing up the database.
This script can be ran manually to backup the database: ```bash
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore latest
```
'';
type = str;
default = restoreScript;
}
// optionalAttrs (restoreScriptText != null) {
defaultText = literalMD restoreScriptText;
};
```bash backupService =
$ systemctl start ${if backupServiceText != null then backupServiceText else backupService} mkOption {
``` description = ''
''; Name of service backing up the database.
type = str;
default = backupService; This script can be ran manually to backup the database:
} // optionalAttrs (backupServiceText != null) {
defaultText = literalMD backupServiceText; ```bash
$ systemctl start ${if backupServiceText != null then backupServiceText else backupService}
```
'';
type = str;
default = backupService;
}
// optionalAttrs (backupServiceText != null) {
defaultText = literalMD backupServiceText;
};
}; };
}; };
}; };
};
} }

View file

@ -1,6 +1,6 @@
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let let
contracts = pkgs.callPackage ../. {}; contracts = pkgs.callPackage ../. { };
inherit (lib) mkOption; inherit (lib) mkOption;
inherit (lib.types) submodule; inherit (lib.types) submodule;

View file

@ -1,84 +1,98 @@
{ pkgs, lib }: { pkgs, lib }:
let let
inherit (lib) getAttrFromPath mkIf optionalAttrs setAttrByPath; inherit (lib)
getAttrFromPath
mkIf
optionalAttrs
setAttrByPath
;
in in
{ name, {
name,
requesterRoot, requesterRoot,
providerRoot, providerRoot,
extraConfig ? null, # { config, database } -> attrset extraConfig ? null, # { config, database } -> attrset
modules ? [], modules ? [ ],
database ? "me", database ? "me",
settings, # { repository, config } -> attrset settings, # { repository, config } -> attrset
}: lib.shb.runNixOSTest { }:
lib.shb.runNixOSTest {
inherit name; inherit name;
nodes.machine = { config, ... }: { nodes.machine =
imports = [ lib.shb.baseImports ] ++ modules; { config, ... }:
config = lib.mkMerge [ {
(setAttrByPath providerRoot { imports = [ lib.shb.baseImports ] ++ modules;
request = (getAttrFromPath requesterRoot config).request; config = lib.mkMerge [
settings = settings { (setAttrByPath providerRoot {
inherit config; request = (getAttrFromPath requesterRoot config).request;
repository = "/opt/repos/database"; settings = settings {
}; inherit config;
}) repository = "/opt/repos/database";
(mkIf (database != "root") { };
users.users.${database} = { })
isSystemUser = true; (mkIf (database != "root") {
extraGroups = [ "sudoers" ]; users.users.${database} = {
group = "root"; isSystemUser = true;
}; extraGroups = [ "sudoers" ];
}) group = "root";
(optionalAttrs (extraConfig != null) (extraConfig { inherit config database; })) };
]; })
}; (optionalAttrs (extraConfig != null) (extraConfig {
inherit config database;
}))
];
};
testScript = { nodes, ... }: let testScript =
provider = getAttrFromPath providerRoot nodes.machine; { nodes, ... }:
in '' let
import csv provider = getAttrFromPath providerRoot nodes.machine;
in
''
import csv
start_all() start_all()
machine.wait_for_unit("postgresql.service") machine.wait_for_unit("postgresql.service")
machine.wait_for_open_port(5432) machine.wait_for_open_port(5432)
def peer_cmd(cmd, db="me"): def peer_cmd(cmd, db="me"):
return "sudo -u ${database} psql -U ${database} {db} --csv --command \"{cmd}\"".format(cmd=cmd, db=db) return "sudo -u ${database} psql -U ${database} {db} --csv --command \"{cmd}\"".format(cmd=cmd, db=db)
def query(query): def query(query):
res = machine.succeed(peer_cmd(query)) res = machine.succeed(peer_cmd(query))
return list(dict(l) for l in csv.DictReader(res.splitlines())) return list(dict(l) for l in csv.DictReader(res.splitlines()))
def cmp_tables(a, b): def cmp_tables(a, b):
for i in range(max(len(a), len(b))): for i in range(max(len(a), len(b))):
diff = set(a[i]) ^ set(b[i]) diff = set(a[i]) ^ set(b[i])
if len(diff) > 0: if len(diff) > 0:
raise Exception(i, diff) raise Exception(i, diff)
table = [{'name': 'car', 'count': '1'}, {'name': 'lollipop', 'count': '2'}] table = [{'name': 'car', 'count': '1'}, {'name': 'lollipop', 'count': '2'}]
with subtest("create fixture"): with subtest("create fixture"):
machine.succeed(peer_cmd("CREATE TABLE test (name text, count int)")) machine.succeed(peer_cmd("CREATE TABLE test (name text, count int)"))
machine.succeed(peer_cmd("INSERT INTO test VALUES ('car', 1), ('lollipop', 2)")) machine.succeed(peer_cmd("INSERT INTO test VALUES ('car', 1), ('lollipop', 2)"))
res = query("SELECT * FROM test") res = query("SELECT * FROM test")
cmp_tables(res, table) cmp_tables(res, table)
with subtest("backup"): with subtest("backup"):
print(machine.succeed("systemctl cat ${provider.result.backupService}")) print(machine.succeed("systemctl cat ${provider.result.backupService}"))
print(machine.succeed("ls -l /run/hardcodedsecrets/hardcodedsecret_passphrase")) print(machine.succeed("ls -l /run/hardcodedsecrets/hardcodedsecret_passphrase"))
machine.succeed("systemctl start ${provider.result.backupService}") machine.succeed("systemctl start ${provider.result.backupService}")
with subtest("drop database"): with subtest("drop database"):
machine.succeed(peer_cmd("DROP DATABASE ${database}", db="postgres")) machine.succeed(peer_cmd("DROP DATABASE ${database}", db="postgres"))
machine.fail(peer_cmd("SELECT * FROM test")) machine.fail(peer_cmd("SELECT * FROM test"))
with subtest("restore"): with subtest("restore"):
print(machine.succeed("readlink -f $(type ${provider.result.restoreScript})")) print(machine.succeed("readlink -f $(type ${provider.result.restoreScript})"))
machine.succeed("${provider.result.restoreScript} restore latest ") machine.succeed("${provider.result.restoreScript} restore latest ")
with subtest("check restoration"): with subtest("check restoration"):
res = query("SELECT * FROM test") res = query("SELECT * FROM test")
cmp_tables(res, table) cmp_tables(res, table)
''; '';
} }

View file

@ -4,55 +4,61 @@ let
inherit (lib.types) anything; inherit (lib.types) anything;
mkContractFunctions = mkContractFunctions =
{ mkRequest, {
mkRequest,
mkResult, mkResult,
}: { }:
{
mkRequester = requestCfg: { mkRequester = requestCfg: {
request = mkRequest requestCfg; request = mkRequest requestCfg;
result = mkResult {}; result = mkResult { };
}; };
mkProvider = mkProvider =
{ resultCfg, {
settings ? {}, resultCfg,
}: { settings ? { },
request = mkRequest {}; }:
{
request = mkRequest { };
result = mkResult resultCfg; result = mkResult resultCfg;
} // optionalAttrs (settings != {}) { inherit settings; }; }
// optionalAttrs (settings != { }) { inherit settings; };
contract = { contract = {
request = mkRequest {}; request = mkRequest { };
result = mkResult {}; result = mkResult { };
settings = mkOption { settings = mkOption {
description = '' description = ''
Optional attribute set with options specific to the provider. Optional attribute set with options specific to the provider.
''; '';
type = anything; type = anything;
}; };
}; };
}; };
importContract = module: importContract =
module:
let let
importedModule = pkgs.callPackage module {}; importedModule = pkgs.callPackage module { };
in in
mkContractFunctions { mkContractFunctions {
inherit (importedModule) mkRequest mkResult; inherit (importedModule) mkRequest mkResult;
}; };
in in
{ {
databasebackup = importContract ./databasebackup.nix; databasebackup = importContract ./databasebackup.nix;
backup = importContract ./backup.nix; backup = importContract ./backup.nix;
mount = pkgs.callPackage ./mount.nix {}; mount = pkgs.callPackage ./mount.nix { };
secret = importContract ./secret.nix; secret = importContract ./secret.nix;
ssl = pkgs.callPackage ./ssl.nix {}; ssl = pkgs.callPackage ./ssl.nix { };
test = { test = {
secret = pkgs.callPackage ./secret/test.nix {}; secret = pkgs.callPackage ./secret/test.nix { };
databasebackup = pkgs.callPackage ./databasebackup/test.nix {}; databasebackup = pkgs.callPackage ./databasebackup/test.nix { };
backup = pkgs.callPackage ./backup/test.nix {}; backup = pkgs.callPackage ./backup/test.nix { };
}; };
} }

View file

@ -1,20 +1,28 @@
{ lib, ... }: { lib, ... }:
let let
inherit (lib) concatStringsSep literalMD mkOption optionalAttrs optionalString; inherit (lib)
concatStringsSep
literalMD
mkOption
optionalAttrs
optionalString
;
inherit (lib.types) listOf submodule str; inherit (lib.types) listOf submodule str;
inherit (lib.shb) anyNotNull; inherit (lib.shb) anyNotNull;
in in
{ {
mkRequest = mkRequest =
{ mode ? "0400", {
mode ? "0400",
modeText ? null, modeText ? null,
owner ? "root", owner ? "root",
ownerText ? null, ownerText ? null,
group ? "root", group ? "root",
groupText ? null, groupText ? null,
restartUnits ? [], restartUnits ? [ ],
restartUnitsText ? null, restartUnitsText ? null,
}: mkOption { }:
mkOption {
description = '' description = ''
Request part of the secret contract. Request part of the secret contract.
@ -23,64 +31,87 @@ in
''; '';
default = { default = {
inherit mode owner group restartUnits; inherit
mode
owner
group
restartUnits
;
}; };
defaultText = optionalString (anyNotNull [ defaultText =
modeText optionalString
ownerText (anyNotNull [
groupText modeText
restartUnitsText ownerText
]) (literalMD '' groupText
{ restartUnitsText
mode = ${if modeText != null then modeText else mode}; ])
owner = ${if ownerText != null then ownerText else owner}; (literalMD ''
group = ${if groupText != null then groupText else group}; {
restartUnits = ${if restartUnitsText != null then restartUnitsText else "[ " + concatStringsSep " " restartUnits + " ]"}; mode = ${if modeText != null then modeText else mode};
} owner = ${if ownerText != null then ownerText else owner};
''); group = ${if groupText != null then groupText else group};
restartUnits = ${
if restartUnitsText != null then
restartUnitsText
else
"[ " + concatStringsSep " " restartUnits + " ]"
};
}
'');
type = submodule { type = submodule {
options = { options = {
mode = mkOption { mode =
description = '' mkOption {
Mode of the secret file. description = ''
''; Mode of the secret file.
type = str; '';
default = mode; type = str;
} // optionalAttrs (modeText != null) { default = mode;
defaultText = literalMD modeText; }
}; // optionalAttrs (modeText != null) {
defaultText = literalMD modeText;
};
owner = mkOption ({ owner = mkOption (
description = '' {
Linux user owning the secret file. description = ''
''; Linux user owning the secret file.
type = str; '';
default = owner; type = str;
} // optionalAttrs (ownerText != null) { default = owner;
defaultText = literalMD ownerText; }
}); // optionalAttrs (ownerText != null) {
defaultText = literalMD ownerText;
}
);
group = mkOption { group =
description = '' mkOption {
Linux group owning the secret file. description = ''
''; Linux group owning the secret file.
type = str; '';
default = group; type = str;
} // optionalAttrs (groupText != null) { default = group;
defaultText = literalMD groupText; }
}; // optionalAttrs (groupText != null) {
defaultText = literalMD groupText;
};
restartUnits = mkOption ({ restartUnits = mkOption (
description = '' {
Systemd units to restart after the secret is updated. description = ''
''; Systemd units to restart after the secret is updated.
type = listOf str; '';
default = restartUnits; type = listOf str;
} // optionalAttrs (restartUnitsText != null) { default = restartUnits;
defaultText = literalMD restartUnitsText; }
}); // optionalAttrs (restartUnitsText != null) {
defaultText = literalMD restartUnitsText;
}
);
}; };
}; };
}; };
@ -90,34 +121,39 @@ in
path ? "/run/secrets/secret", path ? "/run/secrets/secret",
pathText ? null, pathText ? null,
}: }:
mkOption ({ mkOption (
description = '' {
Result part of the secret contract. description = ''
Result part of the secret contract.
Options set by the provider module that indicates where the secret can be found. Options set by the provider module that indicates where the secret can be found.
''; '';
default = { default = {
inherit path; inherit path;
}; };
type = submodule { type = submodule {
options = { options = {
path = mkOption { path =
type = lib.types.path; mkOption {
description = '' type = lib.types.path;
Path to the file containing the secret generated out of band. description = ''
Path to the file containing the secret generated out of band.
This path will exist after deploying to a target host, This path will exist after deploying to a target host,
it is not available through the nix store. it is not available through the nix store.
''; '';
default = path; default = path;
} // optionalAttrs (pathText != null) { }
defaultText = pathText; // optionalAttrs (pathText != null) {
defaultText = pathText;
};
}; };
}; };
}; }
} // optionalAttrs (pathText != null) { // optionalAttrs (pathText != null) {
defaultText = { defaultText = {
path = pathText; path = pathText;
}; };
}); }
);
} }

View file

@ -1,6 +1,6 @@
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let let
contracts = pkgs.callPackage ../. {}; contracts = pkgs.callPackage ../. { };
inherit (lib) mkOption; inherit (lib) mkOption;
inherit (lib.types) submodule; inherit (lib.types) submodule;

View file

@ -3,24 +3,33 @@ let
inherit (lib) getAttrFromPath setAttrByPath; inherit (lib) getAttrFromPath setAttrByPath;
inherit (lib) mkIf; inherit (lib) mkIf;
in in
{ name, {
configRoot, name,
settingsCfg, # str -> attrset configRoot,
modules ? [], settingsCfg, # str -> attrset
owner ? "root", modules ? [ ],
group ? "root", owner ? "root",
mode ? "0400", group ? "root",
restartUnits ? [ "myunit.service" ], mode ? "0400",
}: lib.shb.runNixOSTest { restartUnits ? [ "myunit.service" ],
name = "secret_${name}_${owner}_${group}_${mode}"; }:
lib.shb.runNixOSTest {
name = "secret_${name}_${owner}_${group}_${mode}";
nodes.machine = { config, ... }: { nodes.machine =
{ config, ... }:
{
imports = [ lib.shb.baseImports ] ++ modules; imports = [ lib.shb.baseImports ] ++ modules;
config = lib.mkMerge [ config = lib.mkMerge [
(setAttrByPath configRoot { (setAttrByPath configRoot {
A = { A = {
request = { request = {
inherit owner group mode restartUnits; inherit
owner
group
mode
restartUnits
;
}; };
settings = settingsCfg "secretA"; settings = settingsCfg "secretA";
}; };
@ -29,35 +38,36 @@ in
users.users.${owner}.isNormalUser = true; users.users.${owner}.isNormalUser = true;
}) })
(mkIf (group != "root") { (mkIf (group != "root") {
users.groups.${group} = {}; users.groups.${group} = { };
}) })
]; ];
}; };
testScript = { nodes, ... }: testScript =
let { nodes, ... }:
result = (getAttrFromPath configRoot nodes.machine)."A".result; let
in result = (getAttrFromPath configRoot nodes.machine)."A".result;
'' in
owner = machine.succeed("stat -c '%U' ${result.path}").strip() ''
print(f"Got owner {owner}") owner = machine.succeed("stat -c '%U' ${result.path}").strip()
if owner != "${owner}": print(f"Got owner {owner}")
raise Exception(f"Owner should be '${owner}' but got '{owner}'") if owner != "${owner}":
raise Exception(f"Owner should be '${owner}' but got '{owner}'")
group = machine.succeed("stat -c '%G' ${result.path}").strip() group = machine.succeed("stat -c '%G' ${result.path}").strip()
print(f"Got group {group}") print(f"Got group {group}")
if group != "${group}": if group != "${group}":
raise Exception(f"Group should be '${group}' but got '{group}'") raise Exception(f"Group should be '${group}' but got '{group}'")
mode = str(int(machine.succeed("stat -c '%a' ${result.path}").strip())) mode = str(int(machine.succeed("stat -c '%a' ${result.path}").strip()))
print(f"Got mode {mode}") print(f"Got mode {mode}")
wantedMode = str(int("${mode}")) wantedMode = str(int("${mode}"))
if mode != wantedMode: if mode != wantedMode:
raise Exception(f"Mode should be '{wantedMode}' but got '{mode}'") raise Exception(f"Mode should be '{wantedMode}' but got '{mode}'")
content = machine.succeed("cat ${result.path}").strip() content = machine.succeed("cat ${result.path}").strip()
print(f"Got content {content}") print(f"Got content {content}")
if content != "secretA": if content != "secretA":
raise Exception(f"Content should be 'secretA' but got '{content}'") raise Exception(f"Content should be 'secretA' but got '{content}'")
''; '';
} }

View file

@ -1,6 +1,6 @@
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let let
contracts = pkgs.callPackage ../. {}; contracts = pkgs.callPackage ../. { };
in in
{ {
options.shb.contracts.ssl = lib.mkOption { options.shb.contracts.ssl = lib.mkOption {

View file

@ -1,9 +1,14 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.arr; cfg = config.shb.arr;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
apps = { apps = {
radarr = { radarr = {
@ -11,7 +16,7 @@ let
moreOptions = { moreOptions = {
settings = lib.mkOption { settings = lib.mkOption {
description = "Specific options for radarr."; description = "Specific options for radarr.";
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
freeformType = apps.radarr.settingsFormat.type; freeformType = apps.radarr.settingsFormat.type;
options = { options = {
@ -20,7 +25,10 @@ let
description = "Path to api key secret file."; description = "Path to api key secret file.";
}; };
LogLevel = lib.mkOption { LogLevel = lib.mkOption {
type = lib.types.enum ["debug" "info"]; type = lib.types.enum [
"debug"
"info"
];
description = "Log level."; description = "Log level.";
default = "info"; default = "info";
}; };
@ -69,7 +77,7 @@ let
moreOptions = { moreOptions = {
settings = lib.mkOption { settings = lib.mkOption {
description = "Specific options for sonarr."; description = "Specific options for sonarr.";
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
freeformType = apps.sonarr.settingsFormat.type; freeformType = apps.sonarr.settingsFormat.type;
options = { options = {
@ -78,7 +86,10 @@ let
description = "Path to api key secret file."; description = "Path to api key secret file.";
}; };
LogLevel = lib.mkOption { LogLevel = lib.mkOption {
type = lib.types.enum ["debug" "info"]; type = lib.types.enum [
"debug"
"info"
];
description = "Log level."; description = "Log level.";
default = "info"; default = "info";
}; };
@ -122,12 +133,15 @@ let
moreOptions = { moreOptions = {
settings = lib.mkOption { settings = lib.mkOption {
description = "Specific options for bazarr."; description = "Specific options for bazarr.";
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
freeformType = apps.bazarr.settingsFormat.type; freeformType = apps.bazarr.settingsFormat.type;
options = { options = {
LogLevel = lib.mkOption { LogLevel = lib.mkOption {
type = lib.types.enum ["debug" "info"]; type = lib.types.enum [
"debug"
"info"
];
description = "Log level."; description = "Log level.";
default = "info"; default = "info";
}; };
@ -147,12 +161,15 @@ let
moreOptions = { moreOptions = {
settings = lib.mkOption { settings = lib.mkOption {
description = "Specific options for readarr."; description = "Specific options for readarr.";
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
freeformType = apps.readarr.settingsFormat.type; freeformType = apps.readarr.settingsFormat.type;
options = { options = {
LogLevel = lib.mkOption { LogLevel = lib.mkOption {
type = lib.types.enum ["debug" "info"]; type = lib.types.enum [
"debug"
"info"
];
description = "Log level."; description = "Log level.";
default = "info"; default = "info";
}; };
@ -171,12 +188,15 @@ let
moreOptions = { moreOptions = {
settings = lib.mkOption { settings = lib.mkOption {
description = "Specific options for lidarr."; description = "Specific options for lidarr.";
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
freeformType = apps.lidarr.settingsFormat.type; freeformType = apps.lidarr.settingsFormat.type;
options = { options = {
LogLevel = lib.mkOption { LogLevel = lib.mkOption {
type = lib.types.enum ["debug" "info"]; type = lib.types.enum [
"debug"
"info"
];
description = "Log level."; description = "Log level.";
default = "info"; default = "info";
}; };
@ -191,11 +211,11 @@ let
}; };
}; };
jackett = { jackett = {
settingsFormat = pkgs.formats.json {}; settingsFormat = pkgs.formats.json { };
moreOptions = { moreOptions = {
settings = lib.mkOption { settings = lib.mkOption {
description = "Specific options for jackett."; description = "Specific options for jackett.";
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
freeformType = apps.jackett.settingsFormat.type; freeformType = apps.jackett.settingsFormat.type;
options = { options = {
@ -214,13 +234,18 @@ let
default = null; default = null;
}; };
ProxyType = lib.mkOption { ProxyType = lib.mkOption {
type = lib.types.enum [ "-1" "0" "1" "2" ]; type = lib.types.enum [
"-1"
"0"
"1"
"2"
];
default = "-1"; default = "-1";
description = '' description = ''
-1 = disabled -1 = disabled
0 = HTTP 0 = HTTP
1 = SOCKS4 1 = SOCKS4
2 = SOCKS5 2 = SOCKS5
''; '';
}; };
ProxyUrl = lib.mkOption { ProxyUrl = lib.mkOption {
@ -256,83 +281,101 @@ let
}; };
}; };
vhosts = { extraBypassResources ? [] }: c: { vhosts =
inherit (c) subdomain domain authEndpoint ssl; {
extraBypassResources ? [ ],
}:
c: {
inherit (c)
subdomain
domain
authEndpoint
ssl
;
upstream = "http://127.0.0.1:${toString c.settings.Port}"; upstream = "http://127.0.0.1:${toString c.settings.Port}";
autheliaRules = lib.optionals (!(isNull c.authEndpoint)) [ autheliaRules = lib.optionals (!(isNull c.authEndpoint)) [
{ {
domain = "${c.subdomain}.${c.domain}"; domain = "${c.subdomain}.${c.domain}";
policy = "bypass"; policy = "bypass";
resources = extraBypassResources ++ [ resources = extraBypassResources ++ [
"^/api.*" "^/api.*"
"^/feed.*" "^/feed.*"
]; ];
} }
{ {
domain = "${c.subdomain}.${c.domain}"; domain = "${c.subdomain}.${c.domain}";
policy = "two_factor"; policy = "two_factor";
subject = ["group:arr_user"]; subject = [ "group:arr_user" ];
} }
]; ];
};
appOption = name: c: lib.nameValuePair name (lib.mkOption {
description = "Configuration for ${name}";
default = {};
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption name;
subdomain = lib.mkOption {
type = lib.types.str;
description = "Subdomain under which ${name} will be served.";
example = name;
};
domain = lib.mkOption {
type = lib.types.str;
description = "Domain under which ${name} will be served.";
example = "example.com";
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Directory where ${name} stores data.";
default = "/var/lib/${name}";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
authEndpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Endpoint to the SSO provider. Leave null to not have SSO configured.";
example = "https://authelia.example.com";
};
backup = lib.mkOption {
description = ''
Backup configuration.
'';
default = {};
type = lib.types.submodule {
options = contracts.backup.mkRequester {
user = name;
sourceDirectories = [
cfg.${name}.dataDir
];
excludePatterns = [".db-shm" ".db-wal" ".mono"];
};
};
};
} // (c.moreOptions or {});
}; };
});
appOption =
name: c:
lib.nameValuePair name (
lib.mkOption {
description = "Configuration for ${name}";
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption name;
subdomain = lib.mkOption {
type = lib.types.str;
description = "Subdomain under which ${name} will be served.";
example = name;
};
domain = lib.mkOption {
type = lib.types.str;
description = "Domain under which ${name} will be served.";
example = "example.com";
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Directory where ${name} stores data.";
default = "/var/lib/${name}";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
authEndpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Endpoint to the SSO provider. Leave null to not have SSO configured.";
example = "https://authelia.example.com";
};
backup = lib.mkOption {
description = ''
Backup configuration.
'';
default = { };
type = lib.types.submodule {
options = contracts.backup.mkRequester {
user = name;
sourceDirectories = [
cfg.${name}.dataDir
];
excludePatterns = [
".db-shm"
".db-wal"
".mono"
];
};
};
};
}
// (c.moreOptions or { });
};
}
);
in in
{ {
imports = [ imports = [
@ -343,155 +386,167 @@ in
config = lib.mkMerge [ config = lib.mkMerge [
(lib.mkIf cfg.radarr.enable ( (lib.mkIf cfg.radarr.enable (
let let
cfg' = cfg.radarr; cfg' = cfg.radarr;
isSSOEnabled = !(isNull cfg'.authEndpoint); isSSOEnabled = !(isNull cfg'.authEndpoint);
in in
{ {
services.nginx.enable = true; services.nginx.enable = true;
services.radarr = { services.radarr = {
enable = true; enable = true;
dataDir = "/var/lib/radarr"; dataDir = "/var/lib/radarr";
}; };
systemd.services.radarr.preStart = lib.shb.replaceSecrets { systemd.services.radarr.preStart = lib.shb.replaceSecrets {
userConfig = cfg'.settings userConfig =
// (lib.optionalAttrs isSSOEnabled { cfg'.settings
AuthenticationRequired = "DisabledForLocalAddresses"; // (lib.optionalAttrs isSSOEnabled {
AuthenticationMethod = "External"; AuthenticationRequired = "DisabledForLocalAddresses";
}); AuthenticationMethod = "External";
resultPath = "${config.services.radarr.dataDir}/config.xml"; });
generator = lib.shb.replaceSecretsFormatAdapter apps.radarr.settingsFormat; resultPath = "${config.services.radarr.dataDir}/config.xml";
}; generator = lib.shb.replaceSecretsFormatAdapter apps.radarr.settingsFormat;
};
shb.nginx.vhosts = [ (vhosts {} cfg') ]; shb.nginx.vhosts = [ (vhosts { } cfg') ];
})) }
))
(lib.mkIf cfg.sonarr.enable ( (lib.mkIf cfg.sonarr.enable (
let let
cfg' = cfg.sonarr; cfg' = cfg.sonarr;
isSSOEnabled = !(isNull cfg'.authEndpoint); isSSOEnabled = !(isNull cfg'.authEndpoint);
in in
{ {
services.nginx.enable = true; services.nginx.enable = true;
services.sonarr = { services.sonarr = {
enable = true; enable = true;
dataDir = "/var/lib/sonarr"; dataDir = "/var/lib/sonarr";
}; };
users.users.sonarr = { users.users.sonarr = {
extraGroups = [ "media" ]; extraGroups = [ "media" ];
}; };
systemd.services.sonarr.preStart = lib.shb.replaceSecrets { systemd.services.sonarr.preStart = lib.shb.replaceSecrets {
userConfig = cfg'.settings userConfig =
// (lib.optionalAttrs isSSOEnabled { cfg'.settings
AuthenticationRequired = "DisabledForLocalAddresses"; // (lib.optionalAttrs isSSOEnabled {
AuthenticationMethod = "External"; AuthenticationRequired = "DisabledForLocalAddresses";
}); AuthenticationMethod = "External";
resultPath = "${config.services.sonarr.dataDir}/config.xml"; });
generator = apps.sonarr.settingsFormat.generate; resultPath = "${config.services.sonarr.dataDir}/config.xml";
}; generator = apps.sonarr.settingsFormat.generate;
};
shb.nginx.vhosts = [ (vhosts {} cfg') ]; shb.nginx.vhosts = [ (vhosts { } cfg') ];
})) }
))
(lib.mkIf cfg.bazarr.enable ( (lib.mkIf cfg.bazarr.enable (
let let
cfg' = cfg.bazarr; cfg' = cfg.bazarr;
isSSOEnabled = !(isNull cfg'.authEndpoint); isSSOEnabled = !(isNull cfg'.authEndpoint);
in in
{ {
services.bazarr = { services.bazarr = {
enable = true; enable = true;
listenPort = cfg'.settings.Port; listenPort = cfg'.settings.Port;
}; };
users.users.bazarr = { users.users.bazarr = {
extraGroups = [ "media" ]; extraGroups = [ "media" ];
}; };
systemd.services.bazarr.preStart = lib.shb.replaceSecrets { systemd.services.bazarr.preStart = lib.shb.replaceSecrets {
userConfig = cfg'.settings userConfig =
// (lib.optionalAttrs isSSOEnabled { cfg'.settings
AuthenticationRequired = "DisabledForLocalAddresses"; // (lib.optionalAttrs isSSOEnabled {
AuthenticationMethod = "External"; AuthenticationRequired = "DisabledForLocalAddresses";
}); AuthenticationMethod = "External";
resultPath = "/var/lib/bazarr/config.xml"; });
generator = apps.bazarr.settingsFormat.generate; resultPath = "/var/lib/bazarr/config.xml";
}; generator = apps.bazarr.settingsFormat.generate;
};
shb.nginx.vhosts = [ (vhosts {} cfg') ]; shb.nginx.vhosts = [ (vhosts { } cfg') ];
})) }
))
(lib.mkIf cfg.readarr.enable ( (lib.mkIf cfg.readarr.enable (
let let
cfg' = cfg.readarr; cfg' = cfg.readarr;
in in
{ {
services.readarr = { services.readarr = {
enable = true; enable = true;
dataDir = "/var/lib/readarr"; dataDir = "/var/lib/readarr";
}; };
users.users.readarr = { users.users.readarr = {
extraGroups = [ "media" ]; extraGroups = [ "media" ];
}; };
systemd.services.readarr.preStart = lib.shb.replaceSecrets { systemd.services.readarr.preStart = lib.shb.replaceSecrets {
userConfig = cfg'.settings; userConfig = cfg'.settings;
resultPath = "${config.services.readarr.dataDir}/config.xml"; resultPath = "${config.services.readarr.dataDir}/config.xml";
generator = apps.readarr.settingsFormat.generate; generator = apps.readarr.settingsFormat.generate;
}; };
shb.nginx.vhosts = [ (vhosts {} cfg') ]; shb.nginx.vhosts = [ (vhosts { } cfg') ];
})) }
))
(lib.mkIf cfg.lidarr.enable ( (lib.mkIf cfg.lidarr.enable (
let let
cfg' = cfg.lidarr; cfg' = cfg.lidarr;
isSSOEnabled = !(isNull cfg'.authEndpoint); isSSOEnabled = !(isNull cfg'.authEndpoint);
in in
{ {
services.lidarr = { services.lidarr = {
enable = true; enable = true;
dataDir = "/var/lib/lidarr"; dataDir = "/var/lib/lidarr";
}; };
users.users.lidarr = { users.users.lidarr = {
extraGroups = [ "media" ]; extraGroups = [ "media" ];
}; };
systemd.services.lidarr.preStart = lib.shb.replaceSecrets { systemd.services.lidarr.preStart = lib.shb.replaceSecrets {
userConfig = cfg'.settings userConfig =
// (lib.optionalAttrs isSSOEnabled { cfg'.settings
AuthenticationRequired = "DisabledForLocalAddresses"; // (lib.optionalAttrs isSSOEnabled {
AuthenticationMethod = "External"; AuthenticationRequired = "DisabledForLocalAddresses";
}); AuthenticationMethod = "External";
resultPath = "${config.services.lidarr.dataDir}/config.xml"; });
generator = apps.lidarr.settingsFormat.generate; resultPath = "${config.services.lidarr.dataDir}/config.xml";
}; generator = apps.lidarr.settingsFormat.generate;
};
shb.nginx.vhosts = [ (vhosts {} cfg') ]; shb.nginx.vhosts = [ (vhosts { } cfg') ];
})) }
))
(lib.mkIf cfg.jackett.enable ( (lib.mkIf cfg.jackett.enable (
let let
cfg' = cfg.jackett; cfg' = cfg.jackett;
in in
{ {
services.jackett = { services.jackett = {
enable = true; enable = true;
dataDir = "/var/lib/jackett"; dataDir = "/var/lib/jackett";
}; };
# TODO: avoid implicitly relying on the media group # TODO: avoid implicitly relying on the media group
users.users.jackett = { users.users.jackett = {
extraGroups = [ "media" ]; extraGroups = [ "media" ];
}; };
systemd.services.jackett.preStart = lib.shb.replaceSecrets { systemd.services.jackett.preStart = lib.shb.replaceSecrets {
userConfig = lib.shb.renameAttrName cfg'.settings "ApiKey" "APIKey"; userConfig = lib.shb.renameAttrName cfg'.settings "ApiKey" "APIKey";
resultPath = "${config.services.jackett.dataDir}/ServerConfig.json"; resultPath = "${config.services.jackett.dataDir}/ServerConfig.json";
generator = apps.jackett.settingsFormat.generate; generator = apps.jackett.settingsFormat.generate;
}; };
shb.nginx.vhosts = [ (vhosts { shb.nginx.vhosts = [
extraBypassResources = [ "^/dl.*" ]; (vhosts {
} cfg') ]; extraBypassResources = [ "^/dl.*" ];
})) } cfg')
];
}
))
]; ];
} }

View file

@ -1,9 +1,14 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.audiobookshelf; cfg = config.shb.audiobookshelf;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
@ -40,18 +45,18 @@ in
extraServiceConfig = lib.mkOption { extraServiceConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.str; type = lib.types.attrsOf lib.types.str;
description = "Extra configuration given to the systemd service file."; description = "Extra configuration given to the systemd service file.";
default = {}; default = { };
example = lib.literalExpression '' example = lib.literalExpression ''
{ {
MemoryHigh = "512M"; MemoryHigh = "512M";
MemoryMax = "900M"; MemoryMax = "900M";
} }
''; '';
}; };
sso = lib.mkOption { sso = lib.mkOption {
description = "SSO configuration."; description = "SSO configuration.";
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {
enable = lib.mkEnableOption "SSO"; enable = lib.mkEnableOption "SSO";
@ -87,7 +92,10 @@ in
}; };
authorization_policy = lib.mkOption { authorization_policy = lib.mkOption {
type = lib.types.enum [ "one_factor" "two_factor" ]; type = lib.types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication."; description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor"; default = "one_factor";
}; };
@ -133,84 +141,102 @@ in
}; };
logLevel = lib.mkOption { logLevel = lib.mkOption {
type = lib.types.nullOr (lib.types.enum ["critical" "error" "warning" "info" "debug"]); type = lib.types.nullOr (
lib.types.enum [
"critical"
"error"
"warning"
"info"
"debug"
]
);
description = "Enable logging."; description = "Enable logging.";
default = false; default = false;
example = true; example = true;
}; };
}; };
config = lib.mkIf cfg.enable (lib.mkMerge [{ config = lib.mkIf cfg.enable (
lib.mkMerge [
services.audiobookshelf = {
enable = true;
openFirewall = true;
dataDir = "audiobookshelf";
host = "127.0.0.1";
port = cfg.webPort;
};
services.nginx.enable = true;
services.nginx.virtualHosts."${fqdn}" = {
http2 = true;
forceSSL = !(isNull cfg.ssl);
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
# https://github.com/advplyr/audiobookshelf#nginx-reverse-proxy
extraConfig = ''
set $audiobookshelf 127.0.0.1;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_pass http://$audiobookshelf:${builtins.toString cfg.webPort};
proxy_redirect http:// https://;
}
'';
};
shb.authelia.extraDefinitions = {
user_attributes.${roleClaim}.expression =
''"${cfg.sso.adminUserGroup}" in groups ? ["admin"] : ("${cfg.sso.userGroup}" in groups ? ["user"] : [""])'';
};
shb.authelia.extraOidcClaimsPolicies.${roleClaim} = {
custom_claims = {
"${roleClaim}" = {};
};
};
shb.authelia.extraOidcScopes."${roleClaim}" = {
claims = [ "${roleClaim}" ];
};
shb.authelia.oidcClients = lib.lists.optionals cfg.sso.enable [
{ {
client_id = cfg.sso.clientID;
client_name = "Audiobookshelf"; services.audiobookshelf = {
client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; enable = true;
claims_policy = "${roleClaim}"; openFirewall = true;
public = false; dataDir = "audiobookshelf";
authorization_policy = cfg.sso.authorization_policy; host = "127.0.0.1";
redirect_uris = [ port = cfg.webPort;
"https://${cfg.subdomain}.${cfg.domain}/auth/openid/callback" };
"https://${cfg.subdomain}.${cfg.domain}/auth/openid/mobile-redirect"
services.nginx.enable = true;
services.nginx.virtualHosts."${fqdn}" = {
http2 = true;
forceSSL = !(isNull cfg.ssl);
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
# https://github.com/advplyr/audiobookshelf#nginx-reverse-proxy
extraConfig = ''
set $audiobookshelf 127.0.0.1;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_pass http://$audiobookshelf:${builtins.toString cfg.webPort};
proxy_redirect http:// https://;
}
'';
};
shb.authelia.extraDefinitions = {
user_attributes.${roleClaim}.expression =
''"${cfg.sso.adminUserGroup}" in groups ? ["admin"] : ("${cfg.sso.userGroup}" in groups ? ["user"] : [""])'';
};
shb.authelia.extraOidcClaimsPolicies.${roleClaim} = {
custom_claims = {
"${roleClaim}" = { };
};
};
shb.authelia.extraOidcScopes."${roleClaim}" = {
claims = [ "${roleClaim}" ];
};
shb.authelia.oidcClients = lib.lists.optionals cfg.sso.enable [
{
client_id = cfg.sso.clientID;
client_name = "Audiobookshelf";
client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;
claims_policy = "${roleClaim}";
public = false;
authorization_policy = cfg.sso.authorization_policy;
redirect_uris = [
"https://${cfg.subdomain}.${cfg.domain}/auth/openid/callback"
"https://${cfg.subdomain}.${cfg.domain}/auth/openid/mobile-redirect"
];
scopes = [
"openid"
"profile"
"email"
"groups"
"${roleClaim}"
];
require_pkce = true;
pkce_challenge_method = "S256";
userinfo_signed_response_alg = "none";
token_endpoint_auth_method = "client_secret_basic";
}
]; ];
scopes = [ "openid" "profile" "email" "groups" "${roleClaim}" ];
require_pkce = true;
pkce_challenge_method = "S256";
userinfo_signed_response_alg = "none";
token_endpoint_auth_method = "client_secret_basic";
} }
]; {
} { systemd.services.audiobookshelfd.serviceConfig = cfg.extraServiceConfig;
systemd.services.audiobookshelfd.serviceConfig = cfg.extraServiceConfig; }
}]); ]
);
} }

View file

@ -1,20 +1,31 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.deluge; cfg = config.shb.deluge;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
authGenerator = users: authGenerator =
users:
let let
genLine = name: { password, priority ? 10 }: genLine =
name:
{
password,
priority ? 10,
}:
"${name}:${password}:${toString priority}"; "${name}:${password}:${toString priority}";
lines = lib.mapAttrsToList genLine users; lines = lib.mapAttrsToList genLine users;
in in
lib.concatStringsSep "\n" lines; lib.concatStringsSep "\n" lines;
in in
{ {
imports = [ imports = [
@ -57,7 +68,10 @@ in
daemonListenPorts = lib.mkOption { daemonListenPorts = lib.mkOption {
type = lib.types.listOf lib.types.int; type = lib.types.listOf lib.types.int;
description = "Deluge daemon listen ports"; description = "Deluge daemon listen ports";
default = [ 6881 6889 ]; default = [
6881
6889
];
}; };
webPort = lib.mkOption { webPort = lib.mkOption {
@ -158,12 +172,12 @@ in
extraServiceConfig = lib.mkOption { extraServiceConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.str; type = lib.types.attrsOf lib.types.str;
description = "Extra configuration given to the systemd service file."; description = "Extra configuration given to the systemd service file.";
default = {}; default = { };
example = lib.literalExpression '' example = lib.literalExpression ''
{ {
MemoryHigh = "512M"; MemoryHigh = "512M";
MemoryMax = "900M"; MemoryMax = "900M";
} }
''; '';
}; };
@ -176,14 +190,16 @@ in
extraUsers = lib.mkOption { extraUsers = lib.mkOption {
description = "Users having access to this deluge instance. Attrset of username to user options."; description = "Users having access to this deluge instance. Attrset of username to user options.";
type = lib.types.attrsOf (lib.types.submodule { type = lib.types.attrsOf (
options = { lib.types.submodule {
password = lib.mkOption { options = {
type = lib.shb.secretFileType; password = lib.mkOption {
description = "File containing the user password."; type = lib.shb.secretFileType;
description = "File containing the user password.";
};
}; };
}; }
}); );
}; };
localclientPassword = lib.mkOption { localclientPassword = lib.mkOption {
@ -198,12 +214,17 @@ in
prometheusScraperPassword = lib.mkOption { prometheusScraperPassword = lib.mkOption {
description = "Password for prometheus scraper. Setting this option will activate the prometheus deluge exporter."; description = "Password for prometheus scraper. Setting this option will activate the prometheus deluge exporter.";
type = lib.types.nullOr (lib.types.submodule { type = lib.types.nullOr (
options = contracts.secret.mkRequester { lib.types.submodule {
owner = "deluge"; options = contracts.secret.mkRequester {
restartUnits = [ "deluged.service" "prometheus.service" ]; owner = "deluge";
}; restartUnits = [
}); "deluged.service"
"prometheus.service"
];
};
}
);
default = null; default = null;
}; };
@ -214,35 +235,35 @@ in
Label is automatically enabled if any of the `shb.arr.*` service is enabled. Label is automatically enabled if any of the `shb.arr.*` service is enabled.
''; '';
example = ["Label"]; example = [ "Label" ];
default = []; default = [ ];
}; };
additionalPlugins = lib.mkOption { additionalPlugins = lib.mkOption {
type = lib.types.listOf lib.types.path; type = lib.types.listOf lib.types.path;
description = "Location of additional plugins. Each item in the list must be the path to the directory containing the plugin .egg file."; description = "Location of additional plugins. Each item in the list must be the path to the directory containing the plugin .egg file.";
default = []; default = [ ];
example = lib.literalExpression '' example = lib.literalExpression ''
additionalPlugins = [ additionalPlugins = [
(pkgs.callPackage ({ python3, fetchFromGitHub }: python3.pkgs.buildPythonPackage { (pkgs.callPackage ({ python3, fetchFromGitHub }: python3.pkgs.buildPythonPackage {
name = "deluge-autotracker"; name = "deluge-autotracker";
version = "1.0.0"; version = "1.0.0";
src = fetchFromGitHub { src = fetchFromGitHub {
owner = "ibizaman"; owner = "ibizaman";
repo = "deluge-autotracker"; repo = "deluge-autotracker";
rev = "cc40d816a497bbf1c2ebeb3d8b1176210548a3e6"; rev = "cc40d816a497bbf1c2ebeb3d8b1176210548a3e6";
sha256 = "sha256-0LpVdv1fak2a5eX4unjhUcN7nMAl9fgpr3X+7XnQE6c="; sha256 = "sha256-0LpVdv1fak2a5eX4unjhUcN7nMAl9fgpr3X+7XnQE6c=";
} + "/autotracker"; } + "/autotracker";
doCheck = false; doCheck = false;
format = "other"; format = "other";
nativeBuildInputs = [ python3.pkgs.setuptools ]; nativeBuildInputs = [ python3.pkgs.setuptools ];
buildPhase = ''' buildPhase = '''
mkdir "$out" mkdir "$out"
python3 setup.py install --install-lib "$out" python3 setup.py install --install-lib "$out"
'''; ''';
doInstallPhase = false; doInstallPhase = false;
}) {}) }) {})
]; ];
''; '';
}; };
@ -250,7 +271,7 @@ in
description = '' description = ''
Backup configuration. Backup configuration.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = contracts.backup.mkRequester { options = contracts.backup.mkRequester {
user = "deluge"; user = "deluge";
@ -262,146 +283,175 @@ in
}; };
logLevel = lib.mkOption { logLevel = lib.mkOption {
type = lib.types.nullOr (lib.types.enum ["critical" "error" "warning" "info" "debug"]); type = lib.types.nullOr (
lib.types.enum [
"critical"
"error"
"warning"
"info"
"debug"
]
);
description = "Enable logging."; description = "Enable logging.";
default = null; default = null;
example = "info"; example = "info";
}; };
}; };
config = lib.mkIf cfg.enable (lib.mkMerge [{ config = lib.mkIf cfg.enable (
services.deluge = { lib.mkMerge [
enable = true; {
declarative = true; services.deluge = {
openFirewall = true; enable = true;
inherit (cfg) dataDir; declarative = true;
openFirewall = true;
inherit (cfg) dataDir;
config = { config = {
download_location = cfg.settings.downloadLocation; download_location = cfg.settings.downloadLocation;
allow_remote = true; allow_remote = true;
daemon_port = cfg.daemonPort; daemon_port = cfg.daemonPort;
listen_ports = cfg.daemonListenPorts; listen_ports = cfg.daemonListenPorts;
proxy = lib.optionalAttrs (cfg.proxyPort != null) { proxy = lib.optionalAttrs (cfg.proxyPort != null) {
force_proxy = true; force_proxy = true;
hostname = "127.0.0.1"; hostname = "127.0.0.1";
port = cfg.proxyPort; port = cfg.proxyPort;
proxy_hostnames = true; proxy_hostnames = true;
proxy_peer_connections = true; proxy_peer_connections = true;
proxy_tracker_connections = true; proxy_tracker_connections = true;
type = 4; # HTTP type = 4; # HTTP
};
outgoing_interface = cfg.outgoingInterface;
enabled_plugins =
cfg.enabledPlugins
++ lib.optional (lib.any (x: x.enable) [
config.services.radarr
config.services.sonarr
config.services.bazarr
config.services.readarr
config.services.lidarr
]) "Label";
inherit (cfg.settings)
max_active_limit
max_active_downloading
max_active_seeding
max_connections_global
max_connections_per_torrent
max_download_speed
max_download_speed_per_torrent
max_upload_slots_global
max_upload_slots_per_torrent
max_upload_speed
max_upload_speed_per_torrent
dont_count_slow_torrents
;
new_release_check = false;
};
authFile = "${cfg.dataDir}/.config/deluge/authTemplate";
web.enable = true;
web.port = cfg.webPort;
}; };
outgoing_interface = cfg.outgoingInterface;
enabled_plugins = cfg.enabledPlugins systemd.services.deluged.preStart = lib.mkBefore (
++ lib.optional (lib.any (x: x.enable) [ lib.shb.replaceSecrets {
config.services.radarr userConfig =
config.services.sonarr cfg.extraUsers
config.services.bazarr // {
config.services.readarr localclient.password.source = config.shb.deluge.localclientPassword.result.path;
config.services.lidarr }
]) "Label"; // (lib.optionalAttrs (config.shb.deluge.prometheusScraperPassword != null) {
prometheus_scraper.password.source = config.shb.deluge.prometheusScraperPassword.result.path;
});
resultPath = "${cfg.dataDir}/.config/deluge/authTemplate";
generator = name: value: pkgs.writeText "delugeAuth" (authGenerator value);
}
);
inherit (cfg.settings) systemd.services.deluged.serviceConfig.ExecStart = lib.mkForce (
max_active_limit lib.concatStringsSep " \\\n " (
max_active_downloading [
max_active_seeding "${config.services.deluge.package}/bin/deluged"
max_connections_global "--do-not-daemonize"
max_connections_per_torrent "--config ${cfg.dataDir}/.config/deluge"
]
++ (lib.optional (!(isNull cfg.logLevel)) "-L ${cfg.logLevel}")
)
);
max_download_speed systemd.tmpfiles.rules =
max_download_speed_per_torrent let
plugins = pkgs.symlinkJoin {
name = "deluge-plugins";
paths = cfg.additionalPlugins;
};
in
[
"L+ ${cfg.dataDir}/.config/deluge/plugins - - - - ${plugins}"
];
max_upload_slots_global shb.nginx.vhosts = [
max_upload_slots_per_torrent (
max_upload_speed {
max_upload_speed_per_torrent inherit (cfg) subdomain domain ssl;
upstream = "http://127.0.0.1:${toString config.services.deluge.web.port}";
dont_count_slow_torrents; autheliaRules = lib.mkIf (cfg.authEndpoint != null) [
{
new_release_check = false; domain = fqdn;
}; policy = "bypass";
resources = [
authFile = "${cfg.dataDir}/.config/deluge/authTemplate"; "^/json"
];
web.enable = true; }
web.port = cfg.webPort; {
}; domain = fqdn;
policy = "two_factor";
systemd.services.deluged.preStart = lib.mkBefore (lib.shb.replaceSecrets { subject = [ "group:deluge_user" ];
userConfig = cfg.extraUsers // { }
localclient.password.source = config.shb.deluge.localclientPassword.result.path; ];
} // (lib.optionalAttrs (config.shb.deluge.prometheusScraperPassword != null) { }
prometheus_scraper.password.source = config.shb.deluge.prometheusScraperPassword.result.path; // (lib.optionalAttrs (cfg.authEndpoint != null) {
}); inherit (cfg) authEndpoint;
resultPath = "${cfg.dataDir}/.config/deluge/authTemplate"; })
generator = name: value: pkgs.writeText "delugeAuth" (authGenerator value); )
});
systemd.services.deluged.serviceConfig.ExecStart = lib.mkForce (lib.concatStringsSep " \\\n " ([
"${config.services.deluge.package}/bin/deluged"
"--do-not-daemonize"
"--config ${cfg.dataDir}/.config/deluge"
] ++ (lib.optional (!(isNull cfg.logLevel)) "-L ${cfg.logLevel}")
));
systemd.tmpfiles.rules =
let
plugins = pkgs.symlinkJoin {
name = "deluge-plugins";
paths = cfg.additionalPlugins;
};
in
[
"L+ ${cfg.dataDir}/.config/deluge/plugins - - - - ${plugins}"
]; ];
}
{
systemd.services.deluged.serviceConfig = cfg.extraServiceConfig;
}
(lib.mkIf (config.shb.deluge.prometheusScraperPassword != null) {
services.prometheus.exporters.deluge = {
enable = true;
shb.nginx.vhosts = [ delugeHost = "127.0.0.1";
({ delugePort = config.services.deluge.config.daemon_port;
inherit (cfg) subdomain domain ssl; delugeUser = "prometheus_scraper";
upstream = "http://127.0.0.1:${toString config.services.deluge.web.port}"; delugePasswordFile = config.shb.deluge.prometheusScraperPassword.result.path;
autheliaRules = lib.mkIf (cfg.authEndpoint != null) [ exportPerTorrentMetrics = true;
};
services.prometheus.scrapeConfigs = [
{ {
domain = fqdn; job_name = "deluge";
policy = "bypass"; static_configs = [
resources = [ {
"^/json" targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.deluge.port}" ];
labels = {
"hostname" = config.networking.hostName;
"domain" = cfg.domain;
};
}
]; ];
} }
{
domain = fqdn;
policy = "two_factor";
subject = ["group:deluge_user"];
}
]; ];
} // (lib.optionalAttrs (cfg.authEndpoint != null) { })
inherit (cfg) authEndpoint; ]
})) );
];
} {
systemd.services.deluged.serviceConfig = cfg.extraServiceConfig;
} (lib.mkIf (config.shb.deluge.prometheusScraperPassword != null) {
services.prometheus.exporters.deluge = {
enable = true;
delugeHost = "127.0.0.1";
delugePort = config.services.deluge.config.daemon_port;
delugeUser = "prometheus_scraper";
delugePasswordFile = config.shb.deluge.prometheusScraperPassword.result.path;
exportPerTorrentMetrics = true;
};
services.prometheus.scrapeConfigs = [
{
job_name = "deluge";
static_configs = [{
targets = ["127.0.0.1:${toString config.services.prometheus.exporters.deluge.port}"];
labels = {
"hostname" = config.networking.hostName;
"domain" = cfg.domain;
};
}];
}
];
})
]);
} }

View file

@ -1,25 +1,60 @@
{ config, options, pkgs, lib, ... }: {
config,
options,
pkgs,
lib,
...
}:
let let
cfg = config.shb.forgejo; cfg = config.shb.forgejo;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
inherit (lib) all attrNames concatMapStringsSep getExe lists literalExpression mapAttrsToList mkBefore mkEnableOption mkForce mkIf mkMerge mkOption mkOverride nameValuePair optionalString optionals; inherit (lib)
inherit (lib.types) attrsOf bool enum listOf nullOr package port submodule str; all
attrNames
concatMapStringsSep
getExe
lists
literalExpression
mapAttrsToList
mkBefore
mkEnableOption
mkForce
mkIf
mkMerge
mkOption
mkOverride
nameValuePair
optionalString
optionals
;
inherit (lib.types)
attrsOf
bool
enum
listOf
nullOr
package
port
submodule
str
;
in in
{ {
imports = [ imports = [
../blocks/nginx.nix ../blocks/nginx.nix
(lib.mkRemovedOptionModule [ "shb" "forgejo" "adminPassword" ] ''Instead, define an admin user in shb.forgejo.users and give it the same password, like so: (lib.mkRemovedOptionModule [ "shb" "forgejo" "adminPassword" ] ''
shb.forgejo.users = { Instead, define an admin user in shb.forgejo.users and give it the same password, like so:
"forgejoadmin" = { shb.forgejo.users = {
isAdmin = true; "forgejoadmin" = {
email = "forgejoadmin@example.com"; isAdmin = true;
password.result = <path/to/password>; email = "forgejoadmin@example.com";
}; password.result = <path/to/password>;
}; };
};
'') '')
]; ];
@ -60,7 +95,7 @@ in
description = '' description = ''
LDAP Integration. LDAP Integration.
''; '';
default = {}; default = { };
type = nullOr (submodule { type = nullOr (submodule {
options = { options = {
enable = mkEnableOption "LDAP integration."; enable = mkEnableOption "LDAP integration.";
@ -125,7 +160,7 @@ in
waitForSystemdServices = mkOption { waitForSystemdServices = mkOption {
type = listOf str; type = listOf str;
default = []; default = [ ];
description = '' description = ''
List of systemd services to wait on before starting. List of systemd services to wait on before starting.
This is needed because forgejo will try a lookup on the LDAP instance This is needed because forgejo will try a lookup on the LDAP instance
@ -140,7 +175,7 @@ in
description = '' description = ''
Setup SSO integration. Setup SSO integration.
''; '';
default = {}; default = { };
type = submodule { type = submodule {
options = { options = {
enable = mkEnableOption "SSO integration."; enable = mkEnableOption "SSO integration.";
@ -164,7 +199,10 @@ in
}; };
authorization_policy = mkOption { authorization_policy = mkOption {
type = enum [ "one_factor" "two_factor" ]; type = enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication."; description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor"; default = "one_factor";
}; };
@ -205,9 +243,10 @@ in
}; };
email = mkOption { email = mkOption {
description = ''Email of user. description = ''
Email of user.
This is only set when the user is created, changing this later on will have no effect. This is only set when the user is created, changing this later on will have no effect.
''; '';
type = str; type = str;
}; };
@ -254,7 +293,6 @@ in
''; '';
}; };
hostPackages = mkOption { hostPackages = mkOption {
type = listOf package; type = listOf package;
default = with pkgs; [ default = with pkgs; [
@ -289,13 +327,14 @@ in
description = '' description = ''
Backup configuration. Backup configuration.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = contracts.backup.mkRequester { options = contracts.backup.mkRequester {
user = options.services.forgejo.user.value; user = options.services.forgejo.user.value;
sourceDirectories = [ sourceDirectories = [
options.services.forgejo.dump.backupDir.value options.services.forgejo.dump.backupDir.value
] ++ optionals (cfg.repositoryRoot != null) [ ]
++ optionals (cfg.repositoryRoot != null) [
cfg.repositoryRoot cfg.repositoryRoot
]; ];
}; };
@ -317,7 +356,9 @@ in
``` ```
''; '';
readOnly = true; readOnly = true;
default = { path = config.services.forgejo.stateDir; }; default = {
path = config.services.forgejo.stateDir;
};
}; };
smtp = mkOption { smtp = mkOption {
@ -390,10 +431,12 @@ in
# https://github.com/NixOS/nixpkgs/issues/258371#issuecomment-2271967113 # https://github.com/NixOS/nixpkgs/issues/258371#issuecomment-2271967113
systemd.services.forgejo.serviceConfig.Type = mkForce "exec"; systemd.services.forgejo.serviceConfig.Type = mkForce "exec";
shb.nginx.vhosts = [{ shb.nginx.vhosts = [
inherit (cfg) domain subdomain ssl; {
upstream = "http://unix:${config.services.forgejo.settings.server.HTTP_ADDR}"; inherit (cfg) domain subdomain ssl;
}]; upstream = "http://unix:${config.services.forgejo.settings.server.HTTP_ADDR}";
}
];
}) })
(mkIf cfg.enable { (mkIf cfg.enable {
@ -420,58 +463,60 @@ in
systemd.services.forgejo.wants = cfg.ldap.waitForSystemdServices; systemd.services.forgejo.wants = cfg.ldap.waitForSystemdServices;
systemd.services.forgejo.after = cfg.ldap.waitForSystemdServices; systemd.services.forgejo.after = cfg.ldap.waitForSystemdServices;
# The delimiter in the `cut` command is a TAB! # The delimiter in the `cut` command is a TAB!
systemd.services.forgejo.preStart = let systemd.services.forgejo.preStart =
provider = "SHB-${cfg.ldap.provider}"; let
in '' provider = "SHB-${cfg.ldap.provider}";
auth="${getExe config.services.forgejo.package} admin auth" in
''
auth="${getExe config.services.forgejo.package} admin auth"
echo "Trying to find existing ldap configuration for ${provider}"... echo "Trying to find existing ldap configuration for ${provider}"...
set +e -o pipefail set +e -o pipefail
id="$($auth list | grep "${provider}.*LDAP" | cut -d' ' -f1)" id="$($auth list | grep "${provider}.*LDAP" | cut -d' ' -f1)"
found=$? found=$?
set -e +o pipefail set -e +o pipefail
if [[ $found = 0 ]]; then if [[ $found = 0 ]]; then
echo Found ldap configuration at id=$id, updating it if needed. echo Found ldap configuration at id=$id, updating it if needed.
$auth update-ldap \ $auth update-ldap \
--id $id \ --id $id \
--name ${provider} \ --name ${provider} \
--host ${cfg.ldap.host} \ --host ${cfg.ldap.host} \
--port ${toString cfg.ldap.port} \ --port ${toString cfg.ldap.port} \
--bind-dn uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain} \ --bind-dn uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain} \
--bind-password $(tr -d '\n' < ${cfg.ldap.adminPassword.result.path}) \ --bind-password $(tr -d '\n' < ${cfg.ldap.adminPassword.result.path}) \
--security-protocol Unencrypted \ --security-protocol Unencrypted \
--user-search-base ou=people,${cfg.ldap.dcdomain} \ --user-search-base ou=people,${cfg.ldap.dcdomain} \
--user-filter '(&(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})(|(uid=%[1]s)(mail=%[1]s)))' \ --user-filter '(&(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})(|(uid=%[1]s)(mail=%[1]s)))' \
--admin-filter '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \ --admin-filter '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \
--username-attribute uid \ --username-attribute uid \
--firstname-attribute givenName \ --firstname-attribute givenName \
--surname-attribute sn \ --surname-attribute sn \
--email-attribute mail \ --email-attribute mail \
--avatar-attribute jpegPhoto \ --avatar-attribute jpegPhoto \
--synchronize-users --synchronize-users
echo "Done updating LDAP configuration." echo "Done updating LDAP configuration."
else else
echo Did not find any ldap configuration, creating one with name ${provider}. echo Did not find any ldap configuration, creating one with name ${provider}.
$auth add-ldap \ $auth add-ldap \
--name ${provider} \ --name ${provider} \
--host ${cfg.ldap.host} \ --host ${cfg.ldap.host} \
--port ${toString cfg.ldap.port} \ --port ${toString cfg.ldap.port} \
--bind-dn uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain} \ --bind-dn uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain} \
--bind-password $(tr -d '\n' < ${cfg.ldap.adminPassword.result.path}) \ --bind-password $(tr -d '\n' < ${cfg.ldap.adminPassword.result.path}) \
--security-protocol Unencrypted \ --security-protocol Unencrypted \
--user-search-base ou=people,${cfg.ldap.dcdomain} \ --user-search-base ou=people,${cfg.ldap.dcdomain} \
--user-filter '(&(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})(|(uid=%[1]s)(mail=%[1]s)))' \ --user-filter '(&(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})(|(uid=%[1]s)(mail=%[1]s)))' \
--admin-filter '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \ --admin-filter '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \
--username-attribute uid \ --username-attribute uid \
--firstname-attribute givenName \ --firstname-attribute givenName \
--surname-attribute sn \ --surname-attribute sn \
--email-attribute mail \ --email-attribute mail \
--avatar-attribute jpegPhoto \ --avatar-attribute jpegPhoto \
--synchronize-users --synchronize-users
echo "Done adding LDAP configuration." echo "Done adding LDAP configuration."
fi fi
''; '';
}) })
# For Authelia to Forgejo integration: https://www.authelia.com/integration/openid-connect/gitea/ # For Authelia to Forgejo integration: https://www.authelia.com/integration/openid-connect/gitea/
@ -488,7 +533,7 @@ in
ENABLE_OPENID_SIGNUP = true; ENABLE_OPENID_SIGNUP = true;
WHITELISTED_URIS = cfg.sso.endpoint; WHITELISTED_URIS = cfg.sso.endpoint;
}; };
service = { service = {
# DISABLE_REGISTRATION = mkForce false; # DISABLE_REGISTRATION = mkForce false;
# ALLOW_ONLY_EXTERNAL_REGISTRATION = false; # ALLOW_ONLY_EXTERNAL_REGISTRATION = false;
@ -497,48 +542,53 @@ in
}; };
# The delimiter in the `cut` command is a TAB! # The delimiter in the `cut` command is a TAB!
systemd.services.forgejo.preStart = let systemd.services.forgejo.preStart =
provider = "SHB-${cfg.sso.provider}"; let
in '' provider = "SHB-${cfg.sso.provider}";
auth="${getExe config.services.forgejo.package} admin auth" in
''
auth="${getExe config.services.forgejo.package} admin auth"
echo "Trying to find existing sso configuration for ${provider}"... echo "Trying to find existing sso configuration for ${provider}"...
set +e -o pipefail set +e -o pipefail
id="$($auth list | grep "${provider}.*OAuth2" | cut -d' ' -f1)" id="$($auth list | grep "${provider}.*OAuth2" | cut -d' ' -f1)"
found=$? found=$?
set -e +o pipefail set -e +o pipefail
if [[ $found = 0 ]]; then if [[ $found = 0 ]]; then
echo Found sso configuration at id=$id, updating it if needed. echo Found sso configuration at id=$id, updating it if needed.
$auth update-oauth \ $auth update-oauth \
--id $id \ --id $id \
--name ${provider} \ --name ${provider} \
--provider openidConnect \ --provider openidConnect \
--key forgejo \ --key forgejo \
--secret $(tr -d '\n' < ${cfg.sso.sharedSecret.result.path}) \ --secret $(tr -d '\n' < ${cfg.sso.sharedSecret.result.path}) \
--auto-discover-url ${cfg.sso.endpoint}/.well-known/openid-configuration --auto-discover-url ${cfg.sso.endpoint}/.well-known/openid-configuration
else else
echo Did not find any sso configuration, creating one with name ${provider}. echo Did not find any sso configuration, creating one with name ${provider}.
$auth add-oauth \ $auth add-oauth \
--name ${provider} \ --name ${provider} \
--provider openidConnect \ --provider openidConnect \
--key forgejo \ --key forgejo \
--secret $(tr -d '\n' < ${cfg.sso.sharedSecret.result.path}) \ --secret $(tr -d '\n' < ${cfg.sso.sharedSecret.result.path}) \
--auto-discover-url ${cfg.sso.endpoint}/.well-known/openid-configuration --auto-discover-url ${cfg.sso.endpoint}/.well-known/openid-configuration
fi fi
''; '';
shb.authelia.oidcClients = lists.optionals (!(isNull cfg.sso)) [ shb.authelia.oidcClients = lists.optionals (!(isNull cfg.sso)) [
(let (
provider = "SHB-${cfg.sso.provider}"; let
in { provider = "SHB-${cfg.sso.provider}";
client_id = cfg.sso.clientID; in
client_name = "Forgejo"; {
client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; client_id = cfg.sso.clientID;
public = false; client_name = "Forgejo";
authorization_policy = cfg.sso.authorization_policy; client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;
redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/user/oauth2/${provider}/callback" ]; public = false;
}) authorization_policy = cfg.sso.authorization_policy;
redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/user/oauth2/${provider}/callback" ];
}
)
]; ];
}) })
@ -552,13 +602,14 @@ in
systemd.services.forgejo.preStart = '' systemd.services.forgejo.preStart = ''
admin="${getExe config.services.forgejo.package} admin user" admin="${getExe config.services.forgejo.package} admin user"
'' + concatMapStringsSep "\n" (u: '' ''
+ concatMapStringsSep "\n" (u: ''
if ! $admin list | grep "${u.name}"; then if ! $admin list | grep "${u.name}"; then
$admin create ${optionalString u.value.isAdmin "--admin"} --email "${u.value.email}" --must-change-password=false --username "${u.name}" --password "$(tr -d '\n' < ${u.value.password.result.path})" $admin create ${optionalString u.value.isAdmin "--admin"} --email "${u.value.email}" --must-change-password=false --username "${u.name}" --password "$(tr -d '\n' < ${u.value.password.result.path})"
else else
$admin change-password --must-change-password=false --username "${u.name}" --password "$(tr -d '\n' < ${u.value.password.result.path})" $admin change-password --must-change-password=false --username "${u.name}" --password "$(tr -d '\n' < ${u.value.password.result.path})"
fi fi
'') (mapAttrsToList nameValuePair cfg.users); '') (mapAttrsToList nameValuePair cfg.users);
}) })
(mkIf (cfg.enable && cfg.smtp != null) { (mkIf (cfg.enable && cfg.smtp != null) {
@ -584,15 +635,17 @@ in
instances.local = { instances.local = {
enable = true; enable = true;
name = "local"; name = "local";
url = let url =
protocol = if cfg.ssl != null then "https" else "http"; let
in "${protocol}://${cfg.subdomain}.${cfg.domain}"; protocol = if cfg.ssl != null then "https" else "http";
in
"${protocol}://${cfg.subdomain}.${cfg.domain}";
tokenFile = ""; # Empty variable to satisfy an assertion. tokenFile = ""; # Empty variable to satisfy an assertion.
labels = [ labels = [
# "ubuntu-latest:docker://node:16-bullseye" # "ubuntu-latest:docker://node:16-bullseye"
# "ubuntu-22.04:docker://node:16-bullseye" # "ubuntu-22.04:docker://node:16-bullseye"
# "ubuntu-20.04:docker://node:16-bullseye" # "ubuntu-20.04:docker://node:16-bullseye"
# "ubuntu-18.04:docker://node:16-buster" # "ubuntu-18.04:docker://node:16-buster"
"native:host" "native:host"
]; ];
inherit (cfg) hostPackages; inherit (cfg) hostPackages;

View file

@ -1,9 +1,14 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.grocy; cfg = config.shb.grocy;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
in in
@ -37,7 +42,24 @@ in
}; };
culture = lib.mkOption { culture = lib.mkOption {
type = lib.types.enum [ "de" "en" "da" "en_GB" "es" "fr" "hu" "it" "nl" "no" "pl" "pt_BR" "ru" "sk_SK" "sv_SE" "tr" ]; type = lib.types.enum [
"de"
"en"
"da"
"en_GB"
"es"
"fr"
"hu"
"it"
"nl"
"no"
"pl"
"pt_BR"
"ru"
"sk_SK"
"sv_SE"
"tr"
];
default = "en"; default = "en";
description = '' description = ''
Display language of the frontend. Display language of the frontend.
@ -53,12 +75,12 @@ in
extraServiceConfig = lib.mkOption { extraServiceConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.str; type = lib.types.attrsOf lib.types.str;
description = "Extra configuration given to the systemd service file."; description = "Extra configuration given to the systemd service file.";
default = {}; default = { };
example = lib.literalExpression '' example = lib.literalExpression ''
{ {
MemoryHigh = "512M"; MemoryHigh = "512M";
MemoryMax = "900M"; MemoryMax = "900M";
} }
''; '';
}; };
@ -78,34 +100,47 @@ in
}; };
logLevel = lib.mkOption { logLevel = lib.mkOption {
type = lib.types.nullOr (lib.types.enum ["critical" "error" "warning" "info" "debug"]); type = lib.types.nullOr (
lib.types.enum [
"critical"
"error"
"warning"
"info"
"debug"
]
);
description = "Enable logging."; description = "Enable logging.";
default = false; default = false;
example = true; example = true;
}; };
}; };
config = lib.mkIf cfg.enable (lib.mkMerge [{ config = lib.mkIf cfg.enable (
services.grocy = { lib.mkMerge [
enable = true; {
hostName = fqdn; services.grocy = {
nginx.enableSSL = !(isNull cfg.ssl); enable = true;
dataDir = cfg.dataDir; hostName = fqdn;
settings.currency = cfg.currency; nginx.enableSSL = !(isNull cfg.ssl);
settings.culture = cfg.culture; dataDir = cfg.dataDir;
}; settings.currency = cfg.currency;
settings.culture = cfg.culture;
};
services.phpfpm.pools.grocy.group = lib.mkForce "grocy"; services.phpfpm.pools.grocy.group = lib.mkForce "grocy";
users.groups.grocy = {}; users.groups.grocy = { };
users.users.grocy.group = lib.mkForce "grocy"; users.users.grocy.group = lib.mkForce "grocy";
services.nginx.virtualHosts."${fqdn}" = { services.nginx.virtualHosts."${fqdn}" = {
enableACME = lib.mkForce false; enableACME = lib.mkForce false;
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
}; };
} { }
systemd.services.grocyd.serviceConfig = cfg.extraServiceConfig; {
}]); systemd.services.grocyd.serviceConfig = cfg.extraServiceConfig;
}
]
);
} }

View file

@ -1,9 +1,14 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.hledger; cfg = config.shb.hledger;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
in in
@ -63,7 +68,7 @@ in
description = '' description = ''
Backup configuration. Backup configuration.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = contracts.backup.mkRequester { options = contracts.backup.mkRequester {
user = "hledger"; user = "hledger";
@ -76,7 +81,7 @@ in
extraArguments = lib.mkOption { extraArguments = lib.mkOption {
description = "Extra arguments append to the hledger command."; description = "Extra arguments append to the hledger command.";
default = ["--forecast"]; default = [ "--forecast" ];
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
}; };
}; };
@ -88,7 +93,7 @@ in
baseUrl = ""; baseUrl = "";
stateDir = cfg.dataDir; stateDir = cfg.dataDir;
journalFiles = ["hledger.journal"]; journalFiles = [ "hledger.journal" ];
host = "127.0.0.1"; host = "127.0.0.1";
port = cfg.port; port = cfg.port;
@ -101,20 +106,27 @@ in
# If the hledger.journal file does not exist, hledger-web refuses to start, so we create an # If the hledger.journal file does not exist, hledger-web refuses to start, so we create an
# empty one if it does not exist yet.. # empty one if it does not exist yet..
preStart = '' preStart = ''
test -f /var/lib/hledger/hledger.journal || touch /var/lib/hledger/hledger.journal test -f /var/lib/hledger/hledger.journal || touch /var/lib/hledger/hledger.journal
''; '';
serviceConfig.StateDirectory = "hledger"; serviceConfig.StateDirectory = "hledger";
}; };
shb.nginx.vhosts = [ shb.nginx.vhosts = [
{ {
inherit (cfg) subdomain domain authEndpoint ssl; inherit (cfg)
subdomain
domain
authEndpoint
ssl
;
upstream = "http://${toString config.services.hledger-web.host}:${toString config.services.hledger-web.port}"; upstream = "http://${toString config.services.hledger-web.host}:${toString config.services.hledger-web.port}";
autheliaRules = [{ autheliaRules = [
domain = fqdn; {
policy = "two_factor"; domain = fqdn;
subject = ["group:hledger_user"]; policy = "two_factor";
}]; subject = [ "group:hledger_user" ];
}
];
} }
]; ];
}; };

View file

@ -1,9 +1,14 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.home-assistant; cfg = config.shb.home-assistant;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
@ -24,9 +29,7 @@ let
nonSecrets = (lib.attrsets.filterAttrs (k: v: !(builtins.isAttrs v)) cfg.config); nonSecrets = (lib.attrsets.filterAttrs (k: v: !(builtins.isAttrs v)) cfg.config);
configWithSecretsIncludes = configWithSecretsIncludes = nonSecrets // (lib.attrsets.mapAttrs (k: v: "!secret ${k}") secrets);
nonSecrets
// (lib.attrsets.mapAttrs (k: v: "!secret ${k}") secrets);
in in
{ {
options.shb.home-assistant = { options.shb.home-assistant = {
@ -56,28 +59,49 @@ in
freeformType = lib.types.attrsOf lib.types.str; freeformType = lib.types.attrsOf lib.types.str;
options = { options = {
name = lib.mkOption { name = lib.mkOption {
type = lib.types.oneOf [ lib.types.str lib.shb.secretFileType ]; type = lib.types.oneOf [
lib.types.str
lib.shb.secretFileType
];
description = "Name of the Home Assistant instance."; description = "Name of the Home Assistant instance.";
}; };
country = lib.mkOption { country = lib.mkOption {
type = lib.types.oneOf [ lib.types.str lib.shb.secretFileType ]; type = lib.types.oneOf [
lib.types.str
lib.shb.secretFileType
];
description = "Two letter country code where this instance is located."; description = "Two letter country code where this instance is located.";
}; };
latitude = lib.mkOption { latitude = lib.mkOption {
type = lib.types.oneOf [ lib.types.str lib.shb.secretFileType ]; type = lib.types.oneOf [
lib.types.str
lib.shb.secretFileType
];
description = "Latitude where this instance is located."; description = "Latitude where this instance is located.";
}; };
longitude = lib.mkOption { longitude = lib.mkOption {
type = lib.types.oneOf [ lib.types.str lib.shb.secretFileType ]; type = lib.types.oneOf [
lib.types.str
lib.shb.secretFileType
];
description = "Longitude where this instance is located."; description = "Longitude where this instance is located.";
}; };
time_zone = lib.mkOption { time_zone = lib.mkOption {
type = lib.types.oneOf [ lib.types.str lib.shb.secretFileType ]; type = lib.types.oneOf [
lib.types.str
lib.shb.secretFileType
];
description = "Timezone of this instance."; description = "Timezone of this instance.";
example = "America/Los_Angeles"; example = "America/Los_Angeles";
}; };
unit_system = lib.mkOption { unit_system = lib.mkOption {
type = lib.types.oneOf [ lib.types.str (lib.types.enum [ "metric" "us_customary" ]) ]; type = lib.types.oneOf [
lib.types.str
(lib.types.enum [
"metric"
"us_customary"
])
];
description = "Unit system of this instance."; description = "Unit system of this instance.";
example = "metric"; example = "metric";
}; };
@ -95,7 +119,7 @@ in
Also, enabling LDAP will skip onboarding Also, enabling LDAP will skip onboarding
otherwise Home Assistant gets into a cyclic lock. otherwise Home Assistant gets into a cyclic lock.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {
enable = lib.mkEnableOption "LDAP app."; enable = lib.mkEnableOption "LDAP app.";
@ -140,7 +164,7 @@ in
voice = lib.mkOption { voice = lib.mkOption {
description = "Options related to voice service."; description = "Options related to voice service.";
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {
speech-to-text = lib.mkOption { speech-to-text = lib.mkOption {
@ -150,7 +174,7 @@ in
https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.piper.servers https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.piper.servers
''; '';
type = lib.types.attrsOf lib.types.anything; type = lib.types.attrsOf lib.types.anything;
default = {}; default = { };
}; };
text-to-speech = lib.mkOption { text-to-speech = lib.mkOption {
description = '' description = ''
@ -159,7 +183,7 @@ in
https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.faster-whisper.servers https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.faster-whisper.servers
''; '';
type = lib.types.attrsOf lib.types.anything; type = lib.types.attrsOf lib.types.anything;
default = {}; default = { };
}; };
wakeword = lib.mkOption { wakeword = lib.mkOption {
description = '' description = ''
@ -168,7 +192,9 @@ in
https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.openwakeword https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.openwakeword
''; '';
type = lib.types.anything; type = lib.types.anything;
default = { enable = false; }; default = {
enable = false;
};
}; };
}; };
}; };
@ -178,7 +204,7 @@ in
description = '' description = ''
Backup configuration. Backup configuration.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = contracts.backup.mkRequester { options = contracts.backup.mkRequester {
user = "hass"; user = "hass";
@ -219,7 +245,7 @@ in
config = { config = {
# Includes dependencies for a basic setup # Includes dependencies for a basic setup
# https://www.home-assistant.io/integrations/default_config/ # https://www.home-assistant.io/integrations/default_config/
default_config = {}; default_config = { };
http = { http = {
use_x_forwarded_for = true; use_x_forwarded_for = true;
server_host = "127.0.0.1"; server_host = "127.0.0.1";
@ -240,7 +266,10 @@ in
{ {
type = "command_line"; type = "command_line";
command = ldap_auth_script + "/bin/ldap_auth.sh"; command = ldap_auth_script + "/bin/ldap_auth.sh";
args = [ "http://${cfg.ldap.host}:${toString cfg.ldap.port}" cfg.ldap.userGroup ]; args = [
"http://${cfg.ldap.host}:${toString cfg.ldap.port}"
cfg.ldap.userGroup
];
meta = true; meta = true;
} }
]); ]);
@ -261,11 +290,11 @@ in
action = [ action = [
{ {
service = "shell_command.delete_backups"; service = "shell_command.delete_backups";
data = {}; data = { };
} }
{ {
service = "backup.create"; service = "backup.create";
data = {}; data = { };
} }
]; ];
mode = "single"; mode = "single";
@ -286,7 +315,11 @@ in
{ {
name = "random_joke"; name = "random_joke";
platform = "rest"; platform = "rest";
json_attributes = ["joke" "id" "status"]; json_attributes = [
"joke"
"id"
"status"
];
value_template = "{{ value_json.joke }}"; value_template = "{{ value_json.joke }}";
resource = "https://icanhazdadjoke.com/"; resource = "https://icanhazdadjoke.com/";
scan_interval = "3600"; scan_interval = "3600";
@ -324,33 +357,36 @@ in
}; };
systemd.services.home-assistant.preStart = systemd.services.home-assistant.preStart =
(let (
# TODO: this probably does not work anymore let
onboarding = pkgs.writeText "onboarding" '' # TODO: this probably does not work anymore
{ onboarding = pkgs.writeText "onboarding" ''
"version": 4, {
"minor_version": 1, "version": 4,
"key": "onboarding", "minor_version": 1,
"data": { "key": "onboarding",
"done": [ "data": {
${lib.optionalString cfg.ldap.enable ''"user",''} "done": [
"core_config", ${lib.optionalString cfg.ldap.enable ''"user",''}
"analytics" "core_config",
] "analytics"
]
}
} }
} '';
''; storage = "${config.services.home-assistant.configDir}";
storage = "${config.services.home-assistant.configDir}"; file = "${storage}/.storage/onboarding";
file = "${storage}/.storage/onboarding"; in
in '' ''
if [ ! -f ${file} ]; then if [ ! -f ${file} ]; then
mkdir -p ''$(dirname ${file}) && cp ${onboarding} ${file} mkdir -p ''$(dirname ${file}) && cp ${onboarding} ${file}
fi fi
'') ''
)
+ (lib.shb.replaceSecrets { + (lib.shb.replaceSecrets {
userConfig = cfg.config; userConfig = cfg.config;
resultPath = "${config.services.home-assistant.configDir}/secrets.yaml"; resultPath = "${config.services.home-assistant.configDir}/secrets.yaml";
generator = lib.shb.replaceSecretsGeneratorAdapter (lib.generators.toYAML {}); generator = lib.shb.replaceSecretsGeneratorAdapter (lib.generators.toYAML { });
}); });
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [

View file

@ -1,32 +1,45 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.immich; cfg = config.shb.immich;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
protocol = if !(isNull cfg.ssl) then "https" else "http"; protocol = if !(isNull cfg.ssl) then "https" else "http";
roleClaim = "immich_user"; roleClaim = "immich_user";
# TODO: Quota management, see https://github.com/ibizaman/selfhostblocks/pull/523#discussion_r2309421694 # TODO: Quota management, see https://github.com/ibizaman/selfhostblocks/pull/523#discussion_r2309421694
#quotaClaim = "immich_quota"; #quotaClaim = "immich_quota";
scopes = [ "openid" "email" "profile" "groups" "immich_scope"]; scopes = [
"openid"
"email"
"profile"
"groups"
"immich_scope"
];
dataFolder = cfg.mediaLocation; dataFolder = cfg.mediaLocation;
ssoFqdnWithPort = if isNull cfg.sso.port ssoFqdnWithPort =
then cfg.sso.endpoint if isNull cfg.sso.port then cfg.sso.endpoint else "${cfg.sso.endpoint}:${toString cfg.sso.port}";
else "${cfg.sso.endpoint}:${toString cfg.sso.port}";
# Generate Immich configuration file only for SHB-managed settings # Generate Immich configuration file only for SHB-managed settings
shbManagedSettings = lib.optionalAttrs (cfg.settings != {}) cfg.settings shbManagedSettings =
lib.optionalAttrs (cfg.settings != { }) cfg.settings
// lib.optionalAttrs (cfg.sso.enable) { // lib.optionalAttrs (cfg.sso.enable) {
oauth = { oauth = {
enabled = true; enabled = true;
issuerUrl = "${ssoFqdnWithPort}"; issuerUrl = "${ssoFqdnWithPort}";
clientId = cfg.sso.clientID; clientId = cfg.sso.clientID;
roleClaim = roleClaim; roleClaim = roleClaim;
clientSecret = { source = cfg.sso.sharedSecret.result.path; }; clientSecret = {
source = cfg.sso.sharedSecret.result.path;
};
scope = builtins.concatStringsSep " " scopes; scope = builtins.concatStringsSep " " scopes;
storageLabelClaim = cfg.sso.storageLabelClaim; storageLabelClaim = cfg.sso.storageLabelClaim;
#storageQuotaClaim = quotaClaim; # TODO (commented out, otherwise defaults to 0 bytes!) #storageQuotaClaim = quotaClaim; # TODO (commented out, otherwise defaults to 0 bytes!)
@ -49,7 +62,9 @@ let
host = cfg.smtp.host; host = cfg.smtp.host;
port = cfg.smtp.port; port = cfg.smtp.port;
username = cfg.smtp.username; username = cfg.smtp.username;
password = { source = cfg.smtp.password.result.path; }; password = {
source = cfg.smtp.password.result.path;
};
ignoreTLS = cfg.smtp.ignoreTLS; ignoreTLS = cfg.smtp.ignoreTLS;
secure = cfg.smtp.secure; secure = cfg.smtp.secure;
}; };
@ -58,19 +73,36 @@ let
}; };
configFile = "/var/lib/immich/config.json"; configFile = "/var/lib/immich/config.json";
# Use SHB's replaceSecrets function for loading secrets at runtime # Use SHB's replaceSecrets function for loading secrets at runtime
configSetupScript = lib.optionalString (cfg.sso.enable || cfg.smtp != null) ( configSetupScript = lib.optionalString (cfg.sso.enable || cfg.smtp != null) (
lib.shb.replaceSecrets { lib.shb.replaceSecrets {
userConfig = shbManagedSettings; userConfig = shbManagedSettings;
resultPath = configFile; resultPath = configFile;
generator = lib.shb.replaceSecretsFormatAdapter (pkgs.formats.json {}); generator = lib.shb.replaceSecretsFormatAdapter (pkgs.formats.json { });
user = "immich"; user = "immich";
permissions = "u=r,g=,o="; permissions = "u=r,g=,o=";
} }
); );
inherit (lib) mkEnableOption mkIf lists mkOption optionals; inherit (lib)
inherit (lib.types) attrs attrsOf bool enum listOf nullOr port submodule str path; mkEnableOption
mkIf
lists
mkOption
optionals
;
inherit (lib.types)
attrs
attrsOf
bool
enum
listOf
nullOr
port
submodule
str
path
;
in in
{ {
imports = [ imports = [
@ -154,14 +186,16 @@ in
``` ```
''; '';
readOnly = true; readOnly = true;
default = { path = dataFolder; }; default = {
path = dataFolder;
};
}; };
backup = mkOption { backup = mkOption {
description = '' description = ''
Backup configuration for Immich media files and database. Backup configuration for Immich media files and database.
''; '';
default = {}; default = { };
type = submodule { type = submodule {
options = contracts.backup.mkRequester { options = contracts.backup.mkRequester {
user = "immich"; user = "immich";
@ -190,7 +224,7 @@ in
machineLearning = mkOption { machineLearning = mkOption {
description = "Machine learning configuration."; description = "Machine learning configuration.";
default = {}; default = { };
type = submodule { type = submodule {
options = { options = {
enable = mkOption { enable = mkOption {
@ -202,7 +236,7 @@ in
environment = mkOption { environment = mkOption {
description = "Extra environment variables for machine learning service."; description = "Extra environment variables for machine learning service.";
type = attrsOf str; type = attrsOf str;
default = {}; default = { };
example = { example = {
MACHINE_LEARNING_WORKERS = "2"; MACHINE_LEARNING_WORKERS = "2";
MACHINE_LEARNING_WORKER_TIMEOUT = "180"; MACHINE_LEARNING_WORKER_TIMEOUT = "180";
@ -216,13 +250,17 @@ in
description = '' description = ''
Setup SSO integration. Setup SSO integration.
''; '';
default = {}; default = { };
type = submodule { type = submodule {
options = { options = {
enable = mkEnableOption "SSO integration."; enable = mkEnableOption "SSO integration.";
provider = mkOption { provider = mkOption {
type = enum [ "Authelia" "Keycloak" "Generic" ]; type = enum [
"Authelia"
"Keycloak"
"Generic"
];
description = "OIDC provider name, used for display."; description = "OIDC provider name, used for display.";
default = "Authelia"; default = "Authelia";
}; };
@ -311,7 +349,10 @@ in
}; };
authorization_policy = mkOption { authorization_policy = mkOption {
type = enum [ "one_factor" "two_factor" ]; type = enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication."; description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor"; default = "one_factor";
}; };
@ -325,10 +366,10 @@ in
Immich configuration settings. Immich configuration settings.
Only specify settings that you want SHB to manage declaratively. Only specify settings that you want SHB to manage declaratively.
Other settings can be configured through Immich's admin UI. Other settings can be configured through Immich's admin UI.
See https://immich.app/docs/install/config-file/ for available options. See https://immich.app/docs/install/config-file/ for available options.
''; '';
default = {}; default = { };
example = { example = {
ffmpeg.crf = 23; ffmpeg.crf = 23;
job.backgroundTask.concurrency = 5; job.backgroundTask.concurrency = 5;
@ -351,31 +392,31 @@ in
description = "SMTP address from which the emails originate."; description = "SMTP address from which the emails originate.";
example = "noreply@example.com"; example = "noreply@example.com";
}; };
replyTo = mkOption { replyTo = mkOption {
type = str; type = str;
description = "Reply-to address for emails."; description = "Reply-to address for emails.";
example = "support@example.com"; example = "support@example.com";
}; };
host = mkOption { host = mkOption {
type = str; type = str;
description = "SMTP host to send the emails to."; description = "SMTP host to send the emails to.";
example = "smtp.example.com"; example = "smtp.example.com";
}; };
port = mkOption { port = mkOption {
type = port; type = port;
description = "SMTP port to send the emails to."; description = "SMTP port to send the emails to.";
default = 587; default = 587;
}; };
username = mkOption { username = mkOption {
type = str; type = str;
description = "Username to connect to the SMTP host."; description = "Username to connect to the SMTP host.";
example = "smtp-user"; example = "smtp-user";
}; };
password = mkOption { password = mkOption {
description = "File containing the password to connect to the SMTP host."; description = "File containing the password to connect to the SMTP host.";
type = submodule { type = submodule {
@ -386,13 +427,13 @@ in
}; };
}; };
}; };
ignoreTLS = mkOption { ignoreTLS = mkOption {
type = bool; type = bool;
description = "Ignore TLS certificate errors."; description = "Ignore TLS certificate errors.";
default = false; default = false;
}; };
secure = mkOption { secure = mkOption {
type = bool; type = bool;
description = "Use secure connection (SSL/TLS)."; description = "Use secure connection (SSL/TLS).";
@ -428,7 +469,7 @@ in
host = "127.0.0.1"; host = "127.0.0.1";
port = cfg.port; port = cfg.port;
mediaLocation = cfg.mediaLocation; mediaLocation = cfg.mediaLocation;
# Hardware acceleration configuration # Hardware acceleration configuration
accelerationDevices = cfg.accelerationDevices; accelerationDevices = cfg.accelerationDevices;
@ -436,7 +477,7 @@ in
# Database configuration # Database configuration
database = { database = {
# Disable pgvecto.rs, as it was deprecated before SHB integration # Disable pgvecto.rs, as it was deprecated before SHB integration
enableVectors = false; enableVectors = false;
}; };
@ -452,9 +493,11 @@ in
REDIS_HOSTNAME = "127.0.0.1"; REDIS_HOSTNAME = "127.0.0.1";
REDIS_PORT = "6379"; REDIS_PORT = "6379";
REDIS_DBINDEX = "0"; REDIS_DBINDEX = "0";
} // lib.optionalAttrs (cfg.jwtSecretFile != null) { }
// lib.optionalAttrs (cfg.jwtSecretFile != null) {
JWT_SECRET_FILE = cfg.jwtSecretFile.result.path; JWT_SECRET_FILE = cfg.jwtSecretFile.result.path;
} // lib.optionalAttrs (cfg.settings != {} || cfg.sso.enable || cfg.smtp != null) { }
// lib.optionalAttrs (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null) {
IMMICH_CONFIG_FILE = configFile; IMMICH_CONFIG_FILE = configFile;
}; };
}; };
@ -464,27 +507,32 @@ in
"d /var/lib/immich 0700 immich immich" "d /var/lib/immich 0700 immich immich"
]; ];
# Configuration setup service - generates config only for SHB-managed settings # Configuration setup service - generates config only for SHB-managed settings
systemd.services.immich-setup-config = mkIf (cfg.enable && (cfg.settings != {} || cfg.sso.enable || cfg.smtp != null)) { systemd.services.immich-setup-config =
description = "Setup Immich configuration for SHB-managed settings"; mkIf (cfg.enable && (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null))
wantedBy = [ "multi-user.target" ]; {
before = [ "immich-server.service" ]; description = "Setup Immich configuration for SHB-managed settings";
after = [ "network.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { before = [ "immich-server.service" ];
Type = "oneshot"; after = [ "network.target" ];
User = "immich"; serviceConfig = {
Group = "immich"; Type = "oneshot";
}; User = "immich";
script = '' Group = "immich";
mkdir -p ${dataFolder} };
script = ''
# Generate config file with only SHB-managed settings mkdir -p ${dataFolder}
${configSetupScript}
''; # Generate config file with only SHB-managed settings
}; ${configSetupScript}
'';
};
# Add immich user to video and render groups for hardware acceleration # Add immich user to video and render groups for hardware acceleration
users.users.immich.extraGroups = optionals (cfg.accelerationDevices != []) [ "video" "render" ]; users.users.immich.extraGroups = optionals (cfg.accelerationDevices != [ ]) [
"video"
"render"
];
# PostgreSQL extensions are automatically handled by the Immich service # PostgreSQL extensions are automatically handled by the Immich service
@ -499,7 +547,10 @@ in
{ {
domain = fqdn; domain = fqdn;
policy = cfg.sso.authorization_policy; policy = cfg.sso.authorization_policy;
subject = ["group:immich_user" "group:immich_admin"]; subject = [
"group:immich_user"
"group:immich_admin"
];
} }
]; ];
authEndpoint = lib.mkIf (cfg.sso.enable) cfg.sso.endpoint; authEndpoint = lib.mkIf (cfg.sso.enable) cfg.sso.endpoint;
@ -515,10 +566,20 @@ in
# Ensure services start in correct order # Ensure services start in correct order
systemd.services.immich-server = { systemd.services.immich-server = {
after = [ "postgresql.service" "redis-immich.service" ] after = [
++ optionals (cfg.settings != {} || cfg.sso.enable || cfg.smtp != null) [ "immich-setup-config.service" ]; "postgresql.service"
requires = [ "postgresql.service" "redis-immich.service" ] "redis-immich.service"
++ optionals (cfg.settings != {} || cfg.sso.enable || cfg.smtp != null) [ "immich-setup-config.service" ]; ]
++ optionals (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null) [
"immich-setup-config.service"
];
requires = [
"postgresql.service"
"redis-immich.service"
]
++ optionals (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null) [
"immich-setup-config.service"
];
}; };
systemd.services.immich-machine-learning = mkIf cfg.machineLearning.enable { systemd.services.immich-machine-learning = mkIf cfg.machineLearning.enable {
@ -527,21 +588,21 @@ in
# Authelia integration for SSO # Authelia integration for SSO
shb.authelia.extraDefinitions = { shb.authelia.extraDefinitions = {
# Immich expects all users that get a token to be granted access. So users can either be part of the # Immich expects all users that get a token to be granted access. So users can either be part of the
# "admin" group or the "user" group. Users that are not part of either should be blocked by # "admin" group or the "user" group. Users that are not part of either should be blocked by
# the ID provider (Authelia). # the ID provider (Authelia).
user_attributes.${roleClaim}.expression = ''"${cfg.sso.adminUserGroup}" in groups ? "admin" : "user"''; user_attributes.${roleClaim}.expression =
''"${cfg.sso.adminUserGroup}" in groups ? "admin" : "user"'';
}; };
shb.authelia.extraOidcClaimsPolicies.immich_policy = { shb.authelia.extraOidcClaimsPolicies.immich_policy = {
custom_claims = { custom_claims = {
${roleClaim} = {}; ${roleClaim} = { };
}; };
}; };
shb.authelia.extraOidcScopes.immich_scope = { shb.authelia.extraOidcScopes.immich_scope = {
claims = [ roleClaim ]; claims = [ roleClaim ];
}; };
shb.authelia.oidcClients = lists.optionals (cfg.sso.enable && cfg.sso.provider == "Authelia") [ shb.authelia.oidcClients = lists.optionals (cfg.sso.enable && cfg.sso.provider == "Authelia") [
{ {
client_id = cfg.sso.clientID; client_id = cfg.sso.clientID;

View file

@ -1,11 +1,16 @@
{ config, lib, pkgs, ...}: {
config,
lib,
pkgs,
...
}:
let let
inherit (lib) types; inherit (lib) types;
cfg = config.shb.jellyfin; cfg = config.shb.jellyfin;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
@ -91,31 +96,33 @@ in
admin = lib.mkOption { admin = lib.mkOption {
description = "Default admin user info. Only needed if LDAP or SSO is not configured."; description = "Default admin user info. Only needed if LDAP or SSO is not configured.";
default = null; default = null;
type = types.nullOr (types.submodule { type = types.nullOr (
options = { types.submodule {
username = lib.mkOption { options = {
description = "Username of the default admin user."; username = lib.mkOption {
type = types.str; description = "Username of the default admin user.";
default = "jellyfin"; type = types.str;
}; default = "jellyfin";
password = lib.mkOption { };
description = "Password of the default admin user."; password = lib.mkOption {
type = types.submodule { description = "Password of the default admin user.";
options = contracts.secret.mkRequester { type = types.submodule {
mode = "0440"; options = contracts.secret.mkRequester {
owner = "jellyfin"; mode = "0440";
group = "jellyfin"; owner = "jellyfin";
restartUnits = [ "jellyfin.service" ]; group = "jellyfin";
restartUnits = [ "jellyfin.service" ];
};
}; };
}; };
}; };
}; }
}); );
}; };
ldap = lib.mkOption { ldap = lib.mkOption {
description = "LDAP configuration."; description = "LDAP configuration.";
default = {}; default = { };
type = types.submodule { type = types.submodule {
options = { options = {
enable = lib.mkEnableOption "LDAP"; enable = lib.mkEnableOption "LDAP";
@ -167,7 +174,7 @@ in
sso = lib.mkOption { sso = lib.mkOption {
description = "SSO configuration."; description = "SSO configuration.";
default = {}; default = { };
type = types.submodule { type = types.submodule {
options = { options = {
enable = lib.mkEnableOption "SSO"; enable = lib.mkEnableOption "SSO";
@ -203,7 +210,10 @@ in
}; };
authorization_policy = lib.mkOption { authorization_policy = lib.mkOption {
type = types.enum [ "one_factor" "two_factor" ]; type = types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication."; description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor"; default = "one_factor";
}; };
@ -238,23 +248,27 @@ in
description = '' description = ''
Backup configuration. Backup configuration.
''; '';
default = {}; default = { };
type = types.submodule { type = types.submodule {
options = contracts.backup.mkRequester { options = contracts.backup.mkRequester {
user = "jellyfin"; user = "jellyfin";
sourceDirectories = [ sourceDirectories = [
config.services.jellyfin.dataDir config.services.jellyfin.dataDir
]; ];
sourceDirectoriesText = ''[ sourceDirectoriesText = ''
"services.jellyfin.dataDir" [
]''; "services.jellyfin.dataDir"
]'';
}; };
}; };
}; };
}; };
imports = [ imports = [
(lib.mkRenamedOptionModule [ "shb" "jellyfin" "adminPassword" ] [ "shb" "jellyfin" "admin" "password" ]) (lib.mkRenamedOptionModule
[ "shb" "jellyfin" "adminPassword" ]
[ "shb" "jellyfin" "admin" "password" ]
)
]; ];
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
@ -269,7 +283,10 @@ in
networking.firewall = { networking.firewall = {
# from https://jellyfin.org/docs/general/networking/index.html, for auto-discovery # from https://jellyfin.org/docs/general/networking/index.html, for auto-discovery
allowedUDPPorts = [ 1900 7359 ]; allowedUDPPorts = [
1900
7359
];
}; };
services.nginx.enable = true; services.nginx.enable = true;
@ -380,21 +397,23 @@ in
proxy_set_header X-Forwarded-Protocol $scheme; proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
} }
''; '';
}; };
services.prometheus.scrapeConfigs = [{ services.prometheus.scrapeConfigs = [
job_name = "jellyfin"; {
static_configs = [ job_name = "jellyfin";
{ static_configs = [
targets = ["127.0.0.1:${toString cfg.port}"]; {
labels = { targets = [ "127.0.0.1:${toString cfg.port}" ];
"hostname" = config.networking.hostName; labels = {
"domain" = cfg.domain; "hostname" = config.networking.hostName;
}; "domain" = cfg.domain;
} };
]; }
}]; ];
}
];
# LDAP config but you need to install the plugin by hand # LDAP config but you need to install the plugin by hand
@ -427,7 +446,7 @@ in
<EnabledFolders /> <EnabledFolders />
<PasswordResetUrl /> <PasswordResetUrl />
</PluginConfiguration> </PluginConfiguration>
''; '';
# SchemeOverride is needed because of # SchemeOverride is needed because of
# https://github.com/9p4/jellyfin-plugin-sso/issues/264 # https://github.com/9p4/jellyfin-plugin-sso/issues/264
@ -560,23 +579,23 @@ in
</NetworkConfiguration> </NetworkConfiguration>
''; '';
in in
lib.strings.optionalString cfg.debug lib.strings.optionalString cfg.debug ''
'' if [ -f "${config.services.jellyfin.configDir}/logging.json" ] && [ ! -L "${config.services.jellyfin.configDir}/logging.json" ]; then
if [ -f "${config.services.jellyfin.configDir}/logging.json" ] && [ ! -L "${config.services.jellyfin.configDir}/logging.json" ]; then echo "A ${config.services.jellyfin.configDir}/logging.json file exists already, this indicates probably an existing installation. Please remove it before continuing."
echo "A ${config.services.jellyfin.configDir}/logging.json file exists already, this indicates probably an existing installation. Please remove it before continuing." exit 1
exit 1 fi
fi ln -fs "${debugLogging}" "${config.services.jellyfin.configDir}/logging.json"
ln -fs "${debugLogging}" "${config.services.jellyfin.configDir}/logging.json" ''
'' + (lib.shb.replaceSecretsScript {
+ (lib.shb.replaceSecretsScript { file = networkConfig;
file = networkConfig; # Write permissions are needed otherwise the jellyfin-cli tool will not work correctly.
# Write permissions are needed otherwise the jellyfin-cli tool will not work correctly. permissions = "u=rw,g=rw,o=";
permissions = "u=rw,g=rw,o="; resultPath = "${config.services.jellyfin.dataDir}/config/network.xml";
resultPath = "${config.services.jellyfin.dataDir}/config/network.xml"; replacements = [
replacements = [ ];
]; })
}) + lib.strings.optionalString cfg.ldap.enable (
+ lib.strings.optionalString cfg.ldap.enable (lib.shb.replaceSecretsScript { lib.shb.replaceSecretsScript {
file = ldapConfig; file = ldapConfig;
resultPath = "${config.services.jellyfin.dataDir}/plugins/configurations/LDAP-Auth.xml"; resultPath = "${config.services.jellyfin.dataDir}/plugins/configurations/LDAP-Auth.xml";
replacements = [ replacements = [
@ -585,8 +604,10 @@ in
source = cfg.ldap.adminPassword.result.path; source = cfg.ldap.adminPassword.result.path;
} }
]; ];
}) }
+ lib.strings.optionalString cfg.sso.enable (lib.shb.replaceSecretsScript { )
+ lib.strings.optionalString cfg.sso.enable (
lib.shb.replaceSecretsScript {
file = ssoConfig; file = ssoConfig;
resultPath = "${config.services.jellyfin.dataDir}/plugins/configurations/SSO-Auth.xml"; resultPath = "${config.services.jellyfin.dataDir}/plugins/configurations/SSO-Auth.xml";
replacements = [ replacements = [
@ -595,92 +616,96 @@ in
source = cfg.sso.sharedSecret.result.path; source = cfg.sso.sharedSecret.result.path;
} }
]; ];
}) }
+ lib.strings.optionalString cfg.sso.enable (lib.shb.replaceSecretsScript { )
+ lib.strings.optionalString cfg.sso.enable (
lib.shb.replaceSecretsScript {
file = brandingConfig; file = brandingConfig;
resultPath = "${config.services.jellyfin.dataDir}/config/branding.xml"; resultPath = "${config.services.jellyfin.dataDir}/config/branding.xml";
replacements = [ replacements = [
]; ];
}); }
);
systemd.services.jellyfin.serviceConfig.ExecStartPost = let systemd.services.jellyfin.serviceConfig.ExecStartPost =
# We must always wait for the service to be fully initialized, let
# even if we're planning on changing the config and restarting. # We must always wait for the service to be fully initialized,
waitForCurl = pkgs.writeShellApplication { # even if we're planning on changing the config and restarting.
name = "waitForCurl"; waitForCurl = pkgs.writeShellApplication {
runtimeInputs = [ pkgs.curl ]; name = "waitForCurl";
text = '' runtimeInputs = [ pkgs.curl ];
URL="http://127.0.0.1:${toString cfg.port}/System/Info/Public" text = ''
SLEEP_INTERVAL_SEC=2 URL="http://127.0.0.1:${toString cfg.port}/System/Info/Public"
TIMEOUT=60 SLEEP_INTERVAL_SEC=2
TIMEOUT=60
start_time=$(date +%s) start_time=$(date +%s)
echo "Waiting for $URL to return HTTP 200..." echo "Waiting for $URL to return HTTP 200..."
while true; do while true; do
status_code=$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true) status_code=$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)
if [ "$status_code" = "200" ]; then if [ "$status_code" = "200" ]; then
echo "Service is up (HTTP 200 received)." echo "Service is up (HTTP 200 received)."
exit 0 exit 0
fi fi
now=$(date +%s) now=$(date +%s)
elapsed=$(( now - start_time )) elapsed=$(( now - start_time ))
if [ $elapsed -ge $TIMEOUT ]; then if [ $elapsed -ge $TIMEOUT ]; then
echo "Timeout reached ($TIMEOUT seconds). Exiting with failure." echo "Timeout reached ($TIMEOUT seconds). Exiting with failure."
exit 1 exit 1
fi fi
echo "Waiting for service... (status: $status_code), elapsed: ''${elapsed}s" echo "Waiting for service... (status: $status_code), elapsed: ''${elapsed}s"
sleep "$SLEEP_INTERVAL_SEC" sleep "$SLEEP_INTERVAL_SEC"
done done
echo "Finished waiting, curl returned a 200." echo "Finished waiting, curl returned a 200."
''; '';
}; };
# This file is used to know if the jellyfin service has been restarted # This file is used to know if the jellyfin service has been restarted
# because a new config just got written to. # because a new config just got written to.
# #
# If the file does not exist, write the config, create the file then restart. # If the file does not exist, write the config, create the file then restart.
# If the file exists, do nothing and remove the file, resetting the state for the next time. # If the file exists, do nothing and remove the file, resetting the state for the next time.
restartedFile="${config.services.jellyfin.dataDir}/.jellyfin-restarted"; restartedFile = "${config.services.jellyfin.dataDir}/.jellyfin-restarted";
writeConfig = pkgs.writeShellApplication { writeConfig = pkgs.writeShellApplication {
name = "writeConfig"; name = "writeConfig";
runtimeInputs = [ pkgs.systemd ]; runtimeInputs = [ pkgs.systemd ];
text = '' text = ''
if ! [ -f "${restartedFile}" ]; then if ! [ -f "${restartedFile}" ]; then
${lib.getExe jellyfin-cli} wizard \ ${lib.getExe jellyfin-cli} wizard \
--datadir='${config.services.jellyfin.dataDir}' \ --datadir='${config.services.jellyfin.dataDir}' \
--configdir='${config.services.jellyfin.configDir}' \ --configdir='${config.services.jellyfin.configDir}' \
--cachedir='${config.services.jellyfin.cacheDir}' \ --cachedir='${config.services.jellyfin.cacheDir}' \
--logdir='${config.services.jellyfin.logDir}' \ --logdir='${config.services.jellyfin.logDir}' \
--username=${cfg.admin.username} \ --username=${cfg.admin.username} \
--password-file=${cfg.admin.password.result.path} \ --password-file=${cfg.admin.password.result.path} \
--enable-remote-access=true \ --enable-remote-access=true \
--write --write
fi fi
''; '';
}; };
restartJellyfinOnce = pkgs.writeShellApplication { restartJellyfinOnce = pkgs.writeShellApplication {
name = "restartJellyfin"; name = "restartJellyfin";
runtimeInputs = [ pkgs.systemd ]; runtimeInputs = [ pkgs.systemd ];
text = '' text = ''
if [ -f "${restartedFile}" ]; then if [ -f "${restartedFile}" ]; then
echo "jellyfin.service has been restarted" echo "jellyfin.service has been restarted"
rm "${restartedFile}" rm "${restartedFile}"
else else
echo "Restarting jellyfin.service" echo "Restarting jellyfin.service"
touch "${restartedFile}" touch "${restartedFile}"
systemctl reload-or-restart jellyfin.service systemctl reload-or-restart jellyfin.service
fi fi
''; '';
}; };
in in
lib.optionals (cfg.admin != null) [ lib.optionals (cfg.admin != null) [
(lib.getExe waitForCurl) (lib.getExe waitForCurl)

View file

@ -1,8 +1,13 @@
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
...
}:
let let
cfg = config.shb.karakeep; cfg = config.shb.karakeep;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
in in
{ {
imports = [ imports = [
@ -37,18 +42,18 @@ in
}; };
environment = lib.mkOption { environment = lib.mkOption {
default = {}; default = { };
type = lib.types.attrsOf lib.types.str; type = lib.types.attrsOf lib.types.str;
description = "Extra environment variables. See https://docs.karakeep.app/configuration/"; description = "Extra environment variables. See https://docs.karakeep.app/configuration/";
example = '' example = ''
{ {
OLLAMA_BASE_URL = "http://127.0.0.1:''${toString config.services.ollama.port}"; OLLAMA_BASE_URL = "http://127.0.0.1:''${toString config.services.ollama.port}";
INFERENCE_TEXT_MODEL = "deepseek-r1:1.5b"; INFERENCE_TEXT_MODEL = "deepseek-r1:1.5b";
INFERENCE_IMAGE_MODEL = "llava"; INFERENCE_IMAGE_MODEL = "llava";
EMBEDDING_TEXT_MODEL = "nomic-embed-text:v1.5"; EMBEDDING_TEXT_MODEL = "nomic-embed-text:v1.5";
INFERENCE_ENABLE_AUTO_SUMMARIZATION = "true"; INFERENCE_ENABLE_AUTO_SUMMARIZATION = "true";
INFERENCE_JOB_TIMEOUT_SEC = "200"; INFERENCE_JOB_TIMEOUT_SEC = "200";
} }
''; '';
}; };
@ -56,7 +61,7 @@ in
description = '' description = ''
Setup LDAP integration. Setup LDAP integration.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {
userGroup = lib.mkOption { userGroup = lib.mkOption {
@ -72,7 +77,7 @@ in
description = '' description = ''
Setup SSO integration. Setup SSO integration.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {
enable = lib.mkEnableOption "SSO integration."; enable = lib.mkEnableOption "SSO integration.";
@ -91,7 +96,10 @@ in
}; };
authorization_policy = lib.mkOption { authorization_policy = lib.mkOption {
type = lib.types.enum [ "one_factor" "two_factor" ]; type = lib.types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication."; description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor"; default = "one_factor";
}; };
@ -102,7 +110,11 @@ in
options = contracts.secret.mkRequester { options = contracts.secret.mkRequester {
owner = "karakeep"; owner = "karakeep";
# These services are the ones relying on the environment file containing the secrets. # These services are the ones relying on the environment file containing the secrets.
restartUnits = [ "karakeep-init.service" "karakeep-workers.service" "karakeep-workers.service" ]; restartUnits = [
"karakeep-init.service"
"karakeep-workers.service"
"karakeep-workers.service"
];
}; };
}; };
}; };
@ -125,7 +137,7 @@ in
description = '' description = ''
Backup state directory. Backup state directory.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = contracts.backup.mkRequester { options = contracts.backup.mkRequester {
user = "karakeep"; user = "karakeep";
@ -142,7 +154,11 @@ in
options = contracts.secret.mkRequester { options = contracts.secret.mkRequester {
owner = "karakeep"; owner = "karakeep";
# These services are the ones relying on the environment file containing the secrets. # These services are the ones relying on the environment file containing the secrets.
restartUnits = [ "karakeep-init.service" "karakeep-workers.service" "karakeep-workers.service" ]; restartUnits = [
"karakeep-init.service"
"karakeep-workers.service"
"karakeep-workers.service"
];
}; };
}; };
}; };
@ -153,93 +169,108 @@ in
options = contracts.secret.mkRequester { options = contracts.secret.mkRequester {
owner = "karakeep"; owner = "karakeep";
# These services are the ones relying on the environment file containing the secrets. # These services are the ones relying on the environment file containing the secrets.
restartUnits = [ "karakeep-init.service" "karakeep-workers.service" "karakeep-workers.service" ]; restartUnits = [
"karakeep-init.service"
"karakeep-workers.service"
"karakeep-workers.service"
];
}; };
}; };
}; };
}; };
config = (lib.mkMerge [ config = (
(lib.mkIf cfg.enable { lib.mkMerge [
services.karakeep = { (lib.mkIf cfg.enable {
enable = true; services.karakeep = {
meilisearch.enable = true; enable = true;
meilisearch.enable = true;
extraEnvironment = { extraEnvironment = {
PORT = toString cfg.port; PORT = toString cfg.port;
DISABLE_NEW_RELEASE_CHECK = "true"; # These are handled by NixOS DISABLE_NEW_RELEASE_CHECK = "true"; # These are handled by NixOS
} // cfg.environment; }
}; // cfg.environment;
};
shb.nginx.vhosts = [ shb.nginx.vhosts = [
{
inherit (cfg) subdomain domain ssl;
upstream = "http://127.0.0.1:${toString cfg.port}/";
}
];
# Piggybacking onto the upstream karakeep-init and replacing its script by ours.
# This is needed otherwise the MEILI_MASTER_KEY is generated randomly on first start
# instead of using the value from the cfg.meilisearchMasterKey option.
systemd.services.karakeep-init = {
script = lib.mkForce ((lib.shb.replaceSecrets {
userConfig = {
MEILI_MASTER_KEY.source = cfg.meilisearchMasterKey.result.path;
NEXTAUTH_SECRET.source = cfg.nextauthSecret.result.path;
} // lib.optionalAttrs cfg.sso.enable {
OAUTH_CLIENT_SECRET.source = cfg.sso.sharedSecret.result.path;
};
resultPath = "/var/lib/karakeep/settings.env";
generator = lib.shb.toEnvVar;
}) + ''
export DATA_DIR="$STATE_DIRECTORY"
exec ${config.services.karakeep.package}/lib/karakeep/migrate
'');
};
})
(lib.mkIf cfg.enable {
services.meilisearch = {
dumplessUpgrade = true;
environment = "production";
masterKeyFile = cfg.meilisearchMasterKey.result.path;
};
})
(lib.mkIf (cfg.enable && cfg.sso.enable) {
shb.lldap.ensureGroups = {
${cfg.ldap.userGroup} = {};
};
shb.authelia.extraOidcAuthorizationPolicies.karakeep = {
default_policy = "deny";
rules = [
{ {
subject = [ "group:${cfg.ldap.userGroup}" ]; inherit (cfg) subdomain domain ssl;
policy = cfg.sso.authorization_policy; upstream = "http://127.0.0.1:${toString cfg.port}/";
} }
]; ];
};
shb.authelia.oidcClients = [ # Piggybacking onto the upstream karakeep-init and replacing its script by ours.
{ # This is needed otherwise the MEILI_MASTER_KEY is generated randomly on first start
client_id = cfg.sso.clientID; # instead of using the value from the cfg.meilisearchMasterKey option.
client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; systemd.services.karakeep-init = {
scopes = [ "openid" "email" "profile" ]; script = lib.mkForce (
authorization_policy = "karakeep"; (lib.shb.replaceSecrets {
redirect_uris = [ userConfig = {
"https://${cfg.subdomain}.${cfg.domain}/api/auth/callback/custom" MEILI_MASTER_KEY.source = cfg.meilisearchMasterKey.result.path;
]; NEXTAUTH_SECRET.source = cfg.nextauthSecret.result.path;
} }
]; // lib.optionalAttrs cfg.sso.enable {
services.karakeep = { OAUTH_CLIENT_SECRET.source = cfg.sso.sharedSecret.result.path;
extraEnvironment = { };
DISABLE_SIGNUPS = "false"; resultPath = "/var/lib/karakeep/settings.env";
DISABLE_PASSWORD_AUTH = "true"; generator = lib.shb.toEnvVar;
NEXTAUTH_URL = "https://${cfg.subdomain}.${cfg.domain}"; })
OAUTH_WELLKNOWN_URL = "${cfg.sso.authEndpoint}/.well-known/openid-configuration"; + ''
OAUTH_PROVIDER_NAME = "Single Sign-On"; export DATA_DIR="$STATE_DIRECTORY"
OAUTH_CLIENT_ID = cfg.sso.clientID; exec ${config.services.karakeep.package}/lib/karakeep/migrate
OAUTH_SCOPE = "openid email profile"; ''
);
}; };
}; })
}) (lib.mkIf cfg.enable {
]); services.meilisearch = {
dumplessUpgrade = true;
environment = "production";
masterKeyFile = cfg.meilisearchMasterKey.result.path;
};
})
(lib.mkIf (cfg.enable && cfg.sso.enable) {
shb.lldap.ensureGroups = {
${cfg.ldap.userGroup} = { };
};
shb.authelia.extraOidcAuthorizationPolicies.karakeep = {
default_policy = "deny";
rules = [
{
subject = [ "group:${cfg.ldap.userGroup}" ];
policy = cfg.sso.authorization_policy;
}
];
};
shb.authelia.oidcClients = [
{
client_id = cfg.sso.clientID;
client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;
scopes = [
"openid"
"email"
"profile"
];
authorization_policy = "karakeep";
redirect_uris = [
"https://${cfg.subdomain}.${cfg.domain}/api/auth/callback/custom"
];
}
];
services.karakeep = {
extraEnvironment = {
DISABLE_SIGNUPS = "false";
DISABLE_PASSWORD_AUTH = "true";
NEXTAUTH_URL = "https://${cfg.subdomain}.${cfg.domain}";
OAUTH_WELLKNOWN_URL = "${cfg.sso.authEndpoint}/.well-known/openid-configuration";
OAUTH_PROVIDER_NAME = "Single Sign-On";
OAUTH_CLIENT_ID = cfg.sso.clientID;
OAUTH_SCOPE = "openid email profile";
};
};
})
]
);
} }

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,22 @@
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
...
}:
let let
cfg = config.shb.open-webui; cfg = config.shb.open-webui;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
roleClaim = "openwebui_groups"; roleClaim = "openwebui_groups";
oauthScopes = [ "openid" "email" "profile" "groups" "${roleClaim}" ]; oauthScopes = [
"openid"
"email"
"profile"
"groups"
"${roleClaim}"
];
in in
{ {
imports = [ imports = [
@ -42,19 +53,19 @@ in
environment = lib.mkOption { environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str; type = lib.types.attrsOf lib.types.str;
description = "Extra environment variables. See https://docs.openwebui.com/getting-started/env-configuration"; description = "Extra environment variables. See https://docs.openwebui.com/getting-started/env-configuration";
default = {}; default = { };
example = '' example = ''
{ {
WEBUI_NAME = "SelfHostBlocks"; WEBUI_NAME = "SelfHostBlocks";
OLLAMA_BASE_URL = "http://127.0.0.1:''${toString config.services.ollama.port}"; OLLAMA_BASE_URL = "http://127.0.0.1:''${toString config.services.ollama.port}";
RAG_EMBEDDING_MODEL = "nomic-embed-text:v1.5"; RAG_EMBEDDING_MODEL = "nomic-embed-text:v1.5";
ENABLE_OPENAI_API = "True"; ENABLE_OPENAI_API = "True";
OPENAI_API_BASE_URL = "http://127.0.0.1:''${toString config.services.llama-cpp.port}"; OPENAI_API_BASE_URL = "http://127.0.0.1:''${toString config.services.llama-cpp.port}";
ENABLE_WEB_SEARCH = "True"; ENABLE_WEB_SEARCH = "True";
RAG_EMBEDDING_ENGINE = "openai"; RAG_EMBEDDING_ENGINE = "openai";
} }
''; '';
}; };
@ -62,7 +73,7 @@ in
description = '' description = ''
Setup LDAP integration. Setup LDAP integration.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {
userGroup = lib.mkOption { userGroup = lib.mkOption {
@ -84,7 +95,7 @@ in
description = '' description = ''
Setup SSO integration. Setup SSO integration.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {
enable = lib.mkEnableOption "SSO integration."; enable = lib.mkEnableOption "SSO integration.";
@ -103,7 +114,10 @@ in
}; };
authorization_policy = lib.mkOption { authorization_policy = lib.mkOption {
type = lib.types.enum [ "one_factor" "two_factor" ]; type = lib.types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication."; description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor"; default = "one_factor";
}; };
@ -136,7 +150,7 @@ in
description = '' description = ''
Backup state directory. Backup state directory.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = contracts.backup.mkRequester { options = contracts.backup.mkRequester {
user = "open-webui"; user = "open-webui";
@ -149,124 +163,127 @@ in
}; };
}; };
config = (lib.mkMerge [ config = (
(lib.mkIf cfg.enable { lib.mkMerge [
users.users.open-webui = { (lib.mkIf cfg.enable {
isSystemUser = true; users.users.open-webui = {
group = "open-webui"; isSystemUser = true;
}; group = "open-webui";
users.groups.open-webui = {};
services.open-webui = {
enable = true;
host = "127.0.0.1";
inherit (cfg) port;
environment = {
WEBUI_URL = "https://${cfg.subdomain}.${cfg.domain}";
ENABLE_PERSISTENT_CONFIG = "False";
ANONYMIZED_TELEMETRY = "False";
DO_NOT_TRACK = "True";
SCARF_NO_ANALYTICS = "True";
ENABLE_VERSION_UPDATE_CHECK = "False";
} // cfg.environment;
};
systemd.services.open-webui.path = [
pkgs.ffmpeg-headless
];
shb.nginx.vhosts = [
{
inherit (cfg) subdomain domain ssl;
upstream = "http://127.0.0.1:${toString cfg.port}/";
extraConfig = ''
proxy_read_timeout 300s;
proxy_send_timeout 300s;
'';
}
];
})
(lib.mkIf (cfg.enable && cfg.sso.enable) {
shb.lldap.ensureGroups = {
${cfg.ldap.userGroup} = {};
${cfg.ldap.adminGroup} = {};
};
services.open-webui = {
package = pkgs.open-webui.overrideAttrs (finalAttrs: {
patches = [
../../patches/0001-selfhostblocks-never-onboard.patch
../../patches/0002-selfhostblocks-do-not-allow-unauthorized-roles.patch
];
});
environment = {
ENABLE_SIGNUP = "False";
WEBUI_AUTH = "True";
ENABLE_FORWARD_USER_INFO_HEADERS = "True";
ENABLE_OAUTH_SIGNUP = "True";
OAUTH_UPDATE_PICTURE_ON_LOGIN = "True";
OAUTH_CLIENT_ID = cfg.sso.clientID;
OPENID_PROVIDER_URL = "${cfg.sso.authEndpoint}/.well-known/openid-configuration";
OAUTH_PROVIDER_NAME = "Single Sign-On";
OAUTH_USERNAME_CLAIM = "preferred_username";
ENABLE_OAUTH_ROLE_MANAGEMENT = "True";
OAUTH_ALLOWED_ROLES = "user,admin";
OAUTH_ADMIN_ROLES = "admin";
OAUTH_ROLES_CLAIM = roleClaim;
OAUTH_SCOPES = lib.concatStringsSep " " oauthScopes;
}; };
}; users.groups.open-webui = { };
shb.authelia.extraDefinitions = { services.open-webui = {
user_attributes.${roleClaim}.expression = enable = true;
''"${cfg.ldap.adminGroup}" in groups ? ["admin"] : ("${cfg.ldap.userGroup}" in groups ? ["user"] : [""])'';
}; host = "127.0.0.1";
shb.authelia.extraOidcClaimsPolicies.${roleClaim} = { inherit (cfg) port;
custom_claims = {
"${roleClaim}" = {}; environment = {
WEBUI_URL = "https://${cfg.subdomain}.${cfg.domain}";
ENABLE_PERSISTENT_CONFIG = "False";
ANONYMIZED_TELEMETRY = "False";
DO_NOT_TRACK = "True";
SCARF_NO_ANALYTICS = "True";
ENABLE_VERSION_UPDATE_CHECK = "False";
}
// cfg.environment;
}; };
};
shb.authelia.extraOidcScopes."${roleClaim}" = {
claims = [ "${roleClaim}" ];
};
shb.authelia.oidcClients = [ systemd.services.open-webui.path = [
{ pkgs.ffmpeg-headless
client_id = cfg.sso.clientID; ];
client_name = "Open WebUI";
client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;
claims_policy = "${roleClaim}";
public = false;
authorization_policy = cfg.sso.authorization_policy;
redirect_uris = [
"https://${cfg.subdomain}.${cfg.domain}/oauth/oidc/callback"
];
scopes = oauthScopes;
}
];
systemd.services.open-webui.serviceConfig.EnvironmentFile = "/run/open-webui/secrets.env"; shb.nginx.vhosts = [
systemd.tmpfiles.rules = [ {
"d '/run/open-webui' 0750 root root - -" inherit (cfg) subdomain domain ssl;
]; upstream = "http://127.0.0.1:${toString cfg.port}/";
systemd.services.open-webui-pre = { extraConfig = ''
script = lib.shb.replaceSecrets { proxy_read_timeout 300s;
userConfig = { proxy_send_timeout 300s;
OAUTH_CLIENT_SECRET.source = cfg.sso.sharedSecret.result.path; '';
}
];
})
(lib.mkIf (cfg.enable && cfg.sso.enable) {
shb.lldap.ensureGroups = {
${cfg.ldap.userGroup} = { };
${cfg.ldap.adminGroup} = { };
};
services.open-webui = {
package = pkgs.open-webui.overrideAttrs (finalAttrs: {
patches = [
../../patches/0001-selfhostblocks-never-onboard.patch
../../patches/0002-selfhostblocks-do-not-allow-unauthorized-roles.patch
];
});
environment = {
ENABLE_SIGNUP = "False";
WEBUI_AUTH = "True";
ENABLE_FORWARD_USER_INFO_HEADERS = "True";
ENABLE_OAUTH_SIGNUP = "True";
OAUTH_UPDATE_PICTURE_ON_LOGIN = "True";
OAUTH_CLIENT_ID = cfg.sso.clientID;
OPENID_PROVIDER_URL = "${cfg.sso.authEndpoint}/.well-known/openid-configuration";
OAUTH_PROVIDER_NAME = "Single Sign-On";
OAUTH_USERNAME_CLAIM = "preferred_username";
ENABLE_OAUTH_ROLE_MANAGEMENT = "True";
OAUTH_ALLOWED_ROLES = "user,admin";
OAUTH_ADMIN_ROLES = "admin";
OAUTH_ROLES_CLAIM = roleClaim;
OAUTH_SCOPES = lib.concatStringsSep " " oauthScopes;
}; };
resultPath = "/run/open-webui/secrets.env";
generator = lib.shb.toEnvVar;
}; };
serviceConfig.Type = "oneshot";
wantedBy = [ "multi-user.target" ]; shb.authelia.extraDefinitions = {
before = [ "open-webui.service" ]; user_attributes.${roleClaim}.expression =
requiredBy = [ "open-webui.service" ]; ''"${cfg.ldap.adminGroup}" in groups ? ["admin"] : ("${cfg.ldap.userGroup}" in groups ? ["user"] : [""])'';
}; };
}) shb.authelia.extraOidcClaimsPolicies.${roleClaim} = {
]); custom_claims = {
"${roleClaim}" = { };
};
};
shb.authelia.extraOidcScopes."${roleClaim}" = {
claims = [ "${roleClaim}" ];
};
shb.authelia.oidcClients = [
{
client_id = cfg.sso.clientID;
client_name = "Open WebUI";
client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;
claims_policy = "${roleClaim}";
public = false;
authorization_policy = cfg.sso.authorization_policy;
redirect_uris = [
"https://${cfg.subdomain}.${cfg.domain}/oauth/oidc/callback"
];
scopes = oauthScopes;
}
];
systemd.services.open-webui.serviceConfig.EnvironmentFile = "/run/open-webui/secrets.env";
systemd.tmpfiles.rules = [
"d '/run/open-webui' 0750 root root - -"
];
systemd.services.open-webui-pre = {
script = lib.shb.replaceSecrets {
userConfig = {
OAUTH_CLIENT_SECRET.source = cfg.sso.sharedSecret.result.path;
};
resultPath = "/run/open-webui/secrets.env";
generator = lib.shb.toEnvVar;
};
serviceConfig.Type = "oneshot";
wantedBy = [ "multi-user.target" ];
before = [ "open-webui.service" ];
requiredBy = [ "open-webui.service" ];
};
})
]
);
} }

View file

@ -1,10 +1,15 @@
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
...
}:
let let
cfg = config.shb.pinchflat; cfg = config.shb.pinchflat;
inherit (lib) types; inherit (lib) types;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
in in
{ {
imports = [ imports = [
@ -57,7 +62,10 @@ in
}; };
timeZone = lib.mkOption { timeZone = lib.mkOption {
type = lib.types.oneOf [ lib.types.str lib.shb.secretFileType ]; type = lib.types.oneOf [
lib.types.str
lib.shb.secretFileType
];
description = "Timezone of this instance."; description = "Timezone of this instance.";
example = "America/Los_Angeles"; example = "America/Los_Angeles";
}; };
@ -66,7 +74,7 @@ in
description = '' description = ''
Setup LDAP integration. Setup LDAP integration.
''; '';
default = {}; default = { };
type = types.submodule { type = types.submodule {
options = { options = {
enable = lib.mkEnableOption "LDAP integration." // { enable = lib.mkEnableOption "LDAP integration." // {
@ -86,7 +94,7 @@ in
description = '' description = ''
Setup SSO integration. Setup SSO integration.
''; '';
default = {}; default = { };
type = types.submodule { type = types.submodule {
options = { options = {
enable = lib.mkEnableOption "SSO integration."; enable = lib.mkEnableOption "SSO integration.";
@ -99,7 +107,10 @@ in
}; };
authorization_policy = lib.mkOption { authorization_policy = lib.mkOption {
type = types.enum [ "one_factor" "two_factor" ]; type = types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication."; description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor"; default = "one_factor";
}; };
@ -111,7 +122,7 @@ in
description = '' description = ''
Backup media directory `shb.mediaDir`. Backup media directory `shb.mediaDir`.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = contracts.backup.mkRequester { options = contracts.backup.mkRequester {
user = "pinchflat"; user = "pinchflat";
@ -142,7 +153,9 @@ in
# This should be using a contract instead of setting the option directly. # This should be using a contract instead of setting the option directly.
shb.lldap = lib.mkIf config.shb.lldap.enable { shb.lldap = lib.mkIf config.shb.lldap.enable {
ensureGroups = { ${cfg.ldap.userGroup} = {}; }; ensureGroups = {
${cfg.ldap.userGroup} = { };
};
}; };
systemd.services.pinchflat-pre = { systemd.services.pinchflat-pre = {
@ -160,7 +173,7 @@ in
requiredBy = [ "pinchflat.service" ]; requiredBy = [ "pinchflat.service" ];
}; };
shb.nginx.vhosts = [ shb.nginx.vhosts = [
{ {
inherit (cfg) subdomain domain ssl; inherit (cfg) subdomain domain ssl;
inherit (cfg.sso) authEndpoint; inherit (cfg.sso) authEndpoint;
@ -181,7 +194,7 @@ in
job_name = "pinchflat"; job_name = "pinchflat";
static_configs = [ static_configs = [
{ {
targets = ["127.0.0.1:${toString cfg.port}"]; targets = [ "127.0.0.1:${toString cfg.port}" ];
labels = { labels = {
"hostname" = config.networking.hostName; "hostname" = config.networking.hostName;
"domain" = cfg.domain; "domain" = cfg.domain;

View file

@ -1,13 +1,22 @@
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.shb.vaultwarden; cfg = config.shb.vaultwarden;
contracts = pkgs.callPackage ../contracts {}; contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
dataFolder = if lib.versionOlder (config.system.stateVersion or "24.11") "24.11" then "/var/lib/bitwarden_rs" else "/var/lib/vaultwarden"; dataFolder =
if lib.versionOlder (config.system.stateVersion or "24.11") "24.11" then
"/var/lib/bitwarden_rs"
else
"/var/lib/vaultwarden";
in in
{ {
imports = [ imports = [
@ -55,7 +64,10 @@ in
mode = "0440"; mode = "0440";
owner = "vaultwarden"; owner = "vaultwarden";
group = "postgres"; group = "postgres";
restartUnits = [ "vaultwarden.service" "postgresql.service" ]; restartUnits = [
"vaultwarden.service"
"postgresql.service"
];
}; };
}; };
}; };
@ -63,53 +75,59 @@ in
smtp = lib.mkOption { smtp = lib.mkOption {
description = "SMTP options."; description = "SMTP options.";
default = null; default = null;
type = lib.types.nullOr (lib.types.submodule { type = lib.types.nullOr (
options = { lib.types.submodule {
from_address = lib.mkOption { options = {
type = lib.types.str; from_address = lib.mkOption {
description = "SMTP address from which the emails originate."; type = lib.types.str;
example = "vaultwarden@mydomain.com"; description = "SMTP address from which the emails originate.";
}; example = "vaultwarden@mydomain.com";
from_name = lib.mkOption { };
type = lib.types.str; from_name = lib.mkOption {
description = "SMTP name from which the emails originate."; type = lib.types.str;
default = "Vaultwarden"; description = "SMTP name from which the emails originate.";
}; default = "Vaultwarden";
host = lib.mkOption { };
type = lib.types.str; host = lib.mkOption {
description = "SMTP host to send the emails to."; type = lib.types.str;
}; description = "SMTP host to send the emails to.";
security = lib.mkOption { };
type = lib.types.enum [ "starttls" "force_tls" "off" ]; security = lib.mkOption {
description = "Security expected by SMTP host."; type = lib.types.enum [
default = "starttls"; "starttls"
}; "force_tls"
port = lib.mkOption { "off"
type = lib.types.port; ];
description = "SMTP port to send the emails to."; description = "Security expected by SMTP host.";
default = 25; default = "starttls";
}; };
username = lib.mkOption { port = lib.mkOption {
type = lib.types.str; type = lib.types.port;
description = "Username to connect to the SMTP host."; description = "SMTP port to send the emails to.";
}; default = 25;
auth_mechanism = lib.mkOption { };
type = lib.types.enum [ "Login" ]; username = lib.mkOption {
description = "Auth mechanism."; type = lib.types.str;
default = "Login"; description = "Username to connect to the SMTP host.";
}; };
password = lib.mkOption { auth_mechanism = lib.mkOption {
description = "File containing the password to connect to the SMTP host."; type = lib.types.enum [ "Login" ];
type = lib.types.submodule { description = "Auth mechanism.";
options = contracts.secret.mkRequester { default = "Login";
mode = "0400"; };
owner = "vaultwarden"; password = lib.mkOption {
restartUnits = [ "vaultwarden.service" ]; description = "File containing the password to connect to the SMTP host.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
mode = "0400";
owner = "vaultwarden";
restartUnits = [ "vaultwarden.service" ];
};
}; };
}; };
}; };
}; }
}); );
}; };
mount = lib.mkOption { mount = lib.mkOption {
@ -127,14 +145,16 @@ in
``` ```
''; '';
readOnly = true; readOnly = true;
default = { path = dataFolder; }; default = {
path = dataFolder;
};
}; };
backup = lib.mkOption { backup = lib.mkOption {
description = '' description = ''
Backup configuration. Backup configuration.
''; '';
default = {}; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = contracts.backup.mkRequester { options = contracts.backup.mkRequester {
user = "vaultwarden"; user = "vaultwarden";
@ -170,7 +190,8 @@ in
ROCKET_LOG = if cfg.debug then "trace" else "info"; ROCKET_LOG = if cfg.debug then "trace" else "info";
ROCKET_ADDRESS = "127.0.0.1"; ROCKET_ADDRESS = "127.0.0.1";
ROCKET_PORT = cfg.port; ROCKET_PORT = cfg.port;
} // lib.optionalAttrs (cfg.smtp != null) { }
// lib.optionalAttrs (cfg.smtp != null) {
SMTP_FROM = cfg.smtp.from_address; SMTP_FROM = cfg.smtp.from_address;
SMTP_FROM_NAME = cfg.smtp.from_name; SMTP_FROM_NAME = cfg.smtp.from_name;
SMTP_HOST = cfg.smtp.host; SMTP_HOST = cfg.smtp.host;
@ -189,27 +210,32 @@ in
]; ];
# Needed to be able to write template config. # Needed to be able to write template config.
systemd.services.vaultwarden.serviceConfig.ProtectHome = lib.mkForce false; systemd.services.vaultwarden.serviceConfig.ProtectHome = lib.mkForce false;
systemd.services.vaultwarden.preStart = systemd.services.vaultwarden.preStart = lib.shb.replaceSecrets {
lib.shb.replaceSecrets { userConfig = {
userConfig = { DATABASE_URL.source = cfg.databasePassword.result.path;
DATABASE_URL.source = cfg.databasePassword.result.path; DATABASE_URL.transform = v: "postgresql://vaultwarden:${v}@127.0.0.1:5432/vaultwarden";
DATABASE_URL.transform = v: "postgresql://vaultwarden:${v}@127.0.0.1:5432/vaultwarden"; }
} // lib.optionalAttrs (cfg.smtp != null) { // lib.optionalAttrs (cfg.smtp != null) {
SMTP_PASSWORD.source = cfg.smtp.password.result.path; SMTP_PASSWORD.source = cfg.smtp.password.result.path;
};
resultPath = "${dataFolder}/vaultwarden.env";
generator = lib.shb.toEnvVar;
}; };
resultPath = "${dataFolder}/vaultwarden.env";
generator = lib.shb.toEnvVar;
};
shb.nginx.vhosts = [ shb.nginx.vhosts = [
{ {
inherit (cfg) subdomain domain authEndpoint ssl; inherit (cfg)
subdomain
domain
authEndpoint
ssl
;
upstream = "http://127.0.0.1:${toString config.services.vaultwarden.config.ROCKET_PORT}"; upstream = "http://127.0.0.1:${toString config.services.vaultwarden.config.ROCKET_PORT}";
autheliaRules = lib.mkIf (cfg.authEndpoint != null) [ autheliaRules = lib.mkIf (cfg.authEndpoint != null) [
{ {
domain = "${fqdn}"; domain = "${fqdn}";
policy = "two_factor"; policy = "two_factor";
subject = ["group:vaultwarden_admin"]; subject = [ "group:vaultwarden_admin" ];
resources = [ resources = [
"^/admin" "^/admin"
]; ];

View file

@ -8,178 +8,185 @@ in
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
name = "authelia-basic"; name = "authelia-basic";
nodes.machine = { config, pkgs, ... }: { nodes.machine =
imports = [ { config, pkgs, ... }:
(pkgs'.path + "/nixos/modules/profiles/headless.nix") {
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") imports = [
../../modules/blocks/authelia.nix (pkgs'.path + "/nixos/modules/profiles/headless.nix")
../../modules/blocks/hardcodedsecret.nix (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
]; ../../modules/blocks/authelia.nix
../../modules/blocks/hardcodedsecret.nix
networking.hosts = {
"127.0.0.1" = [
"machine.com"
"client1.machine.com"
"client2.machine.com"
"ldap.machine.com"
"authelia.machine.com"
]; ];
};
shb.lldap = { networking.hosts = {
enable = true; "127.0.0.1" = [
dcdomain = "dc=example,dc=com"; "machine.com"
subdomain = "ldap"; "client1.machine.com"
domain = "machine.com"; "client2.machine.com"
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result; "ldap.machine.com"
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result; "authelia.machine.com"
}; ];
shb.hardcodedsecret.ldapUserPassword = {
request = config.shb.lldap.ldapUserPassword.request;
settings.content = ldapAdminPassword;
};
shb.hardcodedsecret.jwtSecret = {
request = config.shb.lldap.jwtSecret.request;
settings.content = "jwtsecret";
};
shb.authelia = {
enable = true;
subdomain = "authelia";
domain = "machine.com";
ldapHostname = "${config.shb.lldap.subdomain}.${config.shb.lldap.domain}";
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;
}; };
oidcClients = [ shb.lldap = {
{ enable = true;
client_id = "client1"; dcdomain = "dc=example,dc=com";
client_name = "My Client 1"; subdomain = "ldap";
client_secret.source = pkgs.writeText "secret" "$pbkdf2-sha512$310000$LR2wY11djfLrVQixdlLJew$rPByqFt6JfbIIAITxzAXckwh51QgV8E5YZmA8rXOzkMfBUcMq7cnOKEXF6MAFbjZaGf3J/B1OzLWZTCuZtALVw"; domain = "machine.com";
public = false; ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
authorization_policy = "one_factor"; jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;
redirect_uris = [ "http://client1.machine.com/redirect" ]; };
}
{
client_id = "client2";
client_name = "My Client 2";
client_secret.source = pkgs.writeText "secret" "$pbkdf2-sha512$310000$76EqVU1N9K.iTOvD4WJ6ww$hqNJU.UHphiCjMChSqk27lUTjDqreuMuyV/u39Esc6HyiRXp5Ecx89ypJ5M0xk3Na97vbgDpwz7il5uwzQ4bfw";
public = false;
authorization_policy = "one_factor";
redirect_uris = [ "http://client2.machine.com/redirect" ];
}
];
};
shb.hardcodedsecret.autheliaJwtSecret = { shb.hardcodedsecret.ldapUserPassword = {
request = config.shb.authelia.secrets.jwtSecret.request; request = config.shb.lldap.ldapUserPassword.request;
settings.content = "jwtSecret"; settings.content = ldapAdminPassword;
}; };
shb.hardcodedsecret.ldapAdminPassword = { shb.hardcodedsecret.jwtSecret = {
request = config.shb.authelia.secrets.ldapAdminPassword.request; request = config.shb.lldap.jwtSecret.request;
settings.content = ldapAdminPassword; settings.content = "jwtsecret";
}; };
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";
};
specialisation = { shb.authelia = {
withDebug.configuration = { enable = true;
shb.authelia.debug = true; subdomain = "authelia";
domain = "machine.com";
ldapHostname = "${config.shb.lldap.subdomain}.${config.shb.lldap.domain}";
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;
};
oidcClients = [
{
client_id = "client1";
client_name = "My Client 1";
client_secret.source = pkgs.writeText "secret" "$pbkdf2-sha512$310000$LR2wY11djfLrVQixdlLJew$rPByqFt6JfbIIAITxzAXckwh51QgV8E5YZmA8rXOzkMfBUcMq7cnOKEXF6MAFbjZaGf3J/B1OzLWZTCuZtALVw";
public = false;
authorization_policy = "one_factor";
redirect_uris = [ "http://client1.machine.com/redirect" ];
}
{
client_id = "client2";
client_name = "My Client 2";
client_secret.source = pkgs.writeText "secret" "$pbkdf2-sha512$310000$76EqVU1N9K.iTOvD4WJ6ww$hqNJU.UHphiCjMChSqk27lUTjDqreuMuyV/u39Esc6HyiRXp5Ecx89ypJ5M0xk3Na97vbgDpwz7il5uwzQ4bfw";
public = false;
authorization_policy = "one_factor";
redirect_uris = [ "http://client2.machine.com/redirect" ];
}
];
};
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 = ldapAdminPassword;
};
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";
};
specialisation = {
withDebug.configuration = {
shb.authelia.debug = true;
};
}; };
}; };
};
testScript = { nodes, ... }: testScript =
let { nodes, ... }:
specializations = "${nodes.machine.system.build.toplevel}/specialisation"; let
in specializations = "${nodes.machine.system.build.toplevel}/specialisation";
'' in
import json ''
import json
start_all() start_all()
def tests(): def tests():
machine.wait_for_unit("lldap.service") machine.wait_for_unit("lldap.service")
machine.wait_for_unit("authelia-authelia.machine.com.target") machine.wait_for_unit("authelia-authelia.machine.com.target")
machine.wait_for_open_port(9091) machine.wait_for_open_port(9091)
endpoints = json.loads(machine.succeed("curl -s http://machine.com/.well-known/openid-configuration")) endpoints = json.loads(machine.succeed("curl -s http://machine.com/.well-known/openid-configuration"))
auth_endpoint = endpoints['authorization_endpoint'] auth_endpoint = endpoints['authorization_endpoint']
print(f"auth_endpoint: {auth_endpoint}") print(f"auth_endpoint: {auth_endpoint}")
if auth_endpoint != "http://machine.com/api/oidc/authorization": if auth_endpoint != "http://machine.com/api/oidc/authorization":
raise Exception("Unexpected auth_endpoint") raise Exception("Unexpected auth_endpoint")
resp = machine.succeed( resp = machine.succeed(
"curl -f -s '" "curl -f -s '"
+ auth_endpoint + auth_endpoint
+ "?client_id=other" + "?client_id=other"
+ "&redirect_uri=http://client1.machine.com/redirect" + "&redirect_uri=http://client1.machine.com/redirect"
+ "&scope=openid%20profile%20email" + "&scope=openid%20profile%20email"
+ "&response_type=code" + "&response_type=code"
+ "&state=99999999'" + "&state=99999999'"
) )
print(resp) print(resp)
if resp != "": if resp != "":
raise Exception("unexpected response") raise Exception("unexpected response")
resp = machine.succeed( resp = machine.succeed(
"curl -f -s '" "curl -f -s '"
+ auth_endpoint + auth_endpoint
+ "?client_id=client1" + "?client_id=client1"
+ "&redirect_uri=http://client1.machine.com/redirect" + "&redirect_uri=http://client1.machine.com/redirect"
+ "&scope=openid%20profile%20email" + "&scope=openid%20profile%20email"
+ "&response_type=code" + "&response_type=code"
+ "&state=11111111'" + "&state=11111111'"
) )
print(resp) print(resp)
if "Found" not in resp: if "Found" not in resp:
raise Exception("unexpected response") raise Exception("unexpected response")
resp = machine.succeed( resp = machine.succeed(
"curl -f -s '" "curl -f -s '"
+ auth_endpoint + auth_endpoint
+ "?client_id=client2" + "?client_id=client2"
+ "&redirect_uri=http://client2.machine.com/redirect" + "&redirect_uri=http://client2.machine.com/redirect"
+ "&scope=openid%20profile%20email" + "&scope=openid%20profile%20email"
+ "&response_type=code" + "&response_type=code"
+ "&state=22222222'" + "&state=22222222'"
) )
print(resp) print(resp)
if "Found" not in resp: if "Found" not in resp:
raise Exception("unexpected response") raise Exception("unexpected response")
with subtest("no debug"): with subtest("no debug"):
tests() tests()
with subtest("with debug"): with subtest("with debug"):
machine.succeed('${specializations}/withDebug/bin/switch-to-configuration test') machine.succeed('${specializations}/withDebug/bin/switch-to-configuration test')
tests() tests()
''; '';
}; };
} }

View file

@ -24,7 +24,9 @@ in
configWithTemplates = lib.shb.withReplacements userConfig; configWithTemplates = lib.shb.withReplacements userConfig;
nonSecretConfigFile = pkgs.writeText "config.yaml.template" (lib.generators.toJSON {} configWithTemplates); nonSecretConfigFile = pkgs.writeText "config.yaml.template" (
lib.generators.toJSON { } configWithTemplates
);
replacements = lib.shb.getReplacements userConfig; replacements = lib.shb.getReplacements userConfig;
@ -37,105 +39,113 @@ in
replaceInTemplateJSON = lib.shb.replaceSecrets { replaceInTemplateJSON = lib.shb.replaceSecrets {
inherit userConfig; inherit userConfig;
resultPath = "/var/lib/config.json"; resultPath = "/var/lib/config.json";
generator = lib.shb.replaceSecretsFormatAdapter (pkgs.formats.json {}); generator = lib.shb.replaceSecretsFormatAdapter (pkgs.formats.json { });
}; };
replaceInTemplateJSONGen = lib.shb.replaceSecrets { replaceInTemplateJSONGen = lib.shb.replaceSecrets {
inherit userConfig; inherit userConfig;
resultPath = "/var/lib/config_gen.json"; resultPath = "/var/lib/config_gen.json";
generator = lib.shb.replaceSecretsGeneratorAdapter (lib.generators.toJSON {}); generator = lib.shb.replaceSecretsGeneratorAdapter (lib.generators.toJSON { });
}; };
replaceInTemplateXML = lib.shb.replaceSecrets { replaceInTemplateXML = lib.shb.replaceSecrets {
inherit userConfig; inherit userConfig;
resultPath = "/var/lib/config.xml"; resultPath = "/var/lib/config.xml";
generator = lib.shb.replaceSecretsFormatAdapter (lib.shb.formatXML {enclosingRoot = "Root";}); generator = lib.shb.replaceSecretsFormatAdapter (lib.shb.formatXML { enclosingRoot = "Root"; });
}; };
in in
lib.shb.runNixOSTest { lib.shb.runNixOSTest {
name = "lib-template"; name = "lib-template";
nodes.machine = { config, pkgs, ... }: nodes.machine =
{ { config, pkgs, ... }:
imports = [ {
(pkgs'.path + "/nixos/modules/profiles/headless.nix") imports = [
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") (pkgs'.path + "/nixos/modules/profiles/headless.nix")
{ (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
options = { {
libtest.config = lib.mkOption { options = {
type = lib.types.attrsOf (lib.types.oneOf [ lib.types.str lib.secretFileType ]); libtest.config = lib.mkOption {
}; type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.secretFileType
]
);
}; };
} };
]; }
];
system.activationScripts = { system.activationScripts = {
libtest = replaceInTemplate; libtest = replaceInTemplate;
libtestJSON = replaceInTemplateJSON; libtestJSON = replaceInTemplateJSON;
libtestJSONGen = replaceInTemplateJSONGen; libtestJSONGen = replaceInTemplateJSONGen;
libtestXML = replaceInTemplateXML; libtestXML = replaceInTemplateXML;
};
}; };
};
testScript = { nodes, ... }: '' testScript =
import json { nodes, ... }:
from collections import ChainMap ''
from xml.etree import ElementTree import json
from collections import ChainMap
from xml.etree import ElementTree
start_all() start_all()
machine.wait_for_file("/var/lib/config.yaml") machine.wait_for_file("/var/lib/config.yaml")
machine.wait_for_file("/var/lib/config.json") machine.wait_for_file("/var/lib/config.json")
machine.wait_for_file("/var/lib/config_gen.json") machine.wait_for_file("/var/lib/config_gen.json")
machine.wait_for_file("/var/lib/config.xml") machine.wait_for_file("/var/lib/config.xml")
def xml_to_dict_recursive(root): def xml_to_dict_recursive(root):
all_descendants = list(root) all_descendants = list(root)
if len(all_descendants) == 0: if len(all_descendants) == 0:
return {root.tag: root.text} return {root.tag: root.text}
else: else:
merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants)) merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants))
return {root.tag: dict(merged_dict)} return {root.tag: dict(merged_dict)}
wantedConfig = json.loads('${lib.generators.toJSON {} wantedConfig}') wantedConfig = json.loads('${lib.generators.toJSON { } wantedConfig}')
with subtest("config"): with subtest("config"):
print(machine.succeed("cat ${pkgs.writeText "replaceInTemplate" replaceInTemplate}")) print(machine.succeed("cat ${pkgs.writeText "replaceInTemplate" replaceInTemplate}"))
gotConfig = machine.succeed("cat /var/lib/config.yaml") gotConfig = machine.succeed("cat /var/lib/config.yaml")
print(gotConfig) print(gotConfig)
gotConfig = json.loads(gotConfig) gotConfig = json.loads(gotConfig)
if wantedConfig != gotConfig: if wantedConfig != gotConfig:
raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig)) raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig))
with subtest("config JSON Gen"): with subtest("config JSON Gen"):
print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateJSONGen" replaceInTemplateJSONGen}")) print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateJSONGen" replaceInTemplateJSONGen}"))
gotConfig = machine.succeed("cat /var/lib/config_gen.json") gotConfig = machine.succeed("cat /var/lib/config_gen.json")
print(gotConfig) print(gotConfig)
gotConfig = json.loads(gotConfig) gotConfig = json.loads(gotConfig)
if wantedConfig != gotConfig: if wantedConfig != gotConfig:
raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig)) raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig))
with subtest("config JSON"): with subtest("config JSON"):
print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateJSON" replaceInTemplateJSON}")) print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateJSON" replaceInTemplateJSON}"))
gotConfig = machine.succeed("cat /var/lib/config.json") gotConfig = machine.succeed("cat /var/lib/config.json")
print(gotConfig) print(gotConfig)
gotConfig = json.loads(gotConfig) gotConfig = json.loads(gotConfig)
if wantedConfig != gotConfig: if wantedConfig != gotConfig:
raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig)) raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig))
with subtest("config XML"): with subtest("config XML"):
print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateXML" replaceInTemplateXML}")) print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateXML" replaceInTemplateXML}"))
gotConfig = machine.succeed("cat /var/lib/config.xml") gotConfig = machine.succeed("cat /var/lib/config.xml")
print(gotConfig) print(gotConfig)
gotConfig = xml_to_dict_recursive(ElementTree.XML(gotConfig))['Root'] gotConfig = xml_to_dict_recursive(ElementTree.XML(gotConfig))['Root']
if wantedConfig != gotConfig: if wantedConfig != gotConfig:
raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig)) raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig))
''; '';
}; };
} }

View file

@ -9,145 +9,148 @@ in
auth = lib.shb.runNixOSTest { auth = lib.shb.runNixOSTest {
name = "ldap-auth"; name = "ldap-auth";
nodes.server = { config, pkgs, ... }: { nodes.server =
imports = [ { config, pkgs, ... }:
(pkgs'.path + "/nixos/modules/profiles/headless.nix") {
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") imports = [
{ (pkgs'.path + "/nixos/modules/profiles/headless.nix")
options = { (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
shb.ssl.enable = lib.mkEnableOption "ssl"; {
options = {
shb.ssl.enable = lib.mkEnableOption "ssl";
};
}
../../modules/blocks/hardcodedsecret.nix
../../modules/blocks/lldap.nix
];
shb.lldap = {
enable = true;
dcdomain = "dc=example,dc=com";
subdomain = "ldap";
domain = "example.com";
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;
ensureUsers = {
"charlie" = {
email = "charlie@example.com";
password.result = config.shb.hardcodedsecret."charlie".result;
};
}; };
}
../../modules/blocks/hardcodedsecret.nix
../../modules/blocks/lldap.nix
];
shb.lldap = { ensureGroups = {
enable = true; "family" = { };
dcdomain = "dc=example,dc=com";
subdomain = "ldap";
domain = "example.com";
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;
ensureUsers = {
"charlie" = {
email = "charlie@example.com";
password.result = config.shb.hardcodedsecret."charlie".result;
}; };
}; };
shb.hardcodedsecret.ldapUserPassword = {
request = config.shb.lldap.ldapUserPassword.request;
settings.content = password;
};
shb.hardcodedsecret.jwtSecret = {
request = config.shb.lldap.jwtSecret.request;
settings.content = "jwtSecret";
};
shb.hardcodedsecret."charlie" = {
request = config.shb.lldap.ensureUsers."charlie".password.request;
settings.content = charliePassword;
};
ensureGroups = { networking.firewall.allowedTCPPorts = [ 80 ]; # nginx port
"family" = {};
environment.systemPackages = [ pkgs.openldap ];
specialisation = {
withDebug.configuration = {
shb.lldap.debug = true;
};
}; };
}; };
shb.hardcodedsecret.ldapUserPassword = {
request = config.shb.lldap.ldapUserPassword.request;
settings.content = password;
};
shb.hardcodedsecret.jwtSecret = {
request = config.shb.lldap.jwtSecret.request;
settings.content = "jwtSecret";
};
shb.hardcodedsecret."charlie" = {
request = config.shb.lldap.ensureUsers."charlie".password.request;
settings.content = charliePassword;
};
networking.firewall.allowedTCPPorts = [ 80 ]; # nginx port nodes.client = { };
environment.systemPackages = [ pkgs.openldap ];
specialisation = {
withDebug.configuration = {
shb.lldap.debug = true;
};
};
};
nodes.client = {};
# Inspired from https://github.com/lldap/lldap/blob/33f50d13a2e2d24a3e6bb05a148246bc98090df0/example_configs/lldap-ha-auth.sh # Inspired from https://github.com/lldap/lldap/blob/33f50d13a2e2d24a3e6bb05a148246bc98090df0/example_configs/lldap-ha-auth.sh
testScript = { nodes, ... }: testScript =
let { nodes, ... }:
specializations = "${nodes.server.system.build.toplevel}/specialisation"; let
in specializations = "${nodes.server.system.build.toplevel}/specialisation";
'' in
import json ''
import json
start_all() start_all()
def tests(): def tests():
server.wait_for_unit("lldap.service") server.wait_for_unit("lldap.service")
server.wait_for_open_port(${toString nodes.server.shb.lldap.webUIListenPort}) server.wait_for_open_port(${toString nodes.server.shb.lldap.webUIListenPort})
server.wait_for_open_port(${toString nodes.server.shb.lldap.ldapPort}) server.wait_for_open_port(${toString nodes.server.shb.lldap.ldapPort})
with subtest("fail without authenticating"): with subtest("fail without authenticating"):
client.fail( client.fail(
"curl -f -s -X GET" "curl -f -s -X GET"
+ """ -H "Content-type: application/json" """ + """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """ + """ -H "Host: ldap.example.com" """
+ " http://server/api/graphql" + " http://server/api/graphql"
) )
with subtest("fail authenticating with wrong credentials"): with subtest("fail authenticating with wrong credentials"):
resp = client.fail( resp = client.fail(
"curl -f -s -X POST" "curl -f -s -X POST"
+ """ -H "Content-type: application/json" """ + """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """ + """ -H "Host: ldap.example.com" """
+ " http://server/auth/simple/login" + " http://server/auth/simple/login"
+ """ -d '{"username": "admin", "password": "wrong"}'""" + """ -d '{"username": "admin", "password": "wrong"}'"""
) )
print(resp) print(resp)
with subtest("succeed with correct authentication"): with subtest("succeed with correct authentication"):
token = json.loads(client.succeed( token = json.loads(client.succeed(
"curl -f -s -X POST " "curl -f -s -X POST "
+ """ -H "Content-type: application/json" """ + """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """ + """ -H "Host: ldap.example.com" """
+ " http://server/auth/simple/login " + " http://server/auth/simple/login "
+ """ -d '{"username": "admin", "password": "${password}"}' """ + """ -d '{"username": "admin", "password": "${password}"}' """
))['token'] ))['token']
data = json.loads(client.succeed( data = json.loads(client.succeed(
"curl -f -s -X POST " "curl -f -s -X POST "
+ """ -H "Content-type: application/json" """ + """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """ + """ -H "Host: ldap.example.com" """
+ """ -H "Authorization: Bearer {token}" """.format(token=token) + """ -H "Authorization: Bearer {token}" """.format(token=token)
+ " http://server/api/graphql " + " http://server/api/graphql "
+ """ -d '{"variables": {"id": "admin"}, "query":"query($id:String!){user(userId:$id){displayName groups{displayName}}}"}' """ + """ -d '{"variables": {"id": "admin"}, "query":"query($id:String!){user(userId:$id){displayName groups{displayName}}}"}' """
))['data'] ))['data']
assert data['user']['displayName'] == "Administrator" assert data['user']['displayName'] == "Administrator"
assert data['user']['groups'][0]['displayName'] == "lldap_admin" assert data['user']['groups'][0]['displayName'] == "lldap_admin"
with subtest("succeed charlie"): with subtest("succeed charlie"):
resp = client.succeed( resp = client.succeed(
"curl -f -s -X POST " "curl -f -s -X POST "
+ """ -H "Content-type: application/json" """ + """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """ + """ -H "Host: ldap.example.com" """
+ " http://server/auth/simple/login " + " http://server/auth/simple/login "
+ """ -d '{"username": "charlie", "password": "${charliePassword}"}' """ + """ -d '{"username": "charlie", "password": "${charliePassword}"}' """
) )
print(resp) print(resp)
with subtest("ldap user search"): with subtest("ldap user search"):
resp = server.succeed('ldapsearch -H ldap://127.0.0.1:${toString nodes.server.shb.lldap.ldapPort} -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w ${password}') resp = server.succeed('ldapsearch -H ldap://127.0.0.1:${toString nodes.server.shb.lldap.ldapPort} -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w ${password}')
print(resp) print(resp)
if "uid=admin" not in resp: if "uid=admin" not in resp:
raise Exception("Expected to find admin") raise Exception("Expected to find admin")
if "uid=charlie" not in resp: if "uid=charlie" not in resp:
raise Exception("Expected to find charlie") raise Exception("Expected to find charlie")
with subtest("no debug"): with subtest("no debug"):
tests() tests()
with subtest("with debug"): with subtest("with debug"):
server.succeed('${specializations}/withDebug/bin/switch-to-configuration test') server.succeed('${specializations}/withDebug/bin/switch-to-configuration test')
tests() tests()
''; '';
}; };
} }

View file

@ -1,137 +1,148 @@
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let let
serve = port: text: lib.getExe (pkgs.writers.writePython3Bin "serve" serve =
{ port: text:
libraries = [ pkgs.python3Packages.systemd ]; lib.getExe (
} pkgs.writers.writePython3Bin "serve"
(let {
content = pkgs.writeText "content" text; libraries = [ pkgs.python3Packages.systemd ];
in '' }
from http.server import BaseHTTPRequestHandler, HTTPServer (
from systemd.daemon import notify let
content = pkgs.writeText "content" text;
in
''
from http.server import BaseHTTPRequestHandler, HTTPServer
from systemd.daemon import notify
with open("${content}", "rb") as f: with open("${content}", "rb") as f:
content = f.read() content = f.read()
class HardcodedHandler(BaseHTTPRequestHandler): class HardcodedHandler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
reponse = content + self.path.encode('utf-8') reponse = content + self.path.encode('utf-8')
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/plain") self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(reponse))) self.send_header("Content-Length", str(len(reponse)))
self.end_headers() self.end_headers()
print("answering to GET request") print("answering to GET request")
self.wfile.write(reponse) self.wfile.write(reponse)
def log_message(self, format, *args): def log_message(self, format, *args):
pass # optional: suppress logging pass # optional: suppress logging
if __name__ == "__main__": if __name__ == "__main__":
notify('STATUS=Starting up...') notify('STATUS=Starting up...')
server_address = ('127.0.0.1', ${toString port}) server_address = ('127.0.0.1', ${toString port})
httpd = HTTPServer(server_address, HardcodedHandler) httpd = HTTPServer(server_address, HardcodedHandler)
print("Serving hardcoded page on http://127.0.0.1:${toString port}") print("Serving hardcoded page on http://127.0.0.1:${toString port}")
notify('READY=1') notify('READY=1')
httpd.serve_forever() httpd.serve_forever()
'') ''
); )
);
in in
{ {
default = lib.shb.runNixOSTest { default = lib.shb.runNixOSTest {
name = "mitmdump-default"; name = "mitmdump-default";
nodes.machine = { config, pkgs, ... }: { nodes.machine =
imports = [ { config, pkgs, ... }:
../../modules/blocks/mitmdump.nix {
]; imports = [
../../modules/blocks/mitmdump.nix
systemd.services.test1 = {
serviceConfig.ExecStart = serve 8000 "test1";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "notify";
StandardOutput = "journal";
StandardError = "journal";
};
};
systemd.services.test2 = {
serviceConfig.ExecStart = serve 8002 "test2";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "notify";
StandardOutput = "journal";
StandardError = "journal";
};
};
shb.mitmdump.instances."test1" = {
listenPort = 8001;
upstreamPort = 8000;
after = [ "test1.service" ];
};
shb.mitmdump.instances."test2" = {
listenPort = 8003;
upstreamPort = 8002;
after = [ "test2.service" ];
enabledAddons = [ config.shb.mitmdump.addons.logger ];
extraArgs = [
"--set" "verbose_pattern=/verbose"
]; ];
systemd.services.test1 = {
serviceConfig.ExecStart = serve 8000 "test1";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "notify";
StandardOutput = "journal";
StandardError = "journal";
};
};
systemd.services.test2 = {
serviceConfig.ExecStart = serve 8002 "test2";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "notify";
StandardOutput = "journal";
StandardError = "journal";
};
};
shb.mitmdump.instances."test1" = {
listenPort = 8001;
upstreamPort = 8000;
after = [ "test1.service" ];
};
shb.mitmdump.instances."test2" = {
listenPort = 8003;
upstreamPort = 8002;
after = [ "test2.service" ];
enabledAddons = [ config.shb.mitmdump.addons.logger ];
extraArgs = [
"--set"
"verbose_pattern=/verbose"
];
};
}; };
};
testScript = { nodes, ... }: '' testScript =
start_all() { nodes, ... }:
''
start_all()
machine.wait_for_unit("test1.service") machine.wait_for_unit("test1.service")
machine.wait_for_unit("test2.service") machine.wait_for_unit("test2.service")
machine.wait_for_unit("mitmdump-test1.service") machine.wait_for_unit("mitmdump-test1.service")
machine.wait_for_unit("mitmdump-test2.service") machine.wait_for_unit("mitmdump-test2.service")
resp = machine.succeed("curl http://127.0.0.1:8000") resp = machine.succeed("curl http://127.0.0.1:8000")
print(resp) print(resp)
if resp != "test1/": if resp != "test1/":
raise Exception("wanted 'test1'") raise Exception("wanted 'test1'")
resp = machine.succeed("curl -v http://127.0.0.1:8001") resp = machine.succeed("curl -v http://127.0.0.1:8001")
print(resp) print(resp)
if resp != "test1/": if resp != "test1/":
raise Exception("wanted 'test1'") raise Exception("wanted 'test1'")
resp = machine.succeed("curl http://127.0.0.1:8002") resp = machine.succeed("curl http://127.0.0.1:8002")
print(resp) print(resp)
if resp != "test2/": if resp != "test2/":
raise Exception("wanted 'test2'") raise Exception("wanted 'test2'")
resp = machine.succeed("curl http://127.0.0.1:8003/notverbose") resp = machine.succeed("curl http://127.0.0.1:8003/notverbose")
print(resp) print(resp)
if resp != "test2/notverbose": if resp != "test2/notverbose":
raise Exception("wanted 'test2/notverbose'") raise Exception("wanted 'test2/notverbose'")
resp = machine.succeed("curl http://127.0.0.1:8003/verbose") resp = machine.succeed("curl http://127.0.0.1:8003/verbose")
print(resp) print(resp)
if resp != "test2/verbose": if resp != "test2/verbose":
raise Exception("wanted 'test2/verbose'") raise Exception("wanted 'test2/verbose'")
dump = machine.succeed("journalctl -b -u mitmdump-test1.service") dump = machine.succeed("journalctl -b -u mitmdump-test1.service")
print(dump) print(dump)
if "HTTP/1.0 200 OK" not in dump: if "HTTP/1.0 200 OK" not in dump:
raise Exception("expected to see HTTP/1.0 200 OK") raise Exception("expected to see HTTP/1.0 200 OK")
if "test1" not in dump: if "test1" not in dump:
raise Exception("expected to see test1") raise Exception("expected to see test1")
dump = machine.succeed("journalctl -b -u mitmdump-test2.service") dump = machine.succeed("journalctl -b -u mitmdump-test2.service")
print(dump) print(dump)
if "HTTP/1.0 200 OK" not in dump: if "HTTP/1.0 200 OK" not in dump:
raise Exception("expected to see HTTP/1.0 200 OK") raise Exception("expected to see HTTP/1.0 200 OK")
if "test2/notverbose" in dump: if "test2/notverbose" in dump:
raise Exception("expected not to see test2/notverbose") raise Exception("expected not to see test2/notverbose")
if "test2/verbose" not in dump: if "test2/verbose" not in dump:
raise Exception("expected to see test2/verbose") raise Exception("expected to see test2/verbose")
''; '';
}; };
} }

View file

@ -6,177 +6,193 @@ in
peerWithoutUser = lib.shb.runNixOSTest { peerWithoutUser = lib.shb.runNixOSTest {
name = "postgresql-peerWithoutUser"; name = "postgresql-peerWithoutUser";
nodes.machine = { config, pkgs, ... }: { nodes.machine =
imports = [ { config, pkgs, ... }:
(pkgs'.path + "/nixos/modules/profiles/headless.nix") {
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") imports = [
../../modules/blocks/postgresql.nix (pkgs'.path + "/nixos/modules/profiles/headless.nix")
]; (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
../../modules/blocks/postgresql.nix
];
shb.postgresql.ensures = [ shb.postgresql.ensures = [
{ {
username = "me"; username = "me";
database = "me"; database = "me";
} }
]; ];
}; };
testScript = { nodes, ... }: '' testScript =
start_all() { nodes, ... }:
machine.wait_for_unit("postgresql.service") ''
machine.wait_for_open_port(5432) start_all()
machine.wait_for_unit("postgresql.service")
machine.wait_for_open_port(5432)
def peer_cmd(user, database): def peer_cmd(user, database):
return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database) return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database)
with subtest("cannot login because of missing user"): with subtest("cannot login because of missing user"):
machine.fail(peer_cmd("me", "me"), timeout=10) machine.fail(peer_cmd("me", "me"), timeout=10)
with subtest("cannot login with unknown user"): with subtest("cannot login with unknown user"):
machine.fail(peer_cmd("notme", "me"), timeout=10) machine.fail(peer_cmd("notme", "me"), timeout=10)
with subtest("cannot login to unknown database"): with subtest("cannot login to unknown database"):
machine.fail(peer_cmd("me", "notmine"), timeout=10) machine.fail(peer_cmd("me", "notmine"), timeout=10)
''; '';
}; };
peerAuth = lib.shb.runNixOSTest { peerAuth = lib.shb.runNixOSTest {
name = "postgresql-peerAuth"; name = "postgresql-peerAuth";
nodes.machine = { config, pkgs, ... }: { nodes.machine =
imports = [ { config, pkgs, ... }:
(pkgs'.path + "/nixos/modules/profiles/headless.nix") {
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") imports = [
../../modules/blocks/postgresql.nix (pkgs'.path + "/nixos/modules/profiles/headless.nix")
]; (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
../../modules/blocks/postgresql.nix
];
users.users.me = { users.users.me = {
isSystemUser = true; isSystemUser = true;
group = "me"; group = "me";
extraGroups = [ "sudoers" ]; extraGroups = [ "sudoers" ];
};
users.groups.me = { };
shb.postgresql.ensures = [
{
username = "me";
database = "me";
}
];
}; };
users.groups.me = {};
shb.postgresql.ensures = [ testScript =
{ { nodes, ... }:
username = "me"; ''
database = "me"; start_all()
} machine.wait_for_unit("postgresql.service")
]; machine.wait_for_open_port(5432)
};
testScript = { nodes, ... }: '' def peer_cmd(user, database):
start_all() return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database)
machine.wait_for_unit("postgresql.service")
machine.wait_for_open_port(5432)
def peer_cmd(user, database): def tcpip_cmd(user, database, port):
return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database) return "psql -h 127.0.0.1 -p {port} -U {user} {db} --command \"\"".format(user=user, db=database, port=port)
def tcpip_cmd(user, database, port): with subtest("can login with provisioned user and database"):
return "psql -h 127.0.0.1 -p {port} -U {user} {db} --command \"\"".format(user=user, db=database, port=port) machine.succeed(peer_cmd("me", "me"), timeout=10)
with subtest("can login with provisioned user and database"): with subtest("cannot login with unknown user"):
machine.succeed(peer_cmd("me", "me"), timeout=10) machine.fail(peer_cmd("notme", "me"), timeout=10)
with subtest("cannot login with unknown user"): with subtest("cannot login to unknown database"):
machine.fail(peer_cmd("notme", "me"), timeout=10) machine.fail(peer_cmd("me", "notmine"), timeout=10)
with subtest("cannot login to unknown database"): with subtest("cannot login with tcpip"):
machine.fail(peer_cmd("me", "notmine"), timeout=10) machine.fail(tcpip_cmd("me", "me", "5432"), timeout=10)
'';
with subtest("cannot login with tcpip"):
machine.fail(tcpip_cmd("me", "me", "5432"), timeout=10)
'';
}; };
tcpIPWithoutPasswordAuth = lib.shb.runNixOSTest { tcpIPWithoutPasswordAuth = lib.shb.runNixOSTest {
name = "postgresql-tcpIpWithoutPasswordAuth"; name = "postgresql-tcpIpWithoutPasswordAuth";
nodes.machine = { config, pkgs, ... }: { nodes.machine =
imports = [ { config, pkgs, ... }:
(pkgs'.path + "/nixos/modules/profiles/headless.nix") {
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") imports = [
../../modules/blocks/postgresql.nix (pkgs'.path + "/nixos/modules/profiles/headless.nix")
]; (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
../../modules/blocks/postgresql.nix
];
shb.postgresql.enableTCPIP = true; shb.postgresql.enableTCPIP = true;
shb.postgresql.ensures = [ shb.postgresql.ensures = [
{ {
username = "me"; username = "me";
database = "me"; database = "me";
} }
]; ];
}; };
testScript = { nodes, ... }: '' testScript =
start_all() { nodes, ... }:
machine.wait_for_unit("postgresql.service") ''
machine.wait_for_open_port(5432) start_all()
machine.wait_for_unit("postgresql.service")
machine.wait_for_open_port(5432)
def peer_cmd(user, database): def peer_cmd(user, database):
return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database) return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database)
def tcpip_cmd(user, database, port): def tcpip_cmd(user, database, port):
return "psql -h 127.0.0.1 -p {port} -U {user} {db} --command \"\"".format(user=user, db=database, port=port) return "psql -h 127.0.0.1 -p {port} -U {user} {db} --command \"\"".format(user=user, db=database, port=port)
with subtest("cannot login without existing user"): with subtest("cannot login without existing user"):
machine.fail(peer_cmd("me", "me"), timeout=10) machine.fail(peer_cmd("me", "me"), timeout=10)
with subtest("cannot login with user without password"): with subtest("cannot login with user without password"):
machine.fail(tcpip_cmd("me", "me", "5432"), timeout=10) machine.fail(tcpip_cmd("me", "me", "5432"), timeout=10)
''; '';
}; };
tcpIPPasswordAuth = lib.shb.runNixOSTest { tcpIPPasswordAuth = lib.shb.runNixOSTest {
name = "postgresql-tcpIPPasswordAuth"; name = "postgresql-tcpIPPasswordAuth";
nodes.machine = { config, pkgs, ... }: { nodes.machine =
imports = [ { config, pkgs, ... }:
(pkgs'.path + "/nixos/modules/profiles/headless.nix") {
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") imports = [
../../modules/blocks/postgresql.nix (pkgs'.path + "/nixos/modules/profiles/headless.nix")
]; (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
../../modules/blocks/postgresql.nix
];
users.users.me = { users.users.me = {
isSystemUser = true; isSystemUser = true;
group = "me"; group = "me";
extraGroups = [ "sudoers" ]; extraGroups = [ "sudoers" ];
};
users.groups.me = { };
system.activationScripts.secret = ''
echo secretpw > /run/dbsecret
'';
shb.postgresql.enableTCPIP = true;
shb.postgresql.ensures = [
{
username = "me";
database = "me";
passwordFile = "/run/dbsecret";
}
];
}; };
users.groups.me = {};
system.activationScripts.secret = '' testScript =
echo secretpw > /run/dbsecret { nodes, ... }:
''
start_all()
machine.wait_for_unit("postgresql.service")
machine.wait_for_open_port(5432)
def peer_cmd(user, database):
return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database)
def tcpip_cmd(user, database, port, password):
return "PGPASSWORD={password} psql -h 127.0.0.1 -p {port} -U {user} {db} --command \"\"".format(user=user, db=database, port=port, password=password)
with subtest("can peer login with provisioned user and database"):
machine.succeed(peer_cmd("me", "me"), timeout=10)
with subtest("can tcpip login with provisioned user and database"):
machine.succeed(tcpip_cmd("me", "me", "5432", "secretpw"), timeout=10)
with subtest("cannot tcpip login with wrong password"):
machine.fail(tcpip_cmd("me", "me", "5432", "oops"), timeout=10)
''; '';
shb.postgresql.enableTCPIP = true;
shb.postgresql.ensures = [
{
username = "me";
database = "me";
passwordFile = "/run/dbsecret";
}
];
};
testScript = { nodes, ... }: ''
start_all()
machine.wait_for_unit("postgresql.service")
machine.wait_for_open_port(5432)
def peer_cmd(user, database):
return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database)
def tcpip_cmd(user, database, port, password):
return "PGPASSWORD={password} psql -h 127.0.0.1 -p {port} -U {user} {db} --command \"\"".format(user=user, db=database, port=port, password=password)
with subtest("can peer login with provisioned user and database"):
machine.succeed(peer_cmd("me", "me"), timeout=10)
with subtest("can tcpip login with provisioned user and database"):
machine.succeed(tcpip_cmd("me", "me", "5432", "secretpw"), timeout=10)
with subtest("cannot tcpip login with wrong password"):
machine.fail(tcpip_cmd("me", "me", "5432", "oops"), timeout=10)
'';
}; };
} }

View file

@ -1,179 +1,188 @@
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let let
testLib = pkgs.callPackage ../common.nix {}; testLib = pkgs.callPackage ../common.nix { };
commonTest = user: lib.shb.runNixOSTest { commonTest =
name = "restic_backupAndRestore_${user}"; user:
lib.shb.runNixOSTest {
name = "restic_backupAndRestore_${user}";
nodes.machine = { config, ... }: { nodes.machine =
imports = [ { config, ... }:
testLib.baseImports {
imports = [
testLib.baseImports
../../modules/blocks/hardcodedsecret.nix ../../modules/blocks/hardcodedsecret.nix
../../modules/blocks/restic.nix ../../modules/blocks/restic.nix
]; ];
shb.hardcodedsecret.A = { shb.hardcodedsecret.A = {
request = { request = {
owner = "root"; owner = "root";
group = "keys"; group = "keys";
mode = "0440"; mode = "0440";
};
settings.content = "secretA";
};
shb.hardcodedsecret.B = {
request = {
owner = "root";
group = "keys";
mode = "0440";
};
settings.content = "secretB";
};
shb.hardcodedsecret.passphrase = {
request = config.shb.restic.instances."testinstance".settings.passphrase.request;
settings.content = "secretB";
};
shb.restic.instances."testinstance" = {
settings = {
enable = true;
passphrase.result = config.shb.hardcodedsecret.passphrase.result;
repository = {
path = "/opt/repos/A";
timerConfig = {
OnCalendar = "00:00:00";
RandomizedDelaySec = "5h";
}; };
# Those are not needed by the repository but are still included settings.content = "secretA";
# so we can test them in the hooks section. };
secrets = { shb.hardcodedsecret.B = {
A.source = config.shb.hardcodedsecret.A.result.path; request = {
B.source = config.shb.hardcodedsecret.B.result.path; owner = "root";
group = "keys";
mode = "0440";
};
settings.content = "secretB";
};
shb.hardcodedsecret.passphrase = {
request = config.shb.restic.instances."testinstance".settings.passphrase.request;
settings.content = "secretB";
};
shb.restic.instances."testinstance" = {
settings = {
enable = true;
passphrase.result = config.shb.hardcodedsecret.passphrase.result;
repository = {
path = "/opt/repos/A";
timerConfig = {
OnCalendar = "00:00:00";
RandomizedDelaySec = "5h";
};
# Those are not needed by the repository but are still included
# so we can test them in the hooks section.
secrets = {
A.source = config.shb.hardcodedsecret.A.result.path;
B.source = config.shb.hardcodedsecret.B.result.path;
};
};
};
request = {
inherit user;
sourceDirectories = [
"/opt/files/A"
"/opt/files/B"
];
hooks.beforeBackup = [
''
echo $RUNTIME_DIRECTORY
if [ "$RUNTIME_DIRECTORY" = /run/restic-backups-testinstance_opt_repos_A ]; then
if ! [ -f /run/secrets_restic/restic-backups-testinstance_opt_repos_A ]; then
exit 10
fi
if [ -z "$A" ] || ! [ "$A" = "secretA" ]; then
echo "A:$A"
exit 11
fi
if [ -z "$B" ] || ! [ "$B" = "secretB" ]; then
echo "B:$B"
exit 12
fi
fi
''
];
}; };
}; };
}; };
request = { extraPythonPackages = p: [ p.dictdiffer ];
inherit user; skipTypeCheck = true;
sourceDirectories = [ testScript =
"/opt/files/A" { nodes, ... }:
"/opt/files/B" let
]; provider = nodes.machine.shb.restic.instances."testinstance";
backupService = provider.result.backupService;
restoreScript = provider.result.restoreScript;
in
''
from dictdiffer import diff
def list_files(dir):
files_and_content = {}
files = machine.succeed(f"""
find {dir} -type f
""").split("\n")[:-1]
for f in files:
content = machine.succeed(f"""
cat {f}
""").strip()
files_and_content[f] = content
return files_and_content
def assert_files(dir, files):
result = list(diff(list_files(dir), files))
if len(result) > 0:
raise Exception("Unexpected files:", result)
with subtest("Create initial content"):
machine.succeed("""
mkdir -p /opt/files/A
mkdir -p /opt/files/B
echo repoA_fileA_1 > /opt/files/A/fileA
echo repoA_fileB_1 > /opt/files/A/fileB
echo repoB_fileA_1 > /opt/files/B/fileA
echo repoB_fileB_1 > /opt/files/B/fileB
chown ${user}: -R /opt/files
chmod go-rwx -R /opt/files
""")
assert_files("/opt/files", {
'/opt/files/B/fileA': 'repoB_fileA_1',
'/opt/files/B/fileB': 'repoB_fileB_1',
'/opt/files/A/fileA': 'repoA_fileA_1',
'/opt/files/A/fileB': 'repoA_fileB_1',
})
with subtest("First backup in repo A"):
machine.succeed("systemctl start ${backupService}")
with subtest("New content"):
machine.succeed("""
echo repoA_fileA_2 > /opt/files/A/fileA
echo repoA_fileB_2 > /opt/files/A/fileB
echo repoB_fileA_2 > /opt/files/B/fileA
echo repoB_fileB_2 > /opt/files/B/fileB
""")
assert_files("/opt/files", {
'/opt/files/B/fileA': 'repoB_fileA_2',
'/opt/files/B/fileB': 'repoB_fileB_2',
'/opt/files/A/fileA': 'repoA_fileA_2',
'/opt/files/A/fileB': 'repoA_fileB_2',
})
with subtest("Delete content"):
machine.succeed("""
rm -r /opt/files/A /opt/files/B
""")
assert_files("/opt/files", {})
with subtest("Restore initial content from repo A"):
machine.succeed("""
${restoreScript} restore latest
""")
assert_files("/opt/files", {
'/opt/files/B/fileA': 'repoB_fileA_1',
'/opt/files/B/fileB': 'repoB_fileB_1',
'/opt/files/A/fileA': 'repoA_fileA_1',
'/opt/files/A/fileB': 'repoA_fileB_1',
})
'';
hooks.beforeBackup = [''
echo $RUNTIME_DIRECTORY
if [ "$RUNTIME_DIRECTORY" = /run/restic-backups-testinstance_opt_repos_A ]; then
if ! [ -f /run/secrets_restic/restic-backups-testinstance_opt_repos_A ]; then
exit 10
fi
if [ -z "$A" ] || ! [ "$A" = "secretA" ]; then
echo "A:$A"
exit 11
fi
if [ -z "$B" ] || ! [ "$B" = "secretB" ]; then
echo "B:$B"
exit 12
fi
fi
''];
};
};
}; };
extraPythonPackages = p: [ p.dictdiffer ];
skipTypeCheck = true;
testScript = { nodes, ... }: let
provider = nodes.machine.shb.restic.instances."testinstance";
backupService = provider.result.backupService;
restoreScript = provider.result.restoreScript;
in ''
from dictdiffer import diff
def list_files(dir):
files_and_content = {}
files = machine.succeed(f"""
find {dir} -type f
""").split("\n")[:-1]
for f in files:
content = machine.succeed(f"""
cat {f}
""").strip()
files_and_content[f] = content
return files_and_content
def assert_files(dir, files):
result = list(diff(list_files(dir), files))
if len(result) > 0:
raise Exception("Unexpected files:", result)
with subtest("Create initial content"):
machine.succeed("""
mkdir -p /opt/files/A
mkdir -p /opt/files/B
echo repoA_fileA_1 > /opt/files/A/fileA
echo repoA_fileB_1 > /opt/files/A/fileB
echo repoB_fileA_1 > /opt/files/B/fileA
echo repoB_fileB_1 > /opt/files/B/fileB
chown ${user}: -R /opt/files
chmod go-rwx -R /opt/files
""")
assert_files("/opt/files", {
'/opt/files/B/fileA': 'repoB_fileA_1',
'/opt/files/B/fileB': 'repoB_fileB_1',
'/opt/files/A/fileA': 'repoA_fileA_1',
'/opt/files/A/fileB': 'repoA_fileB_1',
})
with subtest("First backup in repo A"):
machine.succeed("systemctl start ${backupService}")
with subtest("New content"):
machine.succeed("""
echo repoA_fileA_2 > /opt/files/A/fileA
echo repoA_fileB_2 > /opt/files/A/fileB
echo repoB_fileA_2 > /opt/files/B/fileA
echo repoB_fileB_2 > /opt/files/B/fileB
""")
assert_files("/opt/files", {
'/opt/files/B/fileA': 'repoB_fileA_2',
'/opt/files/B/fileB': 'repoB_fileB_2',
'/opt/files/A/fileA': 'repoA_fileA_2',
'/opt/files/A/fileB': 'repoA_fileB_2',
})
with subtest("Delete content"):
machine.succeed("""
rm -r /opt/files/A /opt/files/B
""")
assert_files("/opt/files", {})
with subtest("Restore initial content from repo A"):
machine.succeed("""
${restoreScript} restore latest
""")
assert_files("/opt/files", {
'/opt/files/B/fileA': 'repoB_fileA_1',
'/opt/files/B/fileB': 'repoB_fileB_1',
'/opt/files/A/fileA': 'repoA_fileA_1',
'/opt/files/A/fileB': 'repoA_fileB_1',
})
'';
};
in in
{ {
backupAndRestoreRoot = commonTest "root"; backupAndRestoreRoot = commonTest "root";

View file

@ -6,95 +6,100 @@ in
test = lib.shb.runNixOSTest { test = lib.shb.runNixOSTest {
name = "ssl-test"; name = "ssl-test";
nodes.server = { config, pkgs, ... }: { nodes.server =
imports = [ { config, pkgs, ... }:
(pkgs'.path + "/nixos/modules/profiles/headless.nix") {
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") imports = [
../../modules/blocks/ssl.nix (pkgs'.path + "/nixos/modules/profiles/headless.nix")
]; (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
../../modules/blocks/ssl.nix
];
users.users = { users.users = {
user1 = { user1 = {
group = "group1"; group = "group1";
isSystemUser = true; isSystemUser = true;
};
user2 = {
group = "group2";
isSystemUser = true;
};
};
users.groups = {
group1 = {};
group2 = {};
};
shb.certs = {
cas.selfsigned = {
myca = {
name = "My CA";
}; };
myotherca = { user2 = {
name = "My Other CA";
};
};
certs.selfsigned = {
top = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "example.com";
group = "nginx";
};
subdomain = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "subdomain.example.com";
group = "nginx";
};
multi = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "multi1.example.com";
extraDomains = [ "multi2.example.com" "multi3.example.com" ];
group = "nginx";
};
cert1 = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "cert1.example.com";
};
cert2 = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "cert2.example.com";
group = "group2"; group = "group2";
isSystemUser = true;
}; };
}; };
}; users.groups = {
group1 = { };
group2 = { };
};
# The configuration below is to create a webserver that uses the server certificate. shb.certs = {
networking.hosts."127.0.0.1" = [ cas.selfsigned = {
"example.com" myca = {
"subdomain.example.com" name = "My CA";
"wrong.example.com" };
"multi1.example.com" myotherca = {
"multi2.example.com" name = "My Other CA";
"multi3.example.com" };
];
services.nginx.enable = true;
services.nginx.virtualHosts =
let
mkVirtualHost = response: cert: {
onlySSL = true;
sslCertificate = cert.paths.cert;
sslCertificateKey = cert.paths.key;
locations."/".extraConfig = ''
add_header Content-Type text/plain;
return 200 '${response}';
'';
}; };
in certs.selfsigned = {
top = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "example.com";
group = "nginx";
};
subdomain = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "subdomain.example.com";
group = "nginx";
};
multi = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "multi1.example.com";
extraDomains = [
"multi2.example.com"
"multi3.example.com"
];
group = "nginx";
};
cert1 = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "cert1.example.com";
};
cert2 = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "cert2.example.com";
group = "group2";
};
};
};
# The configuration below is to create a webserver that uses the server certificate.
networking.hosts."127.0.0.1" = [
"example.com"
"subdomain.example.com"
"wrong.example.com"
"multi1.example.com"
"multi2.example.com"
"multi3.example.com"
];
services.nginx.enable = true;
services.nginx.virtualHosts =
let
mkVirtualHost = response: cert: {
onlySSL = true;
sslCertificate = cert.paths.cert;
sslCertificateKey = cert.paths.key;
locations."/".extraConfig = ''
add_header Content-Type text/plain;
return 200 '${response}';
'';
};
in
{ {
"example.com" = mkVirtualHost "Top domain" config.shb.certs.certs.selfsigned.top; "example.com" = mkVirtualHost "Top domain" config.shb.certs.certs.selfsigned.top;
"subdomain.example.com" = mkVirtualHost "Subdomain" config.shb.certs.certs.selfsigned.subdomain; "subdomain.example.com" = mkVirtualHost "Subdomain" config.shb.certs.certs.selfsigned.subdomain;
@ -102,26 +107,27 @@ in
"multi2.example.com" = mkVirtualHost "multi2" config.shb.certs.certs.selfsigned.multi; "multi2.example.com" = mkVirtualHost "multi2" config.shb.certs.certs.selfsigned.multi;
"multi3.example.com" = mkVirtualHost "multi3" config.shb.certs.certs.selfsigned.multi; "multi3.example.com" = mkVirtualHost "multi3" config.shb.certs.certs.selfsigned.multi;
}; };
systemd.services.nginx = { systemd.services.nginx = {
after = [ after = [
config.shb.certs.certs.selfsigned.top.systemdService config.shb.certs.certs.selfsigned.top.systemdService
config.shb.certs.certs.selfsigned.subdomain.systemdService config.shb.certs.certs.selfsigned.subdomain.systemdService
config.shb.certs.certs.selfsigned.multi.systemdService config.shb.certs.certs.selfsigned.multi.systemdService
config.shb.certs.certs.selfsigned.cert1.systemdService config.shb.certs.certs.selfsigned.cert1.systemdService
config.shb.certs.certs.selfsigned.cert2.systemdService config.shb.certs.certs.selfsigned.cert2.systemdService
]; ];
requires = [ requires = [
config.shb.certs.certs.selfsigned.top.systemdService config.shb.certs.certs.selfsigned.top.systemdService
config.shb.certs.certs.selfsigned.subdomain.systemdService config.shb.certs.certs.selfsigned.subdomain.systemdService
config.shb.certs.certs.selfsigned.multi.systemdService config.shb.certs.certs.selfsigned.multi.systemdService
config.shb.certs.certs.selfsigned.cert1.systemdService config.shb.certs.certs.selfsigned.cert1.systemdService
config.shb.certs.certs.selfsigned.cert2.systemdService config.shb.certs.certs.selfsigned.cert2.systemdService
]; ];
};
}; };
};
# Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix
testScript = { nodes, ... }: testScript =
{ nodes, ... }:
let let
myca = nodes.server.shb.certs.cas.selfsigned.myca; myca = nodes.server.shb.certs.cas.selfsigned.myca;
myotherca = nodes.server.shb.certs.cas.selfsigned.myotherca; myotherca = nodes.server.shb.certs.cas.selfsigned.myotherca;
@ -131,7 +137,7 @@ in
cert1 = nodes.server.shb.certs.certs.selfsigned.cert1; cert1 = nodes.server.shb.certs.certs.selfsigned.cert1;
cert2 = nodes.server.shb.certs.certs.selfsigned.cert2; cert2 = nodes.server.shb.certs.certs.selfsigned.cert2;
in in
'' ''
start_all() start_all()
# Make sure certs are generated. # Make sure certs are generated.
@ -203,6 +209,6 @@ in
server.fail("curl --cacert /etc/static/ssl/certs/ca-bundle.crt --fail-with-body -v https://subdomain.example.com") server.fail("curl --cacert /etc/static/ssl/certs/ca-bundle.crt --fail-with-body -v https://subdomain.example.com")
server.fail("curl --cacert /etc/static/ssl/certs/ca-certificates.crt --fail-with-body -v https://example.com") server.fail("curl --cacert /etc/static/ssl/certs/ca-certificates.crt --fail-with-body -v https://example.com")
server.fail("curl --cacert /etc/static/ssl/certs/ca-certificates.crt --fail-with-body -v https://subdomain.example.com") server.fail("curl --cacert /etc/static/ssl/certs/ca-certificates.crt --fail-with-body -v https://subdomain.example.com")
''; '';
}; };
} }

View file

@ -1,7 +1,14 @@
{ pkgs, lib }: { pkgs, lib }:
let let
inherit (lib) hasAttr mkOption optionalString; inherit (lib) hasAttr mkOption optionalString;
inherit (lib.types) bool enum listOf nullOr submodule str; inherit (lib.types)
bool
enum
listOf
nullOr
submodule
str
;
baseImports = { baseImports = {
imports = [ imports = [
@ -10,15 +17,17 @@ let
]; ];
}; };
accessScript = lib.makeOverridable ({ accessScript = lib.makeOverridable (
hasSSL {
, waitForServices ? s: [] hasSSL,
, waitForPorts ? p: [] waitForServices ? s: [ ],
, waitForUnixSocket ? u: [] waitForPorts ? p: [ ],
, waitForUrls ? u: [] waitForUnixSocket ? u: [ ],
, extraScript ? {...}: "" waitForUrls ? u: [ ],
, redirectSSO ? false extraScript ? { ... }: "",
}: { nodes, ... }: redirectSSO ? false,
}:
{ nodes, ... }:
let let
cfg = nodes.server.test; cfg = nodes.server.test;
@ -35,34 +44,34 @@ let
lldapEnabled = (hasAttr "lldap" nodes.server.shb) && nodes.server.shb.lldap.enable; lldapEnabled = (hasAttr "lldap" nodes.server.shb) && nodes.server.shb.lldap.enable;
in in
'' ''
import json import json
import os import os
import pathlib import pathlib
start_all() start_all()
def curl(target, format, endpoint, data="", extra=""): def curl(target, format, endpoint, data="", extra=""):
cmd = ("curl --show-error --location" cmd = ("curl --show-error --location"
+ " --cookie-jar cookie.txt" + " --cookie-jar cookie.txt"
+ " --cookie cookie.txt" + " --cookie cookie.txt"
+ " --connect-to ${fqdn}:443:server:443" + " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80" + " --connect-to ${fqdn}:80:server:80"
# Client must be able to resolve talking to auth server # Client must be able to resolve talking to auth server
+ " --connect-to auth.${cfg.domain}:443:server:443" + " --connect-to auth.${cfg.domain}:443:server:443"
+ (f" --data '{data}'" if data != "" else "") + (f" --data '{data}'" if data != "" else "")
+ (f" --silent --output /dev/null --write-out '{format}'" if format != "" else "") + (f" --silent --output /dev/null --write-out '{format}'" if format != "" else "")
+ (f" {extra}" if extra != "" else "") + (f" {extra}" if extra != "" else "")
+ f" {endpoint}") + f" {endpoint}")
print(cmd) print(cmd)
_, r = target.execute(cmd) _, r = target.execute(cmd)
print(r) print(r)
try: try:
return json.loads(r) return json.loads(r)
except: except:
return r return r
def unline_with(j, s): def unline_with(j, s):
return j.join((x.strip() for x in s.split("\n"))) return j.join((x.strip() for x in s.split("\n")))
'' ''
+ lib.strings.concatMapStrings (s: ''server.wait_for_unit("${s}")'' + "\n") ( + lib.strings.concatMapStrings (s: ''server.wait_for_unit("${s}")'' + "\n") (
waitForServices args waitForServices args
@ -72,19 +81,25 @@ let
+ lib.strings.concatMapStrings (p: ''server.wait_for_open_port(${toString p})'' + "\n") ( + lib.strings.concatMapStrings (p: ''server.wait_for_open_port(${toString p})'' + "\n") (
waitForPorts args waitForPorts args
# TODO: when the SSO block exists, replace this hardcoded port. # 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.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
) )
+ lib.strings.concatMapStrings (u: ''server.wait_for_open_unix_socket("${u}")'' + "\n") (waitForUnixSocket args)
+ '' + ''
if ${if hasSSL args then "True" else "False"}: if ${if hasSSL args then "True" else "False"}:
server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt") server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt")
client.succeed("rm -r /etc/ssl/certs") 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") 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, # 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". # otherwise curl will not be able to verify the "legitimacy of the server".
+ lib.strings.concatMapStrings (u: '' + lib.strings.concatMapStrings (
u:
''
import time import time
done = False done = False
@ -97,416 +112,472 @@ let
done = response.get('code') == 200 done = response.get('code') == 200
if not done: if not done:
raise Exception(f"Response was never 200, got last: {response}") raise Exception(f"Response was never 200, got last: {response}")
'' + "\n") ( ''
waitForUrls args + "\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
) )
+ (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) '' + (optionalString (hasAttr "test" nodes.server && hasAttr "login" nodes.server.test) ''
with subtest("Login from server"): with subtest("Login from server"):
code, logs = server.execute("login_playwright") code, logs = server.execute("login_playwright")
print(logs) print(logs)
try: try:
server.copy_from_vm("trace") server.copy_from_vm("trace")
except: except:
print("No trace found on server") print("No trace found on server")
if code != 0: if code != 0:
raise Exception("login_playwright did not succeed") raise Exception("login_playwright did not succeed")
'') '')
+ (optionalString (hasAttr "test" nodes.client && hasAttr "login" nodes.client.test) '' + (optionalString (hasAttr "test" nodes.client && hasAttr "login" nodes.client.test) ''
with subtest("Login from client"): with subtest("Login from client"):
code, logs = client.execute("login_playwright") code, logs = client.execute("login_playwright")
print(logs) print(logs)
try: try:
client.copy_from_vm("trace") client.copy_from_vm("trace")
except: except:
print("No trace found on client") print("No trace found on client")
if code != 0: if code != 0:
raise Exception("login_playwright did not succeed") raise Exception("login_playwright did not succeed")
'') '')
); );
backupScript = args: (accessScript args).override { backupScript =
extraScript = { proto_fqdn, ... }: '' args:
with subtest("backup"): (accessScript args).override {
server.succeed("systemctl start restic-backups-testinstance_opt_repos_A") extraScript =
''; { proto_fqdn, ... }:
}; ''
with subtest("backup"):
server.succeed("systemctl start restic-backups-testinstance_opt_repos_A")
'';
};
in in
{ {
inherit baseImports accessScript; inherit baseImports accessScript;
runNixOSTest = args: pkgs.testers.runNixOSTest ({ runNixOSTest =
interactive.sshBackdoor.enable = true; args:
} // args); pkgs.testers.runNixOSTest (
{
interactive.sshBackdoor.enable = true;
}
// args
);
mkScripts = args: mkScripts = args: {
access = accessScript args;
backup = backupScript args;
};
baseModule =
{ config, ... }:
{ {
access = accessScript args; options.test = {
backup = backupScript args; domain = mkOption {
}; type = str;
default = "example.com";
baseModule = { config, ... }: { };
options.test = { subdomain = mkOption {
domain = mkOption { type = str;
type = str; };
default = "example.com"; 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}";
};
}; };
subdomain = mkOption { imports = [
type = str; baseImports
}; ../modules/blocks/authelia.nix
fqdn = mkOption { ../modules/blocks/hardcodedsecret.nix
type = str; ../modules/blocks/mitmdump.nix
readOnly = true; ../modules/blocks/nginx.nix
default = "${config.test.subdomain}.${config.test.domain}"; ../modules/blocks/postgresql.nix
};
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()
'')
)
]; ];
}; config = {
}; # HTTP(s) server port.
networking.firewall.allowedTCPPorts = [
80
443
];
shb.nginx.accessLog = true;
backup = backupOption: { config, ... }: { networking.hosts = {
imports = [ "192.168.1.2" = [
../modules/blocks/restic.nix config.test.fqdn
]; "auth.${config.test.domain}"
shb.restic.instances."testinstance" = { ];
request = backupOption.request; };
settings = { };
enable = true; };
passphrase.result = config.shb.hardcodedsecret.backupPassphrase.result;
repository = { clientLoginModule =
path = "/opt/repos/A"; { config, pkgs, ... }:
timerConfig = { let
OnCalendar = "00:00:00"; cfg = config.test.login;
RandomizedDelaySec = "5h"; 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 = {
shb.hardcodedsecret.backupPassphrase = { request = config.shb.restic.instances."testinstance".settings.passphrase.request;
request = config.shb.restic.instances."testinstance".settings.passphrase.request; settings.content = "PassPhrase";
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; certs =
domain = "*.${config.test.domain}"; { config, ... }:
group = "nginx"; {
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 = { };
}; };
}; };
}; };
systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ]; sso =
systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ]; ssl:
}; { config, pkgs, ... }:
{
imports = [
../modules/blocks/authelia.nix
];
ldap = { config, pkgs, ... }: { networking.hosts = {
imports = [ "127.0.0.1" = [ "${config.shb.authelia.subdomain}.${config.shb.authelia.domain}" ];
../modules/blocks/lldap.nix };
];
networking.hosts = { shb.authelia = {
"127.0.0.1" = [ "ldap.${config.test.domain}" ]; enable = true;
}; inherit (config.test) domain;
subdomain = "auth";
ssl = config.shb.certs.certs.selfsigned.n;
debug = true;
shb.hardcodedsecret.ldapUserPassword = { ldapHostname = "127.0.0.1";
request = config.shb.lldap.ldapUserPassword.request; ldapPort = config.shb.lldap.ldapPort;
settings.content = "ldapUserPassword"; dcdomain = config.shb.lldap.dcdomain;
};
shb.hardcodedsecret.jwtSecret = {
request = config.shb.lldap.jwtSecret.request;
settings.content = "jwtSecrets";
};
shb.lldap = { secrets = {
enable = true; jwtSecret.result = config.shb.hardcodedsecret.autheliaJwtSecret.result;
inherit (config.test) domain; ldapAdminPassword.result = config.shb.hardcodedsecret.ldapAdminPassword.result;
subdomain = "ldap"; sessionSecret.result = config.shb.hardcodedsecret.sessionSecret.result;
ldapPort = 3890; storageEncryptionKey.result = config.shb.hardcodedsecret.storageEncryptionKey.result;
webUIListenPort = 17170; identityProvidersOIDCHMACSecret.result =
dcdomain = "dc=example,dc=com"; config.shb.hardcodedsecret.identityProvidersOIDCHMACSecret.result;
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result; identityProvidersOIDCIssuerPrivateKey.result =
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result; config.shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey.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 = { shb.hardcodedsecret.autheliaJwtSecret = {
user_group = {}; request = config.shb.authelia.secrets.jwtSecret.request;
admin_group = {}; settings.content = "jwtSecret";
other_group = {}; };
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";
}; };
}; };
};
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";
};
};
} }

View file

@ -1,57 +1,75 @@
{ pkgs, ... }: { pkgs, ... }:
let let
contracts = pkgs.callPackage ../../modules/contracts {}; contracts = pkgs.callPackage ../../modules/contracts { };
in in
{ {
restic_root = contracts.test.backup { restic_root = contracts.test.backup {
name = "restic_root"; name = "restic_root";
username = "root"; username = "root";
providerRoot = [ "shb" "restic" "instances" "mytest" ]; providerRoot = [
"shb"
"restic"
"instances"
"mytest"
];
modules = [ modules = [
../../modules/blocks/restic.nix ../../modules/blocks/restic.nix
../../modules/blocks/hardcodedsecret.nix ../../modules/blocks/hardcodedsecret.nix
]; ];
settings = { repository, config, ... }: { settings =
enable = true; { repository, config, ... }:
passphrase.result = config.shb.hardcodedsecret.passphrase.result; {
repository = { enable = true;
path = repository; passphrase.result = config.shb.hardcodedsecret.passphrase.result;
timerConfig = { repository = {
OnCalendar = "00:00:00"; path = repository;
timerConfig = {
OnCalendar = "00:00:00";
};
}; };
}; };
}; extraConfig =
extraConfig = { username, config, ... }: { { username, config, ... }:
shb.hardcodedsecret.passphrase = { {
request = config.shb.restic.instances."mytest".settings.passphrase.request; shb.hardcodedsecret.passphrase = {
settings.content = "passphrase"; request = config.shb.restic.instances."mytest".settings.passphrase.request;
settings.content = "passphrase";
};
}; };
};
}; };
restic_nonroot = contracts.test.backup { restic_nonroot = contracts.test.backup {
name = "restic_nonroot"; name = "restic_nonroot";
username = "me"; username = "me";
providerRoot = [ "shb" "restic" "instances" "mytest" ]; providerRoot = [
"shb"
"restic"
"instances"
"mytest"
];
modules = [ modules = [
../../modules/blocks/restic.nix ../../modules/blocks/restic.nix
../../modules/blocks/hardcodedsecret.nix ../../modules/blocks/hardcodedsecret.nix
]; ];
settings = { repository, config, ... }: { settings =
enable = true; { repository, config, ... }:
passphrase.result = config.shb.hardcodedsecret.passphrase.result; {
repository = { enable = true;
path = repository; passphrase.result = config.shb.hardcodedsecret.passphrase.result;
timerConfig = { repository = {
OnCalendar = "00:00:00"; path = repository;
timerConfig = {
OnCalendar = "00:00:00";
};
}; };
}; };
}; extraConfig =
extraConfig = { username, config, ... }: { { username, config, ... }:
shb.hardcodedsecret.passphrase = { {
request = config.shb.restic.instances."mytest".settings.passphrase.request; shb.hardcodedsecret.passphrase = {
settings.content = "passphrase"; request = config.shb.restic.instances."mytest".settings.passphrase.request;
settings.content = "passphrase";
};
}; };
};
}; };
} }

View file

@ -1,38 +1,51 @@
{ pkgs, ... }: { pkgs, ... }:
let let
contracts = pkgs.callPackage ../../modules/contracts {}; contracts = pkgs.callPackage ../../modules/contracts { };
in in
{ {
restic_postgres = contracts.test.databasebackup { restic_postgres = contracts.test.databasebackup {
name = "restic_postgres"; name = "restic_postgres";
requesterRoot = [ "shb" "postgresql" "databasebackup" ]; requesterRoot = [
providerRoot = [ "shb" "restic" "databases" "postgresql" ]; "shb"
"postgresql"
"databasebackup"
];
providerRoot = [
"shb"
"restic"
"databases"
"postgresql"
];
modules = [ modules = [
../../modules/blocks/postgresql.nix ../../modules/blocks/postgresql.nix
../../modules/blocks/restic.nix ../../modules/blocks/restic.nix
../../modules/blocks/hardcodedsecret.nix ../../modules/blocks/hardcodedsecret.nix
]; ];
settings = { repository, config, ... }: { settings =
enable = true; { repository, config, ... }:
passphrase.result = config.shb.hardcodedsecret.passphrase.result; {
repository = { enable = true;
path = repository; passphrase.result = config.shb.hardcodedsecret.passphrase.result;
timerConfig = { repository = {
OnCalendar = "00:00:00"; path = repository;
timerConfig = {
OnCalendar = "00:00:00";
};
}; };
}; };
}; extraConfig =
extraConfig = { config, database, ... }: { { config, database, ... }:
shb.postgresql.ensures = [ {
{ shb.postgresql.ensures = [
inherit database; {
username = database; inherit database;
} username = database;
]; }
shb.hardcodedsecret.passphrase = { ];
request = config.shb.restic.databases.postgresql.settings.passphrase.request; shb.hardcodedsecret.passphrase = {
settings.content = "passphrase"; request = config.shb.restic.databases.postgresql.settings.passphrase.request;
settings.content = "passphrase";
};
}; };
};
}; };
} }

View file

@ -1,12 +1,15 @@
{ pkgs, ... }: { pkgs, ... }:
let let
contracts = pkgs.callPackage ../../modules/contracts {}; contracts = pkgs.callPackage ../../modules/contracts { };
in in
{ {
hardcoded_root_root = contracts.test.secret { hardcoded_root_root = contracts.test.secret {
name = "hardcoded"; name = "hardcoded";
modules = [ ../../modules/blocks/hardcodedsecret.nix ]; modules = [ ../../modules/blocks/hardcodedsecret.nix ];
configRoot = [ "shb" "hardcodedsecret" ]; configRoot = [
"shb"
"hardcodedsecret"
];
settingsCfg = secret: { settingsCfg = secret: {
content = secret; content = secret;
}; };
@ -15,7 +18,10 @@ in
hardcoded_user_group = contracts.test.secret { hardcoded_user_group = contracts.test.secret {
name = "hardcoded"; name = "hardcoded";
modules = [ ../../modules/blocks/hardcodedsecret.nix ]; modules = [ ../../modules/blocks/hardcodedsecret.nix ];
configRoot = [ "shb" "hardcodedsecret" ]; configRoot = [
"shb"
"hardcodedsecret"
];
settingsCfg = secret: { settingsCfg = secret: {
content = secret; content = secret;
}; };

View file

@ -1,26 +1,31 @@
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let let
anyOpt = default: lib.mkOption { anyOpt =
type = lib.types.anything; default:
inherit default; lib.mkOption {
}; type = lib.types.anything;
inherit default;
};
testConfig = m: testConfig =
m:
let let
cfg = (lib.evalModules { cfg =
specialArgs = { inherit pkgs; }; (lib.evalModules {
modules = [ specialArgs = { inherit pkgs; };
{ modules = [
options = { {
systemd = anyOpt {}; options = {
services = anyOpt {}; systemd = anyOpt { };
}; services = anyOpt { };
} };
../../modules/blocks/davfs.nix }
m ../../modules/blocks/davfs.nix
]; m
}).config; ];
in { }).config;
in
{
inherit (cfg) systemd services; inherit (cfg) systemd services;
}; };
in in
@ -28,8 +33,8 @@ in
testDavfsNoOptions = { testDavfsNoOptions = {
expected = { expected = {
services.davfs2.enable = false; services.davfs2.enable = false;
systemd.mounts = []; systemd.mounts = [ ];
}; };
expr = testConfig {}; expr = testConfig { };
}; };
} }

View file

@ -17,11 +17,12 @@ in
c = "%SECRET_${root}C%"; c = "%SECRET_${root}C%";
}; };
in in
(item "") // { (item "")
nestedAttr = item "NESTEDATTR_"; // {
nestedList = [ (item "NESTEDLIST_0_") ]; nestedAttr = item "NESTEDATTR_";
doubleNestedList = [ { n = (item "DOUBLENESTEDLIST_0_N_"); } ]; nestedList = [ (item "NESTEDLIST_0_") ];
}; doubleNestedList = [ { n = (item "DOUBLENESTEDLIST_0_N_"); } ];
};
expr = expr =
let let
item = { item = {
@ -33,13 +34,14 @@ in
c.other = "other"; c.other = "other";
}; };
in in
lib.shb.withReplacements ( lib.shb.withReplacements (
item // { item
nestedAttr = item; // {
nestedList = [ item ]; nestedAttr = item;
doubleNestedList = [ { n = item; } ]; nestedList = [ item ];
} doubleNestedList = [ { n = item; } ];
); }
);
}; };
testLibWithReplacementsRootList = { testLibWithReplacementsRootList = {
@ -51,12 +53,12 @@ in
c = "%SECRET_${root}C%"; c = "%SECRET_${root}C%";
}; };
in in
[ [
(item "0_") (item "0_")
(item "1_") (item "1_")
[ (item "2_0_") ] [ (item "2_0_") ]
[ { n = (item "3_0_N_"); } ] [ { n = (item "3_0_N_"); } ]
]; ];
expr = expr =
let let
item = { item = {
@ -68,12 +70,12 @@ in
c.other = "other"; c.other = "other";
}; };
in in
lib.shb.withReplacements [ lib.shb.withReplacements [
item item
item item
[ item ] [ item ]
[ { n = item; } ] [ { n = item; } ]
]; ];
}; };
testLibGetReplacements = { testLibGetReplacements = {
@ -84,10 +86,10 @@ in
(nameValuePair "%SECRET_${root}C%" "prefix-$(cat /path/C)-suffix") (nameValuePair "%SECRET_${root}C%" "prefix-$(cat /path/C)-suffix")
]; ];
in in
(secrets "") ++ (secrets "")
(secrets "DOUBLENESTEDLIST_0_N_") ++ ++ (secrets "DOUBLENESTEDLIST_0_N_")
(secrets "NESTEDATTR_") ++ ++ (secrets "NESTEDATTR_")
(secrets "NESTEDLIST_0_"); ++ (secrets "NESTEDLIST_0_");
expr = expr =
let let
item = { item = {
@ -99,13 +101,16 @@ in
c.other = "other"; c.other = "other";
}; };
in in
map lib.shb.genReplacement (lib.shb.getReplacements ( map lib.shb.genReplacement (
item // { lib.shb.getReplacements (
item
// {
nestedAttr = item; nestedAttr = item;
nestedList = [ item ]; nestedList = [ item ];
doubleNestedList = [ { n = item; } ]; doubleNestedList = [ { n = item; } ];
} }
)); )
);
}; };
testParseXML = { testParseXML = {
@ -119,10 +124,10 @@ in
}; };
expr = lib.shb.parseXML '' expr = lib.shb.parseXML ''
<a> <a>
<b>1</b> <b>1</b>
<c><d>1</d></c> <c><d>1</d></c>
</a> </a>
''; '';
}; };
} }

View file

@ -4,163 +4,206 @@ let
loginUrl = "/UI/Login"; loginUrl = "/UI/Login";
# TODO: Test login # TODO: Test login
commonTestScript = appname: cfgPathFn: lib.shb.mkScripts { commonTestScript =
hasSSL = { node, ... }: !(isNull node.config.shb.arr.${appname}.ssl); appname: cfgPathFn:
waitForServices = { ... }: [ lib.shb.mkScripts {
"${appname}.service" hasSSL = { node, ... }: !(isNull node.config.shb.arr.${appname}.ssl);
"nginx.service" waitForServices =
]; { ... }:
waitForPorts = { node, ... }: [ [
node.config.shb.arr.${appname}.settings.Port "${appname}.service"
]; "nginx.service"
extraScript = { node, fqdn, proto_fqdn, ... }: let ];
shbapp = node.config.shb.arr.${appname}; waitForPorts =
cfgPath = cfgPathFn shbapp; { node, ... }:
apiKey = if (shbapp.settings ? ApiKey) then "01234567890123456789" else null; [
in '' node.config.shb.arr.${appname}.settings.Port
# These curl requests still return a 200 even with sso redirect. ];
with subtest("health"): extraScript =
response = curl(client, """{"code":%{response_code}}""", "${fqdn}${healthUrl}") {
print("response =", response) node,
fqdn,
proto_fqdn,
...
}:
let
shbapp = node.config.shb.arr.${appname};
cfgPath = cfgPathFn shbapp;
apiKey = if (shbapp.settings ? ApiKey) then "01234567890123456789" else null;
in
''
# These curl requests still return a 200 even with sso redirect.
with subtest("health"):
response = curl(client, """{"code":%{response_code}}""", "${fqdn}${healthUrl}")
print("response =", response)
if response['code'] != 200: if response['code'] != 200:
raise Exception(f"Code is {response['code']}") raise Exception(f"Code is {response['code']}")
with subtest("login"): with subtest("login"):
response = curl(client, """{"code":%{response_code}}""", "${fqdn}${loginUrl}") response = curl(client, """{"code":%{response_code}}""", "${fqdn}${loginUrl}")
if response['code'] != 200: if response['code'] != 200:
raise Exception(f"Code is {response['code']}") raise Exception(f"Code is {response['code']}")
'' + lib.optionalString (apiKey != null) '' ''
+ lib.optionalString (apiKey != null) ''
with subtest("apikey"): with subtest("apikey"):
config = server.succeed("cat ${cfgPath}") config = server.succeed("cat ${cfgPath}")
if "${apiKey}" not in config: if "${apiKey}" not in config:
raise Exception(f"Unexpected API Key. Want '${apiKey}', got '{config}'") raise Exception(f"Unexpected API Key. Want '${apiKey}', got '{config}'")
''; '';
};
basic = appname: { config, ... }: {
imports = [
lib.shb.baseModule
../../modules/services/arr.nix
];
test = {
subdomain = appname;
}; };
shb.arr.${appname} = { basic =
enable = true; appname:
inherit (config.test) subdomain domain; { config, ... }:
{
settings.ApiKey.source = pkgs.writeText "APIKey" "01234567890123456789"; # Needs to be >=20 characters.
};
};
clientLogin = appname: { config, ... }: {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
test = {
subdomain = appname;
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "[Uu]sername";
passwordFieldLabelRegex = "^ *[Pp]assword";
loginButtonNameRegex = "[Ll]og [Ii]n";
testLoginWith = [
{ nextPageExpect = [
"expect(page).to_have_title(re.compile('${appname}', re.IGNORECASE))"
]; }
];
};
};
basicTest = appname: cfgPathFn: lib.shb.runNixOSTest {
name = "arr_${appname}_basic";
nodes.client = {
imports = [ imports = [
(clientLogin appname) lib.shb.baseModule
../../modules/services/arr.nix
]; ];
test = {
subdomain = appname;
};
shb.arr.${appname} = {
enable = true;
inherit (config.test) subdomain domain;
settings.ApiKey.source = pkgs.writeText "APIKey" "01234567890123456789"; # Needs to be >=20 characters.
};
}; };
nodes.server = {
clientLogin =
appname:
{ config, ... }:
{
imports = [ imports = [
(basic appname) lib.shb.baseModule
lib.shb.clientLoginModule
]; ];
test = {
subdomain = appname;
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "[Uu]sername";
passwordFieldLabelRegex = "^ *[Pp]assword";
loginButtonNameRegex = "[Ll]og [Ii]n";
testLoginWith = [
{
nextPageExpect = [
"expect(page).to_have_title(re.compile('${appname}', re.IGNORECASE))"
];
}
];
};
}; };
testScript = (commonTestScript appname cfgPathFn).access; basicTest =
}; appname: cfgPathFn:
lib.shb.runNixOSTest {
name = "arr_${appname}_basic";
backupTest = appname: cfgPathFn: lib.shb.runNixOSTest { nodes.client = {
name = "arr_${appname}_backup"; imports = [
(clientLogin appname)
];
};
nodes.server = {
imports = [
(basic appname)
];
};
nodes.server = { config, ... }: { testScript = (commonTestScript appname cfgPathFn).access;
imports = [
(basic appname)
(lib.shb.backup config.shb.arr.${appname}.backup)
];
}; };
nodes.client = {}; backupTest =
appname: cfgPathFn:
lib.shb.runNixOSTest {
name = "arr_${appname}_backup";
testScript = (commonTestScript appname cfgPathFn).backup; nodes.server =
}; { config, ... }:
{
imports = [
(basic appname)
(lib.shb.backup config.shb.arr.${appname}.backup)
];
};
https = appname: { config, ...}: { nodes.client = { };
shb.arr.${appname} = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
httpsTest = appname: cfgPathFn: lib.shb.runNixOSTest { testScript = (commonTestScript appname cfgPathFn).backup;
name = "arr_${appname}_https";
nodes.server = { config, pkgs, ... }: {
imports = [
(basic appname)
lib.shb.certs
(https appname)
];
}; };
nodes.client = {}; https =
appname:
testScript = (commonTestScript appname cfgPathFn).access; { config, ... }:
}; {
shb.arr.${appname} = {
sso = appname: { config, ...}: { ssl = config.shb.certs.certs.selfsigned.n;
shb.arr.${appname} = { };
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
};
ssoTest = appname: cfgPathFn: lib.shb.runNixOSTest {
name = "arr_${appname}_sso";
nodes.server = { config, pkgs, ... }: {
imports = [
(basic appname)
lib.shb.certs
(https appname)
lib.shb.ldap
(lib.shb.sso config.shb.certs.certs.selfsigned.n)
(sso appname)
];
}; };
nodes.client = {}; httpsTest =
appname: cfgPathFn:
lib.shb.runNixOSTest {
name = "arr_${appname}_https";
testScript = (commonTestScript appname cfgPathFn).access.override { nodes.server =
redirectSSO = true; { config, pkgs, ... }:
{
imports = [
(basic appname)
lib.shb.certs
(https appname)
];
};
nodes.client = { };
testScript = (commonTestScript appname cfgPathFn).access;
};
sso =
appname:
{ config, ... }:
{
shb.arr.${appname} = {
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
};
ssoTest =
appname: cfgPathFn:
lib.shb.runNixOSTest {
name = "arr_${appname}_sso";
nodes.server =
{ config, pkgs, ... }:
{
imports = [
(basic appname)
lib.shb.certs
(https appname)
lib.shb.ldap
(lib.shb.sso config.shb.certs.certs.selfsigned.n)
(sso appname)
];
};
nodes.client = { };
testScript = (commonTestScript appname cfgPathFn).access.override {
redirectSSO = true;
};
}; };
};
radarrCfgFn = cfg: "${cfg.dataDir}/config.xml"; radarrCfgFn = cfg: "${cfg.dataDir}/config.xml";
sonarrCfgFn = cfg: "${cfg.dataDir}/config.xml"; sonarrCfgFn = cfg: "${cfg.dataDir}/config.xml";
@ -170,33 +213,33 @@ let
jackettCfgFn = cfg: "${cfg.dataDir}/ServerConfig.json"; jackettCfgFn = cfg: "${cfg.dataDir}/ServerConfig.json";
in in
{ {
radarr_basic = basicTest "radarr" radarrCfgFn; radarr_basic = basicTest "radarr" radarrCfgFn;
radarr_backup = backupTest "radarr" radarrCfgFn; radarr_backup = backupTest "radarr" radarrCfgFn;
radarr_https = httpsTest "radarr" radarrCfgFn; radarr_https = httpsTest "radarr" radarrCfgFn;
radarr_sso = ssoTest "radarr" radarrCfgFn; radarr_sso = ssoTest "radarr" radarrCfgFn;
sonarr_basic = basicTest "sonarr" sonarrCfgFn; sonarr_basic = basicTest "sonarr" sonarrCfgFn;
sonarr_backup = backupTest "sonarr" sonarrCfgFn; sonarr_backup = backupTest "sonarr" sonarrCfgFn;
sonarr_https = httpsTest "sonarr" sonarrCfgFn; sonarr_https = httpsTest "sonarr" sonarrCfgFn;
sonarr_sso = ssoTest "sonarr" sonarrCfgFn; sonarr_sso = ssoTest "sonarr" sonarrCfgFn;
bazarr_basic = basicTest "bazarr" bazarrCfgFn; bazarr_basic = basicTest "bazarr" bazarrCfgFn;
bazarr_backup = backupTest "bazarr" bazarrCfgFn; bazarr_backup = backupTest "bazarr" bazarrCfgFn;
bazarr_https = httpsTest "bazarr" bazarrCfgFn; bazarr_https = httpsTest "bazarr" bazarrCfgFn;
bazarr_sso = ssoTest "bazarr" bazarrCfgFn; bazarr_sso = ssoTest "bazarr" bazarrCfgFn;
readarr_basic = basicTest "readarr" readarrCfgFn; readarr_basic = basicTest "readarr" readarrCfgFn;
readarr_backup = backupTest "readarr" readarrCfgFn; readarr_backup = backupTest "readarr" readarrCfgFn;
readarr_https = httpsTest "readarr" readarrCfgFn; readarr_https = httpsTest "readarr" readarrCfgFn;
readarr_sso = ssoTest "readarr" readarrCfgFn; readarr_sso = ssoTest "readarr" readarrCfgFn;
lidarr_basic = basicTest "lidarr" lidarrCfgFn; lidarr_basic = basicTest "lidarr" lidarrCfgFn;
lidarr_backup = backupTest "lidarr" lidarrCfgFn; lidarr_backup = backupTest "lidarr" lidarrCfgFn;
lidarr_https = httpsTest "lidarr" lidarrCfgFn; lidarr_https = httpsTest "lidarr" lidarrCfgFn;
lidarr_sso = ssoTest "lidarr" lidarrCfgFn; lidarr_sso = ssoTest "lidarr" lidarrCfgFn;
jackett_basic = basicTest "jackett" jackettCfgFn; jackett_basic = basicTest "jackett" jackettCfgFn;
jackett_backup = backupTest "jackett" jackettCfgFn; jackett_backup = backupTest "jackett" jackettCfgFn;
jackett_https = httpsTest "jackett" jackettCfgFn; jackett_https = httpsTest "jackett" jackettCfgFn;
jackett_sso = ssoTest "jackett" jackettCfgFn; jackett_sso = ssoTest "jackett" jackettCfgFn;
} }

View file

@ -2,89 +2,106 @@
let let
commonTestScript = lib.shb.accessScript { commonTestScript = lib.shb.accessScript {
hasSSL = { node, ... }: !(isNull node.config.shb.audiobookshelf.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.audiobookshelf.ssl);
waitForServices = { ... }: [ waitForServices =
"audiobookshelf.service" { ... }:
"nginx.service" [
]; "audiobookshelf.service"
waitForPorts = { node, ... }: [ "nginx.service"
node.config.shb.audiobookshelf.webPort ];
]; waitForPorts =
{ node, ... }:
[
node.config.shb.audiobookshelf.webPort
];
# TODO: Test login # TODO: Test login
# extraScript = { ... }: '' # extraScript = { ... }: ''
# ''; # '';
}; };
basic = { config, ... }: { basic =
imports = [ { config, ... }:
lib.shb.baseModule {
../../modules/services/audiobookshelf.nix imports = [
]; lib.shb.baseModule
../../modules/services/audiobookshelf.nix
test = {
subdomain = "a";
};
shb.audiobookshelf = {
enable = true;
inherit (config.test) subdomain domain;
};
};
clientLogin = { config, ... }: {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "a";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "[Uu]sername";
passwordFieldLabelRegex = "[Pp]assword";
loginButtonNameRegex = "[Ll]og [Ii]n";
testLoginWith = [
# Failure is after so we're not throttled too much.
{ username = "root"; password = "rootpw"; nextPageExpect = [
"expect(page.get_by_text('Wrong username or password')).to_be_visible()"
]; }
# { username = adminUser; password = adminPass; nextPageExpect = [
# "expect(page.get_by_text('Wrong username or password')).not_to_be_visible()"
# "expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()"
# "expect(page).to_have_title(re.compile('Dashboard'))"
# ]; }
]; ];
};
};
https = { config, ... }: { test = {
shb.audiobookshelf = { subdomain = "a";
ssl = config.shb.certs.certs.selfsigned.n; };
}; shb.audiobookshelf = {
};
sso = { config, ... }: {
shb.audiobookshelf = {
sso = {
enable = true; enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; inherit (config.test) subdomain domain;
sharedSecret.result = config.shb.hardcodedsecret.audiobookshelfSSOPassword.result;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.audiobookshelfSSOPasswordAuthelia.result;
}; };
}; };
shb.hardcodedsecret.audiobookshelfSSOPassword = { clientLogin =
request = config.shb.audiobookshelf.sso.sharedSecret.request; { config, ... }:
settings.content = "ssoPassword"; {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "a";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "[Uu]sername";
passwordFieldLabelRegex = "[Pp]assword";
loginButtonNameRegex = "[Ll]og [Ii]n";
testLoginWith = [
# Failure is after so we're not throttled too much.
{
username = "root";
password = "rootpw";
nextPageExpect = [
"expect(page.get_by_text('Wrong username or password')).to_be_visible()"
];
}
# { username = adminUser; password = adminPass; nextPageExpect = [
# "expect(page.get_by_text('Wrong username or password')).not_to_be_visible()"
# "expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()"
# "expect(page).to_have_title(re.compile('Dashboard'))"
# ]; }
];
};
}; };
shb.hardcodedsecret.audiobookshelfSSOPasswordAuthelia = { https =
request = config.shb.audiobookshelf.sso.sharedSecretForAuthelia.request; { config, ... }:
settings.content = "ssoPassword"; {
shb.audiobookshelf = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
sso =
{ config, ... }:
{
shb.audiobookshelf = {
sso = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
sharedSecret.result = config.shb.hardcodedsecret.audiobookshelfSSOPassword.result;
sharedSecretForAuthelia.result =
config.shb.hardcodedsecret.audiobookshelfSSOPasswordAuthelia.result;
};
};
shb.hardcodedsecret.audiobookshelfSSOPassword = {
request = config.shb.audiobookshelf.sso.sharedSecret.request;
settings.content = "ssoPassword";
};
shb.hardcodedsecret.audiobookshelfSSOPasswordAuthelia = {
request = config.shb.audiobookshelf.sso.sharedSecretForAuthelia.request;
settings.content = "ssoPassword";
};
}; };
};
in in
{ {
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
@ -116,7 +133,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript; testScript = commonTestScript;
}; };
@ -124,18 +141,20 @@ in
sso = lib.shb.runNixOSTest { sso = lib.shb.runNixOSTest {
name = "audiobookshelf-sso"; name = "audiobookshelf-sso";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
lib.shb.certs imports = [
https basic
lib.shb.ldap lib.shb.certs
(lib.shb.sso config.shb.certs.certs.selfsigned.n) https
sso lib.shb.ldap
]; (lib.shb.sso config.shb.certs.certs.selfsigned.n)
}; sso
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript; testScript = commonTestScript;
}; };

View file

@ -2,146 +2,169 @@
let let
commonTestScript = lib.shb.mkScripts { commonTestScript = lib.shb.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.deluge.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.deluge.ssl);
waitForServices = { ... }: [ waitForServices =
"nginx.service" { ... }:
"deluged.service" [
"delugeweb.service" "nginx.service"
]; "deluged.service"
waitForPorts = { node, ... }: [ "delugeweb.service"
node.config.shb.deluge.daemonPort
node.config.shb.deluge.webPort
];
extraScript = { node, proto_fqdn, ... }: ''
print(${node.name}.succeed('journalctl -n100 -u deluged'))
print(${node.name}.succeed('systemctl status deluged'))
print(${node.name}.succeed('systemctl status delugeweb'))
with subtest("web connect"):
print(server.succeed("cat ${node.config.services.deluge.dataDir}/.config/deluge/auth"))
response = curl(client, "", "${proto_fqdn}/json", extra = unline_with(" ", """
-H "Content-Type: application/json"
-H "Accept: application/json"
"""), data = unline_with(" ", """
{"method": "auth.login", "params": ["deluge"], "id": 1}
"""))
print(response)
if response['error']:
raise Exception(f"error is {response['error']}")
if not response['result']:
raise Exception(f"response is {response}")
response = curl(client, "", "${proto_fqdn}/json", extra = unline_with(" ", """
-H "Content-Type: application/json"
-H "Accept: application/json"
"""), data = unline_with(" ", """
{"method": "web.get_hosts", "params": [], "id": 1}
"""))
print(response)
if response['error']:
raise Exception(f"error is {response['error']}")
hostID = response['result'][0][0]
response = curl(client, "", "${proto_fqdn}/json", extra = unline_with(" ", """
-H "Content-Type: application/json"
-H "Accept: application/json"
"""), data = unline_with(" ", f"""
{{"method": "web.connect", "params": ["{hostID}"], "id": 1}}
"""))
print(response)
if response['error']:
raise Exception(f"result had an error {response['error']}")
'';
};
prometheusTestScript = { nodes, ... }:
''
server.wait_for_open_port(${toString nodes.server.services.prometheus.exporters.deluge.port})
with subtest("prometheus"):
response = server.succeed(
"curl -sSf "
+ " http://localhost:${toString nodes.server.services.prometheus.exporters.deluge.port}/metrics"
)
print(response)
'';
basic = { config, ... }: {
imports = [
lib.shb.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/deluge.nix
];
test = {
subdomain = "d";
};
shb.deluge = {
enable = true;
inherit (config.test) domain subdomain;
settings = {
downloadLocation = "/var/lib/deluge";
};
extraUsers = {
user.password.source = pkgs.writeText "userpw" "userpw";
};
localclientPassword.result = config.shb.hardcodedsecret."localclientpassword".result;
};
shb.hardcodedsecret."localclientpassword" = {
request = config.shb.deluge.localclientPassword.request;
settings.content = "localpw";
};
};
clientLogin = { config, ... }: {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
test = {
subdomain = "d";
};
test.login = {
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "Login";
testLoginWith = [
{ password = "deluge"; nextPageExpect = [
"expect(page.get_by_role('button', name='Login')).not_to_be_visible()"
"expect(page.get_by_text('Login Failed')).not_to_be_visible()"
]; }
{ password = "other"; nextPageExpect = [
"expect(page.get_by_role('button', name='Login')).to_be_visible()"
"expect(page.get_by_text('Login Failed')).to_be_visible()"
]; }
]; ];
}; waitForPorts =
{ node, ... }:
[
node.config.shb.deluge.daemonPort
node.config.shb.deluge.webPort
];
extraScript =
{ node, proto_fqdn, ... }:
''
print(${node.name}.succeed('journalctl -n100 -u deluged'))
print(${node.name}.succeed('systemctl status deluged'))
print(${node.name}.succeed('systemctl status delugeweb'))
with subtest("web connect"):
print(server.succeed("cat ${node.config.services.deluge.dataDir}/.config/deluge/auth"))
response = curl(client, "", "${proto_fqdn}/json", extra = unline_with(" ", """
-H "Content-Type: application/json"
-H "Accept: application/json"
"""), data = unline_with(" ", """
{"method": "auth.login", "params": ["deluge"], "id": 1}
"""))
print(response)
if response['error']:
raise Exception(f"error is {response['error']}")
if not response['result']:
raise Exception(f"response is {response}")
response = curl(client, "", "${proto_fqdn}/json", extra = unline_with(" ", """
-H "Content-Type: application/json"
-H "Accept: application/json"
"""), data = unline_with(" ", """
{"method": "web.get_hosts", "params": [], "id": 1}
"""))
print(response)
if response['error']:
raise Exception(f"error is {response['error']}")
hostID = response['result'][0][0]
response = curl(client, "", "${proto_fqdn}/json", extra = unline_with(" ", """
-H "Content-Type: application/json"
-H "Accept: application/json"
"""), data = unline_with(" ", f"""
{{"method": "web.connect", "params": ["{hostID}"], "id": 1}}
"""))
print(response)
if response['error']:
raise Exception(f"result had an error {response['error']}")
'';
}; };
prometheus = { config, ... }: { prometheusTestScript =
shb.deluge = { { nodes, ... }:
prometheusScraperPassword.result = config.shb.hardcodedsecret."scraper".result; ''
}; server.wait_for_open_port(${toString nodes.server.services.prometheus.exporters.deluge.port})
shb.hardcodedsecret."scraper" = { with subtest("prometheus"):
request = config.shb.deluge.prometheusScraperPassword.request; response = server.succeed(
settings.content = "scraperpw"; "curl -sSf "
}; + " http://localhost:${toString nodes.server.services.prometheus.exporters.deluge.port}/metrics"
}; )
print(response)
'';
https = { config, ...}: { basic =
shb.deluge = { { config, ... }:
ssl = config.shb.certs.certs.selfsigned.n; {
}; imports = [
}; lib.shb.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/deluge.nix
];
sso = { config, ... }: { test = {
shb.deluge = { subdomain = "d";
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; };
shb.deluge = {
enable = true;
inherit (config.test) domain subdomain;
settings = {
downloadLocation = "/var/lib/deluge";
};
extraUsers = {
user.password.source = pkgs.writeText "userpw" "userpw";
};
localclientPassword.result = config.shb.hardcodedsecret."localclientpassword".result;
};
shb.hardcodedsecret."localclientpassword" = {
request = config.shb.deluge.localclientPassword.request;
settings.content = "localpw";
};
};
clientLogin =
{ config, ... }:
{
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
test = {
subdomain = "d";
};
test.login = {
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "Login";
testLoginWith = [
{
password = "deluge";
nextPageExpect = [
"expect(page.get_by_role('button', name='Login')).not_to_be_visible()"
"expect(page.get_by_text('Login Failed')).not_to_be_visible()"
];
}
{
password = "other";
nextPageExpect = [
"expect(page.get_by_role('button', name='Login')).to_be_visible()"
"expect(page.get_by_text('Login Failed')).to_be_visible()"
];
}
];
};
};
prometheus =
{ config, ... }:
{
shb.deluge = {
prometheusScraperPassword.result = config.shb.hardcodedsecret."scraper".result;
};
shb.hardcodedsecret."scraper" = {
request = config.shb.deluge.prometheusScraperPassword.request;
settings.content = "scraperpw";
};
};
https =
{ config, ... }:
{
shb.deluge = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
sso =
{ config, ... }:
{
shb.deluge = {
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
}; };
};
in in
{ {
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
@ -164,14 +187,16 @@ in
backup = lib.shb.runNixOSTest { backup = lib.shb.runNixOSTest {
name = "deluge_backup"; name = "deluge_backup";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
(lib.shb.backup config.shb.deluge.backup) imports = [
]; basic
}; (lib.shb.backup config.shb.deluge.backup)
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.backup; testScript = commonTestScript.backup;
}; };
@ -187,27 +212,29 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
sso = lib.shb.runNixOSTest { sso = lib.shb.runNixOSTest {
name = "deluge_sso"; name = "deluge_sso";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
lib.shb.certs imports = [
https basic
lib.shb.ldap lib.shb.certs
(lib.shb.sso config.shb.certs.certs.selfsigned.n) https
sso lib.shb.ldap
]; (lib.shb.sso config.shb.certs.certs.selfsigned.n)
}; sso
];
nodes.client = {}; };
nodes.client = { };
testScript = commonTestScript.access.override { testScript = commonTestScript.access.override {
redirectSSO = true; redirectSSO = true;
}; };
@ -225,10 +252,8 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = inputs: testScript = inputs: (commonTestScript.access inputs) + (prometheusTestScript inputs);
(commonTestScript.access inputs)
+ (prometheusTestScript inputs);
}; };
} }

View file

@ -4,150 +4,182 @@ let
commonTestScript = lib.shb.mkScripts { commonTestScript = lib.shb.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.forgejo.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.forgejo.ssl);
waitForServices = { ... }: [ waitForServices =
"forgejo.service" { ... }:
"nginx.service" [
]; "forgejo.service"
waitForUnixSocket = { node, ... }: [ "nginx.service"
node.config.services.forgejo.settings.server.HTTP_ADDR
];
extraScript = { node, ... }: ''
server.wait_for_unit("gitea-runner-local.service", timeout=10)
server.succeed("journalctl -o cat -u gitea-runner-local.service | grep -q 'Runner registered successfully'")
'';
};
basic = { config, ... }: {
imports = [
lib.shb.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/forgejo.nix
];
test = {
subdomain = "f";
};
shb.forgejo = {
enable = true;
inherit (config.test) subdomain domain;
users = {
"theadmin" = {
isAdmin = true;
email = "theadmin@example.com";
password.result = config.shb.hardcodedsecret.forgejoAdminPassword.result;
};
"theuser" = {
email = "theuser@example.com";
password.result = config.shb.hardcodedsecret.forgejoUserPassword.result;
};
};
databasePassword.result = config.shb.hardcodedsecret.forgejoDatabasePassword.result;
};
# Needed for gitea-runner-local to be able to ping forgejo.
networking.hosts = {
"127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ];
};
shb.hardcodedsecret.forgejoAdminPassword = {
request = config.shb.forgejo.users."theadmin".password.request;
settings.content = adminPassword;
};
shb.hardcodedsecret.forgejoUserPassword = {
request = config.shb.forgejo.users."theuser".password.request;
settings.content = "userPassword";
};
shb.hardcodedsecret.forgejoDatabasePassword = {
request = config.shb.forgejo.databasePassword.request;
settings.content = "databasePassword";
};
};
clientLogin = { config, ... }: {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
test = {
subdomain = "f";
};
test.login = {
startUrl = "http://${config.test.fqdn}/user/login";
usernameFieldLabelRegex = "Username or email address";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{ username = "theadmin"; password = adminPassword + "oops"; nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
]; }
{ username = "theadmin"; password = adminPassword; nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
]; }
{ username = "theuser"; password = "userPasswordOops"; nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
]; }
{ username = "theuser"; password = "userPassword"; nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
]; }
]; ];
}; waitForUnixSocket =
{ node, ... }:
[
node.config.services.forgejo.settings.server.HTTP_ADDR
];
extraScript =
{ node, ... }:
''
server.wait_for_unit("gitea-runner-local.service", timeout=10)
server.succeed("journalctl -o cat -u gitea-runner-local.service | grep -q 'Runner registered successfully'")
'';
}; };
https = { config, ... }: { basic =
shb.forgejo = { { config, ... }:
ssl = config.shb.certs.certs.selfsigned.n; {
}; imports = [
}; lib.shb.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/forgejo.nix
];
ldap = { config, ... }: { test = {
shb.forgejo = { subdomain = "f";
ldap = { };
shb.forgejo = {
enable = true; enable = true;
host = "127.0.0.1"; inherit (config.test) subdomain domain;
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
adminPassword.result = config.shb.hardcodedsecret.forgejoLdapUserPassword.result;
waitForSystemdServices = [ "lldap.service" ];
userGroup = "user_group"; users = {
"theadmin" = {
isAdmin = true;
email = "theadmin@example.com";
password.result = config.shb.hardcodedsecret.forgejoAdminPassword.result;
};
"theuser" = {
email = "theuser@example.com";
password.result = config.shb.hardcodedsecret.forgejoUserPassword.result;
};
};
databasePassword.result = config.shb.hardcodedsecret.forgejoDatabasePassword.result;
};
# Needed for gitea-runner-local to be able to ping forgejo.
networking.hosts = {
"127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ];
};
shb.hardcodedsecret.forgejoAdminPassword = {
request = config.shb.forgejo.users."theadmin".password.request;
settings.content = adminPassword;
};
shb.hardcodedsecret.forgejoUserPassword = {
request = config.shb.forgejo.users."theuser".password.request;
settings.content = "userPassword";
};
shb.hardcodedsecret.forgejoDatabasePassword = {
request = config.shb.forgejo.databasePassword.request;
settings.content = "databasePassword";
}; };
}; };
shb.hardcodedsecret.forgejoLdapUserPassword = { clientLogin =
request = config.shb.forgejo.ldap.adminPassword.request; { config, ... }:
settings.content = "ldapUserPassword"; {
}; imports = [
}; lib.shb.baseModule
lib.shb.clientLoginModule
];
test = {
subdomain = "f";
};
sso = { config, ... }: { test.login = {
shb.forgejo = { startUrl = "http://${config.test.fqdn}/user/login";
sso = { usernameFieldLabelRegex = "Username or email address";
enable = true; passwordFieldLabelRegex = "Password";
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; loginButtonNameRegex = "[sS]ign [iI]n";
sharedSecret.result = config.shb.hardcodedsecret.forgejoSSOPassword.result; testLoginWith = [
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.forgejoSSOPasswordAuthelia.result; {
username = "theadmin";
password = adminPassword + "oops";
nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
];
}
{
username = "theadmin";
password = adminPassword;
nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
];
}
{
username = "theuser";
password = "userPasswordOops";
nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
];
}
{
username = "theuser";
password = "userPassword";
nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
];
}
];
}; };
}; };
shb.hardcodedsecret.forgejoSSOPassword = { https =
request = config.shb.forgejo.sso.sharedSecret.request; { config, ... }:
settings.content = "ssoPassword"; {
shb.forgejo = {
ssl = config.shb.certs.certs.selfsigned.n;
};
}; };
shb.hardcodedsecret.forgejoSSOPasswordAuthelia = { ldap =
request = config.shb.forgejo.sso.sharedSecretForAuthelia.request; { config, ... }:
settings.content = "ssoPassword"; {
shb.forgejo = {
ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
adminPassword.result = config.shb.hardcodedsecret.forgejoLdapUserPassword.result;
waitForSystemdServices = [ "lldap.service" ];
userGroup = "user_group";
};
};
shb.hardcodedsecret.forgejoLdapUserPassword = {
request = config.shb.forgejo.ldap.adminPassword.request;
settings.content = "ldapUserPassword";
};
};
sso =
{ config, ... }:
{
shb.forgejo = {
sso = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
sharedSecret.result = config.shb.hardcodedsecret.forgejoSSOPassword.result;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.forgejoSSOPasswordAuthelia.result;
};
};
shb.hardcodedsecret.forgejoSSOPassword = {
request = config.shb.forgejo.sso.sharedSecret.request;
settings.content = "ssoPassword";
};
shb.hardcodedsecret.forgejoSSOPasswordAuthelia = {
request = config.shb.forgejo.sso.sharedSecretForAuthelia.request;
settings.content = "ssoPassword";
};
}; };
};
in in
{ {
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
@ -170,14 +202,16 @@ in
backup = lib.shb.runNixOSTest { backup = lib.shb.runNixOSTest {
name = "forgejo_backup"; name = "forgejo_backup";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
(lib.shb.backup config.shb.forgejo.backup) imports = [
]; basic
}; (lib.shb.backup config.shb.forgejo.backup)
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.backup; testScript = commonTestScript.backup;
}; };
@ -193,7 +227,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
@ -208,71 +242,100 @@ in
ldap ldap
]; ];
}; };
nodes.client = { nodes.client = {
imports = [ imports = [
({ config, ... }: { (
imports = [ { config, ... }:
lib.shb.baseModule {
lib.shb.clientLoginModule imports = [
]; lib.shb.baseModule
lib.shb.clientLoginModule
test = {
subdomain = "f";
};
test.login = {
startUrl = "http://${config.test.fqdn}/user/login";
usernameFieldLabelRegex = "Username or email address";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{ username = "alice"; password = "NotAlicePassword"; nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
]; }
{ username = "alice"; password = "AlicePassword"; nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
]; }
{ username = "bob"; password = "NotBobPassword"; nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
]; }
{ username = "bob"; password = "BobPassword"; nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
]; }
{ username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
]; }
{ username = "charlie"; password = "CharliePassword"; nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
]; }
]; ];
};
}) test = {
subdomain = "f";
};
test.login = {
startUrl = "http://${config.test.fqdn}/user/login";
usernameFieldLabelRegex = "Username or email address";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
];
}
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
];
}
{
username = "charlie";
password = "NotCharliePassword";
nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
];
}
{
username = "charlie";
password = "CharliePassword";
nextPageExpect = [
"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()"
];
}
];
};
}
)
]; ];
}; };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
sso = lib.shb.runNixOSTest { sso = lib.shb.runNixOSTest {
name = "forgejo_sso"; name = "forgejo_sso";
nodes.server = { config, pkgs, ... }: { nodes.server =
imports = [ { config, pkgs, ... }:
basic {
lib.shb.certs imports = [
https basic
lib.shb.ldap lib.shb.certs
(lib.shb.sso config.shb.certs.certs.selfsigned.n) https
sso lib.shb.ldap
]; (lib.shb.sso config.shb.certs.certs.selfsigned.n)
}; sso
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };

View file

@ -2,65 +2,83 @@
let let
commonTestScript = lib.shb.mkScripts { commonTestScript = lib.shb.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.grocy.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.grocy.ssl);
waitForServices = { ... }: [ waitForServices =
"phpfpm-grocy.service" { ... }:
"nginx.service" [
]; "phpfpm-grocy.service"
waitForUnixSocket = { node, ... }: [ "nginx.service"
node.config.services.phpfpm.pools.grocy.socket ];
]; waitForUnixSocket =
}; { node, ... }:
[
basic = { config, ... }: { node.config.services.phpfpm.pools.grocy.socket
imports = [
lib.shb.baseModule
../../modules/services/grocy.nix
];
test = {
subdomain = "g";
};
shb.grocy = {
enable = true;
inherit (config.test) subdomain domain;
};
};
clientLogin = { config, ... }: {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "g";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "OK";
testLoginWith = [
{ username = "admin"; password = "admin oops"; nextPageExpect = [
"expect(page.get_by_text('Invalid credentials, please try again')).to_be_visible()"
]; }
{ username = "admin"; password = "admin"; nextPageExpect = [
"expect(page.get_by_text('Invalid credentials, please try again')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('OK'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Grocy'))"
]; }
]; ];
};
}; };
https = { config, ...}: { basic =
shb.grocy = { { config, ... }:
ssl = config.shb.certs.certs.selfsigned.n; {
imports = [
lib.shb.baseModule
../../modules/services/grocy.nix
];
test = {
subdomain = "g";
};
shb.grocy = {
enable = true;
inherit (config.test) subdomain domain;
};
};
clientLogin =
{ config, ... }:
{
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "g";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "OK";
testLoginWith = [
{
username = "admin";
password = "admin oops";
nextPageExpect = [
"expect(page.get_by_text('Invalid credentials, please try again')).to_be_visible()"
];
}
{
username = "admin";
password = "admin";
nextPageExpect = [
"expect(page.get_by_text('Invalid credentials, please try again')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('OK'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Grocy'))"
];
}
];
};
};
https =
{ config, ... }:
{
shb.grocy = {
ssl = config.shb.certs.certs.selfsigned.n;
};
}; };
};
in in
{ {
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
@ -91,7 +109,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };

View file

@ -2,59 +2,71 @@
let let
commonTestScript = lib.shb.mkScripts { commonTestScript = lib.shb.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.hledger.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.hledger.ssl);
waitForServices = { ... }: [ waitForServices =
"hledger-web.service" { ... }:
"nginx.service" [
]; "hledger-web.service"
}; "nginx.service"
basic = { config, ... }: {
imports = [
lib.shb.baseModule
../../modules/services/hledger.nix
];
test = {
subdomain = "h";
};
shb.hledger = {
enable = true;
inherit (config.test) subdomain domain;
};
};
clientLogin = { config, ... }: {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
test = {
subdomain = "h";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
testLoginWith = [
{ nextPageExpect = [
"expect(page).to_have_title('journal - hledger-web')"
]; }
]; ];
};
}; };
https = { config, ... }: { basic =
shb.hledger = { { config, ... }:
ssl = config.shb.certs.certs.selfsigned.n; {
}; imports = [
}; lib.shb.baseModule
../../modules/services/hledger.nix
];
sso = { config, ... }: { test = {
shb.hledger = { subdomain = "h";
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; };
shb.hledger = {
enable = true;
inherit (config.test) subdomain domain;
};
};
clientLogin =
{ config, ... }:
{
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
test = {
subdomain = "h";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
testLoginWith = [
{
nextPageExpect = [
"expect(page).to_have_title('journal - hledger-web')"
];
}
];
};
};
https =
{ config, ... }:
{
shb.hledger = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
sso =
{ config, ... }:
{
shb.hledger = {
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
}; };
};
in in
{ {
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
@ -78,14 +90,16 @@ in
backup = lib.shb.runNixOSTest { backup = lib.shb.runNixOSTest {
name = "hledger_backup"; name = "hledger_backup";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
(lib.shb.backup config.shb.hledger.backup) imports = [
]; basic
}; (lib.shb.backup config.shb.hledger.backup)
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.backup; testScript = commonTestScript.backup;
}; };
@ -101,7 +115,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
@ -109,18 +123,20 @@ in
sso = lib.shb.runNixOSTest { sso = lib.shb.runNixOSTest {
name = "hledger_sso"; name = "hledger_sso";
nodes.server = { config, pkgs, ... }: { nodes.server =
imports = [ { config, pkgs, ... }:
basic {
lib.shb.certs imports = [
https basic
lib.shb.ldap lib.shb.certs
(lib.shb.sso config.shb.certs.certs.selfsigned.n) https
sso lib.shb.ldap
]; (lib.shb.sso config.shb.certs.certs.selfsigned.n)
}; sso
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access.override { testScript = commonTestScript.access.override {
redirectSSO = true; redirectSSO = true;

View file

@ -2,89 +2,103 @@
let let
commonTestScript = lib.shb.mkScripts { commonTestScript = lib.shb.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.home-assistant.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.home-assistant.ssl);
waitForServices = { ... }: [ waitForServices =
"home-assistant.service" { ... }:
"nginx.service" [
]; "home-assistant.service"
waitForPorts = { node, ... }: [ "nginx.service"
8123 ];
]; waitForPorts =
}; { node, ... }:
[
basic = { config, ... }: { 8123
imports = [
lib.shb.baseModule
../../modules/services/home-assistant.nix
];
test = {
subdomain = "ha";
};
shb.home-assistant = {
enable = true;
inherit (config.test) subdomain domain;
config = {
name = "Tiserbox";
country = "CH";
latitude = "01.0000000000";
longitude.source = pkgs.writeText "longitude" "01.0000000000";
time_zone = "Europe/Zurich";
unit_system = "metric";
};
};
};
clientLogin = { config, ... }: {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "ha";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
testLoginWith = [
{ nextPageExpect = [
"page.get_by_role('button', name=re.compile('Create my smart home')).click()"
"expect(page.get_by_text('Create user')).to_be_visible()"
"page.get_by_label(re.compile('Name')).fill('Admin')"
"page.get_by_label(re.compile('Username')).fill('admin')"
"page.get_by_label(re.compile('Password')).fill('adminpassword')"
"page.get_by_label(re.compile('Confirm password')).fill('adminpassword')"
"page.get_by_role('button', name=re.compile('Create account')).click()"
"expect(page.get_by_text('All set!')).to_be_visible()"
"page.get_by_role('button', name=re.compile('Finish')).click()"
"expect(page).to_have_title(re.compile('Overview'), timeout=15000)"
]; }
]; ];
};
}; };
https = { config, ...}: { basic =
shb.home-assistant = { { config, ... }:
ssl = config.shb.certs.certs.selfsigned.n; {
}; imports = [
}; lib.shb.baseModule
../../modules/services/home-assistant.nix
];
ldap = { config, ... }: { test = {
shb.home-assistant = { subdomain = "ha";
ldap = { };
shb.home-assistant = {
enable = true; enable = true;
host = "127.0.0.1"; inherit (config.test) subdomain domain;
port = config.shb.lldap.webUIListenPort;
userGroup = "homeassistant_user"; config = {
name = "Tiserbox";
country = "CH";
latitude = "01.0000000000";
longitude.source = pkgs.writeText "longitude" "01.0000000000";
time_zone = "Europe/Zurich";
unit_system = "metric";
};
};
};
clientLogin =
{ config, ... }:
{
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "ha";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
testLoginWith = [
{
nextPageExpect = [
"page.get_by_role('button', name=re.compile('Create my smart home')).click()"
"expect(page.get_by_text('Create user')).to_be_visible()"
"page.get_by_label(re.compile('Name')).fill('Admin')"
"page.get_by_label(re.compile('Username')).fill('admin')"
"page.get_by_label(re.compile('Password')).fill('adminpassword')"
"page.get_by_label(re.compile('Confirm password')).fill('adminpassword')"
"page.get_by_role('button', name=re.compile('Create account')).click()"
"expect(page.get_by_text('All set!')).to_be_visible()"
"page.get_by_role('button', name=re.compile('Finish')).click()"
"expect(page).to_have_title(re.compile('Overview'), timeout=15000)"
];
}
];
};
};
https =
{ config, ... }:
{
shb.home-assistant = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
ldap =
{ config, ... }:
{
shb.home-assistant = {
ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.webUIListenPort;
userGroup = "homeassistant_user";
};
}; };
}; };
};
# Not yet supported # Not yet supported
# #
@ -95,58 +109,60 @@ let
# }; # };
# }; # };
voice = { config, ... }: { voice =
# For now, verifying the packages can build is good enough. { config, ... }:
environment.systemPackages = [ {
config.services.wyoming.piper.package # For now, verifying the packages can build is good enough.
config.services.wyoming.openwakeword.package environment.systemPackages = [
config.services.wyoming.faster-whisper.package config.services.wyoming.piper.package
]; config.services.wyoming.openwakeword.package
config.services.wyoming.faster-whisper.package
];
# TODO: enable this back. The issue id the services cannot talk to the internet # TODO: enable this back. The issue id the services cannot talk to the internet
# to download the models so they fail to start.. # to download the models so they fail to start..
# shb.home-assistant.voice.text-to-speech = { # shb.home-assistant.voice.text-to-speech = {
# "fr" = { # "fr" = {
# enable = true; # enable = true;
# voice = "fr-siwis-medium"; # voice = "fr-siwis-medium";
# uri = "tcp://0.0.0.0:10200"; # uri = "tcp://0.0.0.0:10200";
# speaker = 0; # speaker = 0;
# }; # };
# "en" = { # "en" = {
# enable = true; # enable = true;
# voice = "en_GB-alba-medium"; # voice = "en_GB-alba-medium";
# uri = "tcp://0.0.0.0:10201"; # uri = "tcp://0.0.0.0:10201";
# speaker = 0; # speaker = 0;
# }; # };
# }; # };
# shb.home-assistant.voice.speech-to-text = { # shb.home-assistant.voice.speech-to-text = {
# "tiny-fr" = { # "tiny-fr" = {
# enable = true; # enable = true;
# model = "base-int8"; # model = "base-int8";
# language = "fr"; # language = "fr";
# uri = "tcp://0.0.0.0:10300"; # uri = "tcp://0.0.0.0:10300";
# device = "cpu"; # device = "cpu";
# }; # };
# "tiny-en" = { # "tiny-en" = {
# enable = true; # enable = true;
# model = "base-int8"; # model = "base-int8";
# language = "en"; # language = "en";
# uri = "tcp://0.0.0.0:10301"; # uri = "tcp://0.0.0.0:10301";
# device = "cpu"; # device = "cpu";
# }; # };
# }; # };
# shb.home-assistant.voice.wakeword = { # shb.home-assistant.voice.wakeword = {
# enable = true; # enable = true;
# uri = "tcp://127.0.0.1:10400"; # uri = "tcp://127.0.0.1:10400";
# preloadModels = [ # preloadModels = [
# "alexa" # "alexa"
# "hey_jarvis" # "hey_jarvis"
# "hey_mycroft" # "hey_mycroft"
# "hey_rhasspy" # "hey_rhasspy"
# "ok_nabu" # "ok_nabu"
# ]; # ];
# }; # };
}; };
in in
{ {
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
@ -169,14 +185,16 @@ in
backup = lib.shb.runNixOSTest { backup = lib.shb.runNixOSTest {
name = "homeassistant_backup"; name = "homeassistant_backup";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
(lib.shb.backup config.shb.home-assistant.backup) imports = [
]; basic
}; (lib.shb.backup config.shb.home-assistant.backup)
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.backup; testScript = commonTestScript.backup;
}; };
@ -192,24 +210,24 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
ldap = lib.shb.runNixOSTest { ldap = lib.shb.runNixOSTest {
name = "homeassistant_ldap"; name = "homeassistant_ldap";
nodes.server = { nodes.server = {
imports = [ imports = [
basic basic
lib.shb.ldap lib.shb.ldap
ldap ldap
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
@ -218,7 +236,7 @@ in
# sso = lib.shb.runNixOSTest { # sso = lib.shb.runNixOSTest {
# name = "vaultwarden_sso"; # name = "vaultwarden_sso";
# #
# nodes.server = lib.mkMerge [ # nodes.server = lib.mkMerge [
# basic # basic
# (lib.shb.certs domain) # (lib.shb.certs domain)
# https # https
@ -235,16 +253,16 @@ in
voice = lib.shb.runNixOSTest { voice = lib.shb.runNixOSTest {
name = "homeassistant_voice"; name = "homeassistant_voice";
nodes.server = { nodes.server = {
imports = [ imports = [
basic basic
voice voice
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
} }

View file

@ -5,117 +5,138 @@ let
commonTestScript = lib.shb.accessScript { commonTestScript = lib.shb.accessScript {
hasSSL = { node, ... }: !(isNull node.config.shb.immich.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.immich.ssl);
waitForServices = { ... }: [ "immich-server.service" "postgresql.service" "nginx.service" ]; waitForServices =
waitForPorts = { ... }: [ 2283 80 ]; { ... }:
[
"immich-server.service"
"postgresql.service"
"nginx.service"
];
waitForPorts =
{ ... }:
[
2283
80
];
waitForUrls = { proto_fqdn, ... }: [ "${proto_fqdn}" ]; waitForUrls = { proto_fqdn, ... }: [ "${proto_fqdn}" ];
}; };
base = { config, ... }: { base =
imports = [ { config, ... }:
lib.shb.baseModule {
../../modules/services/immich.nix imports = [
]; lib.shb.baseModule
../../modules/services/immich.nix
];
virtualisation.memorySize = 4096; virtualisation.memorySize = 4096;
virtualisation.cores = 2; virtualisation.cores = 2;
test = { test = {
inherit subdomain domain; inherit subdomain domain;
};
shb.immich = {
enable = true;
inherit subdomain domain;
debug = true;
};
# Required for tests
environment.systemPackages = [ pkgs.curl ];
}; };
shb.immich = { basic =
enable = true; { config, ... }:
inherit subdomain domain; {
imports = [ base ];
debug = true; test.hasSSL = false;
}; };
# Required for tests https =
environment.systemPackages = [ pkgs.curl ]; { config, ... }:
}; {
imports = [
base
lib.shb.certs
];
basic = { config, ... }: { test.hasSSL = true;
imports = [ base ]; shb.immich.ssl = config.shb.certs.certs.selfsigned.n;
test.hasSSL = false;
};
https = { config, ... }: {
imports = [
base
lib.shb.certs
];
test.hasSSL = true;
shb.immich.ssl = config.shb.certs.certs.selfsigned.n;
};
backup = { config, ... }: {
imports = [
https
(lib.shb.backup config.shb.immich.backup)
];
};
sso = { config, ... }: {
imports = [
https
lib.shb.ldap
(lib.shb.sso config.shb.certs.certs.selfsigned.n)
];
shb.immich.sso = {
enable = true;
provider = "Authelia";
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "immich";
autoLaunch = true;
sharedSecret.result = config.shb.hardcodedsecret.immichSSOSecret.result;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.immichSSOSecretAuthelia.result;
}; };
shb.hardcodedsecret.immichSSOSecret = { backup =
request = config.shb.immich.sso.sharedSecret.request; { config, ... }:
settings.content = "immichSSOSecret"; {
imports = [
https
(lib.shb.backup config.shb.immich.backup)
];
}; };
shb.hardcodedsecret.immichSSOSecretAuthelia = { sso =
request = config.shb.immich.sso.sharedSecretForAuthelia.request; { config, ... }:
settings.content = "immichSSOSecret"; {
}; imports = [
https
lib.shb.ldap
(lib.shb.sso config.shb.certs.certs.selfsigned.n)
];
# Configure LDAP groups for group-based access control shb.immich.sso = {
shb.lldap.ensureGroups.immich_user = {}; enable = true;
provider = "Authelia";
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "immich";
autoLaunch = true;
sharedSecret.result = config.shb.hardcodedsecret.immichSSOSecret.result;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.immichSSOSecretAuthelia.result;
};
shb.lldap.ensureUsers.immich_test_user = { shb.hardcodedsecret.immichSSOSecret = {
email = "immich_user@example.com"; request = config.shb.immich.sso.sharedSecret.request;
groups = [ "immich_user" ]; settings.content = "immichSSOSecret";
password.result = config.shb.hardcodedsecret.ldapImmichUserPassword.result; };
};
shb.lldap.ensureUsers.regular_test_user = { shb.hardcodedsecret.immichSSOSecretAuthelia = {
email = "regular_user@example.com"; request = config.shb.immich.sso.sharedSecretForAuthelia.request;
groups = [ ]; settings.content = "immichSSOSecret";
password.result = config.shb.hardcodedsecret.ldapRegularUserPassword.result; };
};
shb.hardcodedsecret.ldapImmichUserPassword = { # Configure LDAP groups for group-based access control
request = config.shb.lldap.ensureUsers.immich_test_user.password.request; shb.lldap.ensureGroups.immich_user = { };
settings.content = "immich_user_password";
};
shb.hardcodedsecret.ldapRegularUserPassword = { shb.lldap.ensureUsers.immich_test_user = {
request = config.shb.lldap.ensureUsers.regular_test_user.password.request; email = "immich_user@example.com";
settings.content = "regular_user_password"; groups = [ "immich_user" ];
password.result = config.shb.hardcodedsecret.ldapImmichUserPassword.result;
};
shb.lldap.ensureUsers.regular_test_user = {
email = "regular_user@example.com";
groups = [ ];
password.result = config.shb.hardcodedsecret.ldapRegularUserPassword.result;
};
shb.hardcodedsecret.ldapImmichUserPassword = {
request = config.shb.lldap.ensureUsers.immich_test_user.password.request;
settings.content = "immich_user_password";
};
shb.hardcodedsecret.ldapRegularUserPassword = {
request = config.shb.lldap.ensureUsers.regular_test_user.password.request;
settings.content = "regular_user_password";
};
}; };
};
in in
{ {
basic = pkgs.nixosTest { basic = pkgs.nixosTest {
name = "immich-basic"; name = "immich-basic";
nodes.server = basic; nodes.server = basic;
nodes.client = {}; nodes.client = { };
testScript = commonTestScript; testScript = commonTestScript;
}; };
@ -124,7 +145,7 @@ in
name = "immich-https"; name = "immich-https";
nodes.server = https; nodes.server = https;
nodes.client = {}; nodes.client = { };
testScript = commonTestScript; testScript = commonTestScript;
}; };
@ -133,13 +154,21 @@ in
name = "immich-backup"; name = "immich-backup";
nodes.server = backup; nodes.server = backup;
nodes.client = {}; nodes.client = { };
testScript = (lib.shb.mkScripts { testScript =
hasSSL = args: !(isNull args.node.config.shb.immich.ssl); (lib.shb.mkScripts {
waitForServices = args: [ "immich-server.service" "postgresql.service" "nginx.service" ]; hasSSL = args: !(isNull args.node.config.shb.immich.ssl);
waitForPorts = args: [ 2283 80 ]; waitForServices = args: [
waitForUrls = args: [ "${args.proto_fqdn}" ]; "immich-server.service"
}).backup; "postgresql.service"
"nginx.service"
];
waitForPorts = args: [
2283
80
];
waitForUrls = args: [ "${args.proto_fqdn}" ];
}).backup;
}; };
} }

View file

@ -4,164 +4,185 @@ let
commonTestScript = lib.shb.mkScripts { commonTestScript = lib.shb.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.jellyfin.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.jellyfin.ssl);
waitForServices = { ... }: [ waitForServices =
"jellyfin.service" { ... }:
"nginx.service" [
]; "jellyfin.service"
waitForPorts = { node, ... }: [ "nginx.service"
port
];
waitForUrls = { proto_fqdn, ... }: [
"${proto_fqdn}/System/Info/Public"
];
extraScript = { node, ... }: ''
headers = unline_with(" ", """
-H 'Content-Type: application/json'
-H 'Authorization: MediaBrowser Client="Android TV", Device="Nvidia Shield", DeviceId="ZQ9YQHHrUzk24vV", Version="0.15.3"'
""")
with subtest("api login success"):
response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName",
data="""{"Username": "jellyfin", "Pw": "admin"}""",
extra=headers)
if response['code'] != 200:
raise Exception(f"Expected success, got: {response['code']}")
with subtest("api login failure"):
response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName",
data="""{"Username": "jellyfin", "Pw": "badpassword"}""",
extra=headers)
if response['code'] != 401:
raise Exception(f"Expected failure, got: {response['code']}")
'';
};
basic = { config, ... }: {
imports = [
lib.shb.baseModule
../../modules/services/jellyfin.nix
];
test = {
subdomain = "j";
};
shb.jellyfin = {
enable = true;
inherit (config.test) subdomain domain;
inherit port;
admin = {
username = "jellyfin";
password.result = config.shb.hardcodedsecret.jellyfinAdminPassword.result;
};
debug = true;
};
shb.hardcodedsecret.jellyfinAdminPassword = {
request = config.shb.jellyfin.admin.password.request;
settings.content = "admin";
};
environment.systemPackages = [
pkgs.sqlite
];
};
clientLogin = { config, ... }: {
imports = [
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
test.login = {
browser = "firefox";
# I tried without the path part but it randomly selects either the wizard
# or the page that selects a server.
# startUrl = "${config.test.proto}://${config.test.fqdn}/web/#/wizardstart.html";
# startUrl = "${config.test.proto}://${config.test.fqdn}";
startUrl = "${config.test.proto}://${config.test.fqdn}/web/#/login.html";
usernameFieldLabelRegex = "[Uu]ser";
loginButtonNameRegex = "Sign In";
testLoginWith = [
# I just couldn't make this work. It's very flaky.
# Most of the time, the login jellyfin page doesn't even load
# and the playwright browser is stuck on the splash page.
# I resorted to test the API directly.
# { username = "jellyfin"; password = "badpassword"; nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
# "expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=30000)"
# ]; }
# { username = "jellyfin"; password = "admin"; nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
# "expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=30000)"
# "expect(page.get_by_role('label', re.compile('[Uu]ser'))).not_to_be_visible(timeout=30000)"
# "expect(page.get_by_text(re.compile('[Pp]assword'))).not_to_be_visible(timeout=30000)"
# ]; }
]; ];
}; waitForPorts =
{ node, ... }:
[
port
];
waitForUrls =
{ proto_fqdn, ... }:
[
"${proto_fqdn}/System/Info/Public"
];
extraScript =
{ node, ... }:
''
headers = unline_with(" ", """
-H 'Content-Type: application/json'
-H 'Authorization: MediaBrowser Client="Android TV", Device="Nvidia Shield", DeviceId="ZQ9YQHHrUzk24vV", Version="0.15.3"'
""")
with subtest("api login success"):
response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName",
data="""{"Username": "jellyfin", "Pw": "admin"}""",
extra=headers)
if response['code'] != 200:
raise Exception(f"Expected success, got: {response['code']}")
with subtest("api login failure"):
response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName",
data="""{"Username": "jellyfin", "Pw": "badpassword"}""",
extra=headers)
if response['code'] != 401:
raise Exception(f"Expected failure, got: {response['code']}")
'';
}; };
https = { config, ... }: { basic =
shb.jellyfin = { { config, ... }:
ssl = config.shb.certs.certs.selfsigned.n; {
}; imports = [
test = { lib.shb.baseModule
hasSSL = true; ../../modules/services/jellyfin.nix
}; ];
}; test = {
subdomain = "j";
ldap = { config, ... }: {
shb.jellyfin = {
ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
adminPassword.result = config.shb.hardcodedsecret.jellyfinLdapUserPassword.result;
}; };
};
shb.hardcodedsecret.jellyfinLdapUserPassword = { shb.jellyfin = {
request = config.shb.jellyfin.ldap.adminPassword.request;
settings.content = "ldapUserPassword";
};
};
sso = { config, ... }: {
shb.jellyfin = {
sso = {
enable = true; enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; inherit (config.test) subdomain domain;
sharedSecret.result = config.shb.hardcodedsecret.jellyfinSSOPassword.result; inherit port;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.jellyfinSSOPasswordAuthelia.result; admin = {
username = "jellyfin";
password.result = config.shb.hardcodedsecret.jellyfinAdminPassword.result;
};
debug = true;
}; };
};
shb.hardcodedsecret.jellyfinSSOPassword = { shb.hardcodedsecret.jellyfinAdminPassword = {
request = config.shb.jellyfin.sso.sharedSecret.request; request = config.shb.jellyfin.admin.password.request;
settings.content = "ssoPassword"; settings.content = "admin";
}; };
shb.hardcodedsecret.jellyfinSSOPasswordAuthelia = {
request = config.shb.jellyfin.sso.sharedSecretForAuthelia.request;
settings.content = "ssoPassword";
};
};
jellyfinTest = name: { nodes, testScript }: lib.shb.runNixOSTest {
name = "jellyfin_${name}";
interactive.nodes.server = {
environment.systemPackages = [ environment.systemPackages = [
pkgs.sqlite pkgs.sqlite
]; ];
}; };
inherit nodes; clientLogin =
inherit testScript; { config, ... }:
}; {
imports = [
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
test.login = {
browser = "firefox";
# I tried without the path part but it randomly selects either the wizard
# or the page that selects a server.
# startUrl = "${config.test.proto}://${config.test.fqdn}/web/#/wizardstart.html";
# startUrl = "${config.test.proto}://${config.test.fqdn}";
startUrl = "${config.test.proto}://${config.test.fqdn}/web/#/login.html";
usernameFieldLabelRegex = "[Uu]ser";
loginButtonNameRegex = "Sign In";
testLoginWith = [
# I just couldn't make this work. It's very flaky.
# Most of the time, the login jellyfin page doesn't even load
# and the playwright browser is stuck on the splash page.
# I resorted to test the API directly.
# { username = "jellyfin"; password = "badpassword"; nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
# "expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=30000)"
# ]; }
# { username = "jellyfin"; password = "admin"; nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
# "expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=30000)"
# "expect(page.get_by_role('label', re.compile('[Uu]ser'))).not_to_be_visible(timeout=30000)"
# "expect(page.get_by_text(re.compile('[Pp]assword'))).not_to_be_visible(timeout=30000)"
# ]; }
];
};
};
https =
{ config, ... }:
{
shb.jellyfin = {
ssl = config.shb.certs.certs.selfsigned.n;
};
test = {
hasSSL = true;
};
};
ldap =
{ config, ... }:
{
shb.jellyfin = {
ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
adminPassword.result = config.shb.hardcodedsecret.jellyfinLdapUserPassword.result;
};
};
shb.hardcodedsecret.jellyfinLdapUserPassword = {
request = config.shb.jellyfin.ldap.adminPassword.request;
settings.content = "ldapUserPassword";
};
};
sso =
{ config, ... }:
{
shb.jellyfin = {
sso = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
sharedSecret.result = config.shb.hardcodedsecret.jellyfinSSOPassword.result;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.jellyfinSSOPasswordAuthelia.result;
};
};
shb.hardcodedsecret.jellyfinSSOPassword = {
request = config.shb.jellyfin.sso.sharedSecret.request;
settings.content = "ssoPassword";
};
shb.hardcodedsecret.jellyfinSSOPasswordAuthelia = {
request = config.shb.jellyfin.sso.sharedSecretForAuthelia.request;
settings.content = "ssoPassword";
};
};
jellyfinTest =
name:
{ nodes, testScript }:
lib.shb.runNixOSTest {
name = "jellyfin_${name}";
interactive.nodes.server = {
environment.systemPackages = [
pkgs.sqlite
];
};
inherit nodes;
inherit testScript;
};
in in
{ {
basic = jellyfinTest "basic" { basic = jellyfinTest "basic" {
@ -174,20 +195,22 @@ in
# Client login does not work without SSL. # Client login does not work without SSL.
# At least, I couldn't make it work. # At least, I couldn't make it work.
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
backup = jellyfinTest "backup" { backup = jellyfinTest "backup" {
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
(lib.shb.backup config.shb.jellyfin.backup) imports = [
]; basic
}; (lib.shb.backup config.shb.jellyfin.backup)
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.backup; testScript = commonTestScript.backup;
}; };
@ -201,12 +224,14 @@ in
]; ];
}; };
nodes.client = { config, lib, ... }: { nodes.client =
imports = [ { config, lib, ... }:
lib.shb.baseModule {
clientLogin imports = [
]; lib.shb.baseModule
}; clientLogin
];
};
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
@ -219,25 +244,27 @@ in
ldap ldap
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
sso = jellyfinTest "sso" { sso = jellyfinTest "sso" {
nodes.server = { config, pkgs, ... }: { nodes.server =
imports = [ { config, pkgs, ... }:
basic {
lib.shb.certs imports = [
https basic
lib.shb.ldap lib.shb.certs
(lib.shb.sso config.shb.certs.certs.selfsigned.n) https
sso lib.shb.ldap
]; (lib.shb.sso config.shb.certs.certs.selfsigned.n)
}; sso
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };

View file

@ -3,144 +3,182 @@ let
nextauthSecret = "nextauthSecret"; nextauthSecret = "nextauthSecret";
oidcSecret = "oidcSecret"; oidcSecret = "oidcSecret";
testLib = pkgs.callPackage ../common.nix {}; testLib = pkgs.callPackage ../common.nix { };
commonTestScript = testLib.mkScripts { commonTestScript = testLib.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.karakeep.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.karakeep.ssl);
waitForServices = { ... }: [ waitForServices =
"karakeep-init.service" { ... }:
"karakeep-browser.service" [
"karakeep-web.service" "karakeep-init.service"
"karakeep-workers.service" "karakeep-browser.service"
"nginx.service" "karakeep-web.service"
]; "karakeep-workers.service"
waitForPorts = { node, ... }: [ "nginx.service"
node.config.shb.karakeep.port ];
]; waitForPorts =
}; { node, ... }:
[
basic = { config, ... }: { node.config.shb.karakeep.port
imports = [
testLib.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/blocks/lldap.nix
../../modules/services/karakeep.nix
];
test = {
subdomain = "k";
};
shb.karakeep = {
enable = true;
inherit (config.test) subdomain domain;
nextauthSecret.result = config.shb.hardcodedsecret.nextauthSecret.result;
meilisearchMasterKey.result = config.shb.hardcodedsecret.meilisearchMasterKey.result;
};
shb.hardcodedsecret.nextauthSecret = {
request = config.shb.karakeep.nextauthSecret.request;
settings.content = nextauthSecret;
};
shb.hardcodedsecret.meilisearchMasterKey = {
request = config.shb.karakeep.meilisearchMasterKey.request;
settings.content = "meilisearch-master-key";
};
networking.hosts = {
"127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ];
};
};
https = { config, ... }: {
shb.karakeep = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
ldap = { config, ... }: {
shb.karakeep = {
ldap = {
userGroup = "user_group";
};
};
};
clientLoginSso = { config, ... }: {
imports = [
testLib.baseModule
testLib.clientLoginModule
];
test = {
subdomain = "k";
};
test.login = {
startUrl = "https://${config.test.fqdn}";
beforeHook = ''
page.get_by_role("button", name="single sign-on").click()
'';
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{ username = "alice"; password = "NotAlicePassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)"
]; }
{ username = "alice"; password = "AlicePassword"; nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('new item')).to_be_visible()"
]; }
{ username = "bob"; password = "NotBobPassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)"
]; }
{ username = "bob"; password = "BobPassword"; nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('new item')).to_be_visible()"
]; }
{ username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)"
]; }
{ username = "charlie"; password = "CharliePassword"; nextPageExpect = [
# "page.get_by_role('button', name=re.compile('Accept')).click()" # I don't understand why this is not needed. Maybe it keeps somewhere the previous token?
"expect(page.get_by_text(re.compile('login failed'))).to_be_visible(timeout=10000)"
]; }
]; ];
};
}; };
sso = { config, ... }: { basic =
shb.karakeep = { { config, ... }:
sso = { {
enable = true; imports = [
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; testLib.baseModule
clientID = "karakeep"; ../../modules/blocks/hardcodedsecret.nix
../../modules/blocks/lldap.nix
../../modules/services/karakeep.nix
];
sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result; test = {
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result; subdomain = "k";
};
shb.karakeep = {
enable = true;
inherit (config.test) subdomain domain;
nextauthSecret.result = config.shb.hardcodedsecret.nextauthSecret.result;
meilisearchMasterKey.result = config.shb.hardcodedsecret.meilisearchMasterKey.result;
};
shb.hardcodedsecret.nextauthSecret = {
request = config.shb.karakeep.nextauthSecret.request;
settings.content = nextauthSecret;
};
shb.hardcodedsecret.meilisearchMasterKey = {
request = config.shb.karakeep.meilisearchMasterKey.request;
settings.content = "meilisearch-master-key";
};
networking.hosts = {
"127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ];
}; };
}; };
shb.hardcodedsecret.oidcSecret = { https =
request = config.shb.karakeep.sso.sharedSecret.request; { config, ... }:
settings.content = oidcSecret; {
shb.karakeep = {
ssl = config.shb.certs.certs.selfsigned.n;
};
}; };
shb.hardcodedsecret.oidcAutheliaSecret = {
request = config.shb.karakeep.sso.sharedSecretForAuthelia.request; ldap =
settings.content = oidcSecret; { config, ... }:
{
shb.karakeep = {
ldap = {
userGroup = "user_group";
};
};
};
clientLoginSso =
{ config, ... }:
{
imports = [
testLib.baseModule
testLib.clientLoginModule
];
test = {
subdomain = "k";
};
test.login = {
startUrl = "https://${config.test.fqdn}";
beforeHook = ''
page.get_by_role("button", name="single sign-on").click()
'';
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)"
];
}
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('new item')).to_be_visible()"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('new item')).to_be_visible()"
];
}
{
username = "charlie";
password = "NotCharliePassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)"
];
}
{
username = "charlie";
password = "CharliePassword";
nextPageExpect = [
# "page.get_by_role('button', name=re.compile('Accept')).click()" # I don't understand why this is not needed. Maybe it keeps somewhere the previous token?
"expect(page.get_by_text(re.compile('login failed'))).to_be_visible(timeout=10000)"
];
}
];
};
};
sso =
{ config, ... }:
{
shb.karakeep = {
sso = {
enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "karakeep";
sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result;
};
};
shb.hardcodedsecret.oidcSecret = {
request = config.shb.karakeep.sso.sharedSecret.request;
settings.content = oidcSecret;
};
shb.hardcodedsecret.oidcAutheliaSecret = {
request = config.shb.karakeep.sso.sharedSecretForAuthelia.request;
settings.content = oidcSecret;
};
}; };
};
in in
{ {
basic = pkgs.testers.runNixOSTest { basic = pkgs.testers.runNixOSTest {
name = "karakeep_basic"; name = "karakeep_basic";
nodes.client = {}; nodes.client = { };
nodes.server = { nodes.server = {
imports = [ imports = [
basic basic
@ -153,14 +191,16 @@ in
backup = pkgs.testers.runNixOSTest { backup = pkgs.testers.runNixOSTest {
name = "karakeep_backup"; name = "karakeep_backup";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
(testLib.backup config.shb.karakeep.backup) imports = [
]; basic
}; (testLib.backup config.shb.karakeep.backup)
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.backup; testScript = commonTestScript.backup;
}; };
@ -168,7 +208,7 @@ in
https = pkgs.testers.runNixOSTest { https = pkgs.testers.runNixOSTest {
name = "karakeep_https"; name = "karakeep_https";
nodes.client = {}; nodes.client = { };
nodes.server = { nodes.server = {
imports = [ imports = [
basic basic
@ -191,19 +231,21 @@ in
virtualisation.memorySize = 4096; virtualisation.memorySize = 4096;
}; };
nodes.server = { config, pkgs, ... }: { nodes.server =
imports = [ { config, pkgs, ... }:
basic {
testLib.certs imports = [
https basic
testLib.ldap testLib.certs
ldap https
(testLib.sso config.shb.certs.certs.selfsigned.n) testLib.ldap
sso ldap
]; (testLib.sso config.shb.certs.certs.selfsigned.n)
sso
];
virtualisation.memorySize = 4096; virtualisation.memorySize = 4096;
}; };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };

View file

@ -4,43 +4,51 @@ let
commonTestScript = lib.shb.accessScript { commonTestScript = lib.shb.accessScript {
hasSSL = { node, ... }: !(isNull node.config.shb.monitoring.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.monitoring.ssl);
waitForServices = { ... }: [ waitForServices =
"grafana.service" { ... }:
]; [
waitForPorts = { node, ... }: [ "grafana.service"
node.config.shb.monitoring.grafanaPort ];
]; waitForPorts =
{ node, ... }:
[
node.config.shb.monitoring.grafanaPort
];
}; };
basic = { config, ... }: { basic =
test = { { config, ... }:
subdomain = "g"; {
test = {
subdomain = "g";
};
shb.monitoring = {
enable = true;
inherit (config.test) subdomain domain;
grafanaPort = 3000;
adminPassword.result = config.shb.hardcodedsecret."admin_password".result;
secretKey.result = config.shb.hardcodedsecret."secret_key".result;
};
shb.hardcodedsecret."admin_password" = {
request = config.shb.monitoring.adminPassword.request;
settings.content = password;
};
shb.hardcodedsecret."secret_key" = {
request = config.shb.monitoring.secretKey.request;
settings.content = "secret_key_pw";
};
}; };
shb.monitoring = { https =
enable = true; { config, ... }:
inherit (config.test) subdomain domain; {
shb.monitoring = {
grafanaPort = 3000; ssl = config.shb.certs.certs.selfsigned.n;
adminPassword.result = config.shb.hardcodedsecret."admin_password".result; };
secretKey.result = config.shb.hardcodedsecret."secret_key".result;
}; };
shb.hardcodedsecret."admin_password" = {
request = config.shb.monitoring.adminPassword.request;
settings.content = password;
};
shb.hardcodedsecret."secret_key" = {
request = config.shb.monitoring.secretKey.request;
settings.content = "secret_key_pw";
};
};
https = { config, ...}: {
shb.monitoring = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
in in
{ {
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
@ -54,7 +62,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript; testScript = commonTestScript;
}; };
@ -72,7 +80,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript; testScript = commonTestScript;
}; };

View file

@ -6,267 +6,339 @@ let
commonTestScript = lib.shb.mkScripts { commonTestScript = lib.shb.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.nextcloud.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.nextcloud.ssl);
waitForServices = { ... }: [ waitForServices =
"phpfpm-nextcloud.service" { ... }:
"nginx.service" [
]; "phpfpm-nextcloud.service"
waitForUnixSocket = { node, ... }: [ "nginx.service"
node.config.services.phpfpm.pools.nextcloud.socket
];
extraScript = { node, fqdn, proto_fqdn, ... }: ''
with subtest("fails with incorrect authentication"):
client.fail(
"curl -f -s --location -X PROPFIND"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:other """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ " ${proto_fqdn}/remote.php/dav/files/${adminUser}/"
)
client.fail(
"curl -f -s --location -X PROPFIND"
+ """ -H "Depth: 1" """
+ """ -u root:rootpw """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ " ${proto_fqdn}/remote.php/dav/files/other/"
)
with subtest("fails with incorrect path"):
client.fail(
"curl -f -s --location -X PROPFIND"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:${adminPass} """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ " ${proto_fqdn}/remote.php/dav/files/other/"
)
with subtest("can access webdav"):
client.succeed(
"curl -f -s --location -X PROPFIND"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:${adminPass} """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ " ${proto_fqdn}/remote.php/dav/files/${adminUser}/"
)
with subtest("can create and retrieve file"):
client.fail(
"curl -f -s --location -X GET"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:${adminPass} """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ """ -T file """
+ " ${proto_fqdn}/remote.php/dav/files/${adminUser}/file"
)
client.succeed("echo 'hello' > file")
client.succeed(
"curl -f -s --location -X PUT"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:${adminPass} """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ """ -T file """
+ " ${proto_fqdn}/remote.php/dav/files/${adminUser}/"
)
content = client.succeed(
"curl -f -s --location -X GET"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:${adminPass} """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ """ -T file """
+ " ${proto_fqdn}/remote.php/dav/files/${adminUser}/file"
)
if content != "hello\n":
raise Exception("Got incorrect content for file, expected 'hello\n' but got:\n{}".format(content))
'';
};
basic = { config, ... }: {
imports = [
lib.shb.baseModule
../../modules/services/nextcloud-server.nix
];
test = {
subdomain = "n";
};
shb.nextcloud = {
enable = true;
inherit (config.test) subdomain domain;
dataDir = "/var/lib/nextcloud";
tracing = null;
defaultPhoneRegion = "US";
# This option is only needed because we do not access Nextcloud at the default port in the VM.
externalFqdn = "${config.test.fqdn}:8080";
adminUser = adminUser;
adminPass.result = config.shb.hardcodedsecret.adminPass.result;
debug = false; # Enable this if needed, but beware it is _very_ verbose.
};
shb.hardcodedsecret.adminPass = {
request = config.shb.nextcloud.adminPass.request;
settings.content = adminPass;
};
};
clientLogin = { config, ... }: {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "n";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "Account name";
passwordFieldLabelRegex = "^ *[Pp]assword";
loginButtonNameRegex = "[Ll]og [Ii]n";
testLoginWith = [
{ username = adminUser; password = adminPass; nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
]; }
# Failure is after so we're not throttled too much.
{ username = adminUser; password = adminPass + "oops"; nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).to_be_visible()"
]; }
]; ];
}; waitForUnixSocket =
}; { node, ... }:
[
clientLdapLogin = { config, ... }: { node.config.services.phpfpm.pools.nextcloud.socket
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "n";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "Account name";
passwordFieldLabelRegex = "^ *[Pp]assword";
loginButtonNameRegex = "[Ll]og [Ii]n";
testLoginWith = [
{ username = "alice"; password = "AlicePassword"; nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
]; }
{ username = "alice"; password = "NotAlicePassword"; nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).to_be_visible()"
]; }
{ username = "bob"; password = "BobPassword"; nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
]; }
{ username = "bob"; password = "NotBobPassword"; nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).to_be_visible()"
]; }
]; ];
}; extraScript =
{
node,
fqdn,
proto_fqdn,
...
}:
''
with subtest("fails with incorrect authentication"):
client.fail(
"curl -f -s --location -X PROPFIND"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:other """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ " ${proto_fqdn}/remote.php/dav/files/${adminUser}/"
)
client.fail(
"curl -f -s --location -X PROPFIND"
+ """ -H "Depth: 1" """
+ """ -u root:rootpw """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ " ${proto_fqdn}/remote.php/dav/files/other/"
)
with subtest("fails with incorrect path"):
client.fail(
"curl -f -s --location -X PROPFIND"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:${adminPass} """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ " ${proto_fqdn}/remote.php/dav/files/other/"
)
with subtest("can access webdav"):
client.succeed(
"curl -f -s --location -X PROPFIND"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:${adminPass} """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ " ${proto_fqdn}/remote.php/dav/files/${adminUser}/"
)
with subtest("can create and retrieve file"):
client.fail(
"curl -f -s --location -X GET"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:${adminPass} """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ """ -T file """
+ " ${proto_fqdn}/remote.php/dav/files/${adminUser}/file"
)
client.succeed("echo 'hello' > file")
client.succeed(
"curl -f -s --location -X PUT"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:${adminPass} """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ """ -T file """
+ " ${proto_fqdn}/remote.php/dav/files/${adminUser}/"
)
content = client.succeed(
"curl -f -s --location -X GET"
+ """ -H "Depth: 1" """
+ """ -u ${adminUser}:${adminPass} """
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
+ """ -T file """
+ " ${proto_fqdn}/remote.php/dav/files/${adminUser}/file"
)
if content != "hello\n":
raise Exception("Got incorrect content for file, expected 'hello\n' but got:\n{}".format(content))
'';
}; };
clientSsoLogin = { config, ... }: { basic =
imports = [ { config, ... }:
lib.shb.baseModule {
lib.shb.clientLoginModule imports = [
]; lib.shb.baseModule
virtualisation.memorySize = 4096; ../../modules/services/nextcloud-server.nix
test = {
subdomain = "n";
};
networking.hosts = {
"192.168.1.2" = [ "auth.example.com" ];
};
test.login = {
startUrl = "http://${config.test.fqdn}";
# No need since Nextcloud is auto-redirecting to the SSO sign in page.
# beforeHook = ''
# page.get_by_role("link", name="Sign in with SHB-Authelia").click()
# '';
usernameFieldLabelRegex = "Username";
passwordFieldSelector = "get_by_label(\"Password *\")";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{ username = "alice"; password = "AlicePassword"; nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page).to_have_title(re.compile('Dashboard'))"
"page.goto('https://${config.test.fqdn}/settings/admin')"
"expect(page.get_by_text('Access forbidden')).to_be_visible()"
]; }
{ username = "alice"; password = "NotAlicePassword"; nextPageExpect = [
"expect(page.get_by_text('Incorrect username or password')).to_be_visible()"
]; }
{ username = "bob"; password = "BobPassword"; nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page).to_have_title(re.compile('Dashboard'))"
"page.goto('https://${config.test.fqdn}/settings/admin')"
"expect(page.get_by_text('Access forbidden')).not_to_be_visible()"
]; }
{ username = "bob"; password = "NotBobPassword"; nextPageExpect = [
"expect(page.get_by_text('Incorrect username or password')).to_be_visible()"
]; }
{ username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [
"expect(page.get_by_text('Incorrect username or password')).to_be_visible()"
]; }
{ username = "charlie"; password = "CharliePassword"; nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text('not member of the allowed groups')).to_be_visible()"
]; }
]; ];
};
};
https = { config, ...}: { test = {
shb.nextcloud = { subdomain = "n";
ssl = config.shb.certs.certs.selfsigned.n; };
externalFqdn = lib.mkForce null; shb.nextcloud = {
};
};
ldap = { config, ... }: {
shb.nextcloud = {
apps.ldap = {
enable = true; enable = true;
host = "127.0.0.1"; inherit (config.test) subdomain domain;
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain; dataDir = "/var/lib/nextcloud";
adminName = "admin"; tracing = null;
adminPassword.result = config.shb.hardcodedsecret.nextcloudLdapUserPassword.result; defaultPhoneRegion = "US";
userGroup = "user_group";
# This option is only needed because we do not access Nextcloud at the default port in the VM.
externalFqdn = "${config.test.fqdn}:8080";
adminUser = adminUser;
adminPass.result = config.shb.hardcodedsecret.adminPass.result;
debug = false; # Enable this if needed, but beware it is _very_ verbose.
};
shb.hardcodedsecret.adminPass = {
request = config.shb.nextcloud.adminPass.request;
settings.content = adminPass;
}; };
}; };
shb.hardcodedsecret.nextcloudLdapUserPassword = {
request = config.shb.nextcloud.apps.ldap.adminPassword.request;
settings = config.shb.hardcodedsecret.ldapUserPassword.settings;
};
};
sso = { config, ... }: clientLogin =
{ config, ... }:
{
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "n";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "Account name";
passwordFieldLabelRegex = "^ *[Pp]assword";
loginButtonNameRegex = "[Ll]og [Ii]n";
testLoginWith = [
{
username = adminUser;
password = adminPass;
nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
];
}
# Failure is after so we're not throttled too much.
{
username = adminUser;
password = adminPass + "oops";
nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).to_be_visible()"
];
}
];
};
};
clientLdapLogin =
{ config, ... }:
{
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "n";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
usernameFieldLabelRegex = "Account name";
passwordFieldLabelRegex = "^ *[Pp]assword";
loginButtonNameRegex = "[Ll]og [Ii]n";
testLoginWith = [
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
];
}
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).to_be_visible()"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()"
"expect(page).to_have_title(re.compile('Dashboard'))"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
"expect(page.get_by_text('Wrong login or password')).to_be_visible()"
];
}
];
};
};
clientSsoLogin =
{ config, ... }:
{
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "n";
};
networking.hosts = {
"192.168.1.2" = [ "auth.example.com" ];
};
test.login = {
startUrl = "http://${config.test.fqdn}";
# No need since Nextcloud is auto-redirecting to the SSO sign in page.
# beforeHook = ''
# page.get_by_role("link", name="Sign in with SHB-Authelia").click()
# '';
usernameFieldLabelRegex = "Username";
passwordFieldSelector = "get_by_label(\"Password *\")";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page).to_have_title(re.compile('Dashboard'))"
"page.goto('https://${config.test.fqdn}/settings/admin')"
"expect(page.get_by_text('Access forbidden')).to_be_visible()"
];
}
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
"expect(page.get_by_text('Incorrect username or password')).to_be_visible()"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page).to_have_title(re.compile('Dashboard'))"
"page.goto('https://${config.test.fqdn}/settings/admin')"
"expect(page.get_by_text('Access forbidden')).not_to_be_visible()"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
"expect(page.get_by_text('Incorrect username or password')).to_be_visible()"
];
}
{
username = "charlie";
password = "NotCharliePassword";
nextPageExpect = [
"expect(page.get_by_text('Incorrect username or password')).to_be_visible()"
];
}
{
username = "charlie";
password = "CharliePassword";
nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text('not member of the allowed groups')).to_be_visible()"
];
}
];
};
};
https =
{ config, ... }:
{
shb.nextcloud = {
ssl = config.shb.certs.certs.selfsigned.n;
externalFqdn = lib.mkForce null;
};
};
ldap =
{ config, ... }:
{
shb.nextcloud = {
apps.ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
adminName = "admin";
adminPassword.result = config.shb.hardcodedsecret.nextcloudLdapUserPassword.result;
userGroup = "user_group";
};
};
shb.hardcodedsecret.nextcloudLdapUserPassword = {
request = config.shb.nextcloud.apps.ldap.adminPassword.request;
settings = config.shb.hardcodedsecret.ldapUserPassword.settings;
};
};
sso =
{ config, ... }:
{ {
shb.nextcloud = { shb.nextcloud = {
apps.ldap = { apps.ldap = {
@ -297,17 +369,19 @@ let
request = config.shb.nextcloud.apps.sso.secretForAuthelia.request; request = config.shb.nextcloud.apps.sso.secretForAuthelia.request;
settings.content = oidcSecret; settings.content = oidcSecret;
}; };
};
previewgenerator = { config, ...}: {
systemd.tmpfiles.rules = [
"d '/srv/nextcloud' 0750 nextcloud nextcloud - -"
];
shb.nextcloud = {
apps.previewgenerator.enable = true;
}; };
};
previewgenerator =
{ config, ... }:
{
systemd.tmpfiles.rules = [
"d '/srv/nextcloud' 0750 nextcloud nextcloud - -"
];
shb.nextcloud = {
apps.previewgenerator.enable = true;
};
};
externalstorage = { externalstorage = {
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
@ -323,43 +397,50 @@ let
}; };
}; };
memories = { config, ...}: { memories =
systemd.tmpfiles.rules = [ { config, ... }:
"d '/srv/nextcloud' 0750 nextcloud nextcloud - -" {
]; systemd.tmpfiles.rules = [
"d '/srv/nextcloud' 0750 nextcloud nextcloud - -"
];
shb.nextcloud = { shb.nextcloud = {
apps.memories.enable = true; apps.memories.enable = true;
apps.memories.vaapi = true; apps.memories.vaapi = true;
};
}; };
};
recognize = { config, ...}: { recognize =
systemd.tmpfiles.rules = [ { config, ... }:
"d '/srv/nextcloud' 0750 nextcloud nextcloud - -" {
]; systemd.tmpfiles.rules = [
"d '/srv/nextcloud' 0750 nextcloud nextcloud - -"
];
shb.nextcloud = { shb.nextcloud = {
apps.recognize.enable = true; apps.recognize.enable = true;
};
}; };
};
prometheus = { config, ... }: { prometheus =
shb.nextcloud = { { config, ... }:
phpFpmPrometheusExporter.enable = true; {
shb.nextcloud = {
phpFpmPrometheusExporter.enable = true;
};
}; };
};
prometheusTestScript = { nodes, ... }: prometheusTestScript =
{ nodes, ... }:
'' ''
server.wait_for_open_unix_socket("${nodes.server.services.phpfpm.pools.nextcloud.socket}") server.wait_for_open_unix_socket("${nodes.server.services.phpfpm.pools.nextcloud.socket}")
server.wait_for_open_port(${toString nodes.server.services.prometheus.exporters.php-fpm.port}) server.wait_for_open_port(${toString nodes.server.services.prometheus.exporters.php-fpm.port})
with subtest("prometheus"): with subtest("prometheus"):
response = server.succeed( response = server.succeed(
"curl -sSf " "curl -sSf "
+ " http://localhost:${toString nodes.server.services.prometheus.exporters.php-fpm.port}/metrics" + " http://localhost:${toString nodes.server.services.prometheus.exporters.php-fpm.port}/metrics"
) )
print(response) print(response)
''; '';
in in
{ {
@ -389,44 +470,53 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access.override { testScript = commonTestScript.access.override {
extraScript = { node, fqdn, proto_fqdn, ... }: '' extraScript =
import time {
node,
fqdn,
proto_fqdn,
...
}:
''
import time
def find_in_logs(unit, text): def find_in_logs(unit, text):
return server.systemctl("status {}".format(unit))[1].find(text) != -1 return server.systemctl("status {}".format(unit))[1].find(text) != -1
with subtest("cron job succeeds"): with subtest("cron job succeeds"):
# This call does not block until the service is done. # This call does not block until the service is done.
server.succeed("systemctl start nextcloud-cron.service&") server.succeed("systemctl start nextcloud-cron.service&")
# If the service failed, then we're not happy. # If the service failed, then we're not happy.
status = "active" status = "active"
while status == "active": while status == "active":
status = server.get_unit_info("nextcloud-cron")["ActiveState"] status = server.get_unit_info("nextcloud-cron")["ActiveState"]
time.sleep(5) time.sleep(5)
if status != "inactive": if status != "inactive":
raise Exception("Cron job did not finish correctly") raise Exception("Cron job did not finish correctly")
if not find_in_logs("nextcloud-cron", "nextcloud-cron.service: Deactivated successfully."): if not find_in_logs("nextcloud-cron", "nextcloud-cron.service: Deactivated successfully."):
raise Exception("Nextcloud cron job did not finish successfully.") raise Exception("Nextcloud cron job did not finish successfully.")
''; '';
}; };
}; };
backup = lib.shb.runNixOSTest { backup = lib.shb.runNixOSTest {
name = "nextcloud_backup"; name = "nextcloud_backup";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
(lib.shb.backup config.shb.nextcloud.backup) imports = [
]; basic
}; (lib.shb.backup config.shb.nextcloud.backup)
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.backup; testScript = commonTestScript.backup;
}; };
@ -442,7 +532,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
# TODO: Test login # TODO: Test login
testScript = commonTestScript.access; testScript = commonTestScript.access;
@ -460,7 +550,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
@ -477,7 +567,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
@ -514,77 +604,89 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
ldap = lib.shb.runNixOSTest { ldap = lib.shb.runNixOSTest {
name = "nextcloud_ldap"; name = "nextcloud_ldap";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
lib.shb.certs imports = [
https basic
lib.shb.ldap lib.shb.certs
ldap https
]; lib.shb.ldap
}; ldap
];
};
nodes.client = { nodes.client = {
imports = [ imports = [
clientLdapLogin clientLdapLogin
]; ];
}; };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
sso = lib.shb.runNixOSTest { sso = lib.shb.runNixOSTest {
name = "nextcloud_sso"; name = "nextcloud_sso";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
lib.shb.certs imports = [
https basic
lib.shb.ldap lib.shb.certs
(lib.shb.sso config.shb.certs.certs.selfsigned.n) https
sso lib.shb.ldap
({ config, ... }: { (lib.shb.sso config.shb.certs.certs.selfsigned.n)
networking.hosts = { sso
"127.0.0.1" = [ config.test.fqdn ]; (
}; { config, ... }:
}) {
]; networking.hosts = {
}; "127.0.0.1" = [ config.test.fqdn ];
};
}
)
];
};
nodes.client = { nodes.client = {
imports = [ imports = [
clientSsoLogin clientSsoLogin
({ config, ... }: { (
networking.hosts = { { config, ... }:
"192.168.1.2" = [ config.test.fqdn ]; {
}; networking.hosts = {
}) "192.168.1.2" = [ config.test.fqdn ];
};
}
)
]; ];
}; };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
prometheus = lib.shb.runNixOSTest { prometheus = lib.shb.runNixOSTest {
name = "nextcloud_prometheus"; name = "nextcloud_prometheus";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
prometheus imports = [
]; basic
}; prometheus
];
};
nodes.client = {}; nodes.client = { };
testScript = prometheusTestScript; testScript = prometheusTestScript;
}; };

View file

@ -4,131 +4,169 @@ let
commonTestScript = lib.shb.mkScripts { commonTestScript = lib.shb.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.open-webui.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.open-webui.ssl);
waitForServices = { ... }: [ waitForServices =
"open-webui.service" { ... }:
"nginx.service" [
]; "open-webui.service"
waitForPorts = { node, ... }: [ "nginx.service"
node.config.shb.open-webui.port ];
]; waitForPorts =
}; { node, ... }:
[
basic = { config, ... }: { node.config.shb.open-webui.port
imports = [
lib.shb.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/open-webui.nix
];
test = {
subdomain = "o";
};
shb.open-webui = {
enable = true;
inherit (config.test) subdomain domain;
};
networking.hosts = {
"127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ];
};
};
https = { config, ... }: {
shb.open-webui = {
ssl = config.shb.certs.certs.selfsigned.n;
};
systemd.services.open-webui.environment = {
# Needed for open-webui to be able to talk to auth server.
SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt";
};
};
ldap = { config, ... }: {
shb.open-webui = {
ldap = {
userGroup = "user_group";
adminGroup = "admin_group";
};
};
};
clientLoginSso = { config, ... }: {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "o";
};
test.login = {
startUrl = "https://${config.test.fqdn}/auth";
beforeHook = ''
page.get_by_role("button", name="continue").click()
'';
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{ username = "alice"; password = "NotAlicePassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
]; }
{ username = "alice"; password = "AlicePassword"; nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('logged in')).to_be_visible()"
]; }
{ username = "bob"; password = "NotBobPassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
]; }
{ username = "bob"; password = "BobPassword"; nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('logged in')).to_be_visible()"
]; }
{ username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
]; }
{ username = "charlie"; password = "CharliePassword"; nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text('unauthorized')).to_be_visible()"
]; }
]; ];
};
}; };
sso = { config, ... }: { basic =
shb.open-webui = { { config, ... }:
sso = { {
enable = true; imports = [
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; lib.shb.baseModule
clientID = "open-webui"; ../../modules/blocks/hardcodedsecret.nix
../../modules/services/open-webui.nix
];
sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result; test = {
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result; subdomain = "o";
};
shb.open-webui = {
enable = true;
inherit (config.test) subdomain domain;
};
networking.hosts = {
"127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ];
}; };
}; };
shb.hardcodedsecret.oidcSecret = { https =
request = config.shb.open-webui.sso.sharedSecret.request; { config, ... }:
settings.content = oidcSecret; {
shb.open-webui = {
ssl = config.shb.certs.certs.selfsigned.n;
};
systemd.services.open-webui.environment = {
# Needed for open-webui to be able to talk to auth server.
SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt";
};
}; };
shb.hardcodedsecret.oidcAutheliaSecret = {
request = config.shb.open-webui.sso.sharedSecretForAuthelia.request; ldap =
settings.content = oidcSecret; { config, ... }:
{
shb.open-webui = {
ldap = {
userGroup = "user_group";
adminGroup = "admin_group";
};
};
};
clientLoginSso =
{ config, ... }:
{
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "o";
};
test.login = {
startUrl = "https://${config.test.fqdn}/auth";
beforeHook = ''
page.get_by_role("button", name="continue").click()
'';
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
];
}
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('logged in')).to_be_visible()"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('logged in')).to_be_visible()"
];
}
{
username = "charlie";
password = "NotCharliePassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
];
}
{
username = "charlie";
password = "CharliePassword";
nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text('unauthorized')).to_be_visible()"
];
}
];
};
};
sso =
{ config, ... }:
{
shb.open-webui = {
sso = {
enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "open-webui";
sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result;
};
};
shb.hardcodedsecret.oidcSecret = {
request = config.shb.open-webui.sso.sharedSecret.request;
settings.content = oidcSecret;
};
shb.hardcodedsecret.oidcAutheliaSecret = {
request = config.shb.open-webui.sso.sharedSecretForAuthelia.request;
settings.content = oidcSecret;
};
}; };
};
in in
{ {
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
name = "open-webui_basic"; name = "open-webui_basic";
nodes.client = {}; nodes.client = { };
nodes.server = { nodes.server = {
imports = [ imports = [
basic basic
@ -141,14 +179,16 @@ in
backup = lib.shb.runNixOSTest { backup = lib.shb.runNixOSTest {
name = "open-webui_backup"; name = "open-webui_backup";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
(lib.shb.backup config.shb.open-webui.backup) imports = [
]; basic
}; (lib.shb.backup config.shb.open-webui.backup)
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.backup; testScript = commonTestScript.backup;
}; };
@ -156,7 +196,7 @@ in
https = lib.shb.runNixOSTest { https = lib.shb.runNixOSTest {
name = "open-webui_https"; name = "open-webui_https";
nodes.client = {}; nodes.client = { };
nodes.server = { nodes.server = {
imports = [ imports = [
basic basic
@ -176,17 +216,19 @@ in
clientLoginSso clientLoginSso
]; ];
}; };
nodes.server = { config, pkgs, ... }: { nodes.server =
imports = [ { config, pkgs, ... }:
basic {
lib.shb.certs imports = [
https basic
lib.shb.ldap lib.shb.certs
ldap https
(lib.shb.sso config.shb.certs.certs.selfsigned.n) lib.shb.ldap
sso ldap
]; (lib.shb.sso config.shb.certs.certs.selfsigned.n)
}; sso
];
};
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };

View file

@ -2,134 +2,178 @@
let let
commonTestScript = lib.shb.mkScripts { commonTestScript = lib.shb.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.pinchflat.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.pinchflat.ssl);
waitForServices = { ... }: [ waitForServices =
"pinchflat.service" { ... }:
"nginx.service" [
]; "pinchflat.service"
waitForPorts = { node, ... }: [ "nginx.service"
node.config.shb.pinchflat.port ];
]; waitForPorts =
}; { node, ... }:
[
basic = { config, ... }: { node.config.shb.pinchflat.port
imports = [
lib.shb.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/pinchflat.nix
];
test = {
subdomain = "p";
};
shb.pinchflat = {
enable = true;
inherit (config.test) subdomain domain;
mediaDir = "/src/pinchflat";
timeZone = "America/Los_Angeles";
secretKeyBase.result = config.shb.hardcodedsecret.secretKeyBase.result;
};
systemd.tmpfiles.rules = [
"d '/src/pinchflat' 0750 pinchflat pinchflat - -"
];
# Needed for gitea-runner-local to be able to ping pinchflat.
networking.hosts = {
"127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ];
};
shb.hardcodedsecret.secretKeyBase = {
request = config.shb.pinchflat.secretKeyBase.request;
settings.content = pkgs.lib.strings.replicate 64 "Z";
};
};
clientLogin = { config, ... }: {
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
test = {
subdomain = "p";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
# There is no login without SSO integration.
testLoginWith = [
{ username = null; password = null; nextPageExpect = [
"expect(page.get_by_text('Create a media profile')).to_be_visible()"
]; }
]; ];
};
}; };
https = { config, ... }: { basic =
shb.pinchflat = { { config, ... }:
ssl = config.shb.certs.certs.selfsigned.n; {
}; imports = [
}; lib.shb.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/pinchflat.nix
];
ldap = { config, ... }: { test = {
shb.pinchflat = { subdomain = "p";
ldap = { };
shb.pinchflat = {
enable = true; enable = true;
inherit (config.test) subdomain domain;
mediaDir = "/src/pinchflat";
timeZone = "America/Los_Angeles";
secretKeyBase.result = config.shb.hardcodedsecret.secretKeyBase.result;
};
userGroup = "user_group"; systemd.tmpfiles.rules = [
"d '/src/pinchflat' 0750 pinchflat pinchflat - -"
];
# Needed for gitea-runner-local to be able to ping pinchflat.
networking.hosts = {
"127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ];
};
shb.hardcodedsecret.secretKeyBase = {
request = config.shb.pinchflat.secretKeyBase.request;
settings.content = pkgs.lib.strings.replicate 64 "Z";
}; };
}; };
};
clientLoginSso = { config, ... }: { clientLogin =
imports = [ { config, ... }:
lib.shb.baseModule {
lib.shb.clientLoginModule imports = [
]; lib.shb.baseModule
test = { lib.shb.clientLoginModule
subdomain = "p";
};
test.login = {
startUrl = "https://${config.test.fqdn}";
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{ username = "alice"; password = "NotAlicePassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
]; }
{ username = "alice"; password = "AlicePassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('Create a media profile')).to_be_visible()"
]; }
{ username = "bob"; password = "NotBobPassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
]; }
{ username = "bob"; password = "BobPassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('Create a media profile')).to_be_visible()"
]; }
{ username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
]; }
{ username = "charlie"; password = "CharliePassword"; nextPageExpect = [
"expect(page).to_have_url(re.compile('.*/authenticated'))"
]; }
]; ];
}; test = {
}; subdomain = "p";
};
sso = { config, ... }: { test.login = {
shb.pinchflat = { startUrl = "http://${config.test.fqdn}";
sso = { # There is no login without SSO integration.
enable = true; testLoginWith = [
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; {
username = null;
password = null;
nextPageExpect = [
"expect(page.get_by_text('Create a media profile')).to_be_visible()"
];
}
];
};
};
https =
{ config, ... }:
{
shb.pinchflat = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
ldap =
{ config, ... }:
{
shb.pinchflat = {
ldap = {
enable = true;
userGroup = "user_group";
};
};
};
clientLoginSso =
{ config, ... }:
{
imports = [
lib.shb.baseModule
lib.shb.clientLoginModule
];
test = {
subdomain = "p";
};
test.login = {
startUrl = "https://${config.test.fqdn}";
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
];
}
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('Create a media profile')).to_be_visible()"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('Create a media profile')).to_be_visible()"
];
}
{
username = "charlie";
password = "NotCharliePassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()"
];
}
{
username = "charlie";
password = "CharliePassword";
nextPageExpect = [
"expect(page).to_have_url(re.compile('.*/authenticated'))"
];
}
];
};
};
sso =
{ config, ... }:
{
shb.pinchflat = {
sso = {
enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
}; };
}; };
};
in in
{ {
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
@ -152,14 +196,16 @@ in
backup = lib.shb.runNixOSTest { backup = lib.shb.runNixOSTest {
name = "pinchflat_backup"; name = "pinchflat_backup";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
basic {
(lib.shb.backup config.shb.pinchflat.backup) imports = [
]; basic
}; (lib.shb.backup config.shb.pinchflat.backup)
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.backup; testScript = commonTestScript.backup;
}; };
@ -191,17 +237,19 @@ in
clientLoginSso clientLoginSso
]; ];
}; };
nodes.server = { config, pkgs, ... }: { nodes.server =
imports = [ { config, pkgs, ... }:
basic {
lib.shb.certs imports = [
https basic
lib.shb.ldap lib.shb.certs
ldap https
(lib.shb.sso config.shb.certs.certs.selfsigned.n) lib.shb.ldap
sso ldap
]; (lib.shb.sso config.shb.certs.certs.selfsigned.n)
}; sso
];
};
testScript = commonTestScript.access.override { testScript = commonTestScript.access.override {
redirectSSO = true; redirectSSO = true;

View file

@ -2,14 +2,18 @@
let let
commonTestScript = lib.shb.mkScripts { commonTestScript = lib.shb.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.vaultwarden.ssl); hasSSL = { node, ... }: !(isNull node.config.shb.vaultwarden.ssl);
waitForServices = { ... }: [ waitForServices =
"vaultwarden.service" { ... }:
"nginx.service" [
]; "vaultwarden.service"
waitForPorts = { node, ... }: [ "nginx.service"
8222 ];
5432 waitForPorts =
]; { node, ... }:
[
8222
5432
];
# to get the get token test to succeed we need: # to get the get token test to succeed we need:
# 1. add group Vaultwarden_admin to LLDAP # 1. add group Vaultwarden_admin to LLDAP
# 2. add an Authelia user with to that group # 2. add an Authelia user with to that group
@ -17,58 +21,64 @@ let
# 4. go to the Vaultwarden /admin endpoint # 4. go to the Vaultwarden /admin endpoint
# 5. create a Vaultwarden user # 5. create a Vaultwarden user
# 6. now login with that new user to Vaultwarden # 6. now login with that new user to Vaultwarden
extraScript = { node, proto_fqdn, ... }: '' extraScript =
with subtest("prelogin"): { node, proto_fqdn, ... }:
response = curl(client, "", "${proto_fqdn}/identity/accounts/prelogin", data=unline_with("", """ ''
{"email": "me@example.com"} with subtest("prelogin"):
""")) response = curl(client, "", "${proto_fqdn}/identity/accounts/prelogin", data=unline_with("", """
print(response) {"email": "me@example.com"}
if 'kdf' not in response: """))
raise Exception("Unrecognized response: {}".format(response)) print(response)
if 'kdf' not in response:
raise Exception("Unrecognized response: {}".format(response))
with subtest("get token"): with subtest("get token"):
response = curl(client, "", "${proto_fqdn}/identity/connect/token", data=unline_with("", """ response = curl(client, "", "${proto_fqdn}/identity/connect/token", data=unline_with("", """
scope=api%20offline_access scope=api%20offline_access
&client_id=web &client_id=web
&deviceType=10 &deviceType=10
&deviceIdentifier=a60323bf-4686-4b4d-96e0-3c241fa5581c &deviceIdentifier=a60323bf-4686-4b4d-96e0-3c241fa5581c
&deviceName=firefox &deviceName=firefox
&grant_type=password&username=me &grant_type=password&username=me
&password=mypassword &password=mypassword
""")) """))
print(response) print(response)
if response["message"] != "Username or password is incorrect. Try again": if response["message"] != "Username or password is incorrect. Try again":
raise Exception("Unrecognized response: {}".format(response)) raise Exception("Unrecognized response: {}".format(response))
''; '';
}; };
basic = { config, ... }: { basic =
test = { { config, ... }:
subdomain = "v"; {
test = {
subdomain = "v";
};
shb.vaultwarden = {
enable = true;
inherit (config.test) subdomain domain;
port = 8222;
databasePassword.result = config.shb.hardcodedsecret.passphrase.result;
};
shb.hardcodedsecret.passphrase = {
request = config.shb.vaultwarden.databasePassword.request;
settings.content = "PassPhrase";
};
# networking.hosts = {
# "127.0.0.1" = [ fqdn ];
# };
}; };
shb.vaultwarden = { https =
enable = true; { config, ... }:
inherit (config.test) subdomain domain; {
shb.vaultwarden = {
port = 8222; ssl = config.shb.certs.certs.selfsigned.n;
databasePassword.result = config.shb.hardcodedsecret.passphrase.result; };
}; };
shb.hardcodedsecret.passphrase = {
request = config.shb.vaultwarden.databasePassword.request;
settings.content = "PassPhrase";
};
# networking.hosts = {
# "127.0.0.1" = [ fqdn ];
# };
};
https = { config, ... }: {
shb.vaultwarden = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
# Not yet supported # Not yet supported
# ldap = { config, ... }: { # ldap = { config, ... }: {
@ -78,11 +88,13 @@ let
# # }; # # };
# }; # };
sso = { config, ... }: { sso =
shb.vaultwarden = { { config, ... }:
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; {
shb.vaultwarden = {
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
}; };
};
in in
{ {
basic = lib.shb.runNixOSTest { basic = lib.shb.runNixOSTest {
@ -97,7 +109,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
@ -116,7 +128,7 @@ in
]; ];
}; };
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access; testScript = commonTestScript.access;
}; };
@ -126,7 +138,7 @@ in
# ldap = lib.shb.runNixOSTest { # ldap = lib.shb.runNixOSTest {
# name = "vaultwarden_ldap"; # name = "vaultwarden_ldap";
# #
# nodes.server = lib.mkMerge [ # nodes.server = lib.mkMerge [
# lib.shb.baseModule # lib.shb.baseModule
# ../../modules/blocks/hardcodedsecret.nix # ../../modules/blocks/hardcodedsecret.nix
# ../../modules/services/vaultwarden.nix # ../../modules/services/vaultwarden.nix
@ -142,56 +154,64 @@ in
sso = lib.shb.runNixOSTest { sso = lib.shb.runNixOSTest {
name = "vaultwarden_sso"; name = "vaultwarden_sso";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
lib.shb.baseModule {
../../modules/blocks/hardcodedsecret.nix imports = [
../../modules/services/vaultwarden.nix lib.shb.baseModule
lib.shb.certs ../../modules/blocks/hardcodedsecret.nix
basic ../../modules/services/vaultwarden.nix
https lib.shb.certs
lib.shb.ldap basic
(lib.shb.sso config.shb.certs.certs.selfsigned.n) https
sso lib.shb.ldap
]; (lib.shb.sso config.shb.certs.certs.selfsigned.n)
}; sso
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.access.override { testScript = commonTestScript.access.override {
waitForPorts = { node, ... }: [ waitForPorts =
8222 { node, ... }:
5432 [
9091 8222
]; 5432
extraScript = { node, proto_fqdn, ... }: '' 9091
with subtest("unauthenticated access is not granted to /admin"): ];
response = curl(client, """{"code":%{response_code},"auth_host":"%{urle.host}","auth_query":"%{urle.query}","all":%{json}}""", "${proto_fqdn}/admin") extraScript =
{ node, proto_fqdn, ... }:
''
with subtest("unauthenticated access is not granted to /admin"):
response = curl(client, """{"code":%{response_code},"auth_host":"%{urle.host}","auth_query":"%{urle.query}","all":%{json}}""", "${proto_fqdn}/admin")
if response['code'] != 200: if response['code'] != 200:
raise Exception(f"Code is {response['code']}") raise Exception(f"Code is {response['code']}")
if response['auth_host'] != "auth.${node.config.test.domain}": if response['auth_host'] != "auth.${node.config.test.domain}":
raise Exception(f"auth host should be auth.${node.config.test.domain} but is {response['auth_host']}") raise Exception(f"auth host should be auth.${node.config.test.domain} but is {response['auth_host']}")
if response['auth_query'] != "rd=${proto_fqdn}/admin": if response['auth_query'] != "rd=${proto_fqdn}/admin":
raise Exception(f"auth query should be rd=${proto_fqdn}/admin but is {response['auth_query']}") raise Exception(f"auth query should be rd=${proto_fqdn}/admin but is {response['auth_query']}")
''; '';
}; };
}; };
backup = lib.shb.runNixOSTest { backup = lib.shb.runNixOSTest {
name = "vaultwarden_backup"; name = "vaultwarden_backup";
nodes.server = { config, ... }: { nodes.server =
imports = [ { config, ... }:
lib.shb.baseModule {
../../modules/blocks/hardcodedsecret.nix imports = [
../../modules/services/vaultwarden.nix lib.shb.baseModule
basic ../../modules/blocks/hardcodedsecret.nix
(lib.shb.backup config.shb.vaultwarden.backup) ../../modules/services/vaultwarden.nix
]; basic
}; (lib.shb.backup config.shb.vaultwarden.backup)
];
};
nodes.client = {}; nodes.client = { };
testScript = commonTestScript.backup; testScript = commonTestScript.backup;
}; };