mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
Prepare the patch release for temporary unsigned Git uploads, abandoned PR ref cleanup and crash recovery. Move the accumulated changes into a dated release section with a one-sentence summary of the overall behavior. Align the Cargo manifest, lockfile and NixOS module at 3.0.6; flake packages read the manifest version. Keep grasp-audit at 0.2.0. This commit changes release metadata only; tag creation and publication are separate steps. Validation: locked offline Cargo metadata reports the intended workspace versions; regular and static Nix packages evaluate to 3.0.6; NixOS module parsing and diff checks pass. The parent passed 148 focused tests, including the PRS expiry regression, and formatting checks. Its live soak is separate. Assisted-by: GPT-6
794 lines
30 KiB
Nix
794 lines
30 KiB
Nix
{ config, lib, pkgs, ngitGraspSourceRevision ? "unknown", ... }:
|
|
|
|
with lib;
|
|
|
|
let
|
|
# Build ngit-grasp package (shared across all instances)
|
|
ngit-grasp = pkgs.rustPlatform.buildRustPackage {
|
|
pname = "ngit-grasp";
|
|
version = "3.0.6";
|
|
src = ../.;
|
|
NGIT_BUILD_REVISION = ngitGraspSourceRevision;
|
|
cargoLock = {
|
|
lockFile = ../Cargo.lock;
|
|
};
|
|
|
|
nativeBuildInputs = with pkgs; [ pkg-config ];
|
|
buildInputs = with pkgs; [ openssl ];
|
|
|
|
# Disable tests during Nix build (many require git in PATH for sandboxing)
|
|
# Tests run successfully in dev environment and CI where git is available
|
|
doCheck = false;
|
|
};
|
|
|
|
relayOwnerNsecCredential = "relay_owner_nsec";
|
|
|
|
# Per-instance options
|
|
instanceOptions = { name, ... }: {
|
|
options = {
|
|
enable = mkEnableOption "this ngit-grasp instance";
|
|
|
|
domain = mkOption {
|
|
type = types.str;
|
|
example = "ngit.example.com";
|
|
description =
|
|
"Domain where this relay is hosted (used in GRASP validation)";
|
|
};
|
|
|
|
basePath = mkOption {
|
|
type = types.str;
|
|
default = "/";
|
|
example = "/grasp";
|
|
description = ''
|
|
Public URL path where this relay is mounted. Use "/" for a
|
|
domain-root deployment or a normalized path such as "/grasp".
|
|
'';
|
|
};
|
|
|
|
bindAddress = mkOption {
|
|
type = types.str;
|
|
default = "127.0.0.1";
|
|
description = "IP address to bind to";
|
|
};
|
|
|
|
trustedProxyCidrs = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "127.0.0.1/32" "::1/128" "10.0.0.0/8" ];
|
|
description = ''
|
|
IP address ranges for reverse proxies whose Forwarded,
|
|
X-Forwarded-For, or X-Real-IP headers may identify WebSocket
|
|
clients. Leave empty unless ngit-grasp is behind a trusted proxy.
|
|
Keep the backend unreachable from untrusted networks and include
|
|
every trusted proxy hop needed to resolve a forwarding chain.
|
|
'';
|
|
};
|
|
|
|
port = mkOption {
|
|
type = types.port;
|
|
default = 7334;
|
|
description = "Port to listen on";
|
|
};
|
|
|
|
dataDir = mkOption {
|
|
type = types.path;
|
|
default = "/var/lib/ngit-grasp-${name}";
|
|
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 = "/run/agenix/ngit-grasp-relay-owner-nsec";
|
|
description = ''
|
|
Runtime secret file containing the relay owner's nsec (private key),
|
|
used for the NIP-05 root identity when served at the domain root,
|
|
startup kind-0/kind-10002 seeding, trusted service-event
|
|
admission, NIP-11 relay information, and relay authentication.
|
|
The service receives it as a systemd credential named
|
|
`${relayOwnerNsecCredential}`, so the secret is never placed in the
|
|
process command line.
|
|
|
|
Leave this null to use relayOwnerNsec or to load/generate
|
|
.relay-owner.nsec in dataDir. If set, point it at a runtime secret
|
|
file (agenix, sops-nix, etc.), not a Nix-store path.
|
|
ngit-grasp does not modify this external source file; its ownership
|
|
and permissions remain the operator or secret manager's responsibility.
|
|
'';
|
|
};
|
|
|
|
relayOwnerNsec = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
example = "nsec1...";
|
|
description = ''
|
|
Relay owner's nsec (private key) for the NIP-05 root identity when
|
|
served at the domain root, startup kind-0/kind-10002 seeding,
|
|
trusted service-event admission, NIP-11 relay information, 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)";
|
|
};
|
|
|
|
syncPlusEnabled = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = ''
|
|
Enable GRASP-03 Sync+ mailbox discovery on top of proactive
|
|
GRASP-02 sync. This has no effect unless the relay service, and
|
|
therefore its proactive sync manager, is enabled.
|
|
'';
|
|
};
|
|
|
|
userIndexRelays = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [
|
|
"wss://purplepag.es"
|
|
"wss://index.hzrd149.com"
|
|
"wss://indexer.coracle.social"
|
|
];
|
|
description = ''
|
|
Relays receiving the relay-owner kind-0/kind-10002 identity events
|
|
and used to discover eligible accepted repository participants'
|
|
NIP-65 relay lists.
|
|
'';
|
|
};
|
|
|
|
syncPlusFallbackRelays = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [
|
|
"wss://relay.ditto.pub"
|
|
"wss://relay.damus.io"
|
|
"wss://nos.lol"
|
|
"wss://relay.primal.net"
|
|
];
|
|
description = ''
|
|
Bounded inbox fallback relays used for eligible Sync+ authors only
|
|
after a user-index query succeeds without a NIP-65 relay list.
|
|
'';
|
|
};
|
|
|
|
syncRecursiveDescendantLimit = mkOption {
|
|
type = types.ints.positive;
|
|
default = 500;
|
|
description = ''
|
|
Soft limit on recursive query-frontier members below each event
|
|
which directly tags a repository root. Direct root-tagging events
|
|
do not consume the limit.
|
|
'';
|
|
};
|
|
|
|
databaseBackend = mkOption {
|
|
type = types.enum [ "lmdb" "memory" ];
|
|
default = "lmdb";
|
|
description = ''
|
|
Database backend type:
|
|
- lmdb: LMDB backend (persistent, general purpose)
|
|
- memory: In-memory database (fastest, no persistence)
|
|
'';
|
|
};
|
|
|
|
startupIntegrityIdentifiers = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "repo-one" "repo-two" ];
|
|
description = ''
|
|
Repository identifiers checked by the automatic startup storage-
|
|
and authorization-integrity passes. An empty list checks every
|
|
installed identifier family and is required for a complete sweep.
|
|
A non-empty list is intended only for staged validation.
|
|
'';
|
|
};
|
|
|
|
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.str;
|
|
default = "info";
|
|
example = "debug";
|
|
description = ''
|
|
Application logging level or explicit tracing filter expression.
|
|
Bare levels keep dependency logging at warn.
|
|
Can be a simple level (trace, debug, info, warn, error) or a filter expression.
|
|
Examples: "info", "debug", "ngit_grasp=debug,actix_web=info"
|
|
'';
|
|
};
|
|
|
|
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)";
|
|
};
|
|
|
|
syncAllowNonGlobalTargets = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Allow event-directed sync targets that are not globally reachable
|
|
(loopback, private, link-local addresses and local hostnames).
|
|
Disables the SSRF protection for untrusted announcement/PR URLs;
|
|
intended only for tests and closed development networks. The
|
|
operator-configured bootstrap relay is always allowed regardless.
|
|
'';
|
|
};
|
|
|
|
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";
|
|
};
|
|
|
|
holdingRetentionSecs = mkOption {
|
|
type = types.int;
|
|
default = 7776000;
|
|
description =
|
|
"Retention window in seconds for deleted events in holding DB (default: 90 days)";
|
|
};
|
|
|
|
holdingCleanupIntervalSecs = mkOption {
|
|
type = types.int;
|
|
default = 86400;
|
|
description =
|
|
"Interval in seconds between holding DB and deletion-request retention cleanup passes (default: 24 hours)";
|
|
};
|
|
|
|
deletionRequestRetention = {
|
|
unusedServedSecs = mkOption {
|
|
type = types.ints.positive;
|
|
default = 2592000;
|
|
description = ''
|
|
Time in seconds an unused deletion or vanish request remains served
|
|
from relay-observed first_seen_at (default: 30 days). Production
|
|
periods should be at least one day; sub-day values are for tests.
|
|
'';
|
|
};
|
|
|
|
unusedUnservedGatingAdditionalSecs = mkOption {
|
|
type = types.ints.positive;
|
|
default = 15552000;
|
|
description = ''
|
|
Additional time in seconds after unused serving ends that a deletion
|
|
or vanish request remains unserved but eligible to gate admission
|
|
(default: 180 days). Production periods should be at least one day;
|
|
sub-day values are for tests.
|
|
'';
|
|
};
|
|
|
|
usedServedAfterLastUsedSecs = mkOption {
|
|
type = types.ints.positive;
|
|
default = 23328000;
|
|
description = ''
|
|
Normal-mode time in seconds a used deletion or vanish request
|
|
remains served after last_used_at (default: 270 days, 9 fixed
|
|
30-day months). Used requests remain served indefinitely when
|
|
deletionRequestDisrespector is enabled. Production periods should
|
|
be at least one day; sub-day values are for tests.
|
|
'';
|
|
};
|
|
|
|
usedUnservedGatingAdditionalSecs = mkOption {
|
|
type = types.ints.positive;
|
|
default = 7776000;
|
|
description = ''
|
|
Additional normal-mode time in seconds after used serving ends that
|
|
a deletion or vanish request remains unserved but continues gating
|
|
admission (default: 90 days, 3 fixed 30-day months). This duration
|
|
does not expire used requests when deletionRequestDisrespector is
|
|
enabled. Production periods should be at least one day; sub-day
|
|
values are for tests.
|
|
'';
|
|
};
|
|
};
|
|
|
|
archiveAll = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Enable GRASP-05 archive mode: accept all repository announcements.
|
|
WARNING: Storage and bandwidth risk.
|
|
'';
|
|
};
|
|
|
|
archiveWhitelist = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "npub1alice..." "npub1bob.../linux" "bitcoin-core" ];
|
|
description = ''
|
|
GRASP-05 archive whitelist entries.
|
|
Formats: <npub>, <npub>/<identifier>, <identifier>
|
|
'';
|
|
};
|
|
|
|
archiveGraspServices = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "git.example.com" "git.nostr.dev" ];
|
|
description = ''
|
|
GRASP-05 archive GRASP services: list of GRASP server domains to archive.
|
|
Archives all repositories from the specified GRASP server domains.
|
|
Must be bare domains only (e.g., git.example.com, NOT wss://git.example.com).
|
|
Mutually exclusive with archiveAll and archiveWhitelist.
|
|
Automatically sets archiveReadOnly to true by default.
|
|
'';
|
|
};
|
|
|
|
archiveReadOnly = mkOption {
|
|
type = types.nullOr types.bool;
|
|
default = null;
|
|
description = ''
|
|
Archive read-only mode (relay is read-only sync of archived repositories).
|
|
When true:
|
|
- NIP-11 includes GRASP-05 in supported_grasps
|
|
- NIP-11 curation field describes archive scope
|
|
- Repository announcements not listing this service are accepted per whitelist/archive-all
|
|
Default: true if archiveAll, archiveWhitelist, or archiveGraspServices is set, false otherwise
|
|
Note: Setting to true without archive config causes startup error
|
|
Note: Cannot be used with repositoryWhitelist (mutually exclusive)
|
|
'';
|
|
};
|
|
|
|
grasp06Enable = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Enable the GRASP-06 contributor PR submission endpoint at
|
|
/prs/<npub>/<identifier>.git.
|
|
|
|
When enabled, the relay exposes an unauthenticated PR submission
|
|
endpoint that accepts pushes of refs/nostr/<event-id> from any
|
|
contributor. Security relies on the signed PR/PR-Update events the
|
|
refs reference, not on HTTP-level auth.
|
|
|
|
When disabled (default), /prs/* returns 404 and event acceptance is
|
|
unchanged.
|
|
|
|
See: https://gitworkshop.dev/danconwaydev.com/grasp/tree/master/06.md
|
|
'';
|
|
};
|
|
|
|
privateMode = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Enable GRASP-08 private-service authentication. All Nostr clients
|
|
must authenticate with NIP-42 and all standard Git Smart HTTP
|
|
requests must carry a repository-scoped NIP-98 credential.
|
|
Cannot be combined with grasp06Enable.
|
|
'';
|
|
};
|
|
|
|
privateMembers = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "npub1alice..." "npub1bob..." ];
|
|
description = ''
|
|
Permanently configured GRASP-08 service members. The effective
|
|
whitelist also includes NIP-11 owners of relays referenced by
|
|
accepted repository announcements. At least one valid configured
|
|
npub is required when privateMode is enabled; malformed entries make
|
|
the service fail at startup.
|
|
'';
|
|
};
|
|
|
|
privatePublicOrigin = mkOption {
|
|
type = types.str;
|
|
default = "";
|
|
example = "https://private.example";
|
|
description = "Canonical external origin used by private Git NIP-98 credentials.";
|
|
};
|
|
|
|
repositoryWhitelist = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "npub1alice..." "npub1bob.../linux" "bitcoin-core" ];
|
|
description = ''
|
|
Repository whitelist for GRASP-01 acceptance.
|
|
Announcements must BOTH list our service AND match this whitelist.
|
|
Formats: <npub>, <npub>/<identifier>, <identifier>
|
|
Cannot be used with archiveReadOnly=true (mutually exclusive)
|
|
When set, NIP-11 curation field indicates curated repository acceptance
|
|
'';
|
|
};
|
|
|
|
deletionRequestDisrespector = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Ignore NIP-09 deletion requests and NIP-62 request-to-vanish events
|
|
and act as an archival server.
|
|
|
|
When enabled, incoming NIP-09 (kind 5) deletion requests and NIP-62
|
|
request-to-vanish events are stored but NOT acted upon: their targets
|
|
remain fully accessible. This preserves content and prevents
|
|
"left-pad" scenarios. NIP-11 supported_nips will NOT advertise NIP-09
|
|
(deletion) or NIP-62 (request to vanish).
|
|
|
|
This ONLY affects NIP-09 and NIP-62 user-initiated requests. It does
|
|
NOT prevent blacklist-triggered deletions (operator moderation).
|
|
|
|
When disabled (default), both request types are honoured and NIP-09
|
|
and NIP-62 are advertised in NIP-11.
|
|
|
|
See: docs/explanation/repository-lifecycle.md
|
|
'';
|
|
};
|
|
|
|
repositoryBlacklist = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "npub1spam..." "npub1alice.../bad-repo" "malware" ];
|
|
description = ''
|
|
Repository blacklist for blocking specific repositories/pubkeys/identifiers.
|
|
Blacklist takes precedence over ALL whitelists (archive and repository).
|
|
Formats: <npub>, <npub>/<identifier>, <identifier>
|
|
Blacklisted repos are rejected with specific reasons (npub/identifier/both).
|
|
Does not affect NIP-11 curation field (operational, not curation policy).
|
|
'';
|
|
};
|
|
|
|
blacklistAutoRestore = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Automatically restore repositories deleted by blacklist parity on
|
|
startup when they are no longer blacklisted and still within the
|
|
holding retention window.
|
|
'';
|
|
};
|
|
|
|
eventBlacklist = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "npub1spam..." "npub1abuser..." ];
|
|
description = ''
|
|
Event blacklist for blocking all events from specific authors (npubs).
|
|
Takes precedence over ALL other validation (checked first).
|
|
ALL events from these authors are rejected from relay storage and purgatory.
|
|
Applies to announcements, state events, PRs, and all other event types.
|
|
Does not affect NIP-11 metadata (operational, not curation policy).
|
|
'';
|
|
};
|
|
|
|
maxConnections = mkOption {
|
|
type = types.nullOr types.int;
|
|
default = null;
|
|
description =
|
|
"Maximum total connections to the relay (default: unlimited, defers to OS/infrastructure limits)";
|
|
};
|
|
|
|
relayMaxSubscriptions = mkOption {
|
|
type = types.ints.positive;
|
|
default = 500;
|
|
description = "Maximum active REQ subscriptions per WebSocket connection";
|
|
};
|
|
relayMaxEventSizeBytes = mkOption {
|
|
type = types.ints.positive;
|
|
default = 192 * 1024;
|
|
description = "Maximum serialized event size in bytes";
|
|
};
|
|
relayFilterLimit = mkOption {
|
|
type = types.ints.positive;
|
|
default = 500;
|
|
description = "Per-filter result cap, including when limit is omitted";
|
|
};
|
|
|
|
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";
|
|
};
|
|
};
|
|
};
|
|
|
|
# Create systemd setup service to ensure directories exist before main service
|
|
# This runs without namespace restrictions so it can create directories
|
|
# that ReadWritePaths needs to exist before namespace setup
|
|
mkSetupService = name: cfg: {
|
|
description = "Create data directories for ngit-grasp (${name})";
|
|
before = [ "ngit-grasp-${name}.service" ];
|
|
requiredBy = [ "ngit-grasp-${name}.service" ];
|
|
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
ExecStart =
|
|
"${pkgs.bash}/bin/bash -c '${pkgs.coreutils}/bin/mkdir -p \"${cfg.dataDir}/git\" \"${cfg.dataDir}/relay\" && ${pkgs.coreutils}/bin/chown -R ${cfg.user}:${cfg.group} \"${cfg.dataDir}\" && ${pkgs.coreutils}/bin/chmod 750 \"${cfg.dataDir}\" \"${cfg.dataDir}/git\" \"${cfg.dataDir}/relay\"'";
|
|
};
|
|
};
|
|
|
|
# Create systemd service config for an instance
|
|
mkService = name: cfg:
|
|
let
|
|
loadCredentials = optional (cfg.relayOwnerNsecFile != null)
|
|
"${relayOwnerNsecCredential}:${toString cfg.relayOwnerNsecFile}";
|
|
in {
|
|
description = "ngit-grasp GRASP relay (${name})";
|
|
after = [ "network.target" "ngit-grasp-${name}-setup.service" ];
|
|
requires = [ "ngit-grasp-${name}-setup.service" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
|
|
environment = {
|
|
NGIT_DOMAIN = cfg.domain;
|
|
NGIT_BASE_PATH = cfg.basePath;
|
|
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_PLUS_ENABLED = if cfg.syncPlusEnabled then "true" else "false";
|
|
NGIT_SYNC_RECURSIVE_DESCENDANT_LIMIT =
|
|
toString cfg.syncRecursiveDescendantLimit;
|
|
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;
|
|
NGIT_HOLDING_RETENTION_SECS = toString cfg.holdingRetentionSecs;
|
|
NGIT_HOLDING_CLEANUP_INTERVAL_SECS =
|
|
toString cfg.holdingCleanupIntervalSecs;
|
|
NGIT_DELETION_REQUEST_RETENTION_UNUSED_SERVED_SECS =
|
|
toString cfg.deletionRequestRetention.unusedServedSecs;
|
|
NGIT_DELETION_REQUEST_RETENTION_UNUSED_UNSERVED_GATING_ADDITIONAL_SECS =
|
|
toString cfg.deletionRequestRetention.unusedUnservedGatingAdditionalSecs;
|
|
NGIT_DELETION_REQUEST_RETENTION_USED_SERVED_AFTER_LAST_USED_SECS =
|
|
toString cfg.deletionRequestRetention.usedServedAfterLastUsedSecs;
|
|
NGIT_DELETION_REQUEST_RETENTION_USED_UNSERVED_GATING_ADDITIONAL_SECS =
|
|
toString cfg.deletionRequestRetention.usedUnservedGatingAdditionalSecs;
|
|
NGIT_ARCHIVE_ALL = if cfg.archiveAll then "true" else "false";
|
|
NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist;
|
|
NGIT_ARCHIVE_GRASP_SERVICES =
|
|
concatStringsSep "," cfg.archiveGraspServices;
|
|
NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist;
|
|
NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist;
|
|
NGIT_BLACKLIST_AUTO_RESTORE =
|
|
if cfg.blacklistAutoRestore then "true" else "false";
|
|
NGIT_EVENT_BLACKLIST = concatStringsSep "," cfg.eventBlacklist;
|
|
NGIT_LOG_LEVEL = cfg.logLevel;
|
|
NGIT_GRASP06_ENABLE = if cfg.grasp06Enable then "true" else "false";
|
|
NGIT_PRIVATE_MODE = if cfg.privateMode then "true" else "false";
|
|
NGIT_PRIVATE_MEMBERS = concatStringsSep "," cfg.privateMembers;
|
|
NGIT_PRIVATE_PUBLIC_ORIGIN = cfg.privatePublicOrigin;
|
|
NGIT_DELETION_REQUEST_DISRESPECTOR =
|
|
if cfg.deletionRequestDisrespector then "true" else "false";
|
|
NGIT_RELAY_MAX_SUBSCRIPTIONS = toString cfg.relayMaxSubscriptions;
|
|
NGIT_RELAY_MAX_EVENT_SIZE_BYTES = toString cfg.relayMaxEventSizeBytes;
|
|
NGIT_RELAY_FILTER_LIMIT = toString cfg.relayFilterLimit;
|
|
NGIT_USER_INDEX_RELAYS = concatStringsSep "," cfg.userIndexRelays;
|
|
NGIT_SYNC_PLUS_FALLBACK_RELAYS =
|
|
concatStringsSep "," cfg.syncPlusFallbackRelays;
|
|
} // optionalAttrs (cfg.maxConnections != null) {
|
|
NGIT_MAX_CONNECTIONS = toString cfg.maxConnections;
|
|
} // optionalAttrs (cfg.trustedProxyCidrs != [ ]) {
|
|
NGIT_TRUSTED_PROXY_CIDRS = concatStringsSep "," cfg.trustedProxyCidrs;
|
|
} // optionalAttrs (cfg.startupIntegrityIdentifiers != [ ]) {
|
|
NGIT_STARTUP_INTEGRITY_IDENTIFIERS =
|
|
concatStringsSep "," cfg.startupIntegrityIdentifiers;
|
|
} // optionalAttrs (cfg.relayName != null) {
|
|
NGIT_RELAY_NAME = cfg.relayName;
|
|
} // optionalAttrs (cfg.archiveReadOnly != null) {
|
|
NGIT_ARCHIVE_READ_ONLY = if cfg.archiveReadOnly then "true" else "false";
|
|
} // 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.syncAllowNonGlobalTargets {
|
|
NGIT_SYNC_ALLOW_NON_GLOBAL_TARGETS = "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;
|
|
|
|
# Directory creation is handled by ngit-grasp-${name}-setup.service
|
|
# which runs before this service and creates dataDir with proper ownership
|
|
|
|
# Add git, openssh, and coreutils to PATH for purgatory sync operations
|
|
Environment =
|
|
"PATH=${pkgs.git}/bin:${pkgs.openssh}/bin:${pkgs.coreutils}/bin";
|
|
|
|
# The binary reads systemd credentials and environment configuration
|
|
# itself, keeping relay-owner secrets out of process arguments.
|
|
ExecStart = "${ngit-grasp}/bin/ngit-grasp";
|
|
|
|
# Restart policy
|
|
Restart = "always";
|
|
RestartSec = "10s";
|
|
UMask = "0077";
|
|
|
|
# Hardening
|
|
NoNewPrivileges = true;
|
|
PrivateTmp = true;
|
|
ProtectSystem = "strict";
|
|
ProtectHome = true;
|
|
ReadWritePaths = [ cfg.dataDir ];
|
|
|
|
# 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";
|
|
} // optionalAttrs (loadCredentials != [ ]) {
|
|
LoadCredential = loadCredentials;
|
|
};
|
|
|
|
# Directory creation handled by both ExecStartPre (above) and tmpfiles (below)
|
|
# ExecStartPre ensures directories exist at service start time
|
|
# tmpfiles provides boot-time setup and consistency
|
|
};
|
|
|
|
enabledInstances =
|
|
filterAttrs (_: cfg: cfg.enable) config.services.ngit-grasp;
|
|
|
|
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
|
|
# Each instance has a setup service (creates directories) and main service
|
|
systemd.services = (mapAttrs'
|
|
(name: cfg: nameValuePair "ngit-grasp-${name}" (mkService name cfg))
|
|
enabledInstances) // (mapAttrs' (name: cfg:
|
|
nameValuePair "ngit-grasp-${name}-setup" (mkSetupService name cfg))
|
|
enabledInstances);
|
|
|
|
# Create data directories with proper ownership using tmpfiles
|
|
# This runs as root before the service starts
|
|
# Note: Parent directories are created with root:root ownership (mode 0755)
|
|
# to ensure the path exists, while dataDir itself gets proper service ownership
|
|
systemd.tmpfiles.rules = flatten (mapAttrsToList (name: cfg: [
|
|
# Create parent directories if they don't exist (root-owned, standard perms)
|
|
"d ${dirOf cfg.dataDir} 0755 root root -"
|
|
# Create service-owned directories
|
|
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
|
|
"d ${cfg.dataDir}/git 0750 ${cfg.user} ${cfg.group} -"
|
|
"d ${cfg.dataDir}/relay 0750 ${cfg.user} ${cfg.group} -"
|
|
]) enabledInstances);
|
|
};
|
|
}
|