mirror of
https://github.com/fusion44/blitz_api.git
synced 2026-08-15 12:10:12 +02:00
529 lines
18 KiB
Nix
529 lines
18 KiB
Nix
{
|
|
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;
|
|
|
|
# Persist auto-generated secrets so they survive rebuilds. Without
|
|
# this the JWT secret + local login password would regenerate on
|
|
# every nixos-rebuild, invalidating every token a logged-in client
|
|
# holds. Scripts are embedded into the env-file generator below;
|
|
# the `.jwt-secret` and `.login-password` sidecar files live in
|
|
# `${cfg.dataDir}`.
|
|
jwtSecretScript =
|
|
if cfg.jwt.secretFile != null
|
|
then "$(head -n1 ${lib.escapeShellArg cfg.jwt.secretFile})"
|
|
else ''"$(head -n1 "${cfg.dataDir}/.jwt-secret")"'';
|
|
loginPasswordScript =
|
|
if cfg.passwordFile != null
|
|
then "$(head -n1 ${lib.escapeShellArg cfg.passwordFile})"
|
|
else ''"$(head -n1 "${cfg.dataDir}/.login-password")"'';
|
|
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 = ''
|
|
Which lightning backend (if any) to wire up. "none" means the
|
|
API runs in Bitcoin-only mode and no LN service is required.
|
|
'';
|
|
};
|
|
|
|
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";
|
|
};
|
|
};
|
|
|
|
cln = {
|
|
jrpcPath = mkOption {
|
|
type = types.path;
|
|
default = "${config.services.clightning.dataDir}/bitcoin/lightning-rpc";
|
|
defaultText = literalExpression
|
|
''"''${config.services.clightning.dataDir}/bitcoin/lightning-rpc"'';
|
|
description = ''
|
|
Absolute path to the clightning JSON-RPC socket. Must be
|
|
readable by the blitz-api service user (see the clightning
|
|
group membership added when connectionType = "cln_jrpc").
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
|
|
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 = ''
|
|
<option>services.${name}.rootPath</option> 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;
|
|
# CLN JSON-RPC mode needs socket access via the clightning group.
|
|
extraGroups =
|
|
lib.optional (cfg.ln.connectionType == "cln_jrpc") "clightning";
|
|
};
|
|
};
|
|
|
|
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 .
|
|
|
|
# Seed persistent JWT secret + login password on first boot;
|
|
# re-read on subsequent activations.
|
|
${lib.optionalString (cfg.jwt.secretFile == null) ''
|
|
if [ ! -s .jwt-secret ]; then
|
|
tr -dc '[:alnum:]' < /dev/urandom | head -c 50 > .jwt-secret
|
|
chmod 600 .jwt-secret
|
|
fi
|
|
''}
|
|
${lib.optionalString (cfg.passwordFile == null) ''
|
|
if [ ! -s .login-password ]; then
|
|
tr -dc '[:alnum:]' < /dev/urandom | head -c 50 > .login-password
|
|
chmod 600 .login-password
|
|
fi
|
|
''}
|
|
|
|
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_jrpc") ''
|
|
echo "BAPI_CLN_JRPC_PATH=${cfg.ln.cln.jrpcPath}" >> .env
|
|
''
|
|
}
|
|
|
|
chown ${cfg.user}:${cfg.group} .env
|
|
chmod 400 .env
|
|
'';
|
|
};
|
|
|
|
services.${name} = rec {
|
|
wantedBy = ["multi-user.target"];
|
|
# Bitcoind is always required; pull in the matching lightning
|
|
# service only when the connectionType asks for it. "none" runs
|
|
# the API in Bitcoin-only mode.
|
|
requires =
|
|
["bitcoind.service"]
|
|
++ lib.optional (cfg.ln.connectionType == "lnd_grpc") "lnd.service"
|
|
++ lib.optional (cfg.ln.connectionType == "cln_jrpc") "clightning.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}";
|
|
# LND artifacts need to live inside the service's dataDir so
|
|
# the .env's BAPI_LND_MACAROON / BAPI_LND_CERT paths resolve.
|
|
# Nothing to copy for CLN (socket access is handled via the
|
|
# `clightning` group on cfg.user) or "none".
|
|
ExecStartPre = lib.optionals (cfg.ln.connectionType == "lnd_grpc") [
|
|
(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];
|
|
};
|
|
};
|
|
|
|
# Celery worker handles async tasks dispatched by the FastAPI
|
|
# process (hardware polls, LN forward notifications, etc.). It
|
|
# reads the same .env as the main service and needs Redis + the
|
|
# prepared dataDir (LND macaroon/cert copied by blitz-api's
|
|
# ExecStartPre). Requiring blitz-api.service gives us both
|
|
# ordering and a guarantee that the prep step has finished.
|
|
services."${name}-celery-worker" = {
|
|
wantedBy = ["multi-user.target"];
|
|
requires = ["redis.service" "${name}.service"];
|
|
after = ["redis.service" "${name}.service"];
|
|
description = "${name} Celery worker";
|
|
environment = lib.mkMerge [
|
|
(lib.mkIf cfg.generateDotEnvFile {
|
|
BAPI_ENV_PATH = "${cfg.dataDir}/.env";
|
|
})
|
|
cfg.env
|
|
];
|
|
serviceConfig = {
|
|
ExecStart = "${cfg.package}/bin/celery -A app.celery_app worker --loglevel=info";
|
|
WorkingDirectory = cfg.dataDir;
|
|
User = cfg.user;
|
|
Group = cfg.group;
|
|
Restart = "always";
|
|
SyslogIdentifier = "${name}-celery-worker";
|
|
ReadWritePaths = [cfg.dataDir];
|
|
};
|
|
};
|
|
|
|
# Celery beat runs the periodic task scheduler. Single instance
|
|
# (never run more than one beat per celery app). Same env as
|
|
# worker.
|
|
services."${name}-celery-beat" = {
|
|
wantedBy = ["multi-user.target"];
|
|
requires = ["redis.service" "${name}.service"];
|
|
after = ["redis.service" "${name}.service"];
|
|
description = "${name} Celery beat scheduler";
|
|
environment = lib.mkMerge [
|
|
(lib.mkIf cfg.generateDotEnvFile {
|
|
BAPI_ENV_PATH = "${cfg.dataDir}/.env";
|
|
})
|
|
cfg.env
|
|
];
|
|
serviceConfig = {
|
|
ExecStart = "${cfg.package}/bin/celery -A app.celery_app beat --loglevel=info";
|
|
WorkingDirectory = cfg.dataDir;
|
|
User = cfg.user;
|
|
Group = cfg.group;
|
|
Restart = "always";
|
|
SyslogIdentifier = "${name}-celery-beat";
|
|
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];
|
|
};
|
|
};
|
|
}
|