From 8536be07962ee6b23ecca0f1c084db11a3c104e0 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Sat, 10 Jan 2026 21:55:28 +0000 Subject: feat: add NixOS module for deployment - Create nix/module.nix with comprehensive systemd service - Support both relayOwnerNsecFile and relayOwnerNsec options - Auto-generate nsec if neither specified - Add security hardening (NoNewPrivileges, ProtectSystem, etc.) - Expose as nixosModules.default and nixosModules.ngit-grasp - Include example configuration in nix/example-configuration.nix - Add outputHashes for nostr git dependency --- flake.nix | 47 +++---- nix/example-configuration.nix | 60 +++++++++ nix/module.nix | 302 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 384 insertions(+), 25 deletions(-) create mode 100644 nix/example-configuration.nix create mode 100644 nix/module.nix diff --git a/flake.nix b/flake.nix index 6edab71..71749b9 100644 --- a/flake.nix +++ b/flake.nix @@ -8,28 +8,20 @@ }; outputs = { self, nixpkgs, rust-overlay, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: + (flake-utils.lib.eachDefaultSystem (system: let overlays = [ (import rust-overlay) ]; - pkgs = import nixpkgs { - inherit system overlays; - }; - + pkgs = import nixpkgs { inherit system overlays; }; + rustToolchain = pkgs.rust-bin.stable.latest.default.override { extensions = [ "rust-src" "rust-analyzer" ]; }; - in - { + in { devShells.default = pkgs.mkShell { - buildInputs = with pkgs; [ - rustToolchain - pkg-config - openssl - git - ]; + buildInputs = with pkgs; [ rustToolchain pkg-config openssl git ]; RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; - + shellHook = '' echo "🚀 ngit-grasp development environment" echo "Rust version: $(rustc --version)" @@ -46,16 +38,21 @@ pname = "ngit-grasp"; version = "0.1.0"; src = ./.; - cargoLock.lockFile = ./Cargo.lock; - - nativeBuildInputs = with pkgs; [ - pkg-config - ]; - - buildInputs = with pkgs; [ - openssl - ]; + cargoLock = { + lockFile = ./Cargo.lock; + outputHashes = { + "nostr-0.44.1" = + "sha256-02cawkx6bxfi3bn1sb5ws8cn9wzcwsk8cdv1vx8h8lad1jdic1qg"; + }; + }; + + nativeBuildInputs = with pkgs; [ pkg-config ]; + + buildInputs = with pkgs; [ openssl ]; }; - } - ); + })) // { + # NixOS module for deployment + nixosModules.default = import ./nix/module.nix; + nixosModules.ngit-grasp = self.nixosModules.default; + }; } diff --git a/nix/example-configuration.nix b/nix/example-configuration.nix new file mode 100644 index 0000000..a00d970 --- /dev/null +++ b/nix/example-configuration.nix @@ -0,0 +1,60 @@ +# Example NixOS configuration using ngit-grasp module +# +# Usage: +# 1. Add to your server's flake.nix inputs: +# inputs.ngit-grasp.url = "github:DanConwayDev/ngit-grasp"; +# +# 2. Import the module in your configuration: +# imports = [ inputs.ngit-grasp.nixosModules.default ]; +# +# 3. Configure the service (example below) + +{ inputs, ... }: + +{ + imports = [ inputs.ngit-grasp.nixosModules.default ]; + + services.ngit-grasp = { + 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) + 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"; + + # Option 1: Use nsec file (recommended - more secure) + 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 + }; + + # Caddy reverse proxy (unchanged from current setup) + services.caddy.virtualHosts."ngit.danconwaydev.com" = { + extraConfig = '' + reverse_proxy 127.0.0.1:8082 { + header_down X-Real-IP {http.request.remote} + header_down X-Forwarded-For {http.request.remote} + } + ''; + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..35154d2 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,302 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.ngit-grasp; + + # Build ngit-grasp package + ngit-grasp = pkgs.rustPlatform.buildRustPackage { + pname = "ngit-grasp"; + version = "0.1.0"; + src = ../.; + cargoLock = { + lockFile = ../Cargo.lock; + outputHashes = { + "nostr-0.44.1" = + "sha256-02cawkx6bxfi3bn1sb5ws8cn9wzcwsk8cdv1vx8h8lad1jdic1qg"; + }; + }; + + nativeBuildInputs = with pkgs; [ pkg-config ]; + 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)"; + }; + + bindAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "IP address to bind to"; + }; + + port = mkOption { + type = types.port; + default = 8080; + description = "Port to listen on"; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/ngit-grasp"; + description = "Base directory for data storage"; + }; + + relayName = mkOption { + type = types.nullOr types.str; + default = null; + example = "My GRASP Relay"; + description = + "Relay name for NIP-11 (defaults to \${domain} grasp relay)"; + }; + + relayDescription = mkOption { + type = types.str; + default = "Git Nostr Relay - a grasp implementation"; + description = "Relay description for NIP-11"; + }; + + 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. + ''; + }; + + 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. + ''; + }; + + syncBootstrapRelayUrl = mkOption { + type = types.nullOr types.str; + default = null; + example = "wss://relay.ngit.dev"; + description = "Bootstrap relay URL to sync from on startup (optional)"; + }; + + 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) + ''; + }; + + metricsEnabled = mkOption { + type = types.bool; + default = true; + description = "Enable Prometheus metrics endpoint at /metrics"; + }; + + metricsConnectionPerIpAbuseThreshold = mkOption { + type = types.int; + default = 10; + description = + "Connections per IP before flagging as potential abuse in metrics"; + }; + + metricsTopNRepos = mkOption { + type = types.int; + default = 10; + description = "Number of top bandwidth repos to track in metrics"; + }; + + logLevel = mkOption { + type = types.enum [ "trace" "debug" "info" "warn" "error" ]; + default = "info"; + description = "Logging level for RUST_LOG environment variable"; + }; + + syncMaxBackoffSecs = mkOption { + type = types.int; + default = 3600; + description = + "Maximum backoff time in seconds for sync relay reconnection (default: 1 hour)"; + }; + + syncDisconnectCheckIntervalSecs = mkOption { + type = types.int; + default = 60; + description = "Interval in seconds for checking disconnected relays"; + }; + + syncBaseBackoffSecs = mkOption { + type = types.int; + default = 5; + description = "Base backoff time in seconds for relay reconnection"; + }; + + syncDisableNegentropy = mkOption { + type = types.bool; + default = false; + description = "Disable NIP-77 negentropy sync (use REQ+EOSE instead)"; + }; + + rejectedHotCacheDurationSecs = mkOption { + type = types.int; + default = 120; + description = + "Hot cache duration in seconds for rejected announcements (default: 2 minutes)"; + }; + + rejectedColdIndexExpirySecs = mkOption { + type = types.int; + default = 604800; + description = + "Cold index expiry in seconds for rejected announcements (default: 7 days)"; + }; + + naughtyListExpirationHours = mkOption { + type = types.int; + default = 12; + description = "Hours before removing relay from naughty list"; + }; + + user = mkOption { + type = types.str; + default = "ngit-grasp"; + description = "User account under which ngit-grasp runs"; + }; + + group = mkOption { + type = types.str; + default = "ngit-grasp"; + description = "Group under which ngit-grasp 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; + }; + + 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; + }; + + 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"; + }; + + # 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} + ''; + }; + }; +} -- cgit v1.2.3