--- # DATUM Gateway Deployment Playbook # # Deploys DATUM Gateway (https://github.com/OCEAN-xyz/datum_gateway) on the # Bitcoin Knots host so it has direct localhost RPC access to bitcoind. # # What this does: # 1. Installs build deps and compiles datum_gateway from source # 2. Creates a dedicated system user and config/log directories # 3. Writes /etc/datum-gateway/config.json from vars/secrets # 4. Patches bitcoin.conf with the required blockmaxsize/blocknotify lines # 5. Creates and enables a systemd service # 6. Creates a push-monitor health check script + systemd timer # 7. Registers a push monitor in Uptime Kuma # # Separate play: adds a Caddy reverse proxy on vipy for the dashboard. # # Stratum port (23334) is bound on knots_box_local. Expose it to miners via # a firewall rule, Tailscale, or a socket proxy on vipy — not handled here. # # Required secrets in infra_secrets.yml: # datum_mining_address - Bitcoin address for block rewards # datum_gateway_admin_password - Password for the /api admin endpoint # bitcoin_rpc_user - Shared with the bitcoin-knots deployment # bitcoin_rpc_password - Shared with the bitcoin-knots deployment - name: Deploy DATUM Gateway on knots_box_local hosts: bitcoin become: yes vars_files: - ../../infra_vars.yml - ../../services_config.yml - ../../infra_secrets.yml - ./datum_gateway_vars.yml vars: datum_gateway_subdomain: "{{ subdomains.datum_gateway }}" datum_gateway_domain: "{{ datum_gateway_subdomain }}.{{ root_domain }}" uptime_kuma_api_url: "https://{{ subdomains.uptime_kuma }}.{{ root_domain }}" tasks: # =========================================== # Build Dependencies # =========================================== - name: Install DATUM Gateway build dependencies apt: name: - cmake - build-essential - git - libjansson-dev - libmicrohttpd-dev - libsodium-dev - libcurl4-openssl-dev # Runtime-only (netcat for health check) - netcat-openbsd state: present update_cache: yes # =========================================== # System User and Directories # =========================================== - name: Create datum system user user: name: "{{ datum_gateway_user }}" system: yes shell: /usr/sbin/nologin home: "{{ datum_gateway_dir }}" create_home: no comment: "DATUM Gateway" - name: Create DATUM Gateway directories file: path: "{{ item.path }}" state: directory owner: "{{ item.owner }}" group: "{{ datum_gateway_group }}" mode: "{{ item.mode }}" loop: - { path: "{{ datum_gateway_dir }}", owner: root, mode: "0755" } - { path: "{{ datum_gateway_source_dir }}", owner: root, mode: "0755" } - { path: "{{ datum_gateway_config_dir }}", owner: "{{ datum_gateway_user }}", mode: "0750" } - { path: "{{ datum_gateway_log_dir }}", owner: "{{ datum_gateway_user }}", mode: "0750" } # =========================================== # Build from Source # =========================================== - name: Clone DATUM Gateway repository at {{ datum_gateway_version }} git: repo: https://github.com/OCEAN-xyz/datum_gateway.git dest: "{{ datum_gateway_source_dir }}" version: "{{ datum_gateway_version }}" force: yes register: git_clone - name: Configure cmake build command: cmake . -DCMAKE_BUILD_TYPE=Release args: chdir: "{{ datum_gateway_source_dir }}" - name: Compile datum_gateway command: make -j{{ datum_gateway_build_jobs }} args: chdir: "{{ datum_gateway_source_dir }}" - name: Install datum_gateway binary copy: src: "{{ datum_gateway_source_dir }}/datum_gateway" dest: "{{ datum_gateway_bin_path }}" remote_src: yes owner: root group: root mode: "0755" notify: Restart datum-gateway # =========================================== # Configuration # =========================================== - name: Write DATUM Gateway config.json copy: dest: "{{ datum_gateway_config_dir }}/config.json" content: | { "bitcoind": { "rpcuser": "{{ bitcoin_rpc_user }}", "rpcpassword": "{{ bitcoin_rpc_password }}", "rpcurl": "{{ datum_bitcoin_rpc_url }}", "notify_fallback": true }, "stratum": { "listen_port": {{ datum_gateway_stratum_port }}, "vardiff_min": {{ datum_vardiff_min }} }, "mining": { "pool_address": "{{ datum_mining_address }}", "coinbase_tag_primary": "{{ datum_coinbase_tag_primary }}", "coinbase_tag_secondary": "{{ datum_coinbase_tag_secondary }}" }, "api": { "admin_password": "{{ datum_gateway_admin_password }}", "listen_port": {{ datum_gateway_api_port }}, "modify_conf": false }, "logger": { "log_to_console": true, "log_to_file": true, "log_file": "{{ datum_gateway_log_dir }}/datum_gateway.log", "log_rotate_daily": true, "log_level_console": 2, "log_level_file": 1 }, "datum": { "pool_pass_workers": {{ datum_pool_pass_workers | lower }}, "pool_pass_full_users": {{ datum_pool_pass_full_users | lower }}, "pooled_mining_only": {{ datum_pooled_mining_only | lower }} } } owner: "{{ datum_gateway_user }}" group: "{{ datum_gateway_group }}" mode: "0640" notify: Restart datum-gateway # =========================================== # Systemd Service # =========================================== - name: Create datum-gateway systemd service copy: dest: /etc/systemd/system/datum-gateway.service content: | [Unit] Description=DATUM Gateway - Bitcoin Mining Gateway Documentation=https://github.com/OCEAN-xyz/datum_gateway After=network.target bitcoind.service Wants=bitcoind.service [Service] User={{ datum_gateway_user }} Group={{ datum_gateway_group }} Type=simple ExecStart={{ datum_gateway_bin_path }} --config {{ datum_gateway_config_dir }}/config.json Restart=on-failure RestartSec=10 StandardOutput=journal StandardError=journal # Prevent config from being read by other users ReadWritePaths={{ datum_gateway_log_dir }} ReadOnlyPaths={{ datum_gateway_config_dir }} [Install] WantedBy=multi-user.target owner: root group: root mode: "0644" notify: Restart datum-gateway - name: Reload systemd daemon systemd: daemon_reload: yes - name: Enable and start datum-gateway systemd: name: datum-gateway enabled: yes state: started # =========================================== # Health Check Script + Systemd Timer # =========================================== - name: Create DATUM Gateway health check script copy: dest: /usr/local/bin/datum-gateway-healthcheck-push.sh content: | #!/bin/bash UPTIME_KUMA_PUSH_URL="${UPTIME_KUMA_PUSH_URL}" STRATUM_PORT={{ datum_gateway_stratum_port }} check_datum() { # Service must be active and stratum port must be listening systemctl is-active --quiet datum-gateway && \ nc -z 127.0.0.1 "${STRATUM_PORT}" } push_to_uptime_kuma() { local status=$1 local msg=$2 if [ -z "$UPTIME_KUMA_PUSH_URL" ]; then echo "ERROR: UPTIME_KUMA_PUSH_URL not set" return 1 fi curl -s --max-time 10 --retry 2 -o /dev/null \ "${UPTIME_KUMA_PUSH_URL}?status=${status}&msg=${msg// /%20}&ping=" || true } if check_datum; then push_to_uptime_kuma "up" "OK" exit 0 else push_to_uptime_kuma "down" "DATUM Gateway not responding" exit 1 fi owner: root group: root mode: "0755" - name: Create datum-gateway health check systemd service copy: dest: /etc/systemd/system/datum-gateway-healthcheck.service content: | [Unit] Description=DATUM Gateway Health Check After=network.target datum-gateway.service [Service] Type=oneshot User=root ExecStart=/usr/local/bin/datum-gateway-healthcheck-push.sh Environment=UPTIME_KUMA_PUSH_URL= StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target owner: root group: root mode: "0644" - name: Create datum-gateway health check systemd timer copy: dest: /etc/systemd/system/datum-gateway-healthcheck.timer content: | [Unit] Description=DATUM Gateway Health Check Timer [Timer] OnBootSec=2min OnUnitActiveSec=1min Persistent=true [Install] WantedBy=timers.target owner: root group: root mode: "0644" - name: Reload systemd daemon after health check units systemd: daemon_reload: yes - name: Enable and start datum-gateway health check timer systemd: name: datum-gateway-healthcheck.timer enabled: yes state: started # =========================================== # Uptime Kuma Push Monitor Setup # =========================================== - name: Create Uptime Kuma push monitor setup script for DATUM Gateway delegate_to: localhost become: no copy: dest: /tmp/setup_datum_gateway_monitor.py content: | #!/usr/bin/env python3 import sys import time import traceback import yaml try: import socketio.exceptions except ImportError: pass from uptime_kuma_api import UptimeKumaApi, MonitorType try: with open('/tmp/ansible_datum_gateway_config.yml', 'r') as f: config = yaml.safe_load(f) url = config['uptime_kuma_url'] username = config['username'] password = config['password'] monitor_name = config['monitor_name'] api = UptimeKumaApi(url, timeout=30) api.login(username, password) monitors = api.get_monitors() # Find or create "services" group group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) if not group: try: api.add_monitor(type='group', name='services') except Exception: time.sleep(2) monitors = api.get_monitors() group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) # Get ntfy notification ID notifications = api.get_notifications() ntfy_notification_id = None for notif in notifications: if notif.get('type') == 'ntfy': ntfy_notification_id = notif.get('id') break # Check if monitor already exists existing = next((m for m in monitors if m.get('name') == monitor_name), None) push_url = None if existing: print(f"Monitor '{monitor_name}' already exists (ID: {existing['id']})") push_token = existing.get('pushToken') or existing.get('push_token') if push_token: push_url = f"{url}/api/push/{push_token}" else: print(f"Creating push monitor '{monitor_name}'...") try: api.add_monitor( type=MonitorType.PUSH, name=monitor_name, parent=group['id'], interval=90, maxretries=3, retryInterval=60, notificationIDList={ntfy_notification_id: True} if ntfy_notification_id else {} ) except Exception as e: # socketio timeout: add_monitor may have succeeded server-side print(f"add_monitor raised (possibly timeout): {e}", file=sys.stderr) time.sleep(2) monitors = api.get_monitors() new_monitor = next((m for m in monitors if m.get('name') == monitor_name), None) if new_monitor: push_token = new_monitor.get('pushToken') or new_monitor.get('push_token') if push_token: push_url = f"{url}/api/push/{push_token}" api.disconnect() if push_url: print(f"PUSH_URL={push_url}") with open('/tmp/datum_gateway_push_url.txt', 'w') as f: f.write(push_url) print("SUCCESS") except Exception as e: print(f"ERROR: {str(e)}", file=sys.stderr) traceback.print_exc(file=sys.stderr) sys.exit(1) mode: "0755" - name: Create temporary config for push monitor setup delegate_to: localhost become: no copy: dest: /tmp/ansible_datum_gateway_config.yml content: | uptime_kuma_url: "{{ uptime_kuma_api_url }}" username: "{{ uptime_kuma_username }}" password: "{{ uptime_kuma_password }}" monitor_name: "DATUM Gateway" mode: "0644" - name: Run Uptime Kuma push monitor setup command: python3 /tmp/setup_datum_gateway_monitor.py delegate_to: localhost become: no register: monitor_setup changed_when: "'SUCCESS' in monitor_setup.stdout" ignore_errors: yes - name: Display monitor setup output debug: msg: "{{ monitor_setup.stdout_lines }}" when: monitor_setup.stdout is defined - name: Read push URL from file slurp: src: /tmp/datum_gateway_push_url.txt delegate_to: localhost become: no register: push_url_file ignore_errors: yes - name: Parse push URL set_fact: datum_push_url: "{{ push_url_file.content | b64decode | trim }}" when: push_url_file.content is defined - name: Update health check service with push URL lineinfile: path: /etc/systemd/system/datum-gateway-healthcheck.service regexp: "^Environment=UPTIME_KUMA_PUSH_URL=" line: "Environment=UPTIME_KUMA_PUSH_URL={{ datum_push_url }}" when: datum_push_url is defined notify: Restart datum-gateway health check timer - name: Clean up temporary files delegate_to: localhost become: no file: path: "{{ item }}" state: absent loop: - /tmp/setup_datum_gateway_monitor.py - /tmp/ansible_datum_gateway_config.yml - /tmp/datum_gateway_push_url.txt handlers: - name: Restart datum-gateway systemd: name: datum-gateway state: restarted daemon_reload: yes - name: Restart datum-gateway health check timer systemd: name: datum-gateway-healthcheck.timer state: restarted daemon_reload: yes # =========================================== # Caddy Reverse Proxy for DATUM Dashboard (on vipy) # =========================================== - name: Configure Caddy reverse proxy for DATUM Gateway dashboard on vipy hosts: edge become: yes vars_files: - ../../infra_vars.yml - ../../services_config.yml - ../../infra_secrets.yml - ./datum_gateway_vars.yml vars: datum_gateway_subdomain: "{{ subdomains.datum_gateway }}" datum_gateway_domain: "{{ datum_gateway_subdomain }}.{{ root_domain }}" caddy_sites_dir: "{{ caddy_sites_dir }}" uptime_kuma_api_url: "https://{{ subdomains.uptime_kuma }}.{{ root_domain }}" tasks: - name: Ensure Caddy sites-enabled directory exists file: path: "{{ caddy_sites_dir }}" state: directory owner: root group: root mode: "0755" - name: Ensure Caddyfile includes import directive for sites-enabled lineinfile: path: /etc/caddy/Caddyfile line: "import sites-enabled/*" insertafter: EOF state: present backup: yes create: yes mode: "0644" - name: Create Caddy reverse proxy config for DATUM Gateway dashboard copy: dest: "{{ caddy_sites_dir }}/datum-gateway.conf" content: | {{ datum_gateway_domain }} { basic_auth { {{ datum_dashboard_username }} {{ datum_dashboard_password_hash }} } reverse_proxy knots-box:{{ datum_gateway_api_port }} { # Resolve via Tailscale MagicDNS transport http { resolvers 100.100.100.100 } } } owner: root group: root mode: "0644" - name: Validate Caddy config command: caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile register: caddy_validate changed_when: false - name: Display Caddy validation output debug: msg: "{{ caddy_validate.stdout_lines + caddy_validate.stderr_lines }}" - name: Reload Caddy command: systemctl reload caddy register: caddy_reload - name: Display Caddy reload output debug: msg: "{{ caddy_reload.stdout_lines + caddy_reload.stderr_lines }}" - name: Display DATUM Gateway dashboard URL debug: msg: "DATUM Gateway dashboard: https://{{ datum_gateway_domain }}" # =========================================== # Uptime Kuma HTTP Monitor for Public Dashboard # =========================================== - name: Create Uptime Kuma HTTP monitor setup script for DATUM dashboard delegate_to: localhost become: no copy: dest: /tmp/setup_datum_http_monitor.py content: | #!/usr/bin/env python3 import sys import time import traceback import yaml from uptime_kuma_api import UptimeKumaApi, MonitorType try: with open('/tmp/ansible_datum_http_config.yml', 'r') as f: config = yaml.safe_load(f) url = config['uptime_kuma_url'] username = config['username'] password = config['password'] monitor_url = config['monitor_url'] monitor_name = config['monitor_name'] api = UptimeKumaApi(url, timeout=30) api.login(username, password) monitors = api.get_monitors() group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) if not group: try: api.add_monitor(type='group', name='services') except Exception: time.sleep(2) monitors = api.get_monitors() group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) notifications = api.get_notifications() ntfy_notification_id = None for notif in notifications: if notif.get('type') == 'ntfy': ntfy_notification_id = notif.get('id') break existing = next((m for m in monitors if m.get('name') == monitor_name), None) if existing: print(f"Monitor '{monitor_name}' already exists (ID: {existing['id']})") else: print(f"Creating HTTP monitor '{monitor_name}'...") try: api.add_monitor( type=MonitorType.HTTP, name=monitor_name, url=monitor_url, parent=group['id'], interval=60, maxretries=3, retryInterval=60, notificationIDList={ntfy_notification_id: True} if ntfy_notification_id else {} ) except Exception as e: print(f"add_monitor raised (possibly timeout): {e}", file=sys.stderr) time.sleep(2) api.disconnect() print("SUCCESS") except Exception as e: print(f"ERROR: {str(e)}", file=sys.stderr) traceback.print_exc(file=sys.stderr) sys.exit(1) mode: "0755" - name: Create temporary config for HTTP monitor delegate_to: localhost become: no copy: dest: /tmp/ansible_datum_http_config.yml content: | uptime_kuma_url: "{{ uptime_kuma_api_url }}" username: "{{ uptime_kuma_username }}" password: "{{ uptime_kuma_password }}" monitor_url: "https://{{ datum_gateway_domain }}" monitor_name: "DATUM Gateway Dashboard" mode: "0644" - name: Run Uptime Kuma HTTP monitor setup command: python3 /tmp/setup_datum_http_monitor.py delegate_to: localhost become: no register: http_monitor_setup changed_when: "'SUCCESS' in http_monitor_setup.stdout" ignore_errors: yes - name: Display HTTP monitor setup output debug: msg: "{{ http_monitor_setup.stdout_lines }}" when: http_monitor_setup.stdout is defined - name: Clean up HTTP monitor temporary files delegate_to: localhost become: no file: path: "{{ item }}" state: absent loop: - /tmp/setup_datum_http_monitor.py - /tmp/ansible_datum_http_config.yml # =========================================== # Stratum Port Forwarding on vipy via systemd-socket-proxyd # Miners connect to vipy:23334; traffic is forwarded to knots-box:23334 # over the Tailscale network, matching the Bitcoin P2P proxy pattern. # =========================================== - name: Setup public Stratum port forwarding on vipy via systemd-socket-proxyd hosts: edge become: yes vars_files: - ../../infra_vars.yml - ../../services_config.yml - ../../infra_secrets.yml - ./datum_gateway_vars.yml vars: datum_tailscale_hostname: "knots-box" uptime_kuma_api_url: "https://{{ subdomains.uptime_kuma }}.{{ root_domain }}" tasks: - name: Create Stratum proxy socket unit copy: dest: /etc/systemd/system/datum-stratum-proxy.socket content: | [Unit] Description=DATUM Stratum Proxy Socket [Socket] ListenStream={{ datum_gateway_stratum_port }} [Install] WantedBy=sockets.target owner: root group: root mode: "0644" notify: Restart datum-stratum-proxy socket - name: Create Stratum proxy service unit copy: dest: /etc/systemd/system/datum-stratum-proxy.service content: | [Unit] Description=DATUM Stratum Proxy to {{ datum_tailscale_hostname }} Requires=datum-stratum-proxy.socket After=network.target [Service] Type=notify ExecStart=/lib/systemd/systemd-socket-proxyd {{ datum_tailscale_hostname }}:{{ datum_gateway_stratum_port }} owner: root group: root mode: "0644" - name: Reload systemd daemon systemd: daemon_reload: yes - name: Enable and start Stratum proxy socket systemd: name: datum-stratum-proxy.socket enabled: yes state: started - name: Allow Stratum port through UFW ufw: rule: allow port: "{{ datum_gateway_stratum_port | string }}" proto: tcp comment: "DATUM Gateway Stratum public access" - name: Verify connectivity to knots-box Stratum via Tailscale wait_for: host: "{{ datum_tailscale_hostname }}" port: "{{ datum_gateway_stratum_port }}" timeout: 10 ignore_errors: yes - name: Display public Stratum endpoint debug: msg: "DATUM Stratum public endpoint: {{ ansible_host }}:{{ datum_gateway_stratum_port }}" # =========================================== # Uptime Kuma TCP Monitor for Public Stratum # =========================================== - name: Create Uptime Kuma TCP monitor setup script for Stratum delegate_to: localhost become: no copy: dest: /tmp/setup_datum_stratum_tcp_monitor.py content: | #!/usr/bin/env python3 import sys import time import traceback import yaml from uptime_kuma_api import UptimeKumaApi, MonitorType try: with open('/tmp/ansible_datum_stratum_config.yml', 'r') as f: config = yaml.safe_load(f) url = config['uptime_kuma_url'] username = config['username'] password = config['password'] monitor_host = config['monitor_host'] monitor_port = config['monitor_port'] monitor_name = config['monitor_name'] api = UptimeKumaApi(url, timeout=30) api.login(username, password) monitors = api.get_monitors() group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) if not group: try: api.add_monitor(type='group', name='services') except Exception: time.sleep(2) monitors = api.get_monitors() group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) notifications = api.get_notifications() ntfy_notification_id = None for notif in notifications: if notif.get('type') == 'ntfy': ntfy_notification_id = notif.get('id') break existing = next((m for m in monitors if m.get('name') == monitor_name), None) if existing: print(f"Monitor '{monitor_name}' already exists (ID: {existing['id']})") else: print(f"Creating TCP monitor '{monitor_name}'...") try: api.add_monitor( type=MonitorType.PORT, name=monitor_name, hostname=monitor_host, port=monitor_port, parent=group['id'], interval=60, maxretries=3, retryInterval=60, notificationIDList={ntfy_notification_id: True} if ntfy_notification_id else {} ) except Exception as e: print(f"add_monitor raised (possibly timeout): {e}", file=sys.stderr) time.sleep(2) api.disconnect() print("SUCCESS") except Exception as e: print(f"ERROR: {str(e)}", file=sys.stderr) traceback.print_exc(file=sys.stderr) sys.exit(1) mode: "0755" - name: Create temporary config for Stratum TCP monitor delegate_to: localhost become: no copy: dest: /tmp/ansible_datum_stratum_config.yml content: | uptime_kuma_url: "{{ uptime_kuma_api_url }}" username: "{{ uptime_kuma_username }}" password: "{{ uptime_kuma_password }}" monitor_host: "{{ ansible_host }}" monitor_port: {{ datum_gateway_stratum_port }} monitor_name: "DATUM Stratum (public)" mode: "0644" - name: Run Uptime Kuma TCP monitor setup command: python3 /tmp/setup_datum_stratum_tcp_monitor.py delegate_to: localhost become: no register: tcp_monitor_setup changed_when: "'SUCCESS' in tcp_monitor_setup.stdout" ignore_errors: yes - name: Display TCP monitor setup output debug: msg: "{{ tcp_monitor_setup.stdout_lines }}" when: tcp_monitor_setup.stdout is defined - name: Clean up Stratum TCP monitor temporary files delegate_to: localhost become: no file: path: "{{ item }}" state: absent loop: - /tmp/setup_datum_stratum_tcp_monitor.py - /tmp/ansible_datum_stratum_config.yml handlers: - name: Restart datum-stratum-proxy socket systemd: name: datum-stratum-proxy.socket state: restarted