nuci
Declarative OpenWrt UCI configuration compiler & SSH deployer.
Nix (writeUci) ──► uci.json ──► nuci compile ──► UCI Batch
│
├─► nuci diff (read-only)
└─► nuci deploy ──► SSH ──► Router
CLI Quick Reference
nuci compile ./uci.json --no-sops # Pure UCI compilation
nuci diff ./uci.json --target root@192.168.1.1 # Read-only diff
nuci deploy ./uci.json --target root@192.168.1.1 # Deploy with 60s watchdog
nix run .#example -- "root@192.168.1.1" --force # Flake one-shot deploy
Core Modules
- Architecture — 4-domain code layout, UCI idempotency, watchdog + boot hook, async reload.
- Nix Options — Complete option specification generated from
nix/module-options.nix. - Examples — Copy-paste Nix snippets.
Architecture
Project Layout
src/
config/ models.rs, uci_key.rs, validation.rs
compile/ generator.rs, pipeline.rs, secrets.rs
target/ deploy.rs, diff.rs
utils/ error.rs, helpers.rs
Single compilation seam: compile::pipeline::compile_config.
UCI Idempotency Strategy
- Named Section (
network.lan): Wiped viauci delete, rebuilt viauci set. Removed Nix options are deleted on target. - Anonymous List (
wireless.@wifi-iface[0]): Wiped viawhile uci -q delete config.@type[0]; do :; done, re-added sequentially. - Array Diff: Joined via
\u{1f}(Unit Separator) control character, making element reordering diff-neutral. - Libuci Protection: Automatically emits
touch /etc/config/<cfg>beforeuci batchto avoid silent file creation failures.
Anti-Brick Rollback System
| Layer | Trigger | Action |
|---|---|---|
| Layer A (In-Session) | Network loss / SSH timeout | Background watchdog (trap '' HUP; sleep 60). Killed on SSH handshake success. |
| Layer B (Boot-Time) | Power loss / Reboot mid-deploy | Init script S15nuci_rollback restores /etc/config from /etc/.uci-rollback-backup on boot, then self-deletes. |
Async Detached Reload
Avoids SSH exit status 255 (TCP Reset on interface/sshd restart) by running reloads in a background subshell:
( sleep 1; <reload_commands> ) >/dev/null 2>&1 &
The script exits 0 immediately, closing SSH cleanly before services restart.
Target Execution Requirements
- Text Files: POSIX
cat > path <<'NUCI_FILE_{i}_EOF'(quoted delimiter prevents shell expansion, zero base64 dependency). - Binary Files: Base64 decoded via
echo '<b64>' | base64 -d > path. - Root Password:
chpasswd <<'CHPWD'orpasswdfallback (POSIX heredoc). - Local Packages: Streamed via pure Rust
tar::Builderinto target/tmp/without host disk files.
Nix Module Options Specification
Verified 1:1 against nix/module-options.nix and nix/default.nix.
Options Reference
| Option | Type | Default | Description / Output Mapping |
|---|---|---|---|
uci.packageManager | enum [ "opkg" "apk" ] | "opkg" | Package backend (opkg ≤ 23.05, apk 24.10+). |
uci.settings | (pkgs.formats.json {}).type | {} | UCI configuration attrset (config → section → option). |
uci.secrets.sops.files | listOf path | [] | SOPS encrypted files decrypted in-memory at compile time. |
uci.packages | listOf str | [] | Packages to install. Prefix with - to remove (-pkg). |
uci.packageSources.feeds | listOf str | [] | Repository lines (/etc/opkg/customfeeds.conf or /etc/apk/repositories.d/customfeeds.list). |
uci.packageSources.localPackages | listOf (either str path) | [] | Local .ipk/.apk paths, streamed via in-memory tar to /tmp/. |
uci.sshKeys | listOf str | [] | Public keys deployed to /etc/dropbear/authorized_keys (0600). |
uci.watchdogTimeout | int | 60 | Rollback watchdog timeout in seconds. |
uci.rawUci | listOf str | [] | Raw UCI lines (must start with "uci "). Auto-touches missing /etc/config/<file>. |
uci.files | listOf (submodule) | [] | Custom file payloads (spec below). |
uci.files.* Submodule Options
| Option | Type | Default | Description |
|---|---|---|---|
path | str | (required) | Absolute destination path on target. |
content | nullOr str | null | Text content (written via POSIX cat heredoc). Empty string creates a zero-byte file. |
base64 | nullOr str | null | Base64 binary payload (decoded via base64 -d). Mutually exclusive with content. |
checksum | nullOr str | null | Expected SHA256 hex. Skips write if target hash matches. |
executable | bool | false | File mode (true → 0755, false → 0644). |
Nix Library Functions (nix/default.nix)
writeUci configuration
Evaluates Nix configuration module and returns:
json: Derivation generatinguci.json.command: Wrapper script executingnuci compileornuci deploy(forwards$@flags).
buildFirmware { configuration, profile, release ? null }
Combines nuci with nix-openwrt-imagebuilder:
- Bakes compiled UCI directives into
/etc/uci-defaults/99-nuci-bootstrap. - Inherits
settings,packages,packageSources,sshKeys,files, andrawUci.
Configuration Examples
Basic Network & Firewall
{
uci.settings = {
system.system = [ { _type = "system"; hostname = "rauter"; } ];
network = {
lan = { _type = "interface"; device = "br-lan"; proto = "dhcp"; };
wan = { _type = "interface"; proto = "pppoe"; username = "@wan_user@"; password = "@wan_pass@"; };
};
firewall.guest = { _type = "zone"; name = "guest"; network = [ "guest" ]; input = "REJECT"; output = "ACCEPT"; };
};
uci.watchdogTimeout = 120;
}
Wireless & SOPS Secrets
{
uci.settings.wireless = {
radio0 = { _type = "wifi-device"; type = "mac80211"; channel = "auto"; band = "2g"; };
default_radio0 = {
_type = "wifi-iface"; device = "radio0"; network = "lan";
mode = "ap"; ssid = "home-2.4"; encryption = "sae-mixed"; key = "@wifi_password@";
};
};
uci.secrets.sops.files = [ ./secrets.yml ];
}
Package Management & Feeds
{
uci.packageManager = "opkg"; # or "apk"
uci.packages = [ "-tcpdump" "htop" ];
uci.packageSources = {
feeds = [ "src/gz custom https://dl.openwrt.org/packages" ];
localPackages = [ "./packages/luci-app-custom_1.0_all.ipk" ];
};
}
Custom Files
{
uci.files = [
# Text File (POSIX cat heredoc)
{ path = "/etc/config.txt"; content = "key=value\n"; }
# Binary File (Base64 + SHA256 checksum guard)
{
path = "/usr/bin/blob";
base64 = "aGVsbG8=";
executable = true;
checksum = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
}
];
}
Escape Hatch (rawUci)
{
uci.rawUci = [
"uci rename network.lan=lan0"
"uci reorder wireless.@wifi-iface[0]=1"
];
}
Day-1 Firmware Build
exampleFirmware = uci.buildFirmware {
configuration = ./example.nix;
profile = "linksys_e8450-ubi";
};