Skip to content

Commit 7fd82ed

Browse files
committed
feat(sops): add update-sops script and documentation
1 parent f747a26 commit 7fd82ed

5 files changed

Lines changed: 343 additions & 0 deletions

File tree

docs/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
- [Adding a new User](development/adding_a_new_user.md)
1515
- [Adding a new Host](development/adding_a_new_host.md)
16+
- [Updating SOPS Rules](development/updating_sops.md)
1617
- [Adding an External Package](development/adding_an_external_package.md)
1718
- [Declarative Gnome Dconf](development/declarative_gnome_dconf.md)
1819
- [Using a Package/Module from a Fork](development/using_a_nix_package_or_nixos_module_from_a_separate_fork_of_nixpkgs.md)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Updating SOPS Rules
2+
3+
`update-sops` first regenerates managed `sops-keys.nix`, then regenerates `.sops.yaml` from repository layout. Host and home entries are sorted by name for stable output. Run it after adding, removing, or rotating a host key or home user.
4+
5+
## Host Discovery
6+
7+
A host is discovered only when this file exists:
8+
9+
```text
10+
hosts/{device-type}/{hostname}/ssh_host_ed25519_key.pub
11+
```
12+
13+
The script converts each OpenSSH public key with `ssh-to-age` and creates rules for:
14+
15+
- `hosts/secrets.yaml` — every discovered host
16+
- `hosts/server/secrets.yaml` — every discovered server
17+
- `hosts/{device-type}/{hostname}/` — each discovered host directory
18+
19+
Host-specific rules also cover nested SOPS files under that host directory.
20+
21+
## Managed Key Map
22+
23+
`update-sops` writes `sops-keys.nix` as a readable inventory of discovered recipients:
24+
25+
```nix
26+
{
27+
deployer = "age1gmc8dd4mj5q0zncy5gq4lccjlq9v84t8cqnlananmxt8g0jezv6szawll8";
28+
homes = {
29+
racci = "...";
30+
};
31+
hosts = {
32+
server = {
33+
nixauth = "...";
34+
};
35+
};
36+
}
37+
```
38+
39+
Host recipients come from `ssh_host_ed25519_key.pub`. Home recipients come from `home/{username}/id_ed25519.pub`. The current user's name comes from `whoami`; that user's home recipient is used as the personal recipient in every generated SOPS rule. The current user must have a home public key file.
40+
41+
Every directory under `home/`, except `home/shared/`, gets a rule for:
42+
43+
```text
44+
home/{username}/secrets.yaml
45+
```
46+
47+
Home directories without `id_ed25519.pub` still get SOPS rules, but do not get an entry in `sops-keys.nix`.
48+
49+
## Common Recipients
50+
51+
Every generated rule starts with two always-present recipients, in this order:
52+
53+
1. current user's `home/{username}/id_ed25519.pub` converted with `ssh-to-age` — personal age key
54+
1. `sops-keys.nix` `deployer` value — automated deployer age key
55+
56+
The updater defines this policy in `get-always-present-age-keys`. Host-specific rules append that host's age recipient. Global host rules append all applicable host recipients in the same stable order used by `sops-keys.nix`.
57+
58+
## Usage
59+
60+
Regenerate rules:
61+
62+
```bash
63+
update-sops
64+
```
65+
66+
Check for drift without writing:
67+
68+
```bash
69+
update-sops --check
70+
```
71+
72+
By default, updater changes only `sops-keys.nix` and `.sops.yaml`. To also update recipient metadata in every encrypted SOPS file under `hosts/` and `home/`, opt in explicitly:
73+
74+
```bash
75+
update-sops --update-secrets
76+
```
77+
78+
This runs `sops updatekeys --yes` for each file reported as encrypted by `sops filestatus`, including non-YAML formats. It can re-encrypt file metadata and requires an available SOPS decryption identity. `--update-secrets` cannot be combined with `--check`.

flake/dev/scripts/default.nix

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,14 @@ in
2727
name = "update-redis-mappings";
2828
runtimeInputs = [ pkgs.lix ];
2929
};
30+
31+
update-sops = writeNuApplicationWithLibs {
32+
inherit pkgs;
33+
sourceRoot = ./.;
34+
name = "update-sops";
35+
runtimeInputs = [
36+
pkgs.sops
37+
pkgs.ssh-to-age
38+
];
39+
};
3040
}

flake/dev/scripts/update-sops.nu

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#!/usr/bin/env nu
2+
3+
use std/log
4+
use lib/flake.nu *
5+
6+
const SOPS_FILE = ".sops.yaml"
7+
const AGE_KEYS_FILE = "sops-keys.nix"
8+
const HOST_KEY_FILE = "ssh_host_ed25519_key.pub"
9+
const HOME_KEY_FILE = "id_ed25519.pub"
10+
const DEPLOYER_AGE_KEY = "age1gmc8dd4mj5q0zncy5gq4lccjlq9v84t8cqnlananmxt8g0jezv6szawll8"
11+
12+
# Rebuild sops-keys.nix, then rebuild .sops.yaml from repository layout.
13+
def main [
14+
--check
15+
--update-secrets
16+
--verbose
17+
] {
18+
log set-level (if $verbose { 10 } else { 20 })
19+
$env.NU_LOG_FORMAT = "%ANSI_START%%LEVEL%|%MSG%%ANSI_STOP%"
20+
21+
let hosts = discover-hosts
22+
let users = discover-users
23+
let homes = discover-home-keys $users
24+
let age_keys = {
25+
deployer: $DEPLOYER_AGE_KEY
26+
homes: $homes
27+
hosts: $hosts
28+
}
29+
let always_present_age_keys = get-always-present-age-keys $homes
30+
let rendered_keys = render-age-keys $age_keys
31+
let rendered_sops = render-config $hosts $users $always_present_age_keys
32+
let keys_path = ($env.GIT_ROOT | path join $AGE_KEYS_FILE)
33+
let sops_path = ($env.GIT_ROOT | path join $SOPS_FILE)
34+
let current_keys = if ($keys_path | path exists) { open --raw $keys_path } else { "" }
35+
let current_sops = if ($sops_path | path exists) { open --raw $sops_path } else { "" }
36+
let keys_changed = $current_keys != $rendered_keys
37+
let sops_changed = $current_sops != $rendered_sops
38+
39+
if $check and $update_secrets {
40+
log error "--check cannot be combined with --update-secrets"
41+
exit 1
42+
}
43+
44+
if not $keys_changed and not $sops_changed and not $update_secrets {
45+
log info $"($AGE_KEYS_FILE) and ($SOPS_FILE) are up to date"
46+
exit 0
47+
}
48+
49+
if $check {
50+
if $keys_changed { log error $"($AGE_KEYS_FILE) is out of date" }
51+
if $sops_changed { log error $"($SOPS_FILE) is out of date" }
52+
exit 1
53+
}
54+
55+
if $keys_changed {
56+
$rendered_keys | save --force $keys_path
57+
log info $"Updated ($AGE_KEYS_FILE)"
58+
}
59+
60+
if $sops_changed {
61+
$rendered_sops | save --force $sops_path
62+
log info $"Updated ($SOPS_FILE)"
63+
}
64+
65+
if $update_secrets {
66+
let secret_files = discover-sops-files
67+
update-secret-files $secret_files
68+
}
69+
70+
log info $"Processed ($hosts | length) hosts and ($users | length) users"
71+
}
72+
73+
def discover-hosts [] {
74+
let hosts_root = ($env.GIT_ROOT | path join "hosts")
75+
glob ($hosts_root | path join "*" "*" $HOST_KEY_FILE)
76+
| each {|key_path|
77+
let host_dir = ($key_path | path dirname)
78+
let relative_dir = ($host_dir | path relative-to $env.GIT_ROOT)
79+
{
80+
type: ($relative_dir | path dirname | path basename)
81+
name: ($host_dir | path basename)
82+
recipient: (convert-public-key $key_path)
83+
}
84+
}
85+
| sort-by type name
86+
}
87+
88+
def discover-users [] {
89+
let home_root = ($env.GIT_ROOT | path join "home")
90+
glob ($home_root | path join "*")
91+
| where {|user_dir|
92+
($user_dir | path type) == "dir" and ($user_dir | path basename) != "shared"
93+
}
94+
| each {|user_dir| $user_dir | path basename }
95+
| sort
96+
}
97+
98+
def discover-home-keys [users: list<string>] {
99+
mut homes = []
100+
for user in $users {
101+
let key_path = ($env.GIT_ROOT | path join "home" $user $HOME_KEY_FILE)
102+
if ($key_path | path exists) {
103+
$homes = $homes | append {
104+
name: $user
105+
recipient: (convert-public-key $key_path)
106+
}
107+
}
108+
}
109+
$homes | sort-by name
110+
}
111+
112+
# Recipients included in every SOPS creation rule, in stable priority order.
113+
def get-always-present-age-keys [homes] {
114+
let personal_age_key = get-personal-age-key $homes
115+
[
116+
{ name: "my-home-age-key", recipient: $personal_age_key }
117+
{ name: "deployer-age-key", recipient: $DEPLOYER_AGE_KEY }
118+
]
119+
}
120+
121+
def get-personal-age-key [homes] {
122+
let personal = $homes | where name == $env.CURRENT_USER
123+
if ($personal | is-empty) {
124+
let key_path = ($env.GIT_ROOT | path join "home" $env.CURRENT_USER $HOME_KEY_FILE)
125+
log error $"Missing ($key_path); cannot identify current user age key"
126+
exit 1
127+
}
128+
$personal | get recipient | first
129+
}
130+
131+
def discover-sops-files [] {
132+
cd $env.GIT_ROOT
133+
let candidates = ((glob "hosts/**") ++ (glob "home/**"))
134+
| where {|path| ($path | path type) == "file"}
135+
| sort
136+
137+
$candidates
138+
| each {|file|
139+
try {
140+
let status = ^sops filestatus $file err> /dev/null | from json
141+
if $status.encrypted { $file } else { null }
142+
} catch {
143+
null
144+
}
145+
}
146+
| where {|file| $file != null}
147+
}
148+
149+
def update-secret-files [secret_files: list<string>] {
150+
cd $env.GIT_ROOT
151+
if ($secret_files | is-empty) {
152+
log info "No encrypted SOPS files found"
153+
return
154+
}
155+
156+
for file in $secret_files {
157+
log info $"Updating SOPS recipients in ($file)"
158+
try {
159+
^sops updatekeys --yes $file
160+
} catch {|err|
161+
log error $"Failed to update SOPS recipients in ($file): ($err)"
162+
exit 1
163+
}
164+
}
165+
}
166+
167+
def convert-public-key [key_path: string] {
168+
try {
169+
let recipient = (open --raw $key_path | ^ssh-to-age | str trim)
170+
if ($recipient | is-empty) {
171+
error make {msg: $"No age recipient returned for ($key_path)"}
172+
}
173+
$recipient
174+
} catch {|err|
175+
log error $"Failed to convert public key ($key_path): ($err.msg)"
176+
exit 1
177+
}
178+
}
179+
180+
def render-age-keys [age_keys] {
181+
let home_entries = $age_keys.homes
182+
| each {|home| $" ($home.name) = \"($home.recipient)\";"}
183+
| str join "\n"
184+
let home_block = " homes = {\n" + $home_entries + "\n };\n"
185+
186+
let host_types = $age_keys.hosts | get type | sort | uniq
187+
mut host_blocks = []
188+
for host_type in $host_types {
189+
let host_entries = $age_keys.hosts
190+
| where type == $host_type
191+
| sort-by name
192+
| each {|host| $" ($host.name) = \"($host.recipient)\";"}
193+
| str join "\n"
194+
$host_blocks = $host_blocks | append (" " + $host_type + " = {\n" + $host_entries + "\n };")
195+
}
196+
let hosts_block = " hosts = {\n" + ($host_blocks | str join "\n") + "\n };\n"
197+
198+
let output = "{\n" + $" deployer = \"($age_keys.deployer)\";\n" + $home_block + $hosts_block + "}\n"
199+
$output
200+
}
201+
202+
def render-config [
203+
hosts: list<record<type: string, name: string, recipient: string>>
204+
users: list<string>
205+
always_present_age_keys: list<record<name: string, recipient: string>>
206+
] {
207+
let always_present_recipients = $always_present_age_keys | get recipient
208+
let all_host_keys = $hosts | get recipient
209+
let server_keys = $hosts | where type == "server" | get recipient
210+
mut rules = [
211+
(render-rule "hosts/secrets.yaml$" ($always_present_recipients ++ $all_host_keys))
212+
(render-rule "hosts/server/secrets.yaml$" ($always_present_recipients ++ $server_keys))
213+
]
214+
215+
for user in $users {
216+
$rules = $rules | append (render-rule $"home/($user)/secrets.yaml$" $always_present_recipients)
217+
}
218+
219+
for host in $hosts {
220+
$rules = $rules | append (render-rule $"hosts/($host.type)/($host.name)/" ($always_present_recipients ++ [ $host.recipient ]))
221+
}
222+
223+
let rendered_rules = $rules | str join "\n"
224+
$"creation_rules:\n($rendered_rules)\n"
225+
}
226+
227+
def render-rule [path_regex: string, recipients: list<string>] {
228+
let age_entries = $recipients
229+
| uniq
230+
| each {|recipient| $" - ($recipient)"}
231+
| str join "\n"
232+
233+
$" - path_regex: ($path_regex)\n key_groups:\n - age:\n($age_entries)"
234+
}

sops-keys.nix

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
deployer = "age1gmc8dd4mj5q0zncy5gq4lccjlq9v84t8cqnlananmxt8g0jezv6szawll8";
3+
homes = {
4+
racci = "age187xlhmks2tcymsnw32jzzxr6lryejma4602e3v0jlrsra5u64pdsxal44a";
5+
};
6+
hosts = {
7+
desktop = {
8+
nixmi = "age1sayjklnumzyva2zuszce0uplq59cvr9z9wvv7fw6t9m70qndjutshdp0zm";
9+
};
10+
server = {
11+
nixai = "age10emng003r0p06xudhz8w3846vr9dl6sw4pl8hflpd7p3sejv75ds8rnf2a";
12+
nixarr = "age1qlkycz667c4dzzhywgp94ek5zs9u5xmp4dfd8fh48h58c4253seqajrk49";
13+
nixcloud = "age1vn626zvqf9hhnwur37fzww0xqts5jp45uuhetk2caggx75wp4umshj235u";
14+
nixdev = "age1zeu2cj6mt0nllar0myzhlsgwp0xk2px83vskplu0s2ul87g9634svuhjpw";
15+
nixio = "age1azcacxqvwankaf5ma98jk7swwpn6uxmwvdz0azq5fnulrlwk747sr9x2z9";
16+
nixmon = "age1xct06gxt774vj3ug8j7z7j3vtnvwe29z92nh5al2xqa4uy2kxqjs67j5ay";
17+
nixserv = "age1hepu4nkwau3p0z2sf40xq0pzzss0wl263m5uey7lfj7usnr7sfeskdkeg4";
18+
};
19+
};
20+
}

0 commit comments

Comments
 (0)