Skip to content

Add Ansible-managed Caddy installation and internal TLS for bell webhooks #96

Closed

Description

@psmsmets

Create an idempotent Ansible solution that installs Caddy on Raspberry Pi bell nodes and exposes the School Bell webhook securely over HTTPS.

Architecture

Use the following model:

  • Assign every bell a stable IP address through a DHCP reservation.
  • Use a unique hostname for every bell, such as pibell-vito-01.local.
  • Use hostnames, not IP addresses, in HTTPS webhook URLs.
  • Resolve hostnames through managed local DNS where available.
  • Provide an Ansible-managed /etc/hosts fallback when local DNS is unavailable or too slow.
  • Maintain one internal certificate authority for the bell environment.
  • Distribute only the public root CA certificate to participating nodes.
  • Issue a unique leaf certificate and private key for every bell.
  • Terminate HTTPS in Caddy and proxy to School Bell over loopback.

Do not share one leaf certificate or private key between bell nodes. Never distribute the CA private key to managed nodes.

Inventory

Support deployment through inventory hosts and groups, including selection by school or bell group.

Example:

[school_bells]
pibell-main ansible_host=192.0.2.41 caddy_hostname=pibell-main.local
pibell-yard ansible_host=192.0.2.42 caddy_hostname=pibell-yard.local

[site_bells:children]
school_bells

Activity

  1. psmsmets commented on Sep 12, 2026

    @psmsmets
    OwnerAuthor

    Local HTTPS webhook setup with Caddy

    This procedure was validated with two Raspberry Pi devices:

    • pibell-main: sending node
    • pibell-yard: receiving node running the webhook
    • pibell-yard.school-bell.internal: HTTPS hostname
    • 192.0.2.42: example fixed address of the receiving node

    Use real addresses from the target network. The 192.0.2.0/24 range is reserved for documentation.

    1. Provide stable addressing

    Assign every bell a fixed DHCP reservation. Certificates and remote webhook configuration should use hostnames, not IP addresses.

    Example:

    pibell-yard.school-bell.internal → 192.0.2.42
    

    Use .internal for private network names. Avoid .local, because it is reserved for mDNS, and avoid public suffixes such as .dev.

    2. Create proper DNS records

    In Pi-hole, open:

    Settings → Local DNS Records → DNS Records
    

    Create an A-record:

    Domain:    pibell-yard.school-bell.internal
    IP address 192.0.2.42
    

    Do not create a CNAME pointing to an IP address. This incorrect configuration:

    pibell-yard.school-bell.internal. IN CNAME 192.0.2.42.
    

    must instead return:

    pibell-yard.school-bell.internal. IN A 192.0.2.42
    

    When multiple DNS servers are advertised through DHCP, ensure they all return the same internal records. Do not advertise a public resolver as a secondary DNS server, because it cannot resolve private names and may be selected independently by clients.

    3. Install DNS diagnostic tools

    Install the BIND client utilities on each Raspberry Pi:

    sudo apt update
    sudo apt install bind9-dnsutils

    This installs tools such as dig and nslookup; it does not install the BIND DNS server.

    Verify DNS directly against each internal resolver:

    dig @<PIHOLE_IP> +noall +answer \
      pibell-yard.school-bell.internal A
    
    dig @<UNIFI_GATEWAY_IP> +noall +answer \
      pibell-yard.school-bell.internal A

    Verify normal system resolution:

    dig +short pibell-yard.school-bell.internal A
    getent ahostsv4 pibell-yard.school-bell.internal

    Both must resolve to the fixed address:

    192.0.2.42
    

    dig queries DNS directly, while applications such as curl use the system resolver. If dig works but getent does not, inspect:

    grep '^hosts:' /etc/nsswitch.conf
    cat /etc/resolv.conf

    The hosts: entry must include dns.

    4. Bind the School Bell webhook to loopback

    Configure the receiving node so that its built-in HTTP webhook is not exposed directly to the network:

    {
      "webhook": {
        "enabled": true,
        "host": "127.0.0.1",
        "port": 8081,
        "token": "replace-with-a-long-random-token",
        "rate_limit": 10,
        "rate_window": 60
      }
    }

    Validate the School Bell configuration and restart the service as appropriate.

    Check the backend without activating the bell:

    curl -i http://127.0.0.1:8081/bell

    Expected result:

    405 Method Not Allowed
    

    This confirms that the webhook is running. The endpoint only accepts POST.

    5. Install Caddy on the receiving node

    Install Caddy using its official Debian/Raspberry Pi OS installation procedure:

    https://caddyserver.com/docs/install
    

    Back up the default configuration:

    sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.original

    Configure /etc/caddy/Caddyfile:

    pibell-yard.school-bell.internal {
        tls internal
    
        request_body {
            max_size 4KB
        }
    
        reverse_proxy 127.0.0.1:8081
    }
    

    This configuration:

    • terminates HTTPS on port 443;
    • obtains a certificate from Caddy's local CA;
    • renews the certificate automatically;
    • limits request bodies to 4 KB;
    • forwards accepted traffic to the loopback-only webhook.

    Validate and load the configuration:

    sudo caddy validate --config /etc/caddy/Caddyfile
    sudo systemctl reload caddy
    sudo systemctl status caddy --no-pager

    Verify that Caddy is listening on HTTPS:

    sudo ss -ltnp | grep ':443'

    Inspect errors when necessary:

    sudo journalctl -u caddy -n 50 --no-pager

    6. Verify HTTPS locally

    Caddy's public root certificate is normally stored at:

    /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt
    

    Install it in the receiving node's system trust store:

    sudo install -m 0644 \
      /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt \
      /usr/local/share/ca-certificates/school-bell-caddy.crt
    
    sudo update-ca-certificates

    Test using normal DNS resolution:

    curl -i https://pibell-yard.school-bell.internal/bell

    Expected result:

    HTTP/2 405
    

    For temporary testing before DNS is operational, preserve hostname validation with:

    curl -i \
      --resolve pibell-yard.school-bell.internal:443:192.0.2.42 \
      --cacert /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt \
      https://pibell-yard.school-bell.internal/bell

    Do not use curl -k, because that disables certificate verification.

    7. Export the public CA certificate

    On the receiving node, export only the public root certificate to a location readable by the SSH user:

    sudo install -o pi -g pi -m 0644 \
      /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt \
      /home/pi/school-bell-caddy-root.crt

    Adjust the account and home directory where necessary.

    Record its SHA-256 fingerprint:

    openssl x509 -in /home/pi/school-bell-caddy-root.crt \
      -noout -fingerprint -sha256

    Never export or copy private keys from Caddy's data directory.

    8. Trust the CA on the sending node

    Copy the public certificate over an authenticated SSH connection:

    scp pi@pibell-yard.school-bell.internal:/home/pi/school-bell-caddy-root.crt \
      /tmp/school-bell-caddy-root.crt

    Verify its fingerprint on the sending node:

    openssl x509 -in /tmp/school-bell-caddy-root.crt \
      -noout -fingerprint -sha256

    Confirm that it matches the fingerprint shown on the receiving node.

    Install it in the system trust store:

    sudo install -m 0644 \
      /tmp/school-bell-caddy-root.crt \
      /usr/local/share/ca-certificates/school-bell-caddy.crt
    
    sudo update-ca-certificates

    Verify the complete connection without --resolve, --cacert or -k:

    curl -i https://pibell-yard.school-bell.internal/bell

    Expected result:

    HTTP/2 405
    

    This confirms that DNS resolution, CA trust, hostname validation, TLS and the reverse proxy all work.

    9. Test an authenticated webhook request

    Perform an explicit test before enabling automatic remote triggers:

    curl -i -X POST \
      https://pibell-yard.school-bell.internal/bell \
      -H 'Authorization: Bearer replace-with-a-long-random-token' \
      -H 'Content-Type: application/json' \
      --data '{"wav_key":"lesson"}'

    Expected result:

    202 Accepted
    

    The configured signal should activate on the receiving node.

    Relevant failure responses include:

    • 400: invalid payload or WAVE key;
    • 401: incorrect bearer token;
    • 409: another signal is already active;
    • 429: rate limit exceeded;
    • 502: Caddy cannot reach 127.0.0.1:8081;
    • certificate error: the CA is not trusted or the hostname does not match;
    • name resolution error: the system resolver cannot resolve the internal name.

    10. Configure the remote trigger

    Configure the sending node to use the final HTTPS hostname:

    {
      "transport": "webhook",
      "url": "https://pibell-yard.school-bell.internal/bell",
      "auth": {
        "type": "bearer",
        "token": "replace-with-a-long-random-token"
      },
      "timeout": 5
    }

    Restart School Bell after validating the configuration.

    Trigger the sending node manually and confirm that the receiving node activates.

    11. Monitor the end-to-end test

    On the receiving node:

    sudo journalctl -u school-bell -f

    In another terminal, monitor Caddy:

    sudo journalctl -u caddy -f

    A successful request should produce the School Bell webhook lifecycle events, including:

    webhook_bell_triggered
    webhook_bell_completed
    

    The sending node should report a successful manual_remote_trigger.

    12. Network controls

    Permit only the required flows:

    • sending bell nodes to internal DNS over UDP and TCP port 53;
    • authorized sending nodes to receiving bell nodes over TCP port 443;
    • Caddy to the School Bell webhook over local loopback;
    • administrative SSH access from the management network.

    Do not expose port 8081 outside the receiving host. The built-in webhook remains plain HTTP and should stay bound to 127.0.0.1.

    Because the reverse proxy connects over loopback, all proxied requests appear to the built-in webhook as the same client. Its rate limit is therefore shared by all clients using that proxy.

    13. Requirements for later Ansible automation

    The optional Caddy automation should:

    • remain separate from the core School Bell installation flow;
    • install caddy and bind9-dnsutils;
    • require an explicit caddy_hostname;
    • validate that the webhook is bound to 127.0.0.1;
    • generate the Caddyfile from a template;
    • validate the Caddy configuration before reloading it;
    • preserve Caddy's persistent data directory;
    • distribute only the public CA certificate;
    • verify certificate fingerprints before trust installation;
    • install the CA idempotently in the system trust store;
    • support DNS verification with dig and application-level verification with getent;
    • verify the HTTPS endpoint without disabling TLS validation;
    • support optional /etc/hosts entries only as a controlled fallback;
    • avoid replacing existing School Bell schedules or configuration unexpectedly;
    • use handlers so Caddy and School Bell restart only when their configuration changes.

    For a two-node manual deployment, trusting the receiving node's Caddy CA is sufficient. For a larger managed deployment, define one controlled internal CA and let it issue a separate certificate for every receiving node. Never copy one server certificate between devices, and never distribute the CA private key to managed nodes.

  2. psmsmets commented on Sep 12, 2026

    @psmsmets
    OwnerAuthor

    Additional findings from manual validation

    The complete HTTPS webhook flow was validated successfully between two Raspberry Pi nodes. The following findings should be incorporated into the documentation and optional Ansible automation.

    Use a private hostname

    The .local suffix caused conflicts with mDNS. A hostname below the private .internal namespace worked correctly:

    pibell-yard.school-bell.internal
    

    Use the same hostname consistently in:

    • internal DNS;
    • the Caddyfile;
    • the TLS certificate;
    • remote webhook URLs.

    Create an A-record, not a CNAME

    The first Pi-hole record was accidentally created as:

    pibell-yard.school-bell.internal. IN CNAME 192.0.2.42.
    

    Although dig returned a value, applications using the system resolver could not resolve the hostname because the IP address was incorrectly treated as a CNAME target.

    Create the record under:

    Settings → Local DNS Records → DNS Records
    

    The correct response is:

    pibell-yard.school-bell.internal. IN A 192.0.2.42
    

    Verify DNS using:

    dig +noall +answer pibell-yard.school-bell.internal A
    getent ahostsv4 pibell-yard.school-bell.internal
    

    When multiple DNS servers are advertised through DHCP, ensure that they all return the same internal records. Do not advertise a public resolver as a secondary DNS server because it cannot resolve private hostnames and may be selected independently by clients.

    Install DNS diagnostic tools

    Install the BIND client utilities on the Raspberry Pi nodes:

    sudo apt update
    sudo apt install bind9-dnsutils
    

    This provides dig and nslookup; it does not install the BIND DNS server.

    Useful diagnostic commands include:

    dig pibell-yard.school-bell.internal
    dig @<PIHOLE_IP> pibell-yard.school-bell.internal
    dig @<UNIFI_GATEWAY_IP> pibell-yard.school-bell.internal
    getent ahostsv4 pibell-yard.school-bell.internal
    grep '^hosts:' /etc/nsswitch.conf
    cat /etc/resolv.conf
    

    The optional Ansible Caddy playbook should install bind9-dnsutils so DNS can be diagnosed immediately after deployment.

    Keep the built-in webhook on loopback

    The School Bell webhook should remain bound to:

    127.0.0.1:8081
    

    Caddy accepts external HTTPS traffic on port 443 and proxies it locally over HTTP:

    Sending node → HTTPS:443 → Caddy → HTTP:8081 on loopback → School Bell
    

    Do not expose port 8081 directly to the network.

    Remove the backend port from remote URLs

    After introducing Caddy, remote webhook URLs must use the standard HTTPS port:

    https://pibell-yard.school-bell.internal/bell
    

    The following URL is incorrect:

    https://pibell-yard.school-bell.internal:8081/bell
    

    Port 8081 is the plain HTTP backend and does not provide TLS. Existing remote webhook configurations must therefore remove :8081 when migrating to Caddy.

    Distribute only Caddy's public root certificate

    Caddy's public root certificate is normally stored at:

    /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt
    

    Copy only this public certificate to sending nodes. Never copy private keys from Caddy's data directory.

    Before installing the certificate, compare its SHA-256 fingerprint on the receiving and sending nodes:

    openssl x509 -in school-bell-caddy-root.crt \
      -noout -fingerprint -sha256
    

    Install the verified root certificate on a Debian or Raspberry Pi OS sending node:

    sudo install -m 0644 \
      school-bell-caddy-root.crt \
      /usr/local/share/ca-certificates/school-bell-caddy.crt
    
    sudo update-ca-certificates
    

    After installation, the following command should complete TLS verification and return HTTP status 405:

    curl -i https://pibell-yard.school-bell.internal/bell
    

    Do not use curl -k, because it disables certificate verification.

    Python Requests does not automatically use the installed local CA

    Installing Caddy's root certificate in the operating-system trust store was sufficient for curl, but School Bell initially failed with:

    Manual remote webhook failed: SSLError
    certificate verify failed: unable to get local issuer certificate
    

    School Bell uses Python requests from its virtual environment. In the tested environment, it used its own CA bundle instead of the operating-system bundle.

    The connection succeeded when tested with the system CA bundle:

    sudo -u pi env \
      REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
      /home/pi/.local/bin/python -c \
      "import requests; r=requests.get('https://pibell-yard.school-bell.internal/bell', timeout=5); print(r.status_code)"
    

    The expected result is:

    405
    

    The verified persistent workaround is a systemd drop-in:

    sudo SYSTEMD_EDITOR=vim systemctl edit school-bell
    

    Drop-in contents:

    [Service]
    Environment="REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"
    

    Apply and verify it with:

    sudo systemctl daemon-reload
    sudo systemctl restart school-bell
    sudo systemctl show school-bell --property=Environment
    

    The documentation and optional Ansible automation should:

    • install the public root CA under /usr/local/share/ca-certificates/;
    • run update-ca-certificates;
    • configure REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt;
    • implement the environment variable through an idempotent systemd drop-in;
    • restart School Bell only when the trust configuration changes;
    • verify HTTPS using the same Python interpreter and service user as School Bell;
    • never disable certificate validation;
    • never modify the virtual environment's certifi bundle directly, because package updates may overwrite that change.

    End-to-end validation and latency

    The HTTPS remote trigger was successfully validated between two Raspberry Pi nodes.

    Observed event sequence:

    14:54:50.226  pibell-main  manual_bell_triggered
    14:54:50.234  pibell-main  gpio_activated
    14:54:50.376  pibell-yard  webhook_bell_triggered
    14:54:50.381  pibell-yard  gpio_activated
    14:54:50.381  pibell-main  manual_remote_trigger
    

    The receiving bell started approximately 150 ms after the local trigger. This includes DNS resolution, TCP connection establishment, the TLS handshake, Caddy proxying and webhook processing.

    The local bell was not delayed while the remote HTTPS request was performed. The remote action ran concurrently and completed successfully.

    Observed completion times remained approximately aligned:

    14:55:12.648  pibell-main  manual_bell_completed
    14:55:12.785  pibell-yard  webhook_bell_completed
    

    The measured difference of roughly 140–150 ms is acceptable for this use case. No connection-persistence or latency optimization is currently required.

    Automation requirements identified during validation

    The optional Ansible Caddy automation should:

    • remain separate from the core School Bell installation flow;
    • install Caddy and bind9-dnsutils;
    • require an explicit HTTPS hostname for every receiving node;
    • generate and validate the Caddyfile before reloading Caddy;
    • keep the School Bell webhook bound to loopback;
    • install only public CA certificates on sending nodes;
    • verify CA fingerprints before installing trust;
    • configure the Python Requests CA bundle through systemd;
    • verify DNS with both dig and getent;
    • verify HTTPS without disabling certificate validation;
    • test the endpoint using the School Bell virtual environment;
    • support controlled /etc/hosts entries only as an optional DNS fallback;
    • use handlers so services restart only when their configuration changes;
    • preserve Caddy's persistent PKI data;
    • never distribute server private keys or the CA private key.
  3. psmsmets commented on Sep 12, 2026

    @psmsmets
    OwnerAuthor

    End-to-end validation and latency

    The HTTPS remote trigger was successfully validated between two Raspberry Pi nodes.

    Observed event sequence:

    14:54:50.226  pibell-main  manual_bell_triggered
    14:54:50.234  pibell-main  gpio_activated
    14:54:50.376  pibell-yard  webhook_bell_triggered
    14:54:50.381  pibell-yard  gpio_activated
    14:54:50.381  pibell-main  manual_remote_trigger
    

    The receiving bell started approximately 150 ms after the local trigger. This includes DNS resolution, TCP connection establishment, the TLS handshake, Caddy proxying and webhook processing.

    The local bell was not delayed while the remote HTTPS request was performed. The remote action ran concurrently and completed successfully.

    Observed completion times remained approximately aligned:

    14:55:12.648  pibell-main  manual_bell_completed
    14:55:12.785  pibell-yard  webhook_bell_completed
    

    The measured difference of roughly 140–150 ms is acceptable for this use case. No connection-persistence or latency optimization is currently required.

  4. linked a pull request that will close this issueAdd optional Caddy HTTPS deployment #97on Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocumentationImprovements or additions to documentationenhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions