diff --git a/flake.nix b/flake.nix index cc88389..e566bde 100644 --- a/flake.nix +++ b/flake.nix @@ -9,8 +9,8 @@ outputs = inputs@{ self, nixpkgs, sops-nix, ... }: { nixosModules.default = { config, ... }: { imports = [ + modules/ssl.nix modules/backup.nix - modules/haproxy.nix modules/home-assistant.nix modules/jellyfin.nix modules/monitoring.nix diff --git a/modules/haproxy-configcreator.nix b/modules/haproxy-configcreator.nix deleted file mode 100644 index 5ce0050..0000000 --- a/modules/haproxy-configcreator.nix +++ /dev/null @@ -1,492 +0,0 @@ -{ lib -, pkgs -}: - -with builtins; -with lib; -with lib.attrsets; -with lib.lists; -with lib.strings; -let - getAttrWithDefault = name: default: attrset: - if isAttrs attrset && hasAttr name attrset then - getAttr name attrset - else - default; - - recursiveMerge = attrList: - let f = attrPath: - zipAttrsWith (n: values: - if all isList values then - concatLists values - else if all isAttrs values then - f (attrPath ++ [n]) values - else - last values - ); - in f [] attrList; - - augmentedContent = fieldName: rules: parent: set: - let - print = {rule = k: parent: v: - assert assertMsg (isString v || isInt v) "cannot print key '${fieldName}.${k}' of type '${typeOf v}', should be string or int instead"; - "${k} ${toString v}";}; - - matchingRule = k: v: findFirst (rule: rule.match k parent v) print rules; - - augment = parent: k: v: - let - match = matchingRule k v; - rule = if hasAttr "rule" match then match.rule else null; - rules = if hasAttr "rules" match then match.rules else null; - indent = map (x: if hasAttr "indent" match then match.indent + x else x); - headerFn = if hasAttr "header" match then match.header else null; - header = optional (headerFn != null) (headerFn k); - trailer = optional (headerFn != null) ""; - content = header ++ indent (augmentedContent "${fieldName}.${k}" rules (parent ++ [k]) v) ++ trailer; - in - if rule != null - then rule k parent v - else - assert assertMsg (isAttrs v) "attempt to apply rules on key '${toString k}' which is a '${typeOf v}' but should be a set:\n${toString v}"; - if hasAttr "order" match then - { - inherit (match) order; - inherit content; - } - else - content; - - augmented = mapAttrsToList (augment parent) ( - assert assertMsg (isAttrs set) "attempt to apply rules on field ${fieldName} having type '${typeOf set}':\n${toString set}"; - set - ); - - sortAugmented = sort (a: b: - (isAttrs a && hasAttr "order" a) - && (isAttrs b && hasAttr "order" b) - && a.order < b.order - ); - - onlyContent = (x: if isAttrs x && hasAttr "content" x then x.content else x); - in - flatten (map onlyContent (sortAugmented augmented)); - - updateByPath = path: fn: set: - if hasAttrByPath path set then - recursiveUpdate set (setAttrByPath path (fn (getAttrFromPath path set))) - else - set; - - schema = - let - mkRule = - { redirect ? false - , scheme ? "https" - , code ? null - , condition ? null - }: - concatStringsRecursive " " [ - (optional redirect "redirect") - "scheme" scheme - (optional (code != null) "code ${toString code}") - (optional (condition != null) "if ${condition}") - ]; - - mkBind = - { addr - , ssl ? false - , crt ? null - }: - concatStringsRecursive " " [ - "bind" - addr - (optional ssl "ssl") - (optional (crt != null) "crt ${crt}") - ]; - - mkServer = - { name - , address - , balance ? null - , check ? null - , httpcheck ? null - , forwardfor ? true - , resolvers ? null - }: - [ - "mode http" - (optional forwardfor "option forwardfor") - (optional (httpcheck != null) "option httpchk ${httpcheck}") - (optional (balance != null) "balance ${balance}") - (concatStringsRecursive " " [ - "server" - name - address - (optionals (check != null) (if - isBool check - then (if check then ["check"] else []) - else mapAttrsToList (k: v: "${k} ${v}") check)) - (optional (resolvers != null) "resolvers ${resolvers}") - ]) - ]; - - # Lua's import system requires the import path to be something like: - # - # /nix/store/123-name// - # - # Then the lua-prepend-path can be: - # - # /nix/store/123-name/?/ - # - # Then when lua code imports , it will search in the - # prepend paths and replace the question mark with the - # name to get a match. - # - # But the config.source is actually without the name: - # - # /nix/store/123-name/ - # - # This requires us to create a new directory structure and we're - # using a linkFarm for this. - createPluginLinks = configs: - let - mkLink = name: config: { - inherit name; - path = config.source; - }; - - in - pkgs.linkFarm "haproxyplugins" (mapAttrsToList mkLink configs); - - mkPlugin = links: name: - { luapaths ? [] - , cpaths ? [] - , load ? null - , ... - }: - { - lua-prepend-path = - let - f = ext: type: path: - { - inherit type; - path = - if path == "." then - "${links}/${name}/?.${ext}" - else - "${links}/${name}/${path}/?.${ext}"; - }; - in - map (f "lua" "path") (toList luapaths) - ++ map (f "so" "cpath") (toList cpaths); - } // optionalAttrs (load != null) { - lua-load = ["${links}/${name}/${load}"]; - }; - - # Takes plugins as an attrset of name to {init, load, source}, - # transforms them to a [attrset] with fields lua-prepend-path - # and optionally lua-load then returns a list of lines with all - # lua-prepend-path first and all lua-load afterwards. - mkPlugins = v: - let - f = recursiveMerge (mapAttrsToList (mkPlugin (createPluginLinks v)) v); - lua-prepend-path = map ({path, type}: "lua-prepend-path ${path} ${type}") (getAttrWithDefault "lua-prepend-path" [] f); - lua-load = map (x: "lua-load ${x}") (getAttrWithDefault "lua-load" [] f); - in - lua-prepend-path ++ lua-load; - in [ - { - match = k: parent: v: k == "defaults"; - order = 2; - indent = " "; - header = k: k; - rules = [ - { - match = k: parent: v: k == "timeout"; - rule = k: parent: v: mapAttrsToList (k1: v1: "${k} ${k1} ${v1}") v; - } - ]; - } - { - match = k: parent: v: k == "global"; - order = 1; - indent = " "; - header = k: k; - rules = [ - { - match = k: parent: v: k == "plugins"; - rule = k: parent: v: mkPlugins v; - } - { - match = k: parent: v: k == "setenv"; - rule = k: parent: v: mapAttrsToList (k: v: "setenv ${k} ${v}" ) v; - } - ]; - } - { - match = k: parent: v: k == "resolvers"; - order = 3; - rules = [ - { - match = k: parent: v: true; - header = k: "resolvers " + k; - indent = " "; - rules = [ - { - match = k: parent: v: k == "nameservers"; - rule = k: parent: v: mapAttrsToList (k1: v1: "nameserver ${k1} ${v1}") v; - } - ]; - } - ]; - } - { - match = k: parent: v: k == "frontend"; - order = 4; - rules = [ - { - match = k: parent: v: true; - header = k: "frontend " + k; - indent = " "; - rules = [ - { - match = k: parent: v: k == "rules"; - rule = k: parent: v: map mkRule v; - } - { - match = k: parent: v: k == "bind" && isAttrs v; - rule = k: parent: v: mkBind v; - } - { - match = k: parent: v: k == "use_backend"; - rule = k: parent: v: - let - use = name: value: "use_backend ${name} ${toString value}"; - in - if isList v then - map (v: use v.name v.value) v - else - use v.name v.value; - } - { - match = k: parent: v: true ; - rule = k: parent: v: - let - l = prefix: v: - if isAttrs v then - mapAttrsToList (k: v: l "${prefix} ${k}" v) v - else if isList v then - map (l prefix) v - else if isBool v then - optional v prefix - else - assert assertMsg (isString v) "value for field ${k} should be a string, bool, attr or list, got: ${typeOf v}"; - "${prefix} ${v}"; - in - l k v; - } - ]; - } - ]; - } - { - match = k: parent: v: k == "backend"; - order = 5; - rules = [ - { - match = k: parent: v: true; - header = k: "backend " + k; - indent = " "; - rules = [ - { - match = k: parent: v: k == "options"; - rule = k: parent: v: v; - } - { - match = k: parent: v: k == "servers"; - rule = k: parent: v: map mkServer v; - } - ]; - } - ]; - } - ]; - - - concatStringsRecursive = sep: strings: - concatStringsSep sep (flatten strings); - - assertHasAttr = name: attrPath: v: - assertMsg - (hasAttrByPath attrPath v) - "no ${last attrPath} defined in config for site ${name}.${concatStringsSep "." (init attrPath)}, found attr names: ${toString (attrNames (getAttrFromPath (init attrPath) v))}"; - - # Takes a function producing a [nameValuePair], applies it to - # all name-value pair in the given set and merges the resulting - # [[nameValuePair]]. - mapAttrsFlatten = f: set: listToAttrs (concatLists (mapAttrsToList f set)); - - mapIfIsAttrs = f: value: - if isAttrs value - then f value - else value; - - flattenAttrs = sep: cond: set: - let - recurse = mapIfIsAttrs (mapAttrsFlatten ( - n: v: let - result = recurse v; - in - if isAttrs result && cond n v - then mapAttrsToList (n2: v2: nameValuePair "${n}${sep}${n2}" v2) result - else [(nameValuePair n result)] - )); - in recurse set; -in -{ - inherit updateByPath recursiveMerge; - - default = - { user - , group - , certPath - , plugins ? {} - , globalEnvs ? {} - , stats ? null - , debug ? false - , sites ? {} - , globals ? {} - , defaults ? {} - , resolvers ? {} - }: { - global = { - # Silence a warning issued by haproxy. Using 2048 - # instead of the default 1024 makes the connection stronger. - "tune.ssl.default-dh-param" = 2048; - - maxconn = 20000; - - inherit user group; - - log = "/dev/log local0 info"; - - inherit plugins; - - setenv = globalEnvs; - } // globals; - - defaults = { - log = "global"; - option = "httplog"; - - timeout = { - connect = "10s"; - client = "15s"; - server = "30s"; - queue = "100s"; - }; - } // defaults; - - frontend = { - http-to-https = { - mode = "http"; - bind = "*:80"; - rules = [ - { - redirect = true; - scheme = "https"; - code = 301; - condition = "!{ ssl_fc }"; - } - ]; - backend = {}; - }; - - https = ( - let - r = ( - [{ - mode = "http"; - bind = { - addr = "*:443"; - ssl = true; - crt = certPath; - }; - - http-request = { - set-header = [ - "X-Forwarded-Port %[dst_port]" - "X-Forwarded-For %[src]" - ]; - add-header = [ - "X-Forwarded-Proto https" - ]; - }; - - http-response = { - set-header = [ - ''Strict-Transport-Security "max-age=15552000; includeSubDomains; preload;"'' - ]; - }; - }] - ++ (mapAttrsToList (name: config: - assert assertHasAttr name ["frontend"] config; - (updateByPath ["frontend" "use_backend"] (x: [(nameValuePair name x)]) config).frontend - ) sites) - ++ (mapAttrsToList (name: config: - if (hasAttr "debugHeaders" config && (getAttr "debugHeaders" config) != null) then { - option = "httplog"; - http-request = { - capture = "req.hdrs len 512 if ${config.debugHeaders}"; - }; - log-format = ''"%ci:%cp [%tr] %ft [[%hr]] %hs %{+Q}r"''; - } else {} - ) sites) - ); - in - recursiveMerge r - ) - // optionalAttrs (debug) { - log-format = ''"%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r %sslv %sslc %[ssl_fc_cipherlist_str]"''; - }; - } // optionalAttrs (stats != null) - (let - stats_ = { - enable = true; - port = 8404; - uri = "/stats"; - refresh = "10s"; - prometheusUri = null; - hide-version = false; - } // stats; - in - { - stats = { - bind = "localhost:${toString stats_.port}"; - mode = "http"; - stats = { - enable = stats_.enable; - hide-version = stats_.hide-version; - uri = stats_.uri; - refresh = stats_.refresh; - }; - } // optionalAttrs (stats_.prometheusUri != null) { - http-request = [ - "use-service prometheus-exporter if { path ${stats_.prometheusUri} }" - ]; - }; - }); - - backend = - mapAttrs' (name: config: - assert assertMsg (hasAttr "backend" config) "no backend defined in config for site ${name}, found attr names: ${toString (attrNames config)}"; - nameValuePair name config.backend) - sites; - - inherit resolvers; - }; - - render = config: - concatStringsSep "\n" (augmentedContent "" schema [] config); -} diff --git a/modules/haproxy.nix b/modules/haproxy.nix deleted file mode 100644 index 566c32f..0000000 --- a/modules/haproxy.nix +++ /dev/null @@ -1,112 +0,0 @@ -{ config, pkgs, lib, ... }: - -let - cfg = config.shb.reverseproxy; -in -{ - options.shb.reverseproxy = { - enable = lib.mkEnableOption "selfhostblocks.reverseproxy"; - - sopsFile = lib.mkOption { - type = lib.types.path; - description = "Sops file location"; - example = "secrets/haproxy.yaml"; - }; - - domain = lib.mkOption { - description = lib.mdDoc "Domain to serve sites under."; - type = lib.types.str; - }; - - adminEmail = lib.mkOption { - description = lib.mdDoc "Admin email in case certificate retrieval goes wrong."; - type = lib.types.str; - }; - - sites = lib.mkOption { - description = lib.mdDoc "Sites to serve through the reverse proxy."; - type = lib.types.anything; - default = {}; - example = { - homeassistant = { - frontend = { - acl = { - acl_homeassistant = "hdr_beg(host) ha."; - }; - use_backend = "if acl_homeassistant"; - }; - backend = { - servers = [ - { - name = "homeassistant1"; - address = "127.0.0.1:8123"; - forwardfor = false; - balance = "roundrobin"; - check = { - inter = "5s"; - downinter = "15s"; - fall = "3"; - rise = "3"; - }; - httpcheck = "GET /"; - } - ]; - }; - }; - }; - }; - }; - - config = lib.mkIf cfg.enable { - networking.firewall.allowedTCPPorts = [ 80 443 ]; - - security.acme = { - acceptTerms = true; - certs."${cfg.domain}" = { - extraDomainNames = ["*.${cfg.domain}"]; - }; - defaults = { - email = cfg.adminEmail; - dnsProvider = "linode"; - dnsResolver = "8.8.8.8"; - group = config.services.haproxy.user; - # For example, to use Linode to prove the dns challenge, - # the content of the file should be the following, with - # XXX replaced by your Linode API token. - # LINODE_HTTP_TIMEOUT=10 - # LINODE_POLLING_INTERVAL=10 - # LINODE_PROPAGATION_TIMEOUT=240 - # LINODE_TOKEN=XXX - credentialsFile = "/run/secrets/linode"; - enableDebugLogs = false; - }; - }; - sops.secrets.linode = { - inherit (cfg) sopsFile; - restartUnits = [ "acme-${cfg.domain}.service" ]; - }; - - services.haproxy.enable = true; - - services.haproxy.config = let - configcreator = pkgs.callPackage ./haproxy-configcreator.nix {}; - in configcreator.render ( configcreator.default { - inherit (config.services.haproxy) user group; - - certPath = "/var/lib/acme/${cfg.domain}/full.pem"; - - stats = { - port = 8404; - uri = "/stats"; - refresh = "10s"; - prometheusUri = "/metrics"; - }; - - defaults = { - default-server = "init-addr last,none"; - }; - - inherit (cfg) sites; - }); - }; -} diff --git a/modules/home-assistant.nix b/modules/home-assistant.nix index 5cede7a..8aab26c 100644 --- a/modules/home-assistant.nix +++ b/modules/home-assistant.nix @@ -2,6 +2,8 @@ let cfg = config.shb.home-assistant; + + fqdn = "${cfg.subdomain}.${cfg.domain}"; in { options.shb.home-assistant = { @@ -66,7 +68,9 @@ in # https://www.home-assistant.io/integrations/default_config/ default_config = {}; http = { - use_x_forwarded_for = "true"; + use_x_forwarded_for = true; + server_host = "127.0.0.1"; + server_port = 8123; trusted_proxies = "127.0.0.1"; }; logger.default = "info"; @@ -110,6 +114,20 @@ in }; }; + services.nginx.virtualHosts."${fqdn}" = { + forceSSL = true; + http2 = true; + sslCertificate = "/var/lib/acme/${cfg.domain}/cert.pem"; + sslCertificateKey = "/var/lib/acme/${cfg.domain}/key.pem"; + extraConfig = '' + proxy_buffering off; + ''; + locations."/" = { + proxyPass = "http://${toString config.services.home-assistant.config.http.server_host}:${toString config.services.home-assistant.config.http.server_port}/"; + proxyWebsockets = true; + }; + }; + sops.secrets."home-assistant" = { inherit (cfg) sopsFile; mode = "0440"; @@ -125,32 +143,6 @@ in "f ${config.services.home-assistant.configDir}/scripts.yaml 0755 hass hass" ]; - shb.reverseproxy.sites.homeassistant = { - frontend = { - acl = { - acl_homeassistant = "hdr_beg(host) ${cfg.subdomain}."; - }; - use_backend = "if acl_homeassistant"; - }; - backend = { - servers = [ - { - name = "homeassistant1"; - address = "127.0.0.1:8123"; - forwardfor = false; - balance = "roundrobin"; - check = { - inter = "5s"; - downinter = "15s"; - fall = "3"; - rise = "3"; - }; - httpcheck = "GET /"; - } - ]; - }; - }; - shb.backup.instances.home-assistant = lib.mkIf (cfg.backupCfg != {}) ( cfg.backupCfg // { diff --git a/modules/jellyfin.nix b/modules/jellyfin.nix index 8bc80ab..0f5ad2b 100644 --- a/modules/jellyfin.nix +++ b/modules/jellyfin.nix @@ -2,10 +2,24 @@ let cfg = config.shb.jellyfin; + + fqdn = "${cfg.subdomain}.${cfg.domain}"; in { options.shb.jellyfin = { enable = lib.mkEnableOption "shb jellyfin"; + + subdomain = lib.mkOption { + type = lib.types.str; + description = "Subdomain under which home-assistant will be served."; + example = "jellyfin"; + }; + + domain = lib.mkOption { + description = lib.mdDoc "Domain to serve sites under."; + type = lib.types.str; + example = "domain.com"; + }; }; config = lib.mkIf cfg.enable { @@ -26,36 +40,111 @@ in }; }; - shb.reverseproxy.sites.jellyfin = { - frontend = { - acl = { - acl_jellyfin = "hdr_beg(host) jellyfin."; - acl_jellyfin_network_allowed = "src 127.0.0.1"; - acl_jellyfin_restricted_page = "path_beg /metrics"; - }; - http-request = { - deny = "if acl_jellyfin acl_jellyfin_restricted_page !acl_jellyfin_network_allowed"; - }; - use_backend = "if acl_jellyfin"; - }; - # TODO: enable /metrics and block from outside https://jellyfin.org/docs/general/networking/monitoring/#prometheus-metrics - backend = { - servers = [ - { - name = "jellyfin1"; - address = "127.0.0.1:8091"; - forwardfor = false; - balance = "roundrobin"; - check = { - inter = "5s"; - downinter = "15s"; - fall = "3"; - rise = "3"; - }; - httpcheck = "GET /health"; - } - ]; - }; + # Take advice from https://jellyfin.org/docs/general/networking/nginx/ and https://nixos.wiki/wiki/Plex + services.nginx.virtualHosts."${fqdn}" = { + addSSL = true; + 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; + + # Some players don't reopen a socket and playback stops totally instead of resuming after an extended pause + send_timeout 100m; + + # use a variable to store the upstream proxy + # in this example we are using a hostname which is resolved via DNS + # (if you aren't using DNS remove the resolver line and change the variable to point to an IP address e.g `set $jellyfin 127.0.0.1`) + set $jellyfin 127.0.0.1; + # resolver 127.0.0.1 valid=30; + + #include /etc/letsencrypt/options-ssl-nginx.conf; + #ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + #add_header Strict-Transport-Security "max-age=31536000" always; + #ssl_trusted_certificate /etc/letsencrypt/live/DOMAIN_NAME/chain.pem; + # Why this is important: https://blog.cloudflare.com/ocsp-stapling-how-cloudflare-just-made-ssl-30/ + ssl_stapling on; + ssl_stapling_verify on; + + # Security / XSS Mitigation Headers + # NOTE: X-Frame-Options may cause issues with the webOS app + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-XSS-Protection "0"; # Do NOT enable. This is obsolete/dangerous + add_header X-Content-Type-Options "nosniff"; + + # COOP/COEP. Disable if you use external plugins/images/assets + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + + # Permissions policy. May cause issues on some clients + add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), battery=(), bluetooth=(), camera=(), clipboard-read=(), display-capture=(), document-domain=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), keyboard-map=(), local-fonts=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always; + + # Tell browsers to use per-origin process isolation + add_header Origin-Agent-Cluster "?1" always; + + + # Content Security Policy + # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP + # Enforces https content and restricts JS/CSS to origin + # External Javascript (such as cast_sender.js for Chromecast) must be whitelisted. + # NOTE: The default CSP headers may cause issues with the webOS app + #add_header Content-Security-Policy "default-src https: data: blob: http://image.tmdb.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.gstatic.com/cv/js/sender/v1/cast_sender.js https://www.gstatic.com/eureka/clank/95/cast_sender.js https://www.gstatic.com/eureka/clank/96/cast_sender.js https://www.gstatic.com/eureka/clank/97/cast_sender.js https://www.youtube.com blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; frame-ancestors 'self'"; + + # From Plex: Plex has A LOT of javascript, xml and html. This helps a lot, but if it causes playback issues with devices turn it off. + gzip on; + gzip_vary on; + gzip_min_length 1000; + gzip_proxied any; + gzip_types text/plain text/css text/xml application/xml text/javascript application/x-javascript image/svg+xml; + gzip_disable "MSIE [1-6]\."; + + location = / { + return 302 http://$host/web/; + #return 302 https://$host/web/; + } + + location / { + # Proxy main Jellyfin traffic + proxy_pass http://$jellyfin:8096; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Protocol $scheme; + proxy_set_header X-Forwarded-Host $http_host; + + # Disable buffering when the nginx proxy gets very resource heavy upon streaming + proxy_buffering off; + } + + # location block for /web - This is purely for aesthetics so /web/#!/ works instead of having to go to /web/index.html/#!/ + location = /web/ { + # Proxy main Jellyfin traffic + proxy_pass http://$jellyfin:8096/web/index.html; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Protocol $scheme; + proxy_set_header X-Forwarded-Host $http_host; + } + + location /socket { + # Proxy Jellyfin Websockets traffic + proxy_pass http://$jellyfin:8096; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Protocol $scheme; + proxy_set_header X-Forwarded-Host $http_host; + } + ''; }; shb.backup.instances.jellyfin = { diff --git a/modules/monitoring.nix b/modules/monitoring.nix index d8df37c..dc27092 100644 --- a/modules/monitoring.nix +++ b/modules/monitoring.nix @@ -2,6 +2,8 @@ let cfg = config.shb.monitoring; + + fqdn = "${cfg.subdomain}.${cfg.domain}"; in { options.shb.monitoring = { @@ -12,6 +14,18 @@ in # description = "Sops file location"; # example = "secrets/monitoring.yaml"; # }; + + subdomain = lib.mkOption { + type = lib.types.str; + description = "Subdomain under which home-assistant will be served."; + example = "grafana"; + }; + + domain = lib.mkOption { + type = lib.types.str; + description = "domain under which home-assistant will be served."; + example = "mydomain.com"; + }; }; config = lib.mkIf cfg.enable { @@ -34,50 +48,66 @@ in services.grafana = { enable = true; - database = { - host = "/run/postgresql"; - user = "grafana"; - name = "grafana"; - type = "postgres"; - # Uses peer auth for local users, so we don't need a password. - # Here's the syntax anyway for future refence: - # password = "$__file{/run/secrets/homeassistant/dbpass}"; - }; - settings = { + database = { + host = "/run/postgresql"; + user = "grafana"; + name = "grafana"; + type = "postgres"; + # Uses peer auth for local users, so we don't need a password. + # Here's the syntax anyway for future refence: + # password = "$__file{/run/secrets/homeassistant/dbpass}"; + }; + server = { http_addr = "127.0.0.1"; http_port = 3000; + domain = fqdn; + root_url = "https://${fqdn}"; }; }; }; - shb.reverseproxy.sites.grafana = { - frontend = { - acl = { - acl_grafana = "hdr_beg(host) grafana."; + services.prometheus = { + enable = true; + port = 3001; + }; + + services.nginx = { + enable = true; + # recommendedProxySettings = true; + + virtualHosts.${fqdn} = { + addSSL = true; + sslCertificate = "/var/lib/acme/${cfg.domain}/cert.pem"; + sslCertificateKey = "/var/lib/acme/${cfg.domain}/key.pem"; + locations."/" = { + extraConfig = '' + proxy_set_header Host $host; + ''; + proxyPass = "http://${toString config.services.grafana.settings.server.http_addr}:${toString config.services.grafana.settings.server.http_port}/"; + proxyWebsockets = true; }; - use_backend = "if acl_grafana"; - }; - backend = { - servers = [ - { - name = "grafana1"; - address = "127.0.0.1:3000"; - forwardfor = true; - balance = "roundrobin"; - check = { - inter = "5s"; - downinter = "15s"; - fall = "3"; - rise = "3"; - }; - httpcheck = "GET /"; - } - ]; }; }; + services.prometheus.scrapeConfigs = + (lib.lists.optional config.services.nginx.enable { + job_name = "nginx"; + static_configs = [ + { + targets = ["127.0.0.1:9113"]; + } + ]; + }); + services.prometheus.exporters.nginx = lib.mkIf config.services.nginx.enable { + enable = true; + port = 9113; + listenAddress = "127.0.0.1"; + scrapeUri = "http://localhost:80/nginx_status"; + }; + services.nginx.statusPage = lib.mkDefault config.services.nginx.enable; + # sops.secrets."grafana" = { # inherit (cfg) sopsFile; # mode = "0440"; diff --git a/modules/nextcloud-server.nix b/modules/nextcloud-server.nix index 4ab8cd8..17ccfbc 100644 --- a/modules/nextcloud-server.nix +++ b/modules/nextcloud-server.nix @@ -2,15 +2,23 @@ let cfg = config.shb.nextcloud; + + fqdn = "${cfg.subdomain}.${cfg.domain}"; in { options.shb.nextcloud = { enable = lib.mkEnableOption "selfhostblocks.nextcloud-server"; - fqdn = lib.mkOption { + subdomain = lib.mkOption { type = lib.types.str; - description = "Fully qualified domain under which nextcloud will be served."; - example = "nextcloud.domain.com"; + description = "Subdomain under which home-assistant will be served."; + example = "nextcloud"; + }; + + domain = lib.mkOption { + description = lib.mdDoc "Domain to serve sites under."; + type = lib.types.str; + example = "domain.com"; }; sopsFile = lib.mkOption { @@ -41,7 +49,7 @@ in package = pkgs.nextcloud26; # Enable php-fpm and nginx which will be behind the shb haproxy instance. - hostName = cfg.fqdn; + hostName = fqdn; config = { dbtype = "pgsql"; @@ -63,8 +71,8 @@ in webfinger = true; extraOptions = { - "overwrite.cli.url" = "https://" + cfg.fqdn; - "overwritehost" = cfg.fqdn; + "overwrite.cli.url" = "https://" + fqdn; + "overwritehost" = fqdn; "overwriteprotocol" = "https"; "overwritecondaddr" = "^127\\.0\\.0\\.1$"; }; @@ -83,68 +91,11 @@ in group = "nextcloud"; }; - # The following changed the listen address for nginx and puts haproxy in front. See - # https://nixos.wiki/wiki/Nextcloud#Change_default_listening_port - # - # It's a bit of a waste in resources to have nginx behind haproxy but the config for nginx is - # complex enough that I find it better to re-use the one from nixpkgs instead of trying to copy - # it over to haproxy. At least for now. - services.nginx.virtualHosts.${cfg.fqdn}.listen = [ { addr = "127.0.0.1"; port = 8080; } ]; - shb.reverseproxy.sites.nextcloud = { - frontend = { - acl = { - acl_nextcloud = "hdr_beg(host) n."; - # well_known = "path_beg /.well-known"; - # caldav-endpoint = "path_beg /.well-known/caldav"; - # carddav-endpoint = "path_beg /.well-known/carddav"; - # webfinger-endpoint = "path_beg /.well-known/webfinger"; - # nodeinfo-endpoint = "path_beg /.well-known/nodeinfo"; - }; - http-request.set-header = { - "X-Forwarded-Host" = "%[req.hdr(host)]"; - "X-Forwarded-Port" = "%[dst_port]"; - }; - # http-request = [ - # "redirect code 301 location /remote.php/dav if acl_nextcloud caldav-endpoint" - # "redirect code 301 location /remote.php/dav if acl_nextcloud carddav-endpoint" - # "redirect code 301 location /public.php?service=webfinger if acl_nextcloud webfinger-endpoint" - # "redirect code 301 location /public.php?service=nodeinfo if acl_nextcloud nodeinfo-endpoint" - # ]; - # http-response = { - # set-header = { - # # These headers are from https://github.com/NixOS/nixpkgs/blob/d3bb401dcfc5a46ce51cdfb5762e70cc75d082d2/nixos/modules/services/web-apps/nextcloud.nix#L1167-L1173 - # X-Content-Type-Options = "nosniff"; - # X-XSS-Protection = "\"1; mode=block\""; - # X-Robots-Tag = "\"noindex, nofollow\""; - # X-Download-Options = "noopen"; - # X-Permitted-Cross-Domain-Policies = "none"; - # X-Frame-Options = "sameorigin"; - # Referrer-Policy = "no-referrer"; - # }; - # }; - use_backend = "if acl_nextcloud"; - }; - backend = { - servers = [ - { - name = "nextcloud1"; - address = - let - addrs = config.services.nginx.virtualHosts.${cfg.fqdn}.listen; - in - builtins.map (c: "${c.addr}:${builtins.toString c.port}") addrs; - forwardfor = true; - balance = "roundrobin"; - check = { - inter = "5s"; - downinter = "15s"; - fall = "3"; - rise = "3"; - }; - httpcheck = "GET /"; - } - ]; - }; + services.nginx.virtualHosts.${fqdn} = { + # listen = [ { addr = "0.0.0.0"; port = 443; } ]; + sslCertificate = "/var/lib/acme/${cfg.domain}/cert.pem"; + sslCertificateKey = "/var/lib/acme/${cfg.domain}/key.pem"; + addSSL = true; }; systemd.services.phpfpm-nextcloud.serviceConfig = { diff --git a/modules/ssl.nix b/modules/ssl.nix new file mode 100644 index 0000000..23ee793 --- /dev/null +++ b/modules/ssl.nix @@ -0,0 +1,61 @@ +{ config, pkgs, lib, ... }: + +let + cfg = config.shb.ssl; +in +{ + options.shb.ssl = { + enable = lib.mkEnableOption "selfhostblocks.ssl"; + + sopsFile = lib.mkOption { + type = lib.types.path; + description = "Sops file location"; + example = "secrets/haproxy.yaml"; + }; + + domain = lib.mkOption { + description = lib.mdDoc "Domain to serve sites under."; + type = lib.types.str; + example = "domain.com"; + }; + + adminEmail = lib.mkOption { + description = lib.mdDoc "Admin email in case certificate retrieval goes wrong."; + type = lib.types.str; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.${config.services.nginx.user} = { + isSystemUser = true; + group = "nginx"; + extraGroups = [ config.security.acme.defaults.group ]; + }; + users.groups.ngins = {}; + + security.acme = { + acceptTerms = true; + certs."${cfg.domain}" = { + extraDomainNames = ["*.${cfg.domain}"]; + }; + defaults = { + email = cfg.adminEmail; + dnsProvider = "linode"; + dnsResolver = "8.8.8.8"; + # For example, to use Linode to prove the dns challenge, + # the content of the file should be the following, with + # XXX replaced by your Linode API token. + # LINODE_HTTP_TIMEOUT=10 + # LINODE_POLLING_INTERVAL=10 + # LINODE_PROPAGATION_TIMEOUT=240 + # LINODE_TOKEN=XXX + credentialsFile = "/run/secrets/linode"; + enableDebugLogs = false; + }; + }; + sops.secrets.linode = { + inherit (cfg) sopsFile; + restartUnits = [ "acme-${cfg.domain}.service" ]; + }; + }; +}