feat: inbound webhook relay with bearer token authentication

Add POST /webhook endpoint to the web viewer. Authenticated via
Authorization: Bearer <token> set in [Webhook] config section. Relays
JSON or text payload to a configured MeshCore channel or user DM.
This commit is contained in:
Stacy Olivas
2026-03-17 17:43:21 -07:00
parent d33169fa53
commit 96bf1747c2
2 changed files with 498 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""
Inbound Webhook Service for MeshCore Bot
Starts a lightweight HTTP server that accepts POST requests from external
systems and relays them as messages into MeshCore channels or DMs.
Config section ``[Webhook]``::
enabled = false
host = 127.0.0.1 # bind address (0.0.0.0 to expose externally)
port = 8765 # listen port
secret_token = # if set, require Authorization: Bearer <token>
allowed_channels = # comma-separated whitelist; empty = all channels
max_message_length = 200 # truncate messages exceeding this length
HTTP API
--------
POST /webhook
Content-Type: application/json
Authorization: Bearer <secret_token> (if secret_token is configured)
Body (channel message)::
{"channel": "general", "message": "Hello from webhook!"}
Body (DM)::
{"dm_to": "SomeUser", "message": "Private message"}
Response codes:
200 {"ok": true}
400 {"error": "..."} bad/missing fields
401 {"error": "Unauthorized"} wrong / missing token
405 method not allowed
"""
import secrets
from typing import Any, Optional
from .base_service import BaseServicePlugin
try:
from aiohttp import web as aio_web
AIOHTTP_AVAILABLE = True
except ImportError:
aio_web = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = False
class WebhookService(BaseServicePlugin):
"""Accept inbound HTTP POST webhooks and relay them as MeshCore messages."""
config_section = "Webhook"
description = "Inbound webhook receiver — relay HTTP POST payloads to MeshCore channels"
# Maximum body size accepted (bytes)
MAX_BODY_SIZE = 8_192
def __init__(self, bot: Any) -> None:
super().__init__(bot)
cfg = bot.config
if not AIOHTTP_AVAILABLE:
self.logger.error(
"WebhookService requires aiohttp. Install it with: pip install aiohttp"
)
self.enabled = False
return
if not cfg.has_section("Webhook"):
self.enabled = False
return
self.enabled = cfg.getboolean("Webhook", "enabled", fallback=False)
self.host = cfg.get("Webhook", "host", fallback="127.0.0.1").strip()
self.port = cfg.getint("Webhook", "port", fallback=8765)
self.secret_token: str = cfg.get("Webhook", "secret_token", fallback="").strip()
self.max_message_length: int = cfg.getint(
"Webhook", "max_message_length", fallback=200
)
raw_channels = cfg.get("Webhook", "allowed_channels", fallback="").strip()
self.allowed_channels = (
{c.strip().lstrip("#").lower() for c in raw_channels.split(",") if c.strip()}
if raw_channels
else set()
)
self._runner: Optional[Any] = None # aio_web.AppRunner
self._site: Optional[Any] = None # aio_web.TCPSite
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> None:
if not self.enabled:
self.logger.info("Webhook service is disabled")
return
app = aio_web.Application(client_max_size=self.MAX_BODY_SIZE)
app.router.add_post("/webhook", self._handle_webhook)
self._runner = aio_web.AppRunner(app)
await self._runner.setup()
self._site = aio_web.TCPSite(self._runner, self.host, self.port)
await self._site.start()
auth_info = "token auth enabled" if self.secret_token else "NO auth (secret_token not set)"
self.logger.info(
f"Webhook service listening on {self.host}:{self.port} ({auth_info})"
)
self._running = True
async def stop(self) -> None:
self._running = False
if self._runner:
await self._runner.cleanup()
self._runner = None
self._site = None
self.logger.info("Webhook service stopped")
# ------------------------------------------------------------------
# Request handler
# ------------------------------------------------------------------
async def _handle_webhook(self, request: Any) -> Any:
"""Handle a POST /webhook request."""
# --- Auth ---
if self.secret_token and not self._verify_token(request):
self.logger.warning(
f"Webhook: rejected unauthenticated request from {request.remote}"
)
return aio_web.Response(
status=401,
content_type="application/json",
text='{"error": "Unauthorized"}',
)
# --- Parse body ---
try:
body = await request.json()
except Exception:
return aio_web.Response(
status=400,
content_type="application/json",
text='{"error": "Invalid JSON body"}',
)
message_text: str = str(body.get("message", "")).strip()
if not message_text:
return aio_web.Response(
status=400,
content_type="application/json",
text='{"error": "Missing required field: message"}',
)
# Truncate to configured limit
if len(message_text) > self.max_message_length:
message_text = message_text[: self.max_message_length]
channel: str = str(body.get("channel", "")).strip().lstrip("#")
dm_to: str = str(body.get("dm_to", "")).strip()
if not channel and not dm_to:
return aio_web.Response(
status=400,
content_type="application/json",
text='{"error": "Missing required field: channel or dm_to"}',
)
# --- Channel whitelist check ---
if channel and self.allowed_channels and channel.lower() not in self.allowed_channels:
self.logger.warning(
f"Webhook: channel '{channel}' not in allowed_channels whitelist"
)
return aio_web.Response(
status=400,
content_type="application/json",
text='{"error": "Channel not allowed"}',
)
# --- Dispatch ---
try:
if channel:
await self._send_channel_message(channel, message_text)
self.logger.info(
f"Webhook: sent to #{channel} from {request.remote}: "
f"{message_text[:60]}{'...' if len(message_text) > 60 else ''}"
)
else:
await self._send_dm(dm_to, message_text)
self.logger.info(
f"Webhook: sent DM to {dm_to} from {request.remote}: "
f"{message_text[:60]}{'...' if len(message_text) > 60 else ''}"
)
except Exception as exc:
self.logger.error(f"Webhook: failed to send message: {exc}")
return aio_web.Response(
status=500,
content_type="application/json",
text='{"error": "Failed to send message"}',
)
return aio_web.Response(
status=200,
content_type="application/json",
text='{"ok": true}',
)
# ------------------------------------------------------------------
# Auth helpers
# ------------------------------------------------------------------
def _verify_token(self, request: Any) -> bool:
"""Return True if the request carries the correct bearer token."""
auth_header: str = request.headers.get("Authorization", "")
if auth_header.lower().startswith("bearer "):
provided = auth_header[7:].strip()
return secrets.compare_digest(provided, self.secret_token)
# Also accept X-Webhook-Token header
x_token: str = request.headers.get("X-Webhook-Token", "").strip()
if x_token:
return secrets.compare_digest(x_token, self.secret_token)
return False
# ------------------------------------------------------------------
# Message dispatch
# ------------------------------------------------------------------
async def _send_channel_message(self, channel: str, message: str) -> None:
"""Send a message to a MeshCore channel via command_manager."""
cm = getattr(self.bot, "command_manager", None)
if cm is None:
raise RuntimeError("command_manager not available on bot")
await cm.send_channel_message(
channel,
message,
skip_user_rate_limit=True,
rate_limit_key=None,
)
async def _send_dm(self, recipient: str, message: str) -> None:
"""Send a direct message via command_manager."""
cm = getattr(self.bot, "command_manager", None)
if cm is None:
raise RuntimeError("command_manager not available on bot")
await cm.send_dm(
recipient,
message,
skip_user_rate_limit=True,
rate_limit_key=None,
)
+243
View File
@@ -0,0 +1,243 @@
"""Tests for WebhookService."""
from configparser import ConfigParser
from unittest.mock import AsyncMock, Mock
import pytest
from modules.service_plugins.webhook_service import WebhookService
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _make_bot(mock_logger, extra_cfg=None):
"""Return a minimal mock bot for WebhookService."""
bot = Mock()
bot.logger = mock_logger
bot.config = ConfigParser()
bot.config.add_section("Webhook")
bot.config.set("Webhook", "enabled", "true")
bot.config.set("Webhook", "host", "127.0.0.1")
bot.config.set("Webhook", "port", "8765")
bot.config.set("Webhook", "secret_token", "")
bot.config.set("Webhook", "max_message_length", "200")
bot.config.set("Webhook", "allowed_channels", "")
if extra_cfg:
for key, val in extra_cfg.items():
bot.config.set("Webhook", key, val)
bot.command_manager = Mock()
bot.command_manager.send_channel_message = AsyncMock(return_value=True)
bot.command_manager.send_dm = AsyncMock(return_value=True)
return bot
def _make_request(body=None, headers=None, remote="127.0.0.1"):
"""Return a mock aiohttp Request."""
req = Mock()
req.remote = remote
req.headers = headers or {}
req.json = AsyncMock(return_value=body or {})
return req
def _make_service(mock_logger, extra_cfg=None):
bot = _make_bot(mock_logger, extra_cfg)
return WebhookService(bot), bot
# ---------------------------------------------------------------------------
# TestInit
# ---------------------------------------------------------------------------
class TestInit:
def test_enabled_reads_from_config(self, mock_logger):
svc, _ = _make_service(mock_logger)
assert svc.enabled is True
def test_disabled_when_no_section(self, mock_logger):
bot = Mock()
bot.logger = mock_logger
bot.config = ConfigParser()
svc = WebhookService(bot)
assert svc.enabled is False
def test_allowed_channels_parsed(self, mock_logger):
svc, _ = _make_service(mock_logger, {"allowed_channels": "general, alerts"})
assert "general" in svc.allowed_channels
assert "alerts" in svc.allowed_channels
def test_hash_stripped_from_channel_names(self, mock_logger):
svc, _ = _make_service(mock_logger, {"allowed_channels": "#general,#alerts"})
assert "general" in svc.allowed_channels
assert "alerts" in svc.allowed_channels
def test_empty_allowed_channels_means_all(self, mock_logger):
svc, _ = _make_service(mock_logger)
assert svc.allowed_channels == set()
def test_secret_token_loaded(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "s3cr3t"})
assert svc.secret_token == "s3cr3t"
# ---------------------------------------------------------------------------
# TestVerifyToken
# ---------------------------------------------------------------------------
class TestVerifyToken:
def test_bearer_token_accepted(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "abc123"})
req = _make_request(headers={"Authorization": "Bearer abc123"})
assert svc._verify_token(req) is True
def test_wrong_bearer_rejected(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "abc123"})
req = _make_request(headers={"Authorization": "Bearer wrong"})
assert svc._verify_token(req) is False
def test_x_webhook_token_accepted(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "abc123"})
req = _make_request(headers={"X-Webhook-Token": "abc123"})
assert svc._verify_token(req) is True
def test_no_token_header_rejected(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "abc123"})
req = _make_request(headers={})
assert svc._verify_token(req) is False
def test_case_insensitive_bearer_prefix(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "tok"})
req = _make_request(headers={"Authorization": "BEARER tok"})
assert svc._verify_token(req) is True
# ---------------------------------------------------------------------------
# TestHandleWebhook — auth
# ---------------------------------------------------------------------------
class TestHandleWebhookAuth:
@pytest.mark.asyncio
async def test_missing_token_returns_401(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "secret"})
req = _make_request(body={"channel": "general", "message": "hi"}, headers={})
resp = await svc._handle_webhook(req)
assert resp.status == 401
@pytest.mark.asyncio
async def test_correct_token_returns_200(self, mock_logger):
svc, _ = _make_service(mock_logger, {"secret_token": "secret"})
req = _make_request(
body={"channel": "general", "message": "hi"},
headers={"Authorization": "Bearer secret"},
)
resp = await svc._handle_webhook(req)
assert resp.status == 200
@pytest.mark.asyncio
async def test_no_token_required_when_secret_empty(self, mock_logger):
svc, _ = _make_service(mock_logger) # no secret_token
req = _make_request(body={"channel": "general", "message": "hi"}, headers={})
resp = await svc._handle_webhook(req)
assert resp.status == 200
# ---------------------------------------------------------------------------
# TestHandleWebhook — validation
# ---------------------------------------------------------------------------
class TestHandleWebhookValidation:
@pytest.mark.asyncio
async def test_invalid_json_returns_400(self, mock_logger):
svc, _ = _make_service(mock_logger)
req = _make_request()
req.json = AsyncMock(side_effect=Exception("bad json"))
resp = await svc._handle_webhook(req)
assert resp.status == 400
@pytest.mark.asyncio
async def test_missing_message_returns_400(self, mock_logger):
svc, _ = _make_service(mock_logger)
req = _make_request(body={"channel": "general"})
resp = await svc._handle_webhook(req)
assert resp.status == 400
@pytest.mark.asyncio
async def test_missing_channel_and_dm_returns_400(self, mock_logger):
svc, _ = _make_service(mock_logger)
req = _make_request(body={"message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 400
@pytest.mark.asyncio
async def test_disallowed_channel_returns_400(self, mock_logger):
svc, _ = _make_service(mock_logger, {"allowed_channels": "alerts"})
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 400
@pytest.mark.asyncio
async def test_allowed_channel_passes(self, mock_logger):
svc, _ = _make_service(mock_logger, {"allowed_channels": "general"})
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 200
# ---------------------------------------------------------------------------
# TestHandleWebhook — dispatch
# ---------------------------------------------------------------------------
class TestHandleWebhookDispatch:
@pytest.mark.asyncio
async def test_channel_message_dispatched(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(body={"channel": "general", "message": "Hello!"})
await svc._handle_webhook(req)
bot.command_manager.send_channel_message.assert_awaited_once()
call_args = bot.command_manager.send_channel_message.call_args
assert call_args[0][0] == "general"
assert call_args[0][1] == "Hello!"
@pytest.mark.asyncio
async def test_hash_stripped_from_channel_in_body(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(body={"channel": "#general", "message": "Hello!"})
await svc._handle_webhook(req)
call_args = bot.command_manager.send_channel_message.call_args
assert call_args[0][0] == "general"
@pytest.mark.asyncio
async def test_dm_dispatched(self, mock_logger):
svc, bot = _make_service(mock_logger)
req = _make_request(body={"dm_to": "Alice", "message": "Hi Alice!"})
await svc._handle_webhook(req)
bot.command_manager.send_dm.assert_awaited_once()
call_args = bot.command_manager.send_dm.call_args
assert call_args[0][0] == "Alice"
assert call_args[0][1] == "Hi Alice!"
@pytest.mark.asyncio
async def test_long_message_truncated(self, mock_logger):
svc, bot = _make_service(mock_logger, {"max_message_length": "10"})
long_msg = "A" * 100
req = _make_request(body={"channel": "general", "message": long_msg})
await svc._handle_webhook(req)
sent = bot.command_manager.send_channel_message.call_args[0][1]
assert len(sent) == 10
@pytest.mark.asyncio
async def test_send_failure_returns_500(self, mock_logger):
svc, bot = _make_service(mock_logger)
bot.command_manager.send_channel_message = AsyncMock(
side_effect=RuntimeError("mesh offline")
)
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 500