Files
grapho/modules/grapho.nix
Brandon Lucas 117e6af528 Implement self-contained grapho architecture with four data types
Major rewrite of grapho CLI to support:
- Type 1 (Config): grapho init <repo-url> clones NixOS config
- Type 2 (Sync): Isolated Syncthing on port 8385 (separate from system)
- Type 3 (Backup): Restic integration with systemd timer
- Type 4 (Server): Mount point for central server data

New features:
- Welcome flow on first run (detects ~/.config/grapho/grapho.toml)
- grapho setup wizard creates directory structure
- grapho sync/backup/server subcommands
- grapho status shows all four data types
- grapho doctor checks system health

Added modules/grapho.nix NixOS module:
- Configures isolated Syncthing (ports 8385, 22001, 21028)
- Sets up grapho-backup systemd service and timer
- Creates directory structure via tmpfiles
- Optional NFS server mount

Updated flake.nix:
- Export grapho NixOS module
- Add grapho CLI package (nix build .#grapho)

Documented additional Lux language limitations:
- String == comparison broken in C backend
- let _ = pattern not supported
- List literals with recursion cause segfaults

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 06:12:58 -05:00

342 lines
10 KiB
Nix

# Grapho Module
#
# Unified personal data infrastructure module for NixOS.
# Sets up Syncthing (isolated) + Restic backup + directory structure.
#
# Usage:
# services.grapho.enable = true;
# services.grapho.user = "youruser";
#
# This creates:
# - ~/.config/grapho/ directory structure
# - Isolated Syncthing on port 8385 (separate from system Syncthing)
# - Restic backup timer for grapho data
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.grapho;
home = config.users.users.${cfg.user}.home;
graphoDir = "${home}/.config/grapho";
in {
options.services.grapho = {
enable = mkEnableOption "grapho personal data infrastructure";
user = mkOption {
type = types.str;
description = "User to run grapho services as.";
example = "alice";
};
group = mkOption {
type = types.str;
default = "users";
description = "Group to run grapho services as.";
};
# Syncthing options
syncthing = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable grapho'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 grapho'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 grapho data.";
};
repository = mkOption {
type = types.str;
default = "";
description = "Restic repository location (e.g., 'sftp:server:/backups/grapho').";
example = "sftp:backup-server:/backups/grapho";
};
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 grapho directory structure
systemd.tmpfiles.rules = [
"d ${graphoDir} 0755 ${cfg.user} ${cfg.group} -"
"d ${graphoDir}/config-repo 0755 ${cfg.user} ${cfg.group} -"
"d ${graphoDir}/syncthing/config 0755 ${cfg.user} ${cfg.group} -"
"d ${graphoDir}/syncthing/db 0755 ${cfg.user} ${cfg.group} -"
"d ${graphoDir}/sync 0755 ${cfg.user} ${cfg.group} -"
"d ${graphoDir}/sync/notes 0755 ${cfg.user} ${cfg.group} -"
"d ${graphoDir}/sync/documents 0755 ${cfg.user} ${cfg.group} -"
"d ${graphoDir}/sync/dotfiles 0755 ${cfg.user} ${cfg.group} -"
"d ${graphoDir}/restic/cache 0755 ${cfg.user} ${cfg.group} -"
"d ${graphoDir}/server 0755 ${cfg.user} ${cfg.group} -"
];
# Isolated Syncthing for grapho
services.syncthing = mkIf cfg.syncthing.enable {
enable = true;
user = cfg.user;
group = cfg.group;
dataDir = "${graphoDir}/sync";
configDir = "${graphoDir}/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 grapho folders
"grapho-notes" = {
path = "${graphoDir}/sync/notes";
id = "grapho-notes";
devices = attrNames cfg.syncthing.devices;
type = "sendreceive";
fsWatcherEnabled = true;
};
"grapho-documents" = {
path = "${graphoDir}/sync/documents";
id = "grapho-documents";
devices = attrNames cfg.syncthing.devices;
type = "sendreceive";
fsWatcherEnabled = true;
};
"grapho-dotfiles" = {
path = "${graphoDir}/sync/dotfiles";
id = "grapho-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 grapho 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.grapho-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") {
description = "Grapho data backup";
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
ExecStart = let
paths = [ "${graphoDir}/sync" ] ++ cfg.backup.extraPaths;
pathArgs = concatMapStringsSep " " (p: "'${p}'") paths;
in ''
${pkgs.restic}/bin/restic backup \
--cache-dir ${graphoDir}/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 ${graphoDir}/restic/cache \
${optionalString (cfg.backup.passwordFile != null) "--password-file ${cfg.backup.passwordFile}"} \
-r ${cfg.backup.repository} \
${concatStringsSep " " cfg.backup.pruneOpts}
'';
};
};
systemd.timers.grapho-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") {
description = "Grapho backup timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.backup.schedule;
Persistent = true;
RandomizedDelaySec = "5min";
};
};
# Server mount (NFS)
fileSystems."${graphoDir}/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 grapho CLI and dependencies
environment.systemPackages = with pkgs; [
syncthing
restic
age
jq
];
};
}