blitz_api/modules/blitz_api.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];
};
};
}