Login

Forwarding SMS to my Self-Hosted Server

by Eric Bette

Forwarding SMS to my Self-Hosted Server

Share this post

Forwarding SMS to my Self-Hosted Server

Forwarding SMS to my Self-Hosted Server

While automating my life, I often came across situations where I need for "forward" SMS messages from my phone to my server. This often came up when attempting to automate workflows that require me to log in using two-factor authentication. Being uncomfortable with turning off 2FA just for the sake of my own automation, I developed a small webhook pipeline for forwarding incoming SMS from my phone to my server.

Overview

Incoming SMS on an Android phone are forwarded as HTTP POST requests to a webhook endpoint on the server. The webhook validates each request, then runs a shell script that writes the message body to a text file.

In essence, there's two major components that work together here:

  1. The SMS Forwarder on the Android Phone.
  2. The webhook on the server that receives the hook and does something with it.

More specifically, the end-to-end system looks like this:

SMS forwarding flow: Android phone to Cloudflare Tunnel to Traefik to webhook validation to sms-webhook.sh to sms/*.txt, read by consumers, emptied by daily cleanup

Request path:

  1. Android phone receives SMS.
  2. Incoming SMS to URL forwarder POSTs JSON to https://sms.example.com/hooks/sms-webhook?token=..., signed with HMAC-SHA256.
  3. Cloudflare Tunnel -> Traefik -> webhook container.
  4. adnanh/webhook checks the URL token and the X-Signature HMAC.
  5. Hook runs a custom script, which write the message body to a .txt file.
  6. Consumers read the directory; a scheduled job empties it daily.

Components

Server: webhook container

compose.yaml

services:
  webhook:
    container_name: webhook
    image: thecatlady/webhook
    restart: unless-stopped
    command: -template -verbose -hooks=hooks.json -hotreload
    environment:
      - HOOK_TOKEN=${HOOK_TOKEN}
      - HOOK_SECRET=${HOOK_SECRET}
    volumes:
      - /etc/docker/webhook:/config
    ports: 
      - 9000:9000
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:9000/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

Flags:

.env

HOOK_TOKEN=<random string, sent as ?token= in the URL>
HOOK_SECRET=<random string, shared HMAC key with the phone app>
DOMAIN=example.com

Generate values with e.g. openssl rand -hex 32.

/etc/docker/webhook/hooks.json

This is the hooks.json config file required by the webhook app. We configure it here to set the URL path and trigger the script when hooked.

[
  {
    "id": "sms-webhook",
    "execute-command": "sms-webhook.sh",
    "command-working-directory": "/config",
    "pass-arguments-to-command": [
      { "source": "payload", "name": "from" },
      { "source": "payload", "name": "receivedStamp" },
      { "source": "payload", "name": "text" }
    ],
    "trigger-rule": {
      "and": [
        {
          "match": {
            "type": "value",
            "value": "{{ getenv "HOOK_TOKEN" | js }}",
            "parameter": { "source": "url", "name": "token" }
          }
        },
        {
          "match": {
            "type": "payload-hmac-sha256",
            "secret": "{{ getenv "HOOK_SECRET" | js }}",
            "parameter": { "source": "header", "name": "X-Signature" }
          }
        }
      ]
    }
  }
]

/etc/docker/webhook/sms-webhook.sh

This is the actual script that gets called by webhook (pointed to by hooks.json above).

#!/bin/sh

FROM=$1
RECEIVED=$2
TEXT=$3

echo $TEXT > ./sms/${RECEIVED}_${FROM}.txt
ℹ️ The script must be executable (chmod +x).

Phone: Incoming SMS to URL forwarder

Forwarding rule fields:

{
  "from": "%from%",
  "text": "%text%",
  "sentStamp": "%sentStamp%",
  "receivedStamp": "%receivedStamp%",
  "sim": "%sim%"
}
ℹ️ Only SMS are forwarded. Messages delivered over RCS do not trigger the app; RCS must be turned off in Google Messages for those senders to be forwarded.
ℹ️ Android battery optimization and background permissions likely need to be relaxed for this app to run as a service.

Retention (optional)

To avoid filling up the directory over time, I configured a xyops (formerly Cronicle) event to occassionally rm the .txt files.

#!/bin/sh

ssh host "rm -f /etc/docker/webhook/sms/*"

Conclusion

Thanks for reading! Up to this point, I use this for automated logins only, but if you have any other interesting use cases for this, I'd be really interested in hearing about them.

Happy coding!