diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-10 21:55:28 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-10 21:55:28 +0000 |
| commit | 8536be07962ee6b23ecca0f1c084db11a3c104e0 (patch) | |
| tree | eb53898684353527958a6ff3ae16c5cd19df8c56 /nix | |
| parent | a9ff76e7e294fb54ae3a6876bca3e30ac6a5bdef (diff) | |
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
Diffstat (limited to 'nix')
| -rw-r--r-- | nix/example-configuration.nix | 60 | ||||
| -rw-r--r-- | nix/module.nix | 302 |
2 files changed, 362 insertions, 0 deletions
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 @@ | |||
| 1 | # Example NixOS configuration using ngit-grasp module | ||
| 2 | # | ||
| 3 | # Usage: | ||
| 4 | # 1. Add to your server's flake.nix inputs: | ||
| 5 | # inputs.ngit-grasp.url = "github:DanConwayDev/ngit-grasp"; | ||
| 6 | # | ||
| 7 | # 2. Import the module in your configuration: | ||
| 8 | # imports = [ inputs.ngit-grasp.nixosModules.default ]; | ||
| 9 | # | ||
| 10 | # 3. Configure the service (example below) | ||
| 11 | |||
| 12 | { inputs, ... }: | ||
| 13 | |||
| 14 | { | ||
| 15 | imports = [ inputs.ngit-grasp.nixosModules.default ]; | ||
| 16 | |||
| 17 | services.ngit-grasp = { | ||
| 18 | enable = true; | ||
| 19 | domain = "ngit.danconwaydev.com"; | ||
| 20 | |||
| 21 | # Network | ||
| 22 | bindAddress = "127.0.0.1"; | ||
| 23 | port = 8082; # Same port as current ngit-relay for Caddy compatibility | ||
| 24 | |||
| 25 | # Storage (reuse existing persistent path pattern) | ||
| 26 | dataDir = "/persistent/ngit-danconwaydev-com-ngit-grasp"; | ||
| 27 | |||
| 28 | # Identity | ||
| 29 | relayName = "DanConwayDev's ngit-grasp"; | ||
| 30 | relayDescription = "personal instance of ngit-grasp, a Rust GRASP implementation with proactive sync"; | ||
| 31 | |||
| 32 | # Option 1: Use nsec file (recommended - more secure) | ||
| 33 | relayOwnerNsecFile = "/persistent/ngit-danconwaydev-com-ngit-grasp/relay-owner.nsec"; | ||
| 34 | |||
| 35 | # Option 2: Inline nsec (less secure, ends up in nix store) | ||
| 36 | # relayOwnerNsec = "nsec1..."; | ||
| 37 | |||
| 38 | # Option 3: Auto-generate (default if neither above is set) | ||
| 39 | # ngit-grasp will create .relay-owner.nsec in dataDir automatically | ||
| 40 | |||
| 41 | # Sync | ||
| 42 | syncBootstrapRelayUrl = "wss://relay.ngit.dev"; | ||
| 43 | |||
| 44 | # Metrics | ||
| 45 | metricsEnabled = true; | ||
| 46 | |||
| 47 | # Logging | ||
| 48 | logLevel = "info"; # Options: trace, debug, info, warn, error | ||
| 49 | }; | ||
| 50 | |||
| 51 | # Caddy reverse proxy (unchanged from current setup) | ||
| 52 | services.caddy.virtualHosts."ngit.danconwaydev.com" = { | ||
| 53 | extraConfig = '' | ||
| 54 | reverse_proxy 127.0.0.1:8082 { | ||
| 55 | header_down X-Real-IP {http.request.remote} | ||
| 56 | header_down X-Forwarded-For {http.request.remote} | ||
| 57 | } | ||
| 58 | ''; | ||
| 59 | }; | ||
| 60 | } | ||
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 @@ | |||
| 1 | { config, lib, pkgs, ... }: | ||
| 2 | |||
| 3 | with lib; | ||
| 4 | |||
| 5 | let | ||
| 6 | cfg = config.services.ngit-grasp; | ||
| 7 | |||
| 8 | # Build ngit-grasp package | ||
| 9 | ngit-grasp = pkgs.rustPlatform.buildRustPackage { | ||
| 10 | pname = "ngit-grasp"; | ||
| 11 | version = "0.1.0"; | ||
| 12 | src = ../.; | ||
| 13 | cargoLock = { | ||
| 14 | lockFile = ../Cargo.lock; | ||
| 15 | outputHashes = { | ||
| 16 | "nostr-0.44.1" = | ||
| 17 | "sha256-02cawkx6bxfi3bn1sb5ws8cn9wzcwsk8cdv1vx8h8lad1jdic1qg"; | ||
| 18 | }; | ||
| 19 | }; | ||
| 20 | |||
| 21 | nativeBuildInputs = with pkgs; [ pkg-config ]; | ||
| 22 | buildInputs = with pkgs; [ openssl ]; | ||
| 23 | }; | ||
| 24 | |||
| 25 | in { | ||
| 26 | options.services.ngit-grasp = { | ||
| 27 | enable = mkEnableOption "ngit-grasp GRASP relay"; | ||
| 28 | |||
| 29 | domain = mkOption { | ||
| 30 | type = types.str; | ||
| 31 | example = "ngit.example.com"; | ||
| 32 | description = | ||
| 33 | "Domain where this relay is hosted (used in GRASP validation)"; | ||
| 34 | }; | ||
| 35 | |||
| 36 | bindAddress = mkOption { | ||
| 37 | type = types.str; | ||
| 38 | default = "127.0.0.1"; | ||
| 39 | description = "IP address to bind to"; | ||
| 40 | }; | ||
| 41 | |||
| 42 | port = mkOption { | ||
| 43 | type = types.port; | ||
| 44 | default = 8080; | ||
| 45 | description = "Port to listen on"; | ||
| 46 | }; | ||
| 47 | |||
| 48 | dataDir = mkOption { | ||
| 49 | type = types.path; | ||
| 50 | default = "/var/lib/ngit-grasp"; | ||
| 51 | description = "Base directory for data storage"; | ||
| 52 | }; | ||
| 53 | |||
| 54 | relayName = mkOption { | ||
| 55 | type = types.nullOr types.str; | ||
| 56 | default = null; | ||
| 57 | example = "My GRASP Relay"; | ||
| 58 | description = | ||
| 59 | "Relay name for NIP-11 (defaults to \${domain} grasp relay)"; | ||
| 60 | }; | ||
| 61 | |||
| 62 | relayDescription = mkOption { | ||
| 63 | type = types.str; | ||
| 64 | default = "Git Nostr Relay - a grasp implementation"; | ||
| 65 | description = "Relay description for NIP-11"; | ||
| 66 | }; | ||
| 67 | |||
| 68 | relayOwnerNsecFile = mkOption { | ||
| 69 | type = types.nullOr types.path; | ||
| 70 | default = null; | ||
| 71 | example = "/persistent/ngit-grasp/relay-owner.nsec"; | ||
| 72 | description = '' | ||
| 73 | Path to file containing relay owner's nsec (private key). | ||
| 74 | If file doesn't exist, ngit-grasp will auto-generate a random nsec and save it. | ||
| 75 | Takes precedence over relayOwnerNsec if both are set. | ||
| 76 | ''; | ||
| 77 | }; | ||
| 78 | |||
| 79 | relayOwnerNsec = mkOption { | ||
| 80 | type = types.nullOr types.str; | ||
| 81 | default = null; | ||
| 82 | example = "nsec1..."; | ||
| 83 | description = '' | ||
| 84 | Relay owner's nsec (private key) for signing and authentication. | ||
| 85 | Less secure than relayOwnerNsecFile as it ends up in nix store. | ||
| 86 | Only used if relayOwnerNsecFile is not set. | ||
| 87 | ''; | ||
| 88 | }; | ||
| 89 | |||
| 90 | syncBootstrapRelayUrl = mkOption { | ||
| 91 | type = types.nullOr types.str; | ||
| 92 | default = null; | ||
| 93 | example = "wss://relay.ngit.dev"; | ||
| 94 | description = "Bootstrap relay URL to sync from on startup (optional)"; | ||
| 95 | }; | ||
| 96 | |||
| 97 | databaseBackend = mkOption { | ||
| 98 | type = types.enum [ "lmdb" "nostr-db" "memory" ]; | ||
| 99 | default = "lmdb"; | ||
| 100 | description = '' | ||
| 101 | Database backend type: | ||
| 102 | - lmdb: LMDB backend (persistent, general purpose) | ||
| 103 | - nostr-db: NostrDB backend (persistent, optimized for Nostr) | ||
| 104 | - memory: In-memory database (fastest, no persistence) | ||
| 105 | ''; | ||
| 106 | }; | ||
| 107 | |||
| 108 | metricsEnabled = mkOption { | ||
| 109 | type = types.bool; | ||
| 110 | default = true; | ||
| 111 | description = "Enable Prometheus metrics endpoint at /metrics"; | ||
| 112 | }; | ||
| 113 | |||
| 114 | metricsConnectionPerIpAbuseThreshold = mkOption { | ||
| 115 | type = types.int; | ||
| 116 | default = 10; | ||
| 117 | description = | ||
| 118 | "Connections per IP before flagging as potential abuse in metrics"; | ||
| 119 | }; | ||
| 120 | |||
| 121 | metricsTopNRepos = mkOption { | ||
| 122 | type = types.int; | ||
| 123 | default = 10; | ||
| 124 | description = "Number of top bandwidth repos to track in metrics"; | ||
| 125 | }; | ||
| 126 | |||
| 127 | logLevel = mkOption { | ||
| 128 | type = types.enum [ "trace" "debug" "info" "warn" "error" ]; | ||
| 129 | default = "info"; | ||
| 130 | description = "Logging level for RUST_LOG environment variable"; | ||
| 131 | }; | ||
| 132 | |||
| 133 | syncMaxBackoffSecs = mkOption { | ||
| 134 | type = types.int; | ||
| 135 | default = 3600; | ||
| 136 | description = | ||
| 137 | "Maximum backoff time in seconds for sync relay reconnection (default: 1 hour)"; | ||
| 138 | }; | ||
| 139 | |||
| 140 | syncDisconnectCheckIntervalSecs = mkOption { | ||
| 141 | type = types.int; | ||
| 142 | default = 60; | ||
| 143 | description = "Interval in seconds for checking disconnected relays"; | ||
| 144 | }; | ||
| 145 | |||
| 146 | syncBaseBackoffSecs = mkOption { | ||
| 147 | type = types.int; | ||
| 148 | default = 5; | ||
| 149 | description = "Base backoff time in seconds for relay reconnection"; | ||
| 150 | }; | ||
| 151 | |||
| 152 | syncDisableNegentropy = mkOption { | ||
| 153 | type = types.bool; | ||
| 154 | default = false; | ||
| 155 | description = "Disable NIP-77 negentropy sync (use REQ+EOSE instead)"; | ||
| 156 | }; | ||
| 157 | |||
| 158 | rejectedHotCacheDurationSecs = mkOption { | ||
| 159 | type = types.int; | ||
| 160 | default = 120; | ||
| 161 | description = | ||
| 162 | "Hot cache duration in seconds for rejected announcements (default: 2 minutes)"; | ||
| 163 | }; | ||
| 164 | |||
| 165 | rejectedColdIndexExpirySecs = mkOption { | ||
| 166 | type = types.int; | ||
| 167 | default = 604800; | ||
| 168 | description = | ||
| 169 | "Cold index expiry in seconds for rejected announcements (default: 7 days)"; | ||
| 170 | }; | ||
| 171 | |||
| 172 | naughtyListExpirationHours = mkOption { | ||
| 173 | type = types.int; | ||
| 174 | default = 12; | ||
| 175 | description = "Hours before removing relay from naughty list"; | ||
| 176 | }; | ||
| 177 | |||
| 178 | user = mkOption { | ||
| 179 | type = types.str; | ||
| 180 | default = "ngit-grasp"; | ||
| 181 | description = "User account under which ngit-grasp runs"; | ||
| 182 | }; | ||
| 183 | |||
| 184 | group = mkOption { | ||
| 185 | type = types.str; | ||
| 186 | default = "ngit-grasp"; | ||
| 187 | description = "Group under which ngit-grasp runs"; | ||
| 188 | }; | ||
| 189 | }; | ||
| 190 | |||
| 191 | config = mkIf cfg.enable { | ||
| 192 | # Create user and group | ||
| 193 | users.users.${cfg.user} = { | ||
| 194 | isSystemUser = true; | ||
| 195 | group = cfg.group; | ||
| 196 | description = "ngit-grasp service user"; | ||
| 197 | home = cfg.dataDir; | ||
| 198 | }; | ||
| 199 | |||
| 200 | users.groups.${cfg.group} = { }; | ||
| 201 | |||
| 202 | # Create systemd service | ||
| 203 | systemd.services.ngit-grasp = { | ||
| 204 | description = "ngit-grasp GRASP relay"; | ||
| 205 | after = [ "network.target" ]; | ||
| 206 | wantedBy = [ "multi-user.target" ]; | ||
| 207 | |||
| 208 | environment = { | ||
| 209 | NGIT_DOMAIN = cfg.domain; | ||
| 210 | NGIT_BIND_ADDRESS = "${cfg.bindAddress}:${toString cfg.port}"; | ||
| 211 | NGIT_GIT_DATA_PATH = "${cfg.dataDir}/git"; | ||
| 212 | NGIT_RELAY_DATA_PATH = "${cfg.dataDir}/relay"; | ||
| 213 | NGIT_RELAY_DESCRIPTION = cfg.relayDescription; | ||
| 214 | NGIT_DATABASE_BACKEND = cfg.databaseBackend; | ||
| 215 | NGIT_METRICS_CONNECTION_PER_IP_ABUSE_THRESHOLD = | ||
| 216 | toString cfg.metricsConnectionPerIpAbuseThreshold; | ||
| 217 | NGIT_METRICS_TOP_N_REPOS = toString cfg.metricsTopNRepos; | ||
| 218 | NGIT_SYNC_MAX_BACKOFF_SECS = toString cfg.syncMaxBackoffSecs; | ||
| 219 | NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS = | ||
| 220 | toString cfg.syncDisconnectCheckIntervalSecs; | ||
| 221 | NGIT_SYNC_BASE_BACKOFF_SECS = toString cfg.syncBaseBackoffSecs; | ||
| 222 | NGIT_REJECTED_HOT_CACHE_DURATION_SECS = | ||
| 223 | toString cfg.rejectedHotCacheDurationSecs; | ||
| 224 | NGIT_REJECTED_COLD_INDEX_EXPIRY_SECS = | ||
| 225 | toString cfg.rejectedColdIndexExpirySecs; | ||
| 226 | NGIT_NAUGHTY_LIST_EXPIRATION_HOURS = | ||
| 227 | toString cfg.naughtyListExpirationHours; | ||
| 228 | RUST_LOG = cfg.logLevel; | ||
| 229 | } // optionalAttrs (cfg.relayName != null) { | ||
| 230 | NGIT_RELAY_NAME = cfg.relayName; | ||
| 231 | } // optionalAttrs cfg.metricsEnabled { NGIT_METRICS_ENABLED = "true"; } | ||
| 232 | // optionalAttrs (cfg.syncBootstrapRelayUrl != null) { | ||
| 233 | NGIT_SYNC_BOOTSTRAP_RELAY_URL = cfg.syncBootstrapRelayUrl; | ||
| 234 | } // optionalAttrs cfg.syncDisableNegentropy { | ||
| 235 | NGIT_SYNC_DISABLE_NEGENTROPY = "true"; | ||
| 236 | } // optionalAttrs | ||
| 237 | (cfg.relayOwnerNsec != null && cfg.relayOwnerNsecFile == null) { | ||
| 238 | # Only set inline nsec if file is not specified | ||
| 239 | NGIT_RELAY_OWNER_NSEC = cfg.relayOwnerNsec; | ||
| 240 | }; | ||
| 241 | |||
| 242 | serviceConfig = { | ||
| 243 | Type = "simple"; | ||
| 244 | User = cfg.user; | ||
| 245 | Group = cfg.group; | ||
| 246 | |||
| 247 | # Working directory where .relay-owner.nsec will be created if needed | ||
| 248 | WorkingDirectory = cfg.dataDir; | ||
| 249 | |||
| 250 | # Command to run | ||
| 251 | ExecStart = if cfg.relayOwnerNsecFile != null then | ||
| 252 | # Use nsec from file | ||
| 253 | "${ngit-grasp}/bin/ngit-grasp --relay-owner-nsec $(cat ${cfg.relayOwnerNsecFile})" | ||
| 254 | else | ||
| 255 | # Let ngit-grasp auto-generate nsec in .relay-owner.nsec file in dataDir | ||
| 256 | "${ngit-grasp}/bin/ngit-grasp"; | ||
| 257 | |||
| 258 | # Restart policy | ||
| 259 | Restart = "always"; | ||
| 260 | RestartSec = "10s"; | ||
| 261 | |||
| 262 | # Hardening | ||
| 263 | NoNewPrivileges = true; | ||
| 264 | PrivateTmp = true; | ||
| 265 | ProtectSystem = "strict"; | ||
| 266 | ProtectHome = true; | ||
| 267 | ReadWritePaths = [ cfg.dataDir ]; | ||
| 268 | |||
| 269 | # If using nsecFile, grant read access | ||
| 270 | ReadOnlyPaths = | ||
| 271 | optionals (cfg.relayOwnerNsecFile != null) [ cfg.relayOwnerNsecFile ]; | ||
| 272 | |||
| 273 | # Additional hardening | ||
| 274 | ProtectKernelTunables = true; | ||
| 275 | ProtectKernelModules = true; | ||
| 276 | ProtectControlGroups = true; | ||
| 277 | RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; | ||
| 278 | RestrictNamespaces = true; | ||
| 279 | LockPersonality = true; | ||
| 280 | RestrictRealtime = true; | ||
| 281 | RestrictSUIDSGID = true; | ||
| 282 | PrivateDevices = true; | ||
| 283 | |||
| 284 | # Capabilities | ||
| 285 | CapabilityBoundingSet = ""; | ||
| 286 | AmbientCapabilities = ""; | ||
| 287 | |||
| 288 | # System call filtering | ||
| 289 | SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; | ||
| 290 | SystemCallErrorNumber = "EPERM"; | ||
| 291 | }; | ||
| 292 | |||
| 293 | # Ensure data directories exist before starting | ||
| 294 | preStart = '' | ||
| 295 | mkdir -p ${cfg.dataDir}/git | ||
| 296 | mkdir -p ${cfg.dataDir}/relay | ||
| 297 | chown -R ${cfg.user}:${cfg.group} ${cfg.dataDir} | ||
| 298 | chmod 750 ${cfg.dataDir} | ||
| 299 | ''; | ||
| 300 | }; | ||
| 301 | }; | ||
| 302 | } | ||