machines/nix/modules/nixos/services/nginx/default.nix
Kaare Hoff Skovgaard 9ddab7b706
Some checks failed
/ dev-shell (push) Successful in 30s
/ rust-packages (push) Successful in 37s
/ check (push) Has been cancelled
/ terraform-providers (push) Has been cancelled
/ systems (push) Has been cancelled
Add some basic fail2ban setup of nginx
2025-07-20 23:03:14 +02:00

327 lines
13 KiB
Nix

{
config,
lib,
pkgs,
modulesPath,
...
}:
# TODO: Enable and configure prometheus-nginx-exporter and prometheus-nginxlog-exporter
# to get some metrics into prometheus.
let
cfg = config.khscodes.services.nginx;
locationOptions = import "${modulesPath}/services/web-servers/nginx/location-options.nix" {
inherit lib config;
};
vhostOption = lib.khscodes.mkSubmodule {
description = "nginx vhost";
options = {
acme = lib.mkOption {
description = "If a simple certificate for the virtual host name itself is not desired auto configured, then set this option. If set to a string it will be used as `useAcmeHost` from NixOS nginx service configuration. Otherwise set to the acme submodule and configure the desired certificate that way";
type = lib.types.nullOr (
lib.types.oneOf [
lib.types.str
(lib.khscodes.mkSubmodule {
description = "acme certificate";
options = {
domains = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Domain names the certificate should be requested for, should include the virtual host itself";
};
};
})
]
);
default = null;
};
globalRedirect = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "If set, all requests for this host are redirected (defaults to 301, configurable with redirectCode) to the given hostname.";
};
redirectCode = lib.mkOption {
type = lib.types.int;
default = 301;
description = "HTTP status used by globalRedirect and forceSSL. Possible usecases include temporary (302, 307) redirects, keeping the request method and body (307, 308), or explicitly resetting the method to GET (303). See https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections.";
};
mtls = lib.mkOption {
type = lib.types.nullOr (
lib.khscodes.mkSubmodule {
options = {
verify = lib.mkOption {
type = lib.types.enum [
"optional"
"on"
];
default = "on";
};
certificate = lib.mkOption {
type = lib.types.str;
description = "Path to the certificate to verify client certificates against";
};
};
description = "Nginx MTLS settings";
}
);
default = null;
};
robotsTxt = lib.mkOption {
type = lib.types.path;
description = "Path to robots.txt file, by default everything is disallowed";
default = ./robots.txt;
};
extraConfig = lib.mkOption {
type = lib.types.lines;
description = "Extra configuration to inject into the generated nginx config";
default = '''';
};
locations = lib.mkOption {
type = lib.types.attrsOf (
lib.khscodes.mkSubmodule {
description = "nginx virtual host location";
options = locationOptions.options;
}
);
default = { };
};
};
};
dns01Enabled = config.khscodes.security.acme.dns01Enabled;
useAcmeConfiguration = lib.attrsets.foldlAttrs (
acc: name: item:
acc || (item.acme != null && !lib.attrsets.isAttrs item.acme)
) false cfg.virtualHosts;
modernSslAppendedHttpConfig =
if cfg.sslConfiguration == "modern" then
''
ssl_ecdh_curve X25519:prime256v1:secp384r1;
''
else
'''';
in
{
options.khscodes.services.nginx = {
enable = lib.mkEnableOption "Enables nginx";
sslConfiguration = lib.mkOption {
type = lib.types.enum [
"modern"
"intermediate"
];
description = ''
Which sort of ssl configuration following https://ssl-config.mozilla.org/#server=nginx&version=1.28.0&config=modern&openssl=3.4.1&guideline=5.7 as a baseline to generate.
The generated config is not guarenteed to follow this template specifically. In general, modern is preferred, intermediate should only be used if there's a specific reason to do so.
Do note that intermediate requires generating dhparams of large size, which can take hours to complete.
TODO: Look into OCSP stapling.
'';
default = "modern";
};
virtualHosts = lib.mkOption {
type = lib.types.attrsOf vhostOption;
description = "Virtual hosts settings";
default = { };
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !useAcmeConfiguration || dns01Enabled;
message = "Cannot use `config.khscodes.services.nginx.virtualHosts.<name>.acme = {}` without setting config.khscodes.security.acme.dns01Enabled";
}
];
services.fail2ban.jails = {
nginx-botsearch = {
settings = {
# Block an IP address if it accesses a non-existent
# home directory more than 5 times in 10 minutes,
# since that indicates that it's scanning.
filter = "nginx-botsearch";
action = ''${config.services.fail2ban.banaction}[name=HTTP, port="http,https"]'';
logpath = "/var/log/nginx/access.log";
backend = "auto";
findtime = 600;
maxretry = 5;
};
};
nginx-bad-request = {
settings = {
# Block an IP address if it accesses a non-existent
# home directory more than 5 times in 10 minutes,
# since that indicates that it's scanning.
filter = "nginx-bad-request";
action = ''${config.services.fail2ban.banaction}[name=HTTP, port="http,https"]'';
logpath = "/var/log/nginx/access.log";
backend = "auto";
findtime = 600;
maxretry = 2;
};
};
};
khscodes.networking.aliases = lib.attrsets.attrNames cfg.virtualHosts;
khscodes.security.acme.enable = true;
security.dhparams.enable = lib.mkIf (cfg.sslConfiguration == "intermediate") {
enable = true;
params."nginx" = {
bits = 4096;
};
};
services.nginx = {
enable = true;
package = lib.mkDefault (
pkgs.nginxStable.overrideAttrs (oldAttrs: {
nativeBuildInputs = oldAttrs.nativeBuildInputs ++ [ pkgs.pkg-config ];
})
);
statusPage = config.khscodes.infrastructure.vault-prometheus-sender.enable;
sslDhparam = lib.mkIf (
cfg.sslConfiguration == "intermediate"
) "${config.security.dhparams.params."nginx".path}"; # DHParams only used when using the ciphers of intermediate
sslProtocols = lib.mkIf (cfg.sslConfiguration == "modern") "TLSv1.3"; # The default matches intermediate
sslCiphers = lib.mkIf (cfg.sslConfiguration == "modern") null;
recommendedTlsSettings = lib.mkDefault true;
recommendedGzipSettings = lib.mkDefault true;
recommendedOptimisation = lib.mkDefault true;
recommendedZstdSettings = lib.mkDefault true;
recommendedProxySettings = lib.mkDefault true;
appendHttpConfig = ''
map $scheme $hsts_header {
https "max-age=63072000; preload";
}
add_header Strict-Transport-Security $hsts_header;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
map $http_referer $httpReferer {
default "$http_referer";
"" "(direct)";
}
map $http_user_agent $httpAgent {
default "$http_user_agent";
"" "Unknown";
}
log_format json_analytics escape=json '{'
'"msec": "$msec", ' # request unixtime in seconds with a milliseconds resolution
'"connection": "$connection", ' # connection serial number
'"connection_requests": "$connection_requests", ' # number of requests made in connection
'"pid": "$pid", ' # process pid
'"request_id": "$request_id", ' # the unique request id
'"request_length": "$request_length", ' # request length (including headers and body)
'"remote_addr": "$remote_addr", ' # client IP
'"remote_user": "$remote_user", ' # client HTTP username
'"remote_port": "$remote_port", ' # client port
'"time_local": "$time_local", '
'"time_iso8601": "$time_iso8601", ' # local time in the ISO 8601 standard format
'"request": "$request", ' # full path no arguments if the request
'"request_uri": "$request_uri", ' # full path and arguments if the request
'"args": "$args", ' # args
'"status": "$status", ' # response status code
'"body_bytes_sent": "$body_bytes_sent", ' # the number of body bytes exclude headers sent to a client
'"bytes_sent": "$bytes_sent", ' # the number of bytes sent to a client
'"http_referer": "$http_referer", ' # HTTP referer
'"http_user_agent": "$http_user_agent", ' # user agent
'"http_x_forwarded_for": "$http_x_forwarded_for", ' # http_x_forwarded_for
'"http_host": "$http_host", ' # the request Host: header
'"server_name": "$server_name", ' # the name of the vhost serving the request
'"request_time": "$request_time", ' # request processing time in seconds with msec resolution
'"upstream": "$upstream_addr", ' # upstream backend server for proxied requests
'"upstream_connect_time": "$upstream_connect_time", ' # upstream handshake time incl. TLS
'"upstream_header_time": "$upstream_header_time", ' # time spent receiving upstream headers
'"upstream_response_time": "$upstream_response_time", ' # time spent receiving upstream body
'"upstream_response_length": "$upstream_response_length", ' # upstream response length
'"upstream_cache_status": "$upstream_cache_status", ' # cache HIT/MISS where applicable
'"ssl_protocol": "$ssl_protocol", ' # TLS protocol
'"ssl_cipher": "$ssl_cipher", ' # TLS cipher
'"scheme": "$scheme", ' # http or https
'"request_method": "$request_method", ' # request method
'"server_protocol": "$server_protocol", ' # request protocol, like HTTP/1.1 or HTTP/2.0
'"pipe": "$pipe", ' # "p" if request was pipelined, "." otherwise
'"gzip_ratio": "$gzip_ratio"'
'}';
access_log /var/log/nginx/access.json.log json_analytics;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
${modernSslAppendedHttpConfig}
'';
virtualHosts = lib.attrsets.mapAttrs (
name: value:
let
mtls =
if value.mtls != null then
''
ssl_client_certificate ${value.mtls.certificate};
ssl_verify_client ${value.mtls.verify};
''
else
'''';
extraConfig = ''
${mtls}
${value.extraConfig}
'';
in
{
inherit (value)
globalRedirect
redirectCode
;
inherit extraConfig;
locations = {
"=/robots.txt" = {
alias = value.robotsTxt;
};
} // value.locations;
forceSSL = true;
enableACME = value.acme == null && !dns01Enabled;
useACMEHost =
if lib.strings.isString value.acme then
value.acme
else if lib.attrsets.isAttrs value.acme || dns01Enabled then
name
else
null;
}
) cfg.virtualHosts;
};
networking.firewall.allowedTCPPorts = [
80
443
];
networking.firewall.allowedUDPPorts = [ 443 ];
users.users.nginx.extraGroups = lib.lists.optional dns01Enabled "acme";
khscodes.infrastructure.vault-loki-sender = {
extraFiles = [ ./nginx.alloy ];
extraGroups = [ "nginx" ];
};
security.acme.certs = lib.mkIf dns01Enabled (
lib.attrsets.foldlAttrs (
acc: name: value:
(
acc
// (lib.attrsets.optionalAttrs
(lib.attrsets.isAttrs value.acme || (dns01Enabled && !lib.strings.isString value.acme))
{
"${name}" =
if value.acme == null then
{
domain = name;
reloadServices = [ "nginx" ];
}
else
{
domain = lib.lists.head value.acme.domains;
extraDomainNames = lib.lists.tail value.acme.domains;
reloadServices = [ "nginx" ];
};
}
)
)
) { } cfg.virtualHosts
);
};
}