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
Use SQL-standard single quote doubling instead of psql variable syntax which doesn't work with -c flag. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
422 lines
14 KiB
Nix
422 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";
|
|
permissions = "440"; # Allow postgres group to read
|
|
};
|
|
};
|
|
|
|
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)
|
|
# Escape single quotes by doubling them (SQL standard)
|
|
escaped_password=$(printf '%s' "$password" | sed "s/'/''/g")
|
|
${pkgs.postgresql}/bin/psql -c "ALTER USER \"${cfg.database.user}\" WITH PASSWORD '$escaped_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 "$@"
|
|
'')
|
|
];
|
|
};
|
|
}
|