1
0
Fork 0

use contract for ssl block

This commit is contained in:
ibizaman 2024-01-11 23:22:46 -08:00 committed by Pierre Penninckx
parent 909ebe405a
commit adc09acc49
21 changed files with 790 additions and 144 deletions

View file

@ -26,6 +26,10 @@ Self Host Blocks provides at least one implementation for each block and allows
implementation if you want to, as long as it passes the tests. You can then use blocks to improve
services you already have deployed.
```{=include=} chapters html:into-file=//blocks-ssl.html
modules/blocks/ssl/docs/default.md
```
```{=include=} chapters html:into-file=//blocks-backup.html
modules/blocks/backup/docs/default.md
```

View file

@ -68,21 +68,15 @@ let
optionsDocs = buildOptionsDocs {
modules = allModules ++ [ scrubbedModule ];
variablelistId = "selfhostblocks-block-backup-options";
includeModuleSystemOptions = false;
};
backupOptionsDocs = buildOptionsDocs {
modules = [ ../modules/blocks/backup.nix scrubbedModule ];
variablelistId = "selfhostblocks-options";
includeModuleSystemOptions = false;
};
nextcloudOptionsDocs = buildOptionsDocs {
modules = [ ../modules/services/nextcloud-server.nix scrubbedModule ];
individualModuleOptionsDocs = path: (buildOptionsDocs {
modules = [ path scrubbedModule ];
variablelistId = "selfhostblocks-options";
includeModuleSystemOptions = false;
};
}).optionsJSON;
nmd = import nmdsrc {
inherit lib;
@ -135,22 +129,27 @@ in stdenv.mkDerivation {
'@OPTIONS_JSON@' \
${optionsDocs.optionsJSON}/share/doc/nixos/options.json
substituteInPlace ./modules/blocks/ssl/docs/default.md \
--replace \
'@OPTIONS_JSON@' \
${individualModuleOptionsDocs ../modules/blocks/ssl.nix}/share/doc/nixos/options.json
substituteInPlace ./modules/blocks/backup/docs/default.md \
--replace \
'@OPTIONS_JSON@' \
${backupOptionsDocs.optionsJSON}/share/doc/nixos/options.json
${individualModuleOptionsDocs ../modules/blocks/backup.nix}/share/doc/nixos/options.json
substituteInPlace ./modules/services/nextcloud-server/docs/default.md \
--replace \
'@OPTIONS_JSON@' \
${nextcloudOptionsDocs.optionsJSON}/share/doc/nixos/options.json
${individualModuleOptionsDocs ../modules/services/nextcloud-server.nix}/share/doc/nixos/options.json
find . -name "*.md" -print0 | \
while IFS= read -r -d ''' f; do
substituteInPlace "''${f}" \
--replace \
'@REPO@' \
"${ghRoot}"
"${ghRoot}" 2>/dev/null
done
nixos-render-docs manual html \

View file

@ -58,6 +58,8 @@
release = "0.0.1";
};
lib.contracts = pkgs.callPackage ./modules/contracts {};
checks =
let
importFiles = files:
@ -96,6 +98,7 @@
// (vm_test "postgresql" ./test/vm/postgresql.nix)
// (vm_test "monitoring" ./test/vm/monitoring.nix)
// (vm_test "nextcloud" ./test/vm/nextcloud.nix)
// (vm_test "ssl" ./test/vm/ssl.nix)
);
}
);

View file

@ -3,6 +3,8 @@
let
cfg = config.shb.authelia;
contracts = pkgs.callPackage ../contracts {};
fqdn = "${cfg.subdomain}.${cfg.domain}";
autheliaCfg = config.services.authelia.instances.${fqdn};
@ -35,6 +37,12 @@ in
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
ldapEndpoint = lib.mkOption {
type = lib.types.str;
description = "Endpoint for LDAP authentication backend.";
@ -293,9 +301,9 @@ in
lib.mkBefore (lib.concatStringsSep "\n" (map mkCfg cfg.oidcClients));
services.nginx.virtualHosts.${fqdn} = {
forceSSL = lib.mkIf config.shb.ssl.enable true;
sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem";
sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem";
forceSSL = !(isNull cfg.ssl);
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
# Taken from https://github.com/authelia/authelia/issues/178
# TODO: merge with config from https://matwick.ca/authelia-nginx-sso/
locations."/".extraConfig = ''

View file

@ -3,6 +3,8 @@
let
cfg = config.shb.ldap;
contracts = pkgs.callPackage ../contracts {};
fqdn = "${cfg.subdomain}.${cfg.domain}";
in
{
@ -33,6 +35,12 @@ in
default = 3890;
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
webUIListenPort = lib.mkOption {
type = lib.types.port;
description = "Port on which the web UI is exposed.";
@ -69,9 +77,9 @@ in
enable = true;
virtualHosts.${fqdn} = {
forceSSL = lib.mkIf config.shb.ssl.enable true;
sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem";
sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem";
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 = ''
proxy_set_header Host $host;

View file

@ -3,6 +3,8 @@
let
cfg = config.shb.monitoring;
contracts = pkgs.callPackage ../contracts {};
fqdn = "${cfg.subdomain}.${cfg.domain}";
in
{
@ -21,6 +23,12 @@ in
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
grafanaPort = lib.mkOption {
type = lib.types.port;
description = "Port where Grafana listens to HTTP requests.";
@ -362,9 +370,10 @@ in
enable = true;
virtualHosts.${fqdn} = {
forceSSL = lib.mkIf config.shb.ssl.enable true;
sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem";
sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem";
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."/" = {
proxyPass = "http://${toString config.services.grafana.settings.server.http_addr}:${toString config.services.grafana.settings.server.http_port}";
proxyWebsockets = true;

View file

@ -3,6 +3,8 @@
let
cfg = config.shb.nginx;
contracts = pkgs.callPackage ../contracts {};
fqdn = c: "${c.subdomain}.${c.domain}";
autheliaConfig = lib.types.submodule {
@ -19,6 +21,12 @@ let
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
authEndpoint = lib.mkOption {
type = lib.types.str;
description = "Auth endpoint for SSO.";
@ -102,9 +110,9 @@ in
let
vhostCfg = c: {
${fqdn c} = {
forceSSL = lib.mkIf config.shb.ssl.enable true;
sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${c.domain}/cert.pem";
sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${c.domain}/key.pem";
forceSSL = !(isNull c.ssl);
sslCertificate = lib.mkIf (!(isNull c.ssl)) c.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull c.ssl)) c.ssl.paths.key;
# Taken from https://github.com/authelia/authelia/issues/178
locations."/".extraConfig = ''

View file

@ -1,98 +1,338 @@
{ config, pkgs, lib, ... }:
let
cfg = config.shb.ssl;
cfg = config.shb.certs;
contracts = pkgs.callPackage ../contracts {};
in
{
options.shb.ssl = {
enable = lib.mkEnableOption "selfhostblocks.ssl";
domain = lib.mkOption {
description = "Domain to ask a wildcard certificate for.";
type = lib.types.str;
example = "domain.com";
};
dnsProvider = lib.mkOption {
description = "DNS provider to use. See https://go-acme.github.io/lego/dns/ for the list of supported providers.";
type = lib.types.str;
example = "linode";
};
credentialsFile = lib.mkOption {
type = lib.types.path;
options.shb.certs = {
systemdService = lib.mkOption {
description = ''
Credentials file location for the chosen DNS provider.
The content of this file must expose environment variables as written in the
[documentation](https://go-acme.github.io/lego/dns/) of each DNS provider.
For example, if the documentation says the credential must be located in the environment
variable DNSPROVIDER_TOKEN, then the file content must be:
DNSPROVIDER_TOKEN=xyz
You can put non-secret environment variables here too or use shb.ssl.additionalcfg instead.
Systemd oneshot service used to generate the Certificate Authority bundle.
'';
example = "/run/secrets/ssl";
};
additionalCfg = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = ''Additional environment variables used to configure the DNS provider.
For secrets, use shb.ssl.credentialsFile instead.
See the chose provider's [documentation](https://go-acme.github.io/lego/dns/) for available
options.
'';
example = lib.literalExpression ''
{
DNSPROVIDER_TIMEOUT = "10";
DNSPROVIDER_PROPAGATION_TIMEOUT = "240";
}
'';
};
dnsResolver = lib.mkOption {
description = "IP of a DNS server used to resolve hostnames.";
type = lib.types.str;
default = "8.8.8.8";
default = "shb-ca-bundle.service";
};
cas.selfsigned = lib.mkOption {
description = "Generate a self-signed Certificate Authority.";
default = {};
type = lib.types.attrsOf (lib.types.submodule ({ config, ...}: {
options = {
name = lib.mkOption {
type = lib.types.str;
description = ''
Certificate Authority Name. You can put what you want here, it will be displayed by the
browser.
'';
default = "Self Host Blocks Certificate";
};
paths = lib.mkOption {
description = ''
Paths where CA certs will be located.
This option is the contract output of the `shb.certs.cas` SSL block.
'';
type = contracts.ssl.certs-paths;
default = rec {
key = "/var/lib/certs/cas/${config._module.args.name}.key";
cert = "/var/lib/certs/cas/${config._module.args.name}.cert";
};
};
systemdService = lib.mkOption {
description = "Systemd oneshot service used to generate the certs.";
type = lib.types.str;
default = "shb-certs-ca-${config._module.args.name}.service";
};
};
}));
};
certs.selfsigned = lib.mkOption {
description = "Generate self-signed certificates signed by a Certificate Authority.";
default = {};
type = lib.types.attrsOf (lib.types.submodule ({ config, ... }: {
options = {
ca = lib.mkOption {
type = lib.types.nullOr contracts.ssl.cas;
description = ''
CA used to generate this certificate. Only used for self-signed.
This contract input takes the contract output of the `shb.certs.cas` SSL block.
'';
default = null;
};
domain = lib.mkOption {
type = lib.types.str;
description = ''
Domain to generate a certificate for. This can be a wildcard domain like
`*.example.com`.
'';
example = "example.com";
};
paths = lib.mkOption {
description = ''
Paths where certs will be located.
This option is the contract output of the `shb.certs.certs` SSL block.
'';
type = contracts.ssl.certs-paths;
default = rec {
key = "/var/lib/certs/selfsigned/${config._module.args.name}.key";
cert = "/var/lib/certs/selfsigned/${config._module.args.name}.cert";
};
};
systemdService = lib.mkOption {
description = "Systemd oneshot service used to generate the certs.";
type = lib.types.str;
default = "shb-certs-cert-selfsigned-${config._module.args.name}.service";
};
};
}));
};
adminEmail = lib.mkOption {
description = "Admin email in case certificate retrieval goes wrong.";
type = lib.types.str;
};
certs.letsencrypt = lib.mkOption {
description = "Generate certificates signed by [Let's Encrypt](https://letsencrypt.org/).";
default = {};
type = lib.types.attrsOf (lib.types.submodule ({ config, ... }: {
options = {
domain = lib.mkOption {
type = lib.types.str;
description = ''
Domain to generate a certificate for. This can be a wildcard domain like
`*.example.com`.
'';
example = "example.com";
};
debug = lib.mkOption {
description = "Enable debug logging";
type = lib.types.bool;
default = false;
paths = lib.mkOption {
description = ''
Paths where certs will be located.
This option is the contract output of the `shb.certs.certs` SSL block.
'';
type = contracts.ssl.certs-paths;
default = {
key = "/var/lib/acme/${config._module.args.name}/key.pem";
cert = "/var/lib/acme/${config._module.args.name}/cert.pem";
};
};
systemdService = lib.mkOption {
description = "Systemd oneshot service used to generate the certs.";
type = lib.types.str;
default = "shb-certs-cert-letsencrypt-${config._module.args.name}.service";
};
dnsProvider = lib.mkOption {
description = "DNS provider to use. See https://go-acme.github.io/lego/dns/ for the list of supported providers.";
type = lib.types.nullOr lib.types.str;
default = null;
example = "linode";
};
dnsResolver = lib.mkOption {
description = "IP of a DNS server used to resolve hostnames.";
type = lib.types.str;
default = "8.8.8.8";
};
credentialsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = ''
Credentials file location for the chosen DNS provider.
The content of this file must expose environment variables as written in the
[documentation](https://go-acme.github.io/lego/dns/) of each DNS provider.
For example, if the documentation says the credential must be located in the environment
variable DNSPROVIDER_TOKEN, then the file content must be:
DNSPROVIDER_TOKEN=xyz
You can put non-secret environment variables here too or use shb.ssl.additionalcfg instead.
'';
example = "/run/secrets/ssl";
default = null;
};
additionalEnvironment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = ''
Additional environment variables used to configure the DNS provider.
For secrets, use shb.ssl.credentialsFile instead.
See the chosen provider's [documentation](https://go-acme.github.io/lego/dns/) for
available options.
'';
example = lib.literalExpression ''
{
DNSPROVIDER_TIMEOUT = "10";
DNSPROVIDER_PROPAGATION_TIMEOUT = "240";
}
'';
};
makeAvailableToUser = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Make all certificates available to given user.
'';
default = null;
};
adminEmail = lib.mkOption {
description = "Admin email in case certificate retrieval goes wrong.";
type = lib.types.str;
};
debug = lib.mkOption {
description = "Enable debug logging";
type = lib.types.bool;
default = false;
};
};
}));
};
};
config = lib.mkIf cfg.enable {
users.users.${config.services.nginx.user} = {
isSystemUser = true;
group = "nginx";
extraGroups = [ config.security.acme.defaults.group ];
};
users.groups.nginx = {};
config =
let
filterProvider = provider: lib.attrsets.filterAttrs (k: i: i.provider == provider);
security.acme = {
acceptTerms = true;
certs."${cfg.domain}" = {
extraDomainNames = ["*.${cfg.domain}"];
};
defaults = {
email = cfg.adminEmail;
inherit (cfg) dnsProvider dnsResolver;
credentialsFile = cfg.credentialsFile;
enableDebugLogs = cfg.debug;
};
};
serviceName = lib.strings.removeSuffix ".service";
in
lib.mkMerge [
# Config for self-signed CA.
{
systemd.services = lib.mapAttrs' (_name: caCfg:
lib.nameValuePair (serviceName caCfg.systemdService) {
wantedBy = [ "multi-user.target" ];
wants = [ config.shb.certs.systemdService ];
before = [ config.shb.certs.systemdService ];
serviceConfig.Type = "oneshot";
serviceConfig.RuntimeDirectory = serviceName caCfg.systemdService;
# serviceConfig.User = "nextcloud";
# Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix
script = ''
cd $RUNTIME_DIRECTORY
systemd.services."acme-${cfg.domain}".environment = cfg.additionalCfg;
};
cat >ca.template <<EOF
organization = "${caCfg.name}"
cn = "${caCfg.name}"
expiration_days = 365
ca
cert_signing_key
crl_signing_key
EOF
mkdir -p "$(dirname -- "${caCfg.paths.key}")"
${pkgs.gnutls}/bin/certtool \
--generate-privkey \
--key-type rsa \
--sec-param High \
--outfile ${caCfg.paths.key}
chmod 666 ${caCfg.paths.key}
mkdir -p "$(dirname -- "${caCfg.paths.cert}")"
${pkgs.gnutls}/bin/certtool \
--generate-self-signed \
--load-privkey ${caCfg.paths.key} \
--template ca.template \
--outfile ${caCfg.paths.cert}
chmod 666 ${caCfg.paths.cert}
'';
}
) cfg.cas.selfsigned;
}
{
systemd.services.${serviceName config.shb.certs.systemdService} = (lib.mkIf (cfg.cas.selfsigned != {}) {
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
script = ''
mkdir -p /etc/ssl/certs
rm -f /etc/ssl/certs/ca-certificates.crt
for file in ${lib.concatStringsSep " " (lib.mapAttrsToList (_name: caCfg: caCfg.paths.cert) cfg.cas.selfsigned)}; do
cat "$file" >> /etc/ssl/certs/ca-certificates.crt
done
'';
});
}
# Config for self-signed cert.
{
systemd.services = lib.mapAttrs' (_name: certCfg:
lib.nameValuePair (serviceName certCfg.systemdService) {
after = [ certCfg.ca.systemdService ];
requires = [ certCfg.ca.systemdService ];
wantedBy = [ "multi-user.target" ];
serviceConfig.RuntimeDirectory = serviceName certCfg.systemdService;
# Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix
script = ''
cd $RUNTIME_DIRECTORY
# server cert template
cat >server.template <<EOF
organization = "An example company"
cn = "${certCfg.domain}"
expiration_days = 30
dns_name = "${certCfg.domain}"
encryption_key
signing_key
EOF
mkdir -p "$(dirname -- "${certCfg.paths.key}")"
${pkgs.gnutls}/bin/certtool \
--generate-privkey \
--key-type rsa \
--sec-param High \
--outfile ${certCfg.paths.key}
chmod 666 ${certCfg.paths.key}
mkdir -p "$(dirname -- "${certCfg.paths.cert}")"
${pkgs.gnutls}/bin/certtool \
--generate-certificate \
--load-privkey ${certCfg.paths.key} \
--load-ca-privkey ${certCfg.ca.paths.key} \
--load-ca-certificate ${certCfg.ca.paths.cert} \
--template server.template \
--outfile ${certCfg.paths.cert}
chmod 666 ${certCfg.paths.cert}
'';
serviceConfig.Type = "oneshot";
# serviceConfig.User = "nextcloud";
}
) cfg.certs.selfsigned;
}
# Config for Let's Encrypt cert.
{
users.users = lib.mkMerge (lib.mapAttrsToList (name: certCfg: {
${certCfg.makeAvailableToUser}.extraGroups = lib.mkIf (!(isNull certCfg.makeAvailableToUser)) [
config.security.acme.defaults.group
];
}) cfg.certs.letsencrypt);
security.acme.acceptTerms = lib.mkIf (cfg.certs.letsencrypt != {}) true;
security.acme.certs = lib.mkMerge (lib.mapAttrsToList (name: certCfg: {
"${name}" = {
extraDomainNames = [ certCfg.domain ];
email = certCfg.adminEmail;
inherit (certCfg) dnsProvider dnsResolver;
credentialsFile = certCfg.credentialsFile;
enableDebugLogs = certCfg.debug;
};
}) cfg.certs.letsencrypt);
systemd.services = lib.mkMerge (lib.mapAttrsToList (name: certCfg: {
"acme-${certCfg.domain}".environment = certCfg.additionalEnvironment;
}) cfg.certs.letsencrypt);
}
];
}

View file

@ -0,0 +1,159 @@
# SSL Block {#ssl-block}
This NixOS module is a block that provides a contract to generate TLS certificates.
It is implemented by:
- [`shb.certs.cas.selfsigned`][10] and [`shb.certs.certs.selfsigned`][11]: Generates self-signed certificates,
including self-signed CA thanks to the [certtool][1] package.
- [`shb.certs.certs.letsencrypt`][12]: Requests certificates from [Let's Encrypt][2].
[1]: https://search.nixos.org/packages?channel=23.11&show=gnutls&from=0&size=50&sort=relevance&type=packages&query=certtool
[2]: https://letsencrypt.org/
[10]: blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned
[11]: blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned
[12]: blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt
## Contract {#ssl-block-contract}
The contract for this block is defined in [`/modules/contracts/ssl.nix`](@REPO@/modules/contracts/ssl.nix).
Every module implementing this contract provides the following options:
- `paths.cert`: Path to the cert file.
- `paths.key`: Path to the key file.
- `systemdService`: Systemd oneshot service used to generate the certificate.
The Systemd service file name must include the `.service` suffix. Downstream users of the
certificate can use this option to wait for the certificate to be generated.
## Implementations {#ssl-block-impl}
This sections explains how to generate certificates using the SSL block implementations.
### Self-Signed Certificates {#ssl-block-impl-self-signed}
Defined in [`/modules/blocks/ssl.nix`](@REPO@/modules/blocks/ssl.nix).
To use self-signed certificates, we must first generate at least one Certificate Authority (CA):
```nix
shb.certs.cas.selfsigned.myca = {
name = "My CA";
};
```
Every CA defined this way will be concatenated into the file `/etc/ssl/certs/ca-certificates.cert`
which means those CAs and all certificates generated by those CAs will be automatically trusted.
We can then generate one or more certificates signed by that CA:
```nix
shb.certs.certs.selfsigned = {
"example.com" = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "example.com";
};
"www.example.com" = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "www.example.com";
};
};
```
### Let's Encrypt {#ssl-block-impl-lets-encrypt}
Defined in [`/modules/blocks/ssl.nix`](@REPO@/modules/blocks/ssl.nix).
We can ask Let's Encrypt to generate a certificate with:
```nix
shb.certs.certs.letsencrypt."example.com" = {
domain = "example.com";
dnsProvider = "linode";
adminEmail = "admin@example.com";
credentialsFile = /path/to/secret/file;
additionalEnvironment = {
LINODE_HTTP_TIMEOUT = "10";
LINODE_POLLING_INTERVAL = "10";
LINODE_PROPAGATION_TIMEOUT = "240";
};
};
```
The credential file's content would be a key-value pair:
```yaml
LINODE_TOKEN=XYZ...
```
For other providers, see the [official instruction](https://go-acme.github.io/lego/dns/).
## Usage {#ssl-block-usage}
To use either a self-signed certificates or a Let's Encrypt generated one, we can reference the path
where the certificate and the private key are located:
```nix
config.shb.certs.<implementation>.<name>.paths.cert
config.shb.certs.<implementation>.<name>.paths.key
```
For example:
```nix
config.shb.certs.selfsigned."example.com".paths.cert
config.shb.certs.selfsigned."example.com".paths.key
```
We can then configure Nginx to use those certificates:
```nix
services.nginx.virtualHosts."example.com" =
let
cert = config.shb.certs.selfsigned."example.com";
in
{
onlySSL = true;
sslCertificate = cert.paths.cert;
sslCertificateKey = cert.paths.key;
locations."/".extraConfig = ''
add_header Content-Type text/plain;
return 200 'It works!';
'';
};
```
To make sure the Nginx webserver can find the generated file, we will make it wait for the
certificate to the generated:
```nix
systemd.services.nginx = {
after = [ config.shb.certs.selfsigned."example.com".systemdService ];
requires = [ config.shb.certs.selfsigned."example.com".systemdService ];
};
```
If needed, we can also wait on the CA bundle to be generated by waiting for the Systemd service:
```nix
config.shb.certs.systemdService
```
## Debug {#ssl-block-debug}
Each CA and Cert is generated by a systemd service whose name can be seen in `systemdService`
options below. You can then see the latest errors messages using `journalctl`.
## Tests {#ssl-block-tests}
This block is tested in [`/tests/vm/ssl.nix`](@REPO@/tests/vm/ssl.nix).
## Options Reference {#ssl-block-options}
```{=include=} options
id-prefix: blocks-ssl-options-
list-id: selfhostblocks-options
source: @OPTIONS_JSON@
```

View file

@ -0,0 +1,4 @@
{ lib }:
{
ssl = import ./ssl.nix { inherit lib; };
}

58
modules/contracts/ssl.nix Normal file
View file

@ -0,0 +1,58 @@
{ lib }:
rec {
certs-paths = lib.types.submodule {
freeformType = lib.types.anything;
options = {
cert = lib.mkOption {
type = lib.types.path;
description = "Path to the cert file.";
};
key = lib.mkOption {
type = lib.types.path;
description = "Path to the key file.";
};
};
};
cas = lib.types.submodule {
freeformType = lib.types.anything;
options = {
paths = lib.mkOption {
description = ''
Paths where the files for the CA will be located.
This option is the contract output of the `shb.certs.cas` SSL block.
'';
type = certs-paths;
};
systemdService = lib.mkOption {
description = "Systemd oneshot service used to generate the CA.";
type = lib.types.str;
};
};
};
certs = lib.types.submodule {
freeformType = lib.types.anything;
options = {
paths = lib.mkOption {
description = ''
Paths where the files for the certificate will be located.
This option is the contract output of the `shb.certs.certs` SSL block.
'';
type = certs-paths;
};
systemdService = lib.mkOption {
description = ''
Systemd oneshot service used to generate the certificate. The name must include the
`.service` suffix.
'';
type = lib.types.str;
};
};
};
}

View file

@ -3,6 +3,8 @@
let
cfg = config.shb.arr;
contracts = pkgs.callPackage ../contracts {};
apps = {
radarr = {
defaultPort = 7001;
@ -146,6 +148,12 @@ let
example = "example.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
port = lib.mkOption {
type = lib.types.port;
description = "Port on which ${name} listens to incoming requests.";
@ -304,7 +312,7 @@ config.xml" templatedSettings) "${config.services.radarr.dataDir}/config.xml" (
c = cfg.${name};
in
lib.mkIf (c.authEndpoint != null) {
inherit (c) subdomain domain authEndpoint;
inherit (c) subdomain domain authEndpoint ssl;
upstream = "http://127.0.0.1:${toString c.port}";
autheliaRules = [
{

View file

@ -3,6 +3,8 @@
let
cfg = config.shb.deluge;
contracts = pkgs.callPackage ../contracts {};
fqdn = "${cfg.subdomain}.${cfg.domain}";
in
{
@ -21,6 +23,12 @@ in
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
daemonPort = lib.mkOption {
type = lib.types.int;
description = "Deluge daemon port";
@ -256,7 +264,7 @@ in
shb.nginx.autheliaProtect = lib.mkIf config.shb.authelia.enable [
{
inherit (cfg) subdomain domain authEndpoint;
inherit (cfg) subdomain domain authEndpoint ssl;
upstream = "http://127.0.0.1:${toString config.services.deluge.web.port}";
autheliaRules = [{
domain = fqdn;

View file

@ -3,6 +3,8 @@
let
cfg = config.shb.hledger;
contracts = pkgs.callPackage ../contracts {};
fqdn = "${cfg.subdomain}.${cfg.domain}";
in
{
@ -21,6 +23,12 @@ in
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
port = lib.mkOption {
type = lib.types.int;
description = "HLedger port";
@ -74,7 +82,7 @@ in
shb.nginx.autheliaProtect = [
{
inherit (cfg) subdomain domain authEndpoint;
inherit (cfg) subdomain domain authEndpoint ssl;
upstream = "http://${toString config.services.hledger-web.host}:${toString config.services.hledger-web.port}";
autheliaRules = [{
domain = fqdn;

View file

@ -3,6 +3,8 @@
let
cfg = config.shb.home-assistant;
contracts = pkgs.callPackage ../contracts {};
fqdn = "${cfg.subdomain}.${cfg.domain}";
ldap_auth_script_repo = pkgs.fetchFromGitHub {
@ -33,6 +35,12 @@ in
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
ldap = lib.mkOption {
description = ''
LDAP Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html)
@ -193,10 +201,12 @@ in
};
services.nginx.virtualHosts."${fqdn}" = {
forceSSL = lib.mkIf config.shb.ssl.enable true;
http2 = true;
sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem";
sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem";
forceSSL = !(isNull cfg.ssl);
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
extraConfig = ''
proxy_buffering off;
'';

View file

@ -3,6 +3,8 @@
let
cfg = config.shb.jellyfin;
contracts = pkgs.callPackage ../contracts {};
fqdn = "${cfg.subdomain}.${cfg.domain}";
template = file: newPath: replacements:
@ -33,6 +35,12 @@ in
example = "domain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
ldapHost = lib.mkOption {
type = lib.types.str;
description = "host serving the LDAP server";
@ -108,10 +116,12 @@ in
# Take advice from https://jellyfin.org/docs/general/networking/nginx/ and https://nixos.wiki/wiki/Plex
services.nginx.virtualHosts."${fqdn}" = {
forceSSL = true;
forceSSL = !(isNull cfg.ssl);
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
http2 = true;
sslCertificate = "/var/lib/acme/${cfg.domain}/cert.pem";
sslCertificateKey = "/var/lib/acme/${cfg.domain}/key.pem";
extraConfig = ''
# The default `client_max_body_size` is 1M, this might not be enough for some posters, etc.
client_max_body_size 20M;

View file

@ -5,6 +5,8 @@ let
fqdn = "${cfg.subdomain}.${cfg.domain}";
contracts = pkgs.callPackage ../contracts {};
# Make sure to bump both nextcloudPkg and nextcloudApps at the same time.
nextcloudPkg = pkgs.nextcloud27;
nextcloudApps = pkgs.nextcloud27Packages.apps;
@ -27,6 +29,12 @@ in
example = "domain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
externalFqdn = lib.mkOption {
description = "External fqdn used to access Nextcloud. Defaults to <subdomain>.<domain>. This should only be set if you include the port when accessing Nextcloud.";
type = lib.types.nullOr lib.types.str;
@ -146,6 +154,12 @@ in
default = "oo";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr 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.";
@ -363,14 +377,14 @@ in
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 = config.shb.ssl.enable;
https = !(isNull cfg.ssl);
extraApps = if isNull cfg.extraApps then {} else cfg.extraApps nextcloudApps;
extraAppsEnable = true;
appstoreEnable = true;
extraOptions = let
protocol = if config.shb.ssl.enable then "https" else "http";
protocol = if !(isNull cfg.ssl) then "https" else "http";
in {
"overwrite.cli.url" = "${protocol}://${fqdn}";
"overwritehost" = if (isNull cfg.externalFqdn) then fqdn else cfg.externalFqdn;
@ -382,6 +396,9 @@ in
"overwritecondaddr" = ""; # We need to set it to empty otherwise overwriteprotocol does not work.
"debug" = cfg.debug;
"filelocking.debug" = cfg.debug;
# Use persistent SQL connections.
"dbpersistent" = "true";
};
phpOptions = {
@ -396,7 +413,7 @@ in
"opcache.max_accelerated_files" = "10000";
"opcache.memory_consumption" = "128";
"opcache.revalidate_freq" = "1";
"openssl.cafile" = "/etc/ssl/certs/ca-certificates.crt";
"openssl.cafile" = "/etc/ssl/certs/ca-certificates.cert";
short_open_tag = "Off";
output_buffering = "Off";
@ -424,9 +441,9 @@ in
services.nginx.virtualHosts.${fqdn} = {
# listen = [ { addr = "0.0.0.0"; port = 443; } ];
sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem";
sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem";
forceSSL = lib.mkIf config.shb.ssl.enable true;
forceSSL = !(isNull cfg.ssl);
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
# 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.
@ -491,9 +508,10 @@ in
};
services.nginx.virtualHosts."${cfg.apps.onlyoffice.subdomain}.${cfg.domain}" = {
sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem";
sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem";
forceSSL = lib.mkIf config.shb.ssl.enable true;
forceSSL = !(isNull cfg.ssl);
sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
locations."/" = {
extraConfig = ''
allow ${cfg.apps.onlyoffice.localNetworkIPRange};

View file

@ -3,6 +3,8 @@
let
cfg = config.shb.vaultwarden;
contracts = pkgs.callPackage ../contracts {};
fqdn = "${cfg.subdomain}.${cfg.domain}";
template = file: newPath: replacements:
@ -33,6 +35,12 @@ in
example = "mydomain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
default = null;
};
port = lib.mkOption {
type = lib.types.port;
description = "Port on which vaultwarden service listens.";
@ -164,7 +172,7 @@ in
shb.nginx.autheliaProtect = [
{
inherit (cfg) subdomain domain authEndpoint;
inherit (cfg) subdomain domain authEndpoint ssl;
upstream = "http://127.0.0.1:${toString config.services.vaultwarden.config.ROCKET_PORT}";
autheliaRules = [
{

View file

@ -89,6 +89,7 @@ in
authEndpoint = "https://oidc.example.com";
subdomain = "radarr";
upstream = "http://127.0.0.1:7001";
ssl = null;
}
];
users.users.radarr.extraGroups = [ "media" ];
@ -162,6 +163,7 @@ in
authEndpoint = "https://oidc.example.com";
subdomain = "radarr";
upstream = "http://127.0.0.1:7001";
ssl = null;
}
];
users.users.radarr.extraGroups = [ "media" ];

View file

@ -18,9 +18,11 @@ let
services = anyOpt {};
shb.authelia = anyOpt {};
shb.backup = anyOpt {};
shb.ssl = anyOpt {};
systemd = anyOpt {};
users = anyOpt {};
};
}
../../modules/blocks/ssl.nix
../../modules/blocks/nginx.nix
m
];
@ -46,36 +48,32 @@ in
testAuth = {
expected = {
shb.backup = {};
shb.nginx = {
accessLog = false;
autheliaProtect = [{
authEndpoint = "hello";
autheliaRules = [{}];
subdomain = "my";
domain = "example.com";
upstream = "http://127.0.0.1:1234";
}];
debugLog = false;
};
services.nginx.enable = true;
services.nginx.virtualHosts."my.example.com" = {
nginx.enable = true;
nginx.virtualHosts."my.example.com" = {
forceSSL = true;
locations."/" = {};
locations."/authelia" = {};
sslCertificate = "/var/lib/acme/example.com/cert.pem";
sslCertificateKey = "/var/lib/acme/example.com/key.pem";
sslCertificate = "/var/lib/certs/selfsigned/example.com.cert";
sslCertificateKey = "/var/lib/certs/selfsigned/example.com.key";
};
};
expr = testConfig {
shb.ssl.enable = true;
expr = (testConfig ({ config, ... }: {
shb.certs.cas.selfsigned.myca = {};
shb.certs.certs.selfsigned."example.com" = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "example.com";
};
shb.nginx.autheliaProtect = [{
subdomain = "my";
domain = "example.com";
ssl = config.shb.certs.certs.selfsigned."example.com";
upstream = "http://127.0.0.1:1234";
authEndpoint = "hello";
autheliaRules = [{}];
}];
};
})).services;
};
}

76
test/vm/ssl.nix Normal file
View file

@ -0,0 +1,76 @@
{ pkgs, lib, ... }:
{
test = pkgs.nixosTest {
name = "ssl-test";
nodes.server = { config, pkgs, ... }: {
imports = [
../../modules/blocks/ssl.nix
];
shb.certs = {
cas.selfsigned = {
myca = {
name = "My CA";
};
myotherca = {
name = "My Other CA";
};
};
certs.selfsigned = {
mycert = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "example.com";
};
};
};
# The configuration below is to create a webserver that uses the server certificate.
networking.hosts."127.0.0.1" = [ "example.com" ];
services.nginx.enable = true;
services.nginx.virtualHosts."example.com" =
{
onlySSL = true;
sslCertificate = config.shb.certs.certs.selfsigned.mycert.paths.cert;
sslCertificateKey = config.shb.certs.certs.selfsigned.mycert.paths.key;
locations."/".extraConfig = ''
add_header Content-Type text/plain;
return 200 'It works!';
'';
};
systemd.services.nginx = {
after = [ config.shb.certs.certs.selfsigned.mycert.systemdService ];
requires = [ config.shb.certs.certs.selfsigned.mycert.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;
mycert = nodes.server.shb.certs.certs.selfsigned.mycert;
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("${mycert.paths.key}")
server.wait_for_file("${mycert.paths.cert}")
# Wait for jkkk
server.require_unit_state("${nodes.server.shb.certs.systemdService}", "inactive")
with subtest("Certificate is trusted in curl"):
machine.wait_for_unit("nginx")
machine.wait_for_open_port(443)
machine.succeed("curl --fail-with-body -v https://example.com")
'';
};
}