= {
mapSpecialJunk = "Spam";
};
}
```
### List Local Folders {#services-mailserver-debug-local-folders}
Check the local folders to make sure the mapping is correct
and all folders are correctly downloaded.
For example, if the mapping above is wrong, you will see both a
`Junk` and `Spam` folder while if it is correct,
you will only see the `Junk` folder.
```
$ sudo doveadm mailbox list -u $USER
Junk
Trash
Drafts
Sent
INBOX
MyCustomFolder
```
The following command shows the number of messages in a folder:
```
$ sudo doveadm mailbox status -u $USER messages INBOX
INBOX messages=13591
```
If any folder is not appearing or has 0 message but should have some,
it could mean dovecot is not setup correctly and assumes an incorrect folder layout.
If that is the case, check the user config with:
```
$ sudo doveadm user $USER
field value
uid 5000
gid 5000
home /var/vmail/fastmail/$USER
mail maildir:~/mail:LAYOUT=fs
virtualMail
```
### Test Auth {#services-mailserver-debug-auth}
To test authentication to your dovecot instance, run:
```
$ nix run nixpkgs#openssl -- s_client -connect $SUBDOMAIN.$DOMAIN:993 -crlf -quiet
. LOGIN $USER $PASSWORD
```
You must here also enter the second line verbatim,
replacing your user and password with the real one.
On success, you will see:
```
. OK [CAPABILITY IMAP4rev1 ...] Logged in
```
Otherwise, either if the password is wrong or,
when using LDAP if the user is not part of the LDAP group, you will see:
```
. NO [AUTHENTICATIONFAILED] Authentication failed.
```
To test the postfix instance, run:
```
$ swaks \
--server $SUBDOMAIN.$DOMAIN \
--port 465 \
--tls-on-connect \
--auth LOGIN \
--auth-user $USER \
--auth-password '$PASSWORD' \
--from $USER \
--to $USER
```
Try once with a wrong password and once with a correct one.
The former should log:
```
<~* 535 5.7.8 Error: authentication failed: (reason unavailable)
```
## Mobile Apps {#services-mailserver-mobile}
This module was tested with:
- the iOS mail mobile app,
- Thunderbird on NixOS.
The iOS mail app is pretty finicky.
If downloading emails does not work,
make sure the certificate used includes the whole chain:
```bash
$ openssl s_client -connect $SUBDOMAIN.$DOMAIN:993 -showcerts
```
Normally, the other options are setup correctly but if it fails for you,
feel free to open an issue.
## Options Reference {#services-mailserver-options}
```{=include=} options
id-prefix: services-mailserver-options-
list-id: selfhostblocks-service-mailserver-options
source: @OPTIONS_JSON@
```
================================================
FILE: modules/services/mailserver.nix
================================================
{
config,
lib,
shb,
pkgs,
...
}:
let
cfg = config.shb.mailserver;
in
{
imports = [
(
builtins.fetchGit {
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver.git";
ref = "master";
rev = "7d433bf89882f61621f95082e90a4ab91eb0bdd3";
}
+ "/default.nix"
)
../blocks/lldap.nix
];
options.shb.mailserver = {
enable = lib.mkEnableOption "SHB's nixos-mailserver module";
subdomain = lib.mkOption {
type = lib.types.str;
description = "Subdomain under which imap and smtp functions will be served.";
default = "imap";
};
domain = lib.mkOption {
type = lib.types.str;
description = "domain under which imap and smtp functions will be served.";
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
adminUsername = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Admin username.
postmaster will be made an alias of this user.
'';
example = "admin";
};
adminPassword = lib.mkOption {
description = "Admin user password.";
default = null;
type = lib.types.nullOr (
lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = config.services.postfix.user;
ownerText = "services.postfix.user";
restartUnits = [ "dovecot.service" ];
};
}
);
};
imapSync = lib.mkOption {
description = ''
Synchronize one or more email providers through IMAP
to your dovecot instance.
This allows you to backup that email provider
and centralize your accounts in this dovecot instance.
'';
default = null;
type = lib.types.nullOr (
lib.types.submodule {
options = {
syncTimer = lib.mkOption {
type = lib.types.str;
default = "5m";
description = ''
Systemd timer for when imap sync job should happen.
This timer is not scheduling the job at regular intervals.
After a job finishes, the given amount of time is waited then the next job is started.
The default is set deliberatily slow to not spam you when setting up your mailserver.
When everything works, you will want to reduce it to 10s or something like that.
'';
example = "10s";
};
debug = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable verbose mbsync logging.";
};
accounts = lib.mkOption {
description = ''
Accounts to sync emails from using IMAP.
Emails will be stored under `''${config.mailserver.mailDirectory}/''${name}/''${username}`
'';
type = lib.types.attrsOf (
lib.types.submodule {
options = {
host = lib.mkOption {
type = lib.types.str;
description = "Hostname of the email's provider IMAP server.";
example = "imap.fastmail.com";
};
port = lib.mkOption {
type = lib.types.port;
description = "Port of the email's provider IMAP server.";
default = 993;
};
username = lib.mkOption {
type = lib.types.str;
description = "Username used to login to the email's provider IMAP server.";
example = "userA@fastmail.com";
};
password = lib.mkOption {
description = ''
Password used to login to the email's provider IMAP server.
The password could be an "app password" like for [Fastmail](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords)
'';
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = config.mailserver.vmailUserName;
restartUnits = [ "mbsync.service" ];
};
};
};
sslType = lib.mkOption {
description = "Connection security method.";
type = lib.types.enum [
"IMAPS"
"STARTTLS"
];
default = "IMAPS";
};
timeout = lib.mkOption {
description = "Connect and data timeout.";
type = lib.types.int;
default = 120;
};
mapSpecialDrafts = lib.mkOption {
type = lib.types.str;
default = "Drafts";
description = ''
Drafts special folder name on far side.
You only need to change this if mbsync logs the following error:
Error: ... far side box Drafts cannot be opened
'';
};
mapSpecialSent = lib.mkOption {
type = lib.types.str;
default = "Sent";
description = ''
Sent special folder name on far side.
You only need to change this if mbsync logs the following error:
Error: ... far side box Sent cannot be opened
'';
};
mapSpecialTrash = lib.mkOption {
type = lib.types.str;
default = "Trash";
description = ''
Trash special folder name on far side.
You only need to change this if mbsync logs the following error:
Error: ... far side box Trash cannot be opened
'';
};
mapSpecialJunk = lib.mkOption {
type = lib.types.str;
default = "Junk";
description = ''
Junk special folder name on far side.
You only need to change this if mbsync logs the following error:
Error: ... far side box Junk cannot be opened
'';
example = "Spam";
};
};
}
);
};
};
}
);
};
smtpRelay = lib.mkOption {
description = ''
Proxy outgoing emails through an email provider.
In short, this can help you avoid having your outgoing emails marked as spam.
See the manual for a lengthier explanation.
'';
default = null;
type = lib.types.nullOr (
lib.types.submodule {
options = {
host = lib.mkOption {
type = lib.types.str;
description = "Hostname of the email's provider SMTP server.";
example = "smtp.fastmail.com";
};
port = lib.mkOption {
type = lib.types.port;
description = "Port of the email's provider SMTP server.";
default = 587;
};
username = lib.mkOption {
description = "Username used to login to the email's provider SMTP server.";
type = lib.types.str;
};
password = lib.mkOption {
description = ''
Password used to login to the email's provider IMAP server.
The password could be an "app password" like for [Fastmail](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords)
'';
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = config.services.postfix.user;
ownerText = "services.postfix.user";
restartUnits = [ "postfix.service" ];
};
};
};
};
}
);
};
ldap = lib.mkOption {
description = ''
LDAP Integration.
Enabling this app will create a new LDAP configuration or update one that exists with
the given host.
'';
default = { };
type = lib.types.nullOr (
lib.types.submodule {
options = {
enable = lib.mkEnableOption "LDAP app.";
host = lib.mkOption {
type = lib.types.str;
description = ''
Host serving the LDAP server.
'';
default = "127.0.0.1";
};
port = lib.mkOption {
type = lib.types.port;
description = ''
Port of the service serving the LDAP server.
'';
default = 389;
};
dcdomain = lib.mkOption {
type = lib.types.str;
description = "dc domain for ldap.";
example = "dc=mydomain,dc=com";
};
account = lib.mkOption {
type = lib.types.str;
description = ''
Select one account from those defined in `shb.mailserver.imapSync.accounts`
to login with.
Using LDAP, you can only connect to one account.
This limitation could maybe be lifted, feel free to post an issue if you need this.
'';
};
adminName = lib.mkOption {
type = lib.types.str;
description = "Admin user of the LDAP server.";
default = "admin";
};
adminPassword = lib.mkOption {
description = "LDAP server admin password.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "nextcloud";
restartUnits = [ "dovecot.service" ];
};
};
};
userGroup = lib.mkOption {
type = lib.types.str;
description = "Group users must belong to to be able to use mails.";
default = "mail_user";
};
};
}
);
};
backup = lib.mkOption {
description = ''
Backup emails, index and sieve.
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.backup.mkRequester {
user = config.mailserver.vmailUserName;
sourceDirectories = builtins.filter (x: x != null) [
config.mailserver.indexDir
config.mailserver.mailDirectory
config.mailserver.sieveDirectory
];
sourceDirectoriesText = ''
[
config.mailserver.indexDir
config.mailserver.mailDirectory
config.mailserver.sieveDirectory
]
'';
};
};
};
backupDKIM = lib.mkOption {
description = ''
Backup dkim directory.
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.backup.mkRequester {
user = config.services.rspamd.user;
userText = "services.rspamd.user";
sourceDirectories = builtins.filter (x: x != null) [
config.mailserver.dkimKeyDirectory
];
sourceDirectoriesText = ''
[
config.mailserver.dkimKeyDirectory
]
'';
};
};
};
impermanence = lib.mkOption {
description = ''
Path to save when using impermanence setup.
'';
type = lib.types.attrsOf lib.types.str;
default = {
index = config.mailserver.indexDir;
mail = config.mailserver.mailDirectory;
sieve = config.mailserver.sieveDirectory;
dkim = config.mailserver.dkimKeyDirectory;
};
defaultText = lib.literalExpression ''
{
index = config.mailserver.indexDir;
mail = config.mailserver.mailDirectory;
sieve = config.mailserver.sieveDirectory;
dkim = config.mailserver.dkimKeyDirectory;
}
'';
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.mailserver.subdomain}.\${config.shb.mailserver.domain}";
};
};
};
};
config = lib.mkMerge [
(lib.mkIf cfg.enable {
mailserver = {
enable = true;
stateVersion = 3;
fqdn = "${cfg.subdomain}.${cfg.domain}";
domains = [ cfg.domain ];
localDnsResolver = false;
enableImapSsl = true;
enableSubmissionSsl = true;
x509 = {
certificateFile = cfg.ssl.paths.cert;
privateKeyFile = cfg.ssl.paths.key;
};
# Using / is needed for iOS mail.
# Both following options are used to organize subfolders in subdirectories.
hierarchySeparator = "/";
useFsLayout = true;
};
services.postfix.config = {
smtpd_tls_security_level = lib.mkForce "encrypt";
};
# Is probably needed for iOS mail.
services.dovecot2.extraConfig = ''
ssl_min_protocol = TLSv1.2
ssl_cipher_list = HIGH:!aNULL:!MD5
'';
services.nginx = {
enable = true;
virtualHosts."${cfg.domain}" =
let
announce = pkgs.writeTextDir "config-v1.1.xml" ''
${cfg.domain}
${cfg.domain} Mailserver
${cfg.subdomain}.${cfg.domain}
993
SSL
password-cleartext
%EMAILADDRESS%
${cfg.subdomain}.${cfg.domain}
465
SSL
password-cleartext
%EMAILADDRESS%
'';
in
{
forceSSL = true; # Redirect HTTP → HTTPS
root = "/var/www"; # Dummy root
locations."/.well-known/autoconfig/mail/" = {
alias = "${announce}/";
extraConfig = ''
default_type application/xml;
'';
};
};
virtualHosts."${cfg.subdomain}.${cfg.domain}" =
let
landingPage = pkgs.writeTextDir "index.html" ''
Configuration of the mailserver is done automatically thanks to
${cfg.domain}/.well-known/autoconfig/mail/config-v1.1.xml.
'';
in
{
forceSSL = true; # Redirect HTTP → HTTPS
root = "/var/www"; # Dummy root
locations."/" = {
alias = "${landingPage}/";
extraConfig = ''
default_type application/html;
'';
};
};
};
})
(lib.mkIf (cfg.enable && cfg.adminUsername != null) {
assertions = [
{
assertion = cfg.adminPassword != null;
message = "`shb.mailserver.adminPassword` must be not null if `shb.mailserver.adminUsername` is not null.";
}
];
mailserver = {
# To create the password hashes, use:
# nix run nixpkgs#mkpasswd -- --run 'mkpasswd -s'
loginAccounts = {
"${cfg.adminUsername}@${cfg.domain}" = {
hashedPasswordFile = cfg.adminPassword.result.path;
aliases = [ "postmaster@${cfg.domain}" ];
};
};
};
})
(lib.mkIf (cfg.enable && cfg.ldap != null) {
assertions = [
{
assertion = cfg.adminUsername == null;
message = "`shb.mailserver.adminUsername` must be null `shb.mailserver.ldap` integration is set.";
}
];
shb.lldap.ensureGroups = {
${cfg.ldap.userGroup} = { };
};
mailserver = {
ldap = {
enable = true;
uris = [
"ldap://${cfg.ldap.host}:${toString cfg.ldap.port}"
];
searchBase = "ou=people,${cfg.ldap.dcdomain}";
searchScope = "sub";
bind = {
dn = "uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain}";
passwordFile = cfg.ldap.adminPassword.result.path;
};
# Note that nixos simple mailserver sets auth_bind=yes
# which means authentication binds are used.
# https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_bind/#authentication-ldap-bind
dovecot =
let
filter = "(&(objectClass=inetOrgPerson)(mail=%{user})(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))";
in
{
passAttrs = "user=user";
passFilter = filter;
userAttrs = lib.concatStringsSep "," [
"=home=${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u"
# "mail=maildir:${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u/mail"
"uid=${config.mailserver.vmailUserName}"
"gid=${config.mailserver.vmailGroupName}"
];
userFilter = filter;
};
postfix = {
filter = "(&(objectClass=inetOrgPerson)(mail=%s)(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))";
mailAttribute = "mail";
uidAttribute = "mail";
};
};
};
})
(lib.mkIf (cfg.enable && cfg.imapSync != null) {
systemd.services.mbsync =
let
configFile =
let
mkAccount = name: acct: ''
# ${name} account
IMAPAccount ${name}
Host ${acct.host}
Port ${toString acct.port}
User ${acct.username}
PassCmd "cat ${acct.password.result.path}"
TLSType ${acct.sslType}
AuthMechs LOGIN
Timeout ${toString acct.timeout}
IMAPStore ${name}-remote
Account ${name}
MaildirStore ${name}-local
INBOX ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/
# Maps subfolders on far side to actual subfolders on disk.
# The other option is Maildir++ but then the mailserver.hierarchySeparator must be set to a dot '.'
SubFolders Verbatim
Path ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/
Channel ${name}-main
Far :${name}-remote:
Near :${name}-local:
Patterns * !Drafts !Sent !Trash !Junk !${acct.mapSpecialDrafts} !${acct.mapSpecialSent} !${acct.mapSpecialTrash} !${acct.mapSpecialJunk}
Create Both
Expunge Both
SyncState *
Sync All
CopyArrivalDate yes # Preserve date from incoming message.
Channel ${name}-drafts
Far :${name}-remote:"${acct.mapSpecialDrafts}"
Near :${name}-local:"Drafts"
Create Both
Expunge Both
SyncState *
Sync All
CopyArrivalDate yes # Preserve date from incoming message.
Channel ${name}-sent
Far :${name}-remote:"${acct.mapSpecialSent}"
Near :${name}-local:"Sent"
Create Both
Expunge Both
SyncState *
Sync All
CopyArrivalDate yes # Preserve date from incoming message.
Channel ${name}-trash
Far :${name}-remote:"${acct.mapSpecialTrash}"
Near :${name}-local:"Trash"
Create Both
Expunge Both
SyncState *
Sync All
CopyArrivalDate yes # Preserve date from incoming message.
Channel ${name}-junk
Far :${name}-remote:"${acct.mapSpecialJunk}"
Near :${name}-local:"Junk"
Create Both
Expunge Both
SyncState *
Sync All
CopyArrivalDate yes # Preserve date from incoming message.
Group ${name}
Channel ${name}-main
Channel ${name}-drafts
Channel ${name}-sent
Channel ${name}-trash
Channel ${name}-junk
# END ${name} account
'';
in
pkgs.writeText "mbsync.conf" (
lib.concatStringsSep "\n" (lib.mapAttrsToList mkAccount cfg.imapSync.accounts)
);
in
{
description = "Sync mailbox";
serviceConfig = {
Type = "oneshot";
User = config.mailserver.vmailUserName;
};
script =
let
debug = if cfg.imapSync.debug then "-V" else "";
in
''
${pkgs.isync}/bin/mbsync --all ${debug} --config ${configFile}
'';
};
systemd.tmpfiles.rules =
let
mkAccount =
name: acct:
# The equal sign makes sure parent directories have the corret user and group too.
[
"d '${config.mailserver.mailDirectory}/${name}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -"
"d '${config.mailserver.mailDirectory}/${name}/${acct.username}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -"
];
in
lib.flatten (lib.mapAttrsToList mkAccount cfg.imapSync.accounts);
systemd.timers.mbsync = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = cfg.imapSync.syncTimer;
OnUnitActiveSec = cfg.imapSync.syncTimer;
};
};
})
(lib.mkIf (cfg.enable && cfg.smtpRelay != null) (
let
url = "[${cfg.smtpRelay.host}]:${toString cfg.smtpRelay.port}";
in
{
assertions = [
{
assertion = lib.hasAttr cfg.adminPassword != null;
message = "`shb.mailserver.adminPassword` must be not null if `shb.mailserver.adminUsername` is not null.";
}
];
# Inspiration from https://www.brull.me/postfix/debian/fastmail/2016/08/16/fastmail-smtp.html
services.postfix = {
settings.main = {
relayhost = [ url ];
smtp_sasl_auth_enable = "yes";
smtp_sasl_password_maps = "texthash:/run/secrets/postfix/postfix-smtp-relay-password";
smtp_sasl_security_options = "noanonymous";
smtp_use_tls = "yes";
};
};
systemd.services.postfix-pre = {
script = shb.replaceSecrets {
userConfig = {
inherit url;
inherit (cfg.smtpRelay) username;
password.source = cfg.smtpRelay.password.result.path;
};
generator =
name:
{
url,
username,
password,
}:
pkgs.writeText "postfix-smtp-relay-password" ''
${url} ${username}:${password}
'';
resultPath = "/run/secrets/postfix/postfix-smtp-relay-password";
user = config.services.postfix.user;
};
serviceConfig.Type = "oneshot";
wantedBy = [ "multi-user.target" ];
before = [ "postfix.service" ];
requiredBy = [ "postfix.service" ];
};
}
))
];
}
================================================
FILE: modules/services/nextcloud-server/dashboard/Nextcloud.json
================================================
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 13,
"links": [],
"panels": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
"h": 0,
"w": 24,
"x": 0,
"y": 0
},
"id": 19,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": false,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"pluginVersion": "11.3.0+security-01",
"repeat": "other_service",
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"editorMode": "code",
"expr": "{unit=\"$other_service.service\"} | json | line_format \"{{.message}}\" | json | drop message | line_format \"[{{.app}} - {{.url}}] {{.Message}}: {{.exception_details}}{{.Previous_Message}}\"",
"queryType": "range",
"refId": "A"
}
],
"title": "Panel Title",
"type": "logs"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 12,
"panels": [],
"title": "General",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"description": "Some stall time means that 100% of the CPU is used. It's not an issue if this happens occasionally but can mean the CPU is underpowered for the current use case if this happens most of the time.\nTo fix this, the \"nice\" property of processes can be adjusted.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"decimals": 2,
"fieldMinMax": false,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "transparent",
"value": 0.05
}
]
},
"unit": "percent"
},
"overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "custom.axisPlacement",
"value": "right"
},
{
"id": "unit",
"value": "ms"
},
{
"id": "color",
"value": {
"fixedColor": "orange",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"dash": [
0,
10
],
"fill": "dot"
}
},
{
"id": "custom.lineWidth",
"value": 2
},
{
"id": "custom.fillOpacity",
"value": 34
},
{
"id": "min",
"value": -40
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 1
},
"id": 8,
"options": {
"legend": {
"calcs": [
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "right",
"showLegend": true,
"width": 300
},
"tooltip": {
"maxHeight": 600,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "netdata_system_cpu_some_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1",
"hide": false,
"instant": false,
"legendFormat": "some stall time",
"range": true,
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"disableTextWrap": false,
"editorMode": "code",
"expr": "sum by(dimension, service_name) (netdata_systemd_service_cpu_utilization_percentage_average{hostname=~\"$hostname\", service_name=~\".*$service.*\"})",
"fullMetaSearch": false,
"hide": false,
"includeNullMetadata": true,
"legendFormat": "{{service_name}} / {{dimension}}",
"range": true,
"refId": "used",
"useBackend": false
}
],
"title": "CPU",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"axisSoftMin": -100,
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"decimals": 2,
"fieldMinMax": false,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "transparent",
"value": 0.05
}
]
},
"unit": "mbytes"
},
"overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "A"
},
"properties": [
{
"id": "custom.axisPlacement",
"value": "right"
},
{
"id": "unit",
"value": "ms"
},
{
"id": "decimals"
},
{
"id": "color",
"value": {
"fixedColor": "dark-red",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"dash": [
10,
10
],
"fill": "dash"
}
},
{
"id": "custom.lineWidth",
"value": 2
}
]
},
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "green",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"dash": [
0,
10
],
"fill": "dot"
}
},
{
"id": "custom.lineWidth",
"value": 2
},
{
"id": "custom.fillOpacity",
"value": 10
},
{
"id": "custom.axisPlacement",
"value": "auto"
},
{
"id": "custom.stacking",
"value": {
"group": "A",
"mode": "none"
}
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 1
},
"id": 7,
"options": {
"legend": {
"calcs": [
"max",
"lastNotNull"
],
"displayMode": "table",
"placement": "right",
"showLegend": true,
"width": 300
},
"tooltip": {
"maxHeight": 600,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "netdata_system_memory_full_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1",
"hide": false,
"instant": false,
"legendFormat": "full stall time",
"range": true,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "sum(netdata_mem_available_MiB_average{hostname=~\"$hostname\"})",
"hide": false,
"instant": false,
"legendFormat": "total available",
"range": true,
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"disableTextWrap": false,
"editorMode": "code",
"expr": "sum by(dimension, service_name) (netdata_systemd_service_memory_usage_MiB_average{hostname=~\"$hostname\", service_name=~\".*$service.*\", dimension=\"ram\"})",
"fullMetaSearch": false,
"hide": false,
"includeNullMetadata": true,
"legendFormat": "{{service_name}}",
"range": true,
"refId": "used",
"useBackend": false
}
],
"title": "Memory",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"decimals": 2,
"fieldMinMax": false,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "transparent",
"value": 0.05
}
]
},
"unit": "KBs"
},
"overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "custom.axisPlacement",
"value": "right"
},
{
"id": "unit",
"value": "ms"
},
{
"id": "color",
"value": {
"fixedColor": "orange",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"dash": [
0,
10
],
"fill": "dot"
}
},
{
"id": "custom.lineWidth",
"value": 2
},
{
"id": "custom.fillOpacity",
"value": 34
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 9
},
"id": 4,
"options": {
"legend": {
"calcs": [
"max",
"mean"
],
"displayMode": "table",
"placement": "right",
"showLegend": true,
"width": 300
},
"tooltip": {
"maxHeight": 600,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"disableTextWrap": false,
"editorMode": "code",
"expr": "sum by(dimension) (netdata_system_net_kilobits_persec_average{hostname=~\"$hostname\"})",
"fullMetaSearch": false,
"hide": false,
"includeNullMetadata": true,
"legendFormat": "{{dimension}}",
"range": true,
"refId": "used",
"useBackend": false
}
],
"title": "Network I/O",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"decimals": 2,
"fieldMinMax": false,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "transparent",
"value": 0.05
}
]
},
"unit": "Kibits"
},
"overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "A"
},
"properties": [
{
"id": "custom.axisPlacement",
"value": "right"
},
{
"id": "unit",
"value": "ms"
},
{
"id": "color",
"value": {
"fixedColor": "red",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"dash": [
0,
10
],
"fill": "dot"
}
},
{
"id": "custom.lineWidth",
"value": 2
},
{
"id": "custom.fillOpacity",
"value": 12
},
{
"id": "custom.stacking",
"value": {
"group": "A",
"mode": "none"
}
},
{
"id": "min",
"value": -200
}
]
},
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "custom.axisPlacement",
"value": "right"
},
{
"id": "unit",
"value": "ms"
},
{
"id": "color",
"value": {
"fixedColor": "orange",
"mode": "fixed"
}
},
{
"id": "custom.lineStyle",
"value": {
"dash": [
0,
10
],
"fill": "dot"
}
},
{
"id": "custom.lineWidth",
"value": 2
},
{
"id": "custom.fillOpacity",
"value": 17
},
{
"id": "custom.stacking",
"value": {
"group": "A",
"mode": "none"
}
},
{
"id": "min",
"value": -200
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 9
},
"id": 6,
"options": {
"legend": {
"calcs": [
"max",
"sum"
],
"displayMode": "table",
"placement": "right",
"showLegend": true,
"width": 300
},
"tooltip": {
"maxHeight": 600,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "netdata_system_io_full_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1",
"hide": false,
"instant": false,
"legendFormat": "full stall time",
"range": true,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "netdata_system_io_some_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1",
"hide": false,
"instant": false,
"legendFormat": "some stall time",
"range": true,
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"disableTextWrap": false,
"editorMode": "code",
"expr": "sum by(dimension, service_name) (netdata_systemd_service_disk_io_KiB_persec_average{hostname=~\"$hostname\", service_name=~\".*$service.*\", dimension=\"read\"})",
"fullMetaSearch": false,
"hide": false,
"includeNullMetadata": true,
"legendFormat": "{{service_name}} / {{dimension}}",
"range": true,
"refId": "read",
"useBackend": false
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "sum by(dimension, service_name) (netdata_systemd_service_disk_io_KiB_persec_average{hostname=~\"$hostname\", service_name=~\".*$service.*\", dimension=\"write\"}) * -1",
"hide": false,
"instant": false,
"legendFormat": "{{service_name}} / {{dimension}}",
"range": true,
"refId": "write"
}
],
"title": "Disk I/O",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "area"
}
},
"fieldMinMax": false,
"mappings": [],
"thresholds": {
"mode": "percentage",
"steps": [
{
"color": "transparent",
"value": null
},
{
"color": "orange",
"value": 80
},
{
"color": "red",
"value": 90
},
{
"color": "transparent",
"value": 100
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "total"
},
"properties": [
{
"id": "custom.hideFrom",
"value": {
"legend": true,
"tooltip": false,
"viz": false
}
},
{
"id": "custom.lineWidth",
"value": 0
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 17
},
"id": 22,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.4.0",
"targets": [
{
"editorMode": "code",
"expr": "sum (phpfpm_active_processes{hostname=~\"$hostname\",pool=\"nextcloud\"})",
"hide": false,
"legendFormat": "active",
"range": true,
"refId": "active"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "sum (phpfpm_total_processes{hostname=~\"$hostname\",pool=\"nextcloud\"})",
"hide": false,
"instant": false,
"legendFormat": "total",
"range": true,
"refId": "total"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "sum (phpfpm_max_active_processes{hostname=~\"$hostname\",pool=\"nextcloud\"})",
"hide": false,
"instant": false,
"legendFormat": "max active",
"range": true,
"refId": "max active"
}
],
"title": "PHP-FPM Processes",
"transformations": [
{
"disabled": true,
"id": "calculateField",
"options": {
"binary": {
"left": {
"matcher": {
"id": "byName",
"options": "total"
}
},
"operator": "*",
"right": {
"fixed": "0.8"
}
},
"mode": "binary",
"reduce": {
"reducer": "sum"
}
}
}
],
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 25
},
"id": 14,
"panels": [],
"title": "Network",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"description": "If requests occasionally take longer than the threshold time, that's fine. If instead most of the queries take longer than the threshold, performance issue should be investigated.",
"fieldConfig": {
"defaults": {
"color": {
"fixedColor": "purple",
"mode": "fixed",
"seriesBy": "max"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "dashed+area"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "transparent",
"value": null
},
{
"color": "red",
"value": 700000
}
]
},
"unit": "µs"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 26
},
"id": 23,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.4.0",
"targets": [
{
"disableTextWrap": false,
"editorMode": "code",
"expr": "max by (hostname,child) (phpfpm_process_request_duration and (abs(phpfpm_process_request_duration - phpfpm_process_request_duration offset $__interval) > 1))",
"fullMetaSearch": false,
"hide": false,
"includeNullMetadata": true,
"legendFormat": "__auto",
"range": true,
"refId": "A",
"useBackend": false
}
],
"title": "PHP-FPM Request Duration",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 26
},
"id": 24,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "rate(phpfpm_max_listen_queue[2m])",
"hide": false,
"instant": false,
"legendFormat": "{{hostname}} - max queue",
"range": true,
"refId": "B"
},
{
"editorMode": "code",
"expr": "phpfpm_listen_queue",
"legendFormat": "{{hostname}} - queue",
"range": true,
"refId": "A"
}
],
"title": "PHP-FPM Requests Queue Length",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "points",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "ms"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 34
},
"id": 9,
"options": {
"legend": {
"calcs": [
"max",
"mean",
"variance"
],
"displayMode": "table",
"placement": "right",
"showLegend": true
},
"tooltip": {
"maxHeight": 600,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"editorMode": "code",
"expr": "{hostname=~\"$hostname\",unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | server_name =~ \"^$subdomain.*\"",
"legendFormat": "",
"queryType": "range",
"refId": "A"
}
],
"title": "Requests",
"transformations": [
{
"id": "extractFields",
"options": {
"keepTime": true,
"replace": true,
"source": "labels"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"body_bytes_sent": true,
"bytes_sent": true,
"gzip_ration": true,
"job": true,
"line": true,
"post": true,
"referrer": true,
"remote_addr": false,
"remote_user": true,
"request": true,
"request_length": true,
"status": true,
"time_local": true,
"unit": true,
"upstream_addr": true,
"upstream_connect_time": true,
"upstream_header_time": true,
"upstream_response_time": true,
"upstream_status": false,
"user_agent": true
},
"includeByName": {},
"indexByName": {},
"renameByName": {}
}
},
{
"id": "convertFieldType",
"options": {
"conversions": [
{
"dateFormat": "",
"destinationType": "number",
"targetField": "request_time"
}
],
"fields": {}
}
},
{
"id": "partitionByValues",
"options": {
"fields": [
"server_name",
"remote_addr"
],
"keepFields": false
}
},
{
"id": "renameByRegex",
"options": {
"regex": "request_time (.*)",
"renamePattern": "$1"
}
}
],
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "status"
},
"properties": [
{
"id": "custom.width",
"value": 70
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 42
},
"id": 3,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": [
{
"desc": true,
"displayName": "Time"
}
]
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"editorMode": "code",
"expr": "{hostname=~\"$hostname\",unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | upstream_addr =~ \"$upstream_addr\"",
"queryType": "range",
"refId": "A"
}
],
"title": "Requests Details",
"transformations": [
{
"id": "extractFields",
"options": {
"source": "Line"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Line": true,
"id": true,
"labels": true,
"server_name": true,
"time_local": true,
"tsNs": true,
"upstream_addr": true
},
"includeByName": {},
"indexByName": {
"Line": 2,
"Time": 1,
"body_bytes_sent": 13,
"bytes_sent": 12,
"gzip_ration": 16,
"id": 4,
"labels": 0,
"post": 17,
"referrer": 14,
"remote_addr": 7,
"remote_user": 8,
"request": 6,
"request_length": 10,
"request_time": 20,
"server_name": 11,
"status": 5,
"time_local": 9,
"tsNs": 3,
"upstream_addr": 18,
"upstream_connect_time": 22,
"upstream_header_time": 23,
"upstream_response_time": 21,
"upstream_status": 19,
"user_agent": 15
},
"renameByName": {}
}
}
],
"type": "table"
},
{
"datasource": {
"default": false,
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "status"
},
"properties": [
{
"id": "custom.width",
"value": 70
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 50
},
"id": 11,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"editorMode": "code",
"expr": "{hostname=~\"$hostname\",unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | upstream_addr = \"$upstream_addr\" | status =~\"5..\"",
"queryType": "range",
"refId": "A"
}
],
"title": "5XX Requests Details",
"transformations": [
{
"id": "extractFields",
"options": {
"source": "Line"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Line": true,
"id": true,
"labels": true,
"server_name": true,
"time_local": true,
"tsNs": true,
"upstream_addr": true
},
"includeByName": {},
"indexByName": {
"Line": 2,
"Time": 1,
"body_bytes_sent": 13,
"bytes_sent": 12,
"gzip_ration": 16,
"id": 4,
"labels": 0,
"post": 17,
"referrer": 14,
"remote_addr": 7,
"remote_user": 8,
"request": 6,
"request_length": 10,
"request_time": 20,
"server_name": 11,
"status": 5,
"time_local": 9,
"tsNs": 3,
"upstream_addr": 18,
"upstream_connect_time": 22,
"upstream_header_time": 23,
"upstream_response_time": 21,
"upstream_status": 19,
"user_agent": 15
},
"renameByName": {}
}
}
],
"type": "table"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 58
},
"id": 18,
"panels": [],
"title": "Logs",
"type": "row"
},
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 59
},
"id": 20,
"maxPerRow": 2,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"pluginVersion": "11.4.0",
"repeat": "other_service",
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"editorMode": "code",
"expr": "{hostname=~\"$hostname\",unit=\"$other_service.service\"} | line_format \"{{ if hasPrefix \\\"{\\\" __line__ }}{{ with $parsed := fromJson __line__ }}[{{.app}} - {{.url}}] {{ if hasPrefix \\\"{\\\" .message }}{{ with $mParsed := fromJson .message }}{{ $mParsed.Message }} @ {{ $mParsed.File }}:{{ $mParsed.Line }}{{ end }}{{ else }}{{ .message }}{{ end }}{{ end }}{{ else }}{{ __line__ }}{{ end }}\"",
"queryType": "range",
"refId": "A"
}
],
"title": "Log: $other_service",
"type": "logs"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 89
},
"id": 15,
"panels": [],
"title": "Backup",
"type": "row"
},
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 90
},
"id": 16,
"maxPerRow": 2,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"pluginVersion": "11.4.0",
"repeat": "service_backup",
"repeatDirection": "h",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"editorMode": "code",
"expr": "{hostname=~\"$hostname\",unit=\"$service_backup.service\"}",
"legendFormat": "",
"queryType": "range",
"refId": "A"
}
],
"title": "Log: $service_backup",
"transformations": [
{
"disabled": true,
"id": "extractFields",
"options": {
"source": "Line"
}
},
{
"disabled": true,
"id": "organize",
"options": {
"excludeByName": {
"Line": true,
"id": true,
"labels": true,
"tsNs": true
},
"includeByName": {},
"indexByName": {},
"renameByName": {}
}
}
],
"type": "logs"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 100
},
"id": 13,
"panels": [],
"title": "Supporting Services",
"type": "row"
},
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "duration_ms"
},
"properties": [
{
"id": "custom.width",
"value": 100
}
]
},
{
"matcher": {
"id": "byName",
"options": "unit"
},
"properties": [
{
"id": "custom.width",
"value": 150
}
]
},
{
"matcher": {
"id": "byName",
"options": "statement"
},
"properties": [
{
"id": "custom.width",
"value": 505
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 101
},
"id": 10,
"links": [
{
"title": "explore",
"url": "https://grafana.tiserbox.com/explore?panes=%7B%22HWt%22:%7B%22datasource%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bunit%3D%5C%22nginx.service%5C%22%7D%20%7C%20pattern%20%5C%22%3C_%3E%20%3C_%3E%20%3Cline%3E%5C%22%20%7C%20line_format%20%5C%22%7B%7B.line%7D%7D%5C%22%20%7C%20json%20%7C%20status%20%21~%20%5C%222..%5C%22%20%7C%20__error__%20%21%3D%20%5C%22JSONParserErr%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1"
}
],
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": [
{
"desc": true,
"displayName": "Time"
}
]
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"editorMode": "code",
"expr": "{hostname=~\"$hostname\",unit=\"postgresql.service\"} | regexp \".*duration: (?P[0-9.]+) ms (?P.*)\" | duration_ms > 1000",
"queryType": "range",
"refId": "A"
}
],
"title": "Slow PostgreSQL Queries",
"transformations": [
{
"id": "extractFields",
"options": {
"keepTime": false,
"replace": false,
"source": "labels"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Line": true,
"Time": false,
"id": true,
"job": true,
"labels": true,
"tsNs": true,
"unit": true
},
"includeByName": {},
"indexByName": {
"Line": 6,
"Time": 0,
"duration_ms": 1,
"id": 8,
"job": 2,
"labels": 5,
"statement": 4,
"tsNs": 7,
"unit": 3
},
"renameByName": {}
}
}
],
"type": "table"
},
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 109
},
"id": 2,
"options": {
"dedupStrategy": "none",
"enableLogDetails": false,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"editorMode": "code",
"expr": "{unit=\"redis-$service.service\"} |= ``",
"queryType": "range",
"refId": "A"
}
],
"title": "Redis",
"type": "logs"
}
],
"preload": false,
"schemaVersion": 40,
"tags": [],
"templating": {
"list": [
{
"current": {
"text": [
"baryum"
],
"value": [
"baryum"
]
},
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"definition": "label_values(up,hostname)",
"includeAll": false,
"multi": true,
"name": "hostname",
"options": [],
"query": {
"qryType": 1,
"query": "label_values(up,hostname)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 1,
"regex": "",
"type": "query"
},
{
"current": {
"text": "nextcloud",
"value": "nextcloud"
},
"description": "",
"hide": 2,
"name": "service",
"query": "nextcloud",
"skipUrlSync": true,
"type": "constant"
},
{
"current": {
"text": "n",
"value": "n"
},
"hide": 2,
"name": "subdomain",
"query": "n",
"skipUrlSync": true,
"type": "constant"
},
{
"current": {
"text": "unix:/run/phpfpm/nextcloud.sock",
"value": "unix:/run/phpfpm/nextcloud.sock"
},
"hide": 2,
"name": "upstream_addr",
"query": "unix:/run/phpfpm/nextcloud.sock",
"skipUrlSync": true,
"type": "constant"
},
{
"current": {
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"definition": "label_values({unit_name=~\".*$service.*\", unit_name=~\".*backups.*\", unit_name!~\".*restore_gen\"},unit_name)",
"description": "",
"hide": 2,
"includeAll": true,
"name": "service_backup",
"options": [],
"query": {
"qryType": 1,
"query": "label_values({unit_name=~\".*$service.*\", unit_name=~\".*backups.*\", unit_name!~\".*restore_gen\"},unit_name)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 1,
"regex": "",
"sort": 1,
"type": "query"
},
{
"current": {
"text": "All",
"value": "$__all"
},
"definition": "label_values(netdata_systemd_service_unit_state_state_average{unit_name=~\".*nextcloud.*\", unit_name!~\".*backup.*\", unit_name!~\".*redis.*\"},unit_name)",
"hide": 2,
"includeAll": true,
"name": "other_service",
"options": [],
"query": {
"qryType": 1,
"query": "label_values(netdata_systemd_service_unit_state_state_average{unit_name=~\".*nextcloud.*\", unit_name!~\".*backup.*\", unit_name!~\".*redis.*\"},unit_name)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 1,
"regex": "",
"type": "query"
}
]
},
"time": {
"from": "now-2d",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Nextcloud",
"uid": "cdsszybv2gow0d",
"version": 49,
"weekStart": ""
}
================================================
FILE: modules/services/nextcloud-server/docs/default.md
================================================
# Nextcloud Server Service {#services-nextcloudserver}
Defined in [`/modules/services/nextcloud-server.nix`](@REPO@/modules/services/nextcloud-server.nix).
This NixOS module is a service that sets up a [Nextcloud Server](https://nextcloud.com/).
It is based on the nixpkgs Nextcloud server and provides opinionated defaults.
## Features {#services-nextcloudserver-features}
- Declarative [Apps](#services-nextcloudserver-options-shb.nextcloud.apps) Configuration - no need
to configure those with the UI.
- [LDAP](#services-nextcloudserver-usage-ldap) app:
enables app and sets up integration with an existing LDAP server, in this case LLDAP.
Note that the LDAP app cannot distinguish between normal users and admin users.
- [SSO](#services-nextcloudserver-usage-oidc) app:
enables app and sets up integration with an existing SSO server, in this case Authelia.
The SSO app can distinguish between normal users and admin users.
- [Preview Generator](#services-nextcloudserver-usage-previewgenerator) app:
enables app and sets up required cron job.
- [External Storage](#services-nextcloudserver-usage-externalstorage) app:
enables app and optionally configures one local mount.
This enables having data living on separate hard drives.
- [Only Office](#services-nextcloudserver-usage-onlyoffice) app:
enables app and sets up Only Office service.
- [Memories](#services-nextcloudserver-usage-memories) app:
enables app and sets up all required dependencies and optional hardware acceleration with VAAPI.
- [Recognize](#services-nextcloudserver-usage-recognize) app:
enables app and sets up all required dependencies and optional hardware acceleration with VAAPI.
- Any other app through the
[shb.nextcloud.extraApps](#services-nextcloudserver-options-shb.nextcloud.extraApps) option.
- Access through subdomain using reverse proxy.
- Forces Nginx as the reverse proxy. (This is hardcoded in the upstream nixpkgs module).
- Sets good defaults for trusted proxies settings, chunk size, opcache php options.
- Access through HTTPS using reverse proxy.
- Forces PostgreSQL as the database.
- Forces Redis as the cache and sets good defaults.
- Backup of the [`shb.nextcloud.dataDir`][dataDir] through the [backup block](./blocks-backup.html).
- [Monitoring Dashboard](#services-nextcloudserver-dashboard) for monitoring of reverse proxy, PHP-FPM, and database backups through the [monitoring block](./blocks-monitoring.html).
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard.
- [Integration Tests](@REPO@/test/services/nextcloud.nix)
- Tests system cron job is setup correctly.
- Tests initial admin user and password are setup correctly.
- Tests admin user can create and retrieve a file through WebDAV.
- Enables easy setup of xdebug for PHP debugging if needed.
- Easily add other apps declaratively through [extraApps][]
- By default automatically disables maintenance mode on start.
- By default automatically launches repair mode with expensive migrations on start.
- Access to advanced options not exposed here thanks to how NixOS modules work.
- Has a [demo](#services-nextcloudserver-demo).
[dataDir]: ./services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dataDir
## Usage {#services-nextcloudserver-usage}
### Nextcloud through HTTP {#services-nextcloudserver-usage-basic}
[HTTP]: #services-nextcloudserver-usage-basic
:::: {.note}
This section corresponds to the `basic` section of the [Nextcloud
demo](demo-nextcloud-server.html#demo-nextcloud-deploy-basic).
::::
Configuring Nextcloud to be accessible through Nginx reverse proxy
at the address `http://n.example.com`,
with PostgreSQL and Redis configured,
is done like so:
```nix
shb.nextcloud = {
enable = true;
domain = "example.com";
subdomain = "n";
defaultPhoneRegion = "US";
initialAdminUsername = "root";
adminPass.result = config.shb.sops.secret."nextcloud/adminpass".result;
};
shb.sops.secret."nextcloud/adminpass".request = config.shb.nextcloud.adminPass.request;
```
This assumes secrets are setup with SOPS as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual.
Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.
Note though that Nextcloud will not be very happy to be accessed through HTTP,
it much prefers - rightfully - to be accessed through HTTPS.
We will set that up in the next section.
You can now login as the admin user using the username `root`
and the password defined in `sops.secrets."nextcloud/adminpass"`.
### Nextcloud through HTTPS {#services-nextcloudserver-usage-https}
[HTTPS]: #services-nextcloudserver-usage-https
To setup HTTPS, we will get our certificates from Let's Encrypt using the HTTP method.
This is the easiest way to get started and does not require you to programmatically
configure a DNS provider.
Under the hood, we use the Self Host Block [SSL contract](./contracts-ssl.html).
It allows the end user to choose how to generate the certificates.
If you want other options to generate the certificate, follow the SSL contract link.
Building upon the [Basic Configuration](#services-nextcloudserver-usage-basic) above, we add:
```nix
shb.certs.certs.letsencrypt."example.com" = {
domain = "example.com";
group = "nginx";
reloadServices = [ "nginx.service" ];
adminEmail = "myemail@mydomain.com";
};
shb.certs.certs.letsencrypt."example.com".extraDomains = [ "n.example.com" ];
shb.nextcloud = {
ssl = config.shb.certs.certs.letsencrypt."example.com";
};
```
### Choose Nextcloud Version {#services-nextcloudserver-usage-version}
Self Host Blocks is conservative in the version of Nextcloud it's using.
To choose the version and upgrade at the time of your liking,
just use the [version](#services-nextcloudserver-options-shb.nextcloud.version) option:
```nix
shb.nextcloud.version = 29;
```
### Mount Point {#services-nextcloudserver-usage-mount-point}
If the `dataDir` exists in a mount point,
it is highly recommended to make the various Nextcloud services wait on the mount point before starting.
Doing that is just a matter of setting the `mountPointServices` option.
Assuming a mount point on `/var`, the configuration would look like so:
```nix
fileSystems."/var".device = "...";
shb.nextcloud.mountPointServices = [ "var.mount" ];
```
### With LDAP Support {#services-nextcloudserver-usage-ldap}
[LDAP]: #services-nextcloudserver-usage-ldap
:::: {.note}
This section corresponds to the `ldap` section of the [Nextcloud
demo](demo-nextcloud-server.html#demo-nextcloud-deploy-ldap).
::::
We will build upon the [HTTP][] and [HTTPS][] sections,
so please read those first.
We will use the [LLDAP block][] provided by Self Host Blocks.
Assuming it [has been set already][LLDAP block setup], add the following configuration:
[LLDAP block]: blocks-lldap.html
[LLDAP block setup]: blocks-lldap.html#blocks-lldap-global-setup
```nix
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/adminPassword".result
userGroup = "nextcloud_user";
};
shb.sops.secret."nextcloud/ldap/adminPassword" = {
request = config.shb.nextcloud.apps.ldap.adminPassword.request;
settings.key = "ldap/userPassword";
};
```
The LDAP admin password must be shared between `shb.lldap` and `shb.nextcloud`,
to do that with SOPS we use the `key` option so that both
`sops.secrets."ldap/userPassword"`
and `sops.secrets."nextcloud/ldapUserPassword"`
secrets have the same content.
The LDAP [user group](#services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup) is created automatically.
Add your user to it by going to `http://ldap.example.com`,
create a user if needed and add it to the group.
When that's done, go back to the Nextcloud server at
`https://nextcloud.example.com` and login with that user.
Note that we cannot create an admin user from the LDAP server,
so you need to create a normal user like above,
login with it once so it is known to Nextcloud, then logout,
login with the admin Nextcloud user and promote that new user to admin level.
This limitation does not exist with the [SSO integration](#services-nextcloudserver-usage-oidc).
### With SSO Support {#services-nextcloudserver-usage-oidc}
:::: {.note}
This section corresponds to the `sso` section of the [Nextcloud
demo](demo-nextcloud-server.html#demo-nextcloud-deploy-sso).
::::
We will build upon the [HTTP][], [HTTPS][] and [LDAP][] sections,
so please read those first.
We will use the [SSO block][] provided by Self Host Blocks.
Assuming it [has been set already][SSO block setup], add the following configuration:
[SSO block]: blocks-sso.html
[SSO block setup]: blocks-sso.html#blocks-sso-global-setup
```nix
shb.nextcloud.apps.sso = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "nextcloud";
fallbackDefaultAuth = false;
secret.result = config.shb.sops.secret."nextcloud/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secret."nextcloud/sso/secretForAuthelia".result;
};
shb.sops.secret."nextcloud/sso/secret".request = config.shb.nextcloud.apps.sso.secret.request;
shb.sops.secret."nextcloud/sso/secretForAuthelia" = {
request = config.shb.nextcloud.apps.sso.secretForAuthelia.request;
settings.key = "nextcloud/sso/secret";
};
```
The SSO secret must be shared between `shb.authelia` and `shb.nextcloud`,
to do that with SOPS we use the `key` option so that both
`sops.secrets."nextcloud/sso/secret"`
and `sops.secrets."nextcloud/sso/secretForAuthelia"`
secrets have the same content.
The LDAP [user group](#services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup) and [admin group](#services-nextcloudserver-options-shb.nextcloud.apps.sso.adminGroup) are created automatically.
Add your user to one or both by going to `http://ldap.example.com`,
create a user if needed and add it to the groups.
When that's done, go back to the Nextcloud server at
`https://nextcloud.example.com` and login with that user.
Setting the `fallbackDefaultAuth` to `false` means the only way to login is through Authelia.
If this does not work for any reason, you can let users login through Nextcloud directly by setting this option to `true`.
### Tweak PHPFpm Config {#services-nextcloudserver-usage-phpfpm}
For instances with more users, or if you feel the pages are loading slowly,
you can tweak the `php-fpm` pool settings.
```nix
shb.nextcloud.phpFpmPoolSettings = {
"pm" = "static"; # Can be dynamic
"pm.max_children" = 150;
# "pm.start_servers" = 300;
# "pm.min_spare_servers" = 300;
# "pm.max_spare_servers" = 500;
# "pm.max_spawn_rate" = 50;
# "pm.max_requests" = 50;
# "pm.process_idle_timeout" = "20s";
};
```
I don't have a good heuristic for what are good values here but what I found
is that you don't want too high of a `max_children` value
to avoid I/O strain on the hard drives, especially if you use spinning drives.
To see the effect of your settings,
go to the provided [Grafana dashboard](#services-nextcloudserver-dashboard).
### Tweak PostgreSQL Settings {#services-nextcloudserver-usage-postgres}
These settings will impact all databases since the NixOS Postgres module
configures only one Postgres instance.
To know what values to put here, use [https://pgtune.leopard.in.ua/](https://pgtune.leopard.in.ua/).
Remember the server hosting PostgreSQL is shared at least with the Nextcloud service and probably others.
So to avoid PostgreSQL hogging all the resources, reduce the values you give on that website
for CPU, available memory, etc.
For example, I put 12 GB of memory and 4 CPUs while I had more:
- `DB Version`: 14
- `OS Type`: linux
- `DB Type`: dw
- `Total Memory (RAM)`: 12 GB
- `CPUs num`: 4
- `Data Storage`: ssd
And got the following values:
```nix
shb.nextcloud.postgresSettings = {
max_connections = "400";
shared_buffers = "3GB";
effective_cache_size = "9GB";
maintenance_work_mem = "768MB";
checkpoint_completion_target = "0.9";
wal_buffers = "16MB";
default_statistics_target = "100";
random_page_cost = "1.1";
effective_io_concurrency = "200";
work_mem = "7864kB";
huge_pages = "off";
min_wal_size = "1GB";
max_wal_size = "4GB";
max_worker_processes = "4";
max_parallel_workers_per_gather = "2";
max_parallel_workers = "4";
max_parallel_maintenance_workers = "2";
};
```
To see the effect of your settings,
go to the provided [Grafana dashboard](#services-nextcloudserver-dashboard).
### Backup {#services-nextcloudserver-usage-backup}
Backing up Nextcloud data files using the [Restic block](blocks-restic.html) is done like so:
```nix
shb.restic.instances."nextcloud" = {
request = config.shb.nextcloud.backup;
settings = {
enable = true;
};
};
```
The name `"nextcloud"` in the `instances` can be anything.
The `config.shb.nextcloud.backup` option provides what directories to backup.
You can define any number of Restic instances to backup Nextcloud multiple times.
For backing up the Nextcloud database using the same Restic block, do like so:
```nix
shb.restic.instances."postgres" = {
request = config.shb.postgresql.databasebackup;
settings = {
enable = true;
};
};
```
Note that this will backup the whole PostgreSQL instance,
not just the Nextcloud database.
This limitation will be lifted in the future.
### Application Dashboard {#services-nextcloudserver-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-nextcloudserver-options-shb.nextcloud.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Documents.services.Nextcloud = {
sortOrder = 1;
dashboard.request = config.shb.nextcloud.dashboard.request;
};
}
```
### Enable Preview Generator App {#services-nextcloudserver-usage-previewgenerator}
The following snippet installs and enables the [Preview
Generator](https://apps.nextcloud.com/apps/previewgenerator) application as well as creates the
required cron job that generates previews every 10 minutes.
```nix
shb.nextcloud.apps.previewgenerator.enable = true;
```
Note that you still need to generate the previews for any pre-existing files with:
```bash
nextcloud-occ -vvv preview:generate-all
```
The default settings generates all possible sizes which is a waste since most are not used. SHB will
change the generation settings to optimize disk space and CPU usage as outlined in [this
article](http://web.archive.org/web/20200513043150/https://ownyourbits.com/2019/06/29/understanding-and-improving-nextcloud-previews/).
You can opt-out with:
```nix
shb.nextcloud.apps.previewgenerator.recommendedSettings = false;
```
### Enable External Storage App {#services-nextcloudserver-usage-externalstorage}
The following snippet installs and enables the [External
Storage](https://docs.nextcloud.com/server/28/go.php?to=admin-external-storage) application.
```nix
shb.nextcloud.apps.externalStorage.enable = true;
```
Adding external storage can then be done through the UI.
For the special case of mounting a local folder as an external storage,
Self Host Blocks provides options.
The following snippet will mount the `/srv/nextcloud/$user` local file
in each user's `/home` Nextcloud directory.
```nix
shb.nextcloud.apps.externalStorage.userLocalMount = {
rootDirectory = "/srv/nextcloud/$user";
mountName = "home";
};
```
You can even make the external storage mount in the root `/` Nextcloud directory with:
```nix
shb.nextcloud.apps.externalStorage.userLocalMount = {
mountName = "/";
};
```
Recommended use of this app is to have the Nextcloud's `dataDir` on a SSD
and the `userLocalMount` on a HDD.
Indeed, a SSD is much quicker than a spinning hard drive,
which is well suited for randomly accessing small files like thumbnails.
On the other side, a spinning hard drive can store more data
which is well suited for storing user data.
This Nextcloud module includes a patch that allows the external storage
to actually create the local path. Normally, when login in for the first time,
the user will be greeted with an error saying the external storage path does
not exist. One must then create it manually. With this patch, Nextcloud
creates the path.
### Enable OnlyOffice App {#services-nextcloudserver-usage-onlyoffice}
The following snippet installs and enables the [Only
Office](https://apps.nextcloud.com/apps/onlyoffice) application as well as sets up an Only Office
instance listening at `onlyoffice.example.com` that only listens on the local network.
```nix
shb.nextcloud.apps.onlyoffice = {
enable = true;
subdomain = "onlyoffice";
localNextworkIPRange = "192.168.1.1/24";
};
```
Also, you will need to explicitly allow the package `corefonts`:
```nix
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [
"corefonts"
];
```
### Enable Memories App {#services-nextcloudserver-usage-memories}
The following snippet installs and enables the
[Memories](https://apps.nextcloud.com/apps/memories) application.
```nix
shb.nextcloud.apps.memories = {
enable = true;
vaapi = true; # If hardware acceleration is supported.
photosPath = "/Photos"; # This is the default.
};
```
All the following dependencies are installed correctly
and fully declaratively, the config page is "all green":
- Exiftool with the correct version
- Indexing path is set to `/Photos` by default.
- Images, HEIC, videos preview generation.
- Performance is all green with database triggers.
- Recommended apps are
- Albums: this is installed by default.
- Recognize can be installed [here](#services-nextcloudserver-usage-recognize)
- Preview Generator can be installed [here](#services-nextcloudserver-usage-previewgenerator)
- Reverse Geocoding must be triggered manually with `nextcloud-occ memories:places-setup `.
- Video streaming is setup by installed ffmpeg headless.
- Transcoder is setup natively (not with slow WASM) wit `go-vod` binary.
- Hardware Acceleration is optionally setup by setting `vaapi` to `true`.
It is not required but you can for the first indexing with `nextcloud-occ memories:index`.
Note that the app is not configurable through the UI since the config file is read-only.
### Enable Recognize App {#services-nextcloudserver-usage-recognize}
The following snippet installs and enables the
[Recognize](https://apps.nextcloud.com/apps/recognize) application.
```nix
shb.nextcloud.apps.recognize = {
enable = true;
};
```
The required dependencies are installed: `nodejs` and `nice`.
### Enable Monitoring {#services-nextcloudserver-server-usage-monitoring}
Enable the [monitoring block](./blocks-monitoring.html).
A [Grafana dashboard][] for overall server performance will be created
and the Nextcloud metrics will automatically appear there.
[Grafana dashboard]: ./blocks-monitoring.html#blocks-monitoring-performance-dashboard
### Enable Tracing {#services-nextcloudserver-server-usage-tracing}
You can enable tracing with:
```nix
shb.nextcloud.debug = true;
```
Traces will be located at `/var/log/xdebug`.
See [my blog post][] for how to look at the traces.
I want to make the traces available in Grafana directly
but that's not the case yet.
[my blog post]: http://blog.tiserbox.com/posts/2023-08-12-what%27s-up-with-nextcloud-webdav-slowness.html
### Appdata Location {#services-nextcloudserver-server-usage-appdata}
The appdata folder is a special folder located under the `shb.nextcloud.dataDir` directory.
It is named `appdata_` with the Nextcloud's instance ID as a suffix.
You can find your current instance ID with `nextcloud-occ config:system:get instanceid`.
In there, you will find one subfolder for every installed app that needs to store files.
For performance reasons, it is recommended to store this folder on a fast drive
that is optimized for randomized read and write access.
The best would be either an SSD or an NVMe drive.
The best way to solve this is to use the [External Storage app](#services-nextcloudserver-usage-externalstorage).
If you have an existing installation and put Nextcloud's `shb.nextcloud.dataDir` folder on a HDD with spinning disks,
then the appdata folder is also located on spinning drives.
One way to solve this is to bind mount a folder from an SSD over the appdata folder.
SHB does not provide a declarative way to setup this
as the external storage app is the preferred way
but this command should be enough:
```bash
mount /dev/sdd /srv/sdd
mkdir -p /srv/sdd/appdata_nextcloud
mount --bind /srv/sdd/appdata_nextcloud /var/lib/nextcloud/data/appdata_ocxvky2f5ix7
```
Note that you can re-generate a new appdata folder
by issuing the command `nextcloud-occ config:system:delete instanceid`.
## Demo {#services-nextcloudserver-demo}
Head over to the [Nextcloud demo](demo-nextcloud-server.html) for a demo that installs Nextcloud with or
without LDAP integration on a VM with minimal manual steps.
## Monitoring Dashboard {#services-nextcloudserver-dashboard}
The dashboard is added to Grafana automatically under "Self Host Blocks > Nextcloud"
as long as the Nextcloud service is [enabled][]
as well as the [monitoring block][].
[enabled]: #services-nextcloudserver-options-shb.nextcloud.enable
[monitoring block]: ./blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.enable
- The *General* section shows Nextcloud related services.
This includes cronjobs, Redis and backup jobs.
- *CPU* shows stall time which means CPU is maxed out.
This graph is inverted so having a small area at the top means the stall time is low.
- *Memory* shows stall time which means some job is waiting on memory to be allocated.
This graph is inverted so having a small area at the top means the stall time is low.
Some stall time will always be present. Under 10% is fine
but having constantly over 50% usually means available memory is low and SWAP is being used.
*Memory* also shows available memory which is the remaining allocatable memory.
- Caveat: *Network I/O* shows the network input and output for
all services running, not only those related to Nextcloud.
- *Disk I/O* shows "some" stall time which means some jobs were waiting on disk I/O.
Disk is usually the slowest bottleneck so having "some" stall time is not surprising.
Fixing this can be done by using disks allowing higher speeds or switching to SSDs.
If the "full" stall time is shown, this means _all_ jobs were waiting on disk i/o which
can be more worrying. This could indicate a failing disk if "full" stall time appeared recently.
These graphs are inverted so having a small area at the top means the stall time is low.
*Memory* also shows available memory which is the remaining allocatable memory.

- *PHP-FPM Processes* shows how many processes are used by PHP-FPM.
The orange area goes from 80% to 90% of the maximum allowed processes.
The read area goes from 90% to 100% of the maximum allowed processes.
If the number of active processes reaches those areas once in a while, that's fine
but if it happens most of the time, the maximum allowed processes should be increased.
- *PHP-FPM Request Duration* shows one dot per request and how long it took.
Request time is fine if it is under 400ms.
If most requests take longer than that, some [tracing](#services-nextcloudserver-server-usage-tracing)
is required to understand which subsystem is taking some time.
That being said, maybe another graph in this dashboard will show
why the requests are slow - like disk
or other processes hoarding some resources running at the same time.
- *PHP-FPM Requests Queue Length* shows how many requests are waiting
to be picked up by a PHP-FPM process. Usually, this graph won't show
anything as long as the *PHP-FPM Processes* graph is not in the red area.
Fixing this requires also increasing the maximum allowed processes.

- *Requests Details* shows all requests to the Nextcloud service and the related headers.
- *5XX Requests Details* shows only the requests having a 500 to 599 http status.
Having any requests appearing here should be investigated as soon as possible.

- *Log: \* shows all logs from related systemd `.service` job.
Having no line here most often means the job ran
at a time not currently included in the time range of the dashboard.


- A lot of care has been taken to parse error messages correctly.
Nextcloud mixes json and non-json messages so extracting errors
from json messages was not that easy.
Also, the stacktrace is reduced.
The result though is IMO pretty nice as can be seen by the following screenshot.
The top line is the original json message and the bottom one is the parsed error.

- *Backup logs* show the output of the backup jobs.
Here, there are two backup jobs, one for the core files of Nextcloud
stored on an SSD which includes the appdata folder.
The other backup job is for the external data stored on HDDs which contain all user files.

- *Slow PostgreSQL queries* shows all database queries taking longer than 1s to run.
- *Redis* shows all Redis log output.

## Debug {#services-nextcloudserver-debug}
On the command line, the `occ` tool is called `nextcloud-occ`.
In case of an issue, check the logs for any systemd service mentioned in this section.
On startup, the oneshot systemd service `nextcloud-setup.service` starts. After it finishes, the
`phpfpm-nextcloud.service` starts to serve Nextcloud. The `nginx.service` is used as the reverse
proxy. `postgresql.service` run the database.
Nextcloud' configuration is found at `${shb.nextcloud.dataDir}/config/config.php`. Nginx'
configuration can be found with `systemctl cat nginx | grep -om 1 -e "[^ ]\+conf"`.
Enable verbose logging by setting the `shb.nextcloud.debug` boolean to `true`.
Access the database with `sudo -u nextcloud psql`.
Access Redis with `sudo -u nextcloud redis-cli -s /run/redis-nextcloud/redis.sock`.
## Options Reference {#services-nextcloudserver-options}
```{=include=} options
id-prefix: services-nextcloudserver-options-
list-id: selfhostblocks-service-nextcloud-options
source: @OPTIONS_JSON@
```
================================================
FILE: modules/services/nextcloud-server.nix
================================================
{
config,
pkgs,
lib,
shb,
...
}:
let
cfg = config.shb.nextcloud;
fqdn = "${cfg.subdomain}.${cfg.domain}";
fqdnWithPort = if isNull cfg.port then fqdn else "${fqdn}:${toString cfg.port}";
protocol = if !(isNull cfg.ssl) then "https" else "http";
ssoFqdnWithPort =
if isNull cfg.apps.sso.port then
cfg.apps.sso.endpoint
else
"${cfg.apps.sso.endpoint}:${toString cfg.apps.sso.port}";
nextcloudPkg = builtins.getAttr ("nextcloud" + builtins.toString cfg.version) pkgs;
nextcloudApps =
(builtins.getAttr ("nextcloud" + builtins.toString cfg.version + "Packages") pkgs).apps;
occ = "${config.services.nextcloud.occ}/bin/nextcloud-occ";
in
{
imports = [
../../lib/module.nix
../blocks/authelia.nix
../blocks/monitoring.nix
(lib.mkRenamedOptionModule
[ "shb" "nextcloud" "adminUser" ]
[ "shb" "nextcloud" "initialAdminUsername" ]
)
];
options.shb.nextcloud = {
enable = lib.mkEnableOption "the SHB Nextcloud service";
enableDashboard = lib.mkEnableOption "the Nextcloud SHB dashboard" // {
default = true;
};
subdomain = lib.mkOption {
type = lib.types.str;
description = ''
Subdomain under which Nextcloud will be served.
```
.[:]
```
'';
example = "nextcloud";
};
domain = lib.mkOption {
description = ''
Domain under which Nextcloud is served.
```
.[:]
```
'';
type = lib.types.str;
example = "domain.com";
};
port = lib.mkOption {
description = ''
Port under which Nextcloud will be served. If null is given, then the port is omitted.
```
.[:]
```
'';
type = lib.types.nullOr lib.types.port;
default = null;
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
externalFqdn = lib.mkOption {
description = "External fqdn used to access Nextcloud. Defaults to .. This should only be set if you include the port when accessing Nextcloud.";
type = lib.types.nullOr lib.types.str;
example = "nextcloud.domain.com:8080";
default = null;
};
version = lib.mkOption {
description = "Nextcloud version to choose from.";
type = lib.types.enum [
32
33
];
default = 32;
};
dataDir = lib.mkOption {
description = "Folder where Nextcloud will store all its data.";
type = lib.types.str;
default = "/var/lib/nextcloud";
};
mountPointServices = lib.mkOption {
description = "If given, all the systemd services and timers will depend on the specified mount point systemd services.";
type = lib.types.listOf lib.types.str;
default = [ ];
example = lib.literalExpression ''["var.mount"]'';
};
initialAdminUsername = lib.mkOption {
type = lib.types.str;
description = "Initial username of the admin user. Once it is set, it cannot be changed!";
default = "root";
};
adminPass = lib.mkOption {
description = "Nextcloud admin password.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "nextcloud";
restartUnits = [ "phpfpm-nextcloud.service" ];
};
};
};
maxUploadSize = lib.mkOption {
default = "4G";
type = lib.types.str;
description = ''
The upload limit for files. This changes the relevant options
in php.ini and nginx if enabled.
'';
};
defaultPhoneRegion = lib.mkOption {
type = lib.types.str;
description = ''
Two letters region defining default region.
'';
example = "US";
};
postgresSettings = lib.mkOption {
type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
default = null;
description = ''
Settings for the PostgreSQL database.
Go to https://pgtune.leopard.in.ua/ and copy the generated configuration here.
'';
example = lib.literalExpression ''
{
# From https://pgtune.leopard.in.ua/ with:
# DB Version: 14
# OS Type: linux
# DB Type: dw
# Total Memory (RAM): 7 GB
# CPUs num: 4
# Connections num: 100
# Data Storage: ssd
max_connections = "100";
shared_buffers = "1792MB";
effective_cache_size = "5376MB";
maintenance_work_mem = "896MB";
checkpoint_completion_target = "0.9";
wal_buffers = "16MB";
default_statistics_target = "500";
random_page_cost = "1.1";
effective_io_concurrency = "200";
work_mem = "4587kB";
huge_pages = "off";
min_wal_size = "4GB";
max_wal_size = "16GB";
max_worker_processes = "4";
max_parallel_workers_per_gather = "2";
max_parallel_workers = "4";
max_parallel_maintenance_workers = "2";
}
'';
};
phpFpmPoolSettings = lib.mkOption {
type = lib.types.nullOr (lib.types.attrsOf lib.types.anything);
description = "Settings for PHPFPM.";
default = {
"pm" = "static";
"pm.max_children" = 5;
"pm.start_servers" = 5;
};
example = lib.literalExpression ''
{
"pm" = "dynamic";
"pm.max_children" = 50;
"pm.start_servers" = 25;
"pm.min_spare_servers" = 10;
"pm.max_spare_servers" = 20;
"pm.max_spawn_rate" = 50;
"pm.max_requests" = 50;
"pm.process_idle_timeout" = "20s";
}
'';
};
phpFpmPrometheusExporter = lib.mkOption {
description = "Settings for exporting";
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkOption {
description = "Enable export of php-fpm metrics to Prometheus.";
type = lib.types.bool;
default = true;
};
port = lib.mkOption {
description = "Port on which the exporter will listen.";
type = lib.types.port;
default = 8300;
};
};
};
};
apps = lib.mkOption {
description = ''
Applications to enable in Nextcloud. Enabling an application here will also configure
various services needed for this application.
Enabled apps will automatically be installed, enabled and configured, so no need to do that
through the UI. You can still make changes but they will be overridden on next deploy. You
can still install and configure other apps through the UI.
'';
default = { };
type = lib.types.submodule {
options = {
onlyoffice = lib.mkOption {
description = ''
Only Office App. [Nextcloud App Store](https://apps.nextcloud.com/apps/onlyoffice)
Enabling this app will also start an OnlyOffice instance accessible at the given
subdomain from the given network range.
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Nextcloud OnlyOffice App";
subdomain = lib.mkOption {
type = lib.types.str;
description = "Subdomain under which Only Office will be served.";
default = "oo";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
localNetworkIPRange = lib.mkOption {
type = lib.types.str;
description = "Local network range, to restrict access to Open Office to only those IPs.";
default = "192.168.1.1/24";
};
jwtSecretFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = ''
File containing the JWT secret. This option is required.
Must be readable by the nextcloud system user.
'';
default = null;
};
};
};
};
previewgenerator = lib.mkOption {
description = ''
Preview Generator App. [Nextcloud App Store](https://apps.nextcloud.com/apps/previewgenerator)
Enabling this app will create a cron job running every minute to generate thumbnails
for new and updated files.
To generate thumbnails for already existing files, run:
```
nextcloud-occ -vvv preview:generate-all
```
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Nextcloud Preview Generator App";
recommendedSettings = lib.mkOption {
type = lib.types.bool;
description = ''
Better defaults than the defaults. Taken from [this article](http://web.archive.org/web/20200513043150/https://ownyourbits.com/2019/06/29/understanding-and-improving-nextcloud-previews/).
Sets the following options:
```
nextcloud-occ config:app:set previewgenerator squareSizes --value="32 256"
nextcloud-occ config:app:set previewgenerator widthSizes --value="256 384"
nextcloud-occ config:app:set previewgenerator heightSizes --value="256"
nextcloud-occ config:system:set preview_max_x --type integer --value 2048
nextcloud-occ config:system:set preview_max_y --type integer --value 2048
nextcloud-occ config:system:set jpeg_quality --value 60
nextcloud-occ config:app:set preview jpeg_quality --value=60
```
'';
default = true;
example = false;
};
debug = lib.mkOption {
type = lib.types.bool;
description = "Enable more verbose logging.";
default = false;
example = true;
};
};
};
};
externalStorage = lib.mkOption {
# TODO: would be nice to have quota include external storage but it's not supported for root:
# https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_configuration.html#setting-storage-quotas
description = ''
External Storage App. [Manual](https://docs.nextcloud.com/server/28/go.php?to=admin-external-storage)
Set `userLocalMount` to automatically add a local directory as an external storage.
Use this option if you want to store user data in another folder or another hard drive
altogether.
In the `directory` option, you can use either `$user` and/or `$home` which will be
replaced by the user's name and home directory.
Recommended use of this option is to have the Nextcloud's `dataDir` on a SSD and the
`userLocalRooDirectory` on a HDD. Indeed, a SSD is much quicker than a spinning hard
drive, which is well suited for randomly accessing small files like thumbnails. On the
other side, a spinning hard drive can store more data which is well suited for storing
user data.
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Nextcloud External Storage App";
userLocalMount = lib.mkOption {
default = null;
description = "If set, adds a local mount as external storage.";
type = lib.types.nullOr (
lib.types.submodule {
options = {
directory = lib.mkOption {
type = lib.types.str;
description = ''
Local directory on the filesystem to mount. Use `$user` and/or `$home`
which will be replaced by the user's name and home directory.
'';
example = "/srv/nextcloud/$user";
};
mountName = lib.mkOption {
type = lib.types.str;
description = ''
Path of the mount in Nextcloud. Use `/` to mount as the root.
'';
default = "";
example = [
"home"
"/"
];
};
};
}
);
};
};
};
};
ldap = lib.mkOption {
description = ''
LDAP Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html)
Enabling this app will create a new LDAP configuration or update one that exists with
the given host.
'';
default = { };
type = lib.types.nullOr (
lib.types.submodule {
options = {
enable = lib.mkEnableOption "LDAP app.";
host = lib.mkOption {
type = lib.types.str;
description = ''
Host serving the LDAP server.
'';
default = "127.0.0.1";
};
port = lib.mkOption {
type = lib.types.port;
description = ''
Port of the service serving the LDAP server.
'';
default = 389;
};
dcdomain = lib.mkOption {
type = lib.types.str;
description = "dc domain for ldap.";
example = "dc=mydomain,dc=com";
};
adminName = lib.mkOption {
type = lib.types.str;
description = "Admin user of the LDAP server.";
default = "admin";
};
adminPassword = lib.mkOption {
description = "LDAP server admin password.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "nextcloud";
restartUnits = [ "phpfpm-nextcloud.service" ];
};
};
};
userGroup = lib.mkOption {
type = lib.types.str;
description = "Group users must belong to to be able to login to Nextcloud.";
default = "nextcloud_user";
};
configID = lib.mkOption {
type = lib.types.int;
description = ''
Multiple LDAP configs can co-exist with only one active at a time.
This option sets the config ID used by Self Host Blocks.
'';
default = 50;
};
};
}
);
};
sso = lib.mkOption {
description = ''
SSO Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/oidc_auth.html)
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "SSO app.";
endpoint = lib.mkOption {
type = lib.types.str;
description = "OIDC endpoint for SSO.";
example = "https://authelia.example.com";
};
port = lib.mkOption {
description = "If given, adds a port to the endpoint.";
type = lib.types.nullOr lib.types.port;
default = null;
};
provider = lib.mkOption {
type = lib.types.enum [ "Authelia" ];
description = "OIDC provider name, used for display.";
default = "Authelia";
};
clientID = lib.mkOption {
type = lib.types.str;
description = "Client ID for the OIDC endpoint.";
default = "nextcloud";
};
authorization_policy = lib.mkOption {
type = lib.types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor";
};
adminGroup = lib.mkOption {
type = lib.types.str;
description = ''
Group admins must belong to to be able to login to Nextcloud.
This option is purposely not inside the LDAP app because only SSO allows
distinguising between users and admins.
'';
default = "nextcloud_admin";
};
secret = lib.mkOption {
description = "OIDC shared secret.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "nextcloud";
restartUnits = [ "phpfpm-nextcloud.service" ];
};
};
};
secretForAuthelia = lib.mkOption {
description = "OIDC shared secret. Content must be the same as `secretFile` option.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "authelia";
};
};
};
fallbackDefaultAuth = lib.mkOption {
type = lib.types.bool;
description = ''
Fallback to normal Nextcloud auth if something goes wrong with the SSO app.
Usually, you want to enable this to transfer existing users to LDAP and then you
can disabled it.
'';
default = false;
};
};
};
};
memories = lib.mkOption {
description = ''
Memories App. [Nextcloud App Store](https://apps.nextcloud.com/apps/memories)
Enabling this app will set up the Memories app and configure all its dependencies.
On first install, you can either let the cron job index all images or you can run it manually with:
```nix
nextcloud-occ memories:index
```
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Memories app.";
vaapi = lib.mkOption {
type = lib.types.bool;
description = ''
Enable VAAPI transcoding.
Will make `nextcloud` user part of the `render` group to be able to access
`/dev/dri/renderD128`.
'';
default = false;
};
photosPath = lib.mkOption {
type = lib.types.str;
description = ''
Path where photos are stored in Nextcloud.
'';
default = "/Photos";
};
};
};
};
recognize = lib.mkOption {
description = ''
Recognize App. [Nextcloud App Store](https://apps.nextcloud.com/apps/recognize)
Enabling this app will set up the Recognize app and configure all its dependencies.
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Recognize app.";
};
};
};
};
};
};
extraApps = lib.mkOption {
type = lib.types.raw;
description = ''
Extra apps to install.
Should be a function returning an `attrSet` of `appid` as keys to `packages` as values,
like generated by `fetchNextcloudApp`.
The appid must be identical to the `id` value in the apps'
`appinfo/info.xml`.
Search in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/master/pkgs/servers/nextcloud/packages) for the `NN.json` files for existing apps.
You can still install apps through the appstore.
'';
default = null;
example = lib.literalExpression ''
apps: {
inherit (apps) mail calendar contact;
phonetrack = pkgs.fetchNextcloudApp {
name = "phonetrack";
sha256 = "0qf366vbahyl27p9mshfma1as4nvql6w75zy2zk5xwwbp343vsbc";
url = "https://gitlab.com/eneiluj/phonetrack-oc/-/wikis/uploads/931aaaf8dca24bf31a7e169a83c17235/phonetrack-0.6.9.tar.gz";
version = "0.6.9";
};
}
'';
};
backup = lib.mkOption {
description = ''
Backup configuration.
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.backup.mkRequester {
user = "nextcloud";
sourceDirectories = [
cfg.dataDir
];
excludePatterns = [ ".rnd" ];
};
};
};
debug = lib.mkOption {
type = lib.types.bool;
description = "Enable more verbose logging.";
default = false;
example = true;
};
tracing = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Enable xdebug tracing.
To trigger writing a trace to `/var/log/xdebug`, add a the following header:
```
XDEBUG_TRACE
```
The response will contain the following header:
```
x-xdebug-profile-filename /var/log/xdebug/cachegrind.out.63484
```
'';
default = null;
example = "debug_me";
};
autoDisableMaintenanceModeOnStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Upon starting the service, disable maintenance mode if set.
This is useful if a deploy failed and you try to redeploy.
Note that even if the disabling of maintenance mode fails,
SHB will still allow the startup to continue
because there are valid reasons for maintenance mode
to not be able to be lifted, like for example this is a brand new installation.
'';
};
alwaysApplyExpensiveMigrations = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Run `occ maintenance:repair --include-expensive` on service start.
Larger instances should disable this and run the command at a convenient time
but SHB assumes that it will not be the case for most users.
Note that SHB will still allow the startup
even if the repair failed.
'';
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${fqdn}";
externalUrlText = "https://\${config.shb.nextcloud.subdomain}.\${config.shb.nextcloud.domain}";
internalUrl = "https://${fqdn}";
internalUrlText = "https://\${config.shb.nextcloud.subdomain}.\${config.shb.nextcloud.domain}";
};
};
};
};
config = lib.mkMerge [
(lib.mkIf cfg.enable {
users.users = {
nextcloud = {
name = "nextcloud";
group = "nextcloud";
isSystemUser = true;
};
};
# LDAP is manually configured through
# https://github.com/lldap/lldap/blob/main/example_configs/nextcloud.md, see also
# https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html
#
# Verify setup with:
# - On admin page
# - https://scan.nextcloud.com/
# - https://www.ssllabs.com/ssltest/
# As of writing this, we got no warning on admin page and A+ on both tests.
#
# Content-Security-Policy is hard. I spent so much trying to fix lingering issues with .js files
# not loading to realize those scripts are inserted by extensions. Doh.
services.nextcloud = {
enable = true;
package = nextcloudPkg.overrideAttrs (old: {
patches = [
../../patches/nextcloudexternalstorage.patch
];
});
datadir = cfg.dataDir;
hostName = fqdn;
nginx.hstsMaxAge = 31536000; # Needs > 1 year for https://hstspreload.org to be happy
inherit (cfg) maxUploadSize;
config = {
dbtype = "pgsql";
adminuser = cfg.initialAdminUsername;
adminpassFile = cfg.adminPass.result.path;
};
database.createLocally = true;
# Enable caching using redis https://nixos.wiki/wiki/Nextcloud#Caching.
configureRedis = true;
caching.apcu = false;
# https://docs.nextcloud.com/server/26/admin_manual/configuration_server/caching_configuration.html
caching.redis = true;
# Adds appropriate nginx rewrite rules.
webfinger = true;
# Very important for a bunch of scripts to load correctly. Otherwise you get Content-Security-Policy errors. See https://docs.nextcloud.com/server/13/admin_manual/configuration_server/harden_server.html#enable-http-strict-transport-security
https = !(isNull cfg.ssl);
extraApps = if isNull cfg.extraApps then { } else cfg.extraApps nextcloudApps;
extraAppsEnable = true;
appstoreEnable = true;
settings =
let
protocol = if !(isNull cfg.ssl) then "https" else "http";
in
{
"default_phone_region" = cfg.defaultPhoneRegion;
"overwrite.cli.url" = "${protocol}://${fqdn}";
"overwritehost" = fqdnWithPort;
# 'trusted_domains' needed otherwise we get this issue https://help.nextcloud.com/t/the-polling-url-does-not-start-with-https-despite-the-login-url-started-with-https/137576/2
# TODO: could instead set extraTrustedDomains
"trusted_domains" = [ fqdn ];
"trusted_proxies" = [ "127.0.0.1" ];
# TODO: could instead set overwriteProtocol
"overwriteprotocol" = protocol; # Needed if behind a reverse_proxy
"overwritecondaddr" = ""; # We need to set it to empty otherwise overwriteprotocol does not work.
"debug" = cfg.debug;
"loglevel" = if !cfg.debug then 2 else 0;
"filelocking.debug" = cfg.debug;
# Use persistent SQL connections.
"dbpersistent" = "true";
# https://help.nextcloud.com/t/very-slow-sync-for-small-files/11064/13
"chunkSize" = "5120MB";
};
phpOptions = {
# The OPcache interned strings buffer is nearly full with 8, bump to 16.
catch_workers_output = "yes";
display_errors = "stderr";
error_reporting = "E_ALL & ~E_DEPRECATED & ~E_STRICT";
expose_php = "Off";
"opcache.enable_cli" = "1";
"opcache.fast_shutdown" = "1";
"opcache.interned_strings_buffer" = "16";
"opcache.max_accelerated_files" = "10000";
"opcache.memory_consumption" = "128";
"opcache.revalidate_freq" = "1";
short_open_tag = "Off";
# https://docs.nextcloud.com/server/stable/admin_manual/configuration_files/big_file_upload_configuration.html#configuring-php
# > Output Buffering must be turned off [...] or PHP will return memory-related errors.
output_buffering = "Off";
# Needed to avoid corruption per https://docs.nextcloud.com/server/21/admin_manual/configuration_server/caching_configuration.html#id2
"redis.session.locking_enabled" = "1";
"redis.session.lock_retries" = "-1";
"redis.session.lock_wait_time" = "10000";
}
// lib.optionalAttrs (!(isNull cfg.tracing)) {
# "xdebug.remote_enable" = "on";
# "xdebug.remote_host" = "127.0.0.1";
# "xdebug.remote_port" = "9000";
# "xdebug.remote_handler" = "dbgp";
"xdebug.trigger_value" = cfg.tracing;
"xdebug.mode" = "profile,trace";
"xdebug.output_dir" = "/var/log/xdebug";
"xdebug.start_with_request" = "trigger";
};
poolSettings = lib.mkIf (!(isNull cfg.phpFpmPoolSettings)) cfg.phpFpmPoolSettings;
phpExtraExtensions = all: [ all.xdebug ];
};
services.nginx.virtualHosts.${fqdn} = {
# listen = [ { addr = "0.0.0.0"; port = 443; } ];
forceSSL = !(isNull cfg.ssl);
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
# From [1] this should fix downloading of big files. [2] seems to indicate that buffering
# happens at multiple places anyway, so disabling one place should be okay.
# [1]: https://help.nextcloud.com/t/download-aborts-after-time-or-large-file/25044/6
# [2]: https://stackoverflow.com/a/50891625/1013628
extraConfig = ''
proxy_buffering off;
'';
};
environment.systemPackages = [
# Needed for a few apps. Would be nice to avoid having to put that in the environment and instead override https://github.com/NixOS/nixpkgs/blob/261abe8a44a7e8392598d038d2e01f7b33cf26d0/nixos/modules/services/web-apps/nextcloud.nix#L1035
pkgs.ffmpeg-headless
];
services.postgresql.settings = lib.mkIf (!(isNull cfg.postgresSettings)) cfg.postgresSettings;
systemd.services.phpfpm-nextcloud.preStart = ''
mkdir -p /var/log/xdebug; chown -R nextcloud: /var/log/xdebug
'';
systemd.services.phpfpm-nextcloud.requires = cfg.mountPointServices;
systemd.services.phpfpm-nextcloud.after = cfg.mountPointServices;
systemd.timers.nextcloud-cron.requires = cfg.mountPointServices;
systemd.timers.nextcloud-cron.after = cfg.mountPointServices;
# This is needed to be able to run the cron job before opening the app for the first time.
# Otherwise the cron job fails while searching for this directory.
systemd.services.nextcloud-setup.script = ''
mkdir -p ${cfg.dataDir}/data/appdata_$(${occ} config:system:get instanceid)/theming/global
'';
systemd.services.nextcloud-setup.requires = cfg.mountPointServices;
systemd.services.nextcloud-setup.after = cfg.mountPointServices;
})
(lib.mkIf (cfg.enable && cfg.phpFpmPrometheusExporter.enable) {
services.prometheus.exporters.php-fpm = {
enable = true;
user = "nginx";
port = cfg.phpFpmPrometheusExporter.port;
listenAddress = "127.0.0.1";
extraFlags = [
"--phpfpm.scrape-uri=tcp://127.0.0.1:${
toString (cfg.phpFpmPrometheusExporter.port - 1)
}/status?full"
];
};
services.nextcloud = {
poolSettings = {
"pm.status_path" = "/status";
# Need to use TCP connection to get status.
# I couldn't get PHP-FPM exporter to work with a unix socket.
#
# I also tried to server the status page at /status.php
# but fcgi doesn't like the returned headers.
"pm.status_listen" = "127.0.0.1:${toString (cfg.phpFpmPrometheusExporter.port - 1)}";
};
};
services.prometheus.scrapeConfigs = [
{
job_name = "phpfpm-nextcloud";
static_configs = [
{
targets = [ "127.0.0.1:${toString cfg.phpFpmPrometheusExporter.port}" ];
labels = {
"hostname" = config.networking.hostName;
"domain" = cfg.domain;
};
}
];
}
];
})
(lib.mkIf (cfg.enable && cfg.apps.onlyoffice.enable) {
assertions = [
{
assertion = !(isNull cfg.apps.onlyoffice.jwtSecretFile);
message = "Must set shb.nextcloud.apps.onlyoffice.jwtSecretFile.";
}
];
services.nextcloud.extraApps = {
inherit (nextcloudApps) onlyoffice;
};
services.onlyoffice = {
enable = true;
hostname = "${cfg.apps.onlyoffice.subdomain}.${cfg.domain}";
port = 13444;
postgresHost = "/run/postgresql";
jwtSecretFile = cfg.apps.onlyoffice.jwtSecretFile;
};
services.nginx.virtualHosts."${cfg.apps.onlyoffice.subdomain}.${cfg.domain}" = {
forceSSL = !(isNull cfg.ssl);
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
locations."/" = {
extraConfig = ''
allow ${cfg.apps.onlyoffice.localNetworkIPRange};
'';
};
};
})
(lib.mkIf (cfg.enable && cfg.apps.previewgenerator.enable) {
services.nextcloud.extraApps = {
inherit (nextcloudApps) previewgenerator;
};
services.nextcloud.settings = {
# List obtained from the admin panel of Memories app.
enabledPreviewProviders = [
"OC\\Preview\\BMP"
"OC\\Preview\\GIF"
"OC\\Preview\\HEIC"
"OC\\Preview\\Image"
"OC\\Preview\\JPEG"
"OC\\Preview\\Krita"
"OC\\Preview\\MarkDown"
"OC\\Preview\\Movie"
"OC\\Preview\\MP3"
"OC\\Preview\\OpenDocument"
"OC\\Preview\\PNG"
"OC\\Preview\\TXT"
"OC\\Preview\\XBitmap"
];
};
# Values taken from
# http://web.archive.org/web/20200513043150/https://ownyourbits.com/2019/06/29/understanding-and-improving-nextcloud-previews/
systemd.services.nextcloud-setup.script = lib.mkIf cfg.apps.previewgenerator.recommendedSettings ''
${occ} config:app:set previewgenerator squareSizes --value="32 256"
${occ} config:app:set previewgenerator widthSizes --value="256 384"
${occ} config:app:set previewgenerator heightSizes --value="256"
${occ} config:system:set preview_max_x --type integer --value 2048
${occ} config:system:set preview_max_y --type integer --value 2048
${occ} config:system:set jpeg_quality --value 60
${occ} config:app:set preview jpeg_quality --value=60
'';
# Configured as defined in https://github.com/nextcloud/previewgenerator
systemd.timers.nextcloud-cron-previewgenerator = {
wantedBy = [ "timers.target" ];
requires = cfg.mountPointServices;
after = [ "nextcloud-setup.service" ] ++ cfg.mountPointServices;
timerConfig.OnBootSec = "10m";
timerConfig.OnUnitActiveSec = "10m";
timerConfig.Unit = "nextcloud-cron-previewgenerator.service";
};
systemd.services.nextcloud-cron-previewgenerator = {
environment.NEXTCLOUD_CONFIG_DIR = "${config.services.nextcloud.datadir}/config";
serviceConfig.Type = "oneshot";
serviceConfig.ExecStart =
let
debug = if cfg.debug or cfg.apps.previewgenerator.debug then "-vvv" else "";
in
"${occ} ${debug} preview:pre-generate";
};
})
(lib.mkIf (cfg.enable && cfg.apps.externalStorage.enable) {
systemd.services.nextcloud-setup.script = ''
${occ} app:install files_external || :
${occ} app:enable files_external
''
+ lib.optionalString (cfg.apps.externalStorage.userLocalMount != null) (
let
cfg' = cfg.apps.externalStorage.userLocalMount;
jq = "${pkgs.jq}/bin/jq";
in
# sh
''
exists=$(${occ} files_external:list --output=json | ${jq} 'any(.[]; .mount_point == "${cfg'.mountName}" and .configuration.datadir == "${cfg'.directory}")')
if [[ "$exists" == "false" ]]; then
${occ} files_external:create \
'${cfg'.mountName}' \
local \
null::null \
--config datadir='${cfg'.directory}'
fi
''
);
})
(lib.mkIf (cfg.enable && cfg.apps.ldap.enable) {
systemd.services.nextcloud-setup.path = [ pkgs.jq ];
systemd.services.nextcloud-setup.script =
let
cfg' = cfg.apps.ldap;
cID = "s" + toString cfg'.configID;
in
''
${occ} app:install user_ldap || :
${occ} app:enable user_ldap
${occ} config:app:set user_ldap ${cID}ldap_configuration_active --value=0
${occ} config:app:set user_ldap configuration_prefixes --value '["${cID}"]'
# The following CLI commands follow
# https://github.com/lldap/lldap/blob/main/example_configs/nextcloud.md#nextcloud-config--the-cli-way
${occ} ldap:set-config "${cID}" 'ldapHost' \
'${cfg'.host}'
${occ} ldap:set-config "${cID}" 'ldapPort' \
'${toString cfg'.port}'
${occ} ldap:set-config "${cID}" 'ldapAgentName' \
'uid=${cfg'.adminName},ou=people,${cfg'.dcdomain}'
${occ} ldap:set-config "${cID}" 'ldapAgentPassword' \
"$(cat ${cfg'.adminPassword.result.path})"
${occ} ldap:set-config "${cID}" 'ldapBase' \
'${cfg'.dcdomain}'
${occ} ldap:set-config "${cID}" 'ldapBaseGroups' \
'${cfg'.dcdomain}'
${occ} ldap:set-config "${cID}" 'ldapBaseUsers' \
'${cfg'.dcdomain}'
${occ} ldap:set-config "${cID}" 'ldapEmailAttribute' \
'mail'
${occ} ldap:set-config "${cID}" 'ldapGroupFilter' \
'(&(|(objectclass=groupOfUniqueNames))(|(cn=${cfg'.userGroup})))'
${occ} ldap:set-config "${cID}" 'ldapGroupFilterGroups' \
'${cfg'.userGroup}'
${occ} ldap:set-config "${cID}" 'ldapGroupFilterObjectclass' \
'groupOfUniqueNames'
${occ} ldap:set-config "${cID}" 'ldapGroupMemberAssocAttr' \
'uniqueMember'
${occ} ldap:set-config "${cID}" 'ldapLoginFilter' \
'(&(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))(|(uid=%uid)(|(mail=%uid)(objectclass=%uid))))'
${occ} ldap:set-config "${cID}" 'ldapLoginFilterAttributes' \
'mail;objectclass'
${occ} ldap:set-config "${cID}" 'ldapUserDisplayName' \
'givenname'
${occ} ldap:set-config "${cID}" 'ldapUserFilter' \
'(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))'
${occ} ldap:set-config "${cID}" 'ldapUserFilterMode' \
'1'
${occ} ldap:set-config "${cID}" 'ldapUserFilterObjectclass' \
'person'
# Makes the user_id used when creating a user through LDAP which means the ID used in
# Nextcloud is compatible with the one returned by a (possibly added in the future) SSO
# provider.
${occ} ldap:set-config "${cID}" 'ldapExpertUsernameAttr' \
'uid'
${occ} ldap:test-config -- "${cID}"
# Only one active at the same time
ALL_CONFIG="$(${occ} ldap:show-config --output=json)"
for configid in $(echo "$ALL_CONFIG" | jq --raw-output "keys[]"); do
echo "Deactivating $configid"
${occ} ldap:set-config "$configid" 'ldapConfigurationActive' \
'0'
done
${occ} ldap:set-config "${cID}" 'ldapConfigurationActive' \
'1'
'';
})
(
let
scopes = [
"openid"
"profile"
"email"
"groups"
"nextcloud_userinfo"
];
in
lib.mkIf (cfg.enable && cfg.apps.sso.enable) {
assertions = [
{
assertion = cfg.ssl != null;
message = "To integrate SSO, SSL must be enabled, set the shb.nextcloud.ssl option.";
}
];
services.nextcloud.extraApps = {
inherit (nextcloudApps) oidc_login;
};
systemd.services.nextcloud-setup-pre = {
wantedBy = [ "multi-user.target" ];
before = [ "nextcloud-setup.service" ];
serviceConfig.Type = "oneshot";
serviceConfig.User = "nextcloud";
script = ''
mkdir -p ${cfg.dataDir}/config
cat < "${cfg.dataDir}/config/secretFile"
{
"oidc_login_client_secret": "$(cat ${cfg.apps.sso.secret.result.path})"
}
EOF
'';
};
services.nextcloud = {
secretFile = "${cfg.dataDir}/config/secretFile";
# See all options at https://github.com/pulsejet/nextcloud-oidc-login
# Other important url/links are:
# ${fqdn}/.well-known/openid-configuration
# https://www.authelia.com/reference/guides/attributes/#custom-attributes
# https://github.com/lldap/lldap/blob/main/example_configs/nextcloud_oidc_authelia.md
# https://www.authelia.com/integration/openid-connect/nextcloud/#authelia
# https://www.openidconnect.net/
settings = {
allow_user_to_change_display_name = false;
lost_password_link = "disabled";
oidc_login_provider_url = ssoFqdnWithPort;
oidc_login_client_id = cfg.apps.sso.clientID;
# Automatically redirect the login page to the provider.
oidc_login_auto_redirect = !cfg.apps.sso.fallbackDefaultAuth;
# Authelia at least does not support this.
oidc_login_end_session_redirect = false;
# Redirect to this page after logging out the user
oidc_login_logout_url = ssoFqdnWithPort;
oidc_login_button_text = "Log in with ${cfg.apps.sso.provider}";
oidc_login_hide_password_form = false;
# Now, Authelia provides the info using the UserInfo request.
oidc_login_use_id_token = false;
oidc_login_attributes = {
id = "preferred_username";
name = "name";
mail = "email";
groups = "groups";
is_admin = "is_nextcloud_admin";
};
oidc_login_allowed_groups = [
cfg.apps.ldap.userGroup
cfg.apps.sso.adminGroup
];
oidc_login_default_group = "oidc";
oidc_login_use_external_storage = false;
oidc_login_scope = lib.concatStringsSep " " scopes;
oidc_login_proxy_ldap = false;
# Enable creation of users new to Nextcloud from OIDC login. A user may be known to the
# IdP but not (yet) known to Nextcloud. This setting controls what to do in this case.
# * 'true' (default): if the user authenticates to the IdP but is not known to Nextcloud,
# then they will be returned to the login screen and not allowed entry;
# * 'false': if the user authenticates but is not yet known to Nextcloud, then the user
# will be automatically created; note that with this setting, you will be allowing (or
# relying on) a third-party (the IdP) to create new users
oidc_login_disable_registration = false;
oidc_login_redir_fallback = cfg.apps.sso.fallbackDefaultAuth;
# oidc_login_alt_login_page = "assets/login.php";
oidc_login_tls_verify = true;
# If you get your groups from the oidc_login_attributes, you might want to create them if
# they are not already existing, Default is `false`. This creates groups for all groups
# the user is associated with in LDAP. It's too much.
oidc_create_groups = false;
oidc_login_webdav_enabled = false;
oidc_login_password_authentication = false;
oidc_login_public_key_caching_time = 86400;
oidc_login_min_time_between_jwks_requests = 10;
oidc_login_well_known_caching_time = 86400;
# If true, nextcloud will download user avatars on login. This may lead to security issues
# as the server does not control which URLs will be requested. Use with care.
oidc_login_update_avatar = false;
oidc_login_code_challenge_method = "S256";
};
};
shb.authelia.extraDefinitions = {
user_attributes."is_nextcloud_admin".expression =
''type(groups) == list && "${cfg.apps.sso.adminGroup}" in groups'';
};
shb.authelia.extraOidcClaimsPolicies."nextcloud_userinfo" = {
custom_claims = {
is_nextcloud_admin = { };
};
};
shb.authelia.extraOidcScopes."nextcloud_userinfo" = {
claims = [ "is_nextcloud_admin" ];
};
shb.authelia.oidcClients = lib.mkIf (cfg.apps.sso.provider == "Authelia") [
{
client_id = cfg.apps.sso.clientID;
client_name = "Nextcloud";
client_secret.source = cfg.apps.sso.secretForAuthelia.result.path;
claims_policy = "nextcloud_userinfo";
public = false;
authorization_policy = cfg.apps.sso.authorization_policy;
require_pkce = "true";
pkce_challenge_method = "S256";
redirect_uris = [ "${protocol}://${fqdnWithPort}/apps/oidc_login/oidc" ];
inherit scopes;
response_types = [ "code" ];
grant_types = [ "authorization_code" ];
access_token_signed_response_alg = "none";
userinfo_signed_response_alg = "none";
token_endpoint_auth_method = "client_secret_basic";
}
];
}
)
(lib.mkIf (cfg.enable && cfg.autoDisableMaintenanceModeOnStart) {
systemd.services.nextcloud-setup.preStart = lib.mkBefore ''
if [[ -e /var/lib/nextcloud/config/config.php ]]; then
${occ} maintenance:mode --no-interaction --quiet --off || true
fi
'';
})
(lib.mkIf (cfg.enable && cfg.alwaysApplyExpensiveMigrations) {
systemd.services.nextcloud-setup.script = ''
if [[ -e /var/lib/nextcloud/config/config.php ]]; then
${occ} maintenance:repair --include-expensive || true
fi
'';
})
# Great source of inspiration:
# https://github.com/Shawn8901/nix-configuration/blob/538c18d9ecbf7c7e649b1540c0d40881bada6690/modules/nixos/private/nextcloud/memories.nix#L226
(lib.mkIf cfg.apps.memories.enable (
let
cfg' = cfg.apps.memories;
exiftool = pkgs.exiftool.overrideAttrs (
f: p: {
version = "12.70";
src = pkgs.fetchurl {
url = "https://exiftool.org/Image-ExifTool-12.70.tar.gz";
hash = "sha256-TLJSJEXMPj870TkExq6uraX8Wl4kmNerrSlX3LQsr/4=";
};
}
);
in
{
assertions = [
{
assertion = true;
message = "Memories app has an issue for now, see https://github.com/ibizaman/selfhostblocks/issues/476.";
}
];
services.nextcloud.extraApps = {
inherit (nextcloudApps) memories;
};
systemd.services.nextcloud-cron = {
# required for memories
# see https://github.com/pulsejet/memories/blob/master/docs/troubleshooting.md#issues-with-nixos
path = [ pkgs.perl ];
};
services.nextcloud = {
# See all options at https://memories.gallery/system-config/
settings = {
"memories.exiftool" = "${exiftool}/bin/exiftool";
"memories.exiftool_no_local" = false;
"memories.index.mode" = "3";
"memories.index.path" = cfg'.photosPath;
"memories.timeline.default_path" = cfg'.photosPath;
"memories.vod.disable" = !cfg'.vaapi;
"memories.vod.vaapi" = cfg'.vaapi;
"memories.vod.ffmpeg" = "${pkgs.ffmpeg-headless}/bin/ffmpeg";
"memories.vod.ffprobe" = "${pkgs.ffmpeg-headless}/bin/ffprobe";
"memories.vod.use_transpose" = true;
"memories.vod.use_transpose.force_sw" = cfg'.vaapi; # AMD and old Intel can't use hardware here.
"memories.db.triggers.fcu" = true;
"memories.readonly" = true;
"preview_ffmpeg_path" = "${pkgs.ffmpeg-headless}/bin/ffmpeg";
};
};
systemd.services.phpfpm-nextcloud.serviceConfig = lib.mkIf cfg'.vaapi {
DeviceAllow = [ "/dev/dri/renderD128 rwm" ];
PrivateDevices = lib.mkForce false;
};
}
))
(lib.mkIf cfg.apps.recognize.enable (
let
cfg' = cfg.apps.recognize;
in
{
services.nextcloud.extraApps = {
inherit (nextcloudApps) recognize;
};
systemd.services.nextcloud-setup.script = ''
${occ} config:app:set recognize nice_binary --value ${pkgs.coreutils}/bin/nice
${occ} config:app:set recognize node_binary --value ${pkgs.nodejs}/bin/node
${occ} config:app:set recognize faces.enabled --value true
${occ} config:app:set recognize faces.batchSize --value 50
${occ} config:app:set recognize imagenet.enabled --value true
${occ} config:app:set recognize imagenet.batchSize --value 100
${occ} config:app:set recognize landmarks.batchSize --value 100
${occ} config:app:set recognize landmarks.enabled --value true
${occ} config:app:set recognize tensorflow.cores --value 1
${occ} config:app:set recognize tensorflow.gpu --value false
${occ} config:app:set recognize tensorflow.purejs --value false
${occ} config:app:set recognize musicnn.enabled --value true
${occ} config:app:set recognize musicnn.batchSize --value 100
'';
}
))
(lib.mkIf (cfg.enable && cfg.enableDashboard) {
shb.monitoring.dashboards = [
./nextcloud-server/dashboard/Nextcloud.json
];
})
];
}
================================================
FILE: modules/services/open-webui/docs/default.md
================================================
# Open-WebUI Service {#services-open-webui}
Defined in [`/modules/blocks/open-webui.nix`](@REPO@/modules/blocks/open-webui.nix),
found in the `selfhostblocks.nixosModules.open-webui` module.
See [the manual](usage.html#usage-flake) for how to import the module in your code.
This service sets up [Open WebUI][] which provides a frontend to various LLMs.
[Open WebUI]: https://docs.openwebui.com/
## Features {#services-open-webui-features}
- Telemetry disabled.
- Skip onboarding through custom patch.
- Declarative [LDAP](#services-open-webui-options-shb.open-webui.ldap) Configuration.
- Needed LDAP groups are created automatically.
- Declarative [SSO](#services-open-webui-options-shb.open-webui.sso) Configuration.
- When SSO is enabled, login with user and password is disabled.
- Registration is enabled through SSO.
- Correct error message for unauthorized user through custom patch.
- Access through [subdomain](#services-open-webui-options-shb.open-webui.subdomain) using reverse proxy.
- Access through [HTTPS](#services-open-webui-options-shb.open-webui.ssl) using reverse proxy.
- [Backup](#services-open-webui-options-shb.open-webui.sso) through the [backup block](./blocks-backup.html).
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-open-webui-usage-applicationdashboard)
## Usage {#services-open-webui-usage}
### Initial Configuration {#services-open-webui-usage-configuration}
The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS,
- the [`shb.ssl` block](blocks-ssl.html#usage),
- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).
- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).
```nix
{
shb.open-webui = {
enable = true;
domain = "example.com";
subdomain = "open-webui";
ssl = config.shb.certs.certs.letsencrypt.${domain};
sso = {
enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
sharedSecret.result = config.shb.sops.secret.oidcSecret.result;
sharedSecretForAuthelia.result = config.shb.sops.secret.oidcAutheliaSecret.result;
};
};
shb.sops.secret."open-webui/oidcSecret".request = config.shb.open-webui.sso.sharedSecret.request;
shb.sops.secret."open-webui/oidcAutheliaSecret" = {
request = config.shb.open-webui.sso.sharedSecretForAuthelia.request;
settings.key = "open-webui/oidcSecret";
};
}
```
Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.
The [user](#services-open-webui-options-shb.open-webui.ldap.userGroup)
and [admin](#services-open-webui-options-shb.open-webui.ldap.adminGroup)
LDAP groups are created automatically.
### Application Dashboard {#services-open-webui-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-open-webui-options-shb.open-webui.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Documents.services.OpenWebUI = {
sortOrder = 1;
dashboard.request = config.shb.home-assistant.dashboard.request;
settings.icon = "sh-open-webui";
};
}
```
The icon needs to be set manually otherwise it is not displayed correctly.
## Integration with OLLAMA {#services-open-webui-ollama}
Assuming ollama is enabled, it will be available on port `config.services.ollama.port`.
The following snippet sets up acceleration using an AMD (i)GPU and loads some models.
```nix
{
services.ollama = {
enable = true;
# https://wiki.nixos.org/wiki/Ollama#AMD_GPU_with_open_source_driver
acceleration = "rocm";
# https://ollama.com/library
loadModels = [
"deepseek-r1:1.5b"
"llama3.2:3b"
"llava:7b"
"mxbai-embed-large:335m"
"nomic-embed-text:v1.5"
];
};
}
```
Integrating with the ollama service is done with:
```nix
{
shb.open-webui = {
environment.OLLAMA_BASE_URL = "http://127.0.0.1:${toString config.services.ollama.port}";
};
}
```
## Backup {#services-open-webui-usage-backup}
Backing up Open-Webui using the [Restic block](blocks-restic.html) is done like so:
```nix
shb.restic.instances."open-webui" = {
request = config.shb.open-webui.backup;
settings = {
enable = true;
};
};
```
The name `"open-webui"` in the `instances` can be anything.
The `config.shb.open-webui.backup` option provides what directories to backup.
You can define any number of Restic instances to backup Open WebUI multiple times.
## Options Reference {#services-open-webui-options}
```{=include=} options
id-prefix: services-open-webui-options-
list-id: selfhostblocks-services-open-webui-options
source: @OPTIONS_JSON@
```
================================================
FILE: modules/services/open-webui.nix
================================================
{
config,
lib,
pkgs,
shb,
...
}:
let
cfg = config.shb.open-webui;
roleClaim = "openwebui_groups";
oauthScopes = [
"openid"
"email"
"profile"
"groups"
"${roleClaim}"
];
in
{
imports = [
../../lib/module.nix
../blocks/nginx.nix
];
options.shb.open-webui = {
enable = lib.mkEnableOption "the Open-WebUI service";
subdomain = lib.mkOption {
type = lib.types.str;
description = "Subdomain under which Open-WebUI will be served.";
default = "open-webui";
};
domain = lib.mkOption {
type = lib.types.str;
description = "domain under which Open-WebUI will be served.";
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
port = lib.mkOption {
type = lib.types.port;
description = "Port Open-WebUI listens to incoming requests.";
default = 12444;
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = "Extra environment variables. See https://docs.openwebui.com/getting-started/env-configuration";
default = { };
example = ''
{
WEBUI_NAME = "SelfHostBlocks";
OLLAMA_BASE_URL = "http://127.0.0.1:''${toString config.services.ollama.port}";
RAG_EMBEDDING_MODEL = "nomic-embed-text:v1.5";
ENABLE_OPENAI_API = "True";
OPENAI_API_BASE_URL = "http://127.0.0.1:''${toString config.services.llama-cpp.port}";
ENABLE_WEB_SEARCH = "True";
RAG_EMBEDDING_ENGINE = "openai";
}
'';
};
ldap = lib.mkOption {
description = ''
Setup LDAP integration.
'';
default = { };
type = lib.types.submodule {
options = {
userGroup = lib.mkOption {
type = lib.types.str;
description = "Group users must belong to to be able to login.";
default = "open-webui_user";
};
adminGroup = lib.mkOption {
type = lib.types.str;
description = "Group users must belong to to have administrator privileges.";
default = "open-webui_admin";
};
};
};
};
sso = lib.mkOption {
description = ''
Setup SSO integration.
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "SSO integration.";
authEndpoint = lib.mkOption {
type = lib.types.str;
description = "Endpoint to the SSO provider.";
example = "https://authelia.example.com";
};
clientID = lib.mkOption {
type = lib.types.str;
description = "Client ID for the OIDC endpoint.";
default = "open-webui";
};
authorization_policy = lib.mkOption {
type = lib.types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor";
};
sharedSecret = lib.mkOption {
description = "OIDC shared secret for Open-WebUI.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
owner = "open-webui";
restartUnits = [ "open-webui.service" ];
};
};
};
sharedSecretForAuthelia = lib.mkOption {
description = "OIDC shared secret for Authelia. Must be the same as `sharedSecret`";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
ownerText = "config.shb.authelia.autheliaUser";
owner = config.shb.authelia.autheliaUser;
};
};
};
};
};
};
backup = lib.mkOption {
description = ''
Backup state directory.
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.backup.mkRequester {
user = "open-webui";
sourceDirectories = [
config.services.open-webui.stateDir
];
sourceDirectoriesText = "[ config.services.open-webui.stateDir ]";
};
};
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.open-webui.subdomain}.\${config.shb.open-webui.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
};
config = (
lib.mkMerge [
(lib.mkIf cfg.enable {
users.users.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
];
});
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;
};
};
shb.authelia.extraDefinitions = {
user_attributes.${roleClaim}.expression =
''"${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 = shb.replaceSecrets {
userConfig = {
OAUTH_CLIENT_SECRET.source = cfg.sso.sharedSecret.result.path;
};
resultPath = "/run/open-webui/secrets.env";
generator = shb.toEnvVar;
};
serviceConfig.Type = "oneshot";
wantedBy = [ "multi-user.target" ];
before = [ "open-webui.service" ];
requiredBy = [ "open-webui.service" ];
};
})
]
);
}
================================================
FILE: modules/services/paperless.nix
================================================
{
config,
pkgs,
lib,
shb,
...
}:
let
cfg = config.shb.paperless;
dataFolder = cfg.dataDir;
fqdn = "${cfg.subdomain}.${cfg.domain}";
protocol = if !(isNull cfg.ssl) then "https" else "http";
ssoFqdnWithPort =
if isNull cfg.sso.port then cfg.sso.endpoint else "${cfg.sso.endpoint}:${toString cfg.sso.port}";
ssoClientSettings = {
openid_connect = {
SCOPE = [
"openid"
"profile"
"email"
"groups"
];
OAUTH_PKCE_ENABLED = true;
APPS = [
{
provider_id = "${cfg.sso.provider}";
name = "${cfg.sso.provider}";
client_id = "${cfg.sso.clientID}";
secret = "%SECRET_CLIENT_SECRET_PLACEHOLDER%";
settings = {
server_url = ssoFqdnWithPort;
token_auth_method = "client_secret_basic";
};
}
];
};
};
ssoClientSettingsFile = pkgs.writeText "paperless-sso-client.env" ''
PAPERLESS_SOCIALACCOUNT_PROVIDERS=${builtins.toJSON ssoClientSettings}
'';
replacements = [
{
# Note: replaceSecretsScript prepends '%SECRET_' and appends '%'
# when doing the replacement
name = [ "CLIENT_SECRET_PLACEHOLDER" ];
source = cfg.sso.sharedSecret.result.path;
}
];
replaceSecretsScript = shb.replaceSecretsScript {
file = ssoClientSettingsFile;
resultPath = "/run/paperless/paperless-sso-client.env";
inherit replacements;
user = "paperless";
};
inherit (lib)
mkEnableOption
mkIf
lists
mkOption
;
inherit (lib.types)
attrsOf
bool
enum
listOf
nullOr
port
submodule
str
path
;
in
{
imports = [
../../lib/module.nix
../blocks/nginx.nix
];
options.shb.paperless = {
enable = mkEnableOption "selfhostblocks.paperless";
subdomain = mkOption {
type = str;
description = ''
Subdomain under which paperless will be served.
```
.
```
'';
example = "photos";
};
domain = mkOption {
description = ''
Domain under which paperless is served.
```
.
```
'';
type = str;
example = "example.com";
};
port = mkOption {
description = ''
Port under which paperless will listen.
'';
type = port;
default = 28981;
};
ssl = mkOption {
description = "Path to SSL files";
type = nullOr shb.contracts.ssl.certs;
default = null;
};
dataDir = mkOption {
description = "Directory where paperless will store data files.";
type = str;
default = "/var/lib/paperless";
};
mediaDir = mkOption {
description = "Directory where paperless will store documents.";
type = str;
defaultText = lib.literalExpression ''"''${dataDir}/media"'';
default = "${cfg.dataDir}/media";
};
consumptionDir = mkOption {
description = "Directory from which new documents are imported.";
type = str;
defaultText = lib.literalExpression ''"''${dataDir}/consume"'';
default = "${cfg.dataDir}/consume";
};
configureTika = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to configure Tika and Gotenberg to process Office and e-mail files with OCR.
'';
};
adminPassword = mkOption {
description = "Secret containing the superuser (admin) password.";
type = submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "paperless";
group = "paperless";
restartUnits = [ "paperless-server.service" ];
};
};
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType =
with lib.types;
attrsOf (
let
typeList = [
bool
float
int
str
path
package
];
in
oneOf (
typeList
++ [
(listOf (oneOf typeList))
(attrsOf (oneOf typeList))
]
)
);
};
default = { };
description = ''
Extra paperless config options.
See [the documentation](https://docs.paperless-ngx.com/configuration/) for available options.
Note that some settings such as `PAPERLESS_CONSUMER_IGNORE_PATTERN` expect JSON values.
Settings declared as lists or attrsets will automatically be serialised into JSON strings for your convenience.
'';
example = {
PAPERLESS_OCR_LANGUAGE = "deu+eng";
PAPERLESS_CONSUMER_IGNORE_PATTERN = [
".DS_STORE/*"
"desktop.ini"
];
PAPERLESS_OCR_USER_ARGS = {
optimize = 1;
pdfa_image_compression = "lossless";
};
};
};
mount = mkOption {
type = shb.contracts.mount;
description = ''
Mount configuration. This is an output option.
Use it to initialize a block implementing the "mount" contract.
For example, with a zfs dataset:
```
shb.zfs.datasets."paperless" = {
poolName = "root";
} // config.shb.paperless.mount;
```
'';
readOnly = true;
default = {
path = dataFolder;
};
};
backup = mkOption {
description = ''
Backup configuration for paperless media files and database.
'';
default = { };
type = submodule {
options = shb.contracts.backup.mkRequester {
user = "paperless";
sourceDirectories = [
dataFolder
];
excludePatterns = [
];
};
};
};
sso = mkOption {
description = ''
Setup SSO integration.
'';
default = { };
type = submodule {
options = {
enable = mkEnableOption "SSO integration.";
provider = mkOption {
type = enum [
"Authelia"
"Keycloak"
"Generic"
];
description = "OIDC provider name, used for display.";
default = "Authelia";
};
endpoint = mkOption {
type = str;
description = "OIDC endpoint for SSO.";
example = "https://authelia.example.com";
};
clientID = mkOption {
type = str;
description = "Client ID for the OIDC endpoint.";
default = "paperless";
};
adminUserGroup = lib.mkOption {
type = lib.types.str;
description = "OIDC admin group";
default = "paperless_admin";
};
userGroup = lib.mkOption {
type = lib.types.str;
description = "OIDC user group";
default = "paperless_user";
};
port = mkOption {
description = "If given, adds a port to the endpoint.";
type = nullOr port;
default = null;
};
autoRegister = mkOption {
type = bool;
description = "Automatically register new users from SSO provider.";
default = true;
};
autoLaunch = mkOption {
type = bool;
description = "Automatically redirect to SSO provider.";
default = true;
};
passwordLogin = mkOption {
type = bool;
description = "Enable password login.";
default = true;
};
sharedSecret = mkOption {
description = "OIDC shared secret for paperless.";
type = submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "paperless";
group = "paperless";
restartUnits = [ "paperless-server.service" ];
};
};
};
sharedSecretForAuthelia = mkOption {
description = "OIDC shared secret for Authelia. Content must be the same as `sharedSecret` option.";
type = submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "authelia";
};
};
default = null;
};
authorization_policy = mkOption {
type = enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor";
};
};
};
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.paperless.subdomain}.\${config.shb.paperless.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = !(isNull cfg.ssl) -> !(isNull cfg.ssl.paths.cert) && !(isNull cfg.ssl.paths.key);
message = "SSL is enabled for paperless but no cert or key is provided.";
}
{
assertion = cfg.sso.enable -> cfg.ssl != null;
message = "To integrate SSO, SSL must be enabled, set the shb.paperless.ssl option.";
}
];
# Configure paperless service
services.paperless = {
enable = true;
address = "127.0.0.1";
port = cfg.port;
consumptionDirIsPublic = true;
dataDir = cfg.dataDir;
mediaDir = cfg.mediaDir;
consumptionDir = cfg.consumptionDir;
configureTika = cfg.configureTika;
settings = {
PAPERLESS_URL = "${protocol}://${fqdn}";
}
// cfg.settings
// lib.optionalAttrs (cfg.sso.enable) {
PAPERLESS_APPS = "allauth.socialaccount.providers.openid_connect";
PAPERLESS_SOCIAL_AUTO_SIGNUP = cfg.sso.autoRegister;
PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS = true;
PAPERLESS_DISABLE_REGULAR_LOGIN = !cfg.sso.passwordLogin;
};
}
// lib.optionalAttrs (cfg.sso.enable) {
environmentFile = "/run/paperless/paperless-sso-client.env";
};
# Database defaults to local sqlite
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 paperless paperless"
"d ${cfg.consumptionDir} 0700 paperless paperless"
"d ${cfg.mediaDir} 0700 paperless paperless"
]
++ lib.optionals cfg.sso.enable [ "d '/run/paperless' 0750 root root - -" ];
systemd.services.paperless-pre = lib.mkIf cfg.sso.enable {
script = replaceSecretsScript;
serviceConfig.Type = "oneshot";
wantedBy = [ "multi-user.target" ];
before = [ "paperless-scheduler.service" ];
requiredBy = [ "paperless-scheduler.service" ];
};
shb.nginx.vhosts = [
{
inherit (cfg) subdomain domain ssl;
upstream = "http://127.0.0.1:${toString cfg.port}";
autheliaRules = lib.mkIf (cfg.sso.enable) [
{
domain = fqdn;
policy = cfg.sso.authorization_policy;
subject = [
"group:paperless_user"
"group:paperless_admin"
];
}
];
authEndpoint = lib.mkIf (cfg.sso.enable) cfg.sso.endpoint;
extraConfig = ''
# See https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx
proxy_redirect off;
proxy_set_header X-Forwarded-Host $server_name;
add_header Referrer-Policy "strict-origin-when-cross-origin";
'';
}
];
# Allow large uploads
services.nginx.virtualHosts."${fqdn}".extraConfig = ''
client_max_body_size 500M;
'';
shb.authelia.oidcClients = lists.optionals (cfg.sso.enable && cfg.sso.provider == "Authelia") [
{
client_id = cfg.sso.clientID;
client_name = "paperless";
client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;
public = false;
authorization_policy = cfg.sso.authorization_policy;
token_endpoint_auth_method = "client_secret_basic";
redirect_uris = [
"${protocol}://${fqdn}/accounts/oidc/${cfg.sso.provider}/login/callback/"
];
}
];
};
}
================================================
FILE: modules/services/pinchflat/docs/default.md
================================================
# Pinchflat Service {#services-pinchflat}
Defined in [`/modules/services/pinchflat.nix`](@REPO@/modules/services/pinchflat.nix).
This NixOS module is a service that sets up a [Pinchflat](https://github.com/kieraneglin/pinchflat) instance.
Compared to the stock module from nixpkgs,
this one sets up, in a fully declarative manner,
LDAP and SSO integration
and has a nicer option for secrets.
## Features {#services-pinchflat-features}
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-pinchflat-usage-applicationdashboard)
## Usage {#services-pinchflat-usage}
### Initial Configuration {#services-pinchflat-usage-configuration}
The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS,
- the [`shb.ssl` block](blocks-ssl.html#usage),
- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).
- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).
```nix
shb.pinchflat = {
enable = true;
secretKeyBase.result = config.shb.sops.secret."pinchflat/secretKeyBase".result;
timeZone = "Europe/Brussels";
mediaDir = "/srv/pinchflat";
domain = "example.com";
subdomain = "pinchflat";
ssl = config.shb.certs.certs.letsencrypt.${domain};
ldap = {
enable = true;
};
sso = {
enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
};
shb.sops.secret."pinchflat/secretKeyBase".request = config.shb.pinchflat.secretKeyBase.request;
```
Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.
The [user](#services-pinchflat-options-shb.pinchflat.ldap.userGroup)
LDAP group is created automatically.
### Backup {#services-pinchflat-usage-backup}
Backing up Pinchflat using the [Restic block](blocks-restic.html) is done like so:
```nix
shb.restic.instances."pinchflat" = {
request = config.shb.pinchflat.backup;
settings = {
enable = true;
};
};
```
The name `"pinchflat"` in the `instances` can be anything.
The `config.shb.pinchflat.backup` option provides what directories to backup.
You can define any number of Restic instances to backup Pinchflat multiple times.
### Application Dashboard {#services-pinchflat-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-pinchflat-options-shb.pinchflat.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Media.services.Pinchflat = {
sortOrder = 2;
dashboard.request = config.shb.pinchflat.dashboard.request;
};
}
```
## Options Reference {#services-pinchflat-options}
```{=include=} options
id-prefix: services-pinchflat-options-
list-id: selfhostblocks-service-pinchflat-options
source: @OPTIONS_JSON@
```
================================================
FILE: modules/services/pinchflat.nix
================================================
{
config,
lib,
shb,
...
}:
let
cfg = config.shb.pinchflat;
inherit (lib) types;
in
{
imports = [
../../lib/module.nix
../blocks/nginx.nix
];
options.shb.pinchflat = {
enable = lib.mkEnableOption "the Pinchflat service.";
subdomain = lib.mkOption {
type = lib.types.str;
description = "Subdomain under which Pinchflat will be served.";
default = "pinchflat";
};
domain = lib.mkOption {
type = lib.types.str;
description = "domain under which Pinchflat will be served.";
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
port = lib.mkOption {
type = lib.types.port;
description = "Port Pinchflat listens to incoming requests.";
default = 8945;
};
secretKeyBase = lib.mkOption {
description = ''
Used to sign/encrypt cookies and other secrets.
Make sure the secret is at least 64 characters long.
'';
type = types.submodule {
options = shb.contracts.secret.mkRequester {
restartUnits = [ "pinchflat.service" ];
};
};
};
mediaDir = lib.mkOption {
description = "Path where videos are stored.";
type = lib.types.str;
};
timeZone = lib.mkOption {
type = lib.types.oneOf [
lib.types.str
shb.secretFileType
];
description = "Timezone of this instance.";
example = "America/Los_Angeles";
};
ldap = lib.mkOption {
description = ''
Setup LDAP integration.
'';
default = { };
type = types.submodule {
options = {
enable = lib.mkEnableOption "LDAP integration." // {
default = cfg.sso.enable;
};
userGroup = lib.mkOption {
type = types.str;
description = "Group users must belong to be able to login.";
default = "pinchflat_user";
};
};
};
};
sso = lib.mkOption {
description = ''
Setup SSO integration.
'';
default = { };
type = types.submodule {
options = {
enable = lib.mkEnableOption "SSO integration.";
authEndpoint = lib.mkOption {
type = lib.types.str;
description = "Endpoint to the SSO provider.";
example = "https://authelia.example.com";
};
authorization_policy = lib.mkOption {
type = types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor";
};
};
};
};
backup = lib.mkOption {
description = ''
Backup media directory `shb.mediaDir`.
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.backup.mkRequester {
user = "pinchflat";
sourceDirectories = [
cfg.mediaDir
];
sourceDirectoriesText = "[ config.shb.pinchflat.mediaDir ]";
};
};
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.pinchflat.subdomain}.\${config.shb.pinchflat.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
};
config = lib.mkIf cfg.enable {
systemd.tmpfiles.rules = [
"d '/run/pinchflat' 0750 root root - -"
];
# Pinchflat relies on the global value so for now this is the only way to pass the option in.
time.timeZone = lib.mkDefault cfg.timeZone;
services.pinchflat = {
inherit (cfg) enable port mediaDir;
secretsFile = "/run/pinchflat/secrets.env";
extraConfig = {
ENABLE_PROMETHEUS = true;
# TZ = "as"; # I consider where you live to be sensible so it should be passed as a secret.
};
};
# This should be using a contract instead of setting the option directly.
shb.lldap = lib.mkIf config.shb.lldap.enable {
ensureGroups = {
${cfg.ldap.userGroup} = { };
};
};
systemd.services.pinchflat-pre = {
script = shb.replaceSecrets {
userConfig = {
SECRET_KEY_BASE.source = cfg.secretKeyBase.result.path;
# TZ = cfg.secretKeyBase.result.path; # Uncomment when PR is merged.
};
resultPath = "/run/pinchflat/secrets.env";
generator = shb.toEnvVar;
};
serviceConfig.Type = "oneshot";
wantedBy = [ "multi-user.target" ];
before = [ "pinchflat.service" ];
requiredBy = [ "pinchflat.service" ];
};
shb.nginx.vhosts = [
(
{
inherit (cfg) subdomain domain ssl;
upstream = "http://127.0.0.1:${toString cfg.port}";
autheliaRules = lib.optionals (cfg.sso.enable) [
{
domain = "${cfg.subdomain}.${cfg.domain}";
policy = cfg.sso.authorization_policy;
subject = [ "group:${cfg.ldap.userGroup}" ];
}
];
}
// lib.optionalAttrs cfg.sso.enable {
inherit (cfg.sso) authEndpoint;
}
)
];
services.prometheus.scrapeConfigs = [
{
job_name = "pinchflat";
static_configs = [
{
targets = [ "127.0.0.1:${toString cfg.port}" ];
labels = {
"hostname" = config.networking.hostName;
"domain" = cfg.domain;
};
}
];
}
];
};
}
================================================
FILE: modules/services/vaultwarden/docs/default.md
================================================
# Vaultwarden Service {#services-vaultwarden}
Defined in [`/modules/services/vaultwarden.nix`](@REPO@/modules/services/vaultwarden.nix).
This NixOS module is a service that sets up a [Vaultwarden Server](https://github.com/dani-garcia/vaultwarden).
## Features {#services-vaultwarden-features}
- Access through subdomain using reverse proxy.
- Access through HTTPS using reverse proxy.
- Automatic setup of Redis database for caching.
- Backup of the data directory through the [backup contract](./contracts-backup.html).
- [Integration Tests](@REPO@/test/services/vaultwarden.nix)
- Tests /admin can only be accessed when authenticated with SSO.
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard.
## Usage {#services-vaultwarden-usage}
### Initial Configuration {#services-vaultwarden-usage-configuration}
The following snippet enables Vaultwarden and makes it available under the `vaultwarden.example.com` endpoint.
```nix
shb.vaultwarden = {
enable = true;
domain = "example.com";
subdomain = "vaultwarden";
port = 8222;
databasePassword.result = config.shb.sops.secret."vaultwarden/db".result;
smtp = {
host = "smtp.eu.mailgun.org";
port = 587;
username = "postmaster@mg.${domain}";
from_address = "authelia@${domain}";
passwordFile = config.sops.secrets."vaultwarden/smtp".path;
};
};
shb.sops.secret."vaultwarden/db".request = config.shb.vaultwarden.databasePassword.request;
shb.sops.secret."vaultwarden/smtp".request = config.shb.vaultwarden.smtp.password.request;
```
This assumes secrets are setup with SOPS
as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual.
Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.
The SMTP configuration is needed to invite users to Vaultwarden.
### HTTPS {#services-vaultwarden-usage-https}
If the `shb.ssl` block is used (see [manual](blocks-ssl.html#usage) on how to set it up),
the instance will be reachable at `https://vaultwarden.example.com`.
Here is an example with Let's Encrypt certificates, validated using the HTTP method:
```nix
shb.certs.certs.letsencrypt."example.com" = {
domain = "example.com";
group = "nginx";
reloadServices = [ "nginx.service" ];
adminEmail = "myemail@mydomain.com";
};
```
Then you can tell Vaultwarden to use those certificates.
```nix
shb.certs.certs.letsencrypt."example.com".extraDomains = [ "vaultwarden.example.com" ];
shb.forgejo = {
ssl = config.shb.certs.certs.letsencrypt."example.com";
};
```
### SSO {#services-vaultwarden-usage-sso}
To protect the `/admin` endpoint and avoid needing a secret passphrase for it, we can use SSO.
We will use the [SSO block][] provided by Self Host Blocks.
Assuming it [has been set already][SSO block setup], add the following configuration:
[SSO block]: blocks-sso.html
[SSO block setup]: blocks-sso.html#blocks-sso-global-setup
```nix
shb.vaultwarden.authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
```
Now, go to the LDAP server at `https://ldap.example.com`,
create the `vaultwarden_admin` group and add a user to that group.
When that's done, go back to the Vaultwarden server at
`https://vaultwarden.example.com/admin` and login with that user.
### ZFS {#services-vaultwarden-zfs}
Integration with the ZFS block allows to automatically create the relevant datasets.
```nix
shb.zfs.datasets."vaultwarden" = config.shb.vaultwarden.mount;
shb.zfs.datasets."postgresql".path = "/var/lib/postgresql";
```
### Backup {#services-vaultwarden-backup}
Backing up Vaultwarden using the [Restic block](blocks-restic.html) is done like so:
```nix
shb.restic.instances."vaultwarden" = {
request = config.shb.vaultwarden.backup;
settings = {
enable = true;
};
};
```
The name `"vaultwarden"` in the `instances` can be anything.
The `config.shb.vaultwarden.backup` option provides what directories to backup.
You can define any number of Restic instances to backup Vaultwarden multiple times.
### Application Dashboard {#services-vaultwarden-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-vaultwarden-options-shb.vaultwarden.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Documents.services.Vaultwarden = {
sortOrder = 10;
dashboard.request = config.shb.vaultwarden.dashboard.request;
};
}
```
## Maintenance {#services-vaultwarden-maintenance}
No command-line tool is provided to administer Vaultwarden.
Instead, the admin section can be found at the `/admin` endpoint.
## Debug {#services-vaultwarden-debug}
In case of an issue, check the logs of the `vaultwarden.service` systemd service.
Enable verbose logging by setting the `shb.vaultwarden.debug` boolean to `true`.
Access the database with `sudo -u vaultwarden psql`.
## Options Reference {#services-vaultwarden-options}
```{=include=} options
id-prefix: services-vaultwarden-options-
list-id: selfhostblocks-vaultwarden-options
source: @OPTIONS_JSON@
```
================================================
FILE: modules/services/vaultwarden.nix
================================================
{
config,
lib,
shb,
...
}:
let
cfg = config.shb.vaultwarden;
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";
in
{
imports = [
../../lib/module.nix
../blocks/nginx.nix
];
options.shb.vaultwarden = {
enable = lib.mkEnableOption "selfhostblocks.vaultwarden";
subdomain = lib.mkOption {
type = lib.types.str;
description = "Subdomain under which Authelia will be served.";
example = "ha";
};
domain = lib.mkOption {
type = lib.types.str;
description = "domain under which Authelia will be served.";
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
port = lib.mkOption {
type = lib.types.port;
description = "Port on which vaultwarden service listens.";
default = 8222;
};
authEndpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "OIDC endpoint for SSO";
default = null;
example = "https://authelia.example.com";
};
databasePassword = lib.mkOption {
description = "File containing the Vaultwarden database password.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0440";
owner = "vaultwarden";
group = "postgres";
restartUnits = [
"vaultwarden.service"
"postgresql.service"
];
};
};
};
smtp = lib.mkOption {
description = "SMTP options.";
default = null;
type = lib.types.nullOr (
lib.types.submodule {
options = {
from_address = lib.mkOption {
type = lib.types.str;
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 = "Vaultwarden";
};
host = lib.mkOption {
type = lib.types.str;
description = "SMTP host to send the emails to.";
};
security = lib.mkOption {
type = lib.types.enum [
"starttls"
"force_tls"
"off"
];
description = "Security expected by SMTP host.";
default = "starttls";
};
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.";
};
auth_mechanism = lib.mkOption {
type = lib.types.enum [ "Login" ];
description = "Auth mechanism.";
default = "Login";
};
password = lib.mkOption {
description = "File containing the password to connect to the SMTP host.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "vaultwarden";
restartUnits = [ "vaultwarden.service" ];
};
};
};
};
}
);
};
mount = lib.mkOption {
type = shb.contracts.mount;
description = ''
Mount configuration. This is an output option.
Use it to initialize a block implementing the "mount" contract.
For example, with a zfs dataset:
```
shb.zfs.datasets."vaultwarden" = {
poolName = "root";
} // config.shb.vaultwarden.mount;
```
'';
readOnly = true;
default = {
path = dataFolder;
};
};
backup = lib.mkOption {
description = ''
Backup configuration.
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.backup.mkRequester {
user = "vaultwarden";
sourceDirectories = [
dataFolder
];
};
};
};
debug = lib.mkOption {
type = lib.types.bool;
description = "Set to true to enable debug logging.";
default = false;
example = true;
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.vaultwarden.subdomain}.\${config.shb.vaultwarden.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
};
config = lib.mkIf cfg.enable {
services.vaultwarden = {
enable = true;
dbBackend = "postgresql";
config = {
IP_HEADER = "X-Real-IP";
SIGNUPS_ALLOWED = false;
# Disabled because the /admin path is protected by SSO
DISABLE_ADMIN_TOKEN = true;
INVITATIONS_ALLOWED = true;
DOMAIN = "https://${fqdn}";
USE_SYSLOG = true;
EXTENDED_LOGGING = cfg.debug;
LOG_LEVEL = if cfg.debug then "trace" else "info";
ROCKET_LOG = if cfg.debug then "trace" else "info";
ROCKET_ADDRESS = "127.0.0.1";
ROCKET_PORT = cfg.port;
}
// lib.optionalAttrs (cfg.smtp != null) {
SMTP_FROM = cfg.smtp.from_address;
SMTP_FROM_NAME = cfg.smtp.from_name;
SMTP_HOST = cfg.smtp.host;
SMTP_SECURITY = cfg.smtp.security;
SMTP_USERNAME = cfg.smtp.username;
SMTP_PORT = cfg.smtp.port;
SMTP_AUTH_MECHANISM = cfg.smtp.auth_mechanism;
};
environmentFile = "${dataFolder}/vaultwarden.env";
};
# We create a blank environment file for the service to start. Then, ExecPreStart kicks in and
# fills out the environment file for ExecStart to pick it up.
systemd.tmpfiles.rules = [
"d ${dataFolder} 0750 vaultwarden vaultwarden"
"f ${dataFolder}/vaultwarden.env 0640 vaultwarden vaultwarden"
];
# Needed to be able to write template config.
systemd.services.vaultwarden.serviceConfig.ProtectHome = lib.mkForce false;
systemd.services.vaultwarden.preStart = shb.replaceSecrets {
userConfig = {
DATABASE_URL.source = cfg.databasePassword.result.path;
DATABASE_URL.transform = v: "postgresql://vaultwarden:${v}@127.0.0.1:5432/vaultwarden";
}
// lib.optionalAttrs (cfg.smtp != null) {
SMTP_PASSWORD.source = cfg.smtp.password.result.path;
};
resultPath = "${dataFolder}/vaultwarden.env";
generator = shb.toEnvVar;
};
shb.nginx.vhosts = [
{
inherit (cfg)
subdomain
domain
authEndpoint
ssl
;
upstream = "http://127.0.0.1:${toString config.services.vaultwarden.config.ROCKET_PORT}";
autheliaRules = lib.mkIf (cfg.authEndpoint != null) [
{
domain = "${fqdn}";
policy = "two_factor";
subject = [ "group:vaultwarden_admin" ];
resources = [
"^/admin"
];
}
# There's no way to protect the webapp using Authelia this way, see
# https://github.com/dani-garcia/vaultwarden/discussions/3188
{
domain = fqdn;
policy = "bypass";
}
];
}
];
shb.postgresql.enableTCPIP = true;
shb.postgresql.ensures = [
{
username = "vaultwarden";
database = "vaultwarden";
passwordFile = cfg.databasePassword.result.path;
}
];
# TODO: make this work.
# It does not work because it leads to infinite recursion.
# ${cfg.mount}.path = dataFolder;
};
}
================================================
FILE: patches/0001-nixos-borgbackup-add-option-to-override-state-direct.patch
================================================
From dda895b551c7cf56476ac8892904e289a4d8b920 Mon Sep 17 00:00:00 2001
From: ibizaman
Date: Sat, 1 Nov 2025 13:49:20 +0100
Subject: [PATCH] nixos/borgbackup: add option to override state directory
---
nixos/modules/services/backup/borgbackup.nix | 23 +++++++++++++++-----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/nixos/modules/services/backup/borgbackup.nix b/nixos/modules/services/backup/borgbackup.nix
index adabb2ce0f8b..82baeb928398 100644
--- a/nixos/modules/services/backup/borgbackup.nix
+++ b/nixos/modules/services/backup/borgbackup.nix
@@ -136,7 +136,7 @@ let
mkBackupService =
name: cfg:
let
- userHome = config.users.users.${cfg.user}.home;
+ userHome = if cfg.stateDir != null then cfg.stateDir else config.users.users.${cfg.user}.home;
backupJobName = "borgbackup-job-${name}";
backupScript = mkBackupScript backupJobName cfg;
in
@@ -177,6 +177,7 @@ let
environment = {
BORG_REPO = cfg.repo;
}
+ // (lib.optionalAttrs (cfg.stateDir != null) { BORG_BASE_DIR = cfg.stateDir; })
// (mkPassEnv cfg)
// cfg.environment;
};
@@ -223,6 +224,7 @@ let
set = {
BORG_REPO = cfg.repo;
}
+ // (lib.optionalAttrs (cfg.stateDir != null) { BORG_BASE_DIR = cfg.stateDir; })
// (mkPassEnv cfg)
// cfg.environment;
});
@@ -232,14 +234,15 @@ let
name: cfg:
let
settings = { inherit (cfg) user group; };
+ userHome = if cfg.stateDir != null then cfg.stateDir else config.users.users.${cfg.user}.home;
in
lib.nameValuePair "borgbackup-job-${name}" (
{
# Create parent dirs separately, to ensure correct ownership.
- "${config.users.users."${cfg.user}".home}/.config".d = settings;
- "${config.users.users."${cfg.user}".home}/.cache".d = settings;
- "${config.users.users."${cfg.user}".home}/.config/borg".d = settings;
- "${config.users.users."${cfg.user}".home}/.cache/borg".d = settings;
+ "${userHome}/.config".d = settings;
+ "${userHome}/.cache".d = settings;
+ "${userHome}/.config/borg".d = settings;
+ "${userHome}/.cache/borg".d = settings;
}
// lib.optionalAttrs (isLocalPath cfg.repo && !cfg.removableDevice) {
"${cfg.repo}".d = settings;
@@ -487,6 +490,16 @@ in
default = "root";
};
+ stateDir = lib.mkOption {
+ type = lib.types.nullOr lib.types.str;
+ description = ''
+ Override the directory in which {command}`borg` stores its
+ configuration and cache. By default it uses the user's
+ home directory but is some cases this can cause conflicts.
+ '';
+ default = null;
+ };
+
wrapper = lib.mkOption {
type = with lib.types; nullOr str;
description = ''
--
2.50.1
================================================
FILE: patches/0001-selfhostblocks-never-onboard.patch
================================================
From 6897dd86a41b336c7c03a466990f7e981c5c649c Mon Sep 17 00:00:00 2001
From: ibizaman
Date: Tue, 23 Sep 2025 11:36:24 +0200
Subject: [PATCH] selfhostblocks: never onboard
---
backend/open_webui/main.py | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py
index 5630a5883..5c7c88a64 100644
--- a/backend/open_webui/main.py
+++ b/backend/open_webui/main.py
@@ -1654,11 +1654,9 @@ async def get_app_config(request: Request):
user = Users.get_user_by_id(data["id"])
user_count = Users.get_num_users()
+ # Never onboard
onboarding = False
- if user is None:
- onboarding = user_count == 0
-
return {
**({"onboarding": True} if onboarding else {}),
"status": True,
--
2.50.1
================================================
FILE: patches/lldap.patch
================================================
From e5a1bf4cb019933621eb059cc6cdd1f8af8df71d Mon Sep 17 00:00:00 2001
From: ibizaman
Date: Wed, 13 Aug 2025 08:14:38 +0200
Subject: [PATCH 1/2] lldap-bootstrap: init 0.6.2
---
pkgs/by-name/ll/lldap-bootstrap/package.nix | 57 +++++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 pkgs/by-name/ll/lldap-bootstrap/package.nix
diff --git a/pkgs/by-name/ll/lldap-bootstrap/package.nix b/pkgs/by-name/ll/lldap-bootstrap/package.nix
new file mode 100644
index 00000000000000..8b9a915b18f4d9
--- /dev/null
+++ b/pkgs/by-name/ll/lldap-bootstrap/package.nix
@@ -0,0 +1,57 @@
+{
+ curl,
+ fetchFromGitHub,
+ jq,
+ jo,
+ lib,
+ lldap,
+ lldap-bootstrap,
+ makeWrapper,
+ stdenv,
+}:
+let
+ version = "0.6.2";
+in
+stdenv.mkDerivation {
+ pname = "lldap-bootstrap";
+ inherit version;
+
+ src = fetchFromGitHub {
+ owner = "lldap";
+ repo = "lldap";
+ rev = "v${version}";
+ hash = "sha256-UBQWOrHika8X24tYdFfY8ETPh9zvI7/HV5j4aK8Uq+Y=";
+ };
+
+ dontBuild = true;
+
+ nativeBuildInputs = [ makeWrapper ];
+
+ installPhase = ''
+ mkdir -p $out/bin
+ cp ./scripts/bootstrap.sh $out/bin/lldap-bootstrap
+
+ wrapProgram $out/bin/lldap-bootstrap \
+ --set LLDAP_SET_PASSWORD_PATH ${lldap}/bin/lldap_set_password \
+ --prefix PATH : ${
+ lib.makeBinPath [
+ curl
+ jq
+ jo
+ ]
+ }
+ '';
+
+ meta = {
+ description = "Bootstrap script for LLDAP";
+ homepage = "https://github.com/lldap/lldap";
+ changelog = "https://github.com/lldap/lldap/blob/v${lldap-bootstrap.version}/CHANGELOG.md";
+ license = lib.licenses.gpl3Only;
+ platforms = lib.platforms.linux;
+ maintainers = with lib.maintainers; [
+ bendlas
+ ibizaman
+ ];
+ mainProgram = "lldap-bootstrap";
+ };
+}
From 6666c710b77e53ea274af4c4dddcb9251b0ccf18 Mon Sep 17 00:00:00 2001
From: ibizaman
Date: Wed, 13 Aug 2025 08:15:12 +0200
Subject: [PATCH 2/2] lldap: add ensure options
---
nixos/modules/services/databases/lldap.nix | 372 ++++++++++++++++++++-
nixos/tests/lldap.nix | 143 +++++++-
2 files changed, 498 insertions(+), 17 deletions(-)
diff --git a/nixos/modules/services/databases/lldap.nix b/nixos/modules/services/databases/lldap.nix
index fe956c943281..6097f8d06216 100644
--- a/nixos/modules/services/databases/lldap.nix
+++ b/nixos/modules/services/databases/lldap.nix
@@ -12,6 +12,84 @@ let
dbUser = "lldap";
localPostgresql = cfg.database.createLocally && cfg.database.type == "postgresql";
localMysql = cfg.database.createLocally && cfg.database.type == "mariadb";
+
+ inherit (lib) mkOption types;
+
+ ensureFormat = pkgs.formats.json { };
+ ensureGenerate =
+ let
+ filterNulls = lib.filterAttrsRecursive (n: v: v != null);
+
+ filteredSource =
+ source: if builtins.isList source then map filterNulls source else filterNulls source;
+ in
+ name: source: ensureFormat.generate name (filteredSource source);
+
+ ensureFieldsOptions = name: {
+ name = mkOption {
+ type = types.str;
+ description = "Name of the field.";
+ default = name;
+ };
+
+ attributeType = mkOption {
+ type = types.enum [
+ "STRING"
+ "INTEGER"
+ "JPEG"
+ "DATE_TIME"
+ ];
+ description = "Attribute type.";
+ };
+
+ isEditable = mkOption {
+ type = types.bool;
+ description = "Is field editable.";
+ default = true;
+ };
+
+ isList = mkOption {
+ type = types.bool;
+ description = "Is field a list.";
+ default = false;
+ };
+
+ isVisible = mkOption {
+ type = types.bool;
+ description = "Is field visible in UI.";
+ default = true;
+ };
+ };
+
+ allUserGroups = lib.flatten (lib.mapAttrsToList (n: u: u.groups) cfg.ensureUsers);
+ # The three hardcoded groups are always created when the service starts.
+ allGroups = lib.mapAttrsToList (n: g: g.name) cfg.ensureGroups ++ [
+ "lldap_admin"
+ "lldap_password_manager"
+ "lldap_strict_readonly"
+ ];
+ userGroupNotInEnsuredGroup = lib.sortOn lib.id (
+ lib.unique (lib.subtractLists allGroups allUserGroups)
+ );
+ someUsersBelongToNonEnsuredGroup = (lib.lists.length userGroupNotInEnsuredGroup) > 0;
+
+ generateEnsureConfigDir =
+ name: source:
+ let
+ genOne =
+ name: sourceOne:
+ pkgs.writeTextDir "configs/${name}.json" (
+ builtins.readFile (ensureGenerate "configs/${name}.json" sourceOne)
+ );
+ in
+ "${
+ pkgs.symlinkJoin {
+ inherit name;
+ paths = lib.mapAttrsToList genOne source;
+ }
+ }/configs";
+
+ quoteVariable = x: "\"${x}\"";
in
{
options.services.lldap = with lib; {
@@ -19,6 +97,8 @@ in
package = mkPackageOption pkgs "lldap" { };
+ bootstrap-package = mkPackageOption pkgs "lldap-bootstrap" { };
+
environment = mkOption {
type = with types; attrsOf str;
default = { };
@@ -203,6 +283,198 @@ in
If that is okay for you and you want to silence the warning, set this option to `true`.
'';
};
+
+ ensureUsers = mkOption {
+ description = ''
+ Create the users defined here on service startup.
+
+ If `enforceEnsure` option is `true`, the groups
+ users belong to must be present in the `ensureGroups` option.
+
+ Non-default options must be added to the `ensureGroupFields` option.
+ '';
+ default = { };
+ type = types.attrsOf (
+ types.submodule (
+ { name, ... }:
+ {
+ freeformType = ensureFormat.type;
+
+ options = {
+ id = mkOption {
+ type = types.str;
+ description = "Username.";
+ default = name;
+ };
+
+ email = mkOption {
+ type = types.str;
+ description = "Email.";
+ };
+
+ password_file = mkOption {
+ type = types.str;
+ description = "File containing the password.";
+ };
+
+ displayName = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Display name.";
+ };
+
+ firstName = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "First name.";
+ };
+
+ lastName = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Last name.";
+ };
+
+ avatar_file = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Avatar file. Must be a valid path to jpeg file (ignored if avatar_url specified)";
+ };
+
+ avatar_url = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Avatar url. must be a valid URL to jpeg file (ignored if gravatar_avatar specified)";
+ };
+
+ gravatar_avatar = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Get avatar from Gravatar using the email.";
+ };
+
+ weser_avatar = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Convert avatar retrieved by gravatar or the URL.";
+ };
+
+ groups = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = "Groups the user would be a member of (all the groups must be specified in group config files).";
+ };
+ };
+ }
+ )
+ );
+ };
+
+ ensureGroups = mkOption {
+ description = ''
+ Create the groups defined here on service startup.
+
+ Non-default options must be added to the `ensureGroupFields` option.
+ '';
+ default = { };
+ type = types.attrsOf (
+ types.submodule (
+ { name, ... }:
+ {
+ freeformType = ensureFormat.type;
+
+ options = {
+ name = mkOption {
+ type = types.str;
+ description = "Name of the group.";
+ default = name;
+ };
+ };
+ }
+ )
+ );
+ };
+
+ ensureUserFields = mkOption {
+ description = "Extra fields for users";
+ default = { };
+ type = types.attrsOf (
+ types.submodule (
+ { name, ... }:
+ {
+ options = ensureFieldsOptions name;
+ }
+ )
+ );
+ };
+
+ ensureGroupFields = mkOption {
+ description = "Extra fields for groups";
+ default = { };
+ type = types.attrsOf (
+ types.submodule (
+ { name, ... }:
+ {
+ options = ensureFieldsOptions name;
+ }
+ )
+ );
+ };
+
+ ensureAdminUsername = mkOption {
+ type = types.str;
+ default = "admin";
+ description = ''
+ Username of the default admin user with which to connect to the LLDAP service.
+
+ By default, it is `"admin"`.
+ Extra admin users can be added using the `services.lldap.ensureUsers` option and adding them to the correct groups.
+ '';
+ };
+
+ ensureAdminPassword = mkOption {
+ type = types.nullOr types.str;
+ defaultText = "config.services.lldap.settings.ldap_user_pass";
+ default = cfg.settings.ldap_user_pass or null;
+ description = ''
+ Password of an admin user with which to connect to the LLDAP service.
+
+ By default, it is the same as the password for the default admin user 'admin'.
+ If using a password from another user, it must be managed manually.
+
+ Unsecure. Use `services.lldap.ensureAdminPasswordFile` option instead.
+ '';
+ };
+
+ ensureAdminPasswordFile = mkOption {
+ type = types.nullOr types.str;
+ defaultText = "config.services.lldap.settings.ldap_user_pass_file";
+ default = cfg.settings.ldap_user_pass_file or null;
+ description = ''
+ Path to the file containing the password of an admin user with which to connect to the LLDAP service.
+
+ By default, it is the same as the password for the default admin user 'admin'.
+ If using a password from another user, it must be managed manually.
+ '';
+ };
+
+ enforceUsers = mkOption {
+ description = "Delete users not managed declaratively.";
+ type = types.bool;
+ default = false;
+ };
+
+ enforceUserMemberships = mkOption {
+ description = "Remove users from groups they do not belong to declaratively.";
+ type = types.bool;
+ default = false;
+ };
+
+ enforceGroups = mkOption {
+ description = "Delete groups not managed declaratively.";
+ type = types.bool;
+ default = false;
+ };
};
config = lib.mkIf cfg.enable {
@@ -219,25 +491,77 @@ in
(cfg.settings.ldap_user_pass_file or null) == null || (cfg.settings.ldap_user_pass or null) == null;
message = "lldap: Both `ldap_user_pass` and `ldap_user_pass_file` settings should not be set at the same time. Set one to `null`.";
}
+ {
+ assertion =
+ cfg.ensureUsers != { }
+ || cfg.ensureGroups != { }
+ || cfg.ensureUserFields != { }
+ || cfg.ensureGroupFields != { }
+ || cfg.enforceUsers
+ || cfg.enforceUserMemberships
+ || cfg.enforceGroups
+ -> cfg.ensureAdminPassword != null || cfg.ensureAdminPasswordFile != null;
+ message = ''
+ lldap: Some ensure options are set but no admin user password is set.
+ Add a default password to the `ldap_user_pass` or `ldap_user_pass_file` setting and set `force_ldap_user_pass_reset` to `true` to manage the admin user declaratively
+ or create an admin user manually and set its password in `ensureAdminPasswordFile` option.
+ '';
+ }
+ {
+ assertion = cfg.enforceUserMemberships -> !someUsersBelongToNonEnsuredGroup;
+ message = ''
+ lldap: Some users belong to groups not present in the ensureGroups attr,
+ add the following groups or remove them from the groups a user belong to:
+ ${lib.concatMapStringsSep quoteVariable ", " userGroupNotInEnsuredGroup}
+ '';
+ }
+ (
+ let
+ getNames = source: lib.flatten (lib.mapAttrsToList (x: v: v.name) source);
+ allNames = getNames cfg.ensureUserFields ++ getNames cfg.ensureGroupFields;
+ validFieldName = name: lib.match "[a-zA-Z0-9-]+" name != null;
+ in
+ {
+ assertion = lib.all validFieldName allNames;
+ message = ''
+ lldap: The following custom user or group fields have invalid names. Valid characters are: a-z, A-Z, 0-9, and dash (-).
+ The offending fields are: ${
+ lib.concatMapStringsSep quoteVariable ", " (lib.filter (x: !(validFieldName x)) allNames)
+ }
+ '';
+ }
+ )
];
warnings =
- lib.optionals ((cfg.settings.ldap_user_pass or null) != null) [
+ (lib.optionals (cfg.ensureAdminPassword != null) [
+ ''
+ lldap: Unsecure option `ensureAdminPassword` is used. Prefer `ensureAdminPasswordFile` instead.
+ ''
+ ])
+ ++ (lib.optionals ((cfg.settings.ldap_user_pass or null) != null) [
''
lldap: Unsecure `ldap_user_pass` setting is used. Prefer `ldap_user_pass_file` instead.
''
- ]
- ++
- lib.optionals
- (cfg.settings.force_ldap_user_pass_reset == false && cfg.silenceForceUserPassResetWarning == false)
- [
- ''
- lldap: The `force_ldap_user_pass_reset` setting is set to `false` which means
- the admin password can be changed through the UI and will drift from the one defined in your nix config.
- It also means changing the setting `ldap_user_pass` or `ldap_user_pass_file` will have no effect on the admin password.
- Either set `force_ldap_user_pass_reset` to `"always"` or silence this warning by setting the option `services.lldap.silenceForceUserPassResetWarning` to `true`.
- ''
- ];
+ ])
+ ++ (lib.optionals
+ (cfg.settings.force_ldap_user_pass_reset == false && cfg.silenceForceUserPassResetWarning == false)
+ [
+ ''
+ lldap: The `force_ldap_user_pass_reset` setting is set to `false` which means
+ the admin password can be changed through the UI and will drift from the one defined in your nix config.
+ It also means changing the setting `ldap_user_pass` or `ldap_user_pass_file` will have no effect on the admin password.
+ Either set `force_ldap_user_pass_reset` to `"always"` or silence this warning by setting the option `services.lldap.silenceForceUserPassResetWarning` to `true`.
+ ''
+ ]
+ )
+ ++ (lib.optionals (!cfg.enforceUserMemberships && someUsersBelongToNonEnsuredGroup) [
+ ''
+ Some users belong to groups not managed by the configuration here,
+ make sure the following groups exist or the service will not start properly:
+ ${lib.concatStringsSep ", " (map (x: "\"${x}\"") userGroupNotInEnsuredGroup)}
+ ''
+ ]);
services.lldap.settings.database_url = lib.mkIf cfg.database.createLocally (
lib.mkDefault (
@@ -279,6 +603,28 @@ in
+ ''
exec ${lib.getExe cfg.package} run --config-file ${format.generate "lldap_config.toml" cfg.settings}
'';
+ postStart = ''
+ export LLDAP_URL=http://127.0.0.1:${toString cfg.settings.http_port}
+ export LLDAP_ADMIN_USERNAME=${cfg.ensureAdminUsername}
+ export LLDAP_ADMIN_PASSWORD=${
+ if cfg.ensureAdminPassword != null then cfg.ensureAdminPassword else ""
+ }
+ export LLDAP_ADMIN_PASSWORD_FILE=${
+ if cfg.ensureAdminPasswordFile != null then cfg.ensureAdminPasswordFile else ""
+ }
+ export USER_CONFIGS_DIR=${generateEnsureConfigDir "users" cfg.ensureUsers}
+ export GROUP_CONFIGS_DIR=${generateEnsureConfigDir "groups" cfg.ensureGroups}
+ export USER_SCHEMAS_DIR=${
+ generateEnsureConfigDir "userFields" (lib.mapAttrs (n: v: [ v ]) cfg.ensureUserFields)
+ }
+ export GROUP_SCHEMAS_DIR=${
+ generateEnsureConfigDir "groupFields" (lib.mapAttrs (n: v: [ v ]) cfg.ensureGroupFields)
+ }
+ export DO_CLEANUP_USERS=${if cfg.enforceUsers then "true" else "false"}
+ export DO_CLEANUP_USER_MEMBERSHIPS=${if cfg.enforceUserMemberships then "true" else "false"}
+ export DO_CLEANUP_GROUPS=${if cfg.enforceGroups then "true" else "false"}
+ ${lib.getExe cfg.bootstrap-package}
+ '';
serviceConfig = {
StateDirectory = "lldap";
StateDirectoryMode = "0750";
diff --git a/nixos/tests/lldap.nix b/nixos/tests/lldap.nix
index 8e38d4bdefa3..47d32c7a2a7b 100644
--- a/nixos/tests/lldap.nix
+++ b/nixos/tests/lldap.nix
@@ -1,6 +1,9 @@
{ ... }:
let
adminPassword = "mySecretPassword";
+ alicePassword = "AlicePassword";
+ bobPassword = "BobPassword";
+ charliePassword = "CharliePassword";
in
{
name = "lldap";
@@ -26,7 +29,7 @@ in
{
services.lldap.settings = {
ldap_user_pass = lib.mkForce null;
- ldap_user_pass_file = lib.mkForce (toString (pkgs.writeText "adminPasswordFile" adminPassword));
+ ldap_user_pass_file = toString (pkgs.writeText "adminPasswordFile" adminPassword);
force_ldap_user_pass_reset = "always";
};
};
@@ -40,13 +43,110 @@ in
force_ldap_user_pass_reset = false;
};
};
+
+ withAlice.configuration =
+ { ... }:
+ {
+ services.lldap = {
+ enforceUsers = true;
+ enforceUserMemberships = true;
+ enforceGroups = true;
+
+ # This password was set in the "differentAdminPassword" specialisation.
+ ensureAdminPasswordFile = toString (pkgs.writeText "adminPasswordFile" adminPassword);
+
+ ensureUsers = {
+ alice = {
+ email = "alice@example.com";
+ password_file = toString (pkgs.writeText "alicePasswordFile" alicePassword);
+ groups = [ "mygroup" ];
+ };
+ };
+
+ ensureGroups = {
+ mygroup = { };
+ };
+ };
+ };
+
+ withBob.configuration =
+ { ... }:
+ {
+ services.lldap = {
+ enforceUsers = true;
+ enforceUserMemberships = true;
+ enforceGroups = true;
+
+ # This time we check that ensureAdminPasswordFile correctly defaults to `settings.ldap_user_pass_file`
+ settings = {
+ ldap_user_pass = lib.mkForce "password";
+ force_ldap_user_pass_reset = "always";
+ };
+
+ ensureUsers = {
+ bob = {
+ email = "bob@example.com";
+ password_file = toString (pkgs.writeText "bobPasswordFile" bobPassword);
+ groups = [ "bobgroup" ];
+ displayName = "Bob";
+ };
+ };
+
+ ensureGroups = {
+ bobgroup = { };
+ };
+ };
+ };
+
+ withAttributes.configuration =
+ { ... }:
+ {
+ services.lldap = {
+ enforceUsers = true;
+ enforceUserMemberships = true;
+ enforceGroups = true;
+
+ settings = {
+ ldap_user_pass = lib.mkForce adminPassword;
+ force_ldap_user_pass_reset = "always";
+ };
+
+ ensureUsers = {
+ charlie = {
+ email = "charlie@example.com";
+ password_file = toString (pkgs.writeText "charliePasswordFile" charliePassword);
+ groups = [ "othergroup" ];
+ displayName = "Charlie";
+ myattribute = 2;
+ };
+ };
+
+ ensureGroups = {
+ othergroup = {
+ mygroupattribute = "Managed by NixOS";
+ };
+ };
+
+ ensureUserFields = {
+ myattribute = {
+ attributeType = "INTEGER";
+ };
+ };
+
+ ensureGroupFields = {
+ mygroupattribute = {
+ attributeType = "STRING";
+ };
+ };
+ };
+ };
};
};
testScript =
{ nodes, ... }:
let
- specializations = "${nodes.machine.system.build.toplevel}/specialisation";
+ specialisations = "${nodes.machine.system.build.toplevel}/specialisation";
in
''
machine.wait_for_unit("lldap.service")
@@ -56,6 +156,9 @@ in
machine.succeed("curl --location --fail http://localhost:17170/")
adminPassword="${adminPassword}"
+ alicePassword="${alicePassword}"
+ bobPassword="${bobPassword}"
+ charliePassword="${charliePassword}"
def try_login(user, password, expect_success=True):
cmd = f'ldapsearch -H ldap://localhost:3890 -D uid={user},ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w {password}'
@@ -70,18 +173,50 @@ in
raise Exception("Expected failure, had success")
return response
+ def parse_ldapsearch_output(output):
+ return {n:v for (n, v) in (x.split(': ', 2) for x in output.splitlines() if x != "")}
+
with subtest("default admin password"):
try_login("admin", "password", expect_success=True)
try_login("admin", adminPassword, expect_success=False)
with subtest("different admin password"):
- machine.succeed('${specializations}/differentAdminPassword/bin/switch-to-configuration test')
+ machine.succeed('${specialisations}/differentAdminPassword/bin/switch-to-configuration test')
try_login("admin", "password", expect_success=False)
try_login("admin", adminPassword, expect_success=True)
with subtest("change admin password has no effect"):
- machine.succeed('${specializations}/differentAdminPassword/bin/switch-to-configuration test')
+ machine.succeed('${specialisations}/differentAdminPassword/bin/switch-to-configuration test')
try_login("admin", "password", expect_success=False)
try_login("admin", adminPassword, expect_success=True)
+
+ with subtest("with alice"):
+ machine.succeed('${specialisations}/withAlice/bin/switch-to-configuration test')
+ try_login("alice", "password", expect_success=False)
+ try_login("alice", alicePassword, expect_success=True)
+ try_login("bob", "password", expect_success=False)
+ try_login("bob", bobPassword, expect_success=False)
+
+ with subtest("with bob"):
+ machine.succeed('${specialisations}/withBob/bin/switch-to-configuration test')
+ try_login("alice", "password", expect_success=False)
+ try_login("alice", alicePassword, expect_success=False)
+ try_login("bob", "password", expect_success=False)
+ try_login("bob", bobPassword, expect_success=True)
+
+ with subtest("with attributes"):
+ machine.succeed('${specialisations}/withAttributes/bin/switch-to-configuration test')
+
+ response = machine.succeed(f'ldapsearch -LLL -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "dc=example,dc=com" -w {adminPassword} "(uid=charlie)"')
+ print(response)
+ charlie = parse_ldapsearch_output(response)
+ if charlie.get('myattribute') != "2":
+ raise Exception(f'Unexpected value for attribute "myattribute": {charlie.get('myattribute')}')
+
+ response = machine.succeed(f'ldapsearch -LLL -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "dc=example,dc=com" -w {adminPassword} "(cn=othergroup)"')
+ print(response)
+ othergroup = parse_ldapsearch_output(response)
+ if othergroup.get('mygroupattribute') != "Managed by NixOS":
+ raise Exception(f'Unexpected value for attribute "mygroupattribute": {othergroup.get('mygroupattribute')}')
'';
}
--
================================================
FILE: patches/nextcloudexternalstorage.patch
================================================
diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php
index 260f9218a88..26e5a4172f7 100644
--- a/lib/private/Files/Storage/Local.php
+++ b/lib/private/Files/Storage/Local.php
@@ -66,9 +66,12 @@ class Local extends \OC\Files\Storage\Common {
$this->unlinkOnTruncate = $this->config->getSystemValueBool('localstorage.unlink_on_truncate', false);
if (isset($parameters['isExternal']) && $parameters['isExternal'] && !$this->stat('')) {
- // data dir not accessible or available, can happen when using an external storage of type Local
- // on an unmounted system mount point
- throw new StorageNotAvailableException('Local storage path does not exist "' . $this->getSourcePath('') . '"');
+ if (!$this->mkdir('')) {
+ // data dir not accessible or available, can happen when using an external storage of type Local
+ // on an unmounted system mount point
+ throw new StorageNotAvailableException('Local storage path does not exist and could not create it "' . $this->getSourcePath('') . '"');
+ }
+ Server::get(LoggerInterface::class)->warning('created local storage path ' . $this->getSourcePath(''), ['app' => 'core']);
}
}
--
2.50.1
================================================
FILE: test/blocks/authelia.nix
================================================
{ pkgs, shb, ... }:
let
pkgs' = pkgs;
ldapAdminPassword = "ldapAdminPassword";
in
{
basic = shb.test.runNixOSTest {
name = "authelia-basic";
nodes.machine =
{ config, pkgs, ... }:
{
imports = [
(pkgs'.path + "/nixos/modules/profiles/headless.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 = {
enable = true;
dcdomain = "dc=example,dc=com";
subdomain = "ldap";
domain = "machine.com";
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;
};
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 = [
{
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, ... }:
let
specializations = "${nodes.machine.system.build.toplevel}/specialisation";
in
''
import json
start_all()
def tests():
machine.wait_for_unit("lldap.service")
machine.wait_for_unit("authelia-authelia.machine.com.target")
machine.wait_for_open_port(9091)
endpoints = json.loads(machine.succeed("curl -s http://machine.com/.well-known/openid-configuration"))
auth_endpoint = endpoints['authorization_endpoint']
print(f"auth_endpoint: {auth_endpoint}")
if auth_endpoint != "http://machine.com/api/oidc/authorization":
raise Exception("Unexpected auth_endpoint")
resp = machine.succeed(
"curl -f -s '"
+ auth_endpoint
+ "?client_id=other"
+ "&redirect_uri=http://client1.machine.com/redirect"
+ "&scope=openid%20profile%20email"
+ "&response_type=code"
+ "&state=99999999'"
)
print(resp)
if resp != "":
raise Exception("unexpected response")
resp = machine.succeed(
"curl -f -s '"
+ auth_endpoint
+ "?client_id=client1"
+ "&redirect_uri=http://client1.machine.com/redirect"
+ "&scope=openid%20profile%20email"
+ "&response_type=code"
+ "&state=11111111'"
)
print(resp)
if "Found" not in resp:
raise Exception("unexpected response")
resp = machine.succeed(
"curl -f -s '"
+ auth_endpoint
+ "?client_id=client2"
+ "&redirect_uri=http://client2.machine.com/redirect"
+ "&scope=openid%20profile%20email"
+ "&response_type=code"
+ "&state=22222222'"
)
print(resp)
if "Found" not in resp:
raise Exception("unexpected response")
with subtest("no debug"):
tests()
with subtest("with debug"):
machine.succeed('${specializations}/withDebug/bin/switch-to-configuration test')
tests()
'';
};
}
================================================
FILE: test/blocks/borgbackup.nix
================================================
{ shb, ... }:
let
commonTest =
user:
shb.test.runNixOSTest {
name = "borgbackup_backupAndRestore_${user}";
nodes.machine =
{ config, ... }:
{
imports = [
shb.test.baseImports
../../modules/blocks/hardcodedsecret.nix
../../modules/blocks/borgbackup.nix
];
shb.hardcodedsecret.A = {
request = {
owner = "root";
group = "keys";
mode = "0440";
};
settings.content = "secretA";
};
shb.hardcodedsecret.B = {
request = {
owner = "root";
group = "keys";
mode = "0440";
};
settings.content = "secretB";
};
shb.hardcodedsecret.passphrase = {
request = config.shb.borgbackup.instances."testinstance".settings.passphrase.request;
settings.content = "passphrase";
};
shb.borgbackup.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/borgbackup-backups-testinstance_opt_repos_A ]; then
if ! [ -f /run/secrets_borgbackup/borgbackup-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.borgbackup.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
{
backupAndRestoreRoot = commonTest "root";
backupAndRestoreUser = commonTest "nobody";
}
================================================
FILE: test/blocks/keypair.pem
================================================
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC2x1rFx98p6djQ
X0mJ8nUMnS3LU7ih8UU5soW/TVhdcioe+NUevLjq0Qe/RXAz+yjhAxmJoWsFwMuy
PKQKDbnqLH6Rf2qPiOvg5NB/vINuVpnAo9mQUJlJOrWKe6/pk6FWD0YxGiwETEIX
bdARazOo/n5emQCZ7XwFy9ULlZkURIt9co/SxhMaD0Q+P1eev0lt01XG5ZLg9wL8
CHIkasqK5huKFUnHVyq8+ApVrzsANsvjFSLwd985FpIF+DYcVQ+8ZmwVrbZTzFFC
Pjee8CrhO1ZxOAPwVm7qNTtZ4es8xJyMJvijk6grqLGcWSIWTMAwxmN7feOuEvvk
RlDf9DmpAgMBAAECggEACP51jySrDLAQ/wznTJpRi+u6loYhwFdD26dXGT8kNWHy
JGjEbPEmrMhZKB5xu18VJ4Bca/c1UdjHNTeybzu2balfl4eEbfhz+fKsd1KmiYIJ
qg7t/GHY7x9sUjqMoRLmhhp1juI9rv71JBu/WLIUnlDalUtUWh6zYwIhE0M634I8
GjN4hCxvbVgQEyY4kMBvCcT9sixwm407qL7LfqlsT8KTGB9UU2cC1HD4B/pUKVzw
x+vN93S6KS2SrjaYhAb1xHgxU6Bl1jT1IH8yAXVlmBBmDL9dNEJtD/kuX8kfbvuC
yFY5NWVapSgyIhURkaHqJKmziaq51K1xHGCZYBDsYQKBgQDq0ovgTNBzgmwyl/ye
ZgUIWc/5tE2LlyoM8XTok9EB+8CnoBek8JFo9DVfNzTv+UCUXb7DCveuS1Jb0JY0
Xi4gOSczVV297Lszziogxuni4ax/1Nezah/WSffVEowakPuTLK+0dst0QxWC6+Db
m4OHJY5qjS/mh3rLhFdjXcmAoQKBgQDHQ0BAg8AhlFz9fTxit1pyHuVs1EcEBjqI
UOS1ClS+BDcjERVBJ8GKiZj2/la37OLlQuguH2AXX//wVC5rZEXP38+ELemW0BZC
JFKaY5PYufMcGVd6JBDYCoEa/JERJsD87ADBAUj/kIMfvka/it9PID9jgMPaVESE
LYIsRv40CQKBgQCtdJ0yMEuCJ4L41GAcOUvaYU1JLDBjvmOnb+xlqFqpVmd26sDM
a49dsZaDIOqPoNRdQ+oXdNCEBMtvWuK5CCCWWOFl/9bg5i9aEx33XDeECiM7weMb
enbN+ZGB6NNpBFNw4X9glKew16TaMpbEYVmEyO8sMeKCLO09zCIpGiwwQQKBgQCF
++dhOfXf3mXkoOgQrJ8pazLzSY1y3ElRTatrPEYc+rKkZqE3DWdrIvhy5DQlOiia
5bE/CiPPs+JhlAkedu8mRqS/iSuvF75PvSK540kPioE4nKWgYE3fJrkHD1rwAHH1
3y7mmFmgVmiE2Kmzs8pR5yoYWwXWcaEci4kjAp19GQKBgQDRpy4ojGUmKdDffcGU
pEpl+dGpC3YuGwEsopDTYJSjANq0p5QGcQo9L140XxBEaFd4k/jwvVh2VRx4KmkC
wyFODOk4vbq1NKljLC9yRo6UbUZuzWBsyjP62OHPR5MBg5FQgd4RI6/c3EpAhFGX
pM/CH7yZXp7Brhp4RcdbwhQnIA==
-----END PRIVATE KEY-----
================================================
FILE: test/blocks/lib.nix
================================================
{
pkgs,
lib,
shb,
...
}:
let
pkgs' = pkgs;
in
{
template =
let
aSecret = pkgs.writeText "a-secret.txt" "Secret of A";
bSecret = pkgs.writeText "b-secret.txt" "Secret of B";
userConfig = {
a.a.source = aSecret;
b.source = bSecret;
b.transform = v: "prefix-${v}-suffix";
c = "not secret C";
d.d = "not secret D";
};
wantedConfig = {
a.a = "Secret of A";
b = "prefix-Secret of B-suffix";
c = "not secret C";
d.d = "not secret D";
};
configWithTemplates = shb.withReplacements userConfig;
nonSecretConfigFile = pkgs.writeText "config.yaml.template" (
lib.generators.toJSON { } configWithTemplates
);
replacements = shb.getReplacements userConfig;
replaceInTemplate = shb.replaceSecretsScript {
file = nonSecretConfigFile;
resultPath = "/var/lib/config.yaml";
inherit replacements;
};
replaceInTemplateJSON = shb.replaceSecrets {
inherit userConfig;
resultPath = "/var/lib/config.json";
generator = shb.replaceSecretsFormatAdapter (pkgs.formats.json { });
};
replaceInTemplateJSONGen = shb.replaceSecrets {
inherit userConfig;
resultPath = "/var/lib/config_gen.json";
generator = shb.replaceSecretsGeneratorAdapter (lib.generators.toJSON { });
};
replaceInTemplateXML = shb.replaceSecrets {
inherit userConfig;
resultPath = "/var/lib/config.xml";
generator = shb.replaceSecretsFormatAdapter (shb.formatXML { enclosingRoot = "Root"; });
};
in
shb.test.runNixOSTest {
name = "lib-template";
nodes.machine =
{ config, pkgs, ... }:
{
imports = [
(pkgs'.path + "/nixos/modules/profiles/headless.nix")
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
{
options = {
libtest.config = lib.mkOption {
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.secretFileType
]
);
};
};
}
];
system.activationScripts = {
libtest = replaceInTemplate;
libtestJSON = replaceInTemplateJSON;
libtestJSONGen = replaceInTemplateJSONGen;
libtestXML = replaceInTemplateXML;
};
};
testScript =
{ nodes, ... }:
''
import json
from collections import ChainMap
from xml.etree import ElementTree
start_all()
machine.wait_for_file("/var/lib/config.yaml")
machine.wait_for_file("/var/lib/config.json")
machine.wait_for_file("/var/lib/config_gen.json")
machine.wait_for_file("/var/lib/config.xml")
def xml_to_dict_recursive(root):
all_descendants = list(root)
if len(all_descendants) == 0:
return {root.tag: root.text}
else:
merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants))
return {root.tag: dict(merged_dict)}
wantedConfig = json.loads('${lib.generators.toJSON { } wantedConfig}')
with subtest("config"):
print(machine.succeed("cat ${pkgs.writeText "replaceInTemplate" replaceInTemplate}"))
gotConfig = machine.succeed("cat /var/lib/config.yaml")
print(gotConfig)
gotConfig = json.loads(gotConfig)
if wantedConfig != gotConfig:
raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig))
with subtest("config JSON Gen"):
print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateJSONGen" replaceInTemplateJSONGen}"))
gotConfig = machine.succeed("cat /var/lib/config_gen.json")
print(gotConfig)
gotConfig = json.loads(gotConfig)
if wantedConfig != gotConfig:
raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig))
with subtest("config JSON"):
print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateJSON" replaceInTemplateJSON}"))
gotConfig = machine.succeed("cat /var/lib/config.json")
print(gotConfig)
gotConfig = json.loads(gotConfig)
if wantedConfig != gotConfig:
raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig))
with subtest("config XML"):
print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateXML" replaceInTemplateXML}"))
gotConfig = machine.succeed("cat /var/lib/config.xml")
print(gotConfig)
gotConfig = xml_to_dict_recursive(ElementTree.XML(gotConfig))['Root']
if wantedConfig != gotConfig:
raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig))
'';
};
}
================================================
FILE: test/blocks/lldap.nix
================================================
{
pkgs,
lib,
shb,
...
}:
let
pkgs' = pkgs;
password = "securepassword";
charliePassword = "CharliePassword";
in
{
auth = shb.test.runNixOSTest {
name = "ldap-auth";
nodes.server =
{ config, pkgs, ... }:
{
imports = [
(pkgs'.path + "/nixos/modules/profiles/headless.nix")
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
{
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;
};
};
ensureGroups = {
"family" = { };
};
};
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
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
testScript =
{ nodes, ... }:
let
specializations = "${nodes.server.system.build.toplevel}/specialisation";
in
''
import json
start_all()
def tests():
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.ldapPort})
with subtest("fail without authenticating"):
client.fail(
"curl -f -s -X GET"
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ " http://server/api/graphql"
)
with subtest("fail authenticating with wrong credentials"):
resp = client.fail(
"curl -f -s -X POST"
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ " http://server/auth/simple/login"
+ """ -d '{"username": "admin", "password": "wrong"}'"""
)
print(resp)
with subtest("succeed with correct authentication"):
token = json.loads(client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ " http://server/auth/simple/login "
+ """ -d '{"username": "admin", "password": "${password}"}' """
))['token']
data = json.loads(client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ """ -H "Authorization: Bearer {token}" """.format(token=token)
+ " http://server/api/graphql "
+ """ -d '{"variables": {"id": "admin"}, "query":"query($id:String!){user(userId:$id){displayName groups{displayName}}}"}' """
))['data']
assert data['user']['displayName'] == "Administrator"
assert data['user']['groups'][0]['displayName'] == "lldap_admin"
with subtest("succeed charlie"):
resp = client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ " http://server/auth/simple/login "
+ """ -d '{"username": "charlie", "password": "${charliePassword}"}' """
)
print(resp)
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}')
print(resp)
if "uid=admin" not in resp:
raise Exception("Expected to find admin")
if "uid=charlie" not in resp:
raise Exception("Expected to find charlie")
with subtest("no debug"):
tests()
with subtest("with debug"):
server.succeed('${specializations}/withDebug/bin/switch-to-configuration test')
tests()
'';
};
}
================================================
FILE: test/blocks/mitmdump.nix
================================================
{
pkgs,
lib,
shb,
...
}:
let
serve =
port: text:
lib.getExe (
pkgs.writers.writePython3Bin "serve"
{
libraries = [ pkgs.python3Packages.systemd-python ];
}
(
let
content = pkgs.writeText "content" text;
in
''
from http.server import BaseHTTPRequestHandler, HTTPServer
from systemd.daemon import notify
with open("${content}", "rb") as f:
content = f.read()
class HardcodedHandler(BaseHTTPRequestHandler):
def do_GET(self):
reponse = content + self.path.encode('utf-8')
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(reponse)))
self.end_headers()
print("answering to GET request")
self.wfile.write(reponse)
def log_message(self, format, *args):
pass # optional: suppress logging
if __name__ == "__main__":
notify('STATUS=Starting up...')
server_address = ('127.0.0.1', ${toString port})
httpd = HTTPServer(server_address, HardcodedHandler)
print("Serving hardcoded page on http://127.0.0.1:${toString port}")
notify('READY=1')
httpd.serve_forever()
''
)
);
in
{
default = shb.test.runNixOSTest {
name = "mitmdump-default";
nodes.machine =
{ config, pkgs, ... }:
{
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"
];
};
};
testScript =
{ nodes, ... }:
''
start_all()
machine.wait_for_unit("test1.service")
machine.wait_for_unit("test2.service")
machine.wait_for_unit("mitmdump-test1.service")
machine.wait_for_unit("mitmdump-test2.service")
resp = machine.succeed("curl http://127.0.0.1:8000")
print(resp)
if resp != "test1/":
raise Exception("wanted 'test1'")
resp = machine.succeed("curl -v http://127.0.0.1:8001")
print(resp)
if resp != "test1/":
raise Exception("wanted 'test1'")
resp = machine.succeed("curl http://127.0.0.1:8002")
print(resp)
if resp != "test2/":
raise Exception("wanted 'test2'")
resp = machine.succeed("curl http://127.0.0.1:8003/notverbose")
print(resp)
if resp != "test2/notverbose":
raise Exception("wanted 'test2/notverbose'")
resp = machine.succeed("curl http://127.0.0.1:8003/verbose")
print(resp)
if resp != "test2/verbose":
raise Exception("wanted 'test2/verbose'")
dump = machine.succeed("journalctl -b -u mitmdump-test1.service")
print(dump)
if "HTTP/1.0 200 OK" not in dump:
raise Exception("expected to see HTTP/1.0 200 OK")
if "test1" not in dump:
raise Exception("expected to see test1")
dump = machine.succeed("journalctl -b -u mitmdump-test2.service")
print(dump)
if "HTTP/1.0 200 OK" not in dump:
raise Exception("expected to see HTTP/1.0 200 OK")
if "test2/notverbose" in dump:
raise Exception("expected not to see test2/notverbose")
if "test2/verbose" not in dump:
raise Exception("expected to see test2/verbose")
'';
};
}
================================================
FILE: test/blocks/monitoring.nix
================================================
{ shb, ... }:
let
password = "securepw";
oidcSecret = "oidcSecret";
commonTestScript = shb.test.accessScript {
hasSSL = { node, ... }: !(isNull node.config.shb.monitoring.ssl);
waitForServices =
{ ... }:
[
"grafana.service"
];
waitForPorts =
{ node, ... }:
[
node.config.shb.monitoring.grafanaPort
];
};
basic =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/blocks/monitoring.nix
];
test = {
subdomain = "g";
};
shb.monitoring = {
enable = true;
inherit (config.test) subdomain domain;
scrutiny.enable = false;
contactPoints = [ "me@example.com" ];
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";
};
};
https =
{ config, ... }:
{
shb.monitoring = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
ldap =
{ config, ... }:
{
shb.monitoring = {
ldap = {
userGroup = "user_group";
adminGroup = "admin_group";
};
};
};
clientLoginSso =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
test = {
subdomain = "g";
};
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(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('Welcome to Grafana')).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('Welcome to Grafana')).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('[Ll]ogin failed'))).to_be_visible(timeout=10000)"
];
}
];
};
};
scrutiny =
{ lib, ... }:
{
shb.monitoring = {
scrutiny.enable = lib.mkForce true;
};
};
clientScrutinyLoginSso =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
test = {
subdomain = "scrutiny";
};
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(timeout=10000)"
];
}
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
''
if page.get_by_role('button', name=re.compile('Accept')).count() > 0:
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('Temperature history for each device')).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 = [
''
if page.get_by_role('button', name=re.compile('Accept')).count() > 0:
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('Temperature history for each device')).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('[Ll]ogin failed'))).to_be_visible(timeout=10000)"
];
}
];
};
};
sso =
{ config, ... }:
{
shb.monitoring = {
sso = {
enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result;
};
};
shb.hardcodedsecret.oidcSecret = {
request = config.shb.monitoring.sso.sharedSecret.request;
settings.content = oidcSecret;
};
shb.hardcodedsecret.oidcAutheliaSecret = {
request = config.shb.monitoring.sso.sharedSecretForAuthelia.request;
settings.content = oidcSecret;
};
};
in
{
basic = shb.test.runNixOSTest {
name = "monitoring_basic";
node.pkgsReadOnly = false;
nodes.server = {
imports = [
basic
];
};
nodes.client = { };
testScript = commonTestScript;
};
https = shb.test.runNixOSTest {
name = "monitoring_https";
node.pkgsReadOnly = false;
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
nodes.client = { };
testScript = commonTestScript;
};
sso = shb.test.runNixOSTest {
name = "monitoring_sso";
node.pkgsReadOnly = false;
nodes.client = {
imports = [
clientLoginSso
];
virtualisation.memorySize = 4096;
};
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
# virtualisation.memorySize = 4096;
};
testScript = commonTestScript;
};
scrutiny_sso = shb.test.runNixOSTest {
name = "monitoring_scrutiny_sso";
node.pkgsReadOnly = false;
nodes.client = {
imports = [
clientScrutinyLoginSso
];
virtualisation.memorySize = 4096;
};
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
scrutiny
shb.test.certs
https
shb.test.ldap
ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
# virtualisation.memorySize = 4096;
};
testScript = commonTestScript;
};
}
================================================
FILE: test/blocks/postgresql.nix
================================================
{
pkgs,
lib,
shb,
...
}:
let
pkgs' = pkgs;
in
{
peerWithoutUser = shb.test.runNixOSTest {
name = "postgresql-peerWithoutUser";
nodes.machine =
{ config, pkgs, ... }:
{
imports = [
(pkgs'.path + "/nixos/modules/profiles/headless.nix")
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
../../modules/blocks/postgresql.nix
];
shb.postgresql.ensures = [
{
username = "me-with-special-chars";
database = "me-with-special-chars";
}
];
};
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)
with subtest("cannot login because of missing user"):
machine.fail(peer_cmd("me-with-special-chars", "me-with-special-chars"), timeout=10)
with subtest("cannot login with unknown user"):
machine.fail(peer_cmd("notme", "me-with-other-chars"), timeout=10)
with subtest("cannot login to unknown database"):
machine.fail(peer_cmd("me-with-special-chars", "notmine"), timeout=10)
'';
};
peerAuth = shb.test.runNixOSTest {
name = "postgresql-peerAuth";
nodes.machine =
{ config, pkgs, ... }:
{
imports = [
(pkgs'.path + "/nixos/modules/profiles/headless.nix")
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
../../modules/blocks/postgresql.nix
];
users.users.me = {
isSystemUser = true;
group = "me";
extraGroups = [ "sudoers" ];
};
users.groups.me = { };
shb.postgresql.ensures = [
{
username = "me";
database = "me";
}
];
};
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):
return "psql -h 127.0.0.1 -p {port} -U {user} {db} --command \"\"".format(user=user, db=database, port=port)
with subtest("can login with provisioned user and database"):
machine.succeed(peer_cmd("me", "me"), timeout=10)
with subtest("cannot login with unknown user"):
machine.fail(peer_cmd("notme", "me"), timeout=10)
with subtest("cannot login to unknown database"):
machine.fail(peer_cmd("me", "notmine"), timeout=10)
with subtest("cannot login with tcpip"):
machine.fail(tcpip_cmd("me", "me", "5432"), timeout=10)
'';
};
tcpIPWithoutPasswordAuth = shb.test.runNixOSTest {
name = "postgresql-tcpIpWithoutPasswordAuth";
nodes.machine =
{ config, pkgs, ... }:
{
imports = [
(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.ensures = [
{
username = "me";
database = "me";
}
];
};
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):
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"):
machine.fail(peer_cmd("me", "me"), timeout=10)
with subtest("cannot login with user without password"):
machine.fail(tcpip_cmd("me", "me", "5432"), timeout=10)
'';
};
tcpIPPasswordAuth =
let
username = "me-with-special-chars";
in
shb.test.runNixOSTest {
name = "postgresql-tcpIPPasswordAuth";
nodes.machine =
{ config, pkgs, ... }:
{
imports = [
(pkgs'.path + "/nixos/modules/profiles/headless.nix")
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
../../modules/blocks/postgresql.nix
];
users.users.${username} = {
isSystemUser = true;
group = username;
extraGroups = [ "sudoers" ];
};
users.groups.${username} = { };
system.activationScripts.secret = ''
echo secretpw > /run/dbsecret
'';
shb.postgresql.enableTCPIP = true;
shb.postgresql.ensures = [
{
username = username;
database = username;
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 ${username} 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("${username}", "${username}"), timeout=10)
with subtest("can tcpip login with provisioned user and database"):
machine.succeed(tcpip_cmd("${username}", "${username}", "5432", "secretpw"), timeout=10)
with subtest("cannot tcpip login with wrong password"):
machine.fail(tcpip_cmd("${username}", "${username}", "5432", "oops"), timeout=10)
'';
};
}
================================================
FILE: test/blocks/restic.nix
================================================
{ lib, shb, ... }:
let
commonTest =
user:
shb.test.runNixOSTest {
name = "restic_backupAndRestore_${user}";
nodes.machine =
{ config, ... }:
{
imports = [
shb.test.baseImports
../../modules/blocks/hardcodedsecret.nix
../../modules/blocks/restic.nix
];
shb.hardcodedsecret.A = {
request = {
owner = "root";
group = "keys";
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
# 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
''
];
};
};
};
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
{
backupAndRestoreRoot = commonTest "root";
backupAndRestoreUser = commonTest "nobody";
}
================================================
FILE: test/blocks/ssl.nix
================================================
{ pkgs, shb, ... }:
let
pkgs' = pkgs;
in
{
test = shb.test.runNixOSTest {
name = "ssl-test";
nodes.server =
{ config, pkgs, ... }:
{
imports = [
(pkgs'.path + "/nixos/modules/profiles/headless.nix")
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
../../modules/blocks/ssl.nix
];
users.users = {
user1 = {
group = "group1";
isSystemUser = true;
};
user2 = {
group = "group2";
isSystemUser = true;
};
};
users.groups = {
group1 = { };
group2 = { };
};
shb.certs = {
cas.selfsigned = {
myca = {
name = "My CA";
};
myotherca = {
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";
};
};
};
# 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;
"subdomain.example.com" = mkVirtualHost "Subdomain" config.shb.certs.certs.selfsigned.subdomain;
"multi1.example.com" = mkVirtualHost "multi1" 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;
};
systemd.services.nginx = {
after = [
config.shb.certs.certs.selfsigned.top.systemdService
config.shb.certs.certs.selfsigned.subdomain.systemdService
config.shb.certs.certs.selfsigned.multi.systemdService
config.shb.certs.certs.selfsigned.cert1.systemdService
config.shb.certs.certs.selfsigned.cert2.systemdService
];
requires = [
config.shb.certs.certs.selfsigned.top.systemdService
config.shb.certs.certs.selfsigned.subdomain.systemdService
config.shb.certs.certs.selfsigned.multi.systemdService
config.shb.certs.certs.selfsigned.cert1.systemdService
config.shb.certs.certs.selfsigned.cert2.systemdService
];
};
};
# Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix
testScript =
{ nodes, ... }:
let
myca = nodes.server.shb.certs.cas.selfsigned.myca;
myotherca = nodes.server.shb.certs.cas.selfsigned.myotherca;
top = nodes.server.shb.certs.certs.selfsigned.top;
subdomain = nodes.server.shb.certs.certs.selfsigned.subdomain;
multi = nodes.server.shb.certs.certs.selfsigned.multi;
cert1 = nodes.server.shb.certs.certs.selfsigned.cert1;
cert2 = nodes.server.shb.certs.certs.selfsigned.cert2;
in
''
start_all()
# Make sure certs are generated.
server.wait_for_file("${myca.paths.key}")
server.wait_for_file("${myca.paths.cert}")
server.wait_for_file("${myotherca.paths.key}")
server.wait_for_file("${myotherca.paths.cert}")
server.wait_for_file("${top.paths.key}")
server.wait_for_file("${top.paths.cert}")
server.wait_for_file("${subdomain.paths.key}")
server.wait_for_file("${subdomain.paths.cert}")
server.wait_for_file("${multi.paths.key}")
server.wait_for_file("${multi.paths.cert}")
server.wait_for_file("${cert1.paths.key}")
server.wait_for_file("${cert1.paths.cert}")
server.wait_for_file("${cert2.paths.key}")
server.wait_for_file("${cert2.paths.cert}")
server.require_unit_state("${nodes.server.shb.certs.systemdService}", "inactive")
server.wait_for_unit("nginx")
server.wait_for_open_port(443)
def assert_owner(path, user, group):
owner = server.succeed("stat --format '%U:%G' {}".format(path)).strip();
want_owner = user + ":" + group
if owner != want_owner:
raise Exception('Unexpected owner for {}: wanted "{}", got: "{}"'.format(path, want_owner, owner))
def assert_perm(path, want_perm):
perm = server.succeed("stat --format '%a' {}".format(path)).strip();
if perm != want_perm:
raise Exception('Unexpected perm for {}: wanted "{}", got: "{}"'.format(path, want_perm, perm))
with subtest("Certificates content seem correct"):
myca_key = server.succeed("cat {}".format("${myca.paths.key}")).strip();
myca_cert = server.succeed("cat {}".format("${myca.paths.cert}")).strip();
cert1_key = server.succeed("cat {}".format("${cert1.paths.key}")).strip();
cert1_cert = server.succeed("cat {}".format("${cert1.paths.cert}")).strip();
cert2_key = server.succeed("cat {}".format("${cert2.paths.key}")).strip();
cert2_cert = server.succeed("cat {}".format("${cert2.paths.cert}")).strip();
ca_bundle = server.succeed("cat /etc/ssl/certs/ca-bundle.crt").strip();
if myca_cert == "":
raise Exception("CA cert was empty")
if cert1_key == "":
raise Exception("Cert1 key was empty")
if cert1_cert == "":
raise Exception("Cert1 cert was empty")
if cert2_key == "":
raise Exception("Cert2 key was empty")
if cert2_cert == "":
raise Exception("Cert2 cert was empty")
if cert1_key == cert2_key:
raise Exception("Cert1 key and cert2 key are the same")
if cert1_cert == cert2_cert:
raise Exception("Cert1 cert and cert2 cert are the same")
if ca_bundle == "":
raise Exception("CA bundle was empty")
with subtest("Certificate is trusted in curl"):
resp = server.succeed("curl --fail-with-body -v https://example.com")
if resp != "Top domain":
raise Exception('Unexpected response, got: {}'.format(resp))
resp = server.succeed("curl --fail-with-body -v https://subdomain.example.com")
if resp != "Subdomain":
raise Exception('Unexpected response, got: {}'.format(resp))
resp = server.succeed("curl --fail-with-body -v https://multi1.example.com")
if resp != "multi1":
raise Exception('Unexpected response, got: {}'.format(resp))
resp = server.succeed("curl --fail-with-body -v https://multi2.example.com")
if resp != "multi2":
raise Exception('Unexpected response, got: {}'.format(resp))
resp = server.succeed("curl --fail-with-body -v https://multi3.example.com")
if resp != "multi3":
raise Exception('Unexpected response, got: {}'.format(resp))
with subtest("Certificate has correct permission"):
assert_owner("${cert1.paths.key}", "root", "root")
assert_owner("${cert1.paths.cert}", "root", "root")
assert_perm("${cert1.paths.key}", "640")
assert_perm("${cert1.paths.cert}", "640")
assert_owner("${cert2.paths.key}", "root", "group2")
assert_owner("${cert2.paths.cert}", "root", "group2")
assert_perm("${cert2.paths.key}", "640")
assert_perm("${cert2.paths.cert}", "640")
with subtest("Certificates content seem correct"):
if cert1_key == "":
raise Exception("Cert1 key was empty")
if cert1_cert == "":
raise Exception("Cert1 cert was empty")
if cert2_key == "":
raise Exception("Cert2 key was empty")
if cert2_cert == "":
raise Exception("Cert2 cert was empty")
if cert1_key == cert2_key:
raise Exception("Cert1 key and cert2 key are the same")
if cert1_cert == cert2_cert:
raise Exception("Cert1 cert and cert2 cert are the same")
with subtest("Fail if certificate is not in CA bundle"):
server.fail("curl --cacert /etc/static/ssl/certs/ca-bundle.crt --fail-with-body -v https://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://subdomain.example.com")
with subtest("Idempotency"):
server.succeed("systemctl restart shb-certs-ca-myca")
server.succeed("systemctl restart shb-certs-cert-selfsigned-cert1")
server.succeed("systemctl restart shb-certs-cert-selfsigned-cert2")
new_myca_key = server.succeed("cat {}".format("${myca.paths.key}")).strip();
new_myca_cert = server.succeed("cat {}".format("${myca.paths.cert}")).strip();
new_cert1_key = server.succeed("cat {}".format("${cert1.paths.key}")).strip();
new_cert1_cert = server.succeed("cat {}".format("${cert1.paths.cert}")).strip();
new_cert2_key = server.succeed("cat {}".format("${cert2.paths.key}")).strip();
new_cert2_cert = server.succeed("cat {}".format("${cert2.paths.cert}")).strip();
new_ca_bundle = server.succeed("cat /etc/ssl/certs/ca-bundle.crt").strip();
if new_myca_key != myca_key:
raise Exception("New CA key is different from old one.")
if new_myca_cert != myca_cert:
raise Exception("New CA cert is different from old one.")
if new_cert1_key != cert1_key:
raise Exception("New Cert1 key is different from old one.")
if new_cert1_cert != cert1_cert:
raise Exception("New Cert1 cert is different from old one.")
if new_cert2_key != cert2_key:
raise Exception("New Cert2 key is different from old one.")
if new_cert2_cert != cert2_cert:
raise Exception("New Cert2 cert is different from old one.")
if new_ca_bundle != ca_bundle:
raise Exception("New CA bundle is different from old one.")
'';
};
}
================================================
FILE: test/common.nix
================================================
{ pkgs, lib }:
let
inherit (lib) hasAttr mkOption optionalString;
inherit (lib.types)
bool
enum
listOf
nullOr
submodule
str
;
baseImports = {
imports = [
(pkgs.path + "/nixos/modules/profiles/headless.nix")
(pkgs.path + "/nixos/modules/profiles/qemu-guest.nix")
];
};
accessScript = lib.makeOverridable (
{
hasSSL,
waitForServices ? s: [ ],
waitForPorts ? p: [ ],
waitForUnixSocket ? u: [ ],
waitForUrls ? u: [ ],
extraScript ? { ... }: "",
redirectSSO ? false,
}:
{ nodes, ... }:
let
cfg = nodes.server.test;
fqdn = "${cfg.subdomain}.${cfg.domain}";
proto_fqdn = if hasSSL args then "https://${fqdn}" else "http://${fqdn}";
args = {
node.name = "server";
node.config = nodes.server;
inherit fqdn proto_fqdn;
};
autheliaEnabled = (hasAttr "authelia" nodes.server.shb) && nodes.server.shb.authelia.enable;
lldapEnabled = (hasAttr "lldap" nodes.server.shb) && nodes.server.shb.lldap.enable;
in
''
import json
import os
import pathlib
start_all()
def curl(target, format, endpoint, data="", extra=""):
cmd = ("curl --show-error --location"
+ " --cookie-jar cookie.txt"
+ " --cookie cookie.txt"
+ " --connect-to ${fqdn}:443:server:443"
+ " --connect-to ${fqdn}:80:server:80"
# Client must be able to resolve talking to auth server
+ " --connect-to auth.${cfg.domain}:443:server:443"
+ (f" --data '{data}'" if data != "" else "")
+ (f" --silent --output /dev/null --write-out '{format}'" if format != "" else "")
+ (f" {extra}" if extra != "" else "")
+ f" {endpoint}")
print(cmd)
_, r = target.execute(cmd)
print(r)
try:
return json.loads(r)
except:
return r
def unline_with(j, s):
return j.join((x.strip() for x in s.split("\n")))
''
+ lib.strings.concatMapStrings (s: ''server.wait_for_unit("${s}")'' + "\n") (
waitForServices args
++ (lib.optionals autheliaEnabled [ "authelia-auth.${cfg.domain}.service" ])
++ (lib.optionals lldapEnabled [ "lldap.service" ])
)
+ lib.strings.concatMapStrings (p: "server.wait_for_open_port(${toString p})" + "\n") (
waitForPorts args
# TODO: when the SSO block exists, replace this hardcoded port.
++ (lib.optionals autheliaEnabled [
9091 # nodes.server.services.authelia.instances."auth.${domain}".settings.server.port
])
)
+ lib.strings.concatMapStrings (u: ''server.wait_for_open_unix_socket("${u}")'' + "\n") (
waitForUnixSocket args
)
+ ''
if ${if hasSSL args then "True" else "False"}:
server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt")
client.succeed("rm -r /etc/ssl/certs")
client.copy_from_host(str(pathlib.Path(os.environ.get("out", os.getcwd())) / "ca-certificates.crt"), "/etc/ssl/certs/ca-certificates.crt")
''
# Making a curl request to an URL needs to happen after we copied the certificates over,
# otherwise curl will not be able to verify the "legitimacy of the server".
+ lib.strings.concatMapStrings (
u:
let
url = if builtins.isString u then u else u.url;
status = if builtins.isString u then 200 else u.status;
in
''
import time
done = False
count = 15
while not done and count > 0:
response = curl(client, """{"code":%{response_code}}""", "${url}")
time.sleep(5)
count -= 1
if isinstance(response, dict):
done = response.get('code') == ${toString status}
if not done:
raise Exception(f"Response was never ${toString status}, got last: {response}")
''
+ "\n"
) (waitForUrls args)
+ (
if (!redirectSSO) then
''
with subtest("access"):
response = curl(client, """{"code":%{response_code}}""", "${proto_fqdn}")
if response['code'] != 200:
raise Exception(f"Code is {response['code']}")
''
else
''
with subtest("unauthenticated access is not granted"):
response = curl(client, """{"code":%{response_code},"auth_host":"%{urle.host}","auth_query":"%{urle.query}","all":%{json}}""", "${proto_fqdn}")
if response['code'] != 200:
raise Exception(f"Code is {response['code']}")
if response['auth_host'] != "auth.${cfg.domain}":
raise Exception(f"auth host should be auth.${cfg.domain} but is {response['auth_host']}")
if response['auth_query'] != "rd=${proto_fqdn}/":
raise Exception(f"auth query should be rd=${proto_fqdn}/ but is {response['auth_query']}")
''
)
+ (
let
script = extraScript args;
in
lib.optionalString (script != "") script
)
+ (optionalString (hasAttr "test" nodes.server && hasAttr "login" nodes.server.test) ''
with subtest("Login from server"):
code, logs = server.execute("login_playwright")
print(logs)
try:
server.copy_from_vm("trace")
except:
print("No trace found on server")
# if code != 0:
# raise Exception("login_playwright did not succeed")
'')
+ (optionalString (hasAttr "test" nodes.client && hasAttr "login" nodes.client.test) ''
with subtest("Login from client"):
code, logs = client.execute("login_playwright")
print(logs)
try:
client.copy_from_vm("trace")
except:
print("No trace found on client")
# if code != 0:
# raise Exception("login_playwright did not succeed")
'')
);
backupScript =
args:
(accessScript args).override {
extraScript =
{ proto_fqdn, ... }:
''
with subtest("backup"):
server.succeed("systemctl start restic-backups-testinstance_opt_repos_A")
'';
};
in
{
inherit baseImports accessScript;
runNixOSTest =
args:
pkgs.testers.runNixOSTest (
{
interactive.sshBackdoor.enable = true;
}
// args
);
mkScripts = args: {
access = accessScript args;
backup = backupScript args;
};
baseModule =
{ config, ... }:
{
options.test = {
domain = mkOption {
type = str;
default = "example.com";
};
subdomain = mkOption {
type = str;
};
fqdn = mkOption {
type = str;
readOnly = true;
default = "${config.test.subdomain}.${config.test.domain}";
};
hasSSL = mkOption {
type = bool;
default = false;
};
proto = mkOption {
type = str;
readOnly = true;
default = if config.test.hasSSL then "https" else "http";
};
proto_fqdn = mkOption {
type = str;
readOnly = true;
default = "${config.test.proto}://${config.test.fqdn}";
};
};
imports = [
baseImports
../modules/blocks/hardcodedsecret.nix
../modules/blocks/nginx.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";
};
loginSpawnsNewPage = mkOption {
type = bool;
default = false;
};
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)
print("Test configuration:")
print(json.dumps(testCfg, indent=2))
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()
# This is used to debug frame changes.
# Frame changes or popup are somewhat handled with the expect_page() call later.
page.on("framenavigated", lambda frame: print("NAV:", frame.url))
page.on("frameattached", lambda frame: print("ATTACHED:", frame.url))
page.on("framedetached", lambda frame: print("DETACHED:", frame.url))
print(f"Going to {testCfg['startUrl']}")
page.goto(testCfg['startUrl'])
if testCfg.get("beforeHook") is not None:
if testCfg['loginSpawnsNewPage']:
print("Login spawns new page")
# The with clause handles window.open() or .
with context.expect_page() as p:
exec(testCfg.get("beforeHook"))
page = p.value
else:
exec(testCfg.get("beforeHook"))
if u['username'] is not None:
print(f"Filling field username with {u['username']}")
page.${cfg.usernameFieldSelector}.fill(u['username'])
if u['password'] is not None:
print(f"Filling field password with {u['password']}")
page.${cfg.passwordFieldSelector}.fill(u['password'])
# Assumes we don't need to login, so skip this.
if u['username'] is not None or u['password'] is not None:
print(f"Clicking button {testCfg['loginButtonNameRegex']}")
page.get_by_role("button", name=re.compile(testCfg['loginButtonNameRegex'])).click()
for line in u['nextPageExpect']:
print(f"Running: {line}")
print(f"Page has title: {page.title()}")
exec(line)
finally:
print(f'Saving trace at trace/{i}.zip')
context.tracing.stop(path=f"trace/{i}.zip")
browser.close()
''
)
)
];
};
};
backup =
backupOption:
{ config, ... }:
{
imports = [
../modules/blocks/restic.nix
];
shb.restic.instances."testinstance" = {
request = backupOption.request;
settings = {
enable = true;
passphrase.result = config.shb.hardcodedsecret.backupPassphrase.result;
repository = {
path = "/opt/repos/A";
timerConfig = {
OnCalendar = "00:00:00";
RandomizedDelaySec = "5h";
};
};
};
};
shb.hardcodedsecret.backupPassphrase = {
request = config.shb.restic.instances."testinstance".settings.passphrase.request;
settings.content = "PassPhrase";
};
};
certs =
{ config, ... }:
{
imports = [
../modules/blocks/ssl.nix
];
shb.certs = {
cas.selfsigned.myca = {
name = "My CA";
};
certs.selfsigned = {
n = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "*.${config.test.domain}";
group = "nginx";
};
};
};
systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ];
systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ];
};
ldap =
{ config, pkgs, ... }:
{
imports = [
../modules/blocks/lldap.nix
];
networking.hosts = {
"127.0.0.1" = [ "ldap.${config.test.domain}" ];
};
shb.hardcodedsecret.ldapUserPassword = {
request = config.shb.lldap.ldapUserPassword.request;
settings.content = "ldapUserPassword";
};
shb.hardcodedsecret.jwtSecret = {
request = config.shb.lldap.jwtSecret.request;
settings.content = "jwtSecrets";
};
shb.lldap = {
enable = true;
inherit (config.test) domain;
subdomain = "ldap";
ldapPort = 3890;
webUIListenPort = 17170;
dcdomain = "dc=example,dc=com";
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;
debug = false; # Enable this if needed, but beware it is _very_ verbose.
ensureUsers = {
alice = {
email = "alice@example.com";
groups = [ "user_group" ];
password.result.path = pkgs.writeText "alicePassword" "AlicePassword";
};
bob = {
email = "bob@example.com";
# Purposely not adding bob to the user_group
# so we can make sure users only part admins
# can also login normally.
groups = [ "admin_group" ];
password.result.path = pkgs.writeText "bobPassword" "BobPassword";
};
charlie = {
email = "charlie@example.com";
groups = [ "other_group" ];
password.result.path = pkgs.writeText "charliePassword" "CharliePassword";
};
};
ensureGroups = {
user_group = { };
admin_group = { };
other_group = { };
};
};
};
sso =
ssl:
{ config, pkgs, ... }:
{
imports = [
../modules/blocks/authelia.nix
];
networking.hosts = {
"127.0.0.1" = [ "${config.shb.authelia.subdomain}.${config.shb.authelia.domain}" ];
};
shb.authelia = {
enable = true;
inherit (config.test) domain;
subdomain = "auth";
ssl = config.shb.certs.certs.selfsigned.n;
debug = true;
ldapHostname = "127.0.0.1";
ldapPort = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
secrets = {
jwtSecret.result = config.shb.hardcodedsecret.autheliaJwtSecret.result;
ldapAdminPassword.result = config.shb.hardcodedsecret.ldapAdminPassword.result;
sessionSecret.result = config.shb.hardcodedsecret.sessionSecret.result;
storageEncryptionKey.result = config.shb.hardcodedsecret.storageEncryptionKey.result;
identityProvidersOIDCHMACSecret.result =
config.shb.hardcodedsecret.identityProvidersOIDCHMACSecret.result;
identityProvidersOIDCIssuerPrivateKey.result =
config.shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey.result;
};
};
shb.hardcodedsecret.autheliaJwtSecret = {
request = config.shb.authelia.secrets.jwtSecret.request;
settings.content = "jwtSecret";
};
shb.hardcodedsecret.ldapAdminPassword = {
request = config.shb.authelia.secrets.ldapAdminPassword.request;
settings.content = "ldapUserPassword";
};
shb.hardcodedsecret.sessionSecret = {
request = config.shb.authelia.secrets.sessionSecret.request;
settings.content = "sessionSecret";
};
shb.hardcodedsecret.storageEncryptionKey = {
request = config.shb.authelia.secrets.storageEncryptionKey.request;
settings.content = "storageEncryptionKey";
};
shb.hardcodedsecret.identityProvidersOIDCHMACSecret = {
request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request;
settings.content = "identityProvidersOIDCHMACSecret";
};
shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey = {
request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request;
settings.source =
(pkgs.runCommand "gen-private-key" { } ''
mkdir $out
${pkgs.openssl}/bin/openssl genrsa -out $out/private.pem 4096
'')
+ "/private.pem";
};
};
}
================================================
FILE: test/contracts/backup.nix
================================================
{ shb, ... }:
{
restic_root = shb.contracts.test.backup {
name = "restic_root";
username = "root";
providerRoot = [
"shb"
"restic"
"instances"
"mytest"
];
modules = [
../../modules/blocks/restic.nix
../../modules/blocks/hardcodedsecret.nix
];
settings =
{ repository, config, ... }:
{
enable = true;
passphrase.result = config.shb.hardcodedsecret.passphrase.result;
repository = {
path = repository;
timerConfig = {
OnCalendar = "00:00:00";
};
};
};
extraConfig =
{ username, config, ... }:
{
shb.hardcodedsecret.passphrase = {
request = config.shb.restic.instances."mytest".settings.passphrase.request;
settings.content = "passphrase";
};
};
};
restic_nonroot = shb.contracts.test.backup {
name = "restic_nonroot";
username = "me";
providerRoot = [
"shb"
"restic"
"instances"
"mytest"
];
modules = [
../../modules/blocks/restic.nix
../../modules/blocks/hardcodedsecret.nix
];
settings =
{ repository, config, ... }:
{
enable = true;
passphrase.result = config.shb.hardcodedsecret.passphrase.result;
repository = {
path = repository;
timerConfig = {
OnCalendar = "00:00:00";
};
};
};
extraConfig =
{ username, config, ... }:
{
shb.hardcodedsecret.passphrase = {
request = config.shb.restic.instances."mytest".settings.passphrase.request;
settings.content = "passphrase";
};
};
};
borgbackup_root = shb.contracts.test.backup {
name = "borgbackup_root";
username = "root";
providerRoot = [
"shb"
"borgbackup"
"instances"
"mytest"
];
modules = [
../../modules/blocks/borgbackup.nix
../../modules/blocks/hardcodedsecret.nix
];
settings =
{ repository, config, ... }:
{
enable = true;
passphrase.result = config.shb.hardcodedsecret.passphrase.result;
repository = {
path = repository;
timerConfig = {
OnCalendar = "00:00:00";
};
};
};
extraConfig =
{ username, config, ... }:
{
shb.hardcodedsecret.passphrase = {
request = config.shb.borgbackup.instances."mytest".settings.passphrase.request;
settings.content = "passphrase";
};
};
};
borgbackup_nonroot = shb.contracts.test.backup {
name = "borgbackup_nonroot";
username = "me";
providerRoot = [
"shb"
"borgbackup"
"instances"
"mytest"
];
modules = [
../../modules/blocks/borgbackup.nix
../../modules/blocks/hardcodedsecret.nix
];
settings =
{ repository, config, ... }:
{
enable = true;
passphrase.result = config.shb.hardcodedsecret.passphrase.result;
repository = {
path = repository;
timerConfig = {
OnCalendar = "00:00:00";
};
};
};
extraConfig =
{ username, config, ... }:
{
shb.hardcodedsecret.passphrase = {
request = config.shb.borgbackup.instances."mytest".settings.passphrase.request;
settings.content = "passphrase";
};
};
};
}
================================================
FILE: test/contracts/databasebackup.nix
================================================
{ shb, ... }:
{
restic_postgres = shb.contracts.test.databasebackup {
name = "restic_postgres";
requesterRoot = [
"shb"
"postgresql"
"databasebackup"
];
providerRoot = [
"shb"
"restic"
"databases"
"postgresql"
];
modules = [
../../modules/blocks/postgresql.nix
../../modules/blocks/restic.nix
../../modules/blocks/hardcodedsecret.nix
];
settings =
{ repository, config, ... }:
{
enable = true;
passphrase.result = config.shb.hardcodedsecret.passphrase.result;
repository = {
path = repository;
timerConfig = {
OnCalendar = "00:00:00";
};
};
};
extraConfig =
{ config, database, ... }:
{
shb.postgresql.ensures = [
{
inherit database;
username = database;
}
];
shb.hardcodedsecret.passphrase = {
request = config.shb.restic.databases.postgresql.settings.passphrase.request;
settings.content = "passphrase";
};
};
};
borgbackup_postgres = shb.contracts.test.databasebackup {
name = "borgbackup_postgres";
requesterRoot = [
"shb"
"postgresql"
"databasebackup"
];
providerRoot = [
"shb"
"borgbackup"
"databases"
"postgresql"
];
modules = [
../../modules/blocks/postgresql.nix
../../modules/blocks/borgbackup.nix
../../modules/blocks/hardcodedsecret.nix
];
settings =
{ repository, config, ... }:
{
enable = true;
stateDir = "/var/lib/borgbackup_postgres";
passphrase.result = config.shb.hardcodedsecret.passphrase.result;
repository = {
path = repository;
timerConfig = {
OnCalendar = "00:00:00";
};
};
};
extraConfig =
{ config, database, ... }:
{
shb.postgresql.ensures = [
{
inherit database;
username = database;
}
];
shb.hardcodedsecret.passphrase = {
request = config.shb.borgbackup.databases.postgresql.settings.passphrase.request;
settings.content = "passphrase";
};
};
};
}
================================================
FILE: test/contracts/secret/sops.yaml
================================================
================================================
FILE: test/contracts/secret.nix
================================================
{ shb, ... }:
{
hardcoded_root_root = shb.contracts.test.secret {
name = "hardcoded";
modules = [ ../../modules/blocks/hardcodedsecret.nix ];
configRoot = [
"shb"
"hardcodedsecret"
];
settingsCfg = secret: {
content = secret;
};
};
hardcoded_user_group = shb.contracts.test.secret {
name = "hardcoded";
modules = [ ../../modules/blocks/hardcodedsecret.nix ];
configRoot = [
"shb"
"hardcodedsecret"
];
settingsCfg = secret: {
content = secret;
};
owner = "user";
group = "group";
mode = "640";
};
# TODO: how to do this?
# sops = shb.contracts.test.secret {
# name = "sops";
# configRoot = cfg: name: cfg.sops.secrets.${name};
# createContent = content: {
# sopsFile = ./secret/sops.yaml;
# };
# };
}
================================================
FILE: test/modules/davfs.nix
================================================
{ pkgs, lib, ... }:
let
anyOpt =
default:
lib.mkOption {
type = lib.types.anything;
inherit default;
};
testConfig =
m:
let
cfg =
(lib.evalModules {
specialArgs = { inherit pkgs; };
modules = [
{
options = {
systemd = anyOpt { };
services = anyOpt { };
};
}
../../modules/blocks/davfs.nix
m
];
}).config;
in
{
inherit (cfg) systemd services;
};
in
{
testDavfsNoOptions = {
expected = {
services.davfs2.enable = false;
systemd.mounts = [ ];
};
expr = testConfig { };
};
}
================================================
FILE: test/modules/homepage.nix
================================================
{ shb }:
{
testHomepageAsServiceGroup = {
expected = [
{
"Media" = [
{
"Jellyfin" = {
"href" = "https://example.com/jellyfin";
"icon" = "sh-jellyfin";
"siteMonitor" = "http://127.0.0.1:8096";
};
}
];
}
];
expr = shb.homepage.asServiceGroup {
Media = {
services = {
Jellyfin = {
dashboard.request = {
externalUrl = "https://example.com/jellyfin";
internalUrl = "http://127.0.0.1:8096";
};
apiKey = null;
};
};
};
};
};
testHomepageAsServiceGroupApiKey = {
expected = [
{
"Media" = [
{
"Jellyfin" = {
"href" = "https://example.com/jellyfin";
"icon" = "sh-jellyfin";
"siteMonitor" = "http://127.0.0.1:8096";
"widget" = {
"key" = "{{HOMEPAGE_FILE_Media_Jellyfin}}";
"password" = "{{HOMEPAGE_FILE_Media_Jellyfin}}";
"type" = "jellyfin";
"url" = "http://127.0.0.1:8096";
};
};
}
];
}
];
expr = shb.homepage.asServiceGroup {
Media = {
services = {
Jellyfin = {
dashboard.request = {
externalUrl = "https://example.com/jellyfin";
internalUrl = "http://127.0.0.1:8096";
};
apiKey.result.path = "path_D";
};
};
};
};
};
testHomepageAsServiceGroupNoServiceMonitor = {
expected = [
{
"Media" = [
{
"Jellyfin" = {
"href" = "https://example.com/jellyfin";
"icon" = "sh-jellyfin";
"siteMonitor" = null;
};
}
];
}
];
expr = shb.homepage.asServiceGroup {
Media = {
services = {
Jellyfin = {
dashboard.request = {
externalUrl = "https://example.com/jellyfin";
internalUrl = null;
};
apiKey = null;
};
};
};
};
};
testHomepageAsServiceGroupOverride = {
expected = [
{
"Media" = [
{
"Jellyfin" = {
"href" = "https://example.com/jellyfin";
"icon" = "sh-icon";
"siteMonitor" = "http://127.0.0.1:8096";
};
}
];
}
];
expr = shb.homepage.asServiceGroup {
Media = {
services = {
Jellyfin = {
dashboard.request = {
externalUrl = "https://example.com/jellyfin";
internalUrl = "http://127.0.0.1:8096";
};
settings = {
icon = "sh-icon";
};
apiKey = null;
};
};
};
};
};
testHomepageAsServiceGroupSortOrder = {
expected = [
{ "C" = [ ]; }
{ "A" = [ ]; }
{ "B" = [ ]; }
];
expr = shb.homepage.asServiceGroup {
A = {
sortOrder = 2;
services = { };
};
B = {
sortOrder = 3;
services = { };
};
C = {
sortOrder = 1;
services = { };
};
};
};
testHomepageAsServiceServicesSortOrder = {
expected = [
{
"Media" = [
{
"A" = {
"href" = "https://example.com/a";
"icon" = "sh-a";
"siteMonitor" = null;
};
}
{
"C" = {
"href" = "https://example.com/c";
"icon" = "sh-c";
"siteMonitor" = null;
};
}
{
"B" = {
"href" = "https://example.com/b";
"icon" = "sh-b";
"siteMonitor" = null;
};
}
];
}
];
expr = shb.homepage.asServiceGroup {
Media = {
sortOrder = null;
services = {
A = {
sortOrder = 1;
dashboard.request = {
externalUrl = "https://example.com/a";
internalUrl = null;
};
apiKey = null;
};
B = {
sortOrder = 3;
dashboard.request = {
externalUrl = "https://example.com/b";
internalUrl = null;
};
apiKey = null;
};
C = {
sortOrder = 2;
dashboard.request = {
externalUrl = "https://example.com/c";
internalUrl = null;
};
apiKey = null;
};
};
};
};
};
testHomepageAllKeys = {
expected = {
"A_A" = "path_A";
"A_B" = "path_B";
"B_D" = "path_D";
};
expr = shb.homepage.allKeys {
A = {
sortOrder = 1;
services = {
A = {
sortOrder = 1;
dashboard.request = {
externalUrl = "https://example.com/a";
internalUrl = null;
};
apiKey.result.path = "path_A";
};
B = {
sortOrder = 2;
dashboard.request = {
externalUrl = "https://example.com/b";
internalUrl = null;
};
apiKey.result.path = "path_B";
};
};
};
B = {
sortOrder = 2;
services = {
C = {
sortOrder = 1;
dashboard.request = {
externalUrl = "https://example.com/a";
internalUrl = null;
};
apiKey = null;
};
D = {
sortOrder = 2;
dashboard.request = {
externalUrl = "https://example.com/b";
internalUrl = null;
};
apiKey.result.path = "path_D";
};
};
};
};
};
}
================================================
FILE: test/modules/lib.nix
================================================
{ lib, shb, ... }:
let
inherit (lib) nameValuePair;
in
{
# Tests that withReplacements can:
# - recurse in attrs and lists
# - .source field is understood
# - .transform field is understood
# - if .source field is found, ignores other fields
testLibWithReplacements = {
expected =
let
item = root: {
a = "A";
b = "%SECRET_${root}B%";
c = "%SECRET_${root}C%";
};
in
(item "")
// {
nestedAttr = item "NESTEDATTR_";
nestedList = [ (item "NESTEDLIST_0_") ];
doubleNestedList = [ { n = (item "DOUBLENESTEDLIST_0_N_"); } ];
};
expr =
let
item = {
a = "A";
b.source = "/path/B";
b.transform = null;
c.source = "/path/C";
c.transform = v: "prefix-${v}-suffix";
c.other = "other";
};
in
shb.withReplacements (
item
// {
nestedAttr = item;
nestedList = [ item ];
doubleNestedList = [ { n = item; } ];
}
);
};
testLibWithReplacementsRootList = {
expected =
let
item = root: {
a = "A";
b = "%SECRET_${root}B%";
c = "%SECRET_${root}C%";
};
in
[
(item "0_")
(item "1_")
[ (item "2_0_") ]
[ { n = (item "3_0_N_"); } ]
];
expr =
let
item = {
a = "A";
b.source = "/path/B";
b.transform = null;
c.source = "/path/C";
c.transform = v: "prefix-${v}-suffix";
c.other = "other";
};
in
shb.withReplacements [
item
item
[ item ]
[ { n = item; } ]
];
};
testLibGetReplacements = {
expected =
let
secrets = root: [
(nameValuePair "%SECRET_${root}B%" "$(cat /path/B)")
(nameValuePair "%SECRET_${root}C%" "prefix-$(cat /path/C)-suffix")
];
in
(secrets "")
++ (secrets "DOUBLENESTEDLIST_0_N_")
++ (secrets "NESTEDATTR_")
++ (secrets "NESTEDLIST_0_");
expr =
let
item = {
a = "A";
b.source = "/path/B";
b.transform = null;
c.source = "/path/C";
c.transform = v: "prefix-${v}-suffix";
c.other = "other";
};
in
map shb.genReplacement (
shb.getReplacements (
item
// {
nestedAttr = item;
nestedList = [ item ];
doubleNestedList = [ { n = item; } ];
}
)
);
};
testParseXML = {
expected = {
"a" = {
"b" = "1";
"c" = {
"d" = "1";
};
};
};
expr = shb.parseXML ''
1
1
'';
};
}
// import ./homepage.nix { inherit shb; }
================================================
FILE: test/services/arr.nix
================================================
{
pkgs,
lib,
shb,
...
}:
let
healthUrl = "/health";
loginUrl = "/UI/Login";
# TODO: Test login
commonTestScript =
appname: cfgPathFn:
shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.arr.${appname}.ssl);
waitForServices =
{ ... }:
[
"${appname}.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
node.config.shb.arr.${appname}.settings.Port
];
extraScript =
{
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:
raise Exception(f"Code is {response['code']}")
with subtest("login"):
response = curl(client, """{"code":%{response_code}}""", "${fqdn}${loginUrl}")
if response['code'] != 200:
raise Exception(f"Code is {response['code']}")
''
+ lib.optionalString (apiKey != null && cfgPath != null) ''
with subtest("apikey"):
config = server.succeed("cat ${cfgPath}")
if "${apiKey}" not in config:
raise Exception(f"Unexpected API Key. Want '${apiKey}', got '{config}'")
'';
};
basic =
appname:
{ config, ... }:
{
imports = [
shb.test.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.
};
};
clientLogin =
appname:
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.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:
shb.test.runNixOSTest {
name = "arr_${appname}_basic";
nodes.client = {
imports = [
(clientLogin appname)
];
};
nodes.server = {
imports = [
(basic appname)
];
};
testScript = (commonTestScript appname cfgPathFn).access;
};
backupTest =
appname: cfgPathFn:
shb.test.runNixOSTest {
name = "arr_${appname}_backup";
nodes.server =
{ config, ... }:
{
imports = [
(basic appname)
(shb.test.backup config.shb.arr.${appname}.backup)
];
};
nodes.client = { };
testScript = (commonTestScript appname cfgPathFn).backup;
};
https =
appname:
{ config, ... }:
{
shb.arr.${appname} = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
httpsTest =
appname: cfgPathFn:
shb.test.runNixOSTest {
name = "arr_${appname}_https";
nodes.server =
{ config, pkgs, ... }:
{
imports = [
(basic appname)
shb.test.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:
shb.test.runNixOSTest {
name = "arr_${appname}_sso";
nodes.server =
{ config, pkgs, ... }:
{
imports = [
(basic appname)
shb.test.certs
(https appname)
shb.test.ldap
(shb.test.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";
sonarrCfgFn = cfg: "${cfg.dataDir}/config.xml";
bazarrCfgFn = cfg: null;
readarrCfgFn = cfg: "${cfg.dataDir}/config.xml";
lidarrCfgFn = cfg: "${cfg.dataDir}/config.xml";
jackettCfgFn = cfg: "${cfg.dataDir}/ServerConfig.json";
in
{
radarr_basic = basicTest "radarr" radarrCfgFn;
radarr_backup = backupTest "radarr" radarrCfgFn;
radarr_https = httpsTest "radarr" radarrCfgFn;
radarr_sso = ssoTest "radarr" radarrCfgFn;
sonarr_basic = basicTest "sonarr" sonarrCfgFn;
sonarr_backup = backupTest "sonarr" sonarrCfgFn;
sonarr_https = httpsTest "sonarr" sonarrCfgFn;
sonarr_sso = ssoTest "sonarr" sonarrCfgFn;
bazarr_basic = basicTest "bazarr" bazarrCfgFn;
bazarr_backup = backupTest "bazarr" bazarrCfgFn;
bazarr_https = httpsTest "bazarr" bazarrCfgFn;
bazarr_sso = ssoTest "bazarr" bazarrCfgFn;
readarr_basic = basicTest "readarr" readarrCfgFn;
readarr_backup = backupTest "readarr" readarrCfgFn;
readarr_https = httpsTest "readarr" readarrCfgFn;
readarr_sso = ssoTest "readarr" readarrCfgFn;
lidarr_basic = basicTest "lidarr" lidarrCfgFn;
lidarr_backup = backupTest "lidarr" lidarrCfgFn;
lidarr_https = httpsTest "lidarr" lidarrCfgFn;
lidarr_sso = ssoTest "lidarr" lidarrCfgFn;
jackett_basic = basicTest "jackett" jackettCfgFn;
jackett_backup = backupTest "jackett" jackettCfgFn;
jackett_https = httpsTest "jackett" jackettCfgFn;
jackett_sso = ssoTest "jackett" jackettCfgFn;
}
================================================
FILE: test/services/audiobookshelf.nix
================================================
{ shb, ... }:
let
commonTestScript = shb.test.accessScript {
hasSSL = { node, ... }: !(isNull node.config.shb.audiobookshelf.ssl);
waitForServices =
{ ... }:
[
"audiobookshelf.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
node.config.shb.audiobookshelf.webPort
];
# TODO: Test login
# extraScript = { ... }: ''
# '';
};
basic =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/services/audiobookshelf.nix
];
test = {
subdomain = "a";
};
shb.audiobookshelf = {
enable = true;
inherit (config.test) subdomain domain;
};
};
clientLogin =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.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, ... }:
{
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
{
basic = shb.test.runNixOSTest {
name = "audiobookshelf-basic";
nodes.client = {
imports = [
# TODO: enable this when declarative user management is possible.
# clientLogin
];
};
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript;
};
https = shb.test.runNixOSTest {
name = "audiobookshelf-https";
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
nodes.client = { };
testScript = commonTestScript;
};
sso = shb.test.runNixOSTest {
name = "audiobookshelf-sso";
nodes.server =
{ config, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
nodes.client = { };
testScript = commonTestScript;
};
}
================================================
FILE: test/services/deluge.nix
================================================
{
pkgs,
lib,
shb,
...
}:
let
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.deluge.ssl);
waitForServices =
{ ... }:
[
"nginx.service"
"deluged.service"
"delugeweb.service"
];
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']}")
'';
};
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 = [
shb.test.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 = [
shb.test.baseModule
shb.test.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
{
basic = shb.test.runNixOSTest {
name = "deluge_basic";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
backup = shb.test.runNixOSTest {
name = "deluge_backup";
nodes.server =
{ config, ... }:
{
imports = [
basic
(shb.test.backup config.shb.deluge.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
https = shb.test.runNixOSTest {
name = "deluge_https";
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
sso = shb.test.runNixOSTest {
name = "deluge_sso";
nodes.server =
{ config, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
nodes.client = { };
testScript = commonTestScript.access.override {
redirectSSO = true;
};
};
prometheus = shb.test.runNixOSTest {
name = "deluge_https";
nodes.server = {
imports = [
basic
shb.test.certs
https
prometheus
];
};
nodes.client = { };
# The inputs attrset must be named out explicitly
testScript =
inputs@{ nodes, ... }: (commonTestScript.access inputs) + (prometheusTestScript inputs);
};
}
================================================
FILE: test/services/firefly-iii.nix
================================================
{ pkgs, shb, ... }:
let
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.firefly-iii.ssl);
waitForServices =
{ ... }:
[
"phpfpm-firefly-iii.service"
"phpfpm-firefly-iii-data-importer.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
# node.config.shb.firefly-iii.port
];
};
basic =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/firefly-iii.nix
];
test = {
subdomain = "f";
};
shb.firefly-iii = {
enable = true;
debug = true;
inherit (config.test) subdomain domain;
siteOwnerEmail = "mail@example.com";
appKey.result = config.shb.hardcodedsecret.appKey.result;
dbPassword.result = config.shb.hardcodedsecret.dbPassword.result;
importer.firefly-iii-accessToken.result = config.shb.hardcodedsecret.accessToken.result;
};
shb.hardcodedsecret.appKey = {
request = config.shb.firefly-iii.appKey.request;
# Firefly-iir requires this to be exactly 32 characters.
settings.content = pkgs.lib.strings.replicate 32 "Z";
};
shb.hardcodedsecret.dbPassword = {
request = config.shb.firefly-iii.dbPassword.request;
settings.content = pkgs.lib.strings.replicate 64 "Y";
};
shb.hardcodedsecret.accessToken = {
request = config.shb.firefly-iii.importer.firefly-iii-accessToken.request;
settings.content = pkgs.lib.strings.replicate 64 "X";
};
};
clientLogin =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
test = {
subdomain = "f";
};
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('Register a new account')).to_be_visible()"
];
}
];
};
};
clientLoginDataImporter =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
test = {
subdomain = "f-importer";
};
test.login = {
startUrl = "http://${config.test.fqdn}";
# There is no login without SSO integration.
testLoginWith = [
{
username = null;
password = null;
nextPageExpect = [
# The error to connect is expected since the access token must be created manually in Firefly-iii.
"expect(page.get_by_text('The importer could not connect')).to_be_visible()"
];
}
];
};
};
https =
{ config, ... }:
{
shb.firefly-iii = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
ldap =
{ config, ... }:
{
shb.firefly-iii = {
ldap = {
userGroup = "user_group";
adminGroup = "admin_group";
};
};
};
clientLoginSso =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
test = {
subdomain = "f";
};
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('Dashboard')).to_be_visible(timeout=10000)"
];
}
{
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('Dashboard')).to_be_visible(timeout=10000)"
];
}
{
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'))"
];
}
];
};
};
clientLoginSsoDataImporter =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
test = {
subdomain = "f-importer";
};
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()"
# Only admins have access
"expect(page.get_by_text('Authenticated')).to_be_visible(timeout=10000)"
];
}
{
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()"
# The error to connect is expected since the access token must be created manually in Firefly-iii.
"expect(page.get_by_text('The importer could not connect')).to_be_visible(timeout=10000)"
];
}
{
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.firefly-iii = {
sso = {
enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
};
};
in
{
basic = shb.test.runNixOSTest {
name = "firefly-iii_basic";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
data-importer_basic = shb.test.runNixOSTest {
name = "firefly-iii-data-importer_basic";
nodes.client = {
imports = [
clientLoginDataImporter
];
};
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
backup = shb.test.runNixOSTest {
name = "firefly-iii_backup";
nodes.server =
{ config, ... }:
{
imports = [
basic
(shb.test.backup config.shb.firefly-iii.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
https = shb.test.runNixOSTest {
name = "firefly-iii_https";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
testScript = commonTestScript.access;
};
sso = shb.test.runNixOSTest {
name = "firefly-iii_sso";
nodes.client = {
imports = [
clientLoginSso
];
};
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
testScript = commonTestScript.access.override {
redirectSSO = true;
};
};
data-importer_sso = shb.test.runNixOSTest {
name = "firefly-iii-data-importer_sso";
nodes.client = {
imports = [
clientLoginSsoDataImporter
];
};
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
testScript = commonTestScript.access;
};
}
================================================
FILE: test/services/forgejo.nix
================================================
{ shb, ... }:
let
adminPassword = "AdminPassword";
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.forgejo.ssl);
waitForServices =
{ ... }:
[
"forgejo.service"
"nginx.service"
];
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'")
'';
};
basic =
{ config, ... }:
{
imports = [
shb.test.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 = [
shb.test.baseModule
shb.test.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'))"
];
}
];
};
};
https =
{ config, ... }:
{
shb.forgejo = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
ldap =
{ config, ... }:
{
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";
adminGroup = "admin_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
{
basic = shb.test.runNixOSTest {
name = "forgejo_basic";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
backup = shb.test.runNixOSTest {
name = "forgejo_backup";
nodes.server =
{ config, ... }:
{
imports = [
basic
(shb.test.backup config.shb.forgejo.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
https = shb.test.runNixOSTest {
name = "forgejo_https";
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
ldap = shb.test.runNixOSTest {
name = "forgejo_ldap";
nodes.server = {
imports = [
basic
shb.test.ldap
ldap
];
};
nodes.client = {
imports = [
(
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.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()"
];
}
];
};
}
)
];
};
testScript = commonTestScript.access;
};
sso = shb.test.runNixOSTest {
name = "forgejo_sso";
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
ldap
shb.test.ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
}
================================================
FILE: test/services/grocy.nix
================================================
{ shb, ... }:
let
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.grocy.ssl);
waitForServices =
{ ... }:
[
"phpfpm-grocy.service"
"nginx.service"
];
waitForUnixSocket =
{ node, ... }:
[
node.config.services.phpfpm.pools.grocy.socket
];
};
basic =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/services/grocy.nix
];
test = {
subdomain = "g";
};
shb.grocy = {
enable = true;
inherit (config.test) subdomain domain;
};
};
clientLogin =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.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
{
basic = shb.test.runNixOSTest {
name = "grocy_basic";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
https = shb.test.runNixOSTest {
name = "grocy_https";
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
}
================================================
FILE: test/services/hledger.nix
================================================
{ shb, ... }:
let
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.hledger.ssl);
waitForServices =
{ ... }:
[
"hledger-web.service"
"nginx.service"
];
};
basic =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/services/hledger.nix
];
test = {
subdomain = "h";
};
shb.hledger = {
enable = true;
inherit (config.test) subdomain domain;
};
};
clientLogin =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.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
{
basic = shb.test.runNixOSTest {
name = "hledger_basic";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
backup = shb.test.runNixOSTest {
name = "hledger_backup";
nodes.server =
{ config, ... }:
{
imports = [
basic
(shb.test.backup config.shb.hledger.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
https = shb.test.runNixOSTest {
name = "hledger_https";
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
sso = shb.test.runNixOSTest {
name = "hledger_sso";
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
nodes.client = { };
testScript = commonTestScript.access.override {
redirectSSO = true;
};
};
}
================================================
FILE: test/services/home-assistant.nix
================================================
{ pkgs, shb, ... }:
let
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.home-assistant.ssl);
waitForServices =
{ ... }:
[
"home-assistant.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
8123
];
};
basic =
{ config, ... }:
{
imports = [
shb.test.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 = [
shb.test.baseModule
shb.test.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
#
# sso = { config, ... }: {
# shb.home-assistant = {
# sso = {
# };
# };
# };
voice =
{ config, ... }:
{
# For now, verifying the packages can build is good enough.
environment.systemPackages = [
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
# to download the models so they fail to start..
# shb.home-assistant.voice.text-to-speech = {
# "fr" = {
# enable = true;
# voice = "fr-siwis-medium";
# uri = "tcp://0.0.0.0:10200";
# speaker = 0;
# };
# "en" = {
# enable = true;
# voice = "en_GB-alba-medium";
# uri = "tcp://0.0.0.0:10201";
# speaker = 0;
# };
# };
# shb.home-assistant.voice.speech-to-text = {
# "tiny-fr" = {
# enable = true;
# model = "base-int8";
# language = "fr";
# uri = "tcp://0.0.0.0:10300";
# device = "cpu";
# };
# "tiny-en" = {
# enable = true;
# model = "base-int8";
# language = "en";
# uri = "tcp://0.0.0.0:10301";
# device = "cpu";
# };
# };
# shb.home-assistant.voice.wakeword = {
# enable = true;
# uri = "tcp://127.0.0.1:10400";
# preloadModels = [
# "alexa"
# "hey_jarvis"
# "hey_mycroft"
# "hey_rhasspy"
# "ok_nabu"
# ];
# };
};
in
{
basic = shb.test.runNixOSTest {
name = "homeassistant_basic";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
backup = shb.test.runNixOSTest {
name = "homeassistant_backup";
nodes.server =
{ config, ... }:
{
imports = [
basic
(shb.test.backup config.shb.home-assistant.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
https = shb.test.runNixOSTest {
name = "homeassistant_https";
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
ldap = shb.test.runNixOSTest {
name = "homeassistant_ldap";
nodes.server = {
imports = [
basic
shb.test.ldap
ldap
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
# Not yet supported
#
# sso = shb.test.runNixOSTest {
# name = "vaultwarden_sso";
#
# nodes.server = lib.mkMerge [
# basic
# (shb.certs domain)
# https
# ldap
# (shb.ldap domain pkgs')
# (shb.test.sso domain pkgs' config.shb.certs.certs.selfsigned.n)
# sso
# ];
#
# nodes.client = {};
#
# testScript = commonTestScript.access;
# };
voice = shb.test.runNixOSTest {
name = "homeassistant_voice";
nodes.server = {
imports = [
basic
voice
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
}
================================================
FILE: test/services/homepage.nix
================================================
{ shb, ... }:
let
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.homepage.ssl);
waitForServices =
{ ... }:
[
"homepage-dashboard.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
node.config.services.homepage-dashboard.listenPort
];
};
basic =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/homepage.nix
];
test = {
subdomain = "h";
};
shb.homepage = {
enable = true;
inherit (config.test) subdomain domain;
servicesGroups.MyHomeGroup.services.TestService.dashboard = { };
};
};
clientLogin =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
test = {
subdomain = "h";
};
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('TestService')).to_be_visible()"
];
}
];
};
};
https =
{ config, ... }:
{
shb.homepage = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
ldap =
{ config, ... }:
{
shb.homepage = {
ldap = {
userGroup = "user_group";
};
};
};
clientLoginSso =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
test = {
subdomain = "h";
};
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('TestService')).to_be_visible(timeout=10000)"
];
}
# Bob, with its admin role only, cannot login into Karakeep because admins do not exist in Karakeep.
{
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.homepage = {
sso = {
enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
};
};
in
{
basic = shb.test.runNixOSTest {
name = "homepage_basic";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
https = shb.test.runNixOSTest {
name = "homepage_https";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
testScript = commonTestScript.access;
};
sso = shb.test.runNixOSTest {
name = "homepage_sso";
nodes.client = {
imports = [
clientLoginSso
];
};
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
testScript = commonTestScript.access.override {
redirectSSO = true;
};
};
}
================================================
FILE: test/services/immich.nix
================================================
{
pkgs,
lib,
shb,
}:
let
subdomain = "i";
domain = "example.com";
commonTestScript = shb.test.accessScript {
hasSSL = { node, ... }: !(isNull node.config.shb.immich.ssl);
waitForServices =
{ ... }:
[
"immich-server.service"
"postgresql.service"
"nginx.service"
];
waitForPorts =
{ ... }:
[
2283
80
];
waitForUrls = { proto_fqdn, ... }: [ "${proto_fqdn}" ];
};
base =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/services/immich.nix
];
virtualisation.memorySize = 4096;
virtualisation.cores = 2;
test = {
inherit subdomain domain;
};
shb.immich = {
enable = true;
inherit subdomain domain;
debug = true;
};
# Required for tests
environment.systemPackages = [ pkgs.curl ];
};
basic =
{ config, ... }:
{
imports = [ base ];
test.hasSSL = false;
};
https =
{ config, ... }:
{
imports = [
base
shb.test.certs
];
test.hasSSL = true;
shb.immich.ssl = config.shb.certs.certs.selfsigned.n;
};
backup =
{ config, ... }:
{
imports = [
https
(shb.test.backup config.shb.immich.backup)
];
};
sso =
{ config, ... }:
{
imports = [
https
shb.test.ldap
(shb.test.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 = {
request = config.shb.immich.sso.sharedSecret.request;
settings.content = "immichSSOSecret";
};
shb.hardcodedsecret.immichSSOSecretAuthelia = {
request = config.shb.immich.sso.sharedSecretForAuthelia.request;
settings.content = "immichSSOSecret";
};
# Configure LDAP groups for group-based access control
shb.lldap.ensureGroups.immich_user = { };
shb.lldap.ensureUsers.immich_test_user = {
email = "immich_user@example.com";
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
{
basic = shb.test.runNixOSTest {
name = "immich-basic";
nodes.server = basic;
nodes.client = { };
testScript = commonTestScript;
};
https = shb.test.runNixOSTest {
name = "immich-https";
nodes.server = https;
nodes.client = { };
testScript = commonTestScript;
};
backup = shb.test.runNixOSTest {
name = "immich-backup";
nodes.server = backup;
nodes.client = { };
testScript =
(shb.test.mkScripts {
hasSSL = args: !(isNull args.node.config.shb.immich.ssl);
waitForServices = args: [
"immich-server.service"
"postgresql.service"
"nginx.service"
];
waitForPorts = args: [
2283
80
];
waitForUrls = args: [ "${args.proto_fqdn}" ];
}).backup;
};
}
================================================
FILE: test/services/jellyfin.nix
================================================
{ pkgs, shb, ... }:
let
port = 9096;
adminUser = "jellyfin2";
adminPassword = "admin";
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.jellyfin.ssl);
waitForServices =
{ ... }:
[
"jellyfin.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
port
];
waitForUrls =
{ proto_fqdn, ... }:
[
"${proto_fqdn}/System/Info/Public"
{
url = "${proto_fqdn}/Users/AuthenticateByName";
status = 401;
}
];
extraScript =
{ node, ... }:
''
server.wait_until_succeeds("journalctl --since -1m --unit jellyfin --grep 'Startup complete'")
headers = unline_with(" ", """
-H 'Content-Type: application/json'
-H 'Authorization: MediaBrowser Client="Android TV", Device="Nvidia Shield", DeviceId="ZQ9YQHHrUzk24vV", Version="0.15.3"'
""")
import time
with subtest("api login success"):
ok = False
for i in range(1, 5):
response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName",
data="""{"Username": "${adminUser}", "Pw": "${adminPassword}"}""",
extra=headers)
if response['code'] == 200:
ok = True
break
time.sleep(5)
if not ok:
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": "${adminUser}", "Pw": "badpassword"}""",
extra=headers)
if response['code'] != 401:
raise Exception(f"Expected failure, got: {response['code']}")
'';
};
basic =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/services/jellyfin.nix
];
# Jellyfin checks for minimum 2Gib on startup.
virtualisation.diskSize = 4096;
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
shb.jellyfin = {
enable = true;
inherit (config.test) subdomain domain;
inherit port;
admin = {
username = adminUser;
password.result = config.shb.hardcodedsecret.jellyfinAdminPassword.result;
};
debug = true;
};
shb.hardcodedsecret.jellyfinAdminPassword = {
request = config.shb.jellyfin.admin.password.request;
settings.content = adminPassword;
};
environment.systemPackages = [
pkgs.sqlite
];
};
clientLogin =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
test.login = {
browser = "firefox";
startUrl = "${config.test.proto}://${config.test.fqdn}";
usernameFieldLabelRegex = "[Uu]ser";
loginButtonNameRegex = "Sign In";
testLoginWith = [
{
username = adminUser;
password = "badpassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)"
];
}
{
username = adminUser;
password = adminPassword;
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
];
};
};
https =
{ config, ... }:
{
shb.jellyfin = {
ssl = config.shb.certs.certs.selfsigned.n;
};
test = {
hasSSL = true;
};
};
ldap =
{ config, lib, ... }:
{
shb.jellyfin = {
ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
userGroup = "user_group";
adminGroup = "admin_group";
adminPassword.result = config.shb.hardcodedsecret.jellyfinLdapUserPassword.result;
};
};
# There's something weird happending here
# where this plugin disappears after a jellyfin restart.
# I don't know why this is the case.
# I tried using a real plugin here instead of a mock or just creating a meta.json file.
# But this didn't help.
shb.jellyfin.plugins = lib.mkBefore [
(shb.mkJellyfinPlugin (rec {
pname = "jellyfin-plugin-ldapauth";
version = "19";
url = "https://github.com/jellyfin/${pname}/releases/download/v${version}/ldap-authentication_${version}.0.0.0.zip";
hash = "sha256-NunkpdYjsxYT6a4RaDXLkgRn4scRw8GaWvyHGs9IdWo=";
}))
];
shb.hardcodedsecret.jellyfinLdapUserPassword = {
request = config.shb.jellyfin.ldap.adminPassword.request;
settings.content = "ldapUserPassword";
};
};
clientLoginLdap =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
test.login = {
startUrl = "${config.test.proto}://${config.test.fqdn}";
usernameFieldLabelRegex = "[Uu]ser";
loginButtonNameRegex = "Sign In";
testLoginWith = [
{
username = adminUser;
password = "badpassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)"
];
}
{
username = adminUser;
password = adminPassword;
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
# For a reason I can't explain, redirection needs to happen manually.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
# For a reason I can't explain, redirection needs to happen manually.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)"
];
}
];
};
};
sso =
{ config, ... }:
{
shb.jellyfin = {
ldap = {
userGroup = "user_group";
adminGroup = "admin_group";
};
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";
};
};
clientLoginSso =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
test.login = {
startUrl = "${config.test.proto}://${config.test.fqdn}";
beforeHook = ''
page.locator('text=Sign in with Authelia').click()
'';
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[Ss]ign [Ii]n";
loginSpawnsNewPage = true;
testLoginWith = [
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
"page.get_by_text(re.compile('[Aa]ccept')).click()"
# For a reason I can't explain, redirection needs to happen manually.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
# For a reason I can't explain, redirection needs to happen manually.
# So for failing auth, we check we're back on the login page.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).to_be_visible(timeout=10000)"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
"page.get_by_text(re.compile('[Aa]ccept')).click()"
# For a reason I can't explain, redirection needs to happen manually.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
# For a reason I can't explain, redirection needs to happen manually.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).to_be_visible(timeout=10000)"
];
}
];
};
};
jellyfinTest =
name:
{ nodes, testScript }:
shb.test.runNixOSTest {
name = "jellyfin_${name}";
interactive.sshBackdoor.enable = true;
interactive.nodes.server = {
environment.systemPackages = [
pkgs.sqlite
];
};
inherit nodes;
inherit testScript;
};
in
{
basic = jellyfinTest "basic" {
nodes.server = {
imports = [
basic
];
};
nodes.client = {
imports = [
clientLogin
];
};
testScript = commonTestScript.access;
};
backup = jellyfinTest "backup" {
nodes.server =
{ config, ... }:
{
imports = [
basic
(shb.test.backup config.shb.jellyfin.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
https = jellyfinTest "https" {
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
nodes.client =
{ config, lib, ... }:
{
imports = [
clientLogin
];
};
testScript = commonTestScript.access;
};
ldap = jellyfinTest "ldap" {
nodes.server = {
imports = [
basic
shb.test.certs
https
shb.test.ldap
ldap
];
};
nodes.client = {
imports = [
clientLoginLdap
];
};
testScript = commonTestScript.access.override {
extraScript =
{
node,
...
}:
# I have no idea why the LDAP Authentication_19.0.0.0 plugin disappears.
''
r = server.execute('cat "${node.config.services.jellyfin.dataDir}/plugins/LDAP Authentication_19.0.0.0/meta.json"')
if r[0] != 0:
print("meta.json for plugin LDAP Authentication_19.0.0.0 not found")
else:
c = json.loads(r[1])
if "status" in c and c["status"] != "Disabled":
raise Exception(f'meta.json status: expected Disabled, got: {c["status"]}')
'';
};
};
sso = jellyfinTest "sso" {
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
nodes.client = {
imports = [
clientLoginSso
];
};
testScript = commonTestScript.access;
};
}
================================================
FILE: test/services/karakeep.nix
================================================
{ shb, ... }:
let
nextauthSecret = "nextauthSecret";
oidcSecret = "oidcSecret";
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.karakeep.ssl);
waitForServices =
{ ... }:
[
"karakeep-init.service"
"karakeep-browser.service"
"karakeep-web.service"
"karakeep-workers.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
node.config.shb.karakeep.port
];
};
basic =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../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 = [
shb.test.baseModule
shb.test.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()"
];
}
# Bob, with its admin role only, cannot login into Karakeep because admins do not exist in Karakeep.
{
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
{
basic = shb.test.runNixOSTest {
name = "karakeep_basic";
nodes.client = { };
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
backup = shb.test.runNixOSTest {
name = "karakeep_backup";
nodes.server =
{ config, ... }:
{
imports = [
basic
(shb.test.backup config.shb.karakeep.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
https = shb.test.runNixOSTest {
name = "karakeep_https";
nodes.client = { };
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
testScript = commonTestScript.access;
};
sso = shb.test.runNixOSTest {
name = "karakeep_sso";
nodes.client = {
imports = [
clientLoginSso
];
virtualisation.memorySize = 4096;
};
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
virtualisation.memorySize = 4096;
};
testScript = commonTestScript.access;
};
}
================================================
FILE: test/services/nextcloud.nix
================================================
{ lib, shb, ... }:
let
supportedVersion = [
32
33
];
adminUser = "root";
adminPass = "rootpw";
oidcSecret = "oidcSecret";
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.nextcloud.ssl);
waitForServices =
{ ... }:
[
"phpfpm-nextcloud.service"
"nginx.service"
];
waitForUnixSocket =
{ node, ... }:
[
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 = [
shb.test.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 = [
shb.test.baseModule
shb.test.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 = [
shb.test.baseModule
shb.test.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()"
];
}
];
};
};
clientSsoLogin =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.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 = {
apps.ldap = {
userGroup = "user_group";
};
apps.sso = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "nextcloud";
adminGroup = "admin_group";
secret.result = config.shb.hardcodedsecret.oidcSecret.result;
secretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result;
fallbackDefaultAuth = false;
};
};
# Needed because OIDC somehow does not like self-signed certificates
# which we do use in tests.
# See https://github.com/pulsejet/nextcloud-oidc-login/issues/267
services.nextcloud.settings.oidc_login_tls_verify = lib.mkForce false;
shb.hardcodedsecret.oidcSecret = {
request = config.shb.nextcloud.apps.sso.secret.request;
settings.content = oidcSecret;
};
shb.hardcodedsecret.oidcAutheliaSecret = {
request = config.shb.nextcloud.apps.sso.secretForAuthelia.request;
settings.content = oidcSecret;
};
};
previewgenerator =
{ config, ... }:
{
systemd.tmpfiles.rules = [
"d '/srv/nextcloud' 0750 nextcloud nextcloud - -"
];
shb.nextcloud = {
apps.previewgenerator.enable = true;
};
};
externalstorage = {
systemd.tmpfiles.rules = [
"d '/srv/nextcloud' 0750 nextcloud nextcloud - -"
];
shb.nextcloud = {
apps.externalStorage = {
enable = true;
userLocalMount.directory = "/srv/nextcloud/$user";
userLocalMount.mountName = "home";
};
};
};
memories =
{ config, ... }:
{
systemd.tmpfiles.rules = [
"d '/srv/nextcloud' 0750 nextcloud nextcloud - -"
];
shb.nextcloud = {
apps.memories.enable = true;
apps.memories.vaapi = true;
};
};
recognize =
{ config, ... }:
{
systemd.tmpfiles.rules = [
"d '/srv/nextcloud' 0750 nextcloud nextcloud - -"
];
shb.nextcloud = {
apps.recognize.enable = true;
};
};
prometheus =
{ config, ... }:
{
shb.nextcloud = {
phpFpmPrometheusExporter.enable = true;
};
};
prometheusTestScript =
{ nodes, ... }:
''
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})
with subtest("prometheus"):
response = server.succeed(
"curl -sSf "
+ " http://localhost:${toString nodes.server.services.prometheus.exporters.php-fpm.port}/metrics"
)
print(response)
'';
basicTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_basic_${toString version}";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
{
shb.nextcloud.version = version;
}
];
};
testScript = commonTestScript.access;
};
cronTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_cron_${toString version}";
nodes.server = {
imports = [
basic
{
shb.nextcloud.version = version;
}
];
};
nodes.client = { };
testScript = commonTestScript.access.override {
extraScript =
{
node,
fqdn,
proto_fqdn,
...
}:
''
import time
def find_in_logs(unit, text):
return server.systemctl("status {}".format(unit))[1].find(text) != -1
with subtest("cron job succeeds"):
# This call does not block until the service is done.
server.succeed("systemctl start nextcloud-cron.service&")
# If the service failed, then we're not happy.
status = "active"
while status == "active":
status = server.get_unit_info("nextcloud-cron")["ActiveState"]
time.sleep(5)
if status != "inactive":
raise Exception("Cron job did not finish correctly")
if not find_in_logs("nextcloud-cron", "nextcloud-cron.service: Deactivated successfully."):
raise Exception("Nextcloud cron job did not finish successfully.")
'';
};
};
backupTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_backup_${toString version}";
nodes.server =
{ config, ... }:
{
imports = [
basic
{
shb.nextcloud.version = version;
}
(shb.test.backup config.shb.nextcloud.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
httpsTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_https_${toString version}";
nodes.server = {
imports = [
basic
{
shb.nextcloud.version = version;
}
shb.test.certs
https
];
};
nodes.client = { };
# TODO: Test login
testScript = commonTestScript.access;
};
previewGeneratorTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_previewGenerator_${toString version}";
nodes.server = {
imports = [
basic
{
shb.nextcloud.version = version;
}
shb.test.certs
https
previewgenerator
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
externalStorageTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_externalStorage_${toString version}";
nodes.server = {
imports = [
basic
{
shb.nextcloud.version = version;
}
shb.test.certs
https
externalstorage
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
# TODO: fix memories app
# See https://github.com/ibizaman/selfhostblocks/issues/476
memoriesTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_memories_${toString version}";
nodes.server = {
imports = [
basic
{
shb.nextcloud.version = version;
}
shb.test.certs
https
memories
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
recognizeTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_recognize_${toString version}";
nodes.server = {
imports = [
basic
{
shb.nextcloud.version = version;
}
shb.test.certs
https
recognize
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
ldapTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_ldap_${toString version}";
nodes.server =
{ config, ... }:
{
imports = [
basic
{
shb.nextcloud.version = version;
}
shb.test.certs
https
shb.test.ldap
ldap
];
};
nodes.client = {
imports = [
clientLdapLogin
];
};
testScript = commonTestScript.access;
};
ssoTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_sso_${toString version}";
nodes.server =
{ config, ... }:
{
imports = [
basic
{
shb.nextcloud.version = version;
}
shb.test.certs
https
shb.test.ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
(
{ config, ... }:
{
networking.hosts = {
"127.0.0.1" = [ config.test.fqdn ];
};
}
)
];
};
nodes.client = {
imports = [
clientSsoLogin
(
{ config, ... }:
{
networking.hosts = {
"192.168.1.2" = [ config.test.fqdn ];
};
}
)
];
};
testScript = commonTestScript.access;
};
prometheusTest =
version:
shb.test.runNixOSTest {
name = "nextcloud_prometheus_${toString version}";
nodes.server =
{ config, ... }:
{
imports = [
basic
{
shb.nextcloud.version = version;
}
prometheus
];
};
nodes.client = { };
testScript = prometheusTestScript;
};
versionedTests =
v:
{
"basic_${toString v}" = basicTest v;
"cron_${toString v}" = cronTest v;
"backup_${toString v}" = backupTest v;
"https_${toString v}" = httpsTest v;
"previewGenerator_${toString v}" = previewGeneratorTest v;
"externalStorage_${toString v}" = externalStorageTest v;
"ldap_${toString v}" = ldapTest v;
"sso_${toString v}" = ssoTest v;
"prometheus_${toString v}" = prometheusTest v;
}
// lib.optionalAttrs (v == 32) {
"memories_${toString v}" = memoriesTest v;
"recognize_${toString v}" = recognizeTest v;
};
in
lib.foldl (all: v: lib.mergeAttrs all (versionedTests v)) { } supportedVersion
================================================
FILE: test/services/open-webui.nix
================================================
{ shb, ... }:
let
oidcSecret = "oidcSecret";
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.open-webui.ssl);
waitForServices =
{ ... }:
[
"open-webui.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
node.config.shb.open-webui.port
];
};
basic =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/open-webui.nix
];
test = {
subdomain = "o";
};
shb.open-webui = {
enable = true;
inherit (config.test) subdomain domain;
};
# Speeds up tests because models can't be downloaded anyway and that leads to retries.
services.open-webui.environment.OFFLINE_MODE = "true";
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 = [
shb.test.baseModule
shb.test.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
{
basic = shb.test.runNixOSTest {
name = "open-webui_basic";
nodes.client = { };
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
backup = shb.test.runNixOSTest {
name = "open-webui_backup";
nodes.server =
{ config, ... }:
{
imports = [
basic
(shb.test.backup config.shb.open-webui.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
https = shb.test.runNixOSTest {
name = "open-webui_https";
nodes.client = { };
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
testScript = commonTestScript.access;
};
sso = shb.test.runNixOSTest {
name = "open-webui_sso";
nodes.client = {
imports = [
clientLoginSso
];
};
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
testScript = commonTestScript.access;
};
}
================================================
FILE: test/services/paperless.nix
================================================
{
pkgs,
lib,
shb,
}:
let
subdomain = "p";
domain = "example.com";
commonTestScript = shb.test.accessScript {
hasSSL = { node, ... }: !(isNull node.config.shb.paperless.ssl);
waitForServices =
{ ... }:
[
"paperless-web.service"
"nginx.service"
];
waitForPorts =
{ ... }:
[
28981
80
];
waitForUrls = { proto_fqdn, ... }: [ "${proto_fqdn}" ];
};
base =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/services/paperless.nix
];
virtualisation.memorySize = 4096;
virtualisation.cores = 2;
test = {
inherit subdomain domain;
};
shb.paperless = {
enable = true;
inherit subdomain domain;
};
# Required for tests
environment.systemPackages = [ pkgs.curl ];
};
basic =
{ config, ... }:
{
imports = [ base ];
test.hasSSL = false;
};
https =
{ config, ... }:
{
imports = [
base
shb.test.certs
];
test.hasSSL = true;
shb.paperless.ssl = config.shb.certs.certs.selfsigned.n;
};
backup =
{ config, ... }:
{
imports = [
https
(shb.test.backup config.shb.paperless.backup)
];
};
sso =
{ config, ... }:
{
imports = [
https
shb.test.ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
];
shb.paperless.sso = {
enable = true;
provider = "Authelia";
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
clientID = "paperless";
autoLaunch = true;
sharedSecret.result = config.shb.hardcodedsecret.paperlessSSOSecret.result;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.paperlessSSOSecretAuthelia.result;
};
shb.hardcodedsecret.paperlessSSOSecret = {
request = config.shb.paperless.sso.sharedSecret.request;
settings.content = "paperlessSSOSecret";
};
shb.hardcodedsecret.paperlessSSOSecretAuthelia = {
request = config.shb.paperless.sso.sharedSecretForAuthelia.request;
settings.content = "paperlessSSOSecret";
};
# Configure LDAP groups for group-based access control
shb.lldap.ensureGroups.paperless_user = { };
shb.lldap.ensureUsers.paperless_test_user = {
email = "paperless_user@example.com";
groups = [ "paperless_user" ];
password.result = config.shb.hardcodedsecret.ldappaperlessUserPassword.result;
};
shb.lldap.ensureUsers.regular_test_user = {
email = "regular_user@example.com";
groups = [ ];
password.result = config.shb.hardcodedsecret.ldapRegularUserPassword.result;
};
shb.hardcodedsecret.ldappaperlessUserPassword = {
request = config.shb.lldap.ensureUsers.paperless_test_user.password.request;
settings.content = "paperless_user_password";
};
shb.hardcodedsecret.ldapRegularUserPassword = {
request = config.shb.lldap.ensureUsers.regular_test_user.password.request;
settings.content = "regular_user_password";
};
};
in
{
basic = shb.test.runNixOSTest {
name = "paperless-basic";
nodes.server = basic;
nodes.client = { };
testScript = commonTestScript;
};
https = shb.test.runNixOSTest {
name = "paperless-https";
nodes.server = https;
nodes.client = { };
testScript = commonTestScript;
};
sso = shb.test.runNixOSTest {
name = "paperless-https";
nodes.server = sso;
nodes.client = { };
testScript = commonTestScript;
};
backup = shb.test.runNixOSTest {
name = "paperless-backup";
nodes.server = backup;
nodes.client = { };
testScript =
(shb.test.mkScripts {
hasSSL = args: !(isNull args.node.config.shb.paperless.ssl);
waitForServices = args: [
"paperless-web.service"
"nginx.service"
];
waitForPorts = args: [
28981
80
];
waitForUrls = args: [ "${args.proto_fqdn}" ];
}).backup;
};
}
================================================
FILE: test/services/pinchflat.nix
================================================
{ pkgs, shb, ... }:
let
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.pinchflat.ssl);
waitForServices =
{ ... }:
[
"pinchflat.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
node.config.shb.pinchflat.port
];
};
basic =
{ config, ... }:
{
imports = [
shb.test.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 = [
shb.test.baseModule
shb.test.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, ... }:
{
shb.pinchflat = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
ldap =
{ config, ... }:
{
shb.pinchflat = {
ldap = {
enable = true;
userGroup = "user_group";
};
};
};
clientLoginSso =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.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()"
];
}
# Bob, with its admin role only, cannot login into Karakeep because admins do not exist in Karakeep.
{
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
{
basic = shb.test.runNixOSTest {
name = "pinchflat_basic";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
];
};
testScript = commonTestScript.access;
};
backup = shb.test.runNixOSTest {
name = "pinchflat_backup";
nodes.server =
{ config, ... }:
{
imports = [
basic
(shb.test.backup config.shb.pinchflat.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
https = shb.test.runNixOSTest {
name = "pinchflat_https";
nodes.client = {
imports = [
clientLogin
];
};
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
testScript = commonTestScript.access;
};
sso = shb.test.runNixOSTest {
name = "pinchflat_sso";
nodes.client = {
imports = [
clientLoginSso
];
};
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
testScript = commonTestScript.access.override {
redirectSSO = true;
};
};
}
================================================
FILE: test/services/vaultwarden.nix
================================================
{ shb, ... }:
let
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.vaultwarden.ssl);
waitForServices =
{ ... }:
[
"vaultwarden.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
8222
5432
];
# to get the get token test to succeed we need:
# 1. add group Vaultwarden_admin to LLDAP
# 2. add an Authelia user with to that group
# 3. login in Authelia with that user
# 4. go to the Vaultwarden /admin endpoint
# 5. create a Vaultwarden user
# 6. now login with that new user to Vaultwarden
extraScript =
{ node, proto_fqdn, ... }:
''
with subtest("prelogin"):
response = curl(client, "", "${proto_fqdn}/identity/accounts/prelogin", data=unline_with("", """
{"email": "me@example.com"}
"""))
print(response)
if 'kdf' not in response:
raise Exception("Unrecognized response: {}".format(response))
with subtest("get token"):
response = curl(client, "", "${proto_fqdn}/identity/connect/token", data=unline_with("", """
scope=api%20offline_access
&client_id=web
&deviceType=10
&deviceIdentifier=a60323bf-4686-4b4d-96e0-3c241fa5581c
&deviceName=firefox
&grant_type=password&username=me
&password=mypassword
"""))
print(response)
if response["message"] != "Username or password is incorrect. Try again":
raise Exception("Unrecognized response: {}".format(response))
'';
};
basic =
{ config, ... }:
{
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 ];
# };
};
https =
{ config, ... }:
{
shb.vaultwarden = {
ssl = config.shb.certs.certs.selfsigned.n;
};
};
# Not yet supported
# ldap = { config, ... }: {
# # shb.vaultwarden = {
# # ldapHostname = "127.0.0.1";
# # ldapPort = config.shb.lldap.webUIListenPort;
# # };
# };
sso =
{ config, ... }:
{
shb.vaultwarden = {
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
};
in
{
basic = shb.test.runNixOSTest {
name = "vaultwarden_basic";
nodes.server = {
imports = [
shb.test.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/vaultwarden.nix
basic
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
https = shb.test.runNixOSTest {
name = "vaultwarden_https";
nodes.server = {
imports = [
shb.test.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/vaultwarden.nix
shb.test.certs
basic
https
];
};
nodes.client = { };
testScript = commonTestScript.access;
};
# Not yet supported
#
# ldap = shb.test.runNixOSTest {
# name = "vaultwarden_ldap";
#
# nodes.server = lib.mkMerge [
# shb.test.baseModule
# ../../modules/blocks/hardcodedsecret.nix
# ../../modules/services/vaultwarden.nix
# basic
# ldap
# ];
#
# nodes.client = {};
#
# testScript = commonTestScript.access;
# };
sso = shb.test.runNixOSTest {
name = "vaultwarden_sso";
nodes.server =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/vaultwarden.nix
shb.test.certs
basic
https
shb.test.ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
nodes.client = { };
testScript = commonTestScript.access.override {
waitForPorts =
{ node, ... }:
[
8222
5432
9091
];
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:
raise Exception(f"Code is {response['code']}")
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']}")
if response['auth_query'] != "rd=${proto_fqdn}/admin":
raise Exception(f"auth query should be rd=${proto_fqdn}/admin but is {response['auth_query']}")
'';
};
};
backup = shb.test.runNixOSTest {
name = "vaultwarden_backup";
nodes.server =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/blocks/hardcodedsecret.nix
../../modules/services/vaultwarden.nix
basic
(shb.test.backup config.shb.vaultwarden.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
}