diff --git a/modules/default.nix b/modules/default.nix index ee5387e6..bb4e5785 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -18,6 +18,7 @@ ./prysm-beacon ./prysm-validator ./restore + ./reth ]; }; } diff --git a/modules/reth/args.nix b/modules/reth/args.nix new file mode 100644 index 00000000..347a0e13 --- /dev/null +++ b/modules/reth/args.nix @@ -0,0 +1,166 @@ +lib: +with lib; { + datadir = mkOption { + type = types.nullOr types.str; + default = null; + description = "Data directory for Reth. Defaults to '%S/reth-\', which generally resolves to /var/lib/reth-\."; + }; + + port = mkOption { + type = types.port; + default = 30303; + description = "Network listening port."; + }; + + chain = mkOption { + type = types.enum [ + "mainnet" + "sepolia" + "holesky" + "dev" + ]; + default = "mainnet"; + description = "Name of the network to join. If null the network is mainnet."; + }; + + full = mkOption { + type = types.bool; + default = false; + description = "Run full node. Only the most recent [`MINIMUM_PRUNING_DISTANCE`] block states are stored. This flag takes priority over pruning configuration in reth.toml"; + }; + + http = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable HTTP-RPC server"; + }; + + addr = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "HTTP-RPC server listening interface."; + }; + + port = mkOption { + type = types.port; + default = 8545; + description = "HTTP-RPC server listening port."; + }; + + corsdomain = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + description = "List of domains from which to accept cross origin requests."; + example = ["*"]; + }; + + api = mkOption { + type = types.nullOr (types.listOf types.str); + description = "API's offered over the HTTP-RPC interface."; + example = ["net" "eth"]; + }; + }; + + ws = { + enable = mkEnableOption "Reth WebSocket API"; + addr = mkOption { + type = types.nullOr types.str; + default = null; + description = "WS server listening interface."; + example = "127.0.0.1"; + }; + + port = mkOption { + type = types.nullOr types.port; + default = null; + description = "WS server listening port."; + example = 8545; + }; + + origins = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + description = "List of origins from which to accept `WebSocket` requests"; + }; + + api = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + description = "API's offered over the WS interface."; + example = ["net" "eth"]; + }; + }; + + authrpc = { + addr = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "HTTP-RPC server listening interface for the Engine API."; + }; + + port = mkOption { + type = types.port; + default = 8551; + description = "HTTP-RPC server listening port for the Engine API"; + }; + + jwtsecret = mkOption { + type = types.nullOr types.str; + default = null; + description = "Path to the token that ensures safe connection between CL and EL."; + example = "/var/run/reth/jwtsecret"; + }; + }; + + metrics = { + enable = mkEnableOption "Enable Prometheus metrics collection and reporting."; + + addr = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Enable stand-alone metrics HTTP server listening interface."; + }; + + port = mkOption { + type = types.port; + default = 6060; + description = "Metrics HTTP server listening port"; + }; + }; + + log = let + mkFormatOpt = channel: + mkOption { + type = types.nullOr (types.enum ["terminal" "log-fmt" "json"]); + default = null; + description = "The format to use for logs written to ${channel}."; + example = "log-fmt"; + }; + mkFilterOpt = channel: + mkOption { + type = types.nullOr types.str; + default = null; + description = "The filter to use for logs written to ${channel}."; + example = "info"; + }; + in { + stdout = { + format = mkFormatOpt "stdout"; + filter = mkFilterOpt "stdout"; + }; + file = { + format = mkFormatOpt "the log file"; + filter = mkFilterOpt "the log file"; + directory = mkOption { + type = types.nullOr types.str; + default = null; + description = "The path to put log files in"; + example = "/var/log/reth"; + }; + }; + journald = { + filter = mkFilterOpt "journald"; + }; + }; +} diff --git a/modules/reth/default.nix b/modules/reth/default.nix new file mode 100644 index 00000000..bc9db4e8 --- /dev/null +++ b/modules/reth/default.nix @@ -0,0 +1,140 @@ +{ + config, + lib, + pkgs, + ... +}: let + inherit (lib.lists) optionals findFirst; + inherit (lib.strings) hasPrefix; + inherit + (lib) + concatStringsSep + filterAttrs + flatten + mapAttrs' + mapAttrsToList + mkIf + mkMerge + nameValuePair + zipAttrsWith + ; + + modulesLib = import ../lib.nix lib; + inherit (modulesLib) baseServiceConfig mkArgs dotPathReducer; + + eachNode = config.services.ethereum.reth; +in { + # Disable the service definition currently in nixpkgs + disabledModules = ["services/blockchain/ethereum/reth.nix"]; + + ###### interface + inherit (import ./options.nix {inherit lib pkgs;}) options; + + ###### implementation + + config = mkIf (eachNode != {}) { + # configure the firewall for each service + networking.firewall = let + openFirewall = filterAttrs (_: cfg: cfg.openFirewall) eachNode; + perService = + mapAttrsToList + ( + _: cfg: + with cfg.args; { + allowedTCPPorts = + [port authrpc.port] + ++ (optionals http.enable [http.port]) + ++ (optionals ws.enable [ws.port]) + ++ (optionals metrics.enable [metrics.port]); + } + ) + openFirewall; + in + zipAttrsWith (_name: flatten) perService; + + # configure systemd to create the state directory with a subvolume + systemd.tmpfiles.rules = + map + (name: "v /var/lib/private/reth-${name}") + (builtins.attrNames (filterAttrs (_: v: v.subVolume) eachNode)); + + # create a service for each instance + systemd.services = + mapAttrs' + ( + rethName: let + serviceName = "reth-${rethName}"; + in + cfg: let + scriptArgs = let + args = mkArgs { + opts = import ./args.nix lib; + pathReducer = dotPathReducer; + args = builtins.removeAttrs cfg.args ["ws"]; + }; + + wsArgs = mkArgs { + opts = import ./args.nix lib; + args = { + ws = builtins.removeAttrs cfg.args.ws ["enable"]; + }; + }; + + # filter out certain args which need to be treated differently + specialArgs = [ + "--authrpc.jwtsecret" + "--http.enable" + "--metrics.enable" + "--metrics.addr" + "--metrics.port" + ]; + + isNormalArg = name: (findFirst (arg: hasPrefix arg name) null specialArgs) == null; + filteredArgs = + (builtins.filter isNormalArg args) + ++ (optionals cfg.args.http.enable ["--http"]) + ++ (optionals cfg.args.ws.enable wsArgs) + ++ (optionals cfg.args.metrics.enable ["--metrics" "${cfg.args.metrics.addr}:${toString cfg.args.metrics.port}"]); + + jwtSecret = + if cfg.args.authrpc.jwtsecret != null + then "--authrpc.jwtsecret %d/jwtsecret" + else ""; + + datadir = + if cfg.args.datadir != null + then "${cfg.args.datadir}" + else "%S/${serviceName}"; + in '' + --log.file.directory ${datadir}/logs \ + --datadir ${datadir} \ + ${jwtSecret} \ + ${concatStringsSep " \\\n" filteredArgs} \ + ${lib.escapeShellArgs cfg.extraArgs} + ''; + in + nameValuePair serviceName (mkIf cfg.enable { + description = "Reth Ethereum node (${rethName})"; + wantedBy = ["multi-user.target"]; + after = ["network.target"]; + + # create service config by merging with the base config + serviceConfig = mkMerge [ + baseServiceConfig + { + User = serviceName; + StateDirectory = serviceName; + ExecStart = "${cfg.package}/bin/reth node ${scriptArgs}"; + + # Reth needs this system call for some reason + SystemCallFilter = ["@system-service" "~@privileged" "mincore"]; + } + (mkIf (cfg.args.authrpc.jwtsecret != null) { + LoadCredential = ["jwtsecret:${cfg.args.authrpc.jwtsecret}"]; + }) + ]; + }) + ) + eachNode; + }; +} diff --git a/modules/reth/options.nix b/modules/reth/options.nix new file mode 100644 index 00000000..f086526d --- /dev/null +++ b/modules/reth/options.nix @@ -0,0 +1,43 @@ +{ + lib, + pkgs, + ... +}: let + args = import ./args.nix lib; + + rethOpts = with lib; { + options = { + enable = mkEnableOption "Reth Ethereum Node."; + + subVolume = mkEnableOption "Use a subvolume for the state directory if the underlying filesystem supports it e.g. btrfs"; + + inherit args; + + extraArgs = mkOption { + type = types.listOf types.str; + description = "Additional arguments to pass to Reth."; + default = []; + }; + + package = mkOption { + type = types.package; + default = pkgs.reth; + defaultText = literalExpression "pkgs.reth"; + description = "Package to use as Reth node."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = lib."Open ports in the firewall for any enabled networking services"; + }; + }; + }; +in { + options.services.ethereum.reth = with lib; + mkOption { + type = types.attrsOf (types.submodule rethOpts); + default = {}; + description = "Specification of one or more Reth instances."; + }; +}