Controlling My Homelab over Signal: Building an Encrypted AI Bot Daemon

Written by

in

, ,

When you are away from your desk—standing in line at the grocery store, on the road, or working on-site for a client—and a monitoring alert buzzes in your pocket, what is your immediate reflex? Fumbling through an SSH client on a tiny touchscreen keyboard is painful, and exposing administrative web interfaces or SSH ports to the public internet is an invitation for brute-force attacks. I wanted a way to check status, trigger runbooks, and interrogate my homelab from anywhere in the world, with zero open inbound ports and military-grade encryption. My solution: building an autonomous AI management bot on top of Signal.

Signal is the gold standard for secure personal communications. By hosting a local signal-cli-rest-api gateway container alongside a Python orchestration daemon on docker02, my phone communicates with my internal infrastructure over end-to-end encrypted channels. Here is how the daemon is built, how the security boundaries are enforced, and how I manage my entire lab from a chat window.

1. The Architecture: Zero Inbound Firewall Ports

Traditional remote management relies on VPNs, port forwarding, or cloud-relayed management agents. While I have WireGuard configured on our UniFi Dream Machine for full tunnel access, opening a VPN tunnel just to check if a backup job finished or reboot a hung container is overkill.

The Signal bot architecture requires zero inbound ports through our edge firewall:

  1. Signal Gateway Container: A Docker container runs bbernhard/signal-cli-rest-api on docker02 (IP 10.1.20.19, port 8085). It maintains an outbound TLS connection to Signal’s relay servers.
  2. Python Daemon: A lightweight background service polls the REST API receive endpoint (or attaches via WebSocket) to capture incoming incoming messages in real time.
  3. Identity & Auth Verification: Every incoming payload is validated against cryptographic Sender UUIDs and verified phone numbers before any parsing happens.
  4. Execution & Reply: The daemon routes commands through our local AI toolset (Antigravity CLI / local scripts), formats the result, and pushes an encrypted reply back to my phone.

2. Deploying the Signal REST API Gateway

The gateway runs inside our standard Docker environment on docker02. The docker-compose.yml file pins a persistent data volume to preserve the cryptographic identity keys across restarts:

# /home/ntadmin/stacks/signal/docker-compose.yml
version: "3.8"
services:
  signal-api:
    image: bbernhard/signal-cli-rest-api:latest
    container_name: signal-api
    restart: unless-stopped
    ports:
      - "8085:8080"
    volumes:
      - ./data:/home/.local/share/signal-cli
    environment:
      - MODE=normal # Running in standard HTTP/JSON mode

To register the bot’s phone number or link it as a secondary device to an existing number, we generate a linking URI and render a QR code in the terminal using qrencode:

# Link device as a Signal client
docker exec -it signal-api signal-cli link -n "Homelab-Daemon" | while read -r line; do
  qrencode -t ansiutf8 ""
done

Scanning that QR code from the Signal app on my phone immediately pairs the container as an authorized secondary endpoint. From that second forward, the container can send and receive end-to-end encrypted messages.

3. The Security Model: Whitelisting & Human Confirmation

Critical Security Rule: Giving a bot access to execute commands on your infrastructure over a chat interface is extraordinarily dangerous if you don’t enforce strict authentication and dual-layer authorization.

I instituted three non-negotiable security layers inside the daemon:

  • Cryptographic UUID Verification: Phone numbers can theoretically be spoofed in SMS, but Signal relies on asymmetric key pairs. The daemon compares incoming message envelopes against my hardcoded source_uuid. Messages from any other sender are dropped instantly without returning an error (preventing reconnaissance).
  • Read vs. Write Boundaries: Non-destructive queries—such as asking for temperature telemetry, active Zabbix alarms, or Docker container states—respond immediately. Any destructive action (e.g., restarting a VM, bouncing a switch port, or stopping a service) requires an explicit confirmation challenge token.
  • Confirmation Handshake: If I type /restart jellyfin, the bot generates a single-use 6-digit confirmation token with a 60-second expiration: “Confirm restart of container ‘jellyfin’ by replying: CONFIRM-839201”. Nothing executes until the exact token matches.

4. Building the Python Daemon

Here is an architectural view of the receive loop inside our Python bot service. It continuously listens for incoming message payloads, verifies the sender, and handles both direct slash commands and natural-language AI prompts:

import requests, time, subprocess, json

SIGNAL_API = "http://127.0.0.1:8085"
BOT_NUMBER = "+13145550199"
AUTHORIZED_UUID = "e4a2d810-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

def send_message(recipient, message):
    payload = {
        "message": message,
        "number": BOT_NUMBER,
        "recipients": [recipient]
    }
    requests.post(f"{SIGNAL_API}/v2/send", json=payload)

def process_command(text, sender):
    text = text.strip()
    
    # 1. Quick status check
    if text.lower() == "/status":
        cmd = subprocess.run(["docker", "ps", "--format", "{{.Names}}: {{.Status}}"], 
                             capture_output=True, text=True)
        return f"Lab Status on docker02:\n{cmd.stdout[:1500]}"
    
    # 2. Network WAN check
    if text.lower() == "/wan":
        ping = subprocess.run(["ping", "-c", "2", "1.1.1.1"], capture_output=True, text=True)
        status = "ONLINE" if ping.returncode == 0 else "OFFLINE"
        return f"Internet Gateway Status: {status}"
        
    # 3. Route to Local AI Assistant
    return execute_ai_query(text)

def listen_loop():
    while True:
        try:
            resp = requests.get(f"{SIGNAL_API}/v1/receive/{BOT_NUMBER}")
            messages = resp.json()
            for item in messages:
                envelope = item.get("envelope", {})
                source_uuid = envelope.get("sourceUuid")
                source_number = envelope.get("sourceNumber")
                data_msg = envelope.get("dataMessage", {})
                body = data_msg.get("message")
                
                # Enforce cryptographic UUID whitelist
                if source_uuid != AUTHORIZED_UUID or not body:
                    continue
                
                reply = process_command(body, source_number)
                send_message(source_number, reply)
        except Exception as e:
            time.sleep(5)
        time.sleep(2)

5. Real-World Scenarios from My Phone

Having this daemon in production has completely changed how I interact with the lab when I am away:

  • Proactive Zabbix Alert Pushes: When a Zabbix trigger hits High or Disaster severity (like a disk reaching 90% capacity on the EqualLogic SAN), Zabbix fires a webhook directly into the Signal REST API. My phone receives a clean, actionable notification within 2 seconds.
  • Natural Language Health Checks: I can send: “How are the temperatures on R710?” The local AI parses IPMI telemetry and replies: “Ambient: 21°C, CPU 1: 42°C, CPU 2: 44°C. Fans running at 3,600 RPM.”
  • Quick Network Diagnostics: If my family texts that Netflix isn’t loading, I send /wan or /speedtest to verify whether our ISP gateway is having packet loss before I even log into my laptop.

It gives me total peace of mind. No inbound firewall holes, zero reliance on external third-party cloud brokers, and an interface as familiar and responsive as sending a text to a friend.