{ config, lib, pkgs, ... }: let cfg = config.services.chainfire; tomlFormat = pkgs.formats.toml { }; stripLeadingZeros = digits: if digits == "" then "" else if lib.hasPrefix "0" digits then stripLeadingZeros (lib.removePrefix "0" digits) else digits; numericIdString = value: let captures = builtins.match ".*?([0-9]+)$" value; digits = if captures == null then throw "services.chainfire.nodeId must end with digits (got '${value}')" else builtins.elemAt captures 0; normalized = stripLeadingZeros digits; in if normalized == "" then "0" else normalized; numericId = value: builtins.fromJSON (numericIdString value); hostFromAddr = addr: let captures = builtins.match "(.+):[0-9]+" addr; in if captures == null then null else builtins.elemAt captures 0; apiAddrArg = if cfg.apiAddr != null then cfg.apiAddr else "0.0.0.0:${toString cfg.port}"; raftAddrArg = if cfg.raftAddr != null then cfg.raftAddr else "0.0.0.0:${toString cfg.raftPort}"; gossipAddrArg = if cfg.gossipAddr != null then cfg.gossipAddr else let host = hostFromAddr apiAddrArg; in if host != null then "${host}:${toString cfg.gossipPort}" else "0.0.0.0:${toString cfg.gossipPort}"; initialMembers = map (peer: let parts = lib.splitString "=" peer; rawId = if builtins.length parts == 2 then builtins.elemAt parts 0 else throw "services.chainfire.initialPeers entries must be 'nodeId=host:port' (got '${peer}')"; raftAddr = builtins.elemAt parts 1; in { id = numericId rawId; raft_addr = raftAddr; }) cfg.initialPeers; chainfireConfigFile = tomlFormat.generate "chainfire.toml" { node = { id = numericId cfg.nodeId; name = cfg.nodeId; role = cfg.role; }; storage = { data_dir = toString cfg.dataDir; }; network = { api_addr = apiAddrArg; http_addr = "0.0.0.0:${toString cfg.httpPort}"; raft_addr = raftAddrArg; gossip_addr = gossipAddrArg; }; cluster = { id = cfg.clusterId; initial_members = initialMembers; bootstrap = cfg.bootstrap; }; raft = { role = cfg.raftRole; }; }; in { options.services.chainfire = { enable = lib.mkEnableOption "chainfire cluster coordination service"; nodeId = lib.mkOption { type = lib.types.str; default = config.networking.hostName; description = "Unique node identifier for the Raft cluster"; }; port = lib.mkOption { type = lib.types.port; default = 2379; description = "Port for chainfire API"; }; raftPort = lib.mkOption { type = lib.types.port; default = 2380; description = "Port for chainfire Raft protocol"; }; raftAddr = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "Full address for Raft (host:port). If null, uses 0.0.0.0:raftPort"; }; apiAddr = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "Full address for API (host:port). If null, uses 0.0.0.0:port"; }; initialPeers = lib.mkOption { type = lib.types.listOf lib.types.str; default = []; description = "Initial Raft peers for cluster bootstrap (format: nodeId=addr:port)"; example = [ "node01=10.0.0.1:2380" "node02=10.0.0.2:2380" ]; }; gossipPort = lib.mkOption { type = lib.types.port; default = 2381; description = "Port for chainfire gossip protocol"; }; httpPort = lib.mkOption { type = lib.types.port; default = 8081; description = "Port for chainfire HTTP/admin API (used for cluster join)"; }; gossipAddr = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "Full gossip advertise/listen address (host:port). If null, derives from apiAddr host and gossipPort."; }; clusterId = lib.mkOption { type = lib.types.int; default = 1; description = "Cluster identifier written into the ChainFire config."; }; bootstrap = lib.mkOption { type = lib.types.bool; default = true; description = "Whether this node boots using the configured initial_members set."; }; role = lib.mkOption { type = lib.types.enum [ "control_plane" "worker" ]; default = "control_plane"; description = "Logical node role advertised through ChainFire gossip metadata."; }; raftRole = lib.mkOption { type = lib.types.enum [ "voter" "learner" "none" ]; default = "voter"; description = "Raft participation role written into the ChainFire config."; }; dataDir = lib.mkOption { type = lib.types.path; default = "/var/lib/chainfire"; description = "Data directory for chainfire"; }; settings = lib.mkOption { type = lib.types.attrs; default = {}; description = "Additional configuration settings"; }; package = lib.mkOption { type = lib.types.package; default = pkgs.chainfire-server or (throw "chainfire-server package not found"); description = "Package to use for chainfire"; }; }; config = lib.mkIf cfg.enable { # Create system user users.users.chainfire = { isSystemUser = true; group = "chainfire"; description = "Chainfire service user"; home = cfg.dataDir; }; users.groups.chainfire = {}; # Create systemd service systemd.services.chainfire = { description = "Chainfire Distributed Cluster Coordination Service"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; serviceConfig = { Type = "simple"; User = "chainfire"; Group = "chainfire"; Restart = "on-failure"; RestartSec = "10s"; # State directory management StateDirectory = "chainfire"; StateDirectoryMode = "0750"; # Security hardening NoNewPrivileges = true; PrivateTmp = true; ProtectSystem = "strict"; ProtectHome = true; ReadWritePaths = [ cfg.dataDir ]; ExecStart = "${cfg.package}/bin/chainfire --config ${chainfireConfigFile}"; }; }; }; }