391 lines
12 KiB
Nix
391 lines
12 KiB
Nix
{ bitcoin-pulse, config, lib, pkgs, ... }:
|
|
|
|
let
|
|
backupDirectory = "/var/backup/bitcoin-pulse";
|
|
backupDump = "${backupDirectory}/bitcoin-pulse.dump";
|
|
backupRepository = "b2:tobcloud-backup:bitcoin-pulse";
|
|
backupServiceName = "restic-backups-bitcoin-pulse";
|
|
containerName = "bitcoin-pulse-postgres";
|
|
databaseUrl = "jdbc:postgresql://127.0.0.1:5432/bitcoin_pulse?tcpKeepAlive=true";
|
|
migrationServiceName = "bitcoin-pulse-migrate";
|
|
serviceName = "podman-${containerName}";
|
|
runtimeDirectory = "/run/${containerName}";
|
|
|
|
secretFiles = {
|
|
admin = config.sops.secrets."bitcoin-pulse/postgres/admin-password".path;
|
|
app = config.sops.secrets."bitcoin-pulse/postgres/app-password".path;
|
|
backblaze = config.sops.secrets."backblaze/tobcloud-backup-env".path;
|
|
migration = config.sops.secrets."bitcoin-pulse/postgres/migration-password".path;
|
|
restic = config.sops.secrets."restic/bitcoin-pulse-password".path;
|
|
};
|
|
|
|
prepareSecrets = pkgs.writeShellApplication {
|
|
name = "bitcoin-pulse-postgresql-prepare-secrets";
|
|
runtimeInputs = [
|
|
config.virtualisation.podman.package
|
|
pkgs.coreutils
|
|
];
|
|
text = ''
|
|
copy_secret() {
|
|
source="$1"
|
|
destination="$2"
|
|
|
|
podman unshare rm -f "$destination"
|
|
# PostgreSQL runs as uid 999 in the container. Store each runtime copy
|
|
# with the corresponding rootless subordinate uid and a private mode.
|
|
podman unshare install -m 0400 -o 999 -g 999 "$source" "$destination"
|
|
}
|
|
|
|
copy_secret ${lib.escapeShellArg secretFiles.admin} \
|
|
${lib.escapeShellArg "${runtimeDirectory}/admin-password"}
|
|
copy_secret ${lib.escapeShellArg secretFiles.app} \
|
|
${lib.escapeShellArg "${runtimeDirectory}/app-password"}
|
|
copy_secret ${lib.escapeShellArg secretFiles.migration} \
|
|
${lib.escapeShellArg "${runtimeDirectory}/migration-password"}
|
|
'';
|
|
};
|
|
|
|
cleanupSecrets = pkgs.writeShellApplication {
|
|
name = "bitcoin-pulse-postgresql-cleanup-secrets";
|
|
runtimeInputs = [
|
|
config.virtualisation.podman.package
|
|
pkgs.coreutils
|
|
];
|
|
text = ''
|
|
podman unshare rm -f \
|
|
${lib.escapeShellArg "${runtimeDirectory}/admin-password"} \
|
|
${lib.escapeShellArg "${runtimeDirectory}/app-password"} \
|
|
${lib.escapeShellArg "${runtimeDirectory}/migration-password"}
|
|
'';
|
|
};
|
|
|
|
waitForPostgresql = pkgs.writeShellApplication {
|
|
name = "bitcoin-pulse-wait-for-postgresql";
|
|
runtimeInputs = [
|
|
pkgs.coreutils
|
|
pkgs.postgresql_18
|
|
];
|
|
text = ''
|
|
deadline=$((SECONDS + 60))
|
|
echo "Waiting up to 60 seconds for PostgreSQL on 127.0.0.1:5432"
|
|
|
|
while (( SECONDS < deadline )); do
|
|
if pg_isready \
|
|
--host=127.0.0.1 \
|
|
--port=5432 \
|
|
--username=bitcoin_pulse_migration \
|
|
--dbname=bitcoin_pulse \
|
|
--timeout=2 \
|
|
--quiet; then
|
|
echo "PostgreSQL is accepting connections"
|
|
exit 0
|
|
fi
|
|
|
|
sleep 2
|
|
done
|
|
|
|
echo "PostgreSQL did not become ready on 127.0.0.1:5432 within 60 seconds" >&2
|
|
pg_isready \
|
|
--host=127.0.0.1 \
|
|
--port=5432 \
|
|
--username=bitcoin_pulse_migration \
|
|
--dbname=bitcoin_pulse \
|
|
--timeout=2 || true
|
|
exit 1
|
|
'';
|
|
};
|
|
in
|
|
{
|
|
imports = [ bitcoin-pulse.nixosModules.default ];
|
|
|
|
services.bitcoin-pulse = {
|
|
enable = true;
|
|
|
|
bindAddress = "127.0.0.1";
|
|
port = 3000;
|
|
mempool.baseUrl = "http://127.0.0.1:8999";
|
|
|
|
database = {
|
|
url = databaseUrl;
|
|
username = "bitcoin_pulse_app";
|
|
passwordFile = secretFiles.app;
|
|
};
|
|
};
|
|
|
|
services.restic.backups.bitcoin-pulse = {
|
|
initialize = true;
|
|
repository = backupRepository;
|
|
|
|
passwordFile = secretFiles.restic;
|
|
environmentFile = secretFiles.backblaze;
|
|
|
|
paths = [ backupDump ];
|
|
|
|
backupPrepareCommand = ''
|
|
set -euo pipefail
|
|
|
|
install -d -m 0700 -o root -g root ${lib.escapeShellArg backupDirectory}
|
|
|
|
dump_path=${lib.escapeShellArg backupDump}
|
|
temporary_dump="$dump_path.tmp"
|
|
pgpass_file="$RUNTIME_DIRECTORY/postgresql.pgpass"
|
|
|
|
rm -f "$dump_path" "$temporary_dump" "$pgpass_file"
|
|
|
|
password="$(cat "$CREDENTIALS_DIRECTORY/postgres-password")"
|
|
escaped_password="''${password//\\/\\\\}"
|
|
escaped_password="''${escaped_password//:/\\:}"
|
|
printf '127.0.0.1:5432:bitcoin_pulse:bitcoin_pulse_admin:%s\n' \
|
|
"$escaped_password" > "$pgpass_file"
|
|
chmod 0600 "$pgpass_file"
|
|
|
|
trap 'rm -f "$temporary_dump" "$pgpass_file"' EXIT
|
|
|
|
PGPASSFILE="$pgpass_file" ${pkgs.postgresql_18}/bin/pg_dump \
|
|
--host=127.0.0.1 \
|
|
--port=5432 \
|
|
--username=bitcoin_pulse_admin \
|
|
--dbname=bitcoin_pulse \
|
|
--format=custom \
|
|
--no-password \
|
|
--file="$temporary_dump"
|
|
|
|
mv "$temporary_dump" "$dump_path"
|
|
'';
|
|
|
|
backupCleanupCommand = ''
|
|
rm -f \
|
|
${lib.escapeShellArg backupDump} \
|
|
${lib.escapeShellArg "${backupDump}.tmp"} \
|
|
"$RUNTIME_DIRECTORY/postgresql.pgpass"
|
|
'';
|
|
|
|
timerConfig = {
|
|
OnCalendar = "*-*-* 03:00:00";
|
|
Persistent = true;
|
|
RandomizedDelaySec = "30m";
|
|
};
|
|
|
|
pruneOpts = [
|
|
"--keep-daily 7"
|
|
"--keep-weekly 4"
|
|
"--keep-monthly 12"
|
|
];
|
|
};
|
|
|
|
users.groups = {
|
|
bitcoin-pulse = { };
|
|
bitcoin-pulse-migration = { };
|
|
bitcoin-pulse-postgres.gid = 400;
|
|
};
|
|
|
|
users.users = {
|
|
bitcoin-pulse-migration = {
|
|
description = "Bitcoin Pulse database migration account";
|
|
isSystemUser = true;
|
|
group = "bitcoin-pulse-migration";
|
|
};
|
|
|
|
bitcoin-pulse-postgres = {
|
|
description = "Rootless PostgreSQL container account for Bitcoin Pulse";
|
|
isSystemUser = true;
|
|
uid = 400;
|
|
group = "bitcoin-pulse-postgres";
|
|
extraGroups = [
|
|
"bitcoin-pulse"
|
|
"bitcoin-pulse-migration"
|
|
];
|
|
home = "/var/lib/bitcoin-pulse-postgres";
|
|
homeMode = "0700";
|
|
createHome = true;
|
|
linger = true;
|
|
autoSubUidGidRange = true;
|
|
};
|
|
};
|
|
|
|
sops.secrets = {
|
|
"bitcoin-pulse/postgres/admin-password" = {
|
|
group = "bitcoin-pulse-postgres";
|
|
mode = "0440";
|
|
};
|
|
|
|
"bitcoin-pulse/postgres/app-password" = {
|
|
group = "bitcoin-pulse";
|
|
mode = "0440";
|
|
};
|
|
|
|
"bitcoin-pulse/postgres/migration-password" = {
|
|
group = "bitcoin-pulse-migration";
|
|
mode = "0440";
|
|
};
|
|
|
|
"restic/bitcoin-pulse-password" = {
|
|
owner = "root";
|
|
group = "root";
|
|
mode = "0400";
|
|
};
|
|
};
|
|
|
|
virtualisation.oci-containers = {
|
|
backend = "podman";
|
|
|
|
containers.${containerName} = {
|
|
# Official PostgreSQL 18.4 Bookworm multi-architecture image, pinned to
|
|
# the immutable digest tested by BitcoinPulse on 2026-07-16.
|
|
image = "docker.io/library/postgres@sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296";
|
|
pull = "missing";
|
|
autoStart = true;
|
|
autoRemoveOnStop = true;
|
|
|
|
podman = {
|
|
user = "bitcoin-pulse-postgres";
|
|
sdnotify = "healthy";
|
|
};
|
|
|
|
environment = {
|
|
POSTGRES_DB = "bitcoin_pulse";
|
|
POSTGRES_USER = "bitcoin_pulse_admin";
|
|
POSTGRES_PASSWORD_FILE = "/run/secrets/admin-password";
|
|
POSTGRES_HOST_AUTH_METHOD = "scram-sha-256";
|
|
POSTGRES_INITDB_ARGS = "--auth-host=scram-sha-256 --data-checksums";
|
|
};
|
|
|
|
ports = [ "127.0.0.1:5432:5432" ];
|
|
|
|
volumes = [
|
|
"bitcoin-pulse-db:/var/lib/postgresql"
|
|
"${runtimeDirectory}/admin-password:/run/secrets/admin-password:ro"
|
|
"${runtimeDirectory}/app-password:/run/secrets/app-password:ro"
|
|
"${runtimeDirectory}/migration-password:/run/secrets/migration-password:ro"
|
|
"${bitcoin-pulse}/resources/postgres/initdb/010-roles.sql:/docker-entrypoint-initdb.d/010-roles.sql:ro"
|
|
];
|
|
|
|
extraOptions = [
|
|
"--health-cmd=pg_isready --host=127.0.0.1 --port=5432 --username=bitcoin_pulse_admin --dbname=bitcoin_pulse"
|
|
"--health-interval=10s"
|
|
"--health-timeout=5s"
|
|
"--health-retries=6"
|
|
"--health-start-period=60s"
|
|
"--stop-signal=SIGINT"
|
|
"--stop-timeout=60"
|
|
"--pids-limit=256"
|
|
"--security-opt=no-new-privileges"
|
|
];
|
|
};
|
|
};
|
|
|
|
systemd.services = {
|
|
${serviceName}.serviceConfig = {
|
|
ExecStartPre = lib.mkAfter [ "${prepareSecrets}/bin/bitcoin-pulse-postgresql-prepare-secrets" ];
|
|
ExecStartPost = [ "${cleanupSecrets}/bin/bitcoin-pulse-postgresql-cleanup-secrets" ];
|
|
ExecStopPost = lib.mkAfter [ "${cleanupSecrets}/bin/bitcoin-pulse-postgresql-cleanup-secrets" ];
|
|
RuntimeDirectoryMode = "0700";
|
|
RestartSec = "5s";
|
|
TimeoutStopSec = lib.mkForce "90s";
|
|
UMask = "0077";
|
|
};
|
|
|
|
${migrationServiceName} = {
|
|
description = "Migrate the Bitcoin Pulse PostgreSQL database";
|
|
requires = [ "${serviceName}.service" ];
|
|
after = [ "${serviceName}.service" ];
|
|
before = [ "bitcoin-pulse.service" ];
|
|
|
|
environment = {
|
|
MIGRATUS_DATABASE_URL = databaseUrl;
|
|
MIGRATUS_USERNAME = "bitcoin_pulse_migration";
|
|
MIGRATUS_PASSWORD_FILE = secretFiles.migration;
|
|
};
|
|
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStartPre = [ "${waitForPostgresql}/bin/bitcoin-pulse-wait-for-postgresql" ];
|
|
ExecStart = "${config.services.bitcoin-pulse.package}/bin/bitcoin-pulse-migrate migrate";
|
|
User = "bitcoin-pulse-migration";
|
|
Group = "bitcoin-pulse-migration";
|
|
Restart = "no";
|
|
TimeoutStartSec = "5min";
|
|
|
|
AmbientCapabilities = "";
|
|
CapabilityBoundingSet = "";
|
|
LockPersonality = true;
|
|
NoNewPrivileges = true;
|
|
PrivateDevices = true;
|
|
PrivateTmp = true;
|
|
ProcSubset = "pid";
|
|
ProtectClock = true;
|
|
ProtectControlGroups = true;
|
|
ProtectHome = true;
|
|
ProtectHostname = true;
|
|
ProtectKernelLogs = true;
|
|
ProtectKernelModules = true;
|
|
ProtectKernelTunables = true;
|
|
ProtectProc = "invisible";
|
|
ProtectSystem = "strict";
|
|
RemoveIPC = true;
|
|
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
|
|
RestrictNamespaces = true;
|
|
RestrictRealtime = true;
|
|
RestrictSUIDSGID = true;
|
|
SystemCallArchitectures = "native";
|
|
UMask = "0077";
|
|
};
|
|
};
|
|
|
|
bitcoin-pulse = {
|
|
requires = [ "${migrationServiceName}.service" ];
|
|
after = [ "${migrationServiceName}.service" ];
|
|
};
|
|
|
|
${backupServiceName} = {
|
|
requires = [ "${serviceName}.service" ];
|
|
after = [ "${serviceName}.service" ];
|
|
|
|
serviceConfig = {
|
|
LoadCredential = [
|
|
"postgres-password:${secretFiles.admin}"
|
|
];
|
|
TimeoutStartSec = "6h";
|
|
UMask = "0077";
|
|
};
|
|
};
|
|
};
|
|
|
|
services.nginx = {
|
|
appendHttpConfig = ''
|
|
limit_req_zone $binary_remote_addr zone=bitcoin_pulse_api:10m rate=300r/m;
|
|
'';
|
|
|
|
virtualHosts."bitcoin-pulse.tobiasostner.de" = {
|
|
enableACME = true;
|
|
forceSSL = true;
|
|
|
|
locations."/" = {
|
|
proxyPass = "http://127.0.0.1:3000";
|
|
extraConfig = ''
|
|
limit_req zone=bitcoin_pulse_api burst=50 nodelay;
|
|
limit_req_status 429;
|
|
client_max_body_size 16k;
|
|
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header Connection "";
|
|
|
|
proxy_connect_timeout 2s;
|
|
proxy_read_timeout 10s;
|
|
proxy_send_timeout 10s;
|
|
proxy_next_upstream off;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
|
|
systemd.tmpfiles.rules = [
|
|
"d ${backupDirectory} 0700 root root - -"
|
|
];
|
|
|
|
environment.systemPackages = [ pkgs.postgresql_18 ];
|
|
}
|