main
1{ lib }:
2let
3 /**
4 Check if the given name matches the current hostname.
5
6 @param hostname The current hostname to compare against
7 @param n The name to check
8 @return true if n equals hostname, false otherwise
9 */
10 isCurrentHost = hostname: n: n == hostname;
11
12 /**
13 Check if a host has a VPN public key configured.
14
15 @param host The host configuration to check
16 @return true if host has a non-empty VPN public key, false otherwise
17 */
18 hasVPNPublicKey = host: (lib.attrsets.attrByPath [ "net" "vpn" "pubkey" ] "" host) != "";
19
20 /**
21 Check if a host has VPN IP addresses configured.
22
23 @param host The host configuration to check
24 @return true if host has at least one VPN IP address, false otherwise
25 */
26 hasVPNips = host: (builtins.length (lib.attrsets.attrByPath [ "net" "vpn" "ips" ] [ ] host)) > 0;
27
28 /**
29 Check if a host has network IP addresses configured.
30
31 @param host The host configuration to check
32 @return true if host has at least one VPN IP address, false otherwise
33 */
34 hasIps = host: (builtins.length (lib.attrsets.attrByPath [ "net" "ips" ] [ ] host)) > 0;
35
36 /**
37 Return true if the given host has a list of Syncthing folder configured.
38
39 @param host The host configuration to check
40 @return true if host has syncthing folders configured, false otherwise
41 */
42 hasSyncthingFolders =
43 host:
44 builtins.hasAttr "syncthing" host
45 && builtins.hasAttr "folders" host.syncthing
46 && (builtins.length (lib.attrsets.attrValues host.syncthing.folders)) > 0;
47
48 /**
49 Check if a host has SSH host keys configured.
50
51 @param host The host configuration to check
52 @return true if host has SSH host keys, false otherwise
53 */
54 hasSSHHostKeys = host: builtins.hasAttr "ssh" host && builtins.hasAttr "hostKey" host.ssh;
55
56 /**
57 Get the path for the given folder, either using the host specified path or the default one.
58
59 @param name The folder name
60 @param folder The folder configuration
61 @param folders The complete folders configuration
62 @return The path for the folder
63 */
64 syncthingFolderPath =
65 name: folder: folders:
66 lib.attrsets.attrByPath [ "path" ] folders."${name}".path folder;
67
68 /**
69 Filter machines with the given syncthing folder.
70
71 @param hostname The current hostname to exclude from results
72 @param folderName The folder name to filter by
73 @param machines The set of all machines
74 @return Filtered set of machines that have the specified folder and are not the current host
75 */
76 syncthingMachinesWithFolder =
77 hostname: folderName: machines:
78 lib.attrsets.filterAttrs (
79 name: value:
80 hasSyncthingFolders value
81 && !(isCurrentHost hostname name)
82 && (builtins.hasAttr folderName value.syncthing.folders)
83 ) machines;
84
85 /**
86 Generate Syncthing addresses for a machine from its network configuration.
87
88 @param machine The machine configuration
89 @return List of TCP addresses (ips, vpn ips, and names) prefixed with "tcp://"
90 */
91 generateSyncthingAdresses =
92 machine:
93 builtins.map (x: "tcp://${x}") (
94 lib.attrsets.attrByPath [ "net" "ips" ] [ ] machine
95 ++ lib.attrsets.attrByPath [ "net" "vpn" "ips" ] [ ] machine
96 ++ lib.attrsets.attrByPath [ "net" "names" ] [ ] machine
97 );
98
99 /**
100 Get SSH host identifiers for a machine (names, IPs, and VPN IPs).
101
102 @param machine The machine configuration
103 @return List of all network identifiers for the machine
104 */
105 sshHostIdentifier =
106 machine:
107 lib.attrsets.attrByPath [ "net" "names" ] [ ] machine
108 ++ lib.attrsets.attrByPath [ "net" "ips" ] [ ] machine
109 ++ lib.attrsets.attrByPath [ "net" "vpn" "ips" ] [ ] machine;
110
111 /**
112 Generate host configuration mapping IPs to appropriate hostnames.
113
114 @param machine The machine configuration
115 @return Attribute set mapping IP addresses to corresponding hostnames
116 */
117 hostConfig =
118 machine:
119 builtins.listToAttrs (
120 map
121 (x: {
122 name = x;
123 value =
124 if (lib.strings.hasPrefix "10.100" x) then
125 builtins.filter (n: lib.strings.hasSuffix ".vpn" n) machine.net.names
126 else if (lib.strings.hasPrefix "192.168" x) then
127 builtins.filter (n: lib.strings.hasSuffix ".home" n) machine.net.names
128 else
129 [ ];
130 })
131 (
132 lib.attrsets.attrByPath [ "net" "ips" ] [ ] machine
133 ++ lib.attrsets.attrByPath [ "net" "vpn" "ips" ] [ ] machine
134 )
135 );
136
137 /**
138 Generate SSH configuration for a machine.
139
140 @param machine The machine configuration
141 @return Attribute set of SSH host configurations with hostnames, identity settings, etc.
142 */
143 sshConfig =
144 machine:
145 builtins.listToAttrs (
146 map
147 (x: {
148 name = x;
149 value = {
150 hostname =
151 if (lib.strings.hasSuffix ".vpn" x) then
152 builtins.head machine.net.vpn.ips
153 else if (lib.strings.hasSuffix ".home" x) then
154 builtins.head machine.net.ips
155 else
156 # .sbr.pm uses the hostname directly (DNS resolution)
157 x;
158 user = machine.user or "vincent";
159 forwardAgent = false;
160 };
161 })
162 (
163 builtins.filter (
164 x:
165 (lib.strings.hasSuffix ".home" x)
166 || (lib.strings.hasSuffix ".vpn" x)
167 || (lib.strings.hasSuffix ".sbr.pm" x)
168 ) (sshHostIdentifier machine)
169 )
170 );
171
172 /**
173 Return a list of wireguard ips from a list of ips.
174
175 Essentially, it will append /32 to each element of the list.
176
177 @param ips List of IP addresses
178 @return List of IP addresses with /32 suffix for wireguard configuration
179 */
180 wg-ips = ips: builtins.map (x: "${x}/32") ips;
181
182 /**
183 Generate Wireguard peer configurations from a set of machines.
184
185 @param machines The set of all machines
186 @return List of wireguard peer configurations with allowedIPs and publicKey
187 */
188 generateWireguardPeers =
189 machines:
190 lib.attrsets.attrValues (
191 lib.attrsets.mapAttrs
192 (_name: value: {
193 allowedIPs = value.net.vpn.ips;
194 publicKey = value.net.vpn.pubkey;
195 })
196 (
197 lib.attrsets.filterAttrs (
198 name: value: name != "carthage" && (hasVPNPublicKey value) && (hasVPNips value)
199 ) machines
200 )
201 );
202
203 /**
204 Generate Syncthing folder configurations for the current machine.
205
206 @param hostname The current hostname
207 @param machine The current machine configuration
208 @param machines The set of all machines
209 @param folders The folder definitions
210 @return Attribute set of syncthing folder configurations
211 */
212 generateSyncthingFolders =
213 hostname: machine: machines: folders:
214 let
215 # Default ignore patterns applied to all folders unless overridden
216 defaultIgnores = [
217 "(?d).DS_Store" # macOS metadata files
218 "(?d).localized" # macOS localized folder names
219 "(?d)Thumbs.db" # Windows thumbnails
220 "(?d)desktop.ini" # Windows folder config
221 "*.tmp" # Temporary files
222 "~*" # Backup files
223 ".~lock.*" # LibreOffice lock files
224 ];
225 in
226 lib.attrsets.mapAttrs' (
227 name: value:
228 lib.attrsets.nameValuePair (syncthingFolderPath name value folders) {
229 inherit (folders."${name}") id;
230 label = name;
231 devices = lib.attrsets.mapAttrsToList (n: _v: n) (
232 syncthingMachinesWithFolder hostname name machines
233 );
234 rescanIntervalS = 3600 * 6; # TODO: make it configurable
235 # Apply default ignores if not specified in globals
236 ignores = folders."${name}".ignores or defaultIgnores;
237 # Pass through versioning configuration if present
238 versioning = folders."${name}".versioning or null;
239 }
240 ) (lib.attrsets.attrByPath [ "syncthing" "folders" ] { } machine);
241
242 /**
243 Generate Syncthing device configurations for all machines except the current one.
244
245 @param hostname The current hostname to exclude
246 @param machines The set of all machines
247 @return Attribute set of syncthing device configurations with IDs and addresses
248 */
249 generateSyncthingDevices =
250 hostname: machines:
251 lib.attrsets.mapAttrs
252 (_name: value: {
253 inherit (value.syncthing) id;
254 addresses = generateSyncthingAdresses value;
255 })
256 (
257 lib.attrsets.filterAttrs (
258 name: value: hasSyncthingFolders value && !(isCurrentHost hostname name)
259 ) machines
260 );
261
262 /**
263 Generate Syncthing GUI address for a machine.
264
265 @param machine The machine configuration
266 @return String in format "IP:8384" for accessing Syncthing GUI
267 */
268 syncthingGuiAddress =
269 machine:
270 (builtins.head (lib.attrsets.attrByPath [ "net" "vpn" "ips" ] [ "127.0.0.1" ] machine)) + ":8384";
271
272 /**
273 Generate SSH known_hosts entries for all machines with SSH host keys.
274
275 @param machines The set of all machines
276 @return String containing SSH known_hosts entries
277 */
278 sshKnownHosts =
279 machines:
280 lib.strings.concatStringsSep "\n" (
281 lib.attrsets.mapAttrsToList (
282 _name: value: "${lib.strings.concatStringsSep "," (sshHostIdentifier value)} ${value.ssh.hostKey}"
283 ) (lib.attrsets.filterAttrs (_name: hasSSHHostKeys) machines)
284 );
285
286 /**
287 Merge host configurations from all machines.
288
289 @param machines The set of all machines
290 @return Merged attribute set of all host configurations
291 */
292 hostConfigs =
293 machines: lib.attrsets.mergeAttrsList (lib.attrsets.mapAttrsToList (_name: hostConfig) machines);
294
295 /**
296 Generate and merge SSH configurations from all machines.
297
298 @param machines The set of all machines
299 @return Merged attribute set of all SSH configurations
300 */
301 sshConfigs =
302 machines:
303 lib.attrsets.mergeAttrsList (
304 lib.attrsets.mapAttrsToList (_name: sshConfig) (
305 lib.attrsets.filterAttrs (_name: _value: true) machines
306 )
307 );
308
309 /**
310 Create service defaults for media/homelab services.
311
312 Common pattern for services that run as a specific user/group with firewall access.
313
314 @param user The user to run the service as (default: "vincent")
315 @param group The group to run the service as (default: "users")
316 @param openFirewall Whether to open firewall for the service (default: true)
317 @return Attribute set with user, group, and openFirewall settings
318 */
319 mkServiceDefaults =
320 {
321 user ? "vincent",
322 group ? "users",
323 openFirewall ? true,
324 }:
325 {
326 inherit user group openFirewall;
327 };
328
329 /**
330 Render an authorized_keys list for a given host + account from the keyed SSH
331 registry (see globals.ssh.<user>).
332
333 Each registry entry is `{ key; access; }` where `access.<account>.<host>` (or
334 `access.<account>.default`) resolves to one of:
335 - "trusted" -> bare key (full, unrestricted login)
336 - { gated = "a"; } -> key prefixed with command="praetorian run a" + lockdown
337 - absent -> key omitted for that host/account
338 `access.<account>` may also be the string "trusted"/"absent" as a whole-account
339 shorthand for `.default`.
340
341 Every entry MUST declare a non-empty `access`; missing/invalid shapes throw at
342 eval time so an over-provisioned key can never silently grant access.
343
344 @param registry The keyed SSH registry (attrset label -> { key; access; })
345 @param host The current hostname
346 @param account The account being rendered ("vincent", "root", ...)
347 @return List of authorized_keys lines
348 */
349 authorizedKeysFor =
350 registry: host: account:
351 let
352 gateOpts = "no-pty,no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-user-rc";
353 resolveLeaf =
354 label: leaf: key:
355 if leaf == null || leaf == "absent" then
356 null
357 else if leaf == "trusted" then
358 key
359 else if builtins.isAttrs leaf && leaf ? gated then
360 ''command="praetorian run ${leaf.gated}",${gateOpts} ${key}''
361 else
362 throw ''ssh key '${label}': invalid access leaf for ${account}@${host} (expected "trusted", { gated = "alias"; }, or "absent")'';
363 resolveOne =
364 label: entry:
365 let
366 access =
367 if entry ? access && entry.access != { } then
368 entry.access
369 else
370 throw "ssh key '${label}': missing non-empty 'access'";
371 ua = access.${account} or null;
372 leaf =
373 if builtins.isString ua then
374 ua
375 else
376 (if ua == null then null else (ua.${host} or ua.default or null));
377 in
378 if ua == null then null else resolveLeaf label leaf entry.key;
379 in
380 builtins.filter (x: x != null) (lib.attrValues (lib.mapAttrs resolveOne registry));
381
382 /**
383 Create a Samba share configuration with common defaults.
384
385 Standard configuration for public, writable shares with guest access.
386
387 @param name The name of the share
388 @param path The filesystem path to share
389 @param user The user for force user/group (default: "vincent")
390 @param group The group for force user/group (default: "users")
391 @param readOnly Make the share read-only (default: false)
392 @return Attribute set with complete Samba share configuration
393 */
394 mkSambaShare =
395 {
396 name,
397 path,
398 user ? "vincent",
399 group ? "users",
400 readOnly ? false,
401 }:
402 {
403 inherit path;
404 public = "yes";
405 browseable = "yes";
406 "read only" = if readOnly then "yes" else "no";
407 "guest ok" = "yes";
408 writable = if readOnly then "no" else "yes";
409 comment = if readOnly then "${name} (read-only)" else name;
410 "create mask" = "0644";
411 "directory mask" = "0755";
412 "force user" = user;
413 "force group" = group;
414 };
415in
416{
417 inherit
418 syncthingFolderPath
419 hasSyncthingFolders
420 syncthingMachinesWithFolder
421 generateSyncthingAdresses
422 isCurrentHost
423 hasVPNPublicKey
424 hasVPNips
425 hasIps
426 hasSSHHostKeys
427 sshHostIdentifier
428 sshConfig
429 hostConfig
430 wg-ips
431 generateWireguardPeers
432 generateSyncthingFolders
433 generateSyncthingDevices
434 syncthingGuiAddress
435 sshKnownHosts
436 hostConfigs
437 sshConfigs
438 mkServiceDefaults
439 mkSambaShare
440 authorizedKeysFor
441 ;
442}