commit 90d1972cdc46e00ca2ebf0a67272618e94e991c4 Author: stakeynet Date: Sat Nov 29 12:23:09 2025 -0800 init: dcrd, dcrwallet, and dcrctl at 2.0.6. init modules for dcrd and dcrwallet Signed-off-by: stakeynet diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..304ed32 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.stfolder/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e24263 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# nix-decred + +nix-decred is a collection of Nix packages and NixOS modules, inspired by [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin). + +Overview +--- +While nix-decred is used in production today, it is considered experimental. + +nix-decred nodes can be deployed on dedicated hardware, virtual machines or containers. +The Nix packages and NixOS modules can be used independently and combined freely. + +nix-decred is built on top of Nix and [NixOS](https://nixos.org/) which provide powerful abstractions to keep it highly customizable and maintainable. Running nix-decred currently requires previous experience with the Nix ecosystem, as no support is provided. + +Security Considerations +--- +Using this flake could be dangerous, as it provides the hashes for the software you'll be running. + +If you want to use this today, the sane way would be to fork the project, review the codebase, and then review and merge all future changes as necessary. If you're familiar with nix, forking should still be faster than writing the packages and modules from scratch yourself. + +The git server for this project runs on a shared virtual server, so don't trust unsigned or unverified messages. + +Support +--- +No support is provided. If you have ideas for improvement, general feedback, or a PR, you can tag @stakeynet on the Decred matrix channels. diff --git a/docs/dcrd.md b/docs/dcrd.md new file mode 100644 index 0000000..1a39f9f --- /dev/null +++ b/docs/dcrd.md @@ -0,0 +1,49 @@ +# dcrd options + +`dcrd` uses rpc credentials, so it's recommended to secure your secrets using a tool like [sops-nix](https://github.com/Mic92/sops-nix). + +## sops-nix + +Render `dcrd.conf` with `sops-nix` and point the service at it. Example: + +```nix +{ config, lib, pkgs, ... }: +{ + # Define credentials as secrets + sops.secrets."dcrd/rpcuser" = {}; + sops.secrets."dcrd/rpcpass" = {}; + + # Render dcrd.conf owned by the dcrd service user/group + sops.templates."dcrd.conf" = { + owner = config.services.dcrd.user; + group = config.services.dcrd.group; + mode = "0440"; + restartUnits = [ "dcrd.service" ]; + content = '' + [Application Options] + # example settings + rpcuser=${config.sops.placeholder."dcrd/rpcuser"} + rpcpass=${config.sops.placeholder."dcrd/rpcpass"} + ''; + }; + + # Ensure dcrd only starts when the config exists + systemd.services.dcrd.unitConfig.ConditionPathExists = + config.sops.templates."dcrd.conf".path; + + # Point the module to the rendered config + services.dcrd = { + enable = true; + configFile = config.sops.templates."dcrd.conf".path; + }; + +} +``` + +## Notes + +Instead of using DynamicUser, the module necessarily runs `dcrd` as a fixed `User`/`Group` and uses a non-private state directory so `rpc.cert` can be read by intended consumers. + +- `dcrd` writes `rpc.cert` into its data dir (`--appdata`, defaulting to `/var/lib/dcrd`). +- Consumers (e.g., wallets, tooling) often need to read `rpc.cert` without elevated privileges. +- With `DynamicUser=true`, systemd places state under `/var/lib/private/dcrd` and uses an ephemeral UID, which prevents other users/services from traversing the directory and reading `rpc.cert`. diff --git a/docs/dcrwallet.md b/docs/dcrwallet.md new file mode 100644 index 0000000..43a8c6b --- /dev/null +++ b/docs/dcrwallet.md @@ -0,0 +1,95 @@ +# dcrwallet options + +`dcrwallet` uses rpc credentials, so it's recommended to secure your secrets using a tool like [sops-nix](https://github.com/Mic92/sops-nix). + +## sops-nix + +Render `dcrwallet.conf` with `sops-nix` and point the service at it. For example, here's a sample configuration for a voting wallet: + +```nix +{ config, lib, pkgs, ... }: +{ + # Define credentials as secrets + sops.secrets."dcrwallet/rpcuser" = {}; + sops.secrets."dcrwallet/rpcpass" = {}; + + sops.templates."dcrctl.conf" = { + path = "/home/operator/.dcrctl/dcrctl.conf"; + owner = "operator"; + group = "users"; + mode = "0400"; + content = '' + [Application Options] + rpcuser=${config.sops.placeholder."dcrwallet/rpcuser"} + rpcpass=${config.sops.placeholder."dcrwallet/rpcpass"} + rpccert=/var/lib/dcrwallet/rpc.cert + wallet=1 + ''; + }; + sops.templates."dcrwallet.conf" = { + owner = config.services.dcrwallet.user; + group = config.services.dcrwallet.group; + mode = "0440"; + restartUnits = [ "dcrwallet.service" ]; + content = '' + [Application Options] + CAFile=/var/lib/dcrd/rpc.cert + rpclisten=0.0.0.0:9110 + username=${config.sops.placeholder."dcrwallet/rpcpass"} + password=${config.sops.placeholder."dcrwallet/rpcpass"} + enablevoting=1 + manualtickets=1 + ''; + }; + + # Ensure dcrwallet only starts when the config exists + systemd.services.dcrwallet.unitConfig.ConditionPathExists = config.sops.templates."dcrwallet.conf".path; + + services.dcrwallet = { + enable = true; + configFile = config.sops.templates."dcrwallet.conf".path; + extraPackages = [ + pkgs.dcrctl + pkgs.dcrd # promptsecret + ]; + operator = { + enable = true; + name = "stakey"; + }; + }; +} +``` + +## Initialization + +If you run dcrwallet as a service, here's how to intialize a wallet as `root`. + +```bash +cd /var/lib/dcrwallet +export DCRWALLET_BIN=$(systemctl cat --runtime dcrwallet.service | grep ExecStart | awk '{print $1}' | cut -d= -f2) +doas -u dcrwallet $DCRWALLET_BIN \ + --configfile=/run/secrets/rendered/dcrwallet.conf \ + --appdata=/var/lib/dcrwallet \ + --create +``` + +Then you need to start dcrwallet manually the first time to sync. + +```sh +tmux new "doas -u dcrwallet $DCRWALLET_BIN --configfile=/run/secrets/rendered/dcrwallet.conf --appdata=/var/lib/dcrwallet" +``` + +## Using the operator + +```sh +su - operator +dcrctl help +``` + +## Enable Voting + +Use the operator account. + +```sh + promptsecret | dcrwallet walletpassphrase - 0 +``` diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..979e33c --- /dev/null +++ b/flake.nix @@ -0,0 +1,43 @@ +{ + description = '' + A collection of Nix packages and NixOS modules for easily + installing full-featured Decred nodes. + ''; + + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: let + supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + in { + packages = forAllSystems (system: let + pkgs = nixpkgs.legacyPackages.${system}; + in { + dcrd = pkgs.callPackage ./pkgs/dcrd.nix {}; + dcrctl = pkgs.callPackage ./pkgs/dcrctl.nix {}; + dcrwallet = pkgs.callPackage ./pkgs/dcrwallet.nix {}; + }); + + overlays.default = final: prev: { + dcrd = final.callPackage ./pkgs/dcrd.nix {}; + dcrctl = final.callPackage ./pkgs/dcrctl.nix {}; + dcrwallet = final.callPackage ./pkgs/dcrwallet.nix {}; + }; + + nixosModules = { + dcrd = ./modules/dcrd.nix; + dcrwallet = ./modules/dcrwallet.nix; + default = { config, lib, pkgs, ... }: { + imports = [ + self.nixosModules.dcrd + self.nixosModules.dcrwallet + ]; + nixpkgs.overlays = [ self.overlays.default ]; + }; + }; + }; +} + diff --git a/modules/dcrd.nix b/modules/dcrd.nix new file mode 100644 index 0000000..b84936f --- /dev/null +++ b/modules/dcrd.nix @@ -0,0 +1,123 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.services.dcrd; + settingsFormat = pkgs.formats.ini { + listsAsDuplicateKeys = true; + }; + networkPorts = { + mainnet = 9108; + testnet = 19108; + simnet = 18555; + regnet = 18655; + }; + effectivePort = if cfg.port == null then networkPorts.${cfg.network} else cfg.port; + # dcrd requires settings to be lowercase + lowercaseKeys = attrs: lib.mapAttrs' (k: v: { name = lib.strings.toLower k; value = v; }) attrs; + baseAppOptions = + {} + // (if cfg.network == "testnet" then { testnet = true; } + else if cfg.network == "simnet" then { simnet = true; } + else if cfg.network == "regnet" then { regnet = true; } + else {}) + // lib.optionalAttrs (cfg.port != null || cfg.listenAddress != "0.0.0.0") { + listen = "${cfg.listenAddress}:${toString effectivePort}"; + }; + combinedAppOptions = lib.recursiveUpdate baseAppOptions (lowercaseKeys cfg.settings); + finalSettings = { "Application Options" = combinedAppOptions; }; + generatedConfig = settingsFormat.generate "dcrd.conf" finalSettings; +in +{ + options.services.dcrd = with lib; { + enable = mkEnableOption "Decred daemon"; + package = mkOption { + type = types.package; + default = pkgs.dcrd; + description = "Package providing the dcrd binary"; + }; + network = mkOption { + type = types.enum [ "mainnet" "testnet" "simnet" "regnet" ]; + default = "mainnet"; + description = "Select which network to run: mainnet, testnet, simnet, or regnet"; + }; + port = mkOption { + type = types.nullOr types.port; + default = null; + description = "P2P port override. Null uses the default for the selected network"; + }; + listenAddress = mkOption { + type = types.str; + default = "0.0.0.0"; + description = "IP address to bind to listen for connections"; + }; + openFirewall = mkOption { + type = types.bool; + default = true; + description = "Open P2P port in the firewall"; + }; + configFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Absolute path to a dcrd.conf to use instead of the generated config. When set, the generated settings are ignored."; + }; + user = mkOption { + type = types.str; + default = "dcrd"; + description = "User to run dcrd as"; + }; + group = mkOption { + type = types.str; + default = cfg.user; + description = "Group to run dcrd as"; + }; + settings = mkOption { + type = types.submodule { + freeformType = types.attrsOf settingsFormat.lib.types.atom; + }; + default = {}; + example = { + externalip = [ "203.0.113.42" ]; + rpclisten = [ "127.0.0.1:9109" "192.168.1.100:9109" ]; + rpcuser = "dcrd"; + rpcpass = "hunter2"; + txindex = true; + proxy = "127.0.0.1:9050"; + }; + description = '' + Options that accept multiple values (like rpclisten, externalip, + listen, addpeer, miningaddr, altdnsnames, whitelist) should be + specified as lists. + + Single-value options (like rpcuser, rpcpass, proxy, txindex) + should be specified as strings or booleans. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + users.groups.${cfg.group} = {}; + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + description = "Decred daemon user"; + }; + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ effectivePort ]; + + systemd.services.dcrd = { + description = "Decred full node"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + StateDirectory = "dcrd"; + StateDirectoryMode = "0750"; + ExecStart = ''${lib.getExe cfg.package} --appdata=/var/lib/dcrd --configfile=${if cfg.configFile != null then cfg.configFile else generatedConfig}''; + Restart = "on-failure"; + RestartSec = 5; + }; + restartTriggers = [ generatedConfig ] ++ lib.optional (cfg.configFile != null) cfg.configFile; + }; + }; +} + diff --git a/modules/dcrwallet.nix b/modules/dcrwallet.nix new file mode 100644 index 0000000..6f6cf7f --- /dev/null +++ b/modules/dcrwallet.nix @@ -0,0 +1,122 @@ +{ config, lib, pkgs, ... }: +let + dcrdEnabled = config.services.dcrd.enable or false; + cfg = config.services.dcrwallet; +in +{ + options.services.dcrwallet = with lib; { + enable = mkEnableOption "Decred wallet daemon"; + + package = mkOption { + type = types.package; + default = pkgs.dcrwallet; + description = "Package providing the dcrwallet binary"; + }; + + user = mkOption { + type = types.str; + default = "dcrwallet"; + description = "User to run dcrwallet as"; + }; + + group = mkOption { + type = types.str; + default = cfg.user; + description = "Group to run dcrwallet as"; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/dcrwallet"; + description = "State directory for dcrwallet (appdata)"; + }; + + configFile = mkOption { + type = types.path; + description = "Path to dcrwallet configuration file (runtime path)"; + }; + + extraPackages = mkOption { + type = types.listOf types.package; + default = []; + example = [ pkgs.dcrctl ]; + description = "Additional packages to add to the dcrwallet user's environment"; + }; + + operator = { + enable = mkEnableOption "Install dcrwallet CLI packages for an operator user"; + name = mkOption { + type = types.str; + default = "operator"; + description = "Operator username to receive dcrwallet package(s) in their environment."; + }; + }; + + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + users.users.${cfg.user} = { + group = cfg.group; + home = cfg.dataDir; + isNormalUser = true; + packages = [ cfg.package ] ++ cfg.extraPackages; + # dcrwallet needs read access to the dcrd RPC certificate + extraGroups = lib.optional (dcrdEnabled && config.services.dcrd.group != cfg.group) config.services.dcrd.group; + }; + users.groups.${cfg.group} = {}; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -" + ]; + + systemd.services.dcrwallet = { + description = "Decred wallet daemon"; + after = [ "network-online.target" ] ++ lib.optional dcrdEnabled "dcrd.service"; + wants = [ "network-online.target" ] ++ lib.optional dcrdEnabled "dcrd.service"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + RuntimeDirectory = "dcrwallet"; + StateDirectory = "dcrwallet"; + StateDirectoryMode = "0750"; + ExecStart = ''${lib.getExe cfg.package} --appdata=${cfg.dataDir} --configfile=${cfg.configFile}''; + }; + restartTriggers = [ cfg.configFile ]; + }; + } + (lib.mkIf cfg.operator.enable { + users.users.${cfg.operator.name} = { + packages = [ cfg.package ] ++ cfg.extraPackages; + # Add the operator user to the dcrwallet group and dcrd group + extraGroups = [ cfg.group ] ++ lib.optional (dcrdEnabled && config.services.dcrd.group != cfg.group) config.services.dcrd.group; + }; + + # Ensure operator (a member of cfg.group) can read rpc.cert which dcrwallet + # writes with mode 0600 by default. We watch for the file and set g+r. + systemd.services.dcrwallet-cert-perms = { + description = "Ensure group-read on dcrwallet rpc.cert"; + after = [ "dcrwallet.service" ]; + serviceConfig = { + Type = "oneshot"; + }; + script = '' + if [ -e ${cfg.dataDir}/rpc.cert ]; then + ${pkgs.coreutils}/bin/chmod g+r ${cfg.dataDir}/rpc.cert + fi + ''; + }; + + systemd.paths.dcrwallet-cert-perms = { + description = "Watch dcrwallet rpc.cert to fix permissions"; + pathConfig = { +# PathModified = "${cfg.dataDir}/rpc.cert"; + PathChanged = "${cfg.dataDir}/rpc.cert"; + }; + wantedBy = [ "multi-user.target" ]; + }; + }) + ]); +} + diff --git a/pkgs/dcrctl.nix b/pkgs/dcrctl.nix new file mode 100644 index 0000000..8d1e7e1 --- /dev/null +++ b/pkgs/dcrctl.nix @@ -0,0 +1,32 @@ +{ + lib, + buildGoModule, + fetchFromGitHub, +}: + +buildGoModule (finalAttrs: { + pname = "dcrctl"; + version = "2.0.6"; + + src = fetchFromGitHub { + owner = "decred"; + repo = "dcrctl"; + rev = "release-v${finalAttrs.version}"; + hash = "sha256-TxXPPe4AUEck3dFS0+TJgEPenAP43UOwqKWK/3unzjA="; + }; + + vendorHash = "sha256-YctyhZV6qf8xPnjrkkOwerazKHVOUAkpKe7kV3t6Tis="; + + ldflags = [ + "-s" + "-w" + ]; + + meta = { + homepage = "https://decred.org"; + description = "Decred CLI tool"; + license = with lib.licenses; [ isc ]; + mainProgram = "dcrctl"; + }; +}) + diff --git a/pkgs/dcrd.nix b/pkgs/dcrd.nix new file mode 100644 index 0000000..f5d9088 --- /dev/null +++ b/pkgs/dcrd.nix @@ -0,0 +1,37 @@ +{ + lib, + buildGoModule, + fetchFromGitHub, +}: + +buildGoModule rec { + pname = "dcrd"; + version = "2.0.6"; + + src = fetchFromGitHub { + owner = "decred"; + repo = "dcrd"; + rev = "dabb3760c7ae45e42ee55f0d86dd8def61b41a69"; + hash = "sha256-mSq4SRSnZOoCuRKVwmb8Y6+KbaTtg+DLf4YX5oApx0k="; + }; + + vendorHash = "sha256-kzb8qh1j2+TlX+et0RSq5qU1LHSEs3Kaf0nHOnGjdd0="; + + subPackages = [ + "." + "cmd/promptsecret" + ]; + + # Keep tests from writing outside the sandbox + preCheck = '' + export DCRD_APPDATA="$TMPDIR" + ''; + + meta = { + homepage = "https://decred.org"; + description = "Decred daemon in Go (golang)"; + license = with lib.licenses; [ isc ]; + mainProgram = "dcrd"; + }; +} + diff --git a/pkgs/dcrwallet.nix b/pkgs/dcrwallet.nix new file mode 100644 index 0000000..0144f38 --- /dev/null +++ b/pkgs/dcrwallet.nix @@ -0,0 +1,35 @@ +{ + lib, + buildGoModule, + fetchFromGitHub, +}: + +buildGoModule (finalAttrs: { + pname = "dcrwallet"; + version = "2.0.6"; + + src = fetchFromGitHub { + owner = "decred"; + repo = "dcrwallet"; + rev = "release-v${finalAttrs.version}"; + hash = "sha256-MrQrDip8vE0l5XHkx/zIegSZd/AkWq1aFZLUVPdMy50="; + }; + + vendorHash = "sha256-Ulh6RxK+PvS70mJ7TYiGMzKFsR79+asWuQ5W1FAI23I="; + + subPackages = [ "." ]; + + checkFlags = [ + # Test fails with: + # 'x509_test.go:201: server did not report bad certificate error; + # instead errored with [...] tls: unknown certificate authority (*url.Error)' + "-skip=^TestUntrustedClientCert$" + ]; + + meta = { + homepage = "https://decred.org"; + description = "Decred wallet daemon written in Go (golang)"; + license = with lib.licenses; [ isc ]; + mainProgram = "dcrwallet"; + }; +}) \ No newline at end of file