diff options
author | pennae <pennae.git@eno.space> | 2023-09-22 20:55:05 +0200 |
---|---|---|
committer | pennae <pennae.git@eno.space> | 2023-09-22 21:06:55 +0200 |
commit | 66c6d2c1dfd4b3ef222bb64d3ccef9be915e0895 (patch) | |
tree | 0dde64acbdf9aa61134cdf066723bd731101f767 /openwrt | |
download | dewclaw-66c6d2c1dfd4b3ef222bb64d3ccef9be915e0895.tar.gz dewclaw-66c6d2c1dfd4b3ef222bb64d3ccef9be915e0895.tar.xz dewclaw-66c6d2c1dfd4b3ef222bb64d3ccef9be915e0895.zip |
initial commit
without warranty of any kind, express or impliend
Diffstat (limited to 'openwrt')
-rw-r--r-- | openwrt/config_generation.sh | 95 | ||||
-rw-r--r-- | openwrt/default.nix | 214 | ||||
-rw-r--r-- | openwrt/etc.nix | 41 | ||||
-rw-r--r-- | openwrt/packages.nix | 58 | ||||
-rw-r--r-- | openwrt/uci.nix | 216 | ||||
-rw-r--r-- | openwrt/users.nix | 32 |
6 files changed, 656 insertions, 0 deletions
diff --git a/openwrt/config_generation.sh b/openwrt/config_generation.sh new file mode 100644 index 0000000..c8e9bfa --- /dev/null +++ b/openwrt/config_generation.sh @@ -0,0 +1,95 @@ +#!/bin/sh /etc/rc.common + +EXTRA_COMMANDS="apply commit" +START=99 + +_unregister_script() { + /etc/init.d/config_generation disable + rm /etc/init.d/config_generation +} + +_rollback() { + rm -rf /overlay/upper.dead + mv /overlay/upper /overlay/upper.dead + # this should never fail, unless something *else* is also mucking + # with overlayfs state. + if mv -T /overlay/upper.prev /overlay/upper; then + rm -rf /overlay/upper.dead + else + echo "rollback failed, check /overlay/upper.dead and recover!" >&2 + exit 1 + fi +} + +apply() { + if ! rm -rf /overlay/upper.prev/ \ + || ! cp -al /overlay/upper/ /overlay/upper.prev/ \ + || ! rm -rf /overlay/upper.prev/etc/ \ + || ! cp -a /overlay/upper/etc/ /overlay/upper.prev/ + then + echo "failed to snapshot old config" + rm -rf /overlay/upper.prev + exit 1 + fi + + if ! /etc/init.d/config_generation enable + then + echo "failed to schedule rollback" + rm -rf /overlay/upper.prev + exit 1 + fi + + # everything after this point may fail. if it does we'll simply roll back + # immediately and reboot. + + trap 'reboot &' EXIT + + log() { + printf "$LOG_FMT\n" "$*" + } + + if ! ( + set -e + + @deploy_steps@ + ) + then + _rollback + fi +} + +commit() { + if ! [ -e /overlay/upper.prev ]; then + exit 1 + fi + touch /tmp/.abort-rollback +} + +start() { + [ -d /overlay/upper.prev ] || { + _unregister_script + exit 0 + } + + local needs_rollback=true + local timeout=@rollback_timeout@ + + while [ $timeout -gt 0 ]; do + timeout=$(( timeout - 1 )) + [ -e /tmp/.abort-rollback ] && { + needs_rollback=false + rm /tmp/.abort-rollback + break + } + sleep 1 + done + + if $needs_rollback; then + _rollback + _unregister_script + reboot + else + rm -rf /overlay/upper.prev + _unregister_script + fi +} diff --git a/openwrt/default.nix b/openwrt/default.nix new file mode 100644 index 0000000..4bd00fb --- /dev/null +++ b/openwrt/default.nix @@ -0,0 +1,214 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.openwrt; + + devType = lib.types.submoduleWith { + specialArgs.pkgs = pkgs; + modules = [({ name, config, ... }: { + options = { + deploy = { + host = lib.mkOption { + type = lib.types.str; + default = name; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "root"; + visible = false; + description = '' + User name for SSH connections. Doesn't currently to anything useful considering + that we don't have any kind of `useSudo` option. + ''; + }; + + sshConfig = lib.mkOption { + type = with lib.types; attrsOf (oneOf [ str int bool path ]); + default = {}; + description = '' + SSH options to apply to connections, see {manpage}`ssh_config(5)`. + Notably these are *not* command-line arguments, although they *will* + be passed as `-o...` arguments. + ''; + }; + + rebootAllowance = lib.mkOption { + type = lib.types.ints.unsigned; + default = 60; + description = '' + How long to wait (in seconds) for the device to come back up. + The timer runs on the deploying host and starts when the device reboots. + ''; + }; + + rollbackTimeout = lib.mkOption { + type = lib.types.ints.unsigned; + default = 60; + description = '' + How long to wait (in seconds) before rolling back to the old configuration. + The timer runs on the device and starts once the device has completed its boot cycle. + + ::: {.warning} + Values under `20` will very likely cause spurious rollbacks. + ::: + ''; + }; + }; + + build = lib.mkOption { + type = lib.types.attrsOf lib.types.unspecified; + internal = true; + }; + + deploySteps = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: { + options = { + name = lib.mkOption { type = lib.types.str; default = name; }; + priority = lib.mkOption { type = lib.types.int; }; + + prepare = lib.mkOption { type = lib.types.lines; default = ""; }; + copy = lib.mkOption { type = lib.types.lines; default = ""; }; + apply = lib.mkOption { type = lib.types.lines; }; + }; + })); + internal = true; + default = {}; + }; + }; + + imports = [ + ./etc.nix + ./packages.nix + ./uci.nix + ./users.nix + ]; + + config = { + build.deploy = + let + steps = lib.sort (a: b: a.priority < b.priority) (lib.attrValues config.deploySteps); + prepare = lib.concatMapStringsSep "\n\n" (s: "# prepare ${s.name}\n${s.prepare}") steps; + copy = lib.concatMapStringsSep "\n\n" (s: "# copy ${s.name}\n${s.copy}") steps; + config_generation = pkgs.runCommand "config_generation.sh" { + src = ./config_generation.sh; + deploy_steps = '' + ${lib.concatMapStrings + (s: '' + # apply ${s.name} + log "running ${s.name} ..." + ${s.apply} + '') + steps} + + log 'rebooting device ...' + ''; + rollback_timeout = config.deploy.rollbackTimeout; + } '' + substitute "$src" "$out" \ + --subst-var deploy_steps \ + --subst-var rollback_timeout + chmod +x "$out" + ''; + timeout = config.deploy.rollbackTimeout + config.deploy.rebootAllowance; + sshOpts = + ''-o ControlPath="$TMP/cm" '' + + lib.escapeShellArgs + (lib.mapAttrsToList + (arg: val: "-o${arg}=${ + if val == true then "yes" + else if val == false then "no" + else toString val + }") + ({ + ControlMaster = "auto"; + User = config.deploy.user; + Hostname = config.deploy.host; + } // config.deploy.sshConfig) + ); + in + pkgs.writeShellScriptBin "deploy-${name}" '' + set -euo pipefail + shopt -s inherit_errexit + + BOLD='\e[1m' + PURP='\e[35m' + CYAN='\e[36m' + RED='\e[31m' + NORMAL='\e[0m' + + log() { + printf "$BOLD$PURP> %s$NORMAL\n" "$*" + } + log_err() { + printf "$BOLD$RED> %s$NORMAL\n" "$*" + } + + # generate a (reasonably) unique logger tag. mustn't be too long, + # or it'll be truncated and matching will fail. + TAG="apply_config_$$_$RANDOM" + + ssh() { + command ssh ${sshOpts} device "$@" + } + + scp() { + command scp -Op ${sshOpts} "$@" + } + + main() { + export TMP="$(umask 0077; mktemp -d)" + + trap ' + [ -e "$TMP/cm" ] && ssh -O exit 2>/dev/null || true + rm -rf "$TMP" + ' EXIT + + log 'preparing files' + ${prepare} + + log 'copying files' + scp ${config_generation} device:/etc/init.d/config_generation + ${copy} + + # apply the new config and wait for the box to go down via ssh connection + # timeout. + log 'applying config' + ssh ' + export LOG_FMT="'"$CYAN"'>> %s'"$NORMAL"'" + /etc/init.d/config_generation apply </dev/null 2>&1 \ + | logger -t '"$TAG" & + ssh 'logread -l9999 -f' | awk -v FS="$TAG: " '$2 { print $2 }' || true + + log 'waiting for device to return' + __DO_WAIT=1 timeout --foreground ${toString timeout}s "$0" || { + log_err 'configuration change failed, device will roll back and reboot' + exit 1 + } + + log 'new configuration applied' + } + + _wait() { + while ! ssh -oConnectTimeout=5 '/etc/init.d/config_generation commit'; do + sleep 1 + done + } + + case "''${__DO_WAIT:-}" in + "") main ;; + *) _wait ;; + esac + ''; + }; + })]; + }; + +in + +{ + options.openwrt = lib.mkOption { + type = lib.types.attrsOf devType; + default = {}; + }; +} diff --git a/openwrt/etc.nix b/openwrt/etc.nix new file mode 100644 index 0000000..8231125 --- /dev/null +++ b/openwrt/etc.nix @@ -0,0 +1,41 @@ +{ config, lib, ... }: + +let + cfg = config.etc; +in + +{ + options.etc = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: { + options = { + enable = lib.mkEnableOption "this `/etc` file" // { + default = true; + }; + + text = lib.mkOption { + type = lib.types.lines; + description = '' + Contents of the file. + ''; + }; + }; + })); + default = {}; + }; + + config = lib.mkIf (cfg != {}) { + deploySteps.etc = { + priority = 8000; + apply = + lib.concatStrings + (lib.mapAttrsToList + (name: file: lib.optionalString (file.enable) '' + ${lib.optionalString (dirOf name != ".") '' + mkdir -p ${lib.escapeShellArg (dirOf name)} + ''} + echo ${lib.escapeShellArg file.text} >${lib.escapeShellArg "/etc/${name}"} + '') + cfg); + }; + }; +} diff --git a/openwrt/packages.nix b/openwrt/packages.nix new file mode 100644 index 0000000..bf7b3a7 --- /dev/null +++ b/openwrt/packages.nix @@ -0,0 +1,58 @@ +{ pkgs, lib, config, ... }: + +let + deps = config.build.depsPackage; +in + +{ + options.packages = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = '' + Extra packages to install. These are merely names of packages available + to opkg through the package source lists configured on the device, it is + not currently possible to provide packages for installation without + configuring an opkg source first. + ''; + }; + + config = { + deploySteps.packages = { + priority = 9999; + copy = '' + scp ${deps} device:/tmp/deps-${deps.version}.ipk + ''; + apply = '' + if [ ${deps.version} != "$(opkg info ${deps.package_name} | grep Version | cut -d' ' -f2)" ]; then + opkg update + opkg install --autoremove --force-downgrade /tmp/deps-${deps.version}.ipk + fi + ''; + }; + + build.depsPackage = pkgs.runCommand "deps.ipk" rec { + package_name = ".extra-system-deps."; + version = builtins.hashString "sha256" (toString config.packages); + control = '' + Package: ${package_name} + Version: ${version} + Architecture: all + Description: extra system dependencies + ${lib.optionalString + (config.packages != []) + "Depends: ${lib.concatStringsSep ", " config.packages}" + } + ''; + passAsFile = [ "control" ]; + } '' + mkdir -p deps/control deps/data + cp $controlPath deps/control/control + echo 2.0 > deps/debian-binary + + alias tar='command tar --numeric-owner --group=0 --owner=0' + (cd deps/control && tar -czf ../control.tar.gz ./*) + (cd deps/data && tar -czf ../data.tar.gz .) + (cd deps && tar -zcf $out ./debian-binary ./data.tar.gz ./control.tar.gz) + ''; + }; +} diff --git a/openwrt/uci.nix b/openwrt/uci.nix new file mode 100644 index 0000000..1557797 --- /dev/null +++ b/openwrt/uci.nix @@ -0,0 +1,216 @@ +{ 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 + ]; +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>`. 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. + ''; + }; + }) + ]; + options = attrsOf (either scalar (listOf scalar)); + in + submodule { + freeformType = + # <config>.<name>=type -> config.type.name ... + # <config>.@<anonymous>=type -> config.type = [{ ... }] + attrsOf # config + (attrsOf # type + (either + (attrsOf options) # name ... + (listOf options) # [{ ... }] + )); + }; + 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 = + 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 + ) + ''; + }; + }; +} diff --git a/openwrt/users.nix b/openwrt/users.nix new file mode 100644 index 0000000..6e4f6fc --- /dev/null +++ b/openwrt/users.nix @@ -0,0 +1,32 @@ +{ config, lib, ... }: + +{ + options.users.root.hashedPassword = lib.mkOption { + type = lib.types.nullOr (lib.types.strMatching "[^\n:]*"); + default = null; + description = '' + Hashed password of the user. This should be either a disabled password + (e.g. `*` or `!`) or use MD5, SHA256, or SHA512. + ''; + }; + + config = { + deploySteps.rootPassword = lib.mkIf (config.users.root.hashedPassword != null) { + priority = 5000; + apply = '' + ( + umask 0077 + touch /tmp/.shadow + while IFS=: read name pw rest; do + if [ "$name" = root ]; then + echo "$name:"${lib.escapeShellArg config.users.root.hashedPassword}":$rest" + else + echo "$name:$pw:$rest" + fi + done </etc/shadow >>/tmp/.shadow + mv /tmp/.shadow /etc/shadow + ) + ''; + }; + }; +} |