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}