- Rename grapho → pal across entire codebase (CLI, NixOS module, flake, docs, config paths) - Add `pal onboard` interactive setup wizard with 4 steps: device, config repo, sync, backups - Shows current setup summary when re-running onboard on an existing installation with warnings about what will change - Fix askConfirm/askInput to read from /dev/tty so interactive prompts work correctly - Remove || operator usage in askConfirm (not reliable in Lux) - Add pal package to nix develop shell - Document ~/.config/pal/ directory structure in README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
342 lines
10 KiB
Nix
342 lines
10 KiB
Nix
# 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
|
|
];
|
|
};
|
|
}
|