summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpennae <pennae.git@eno.space>2023-09-22 20:55:05 +0200
committerpennae <pennae.git@eno.space>2023-09-22 21:06:55 +0200
commit66c6d2c1dfd4b3ef222bb64d3ccef9be915e0895 (patch)
tree0dde64acbdf9aa61134cdf066723bd731101f767
downloaddewclaw-66c6d2c1dfd4b3ef222bb64d3ccef9be915e0895.tar.gz
dewclaw-66c6d2c1dfd4b3ef222bb64d3ccef9be915e0895.tar.xz
dewclaw-66c6d2c1dfd4b3ef222bb64d3ccef9be915e0895.zip
initial commit
without warranty of any kind, express or impliend
-rw-r--r--.gitignore1
-rw-r--r--README.md3
-rw-r--r--default.nix19
-rw-r--r--example/default.nix7
-rw-r--r--example/example.key7
-rw-r--r--example/example.nix106
-rw-r--r--example/secrets.yaml21
-rw-r--r--openwrt/config_generation.sh95
-rw-r--r--openwrt/default.nix214
-rw-r--r--openwrt/etc.nix41
-rw-r--r--openwrt/packages.nix58
-rw-r--r--openwrt/uci.nix216
-rw-r--r--openwrt/users.nix32
13 files changed, 820 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c4a847d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/result
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bc4036d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,3 @@
+# Declarative Imperative OpenWRT configs
+
+Or dewclaw, for short. It evals, ship it!
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..52f4000
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,19 @@
+{ pkgs ? import <nixpkgs> { config = {}; overlays = []; }
+, configuration
+}:
+
+let
+ evaluated = pkgs.lib.evalModules {
+ modules = [
+ ./openwrt
+ configuration
+ ];
+ specialArgs = {
+ inherit pkgs;
+ };
+ };
+in
+
+pkgs.lib.mapAttrs
+ (_: dev: dev.build.deploy)
+ evaluated.config.openwrt
diff --git a/example/default.nix b/example/default.nix
new file mode 100644
index 0000000..bec7622
--- /dev/null
+++ b/example/default.nix
@@ -0,0 +1,7 @@
+{ pkgs ? import <nixpkgs> {}
+}:
+
+import ../. {
+ inherit pkgs;
+ configuration = ./example.nix;
+}
diff --git a/example/example.key b/example/example.key
new file mode 100644
index 0000000..b909ea7
--- /dev/null
+++ b/example/example.key
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACBxkBCpST6VJEfjUBzm3dQlljOyLW5BS5PND8ivm/thDQAAAJj8yiBW/Mog
+VgAAAAtzc2gtZWQyNTUxOQAAACBxkBCpST6VJEfjUBzm3dQlljOyLW5BS5PND8ivm/thDQ
+AAAEBXKPGOexD2ynQZKK/w5WimXmtLHagt8JzotMGv1gKywHGQEKlJPpUkR+NQHObd1CWW
+M7ItbkFLk80PyK+b+2ENAAAAE2RoaXZhZWxAY2hjb3JkYWloa2gBAg==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/example/example.nix b/example/example.nix
new file mode 100644
index 0000000..ce399ee
--- /dev/null
+++ b/example/example.nix
@@ -0,0 +1,106 @@
+# example config for a qemu image of openwrt that is accessible
+# via port 2222 on localhost. the root password is set to `a`
+# and a few utilities are installed, otherwise the configuration
+# is a subset of the default config.
+#
+# to use this example run a squashfs image of openwrt
+# (eg https://downloads.openwrt.org/releases/22.03.5/targets/x86/64/openwrt-22.03.5-x86-64-generic-squashfs-combined.img.gz)
+# with something like
+#
+# qemu-system-x86_64 -M q35,accel=kvm \
+# -drive file=openwrt-22.03.5-x86-64-generic-squashfs-combined.img,id=d0,if=none,bus=0,unit=0 \
+# -device ide-hd,drive=d0,bus=ide.0 \
+# -nic user,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80
+#
+# and run `uci set network.lan.proto=dhcp; uci commit; reload_config`
+# from the serial console.
+#
+# age keys for sops are as follow:
+#
+# SOPS_AGE_KEY=AGE-SECRET-KEY-1292U9T04N6MJUK223038MD246X4G2K8GPDWHVHY09JVCLSRUS6TQ6988D9
+
+{
+ openwrt.example = {
+ deploy.host = "localhost";
+ deploy.sshConfig = {
+ Port = 2222;
+ NoHostAuthenticationForLocalhost = true;
+ IdentityFile = ./example.key;
+ };
+
+ packages = [ "losetup" "mount-utils" "coreutils-stat" "htop" ];
+ users.root.hashedPassword = "$6$n/dIMAV5QZyMp6UQ$fSvzsPZ8Vl1kzq9Mm3oQy81hxDkPqv04YPSlBOpqjMQKGu6xjcIuXrrfvf3Dcm8ea46oG8XtEPm6AViOFESF81";
+ etc."dropbear/authorized_keys".text = ''
+ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHGQEKlJPpUkR+NQHObd1CWWM7ItbkFLk80PyK+b+2EN example@key
+ '';
+
+ uci.sopsSecrets = ./secrets.yaml;
+ # leave the ucitrack and firewall packages as they are, retaining defaults if
+ # freshly installed. the firewall rules are verbose and ucitrack is mostly not
+ # necessary, so we don't want to include either here. we also keep luci to not
+ # break the web interface, although configuration through the web ui is discouraged.
+ # rpcd is needed for luci.
+ uci.retain = [ "ucitrack" "firewall" "luci" "rpcd" ];
+ uci.settings = {
+ dropbear.dropbear = [{
+ PasswordAuth = "on";
+ RootPasswordAuth = "on";
+ Port = 22;
+ }];
+
+ network = {
+ device = [{
+ name = "br-lan";
+ ports = "eth0";
+ type = "bridge";
+ }];
+
+ globals = [{
+ ula_prefix = "fd10:155d:7ef5::/48";
+ }];
+
+ interface.lan = {
+ device = "br-lan";
+ proto = "dhcp";
+ };
+
+ interface.loopback = {
+ device = "lo";
+ ipaddr = "127.0.0.1";
+ netmask = "255.0.0.0";
+ proto = "static";
+ };
+ };
+
+ uhttpd.uhttpd.main = {
+ listen_http = [ "0.0.0.0:80" "[::]:80" ];
+ lua_prefix = [ "/cgi-bin/luci=/usr/lib/lua/luci/sgi/uhttpd.lua" ];
+ home = "/www";
+ cgi_prefix = "/cgi-bin";
+ ubus_prefix = "/ubus";
+ };
+
+ system = {
+ system = [{
+ hostname = "OpenWrt";
+ timezone = "UTC";
+ ttylogin = 0;
+ log_size = 64;
+ urandom_seed = 0;
+ notes._secret = "notes";
+ }];
+
+ timeserver.ntp = {
+ enabled = true;
+ enable_server = false;
+ server = [
+ "0.openwrt.pool.ntp.org"
+ "1.openwrt.pool.ntp.org"
+ "2.openwrt.pool.ntp.org"
+ "3.openwrt.pool.ntp.org"
+ ];
+ };
+ };
+ };
+ };
+}
diff --git a/example/secrets.yaml b/example/secrets.yaml
new file mode 100644
index 0000000..c39d0a8
--- /dev/null
+++ b/example/secrets.yaml
@@ -0,0 +1,21 @@
+notes: ENC[AES256_GCM,data:n0mIh8xH33lfehAl8hVtaT01Ge4dUOYI9r4=,iv:YtDWrrLWL0PHADc5mQi2XGjUh2XJLuESuv2Hz61JcmU=,tag:Y/1nhr3iBwFxUrNoeEIKow==,type:str]
+sops:
+ kms: []
+ gcp_kms: []
+ azure_kv: []
+ hc_vault: []
+ age:
+ - recipient: age1megd5nhnhr03x5syey7ualp0sgk72j0gskjvc7jtj052pd28ndtsalql9m
+ enc: |
+ -----BEGIN AGE ENCRYPTED FILE-----
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZVC82aTZJMGhBV0JTM3Rx
+ SkEreDYwTmFENWpkNThLK09DblNDS09HVVFvClQ2dkIrOUt6Zm5wSjZVbWdkeFdw
+ Sk9nRTFobTJ5LzZUYllIS3VmR1VMTE0KLS0tIExLZnAwaURkODN5VjE5TXNCNStT
+ dVdFSUdTTVIxa1ZCdEc4ZS9iYnJkZTQKFdpjkFFIXEV0/V6twZBwh5CszlvSQNVb
+ STPm1i2CMHanYSORzZMWfwahZII2D7hy8oRdM6TxULYvPqktgj8+Ew==
+ -----END AGE ENCRYPTED FILE-----
+ lastmodified: "2023-09-22T18:51:22Z"
+ mac: ENC[AES256_GCM,data:SpQt2upVEbXNjguWcBcn907M0Y8autst7ptJZhE62m81AFv60ziZYdslRm7a5r2nqaG38PVTJSTvvfsi25zw/Gcfc0bTDl+PxU3Ew4Bt2Nq7M8HWOmhZZrCb4N1N0uePhjEIOsw0dY2BYc77BW+C2DDOIiF3vjGLLmfO2naWk98=,iv:uEY7MMzi+Ekfrmw5ygShypr7QFpNoh0/R987DhgmkvU=,tag:zdSH0CPmU1KolNHaIRRrWQ==,type:str]
+ pgp: []
+ unencrypted_suffix: _unencrypted
+ version: 3.7.3
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
+ )
+ '';
+ };
+ };
+}