Skip to main content

Hookshot — Matrix Webhook Bridge

Project: matrix-org/matrix-hookshot Host: hookshot / hookshot.home.lab IP: 10.69.70.40 VLAN: 70 (DMZ) VMID: 740


Overview

Hookshot is a Matrix appservice bridge that receives incoming webhooks and forwards them as messages to Matrix rooms. It is used to deliver infrastructure alerts (e.g. from Pulse) to a Matrix room via Element.


Infrastructure

LXC Container

SettingValue
Nodepve02
VMID740
IP10.69.70.40/24
Gateway10.69.70.1
CPU1 core
RAM512 MB
Swap256 MB
Disk8 GB
Templatedebian-13-standard_13.1-2
Unprivilegedyes

Ansible

Provisioned and managed via Ansible:

  • Playbook: playbooks/hookshot.yml
  • Role: roles/hookshot/
  • Host vars: inventory/host_vars/hookshot/
  • Runtime: Podman Quadlet (systemd-managed container)
  • Data dir: /srv/hookshot (mounted as /data inside container)

Podman Quadlet

systemctl status hookshot
systemctl restart hookshot
podman logs hookshot

Ports

PortProtocolPurpose
9993TCPMatrix appservice endpoint (Synapse → hookshot)
9000TCPWebhook ingress (external services → hookshot)

Synapse Registration

Hookshot is registered with Synapse as a Matrix appservice. The registration file is deployed to /srv/hookshot/registration.yml and must be referenced in Synapse's homeserver.yaml:

app_service_config_files:
- /data/hookshot-registration.yml

The registration file is automatically fetched to /tmp/hookshot-registration.yml on the Ansible controller after each deploy. Copy it to the Synapse data directory and restart Synapse:

cp /tmp/hookshot-registration.yml /opt/matrix/synapse-data/hookshot-registration.yml
chmod 644 /opt/matrix/synapse-data/hookshot-registration.yml
podman restart synapse

Bot User

SettingValue
Matrix ID@hookshot:matrix.helix9.org
Sender localparthookshot
User namespace@hookshot_.*:matrix.helix9.org

Configuration

Key config at /srv/hookshot/config.yml:

homeserver:
url: "http://10.69.70.30:8008"
domain: "matrix.helix9.org"

passFile: "/data/passkey.pem"

bridge:
domain: "matrix.helix9.org"
url: "http://10.69.70.30:8008"
mediaUrl: "http://10.69.70.30:8008"
port: 9993
bindAddress: "0.0.0.0"

listeners:
- port: 9000
bindAddress: "0.0.0.0"
resources:
- webhooks

generic:
enabled: true
urlPrefix: "http://10.69.70.40:9000"
allowJsTransformationFunctions: false
waitForComplete: false

Firewall

PolicyRuleSourceDestinationPortDescription
SERVERS-SCAN150PULSE (10.69.20.60)10.69.70.409000/TCPPulse webhook → hookshot

Synapse (10.69.70.30) reaches hookshot on port 9993 within the same DMZ VLAN — no firewall rule needed.


Setting Up a Webhook Room

  1. In Element, create a new unencrypted room (e.g. #homelab-alerts)

    Hookshot does not support end-to-end encrypted rooms

  2. Invite @hookshot:matrix.helix9.org to the room
  3. Promote hookshot to Moderator in Room Settings → Roles & Permissions
  4. Send the following command in the room:
    !hookshot webhook <name>
  5. Hookshot will reply in the admin DM with a secret webhook URL:
    http://10.69.70.40:9000/webhook/<uuid>

Pulse Alert Integration

Pulse sends webhook POST requests to hookshot when alert thresholds are exceeded.

Pulse Webhook Configuration

In Pulse → Settings → Notifications → Webhooks → Add Webhook:

FieldValue
Namehookshot - Element
HTTP MethodPOST
Webhook URLhttp://10.69.70.40:9000/webhook/<uuid>
Content-Type headerapplication/json

Payload Template

{
"text": "🚨 **{{.Level}} Alert** — {{.Message}}\n\n🖥️ **Node:** `{{.Node}}`\n📊 **Resource:** `{{.ResourceName}}`\n📈 **Value:** {{.Value}} *(threshold: {{.Threshold}})*\n⏱️ **Duration:** {{.Duration}}\n🕐 **Time:** {{.Timestamp}}"
}

Available Pulse template variables: {{.ID}}, {{.Level}}, {{.Type}}, {{.ResourceName}}, {{.Node}}, {{.Message}}, {{.Value}}, {{.Threshold}}, {{.Duration}}, {{.Timestamp}}

Private Network Allowlist

Pulse blocks webhook URLs resolving to private IPs by default. Add hookshot's IP in Pulse → System Settings → Webhook allowlist:

10.69.70.40

Known Issues / Workarounds

Netavark stale DNAT rules

Podman's netavark backend can leave stale nft DNAT rules from previous container runs with published ports. These rules redirect traffic to non-existent container IPs, causing EHOSTUNREACH even for loopback connections.

The Quadlet pre-starts a flush to clear stale rules:

ExecStartPre=-/usr/sbin/nft flush chain inet netavark nv_2f259bab_10_88_0_0_nm16_dnat

If hookshot becomes unreachable, flush manually:

nft flush chain inet netavark nv_2f259bab_10_88_0_0_nm16_dnat
systemctl restart hookshot

No E2EE support

Hookshot cannot read or send messages in encrypted Matrix rooms. Always create alert rooms without encryption.

Synapse ping endpoint

Hookshot logs a warning at startup:

Homeserver was pinged but was unable to validate the connection

This is non-fatal — Synapse 1.148.0 does not implement the optional /_matrix/app/v1/ping endpoint. The bridge starts normally.


Secrets

Stored in Ansible Vault at inventory/host_vars/hookshot/vault.yml:

VariablePurpose
vault_hookshot_hs_tokenToken Synapse sends to hookshot (64-char random)
vault_hookshot_as_tokenToken hookshot sends to Synapse (64-char random)

Troubleshooting

ProblemSolution
Bot doesn't respond to !hookshot webhookRoom must be unencrypted; bot must be Moderator or above
Webhook returns 404Check bridge.port is set in config (not listeners.resources.appservice)
EHOSTUNREACH on startupFlush netavark DNAT chain (see above)
Pulse webhook fails with "private IP" errorAdd 10.69.70.40 to Pulse System Settings allowlist
Synapse not delivering eventsCheck podman logs synapse for appservice scheduler errors