# Pal Module # # Unified personal data infrastructure module for NixOS. # Sets up Syncthing (isolated) + Restic backup + directory structure. # # Usage: # services.pal.enable = true; # services.pal.user = "youruser"; # # This creates: # - ~/.config/pal/ directory structure # - Isolated Syncthing on port 8385 (separate from system Syncthing) # - Restic backup timer for pal data { config, lib, pkgs, ... }: with lib; let cfg = config.services.pal; home = config.users.users.${cfg.user}.home; palDir = "${home}/.config/pal"; in { options.services.pal = { enable = mkEnableOption "pal personal data infrastructure"; user = mkOption { type = types.str; description = "User to run pal services as."; example = "alice"; }; group = mkOption { type = types.str; default = "users"; description = "Group to run pal services as."; }; # Syncthing options syncthing = { enable = mkOption { type = types.bool; default = true; description = "Enable pal's isolated Syncthing instance."; }; guiPort = mkOption { type = types.port; default = 8385; description = "Port for Syncthing web GUI (separate from system Syncthing)."; }; syncPort = mkOption { type = types.port; default = 22001; description = "Port for Syncthing file sync (separate from system Syncthing)."; }; discoveryPort = mkOption { type = types.port; default = 21028; description = "Port for Syncthing local discovery."; }; openFirewall = mkOption { type = types.bool; default = true; description = "Open firewall for pal's Syncthing ports."; }; devices = mkOption { type = types.attrsOf (types.submodule { options = { id = mkOption { type = types.str; description = "Device ID."; }; name = mkOption { type = types.nullOr types.str; default = null; description = "Friendly name."; }; addresses = mkOption { type = types.listOf types.str; default = [ "dynamic" ]; description = "Connection addresses."; }; }; }); default = {}; description = "Devices to connect to."; }; extraFolders = mkOption { type = types.attrsOf (types.submodule { options = { path = mkOption { type = types.str; description = "Local path to sync."; }; devices = mkOption { type = types.listOf types.str; default = []; description = "Devices to share with."; }; type = mkOption { type = types.enum [ "sendreceive" "sendonly" "receiveonly" ]; default = "sendreceive"; description = "Sync type."; }; }; }); default = {}; description = "Additional folders to sync (beyond default notes/documents/dotfiles)."; }; }; # Backup options backup = { enable = mkOption { type = types.bool; default = false; description = "Enable restic backup of pal data."; }; repository = mkOption { type = types.str; default = ""; description = "Restic repository location (e.g., 'sftp:server:/backups/pal')."; example = "sftp:backup-server:/backups/pal"; }; passwordFile = mkOption { type = types.nullOr types.path; default = null; description = "Path to file containing restic repository password."; }; schedule = mkOption { type = types.str; default = "hourly"; description = "Backup schedule (systemd timer OnCalendar syntax)."; example = "*-*-* *:00:00"; }; extraPaths = mkOption { type = types.listOf types.str; default = []; description = "Additional paths to include in backup."; }; pruneOpts = mkOption { type = types.listOf types.str; default = [ "--keep-hourly 24" "--keep-daily 7" "--keep-weekly 4" "--keep-monthly 12" ]; description = "Restic prune/forget options."; }; }; # Server mount options server = { enable = mkOption { type = types.bool; default = false; description = "Enable server data mount."; }; type = mkOption { type = types.enum [ "nfs" "sshfs" "syncthing" ]; default = "syncthing"; description = "Type of server mount."; }; host = mkOption { type = types.str; default = ""; description = "Server hostname (for NFS/SSHFS)."; }; remotePath = mkOption { type = types.str; default = ""; description = "Path on server to mount."; }; }; }; config = mkIf cfg.enable { # Ensure user exists users.users.${cfg.user} = { isNormalUser = true; }; # Create pal directory structure systemd.tmpfiles.rules = [ "d ${palDir} 0755 ${cfg.user} ${cfg.group} -" "d ${palDir}/config-repo 0755 ${cfg.user} ${cfg.group} -" "d ${palDir}/syncthing/config 0755 ${cfg.user} ${cfg.group} -" "d ${palDir}/syncthing/db 0755 ${cfg.user} ${cfg.group} -" "d ${palDir}/sync 0755 ${cfg.user} ${cfg.group} -" "d ${palDir}/sync/notes 0755 ${cfg.user} ${cfg.group} -" "d ${palDir}/sync/documents 0755 ${cfg.user} ${cfg.group} -" "d ${palDir}/sync/dotfiles 0755 ${cfg.user} ${cfg.group} -" "d ${palDir}/restic/cache 0755 ${cfg.user} ${cfg.group} -" "d ${palDir}/server 0755 ${cfg.user} ${cfg.group} -" ]; # Isolated Syncthing for pal services.syncthing = mkIf cfg.syncthing.enable { enable = true; user = cfg.user; group = cfg.group; dataDir = "${palDir}/sync"; configDir = "${palDir}/syncthing/config"; guiAddress = "127.0.0.1:${toString cfg.syncthing.guiPort}"; overrideDevices = true; overrideFolders = true; settings = { devices = mapAttrs (name: device: { inherit (device) id addresses; name = if device.name != null then device.name else name; }) cfg.syncthing.devices; folders = { # Default pal folders "pal-notes" = { path = "${palDir}/sync/notes"; id = "pal-notes"; devices = attrNames cfg.syncthing.devices; type = "sendreceive"; fsWatcherEnabled = true; }; "pal-documents" = { path = "${palDir}/sync/documents"; id = "pal-documents"; devices = attrNames cfg.syncthing.devices; type = "sendreceive"; fsWatcherEnabled = true; }; "pal-dotfiles" = { path = "${palDir}/sync/dotfiles"; id = "pal-dotfiles"; devices = attrNames cfg.syncthing.devices; type = "sendreceive"; fsWatcherEnabled = true; }; } // (mapAttrs (name: folder: { inherit (folder) path type; id = name; devices = if folder.devices == [] then attrNames cfg.syncthing.devices else folder.devices; fsWatcherEnabled = true; }) cfg.syncthing.extraFolders); options = { urAccepted = -1; relaysEnabled = true; globalAnnounceEnabled = true; localAnnounceEnabled = true; localAnnouncePort = cfg.syncthing.discoveryPort; listenAddresses = [ "tcp://0.0.0.0:${toString cfg.syncthing.syncPort}" "quic://0.0.0.0:${toString cfg.syncthing.syncPort}" ]; }; }; }; # Firewall for pal Syncthing networking.firewall = mkIf (cfg.syncthing.enable && cfg.syncthing.openFirewall) { allowedTCPPorts = [ cfg.syncthing.syncPort ]; allowedUDPPorts = [ cfg.syncthing.syncPort cfg.syncthing.discoveryPort ]; }; # Restic backup service systemd.services.pal-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") { description = "Pal data backup"; wants = [ "network-online.target" ]; after = [ "network-online.target" ]; serviceConfig = { Type = "oneshot"; User = cfg.user; Group = cfg.group; ExecStart = let paths = [ "${palDir}/sync" ] ++ cfg.backup.extraPaths; pathArgs = concatMapStringsSep " " (p: "'${p}'") paths; in '' ${pkgs.restic}/bin/restic backup \ --cache-dir ${palDir}/restic/cache \ ${optionalString (cfg.backup.passwordFile != null) "--password-file ${cfg.backup.passwordFile}"} \ -r ${cfg.backup.repository} \ ${pathArgs} ''; ExecStartPost = '' ${pkgs.restic}/bin/restic forget \ --cache-dir ${palDir}/restic/cache \ ${optionalString (cfg.backup.passwordFile != null) "--password-file ${cfg.backup.passwordFile}"} \ -r ${cfg.backup.repository} \ ${concatStringsSep " " cfg.backup.pruneOpts} ''; }; }; systemd.timers.pal-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") { description = "Pal backup timer"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = cfg.backup.schedule; Persistent = true; RandomizedDelaySec = "5min"; }; }; # Server mount (NFS) fileSystems."${palDir}/server" = mkIf (cfg.server.enable && cfg.server.type == "nfs" && cfg.server.host != "") { device = "${cfg.server.host}:${cfg.server.remotePath}"; fsType = "nfs"; options = [ "x-systemd.automount" "noauto" "x-systemd.idle-timeout=600" "soft" "timeo=15" ]; }; # Install pal CLI and dependencies environment.systemPackages = with pkgs; [ syncthing restic age jq ]; }; }