From fe40518a2d2d30f29b8b668c42ce2afa059d95a8 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Sat, 10 Jan 2026 21:57:37 +0000 Subject: feat: support multiple ngit-grasp instances in NixOS module - Convert module from single service to attrsOf instances - Each instance gets separate systemd service: ngit-grasp- - Each instance gets separate user: ngit-grasp- (customizable) - Default dataDir per instance: /var/lib/ngit-grasp- - Update example to show single and multiple instance configs - Add notes on systemd service management per instance --- nix/example-configuration.nix | 111 +++++++-- nix/module.nix | 539 ++++++++++++++++++++++-------------------- 2 files changed, 382 insertions(+), 268 deletions(-) diff --git a/nix/example-configuration.nix b/nix/example-configuration.nix index a00d970..34615be 100644 --- a/nix/example-configuration.nix +++ b/nix/example-configuration.nix @@ -1,4 +1,4 @@ -# Example NixOS configuration using ngit-grasp module +# Example NixOS configurations using ngit-grasp module # # Usage: # 1. Add to your server's flake.nix inputs: @@ -7,48 +7,54 @@ # 2. Import the module in your configuration: # imports = [ inputs.ngit-grasp.nixosModules.default ]; # -# 3. Configure the service (example below) +# 3. Configure one or more instances (examples below) { inputs, ... }: { imports = [ inputs.ngit-grasp.nixosModules.default ]; - services.ngit-grasp = { + # ============================================================================ + # EXAMPLE 1: Single Instance Configuration + # ============================================================================ + + services.ngit-grasp.production = { enable = true; domain = "ngit.danconwaydev.com"; - + # Network bindAddress = "127.0.0.1"; - port = 8082; # Same port as current ngit-relay for Caddy compatibility - - # Storage (reuse existing persistent path pattern) + port = 8082; + + # Storage dataDir = "/persistent/ngit-danconwaydev-com-ngit-grasp"; - + # Identity relayName = "DanConwayDev's ngit-grasp"; - relayDescription = "personal instance of ngit-grasp, a Rust GRASP implementation with proactive sync"; - + relayDescription = + "personal instance of ngit-grasp, a Rust GRASP implementation with proactive sync"; + # Option 1: Use nsec file (recommended - more secure) - relayOwnerNsecFile = "/persistent/ngit-danconwaydev-com-ngit-grasp/relay-owner.nsec"; - + relayOwnerNsecFile = + "/persistent/ngit-danconwaydev-com-ngit-grasp/relay-owner.nsec"; + # Option 2: Inline nsec (less secure, ends up in nix store) # relayOwnerNsec = "nsec1..."; - + # Option 3: Auto-generate (default if neither above is set) # ngit-grasp will create .relay-owner.nsec in dataDir automatically - + # Sync syncBootstrapRelayUrl = "wss://relay.ngit.dev"; - + # Metrics metricsEnabled = true; - + # Logging - logLevel = "info"; # Options: trace, debug, info, warn, error + logLevel = "info"; # Options: trace, debug, info, warn, error }; - # Caddy reverse proxy (unchanged from current setup) + # Caddy reverse proxy for production instance services.caddy.virtualHosts."ngit.danconwaydev.com" = { extraConfig = '' reverse_proxy 127.0.0.1:8082 { @@ -57,4 +63,73 @@ } ''; }; + + # ============================================================================ + # EXAMPLE 2: Multiple Instances on Same Server + # ============================================================================ + + # Uncomment to run multiple instances: + + # # Production instance + # services.ngit-grasp.prod = { + # enable = true; + # domain = "ngit.example.com"; + # port = 8082; + # dataDir = "/persistent/ngit-production"; + # relayName = "Production GRASP Relay"; + # syncBootstrapRelayUrl = "wss://relay.ngit.dev"; + # logLevel = "info"; + # }; + # + # # Testing/staging instance + # services.ngit-grasp.staging = { + # enable = true; + # domain = "ngit-staging.example.com"; + # port = 8083; + # dataDir = "/persistent/ngit-staging"; + # relayName = "Staging GRASP Relay"; + # syncBootstrapRelayUrl = "wss://relay.ngit.dev"; + # logLevel = "debug"; # More verbose logging for testing + # }; + # + # # Development instance with in-memory database + # services.ngit-grasp.dev = { + # enable = true; + # domain = "localhost"; + # bindAddress = "127.0.0.1"; + # port = 8084; + # dataDir = "/tmp/ngit-dev"; + # databaseBackend = "memory"; # No persistence + # relayName = "Development GRASP Relay"; + # metricsEnabled = false; + # logLevel = "trace"; # Maximum verbosity for debugging + # }; + # + # # Caddy configuration for multiple instances + # services.caddy.virtualHosts = { + # "ngit.example.com" = { + # extraConfig = "reverse_proxy 127.0.0.1:8082"; + # }; + # "ngit-staging.example.com" = { + # extraConfig = "reverse_proxy 127.0.0.1:8083"; + # }; + # }; + + # ============================================================================ + # NOTES + # ============================================================================ + + # Instance names (e.g., "production", "prod", "staging") can be anything. + # They are used for: + # - systemd service names: ngit-grasp- + # - default user names: ngit-grasp- + # - default data directories: /var/lib/ngit-grasp- + + # Systemd service management: + # systemctl status ngit-grasp-production + # systemctl restart ngit-grasp-staging + # journalctl -u ngit-grasp-prod -f + + # Each instance runs as a separate user but shares the same group by default. + # You can customize user/group per instance if needed. } diff --git a/nix/module.nix b/nix/module.nix index 35154d2..39e7d8a 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -3,9 +3,7 @@ with lib; let - cfg = config.services.ngit-grasp; - - # Build ngit-grasp package + # Build ngit-grasp package (shared across all instances) ngit-grasp = pkgs.rustPlatform.buildRustPackage { pname = "ngit-grasp"; version = "0.1.0"; @@ -22,281 +20,322 @@ let buildInputs = with pkgs; [ openssl ]; }; -in { - options.services.ngit-grasp = { - enable = mkEnableOption "ngit-grasp GRASP relay"; - - domain = mkOption { - type = types.str; - example = "ngit.example.com"; - description = - "Domain where this relay is hosted (used in GRASP validation)"; - }; + # Per-instance options + instanceOptions = { name, ... }: { + options = { + enable = mkEnableOption "this ngit-grasp instance"; - bindAddress = mkOption { - type = types.str; - default = "127.0.0.1"; - description = "IP address to bind to"; - }; + domain = mkOption { + type = types.str; + example = "ngit.example.com"; + description = + "Domain where this relay is hosted (used in GRASP validation)"; + }; - port = mkOption { - type = types.port; - default = 8080; - description = "Port to listen on"; - }; + bindAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "IP address to bind to"; + }; - dataDir = mkOption { - type = types.path; - default = "/var/lib/ngit-grasp"; - description = "Base directory for data storage"; - }; + port = mkOption { + type = types.port; + default = 8080; + description = "Port to listen on"; + }; - relayName = mkOption { - type = types.nullOr types.str; - default = null; - example = "My GRASP Relay"; - description = - "Relay name for NIP-11 (defaults to \${domain} grasp relay)"; - }; + dataDir = mkOption { + type = types.path; + default = "/var/lib/ngit-grasp-${name}"; + description = "Base directory for data storage"; + }; - relayDescription = mkOption { - type = types.str; - default = "Git Nostr Relay - a grasp implementation"; - description = "Relay description for NIP-11"; - }; + relayName = mkOption { + type = types.nullOr types.str; + default = null; + example = "My GRASP Relay"; + description = + "Relay name for NIP-11 (defaults to \${domain} grasp relay)"; + }; - relayOwnerNsecFile = mkOption { - type = types.nullOr types.path; - default = null; - example = "/persistent/ngit-grasp/relay-owner.nsec"; - description = '' - Path to file containing relay owner's nsec (private key). - If file doesn't exist, ngit-grasp will auto-generate a random nsec and save it. - Takes precedence over relayOwnerNsec if both are set. - ''; - }; + relayDescription = mkOption { + type = types.str; + default = "Git Nostr Relay - a grasp implementation"; + description = "Relay description for NIP-11"; + }; - relayOwnerNsec = mkOption { - type = types.nullOr types.str; - default = null; - example = "nsec1..."; - description = '' - Relay owner's nsec (private key) for signing and authentication. - Less secure than relayOwnerNsecFile as it ends up in nix store. - Only used if relayOwnerNsecFile is not set. - ''; - }; + relayOwnerNsecFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/persistent/ngit-grasp/relay-owner.nsec"; + description = '' + Path to file containing relay owner's nsec (private key). + If file doesn't exist, ngit-grasp will auto-generate a random nsec and save it. + Takes precedence over relayOwnerNsec if both are set. + ''; + }; - syncBootstrapRelayUrl = mkOption { - type = types.nullOr types.str; - default = null; - example = "wss://relay.ngit.dev"; - description = "Bootstrap relay URL to sync from on startup (optional)"; - }; + relayOwnerNsec = mkOption { + type = types.nullOr types.str; + default = null; + example = "nsec1..."; + description = '' + Relay owner's nsec (private key) for signing and authentication. + Less secure than relayOwnerNsecFile as it ends up in nix store. + Only used if relayOwnerNsecFile is not set. + ''; + }; - databaseBackend = mkOption { - type = types.enum [ "lmdb" "nostr-db" "memory" ]; - default = "lmdb"; - description = '' - Database backend type: - - lmdb: LMDB backend (persistent, general purpose) - - nostr-db: NostrDB backend (persistent, optimized for Nostr) - - memory: In-memory database (fastest, no persistence) - ''; - }; + syncBootstrapRelayUrl = mkOption { + type = types.nullOr types.str; + default = null; + example = "wss://relay.ngit.dev"; + description = "Bootstrap relay URL to sync from on startup (optional)"; + }; - metricsEnabled = mkOption { - type = types.bool; - default = true; - description = "Enable Prometheus metrics endpoint at /metrics"; - }; + databaseBackend = mkOption { + type = types.enum [ "lmdb" "nostr-db" "memory" ]; + default = "lmdb"; + description = '' + Database backend type: + - lmdb: LMDB backend (persistent, general purpose) + - nostr-db: NostrDB backend (persistent, optimized for Nostr) + - memory: In-memory database (fastest, no persistence) + ''; + }; - metricsConnectionPerIpAbuseThreshold = mkOption { - type = types.int; - default = 10; - description = - "Connections per IP before flagging as potential abuse in metrics"; - }; + metricsEnabled = mkOption { + type = types.bool; + default = true; + description = "Enable Prometheus metrics endpoint at /metrics"; + }; - metricsTopNRepos = mkOption { - type = types.int; - default = 10; - description = "Number of top bandwidth repos to track in metrics"; - }; + metricsConnectionPerIpAbuseThreshold = mkOption { + type = types.int; + default = 10; + description = + "Connections per IP before flagging as potential abuse in metrics"; + }; - logLevel = mkOption { - type = types.enum [ "trace" "debug" "info" "warn" "error" ]; - default = "info"; - description = "Logging level for RUST_LOG environment variable"; - }; + metricsTopNRepos = mkOption { + type = types.int; + default = 10; + description = "Number of top bandwidth repos to track in metrics"; + }; - syncMaxBackoffSecs = mkOption { - type = types.int; - default = 3600; - description = - "Maximum backoff time in seconds for sync relay reconnection (default: 1 hour)"; - }; + logLevel = mkOption { + type = types.enum [ "trace" "debug" "info" "warn" "error" ]; + default = "info"; + description = "Logging level for RUST_LOG environment variable"; + }; - syncDisconnectCheckIntervalSecs = mkOption { - type = types.int; - default = 60; - description = "Interval in seconds for checking disconnected relays"; - }; + syncMaxBackoffSecs = mkOption { + type = types.int; + default = 3600; + description = + "Maximum backoff time in seconds for sync relay reconnection (default: 1 hour)"; + }; - syncBaseBackoffSecs = mkOption { - type = types.int; - default = 5; - description = "Base backoff time in seconds for relay reconnection"; - }; + syncDisconnectCheckIntervalSecs = mkOption { + type = types.int; + default = 60; + description = "Interval in seconds for checking disconnected relays"; + }; - syncDisableNegentropy = mkOption { - type = types.bool; - default = false; - description = "Disable NIP-77 negentropy sync (use REQ+EOSE instead)"; - }; + syncBaseBackoffSecs = mkOption { + type = types.int; + default = 5; + description = "Base backoff time in seconds for relay reconnection"; + }; - rejectedHotCacheDurationSecs = mkOption { - type = types.int; - default = 120; - description = - "Hot cache duration in seconds for rejected announcements (default: 2 minutes)"; - }; + syncDisableNegentropy = mkOption { + type = types.bool; + default = false; + description = "Disable NIP-77 negentropy sync (use REQ+EOSE instead)"; + }; - rejectedColdIndexExpirySecs = mkOption { - type = types.int; - default = 604800; - description = - "Cold index expiry in seconds for rejected announcements (default: 7 days)"; - }; + rejectedHotCacheDurationSecs = mkOption { + type = types.int; + default = 120; + description = + "Hot cache duration in seconds for rejected announcements (default: 2 minutes)"; + }; - naughtyListExpirationHours = mkOption { - type = types.int; - default = 12; - description = "Hours before removing relay from naughty list"; - }; + rejectedColdIndexExpirySecs = mkOption { + type = types.int; + default = 604800; + description = + "Cold index expiry in seconds for rejected announcements (default: 7 days)"; + }; - user = mkOption { - type = types.str; - default = "ngit-grasp"; - description = "User account under which ngit-grasp runs"; - }; + naughtyListExpirationHours = mkOption { + type = types.int; + default = 12; + description = "Hours before removing relay from naughty list"; + }; - group = mkOption { - type = types.str; - default = "ngit-grasp"; - description = "Group under which ngit-grasp runs"; + user = mkOption { + type = types.str; + default = "ngit-grasp-${name}"; + description = "User account under which this instance runs"; + }; + + group = mkOption { + type = types.str; + default = "ngit-grasp"; + description = "Group under which this instance runs"; + }; }; }; - config = mkIf cfg.enable { - # Create user and group - users.users.${cfg.user} = { - isSystemUser = true; - group = cfg.group; - description = "ngit-grasp service user"; - home = cfg.dataDir; + # Create systemd service config for an instance + mkService = name: cfg: { + description = "ngit-grasp GRASP relay (${name})"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = { + NGIT_DOMAIN = cfg.domain; + NGIT_BIND_ADDRESS = "${cfg.bindAddress}:${toString cfg.port}"; + NGIT_GIT_DATA_PATH = "${cfg.dataDir}/git"; + NGIT_RELAY_DATA_PATH = "${cfg.dataDir}/relay"; + NGIT_RELAY_DESCRIPTION = cfg.relayDescription; + NGIT_DATABASE_BACKEND = cfg.databaseBackend; + NGIT_METRICS_CONNECTION_PER_IP_ABUSE_THRESHOLD = + toString cfg.metricsConnectionPerIpAbuseThreshold; + NGIT_METRICS_TOP_N_REPOS = toString cfg.metricsTopNRepos; + NGIT_SYNC_MAX_BACKOFF_SECS = toString cfg.syncMaxBackoffSecs; + NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS = + toString cfg.syncDisconnectCheckIntervalSecs; + NGIT_SYNC_BASE_BACKOFF_SECS = toString cfg.syncBaseBackoffSecs; + NGIT_REJECTED_HOT_CACHE_DURATION_SECS = + toString cfg.rejectedHotCacheDurationSecs; + NGIT_REJECTED_COLD_INDEX_EXPIRY_SECS = + toString cfg.rejectedColdIndexExpirySecs; + NGIT_NAUGHTY_LIST_EXPIRATION_HOURS = + toString cfg.naughtyListExpirationHours; + RUST_LOG = cfg.logLevel; + } // optionalAttrs (cfg.relayName != null) { + NGIT_RELAY_NAME = cfg.relayName; + } // optionalAttrs cfg.metricsEnabled { NGIT_METRICS_ENABLED = "true"; } + // optionalAttrs (cfg.syncBootstrapRelayUrl != null) { + NGIT_SYNC_BOOTSTRAP_RELAY_URL = cfg.syncBootstrapRelayUrl; + } // optionalAttrs cfg.syncDisableNegentropy { + NGIT_SYNC_DISABLE_NEGENTROPY = "true"; + } // optionalAttrs + (cfg.relayOwnerNsec != null && cfg.relayOwnerNsecFile == null) { + # Only set inline nsec if file is not specified + NGIT_RELAY_OWNER_NSEC = cfg.relayOwnerNsec; + }; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + + # Working directory where .relay-owner.nsec will be created if needed + WorkingDirectory = cfg.dataDir; + + # Command to run + ExecStart = if cfg.relayOwnerNsecFile != null then + # Use nsec from file + "${ngit-grasp}/bin/ngit-grasp --relay-owner-nsec $(cat ${cfg.relayOwnerNsecFile})" + else + # Let ngit-grasp auto-generate nsec in .relay-owner.nsec file in dataDir + "${ngit-grasp}/bin/ngit-grasp"; + + # Restart policy + Restart = "always"; + RestartSec = "10s"; + + # Hardening + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ReadWritePaths = [ cfg.dataDir ]; + + # If using nsecFile, grant read access + ReadOnlyPaths = + optionals (cfg.relayOwnerNsecFile != null) [ cfg.relayOwnerNsecFile ]; + + # Additional hardening + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + RestrictNamespaces = true; + LockPersonality = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + PrivateDevices = true; + + # Capabilities + CapabilityBoundingSet = ""; + AmbientCapabilities = ""; + + # System call filtering + SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; + SystemCallErrorNumber = "EPERM"; }; - users.groups.${cfg.group} = { }; - - # Create systemd service - systemd.services.ngit-grasp = { - description = "ngit-grasp GRASP relay"; - after = [ "network.target" ]; - wantedBy = [ "multi-user.target" ]; - - environment = { - NGIT_DOMAIN = cfg.domain; - NGIT_BIND_ADDRESS = "${cfg.bindAddress}:${toString cfg.port}"; - NGIT_GIT_DATA_PATH = "${cfg.dataDir}/git"; - NGIT_RELAY_DATA_PATH = "${cfg.dataDir}/relay"; - NGIT_RELAY_DESCRIPTION = cfg.relayDescription; - NGIT_DATABASE_BACKEND = cfg.databaseBackend; - NGIT_METRICS_CONNECTION_PER_IP_ABUSE_THRESHOLD = - toString cfg.metricsConnectionPerIpAbuseThreshold; - NGIT_METRICS_TOP_N_REPOS = toString cfg.metricsTopNRepos; - NGIT_SYNC_MAX_BACKOFF_SECS = toString cfg.syncMaxBackoffSecs; - NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS = - toString cfg.syncDisconnectCheckIntervalSecs; - NGIT_SYNC_BASE_BACKOFF_SECS = toString cfg.syncBaseBackoffSecs; - NGIT_REJECTED_HOT_CACHE_DURATION_SECS = - toString cfg.rejectedHotCacheDurationSecs; - NGIT_REJECTED_COLD_INDEX_EXPIRY_SECS = - toString cfg.rejectedColdIndexExpirySecs; - NGIT_NAUGHTY_LIST_EXPIRATION_HOURS = - toString cfg.naughtyListExpirationHours; - RUST_LOG = cfg.logLevel; - } // optionalAttrs (cfg.relayName != null) { - NGIT_RELAY_NAME = cfg.relayName; - } // optionalAttrs cfg.metricsEnabled { NGIT_METRICS_ENABLED = "true"; } - // optionalAttrs (cfg.syncBootstrapRelayUrl != null) { - NGIT_SYNC_BOOTSTRAP_RELAY_URL = cfg.syncBootstrapRelayUrl; - } // optionalAttrs cfg.syncDisableNegentropy { - NGIT_SYNC_DISABLE_NEGENTROPY = "true"; - } // optionalAttrs - (cfg.relayOwnerNsec != null && cfg.relayOwnerNsecFile == null) { - # Only set inline nsec if file is not specified - NGIT_RELAY_OWNER_NSEC = cfg.relayOwnerNsec; - }; + # Ensure data directories exist before starting + preStart = '' + mkdir -p ${cfg.dataDir}/git + mkdir -p ${cfg.dataDir}/relay + chown -R ${cfg.user}:${cfg.group} ${cfg.dataDir} + chmod 750 ${cfg.dataDir} + ''; + }; - serviceConfig = { - Type = "simple"; - User = cfg.user; - Group = cfg.group; - - # Working directory where .relay-owner.nsec will be created if needed - WorkingDirectory = cfg.dataDir; - - # Command to run - ExecStart = if cfg.relayOwnerNsecFile != null then - # Use nsec from file - "${ngit-grasp}/bin/ngit-grasp --relay-owner-nsec $(cat ${cfg.relayOwnerNsecFile})" - else - # Let ngit-grasp auto-generate nsec in .relay-owner.nsec file in dataDir - "${ngit-grasp}/bin/ngit-grasp"; - - # Restart policy - Restart = "always"; - RestartSec = "10s"; - - # Hardening - NoNewPrivileges = true; - PrivateTmp = true; - ProtectSystem = "strict"; - ProtectHome = true; - ReadWritePaths = [ cfg.dataDir ]; - - # If using nsecFile, grant read access - ReadOnlyPaths = - optionals (cfg.relayOwnerNsecFile != null) [ cfg.relayOwnerNsecFile ]; - - # Additional hardening - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; - RestrictNamespaces = true; - LockPersonality = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - PrivateDevices = true; - - # Capabilities - CapabilityBoundingSet = ""; - AmbientCapabilities = ""; - - # System call filtering - SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; - SystemCallErrorNumber = "EPERM"; - }; + enabledInstances = + filterAttrs (_: cfg: cfg.enable) config.services.ngit-grasp; - # Ensure data directories exist before starting - preStart = '' - mkdir -p ${cfg.dataDir}/git - mkdir -p ${cfg.dataDir}/relay - chown -R ${cfg.user}:${cfg.group} ${cfg.dataDir} - chmod 750 ${cfg.dataDir} - ''; - }; +in { + options.services.ngit-grasp = mkOption { + type = types.attrsOf (types.submodule instanceOptions); + default = { }; + description = '' + ngit-grasp GRASP relay instances. + + Multiple instances can be configured with different domains and ports. + Each instance runs as a separate systemd service. + ''; + example = literalExpression '' + { + production = { + enable = true; + domain = "ngit.example.com"; + port = 8082; + dataDir = "/persistent/ngit-production"; + }; + + testing = { + enable = true; + domain = "ngit-test.example.com"; + port = 8083; + dataDir = "/persistent/ngit-testing"; + }; + } + ''; + }; + + config = mkIf (enabledInstances != { }) { + # Create users for all enabled instances + users.users = mapAttrs' (name: cfg: + nameValuePair cfg.user { + isSystemUser = true; + group = cfg.group; + description = "ngit-grasp service user (${name})"; + home = cfg.dataDir; + }) enabledInstances; + + # Create shared group (all instances use the same group by default) + users.groups.ngit-grasp = { }; + + # Create systemd services for all enabled instances + systemd.services = mapAttrs' + (name: cfg: nameValuePair "ngit-grasp-${name}" (mkService name cfg)) + enabledInstances; }; } -- cgit v1.2.3