From 8761dc2e9d1e9ed18c16a7cbbe5d185d7a7aab7f Mon Sep 17 00:00:00 2001 From: ibizaman Date: Thu, 22 Jun 2023 21:22:34 -0700 Subject: [PATCH] add flake with some modules --- flake.lock | 79 +++++ flake.nix | 22 ++ modules/backup.nix | 269 ++++++++++++++++ modules/haproxy-configcreator.nix | 492 ++++++++++++++++++++++++++++++ modules/haproxy.nix | 110 +++++++ modules/home-assistant.nix | 172 +++++++++++ modules/jellyfin.nix | 73 +++++ modules/nextcloud-server.nix | 162 ++++++++++ 8 files changed, 1379 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 modules/backup.nix create mode 100644 modules/haproxy-configcreator.nix create mode 100644 modules/haproxy.nix create mode 100644 modules/home-assistant.nix create mode 100644 modules/jellyfin.nix create mode 100644 modules/nextcloud-server.nix diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ce3a191 --- /dev/null +++ b/flake.lock @@ -0,0 +1,79 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1687412861, + "narHash": "sha256-Z/g0wbL68C+mSGerYS2quv9FXQ1RRP082cAC0Bh4vcs=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "e603dc5f061ca1d8a19b3ede6a8cf9c9fcba6cdc", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1687031877, + "narHash": "sha256-yMFcVeI+kZ6KD2QBrFPNsvBrLq2Gt//D0baHByMrjFY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e2e2059d19668dab1744301b8b0e821e3aae9c99", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "release-23.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1686979235, + "narHash": "sha256-gBlBtk+KrezFkfMrZw6uwTuA7YWtbFciiS14mEoTCo0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7cc30fd5372ddafb3373c318507d9932bd74aafe", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "sops-nix": "sops-nix" + } + }, + "sops-nix": { + "inputs": { + "nixpkgs": "nixpkgs_2", + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1687398569, + "narHash": "sha256-e/umuIKFcFtZtWeX369Hbdt9r+GQ48moDmlTcyHWL28=", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "2ff6973350682f8d16371f8c071a304b8067f192", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "sops-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9655e72 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "SelfHostBlocks module"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + sops-nix.url = "github:Mic92/sops-nix"; + }; + + outputs = inputs@{ self, nixpkgs, sops-nix, ... }: { + nixosModules.default = { config, ... }: { + imports = [ + modules/backup.nix + modules/jellyfin.nix + modules/haproxy.nix + modules/home-assistant.nix + modules/nextcloud-server.nix + ]; + }; + + # templates.default = {}; Would be nice to have a template + }; +} diff --git a/modules/backup.nix b/modules/backup.nix new file mode 100644 index 0000000..40c5425 --- /dev/null +++ b/modules/backup.nix @@ -0,0 +1,269 @@ +{ config, pkgs, lib, ... }: + +let + cfg = config.shb.backup; + + instanceOptions = { + backend = lib.mkOption { + description = "What program to use to make the backups."; + type = lib.types.enum [ "borgmatic" "restic" ]; + example = "borgmatic"; + }; + + keySopsFile = lib.mkOption { + description = "Sops file that holds this instance's Borgmatic repository key and passphrase."; + type = lib.types.path; + example = "secrets/backup.yaml"; + }; + + sourceDirectories = lib.mkOption { + description = "Borgmatic source directories."; + type = lib.types.nonEmptyListOf lib.types.str; + }; + + excludePatterns = lib.mkOption { + description = "Borgmatic exclude patterns."; + type = lib.types.listOf lib.types.str; + default = []; + }; + + repositories = lib.mkOption { + description = lib.mdDoc "Repositories to back this instance to."; + type = lib.types.nonEmptyListOf lib.types.str; + }; + + retention = lib.mkOption { + description = "Retention options."; + type = lib.types.attrsOf (lib.types.oneOf [ lib.types.int lib.types.nonEmptyStr ]); + default = { + keep_within = "1d"; + keep_hourly = 24; + keep_daily = 7; + keep_weekly = 4; + keep_monthly = 6; + }; + }; + + consistency = lib.mkOption { + description = "Consistency frequency options. Only applicable for borgmatic"; + type = lib.types.attrsOf lib.types.nonEmptyStr; + default = {}; + example = { + repository = "2 weeks"; + archives = "1 month"; + }; + }; + + hooks = lib.mkOption { + description = "Borgmatic hooks."; + default = {}; + type = lib.types.submodule { + options = { + before_backup = lib.mkOption { + description = "Hooks to run before backup"; + type = lib.types.listOf lib.types.str; + default = []; + }; + + after_backup = lib.mkOption { + description = "Hooks to run after backup"; + type = lib.types.listOf lib.types.str; + default = []; + }; + }; + }; + }; + + environmentFile = lib.mkOption { + type = lib.types.bool; + description = "Add environment file to be read by the systemd service."; + default = false; + example = true; + }; + }; +in +{ + options.shb.backup = { + onlyOnAC = lib.mkOption { + description = lib.mdDoc "Run backups only if AC power is plugged in."; + default = true; + example = false; + type = lib.types.bool; + }; + + user = lib.mkOption { + description = lib.mdDoc "Unix user doing the backups."; + type = lib.types.str; + default = "backup"; + }; + + group = lib.mkOption { + description = lib.mdDoc "Unix group doing the backups."; + type = lib.types.str; + default = "backup"; + }; + + instances = lib.mkOption { + description = lib.mdDoc "Each instance is a backup setting"; + default = {}; + type = lib.types.attrsOf (lib.types.submodule { + options = instanceOptions; + }); + }; + + borgServer = lib.mkOption { + description = lib.mdDoc "Add borgbackup package so external backups can use this server as a remote."; + default = false; + example = true; + type = lib.types.bool; + }; + }; + + config = lib.mkIf (cfg.instances != {}) ( + let + borgmaticInstances = lib.attrsets.filterAttrs (k: i: i.backend == "borgmatic") cfg.instances; + resticInstances = lib.attrsets.filterAttrs (k: i: i.backend == "restic") cfg.instances; + in + { + users.users = { + ${cfg.user} = { + name = cfg.user; + group = cfg.group; + home = "/var/lib/backup"; + createHome = true; + isSystemUser = true; + }; + }; + users.groups = { + ${cfg.group} = { + name = cfg.group; + }; + }; + + sops.secrets = + let + repoSlugName = name: builtins.replaceStrings ["/"] ["_"] (lib.strings.removePrefix "/" name); + + mkSopsSecret = name: instance: ( + [ + { + "${instance.backend}/${name}/passphrase" = { + sopsFile = instance.keySopsFile; + mode = "0440"; + owner = cfg.user; + group = cfg.group; + }; + } + ] ++ lib.optional ((lib.filter (lib.strings.hasPrefix "s3") instance.repositories) != []) { + "${instance.backend}/${name}/environmentfile" = { + sopsFile = instance.keySopsFile; + mode = "0440"; + owner = cfg.user; + group = cfg.group; + }; + } + ); + in + lib.mkMerge (lib.flatten (lib.attrsets.mapAttrsToList mkSopsSecret cfg.instances)); + + systemd.timers.borgmatic = lib.mkIf (borgmaticInstances != {}) { + timerConfig = { + OnCalendar = "hourly"; + }; + }; + + systemd.services.borgmatic = lib.mkIf (borgmaticInstances != {}) { + serviceConfig = { + User = cfg.user; + Group = cfg.group; + ExecStartPre = ""; # Do not sleep before starting. + ExecStart = [ "" "${pkgs.borgmatic}/bin/borgmatic --verbosity -1 --syslog-verbosity 1" ]; + # For borgmatic, since we have only one service, we need to merge all environmentFile + # from all instances. + EnvironmentFile = builtins.mapAttrsToList (name: value: value.environmentFile) cfg.instances; + }; + }; + + systemd.packages = lib.mkIf (borgmaticInstances != {}) [ pkgs.borgmatic ]; + environment.systemPackages = ( + lib.optionals cfg.borgServer [ pkgs.borgbackup ] + ++ lib.optionals (borgmaticInstances != {}) [ pkgs.borgbackup pkgs.borgmatic ] + ++ lib.optionals (resticInstances != {}) [ pkgs.restic ] + ); + + services.restic.backups = + let + repoSlugName = name: builtins.replaceStrings ["/" ":"] ["_" "_"] (lib.strings.removePrefix "/" name); + + mkRepositorySettings = name: instance: repository: { + "${name}_${repoSlugName repository}" = { + inherit (cfg) user; + inherit repository; + + paths = instance.sourceDirectories; + + passwordFile = "/run/secrets/${instance.backend}/${name}/passphrase"; + + initialize = true; + + timerConfig = { + OnCalendar = "hourly"; + RandomizedDelaySec = "5m"; + }; + + pruneOpts = lib.mapAttrsToList (name: value: + "--${builtins.replaceStrings ["_"] ["-"] name} ${builtins.toString value}" + ) instance.retention; + + backupPrepareCommand = lib.strings.concatStringsSep "\n" instance.hooks.before_backup; + + backupCleanupCommand = lib.strings.concatStringsSep "\n" instance.hooks.after_backup; + } // lib.attrsets.optionalAttrs (instance.environmentFile) { + environmentFile = "/run/secrets/${instance.backend}/${name}/environmentfile"; + }; + }; + + mkSettings = name: instance: builtins.map (mkRepositorySettings name instance) instance.repositories; + in + lib.mkMerge (lib.flatten (lib.attrsets.mapAttrsToList mkSettings resticInstances)); + + environment.etc = + let + mkSettings = name: instance: { + "borgmatic.d/${name}.yaml".text = lib.generators.toYAML {} { + location = + { + source_directories = instance.sourceDirectories; + repositories = instance.repositories; + } + // (lib.attrsets.optionalAttrs (builtins.length instance.excludePatterns > 0) { + excludePatterns = instance.excludePatterns; + }); + + storage = { + encryption_passcommand = "cat /run/secrets/borgmatic/${name}/passphrase"; + }; + + retention = instance.retention; + consistency.checks = + let + mkCheck = name: frequency: { + inherit name frequency; + }; + in + lib.attrsets.mapAttrsToList mkCheck instance.consistency; + + # hooks = lib.mkMerge [ + # lib.optionalAttrs (builtins.length instance.hooks.before_backup > 0) { + # inherit (instance.hooks) before_backup; + # } + # lib.optionalAttrs (builtins.length instance.hooks.after_backup > 0) { + # inherit (instance.hooks) after_backup; + # } + # ]; + }; + }; + in + lib.mkMerge (lib.attrsets.mapAttrsToList mkSettings borgmaticInstances); + }); +} diff --git a/modules/haproxy-configcreator.nix b/modules/haproxy-configcreator.nix new file mode 100644 index 0000000..5ce0050 --- /dev/null +++ b/modules/haproxy-configcreator.nix @@ -0,0 +1,492 @@ +{ 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 new file mode 100644 index 0000000..72976bb --- /dev/null +++ b/modules/haproxy.nix @@ -0,0 +1,110 @@ +{ config, pkgs, lib, ... }: + +let + cfg = config.shb.reverseproxy; +in +{ + options.shb.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.sites != {}) { + 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 new file mode 100644 index 0000000..b478681 --- /dev/null +++ b/modules/home-assistant.nix @@ -0,0 +1,172 @@ +{ config, pkgs, lib, ... }: + +let + cfg = config.shb.home-assistant; +in +{ + options.shb.home-assistant = { + enable = lib.mkEnableOption "selfhostblocks.home-assistant"; + + subdomain = lib.mkOption { + type = lib.types.str; + description = "Subdomain under which home-assistant will be served."; + example = "ha"; + }; + + sopsFile = lib.mkOption { + type = lib.types.path; + description = "Sops file location"; + example = "secrets/homeassistant.yaml"; + }; + + backupCfg = lib.mkOption { + type = lib.types.anything; + description = "Backup configuration for home-assistant"; + default = {}; + example = { + backend = "restic"; + repositories = []; + }; + }; + }; + + config = lib.mkIf cfg.enable { + services.home-assistant = { + enable = true; + # Find them at https://github.com/NixOS/nixpkgs/blob/master/pkgs/servers/home-assistant/component-packages.nix + extraComponents = [ + # Components required to complete the onboarding + "met" + "radio_browser" + ]; + configDir = "/var/lib/hass"; + # If you can't find a component in component-packages.nix, you can add them manually with something similar to: + # extraPackages = python3Packages: [ + # (python3Packages.simplisafe-python.overrideAttrs (old: rec { + # pname = "simplisafe-python"; + # version = "5b003a9fa1abd00f0e9a0b99d3ee57c4c7c16bda"; + # format = "pyproject"; + + # src = pkgs.fetchFromGitHub { + # owner = "bachya"; + # repo = pname; + # rev = "${version}"; + # hash = "sha256-Ij2e0QGYLjENi/yhFBQ+8qWEJp86cgwC9E27PQ5xNno="; + # }; + # })) + # ]; + config = { + # Includes dependencies for a basic setup + # https://www.home-assistant.io/integrations/default_config/ + default_config = {}; + http = { + use_x_forwarded_for = "true"; + trusted_proxies = "127.0.0.1"; + }; + logger.default = "info"; + homeassistant = { + country = "!secret country"; + latitude = "!secret latitude_home"; + longitude = "!secret longitude_home"; + time_zone = "America/Los_Angeles"; + }; + "automation ui" = "!include automations.yaml"; + "scene ui" = "!include scenes.yaml"; + "script ui" = "!include scripts.yaml"; + + "automation manual" = [ + { + alias = "Create Backup on Schedule"; + trigger = [ + { + platform = "time_pattern"; + minutes = "5"; + } + ]; + action = [ + { + service = "shell_command.delete_backups"; + data = {}; + } + { + service = "backup.create"; + data = {}; + } + ]; + mode = "single"; + } + ]; + + shell_command = { + delete_backups = "find ${config.services.home-assistant.configDir}/backups -type f -delete"; + }; + }; + }; + + sops.secrets."home-assistant" = { + inherit (cfg) sopsFile; + mode = "0440"; + owner = "hass"; + group = "hass"; + path = "${config.services.home-assistant.configDir}/secrets.yaml"; + restartUnits = [ "home-assistant.service" ]; + }; + + systemd.tmpfiles.rules = [ + "f ${config.services.home-assistant.configDir}/automations.yaml 0755 hass hass" + "f ${config.services.home-assistant.configDir}/scenes.yaml 0755 hass hass" + "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 + // { + sourceDirectories = [ + "${config.services.home-assistant.configDir}/backups" + ]; + + # No need for backup hooks as we use an hourly automation job in home assistant directly with a cron job. + } + ); + + users.groups = { + hass = { + members = [ "backup" ]; + }; + }; + + systemd.services.home-assistant.serviceConfig = { + # Setup permissions needed for backups, as the backup user is member of the hass group. + UMask = lib.mkForce "0027"; + StateDirectory = "hass"; + StateDirectoryMode = lib.mkForce "0750"; + SupplementaryGroups = [ config.users.groups.keys.name ]; + }; + }; +} diff --git a/modules/jellyfin.nix b/modules/jellyfin.nix new file mode 100644 index 0000000..8bc80ab --- /dev/null +++ b/modules/jellyfin.nix @@ -0,0 +1,73 @@ +{ config, lib, pkgs, ...}: + +let + cfg = config.shb.jellyfin; +in +{ + options.shb.jellyfin = { + enable = lib.mkEnableOption "shb jellyfin"; + }; + + config = lib.mkIf cfg.enable { + services.jellyfin.enable = true; + + networking.firewall = { + # from https://jellyfin.org/docs/general/networking/index.html, for auto-discovery + allowedUDPPorts = [ 1900 7359 ]; + }; + + users.groups = { + media = { + name = "media"; + members = [ "jellyfin" ]; + }; + jellyfin = { + members = [ "backup" ]; + }; + }; + + 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"; + } + ]; + }; + }; + + shb.backup.instances.jellyfin = { + sourceDirectories = [ + "/var/lib/jellyfin" + ]; + }; + + systemd.services.jellyfin.serviceConfig = { + # Setup permissions needed for backups, as the backup user is member of the jellyfin group. + UMask = lib.mkForce "0027"; + StateDirectoryMode = lib.mkForce "0750"; + }; + }; +} diff --git a/modules/nextcloud-server.nix b/modules/nextcloud-server.nix new file mode 100644 index 0000000..4ab8cd8 --- /dev/null +++ b/modules/nextcloud-server.nix @@ -0,0 +1,162 @@ +{ config, pkgs, lib, ... }: + +let + cfg = config.shb.nextcloud; +in +{ + options.shb.nextcloud = { + enable = lib.mkEnableOption "selfhostblocks.nextcloud-server"; + + fqdn = lib.mkOption { + type = lib.types.str; + description = "Fully qualified domain under which nextcloud will be served."; + example = "nextcloud.domain.com"; + }; + + sopsFile = lib.mkOption { + type = lib.types.path; + description = "Sops file location"; + example = "secrets/nextcloud.yaml"; + }; + }; + + config = lib.mkIf cfg.enable { + users.users = { + nextcloud = { + name = "nextcloud"; + group = "nextcloud"; + home = "/srv/data/nextcloud"; + isSystemUser = true; + }; + }; + + users.groups = { + nextcloud = { + members = [ "backup" ]; + }; + }; + + services.nextcloud = { + enable = true; + package = pkgs.nextcloud26; + + # Enable php-fpm and nginx which will be behind the shb haproxy instance. + hostName = cfg.fqdn; + + config = { + dbtype = "pgsql"; + adminuser = "root"; + adminpassFile = "/run/secrets/nextcloud/adminpass"; + # Not using dbpassFile as we're using socket authentication. + defaultPhoneRegion = "US"; + trustedProxies = [ "127.0.0.1" ]; + }; + 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; + + extraOptions = { + "overwrite.cli.url" = "https://" + cfg.fqdn; + "overwritehost" = cfg.fqdn; + "overwriteprotocol" = "https"; + "overwritecondaddr" = "^127\\.0\\.0\\.1$"; + }; + + phpOptions = { + # The OPcache interned strings buffer is nearly full with 8, bump to 16. + "opcache.interned_strings_buffer" = "16"; + }; + }; + + # Secret needed for services.nextcloud.config.adminpassFile. + sops.secrets."nextcloud/adminpass" = { + inherit (cfg) sopsFile; + mode = "0440"; + owner = "nextcloud"; + 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 /"; + } + ]; + }; + }; + + systemd.services.phpfpm-nextcloud.serviceConfig = { + # Setup permissions needed for backups, as the backup user is member of the jellyfin group. + UMask = lib.mkForce "0027"; + }; + + # Sets up backup for Nextcloud. + shb.backup.instances.nextcloud = { + sourceDirectories = [ + config.services.nextcloud.datadir + ]; + }; + }; +}