{ pkgs, lib, config, ... }: let cfg = config.uci; formatConfig = nix: lib.concatStringsSep "\n" (lib.flatten (lib.mapAttrsToList (config: sections: [ "package ${config}" (lib.mapAttrsToList formatSections sections) ]) nix)); formatSections = type: sections: if lib.isAttrs sections then lib.mapAttrsToList (name: vals: [ "config ${type} ${formatScalar name}" (formatSection vals) ]) sections else map (vals: [ "config ${type}" (formatSection vals) ]) sections; formatSection = lib.mapAttrsToList (option: value: if lib.isList value then map (value: " list ${option} ${formatScalar value}") value else " option ${option} ${formatScalar value}"); formatScalar = val: if lib.isBool val then (if val then "'1'" else "'0'") else if lib.isInt val then "'${toString val}'" else if lib.isAttrs val then "'${secretName val._secret}'" else "'${lib.replaceStrings [ "'" ] [ "'\\''" ] val}'"; secretName = sec: "@secret_${sec}_${builtins.hashString "sha256" sec}@"; collectSecrets = nix: lib.pipe nix [ lib.attrValues (lib.concatMap lib.attrValues) (lib.concatMap (s: if lib.isAttrs s then lib.attrValues s else s)) (lib.concatMap lib.attrValues) (lib.concatMap lib.toList) (lib.concatMap (v: if v ? _secret then [{ name = v._secret; value = {}; }] else [])) lib.listToAttrs lib.attrNames ]; uciIdentifierCheck = type: attrs: let invalid = lib.filter (n: builtins.match "[a-zA-Z0-9_]+" n == null) (lib.attrNames attrs); in lib.warnIf (invalid != []) ("Invalid UCI ${type} names found: ${toString invalid}") (invalid == []); in { options.uci = { secretsCommand = lib.mkOption { type = lib.types.path; default = pkgs.writeScript "no-secrets" "echo '{}'"; description = '' Command to retrieve secrets. Must be an executable command that returns a JSON object on `stdout`, with secret names as keys and string values. ''; }; sopsSecrets = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = '' sops secrets file. This as a shorthand for setting {option}`secretsCommand` to a script that calls `sops -d `. Path semantics apply: if the given path is a path literal it is copied into the store and the resulting absolute path is used, otherwise the given path is used verbatim in the generated script. ''; }; settings = lib.mkOption { type = with lib.types; let scalar = oneOf [ str int bool (submodule { options._secret = lib.mkOption { type = str; description = '' Name of the secret to insert into the config from data exported by {option}`secretsCommand`. Secrets are always interpolated as strings, which uci allows for scalars. Lists cannot currently be made entirely secret, only individual values of lists can. ''; }; }) ]; uciAttrsOf = type: elem: addCheck (attrsOf elem) (uciIdentifierCheck type); options = uciAttrsOf "option" (either scalar (listOf scalar)); in submodule { freeformType = # .=type -> config.type.name ... # .@=type -> config.type = [{ ... }] attrsOf # config (attrsOf # type (either (uciAttrsOf "section" options) # name ... (listOf options) # [{ ... }] )) // { description = "UCI config"; }; }; default = {}; description = '' UCI settings in hierarchical representation. The toplevel key of this set denotes a UCI package, the second level the type of section, and the third level may be either a list of anonymous setions or a set of named sections. Packages defined here will replace existing settings on the system entirely, no merging with existing configuration is done. ''; example = { network = { interface.loopback = { device = "lo"; proto = "static"; ipaddr = "127.0.0.1"; netmask = "255.0.0.0"; }; globals = [{ ula_prefix = "fdb8:155d:7ef5::/48"; }]; }; }; }; retain = lib.mkOption { type = lib.types.listOf lib.types.str; default = []; example = [ "ucitrack" ]; description = '' UCI package configuration to retain. Packages listed here will not have preexisting configuration deleted during deployment, even if no matching {option}`settings` are defined. ''; }; }; config = { uci.secretsCommand = lib.mkIf (cfg.sopsSecrets != null) (pkgs.writeShellScript "sops" '' ${pkgs.sops}/bin/sops --output-type json -d ${lib.escapeShellArg "${cfg.sopsSecrets}"} ''); build.configFile = pkgs.writeText "config" (formatConfig cfg.settings); deploySteps.uciConfig = # correctness of config identifiers can't be checked on the type level # because submodules are weird sometimes, so we have to do it here. assert uciIdentifierCheck "config" cfg.settings; let cfgName = baseNameOf config.build.configFile; jq = "${pkgs.jq}/bin/jq"; configured = lib.attrNames config.uci.settings ++ config.uci.retain; in { priority = 4999; prepare = '' cp --no-preserve=all ${config.build.configFile} "$TMP" ( umask 0077 C="$TMP"/${cfgName} S="$TMP"/secrets ${cfg.secretsCommand} > "$S" [ "$(${jq} -r type <"$S")" == "object" ] || { log_err "secrets command did not produce an object" exit 1 } ${lib.concatMapStrings (secret: let arg = lib.escapeShellArg secret; in '' has="$(${jq} -r --arg s ${arg} 'has($s)' <"$S")" $has || { log_err secret ${arg} not defined exit 1 } ${pkgs.replace-secret}/bin/replace-secret \ ${lib.escapeShellArg (secretName secret)} \ <(${jq} -r --arg s ${arg} '.[$s]'" | tostring | sub(\"'\"; \"'\\\\'''\")" <"$S") \ "$C" '') (collectSecrets cfg.settings)} ) ''; copy = '' scp "$TMP"/${cfgName} device:/tmp/ ''; apply = '' uci import < /tmp/${cfgName} uci commit ( cd /etc/config for cfg in *; do case "$cfg" in ${lib.optionalString (configured != []) '' ${lib.concatMapStringsSep "|" lib.escapeShellArg configured}) : ;; ''} *) rm "$cfg" ;; esac done ) ''; }; }; }