{ config, pkgs, lib, ... }: let defaultUser = "blitzapi"; defaultGroup = defaultUser; name = "blitz-api"; cfg = config.services.${name}; nbLib = config.nix-bitcoin.lib; secretsDir = config.nix-bitcoin.secretsDir; bitcoindRpcAddress = nbLib.address bitcoind.rpc.address; # The ZMQ URL is always given fully qualified, # but the API expects the port only parts = strings.splitString ":" bitcoind.zmqpubrawblock; bitcoindZmqPort = builtins.elemAt parts 2; jwtSecretScript = if cfg.jwt.secretFile != null then "$(head -n1 ${lib.escapeShellArg cfg.jwt.secretFile})" else "$(cat /dev/urandom | tr -dc '[:alnum:]' | head -c 50)"; loginPasswordScript = if cfg.passwordFile != null then "$(head -n1 ${lib.escapeShellArg cfg.passwordFile})" else "$(cat /dev/urandom | tr -dc '[:alnum:]' | head -c 50)"; fullDotEnvPath = if cfg.generateDotEnvFile then "${cfg.dataDir}/.env" else if cfg.dotEnvFile != null then cfg.dotEnvFile else "${cfg.dataDir}/.env"; inherit (lib) mkOption mkIf mkEnableOption types literalExpression strings; inherit (config.services) bitcoind lnd; in { options = { services.${name} = { enable = mkEnableOption "${name}"; package = mkOption { type = types.package; defaultText = literalExpression "pkgs.${name}"; default = pkgs.${name}; description = "The ${name} package to use."; }; host = mkOption { type = types.str; default = "127.0.0.1"; example = "127.0.0.1"; description = "The host to bind to"; }; port = mkOption { type = types.port; default = 2121; example = 2121; description = "The port the ${name} will be listening on"; }; user = mkOption { type = types.str; default = defaultUser; example = "${defaultUser}"; description = "The user to run the ${name} as"; }; home = mkOption { type = types.nullOr types.path; default = null; example = "/home/${defaultUser}"; description = "Storage path of ${name}. This is where the cookie will be located, if enabled."; }; group = mkOption { type = types.str; default = defaultGroup; description = "Group to run the ${name} as"; }; network = mkOption { type = types.enum ["mainnet" "testnet" "regtest"]; default = "mainnet"; description = "The bitcoin network type"; }; dataDir = mkOption { type = types.path; default = "/var/lib/blitz_api"; description = "The data directory for ${name}."; }; bitcoind = { rpc = { address = mkOption { type = types.str; default = "127.0.0.1"; description = '' Address to use for the JSON-RPC connection. ''; }; port = mkOption { type = types.port; default = if cfg.network == "mainnet" then 8332 else if cfg.network == "testnet" then 18332 else 28332; defaultText = '' if cfg.network == "mainnet" then 8332 else if cfg.network == "testnet" then 18332 else 28332; ''; description = "Port to use for the JSON-RPC connections."; }; }; zmq = { blockRPCType = mkOption { type = types.enum ["hashblock" "rawblock"]; default = "hashblock"; description = '' How the ${name} api will be notified of new blocks. Hashblock is a bit faster, so it should be used if possible. ''; }; }; }; localCookieAuth = mkOption { type = types.bool; default = false; description = '' Whether to enable local cookie autentication. This will create a file called `~/user/.cookie` with a JWT token ''; }; logLevel = lib.mkOption { type = types.enum ["TRACE" "DEBUG" "INFO" "SUCCESS" "WARNING" "ERROR" "CRITICAL"]; default = "INFO"; description = "Log level for the ${name}"; example = "DEBUG"; }; ln = { connectionType = lib.mkOption { type = types.enum ["none" "lnd_grpc" "cln_jrpc"]; default = "none"; description = "Whether lighgning is enabled and which implementation is used."; }; lnd = { grpcHost = mkOption { type = types.str; default = "127.0.0.1"; example = "127.0.0.1"; description = "The host to connect to"; }; grpcPort = mkOption { type = types.port; default = 10009; description = "The port to connect to"; }; }; }; jwt = { algorithm = mkOption { type = types.str; example = "HS256"; default = "HS256"; description = "The hashing algorithm for the JWT. See PyJWT for a list of available algorithms."; }; expiry = mkOption { type = types.int; example = "3600000"; default = 3600000; description = "JWT expiry time in milliseconds (3600000 = 1 hour)"; }; secretFile = mkOption { type = types.nullOr types.str; default = null; example = "/run/keys/jwt_secret"; description = "File path containing the JWT secret."; }; }; generateDotEnvFile = mkOption { type = types.bool; default = false; example = true; description = "Wheter to generate the dot env file."; }; dotEnvFile = mkOption { type = types.nullOr types.str; default = null; example = "/var/lib/blitz_api/.env"; description = "The path where the .env file will be live."; }; passwordFile = mkOption { type = types.nullOr types.str; default = null; example = "/run/keys/login_password"; description = "File path containing the password for native python to authenticate with."; }; rootPath = mkOption { type = types.str; default = "/"; example = "/api"; description = "The root path the api will be served on. E.g. https://127.0.0.1/api if set to /api."; }; nginx = { enable = mkEnableOption "Whether to enable nginx server for ${name}."; description = "This is used to generate the nginx configuration."; hostName = mkOption { type = types.str; example = "my.node.net"; default = "localhost"; description = "The hostname to use for the nginx virtual host."; }; location = mkOption { type = types.str; example = "/api"; default = "/api"; description = "The location to serve the ${name} from from."; }; openFirewall = mkOption { type = types.bool; default = false; description = "Whether to open the ports used by ${name} in the firewall for the server"; }; }; env = mkOption { type = types.attrsOf types.str; default = {}; description = '' Additional environment variables that are passed to the ${name}. Reference Variables: https://github.com/fusion44/blitz_api/blob/dev/.env_sample ''; example = { BAPI_JWT_EXPIRY_TIME = 3600000; }; }; }; }; config = mkIf cfg.enable { assertions = [ { assertion = lib.strings.hasPrefix "/" cfg.rootPath; message = '' needs to start with a / if set. Actual: ${cfg.rootPath} ''; } { assertion = cfg.passwordFile != null -> builtins.pathExists cfg.passwordFile; message = "The specified password file does not exist: ${cfg.passwordFile}"; } ]; users.users = mkIf (cfg.user == defaultUser) { ${defaultUser} = { description = "${name} service"; home = mkIf (cfg.home != null) cfg.home; group = cfg.group; isSystemUser = true; }; }; users.groups = mkIf (cfg.group == defaultGroup) { ${defaultGroup} = {}; }; systemd = { tmpfiles.rules = [ "d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -" ]; # This target is active when the env file have been created successfully. targets = { "${name}-env-file" = mkIf cfg.generateDotEnvFile { # This ensures that the secrets target is always activated when switching # configurations. # In this way `switch-to-configuration` is guaranteed to show an error # when activating the secrets target fails on deployment. wantedBy = ["multi-user.target"]; }; "${name}" = { # This ensures that the secrets target is always activated when switching # configurations. # In this way `switch-to-configuration` is guaranteed to show an error # when activating the secrets target fails on deployment. wantedBy = ["multi-user.target"]; }; }; services.blitz-api-setup-env = mkIf cfg.generateDotEnvFile rec { wantedBy = ["multi-user.target"]; before = ["${name}.target"]; wants = ["nix-bitcoin-secrets.target"]; after = wants; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; script = '' mkdir -p "${cfg.dataDir}" cd "${cfg.dataDir}" chown root: . chmod 0700 . echo "BAPI_JWT_SECRET=${jwtSecretScript}" >> .env echo "BAPI_JWT_ALGORITHM=${cfg.jwt.algorithm}" >> .env echo "BAPI_JWT_EXPIRY_TIME=${toString cfg.jwt.expiry}" >> .env echo "BAPI_ENABLE_LOCAL_COOKIE_AUTH=${toString cfg.localCookieAuth}" >> .env echo "BAPI_LOG_LEVEL=${cfg.logLevel}" >> .env echo "BAPI_ROOT_PATH=${cfg.rootPath}" >> .env echo "BAPI_PLATFORM=native_python" >> .env echo "BAPI_NETWORK=${cfg.network}" >> .env echo "BAPI_BITCOIND_ADDRESS=${bitcoindRpcAddress}" >> .env echo "BAPI_BITCOIND_PORT_RPC=${toString bitcoind.rpc.port}" >> .env echo "BAPI_BITCOIND_ZMQ_BLOCK_RPC=${cfg.bitcoind.zmq.blockRPCType}" >> .env echo "BAPI_BITCOIND_ZMQ_BLOCK_PORT=${bitcoindZmqPort}" >> .env echo "BAPI_BITCOIND_USER=${bitcoind.rpc.users.public.name}" >> .env pw=$(head -n1 ${secretsDir}/bitcoin-rpcpassword-public) echo "BAPI_BITCOIND_RPC_PW=$pw" >> .env echo "BAPI_NATIVE_LOGIN_PASSWORD=${loginPasswordScript}" >> .env echo "BAPI_LN_NODE=${cfg.ln.connectionType}" >> .env ${ lib.strings.optionalString (cfg.ln.connectionType == "lnd_grpc") '' echo "BAPI_LND_MACAROON=${cfg.dataDir}/macaroons/admin.macaroon" >> .env echo "BAPI_LND_CERT=${cfg.dataDir}/lnd-cert" >> .env echo "BAPI_LND_GRPC_IP=${cfg.ln.lnd.grpcHost}" >> .env echo "BAPI_LND_GRPC_PORT=${toString cfg.ln.lnd.grpcPort}" >> .env '' } ${ lib.strings.optionalString (cfg.ln.connectionType == "cln_grpc") '' echo "# CLN TODO" '' } chown ${cfg.user}:${cfg.group} .env chmod 400 .env ''; }; services.${name} = rec { wantedBy = ["multi-user.target"]; requires = ["bitcoind.service" "lnd.service"]; after = requires ++ ["blitz-api-setup-env.target" "nix-bitcoin-secrets.target"]; description = "${name} server daemon"; environment = lib.mkMerge [ (lib.mkIf cfg.generateDotEnvFile { BAPI_ENV_PATH = "${cfg.dataDir}/.env"; }) cfg.env ]; serviceConfig = # TODO: some hardenings interfere with API fuctionality # nbLib.defaultHardening // { ExecStart = "${cfg.package}/bin/api --port ${toString cfg.port} --host ${cfg.host} --root_path ${cfg.rootPath}"; ExecStartPre = [ (nbLib.rootScript "${name}-prepare-data-dir" '' install -D -o ${cfg.user} -g ${cfg.group} ${lnd.networkDir}/admin.macaroon \ '${cfg.dataDir}/macaroons/admin.macaroon' install -D -o ${cfg.user} -g ${cfg.group} ${lnd.certPath} \ '${cfg.dataDir}/lnd-cert' chown -R ${cfg.user}:${cfg.group} ${cfg.dataDir} # chmod -R 440 ${cfg.dataDir} chmod -R 777 ${cfg.dataDir} '') ]; User = cfg.user; Group = cfg.group; Restart = "always"; SyslogIdentifier = name; ReadWritePaths = [cfg.dataDir]; }; }; }; services.nginx = mkIf cfg.nginx.enable { enable = true; virtualHosts.${cfg.nginx.hostName} = { forceSSL = false; enableACME = false; locations."${cfg.nginx.location}" = { extraConfig = '' rewrite ${cfg.nginx.location}/(.*) /$1 break; proxy_redirect off; ''; proxyPass = "http://${cfg.host}:${toString cfg.port}/"; recommendedProxySettings = true; }; }; }; networking.firewall = mkIf cfg.nginx.openFirewall { allowedTCPPorts = [80]; }; }; }