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
| Setting | Value |
|---|---|
| Node | pve02 |
| VMID | 740 |
| IP | 10.69.70.40/24 |
| Gateway | 10.69.70.1 |
| CPU | 1 core |
| RAM | 512 MB |
| Swap | 256 MB |
| Disk | 8 GB |
| Template | debian-13-standard_13.1-2 |
| Unprivileged | yes |
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/datainside container)
Podman Quadlet
systemctl status hookshot
systemctl restart hookshot
podman logs hookshot
Ports
| Port | Protocol | Purpose |
|---|---|---|
| 9993 | TCP | Matrix appservice endpoint (Synapse → hookshot) |
| 9000 | TCP | Webhook 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
| Setting | Value |
|---|---|
| Matrix ID | @hookshot:matrix.helix9.org |
| Sender localpart | hookshot |
| 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
| Policy | Rule | Source | Destination | Port | Description |
|---|---|---|---|---|---|
| SERVERS-SCAN | 150 | PULSE (10.69.20.60) | 10.69.70.40 | 9000/TCP | Pulse 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
- In Element, create a new unencrypted room (e.g.
#homelab-alerts)Hookshot does not support end-to-end encrypted rooms
- Invite
@hookshot:matrix.helix9.orgto the room - Promote hookshot to Moderator in Room Settings → Roles & Permissions
- Send the following command in the room:
!hookshot webhook <name>
- 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:
| Field | Value |
|---|---|
| Name | hookshot - Element |
| HTTP Method | POST |
| Webhook URL | http://10.69.70.40:9000/webhook/<uuid> |
| Content-Type header | application/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:
| Variable | Purpose |
|---|---|
vault_hookshot_hs_token | Token Synapse sends to hookshot (64-char random) |
vault_hookshot_as_token | Token hookshot sends to Synapse (64-char random) |
Troubleshooting
| Problem | Solution |
|---|---|
Bot doesn't respond to !hookshot webhook | Room must be unencrypted; bot must be Moderator or above |
| Webhook returns 404 | Check bridge.port is set in config (not listeners.resources.appservice) |
| EHOSTUNREACH on startup | Flush netavark DNAT chain (see above) |
| Pulse webhook fails with "private IP" error | Add 10.69.70.40 to Pulse System Settings allowlist |
| Synapse not delivering events | Check podman logs synapse for appservice scheduler errors |