nix-bitcoin/modules/lamassu-lnbits.nix
Patrick Mulligan 4d2d65803b
Some checks failed
nix-bitcoin tests / build_test_drivers (push) Has been cancelled
nix-bitcoin tests / check_flake (push) Has been cancelled
nix-bitcoin tests / test_scenario (default) (push) Has been cancelled
nix-bitcoin tests / test_scenario (joinmarket-bitcoind-29) (push) Has been cancelled
nix-bitcoin tests / test_scenario (netns) (push) Has been cancelled
nix-bitcoin tests / test_scenario (netnsRegtest) (push) Has been cancelled
Fix security vulnerabilities in lamassu module
- Fix SQL injection in PostgreSQL password setup by using psql's
  parameterized variable syntax (:'password') instead of direct
  string interpolation
- Change skip2FA default to false for secure-by-default behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:03:39 +01:00

419 lines
14 KiB
Nix

{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.lamassu-server;
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
# Shared environment variables for both services
commonEnv = {
NODE_ENV = cfg.mode;
LOG_LEVEL = cfg.logLevel;
HOSTNAME = cfg.hostname;
# Database
POSTGRES_HOST = "127.0.0.1";
POSTGRES_PORT = "5432";
POSTGRES_DB = cfg.database.name;
POSTGRES_USER = cfg.database.user;
# TLS certificates
CA_PATH = cfg.certPath;
CERT_PATH = cfg.certPath;
KEY_PATH = cfg.keyPath;
# Data directories
MNEMONIC_PATH = "${cfg.dataDir}/lamassu-mnemonic";
OFAC_DATA_DIR = "${cfg.dataDir}/ofac";
ID_PHOTO_CARD_DIR = "${cfg.dataDir}/photos/idcards";
FRONT_CAMERA_DIR = "${cfg.dataDir}/photos/frontcamera";
OPERATOR_DATA_DIR = "${cfg.dataDir}/operator";
# Security
SKIP_2FA = if cfg.skip2FA then "true" else "false";
};
# Shared wrapper script that sets up the environment
lamassuEnv = pkgs.writeShellScript "lamassu-env" ''
set -euo pipefail
export PATH=${pkgs.nodejs_22}/bin:$PATH
DB_PASSWORD=$(cat ${secretsDir}/lamassu-db-password)
export DATABASE_URL="postgresql://${cfg.database.user}:$DB_PASSWORD@127.0.0.1:5432/${cfg.database.name}"
export POSTGRES_PASSWORD="$DB_PASSWORD"
export NODE_PATH=${cfg.dataDir}/source/node_modules
cd ${cfg.dataDir}/source
exec "$@"
'';
# Hardening settings for runtime services
hardeningConfig = {
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = false; # Required for Node.js JIT
ReadWritePaths = [ cfg.dataDir ];
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
User = cfg.user;
Group = cfg.group;
Restart = "on-failure";
RestartSec = "10s";
};
in
{
options.services.lamassu-server = {
enable = mkEnableOption "Lamassu Bitcoin ATM server";
serverPort = mkOption {
type = types.port;
default = 3000;
description = "Port for the main lamassu server API";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/lamassu-server";
description = "Data directory for lamassu-server";
};
user = mkOption {
type = types.str;
default = "lamassu-server";
description = "User to run lamassu-server as";
};
group = mkOption {
type = types.str;
default = cfg.user;
description = "Group to run lamassu-server as";
};
source = {
url = mkOption {
type = types.str;
default = "https://git.atitlan.io/aiolabs/lamassu-server";
description = "Git repository URL for lamassu-server source";
};
ref = mkOption {
type = types.str;
default = "main";
description = "Git ref (branch, tag, or commit) to checkout";
};
};
logLevel = mkOption {
type = types.enum [ "error" "warn" "info" "verbose" "debug" "silly" ];
default = "info";
description = "Logging level for lamassu-server";
};
mode = mkOption {
type = types.enum [ "production" "development" ];
default = "development";
description = ''
Run in production or development mode.
Development mode uses port 3001 for admin UI registration URLs.
'';
};
skip2FA = mkOption {
type = types.bool;
default = false;
description = "Skip 2FA authentication (only enable for initial setup, then disable)";
};
database = {
name = mkOption {
type = types.str;
default = cfg.user;
description = "PostgreSQL database name";
};
user = mkOption {
type = types.str;
default = cfg.user;
description = "PostgreSQL username";
};
};
hostname = mkOption {
type = types.str;
default = "localhost";
description = ''
Hostname for the server. This is embedded in the pairing QR code
and tells ATMs where to connect. Can be an IP address or domain name.
'';
};
certificate = {
extraIPs = mkOption {
type = with types; listOf str;
default = [];
example = [ "192.168.1.100" ];
description = "Extra IP addresses to include in the certificate SAN.";
};
extraDomains = mkOption {
type = with types; listOf str;
default = [];
example = [ "lamassu.example.com" ];
description = "Extra domain names to include in the certificate SAN.";
};
};
certPath = mkOption {
readOnly = true;
default = "${secretsDir}/lamassu-cert";
description = "Path to the TLS certificate.";
};
keyPath = mkOption {
readOnly = true;
default = "${secretsDir}/lamassu-key";
description = "Path to the TLS private key.";
};
};
config = mkIf cfg.enable {
# Secrets
nix-bitcoin.secrets = {
lamassu-key.user = cfg.user;
lamassu-cert = {
user = cfg.user;
permissions = "444";
};
lamassu-db-password = {
user = cfg.user;
group = "postgres";
};
};
nix-bitcoin.generateSecretsCmds.lamassu = ''
makeCert lamassu '${nbLib.mkCertExtraAltNames cfg.certificate}'
makePasswordSecret lamassu-db-password
'';
# PostgreSQL
services.postgresql = {
enable = true;
package = pkgs.postgresql_15;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [{
name = cfg.database.user;
ensureDBOwnership = true;
}];
authentication = pkgs.lib.mkOverride 10 ''
local all all peer
host all all 127.0.0.1/32 md5
host all all ::1/128 md5
'';
};
# User and group
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.group} = {};
# Data directories
systemd.tmpfiles.rules = [
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.dataDir}/ofac' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.dataDir}/photos' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.dataDir}/photos/idcards' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.dataDir}/photos/frontcamera' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.dataDir}/operator' 0770 ${cfg.user} ${cfg.group} - -"
];
# PostgreSQL password setup
systemd.services.lamassu-postgres-setup = {
description = "Setup PostgreSQL password for lamassu-server";
wantedBy = [ "multi-user.target" ];
after = [ "postgresql.service" "nix-bitcoin-secrets.target" ];
wants = [ "postgresql.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "postgres";
};
script = ''
for i in {1..30}; do
if ${pkgs.postgresql}/bin/psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${cfg.database.user}'" | grep -q 1; then
password=$(cat ${secretsDir}/lamassu-db-password)
${pkgs.postgresql}/bin/psql -v password="$password" -c "ALTER USER \"${cfg.database.user}\" WITH PASSWORD :'password';"
exit 0
fi
sleep 1
done
exit 1
'';
};
# Build service
systemd.services.lamassu-build = {
description = "Clone and Build Lamassu Server";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
path = with pkgs; [
nodejs_22 nodePackages.pnpm python3 git coreutils bash util-linux
stdenv.cc gnumake pkg-config binutils expat
];
environment = {
PYTHON = "${pkgs.python3}/bin/python3";
HOME = cfg.dataDir;
CI = "true";
CC = "${pkgs.stdenv.cc}/bin/cc";
CXX = "${pkgs.stdenv.cc}/bin/c++";
# Use content-addressable store to reduce disk usage
npm_config_cache = "${cfg.dataDir}/.npm-cache";
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = cfg.user;
Group = cfg.group;
TimeoutStartSec = "30min";
KillMode = "process";
KillSignal = "SIGTERM";
# Sandboxing with write access to data directory
ProtectSystem = "strict";
ProtectHome = true;
NoNewPrivileges = true;
ReadWritePaths = [ cfg.dataDir ];
# node-gyp needs writable /tmp for native module compilation
PrivateTmp = true;
};
script = ''
set -euo pipefail
SOURCE_DIR="${cfg.dataDir}/source"
# Clone or update
if [ ! -d "$SOURCE_DIR/.git" ]; then
git clone "${cfg.source.url}" "$SOURCE_DIR"
cd "$SOURCE_DIR"
git checkout "${cfg.source.ref}"
NEEDS_BUILD=1
else
cd "$SOURCE_DIR"
git fetch origin
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse "origin/${cfg.source.ref}" 2>/dev/null || git rev-parse "${cfg.source.ref}")
if [ "$LOCAL" != "$REMOTE" ]; then
git checkout "${cfg.source.ref}"
git pull origin "${cfg.source.ref}" 2>/dev/null || true
NEEDS_BUILD=1
else
NEEDS_BUILD=0
fi
fi
# Check build artifacts
[ ! -d "node_modules" ] || [ ! -d "packages/admin-ui/build" ] && NEEDS_BUILD=1
[ "$NEEDS_BUILD" = "0" ] && exit 0
# Install dependencies (without running install scripts)
pnpm install --no-frozen-lockfile --ignore-scripts
# Build native modules explicitly (pnpm rebuild has signal handling issues)
for module in node-expat iconv; do
path=$(find node_modules/.pnpm -name "$module" -type d -path "*/$module" 2>/dev/null | head -1)
[ -n "$path" ] && [ -f "$path/binding.gyp" ] && (cd "$path" && npx node-gyp rebuild) || true
done
# argon2 uses node-pre-gyp
path=$(find node_modules/.pnpm -name "argon2" -type d -path "*/argon2" 2>/dev/null | head -1)
[ -n "$path" ] && (cd "$path" && npx node-pre-gyp install --fallback-to-build) || true
# Build with setsid to isolate from signal issues
setsid --wait ./node_modules/.bin/turbo build
# Link admin UI
ln -sfn ../admin-ui/build packages/server/public
'';
};
# Main server
systemd.services.lamassu-server = {
description = "Lamassu Bitcoin ATM Server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "postgresql.service" "lamassu-postgres-setup.service" "lamassu-build.service" "nix-bitcoin-secrets.target" ];
wants = [ "postgresql.service" "lamassu-postgres-setup.service" "lamassu-build.service" ];
environment = commonEnv // {
SERVER_PORT = toString cfg.serverPort;
};
serviceConfig = hardeningConfig // {
WorkingDirectory = "${cfg.dataDir}/source/packages/server";
ExecStartPre = [
"${pkgs.bash}/bin/bash -c 'test -f ${cfg.dataDir}/lamassu-mnemonic || echo \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\" > ${cfg.dataDir}/lamassu-mnemonic'"
"${lamassuEnv} ${pkgs.nodejs_22}/bin/node packages/server/bin/lamassu-migrate"
];
ExecStart = "${lamassuEnv} ${pkgs.nodejs_22}/bin/node packages/server/bin/lamassu-server --port ${toString cfg.serverPort} --logLevel ${cfg.logLevel}";
};
preStart = ''
timeout=30
while ! ${pkgs.postgresql}/bin/psql -h /run/postgresql -U ${cfg.database.user} -d ${cfg.database.name} -c '\q' 2>/dev/null; do
[ $timeout -le 0 ] && exit 1
sleep 1
((timeout--))
done
'';
};
# Admin server
systemd.services.lamassu-admin-server = {
description = "Lamassu Admin Server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "lamassu-server.service" "lamassu-build.service" ];
wants = [ "lamassu-server.service" "lamassu-build.service" ];
environment = commonEnv;
serviceConfig = hardeningConfig // {
WorkingDirectory = "${cfg.dataDir}/source/packages/server";
ExecStart = "${lamassuEnv} ${pkgs.nodejs_22}/bin/node packages/server/bin/lamassu-admin-server --logLevel ${cfg.logLevel}";
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
};
};
# Firewall
networking.firewall.allowedTCPPorts = [ cfg.serverPort 443 ];
# Helper tools
environment.systemPackages = with pkgs; [
nodejs_22
nodePackages.pnpm
postgresql
(writeShellScriptBin "lamassu-register-user" ''
DB_PASSWORD=$(cat ${secretsDir}/lamassu-db-password)
export DATABASE_URL="postgresql://${cfg.database.user}:$DB_PASSWORD@127.0.0.1:5432/${cfg.database.name}"
export POSTGRES_HOST="127.0.0.1" POSTGRES_PORT="5432"
export POSTGRES_DB="${cfg.database.name}" POSTGRES_USER="${cfg.database.user}" POSTGRES_PASSWORD="$DB_PASSWORD"
export HOSTNAME="${cfg.hostname}" SKIP_2FA="${if cfg.skip2FA then "true" else "false"}"
export NODE_ENV="${cfg.mode}"
sudo -E -u ${cfg.user} ${pkgs.nodejs_22}/bin/node ${cfg.dataDir}/source/packages/server/bin/lamassu-register "$@"
'')
];
};
}