feat: add webhook listener for push events to channels
HTTP POST endpoint for external services (CI, monitoring, GitHub). HMAC-SHA256 auth, JSON body, single POST endpoint at /. - asyncio.start_server with raw HTTP parsing (zero deps) - Body validation: channel prefix, non-empty text, 64KB cap - !webhook admin command shows address, request count, uptime - Module-level server guard prevents duplicates on reconnect - 22 test cases in test_webhook.py
This commit is contained in:
196
plugins/webhook.py
Normal file
196
plugins/webhook.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Webhook listener: receive HTTP POST requests and relay messages to IRC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from derp.plugin import command, event
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_MAX_BODY = 65536 # 64 KB
|
||||
_server: asyncio.Server | None = None
|
||||
_request_count: int = 0
|
||||
_started: float = 0.0
|
||||
|
||||
|
||||
def _verify_signature(secret: str, body: bytes, signature: str) -> bool:
|
||||
"""Verify HMAC-SHA256 signature from X-Signature header."""
|
||||
if not secret:
|
||||
return True # no secret configured = open access
|
||||
if not signature.startswith("sha256="):
|
||||
return False
|
||||
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature[7:])
|
||||
|
||||
|
||||
def _http_response(status: int, reason: str, body: str = "") -> bytes:
|
||||
"""Build a minimal HTTP/1.1 response."""
|
||||
body_bytes = body.encode("utf-8") if body else b""
|
||||
lines = [
|
||||
f"HTTP/1.1 {status} {reason}",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
f"Content-Length: {len(body_bytes)}",
|
||||
"Connection: close",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
return "\r\n".join(lines).encode("utf-8") + body_bytes
|
||||
|
||||
|
||||
async def _handle_request(reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
bot, secret: str) -> None:
|
||||
"""Parse one HTTP request and dispatch to IRC."""
|
||||
global _request_count
|
||||
|
||||
try:
|
||||
# Read request line
|
||||
request_line = await asyncio.wait_for(reader.readline(), timeout=10.0)
|
||||
if not request_line:
|
||||
return
|
||||
parts = request_line.decode("utf-8", errors="replace").strip().split()
|
||||
if len(parts) < 2:
|
||||
writer.write(_http_response(400, "Bad Request", "malformed request"))
|
||||
return
|
||||
method, _path = parts[0], parts[1]
|
||||
|
||||
# Read headers
|
||||
headers: dict[str, str] = {}
|
||||
while True:
|
||||
line = await asyncio.wait_for(reader.readline(), timeout=10.0)
|
||||
if not line or line == b"\r\n" or line == b"\n":
|
||||
break
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if ":" in decoded:
|
||||
key, val = decoded.split(":", 1)
|
||||
headers[key.strip().lower()] = val.strip()
|
||||
|
||||
# Method check
|
||||
if method != "POST":
|
||||
writer.write(_http_response(405, "Method Not Allowed", "POST only"))
|
||||
return
|
||||
|
||||
# Read body
|
||||
content_length = int(headers.get("content-length", "0"))
|
||||
if content_length > _MAX_BODY:
|
||||
writer.write(_http_response(413, "Payload Too Large",
|
||||
f"max {_MAX_BODY} bytes"))
|
||||
return
|
||||
body = await asyncio.wait_for(reader.readexactly(content_length),
|
||||
timeout=10.0)
|
||||
|
||||
# Verify HMAC signature
|
||||
signature = headers.get("x-signature", "")
|
||||
if not _verify_signature(secret, body, signature):
|
||||
writer.write(_http_response(401, "Unauthorized", "bad signature"))
|
||||
return
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
writer.write(_http_response(400, "Bad Request", "invalid JSON"))
|
||||
return
|
||||
|
||||
# Validate fields
|
||||
channel = data.get("channel", "")
|
||||
text = data.get("text", "")
|
||||
is_action = data.get("action", False)
|
||||
|
||||
if not isinstance(channel, str) or not channel.startswith(("#", "&")):
|
||||
writer.write(_http_response(400, "Bad Request", "invalid channel"))
|
||||
return
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
writer.write(_http_response(400, "Bad Request", "empty text"))
|
||||
return
|
||||
|
||||
# Send to IRC
|
||||
text = text.strip()
|
||||
if is_action:
|
||||
await bot.action(channel, text)
|
||||
else:
|
||||
await bot.send(channel, text)
|
||||
|
||||
_request_count += 1
|
||||
writer.write(_http_response(200, "OK", "sent"))
|
||||
log.info("webhook: relayed to %s (%d bytes)", channel, len(text))
|
||||
|
||||
except (asyncio.TimeoutError, asyncio.IncompleteReadError, ConnectionError):
|
||||
log.debug("webhook: client disconnected")
|
||||
except Exception:
|
||||
log.exception("webhook: error handling request")
|
||||
try:
|
||||
writer.write(_http_response(500, "Internal Server Error"))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@event("001")
|
||||
async def on_connect(bot, message):
|
||||
"""Start the webhook HTTP server on connect (if enabled)."""
|
||||
global _server, _started, _request_count
|
||||
|
||||
if _server is not None:
|
||||
return # already running
|
||||
|
||||
cfg = bot.config.get("webhook", {})
|
||||
if not cfg.get("enabled"):
|
||||
return
|
||||
|
||||
host = cfg.get("host", "127.0.0.1")
|
||||
port = cfg.get("port", 8080)
|
||||
secret = cfg.get("secret", "")
|
||||
|
||||
async def handler(reader, writer):
|
||||
await _handle_request(reader, writer, bot, secret)
|
||||
|
||||
try:
|
||||
_server = await asyncio.start_server(handler, host, port)
|
||||
_started = time.monotonic()
|
||||
_request_count = 0
|
||||
log.info("webhook: listening on %s:%d", host, port)
|
||||
except OSError as exc:
|
||||
log.error("webhook: failed to bind %s:%d: %s", host, port, exc)
|
||||
|
||||
|
||||
@command("webhook", help="Show webhook listener status", admin=True)
|
||||
async def cmd_webhook(bot, message):
|
||||
"""Display webhook server status."""
|
||||
if _server is None:
|
||||
await bot.reply(message, "Webhook: not running")
|
||||
return
|
||||
|
||||
socks = _server.sockets
|
||||
if socks:
|
||||
addr = socks[0].getsockname()
|
||||
address = f"{addr[0]}:{addr[1]}"
|
||||
else:
|
||||
address = "unknown"
|
||||
|
||||
elapsed = int(time.monotonic() - _started)
|
||||
hours, rem = divmod(elapsed, 3600)
|
||||
minutes, secs = divmod(rem, 60)
|
||||
parts = []
|
||||
if hours:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes:
|
||||
parts.append(f"{minutes}m")
|
||||
parts.append(f"{secs}s")
|
||||
uptime = " ".join(parts)
|
||||
|
||||
await bot.reply(
|
||||
message,
|
||||
f"Webhook: {address} | {_request_count} requests | up {uptime}",
|
||||
)
|
||||
395
tests/test_webhook.py
Normal file
395
tests/test_webhook.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""Tests for the webhook listener plugin."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from derp.irc import Message
|
||||
|
||||
# plugins/ is not a Python package -- load the module from file path
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"plugins.webhook", Path(__file__).resolve().parent.parent / "plugins" / "webhook.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_spec.name] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
from plugins.webhook import ( # noqa: E402
|
||||
_MAX_BODY,
|
||||
_handle_request,
|
||||
_http_response,
|
||||
_verify_signature,
|
||||
cmd_webhook,
|
||||
on_connect,
|
||||
)
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeState:
|
||||
"""In-memory stand-in for bot.state."""
|
||||
|
||||
def __init__(self):
|
||||
self._store: dict[str, dict[str, str]] = {}
|
||||
|
||||
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
|
||||
return self._store.get(plugin, {}).get(key, default)
|
||||
|
||||
def set(self, plugin: str, key: str, value: str) -> None:
|
||||
self._store.setdefault(plugin, {})[key] = value
|
||||
|
||||
def delete(self, plugin: str, key: str) -> bool:
|
||||
try:
|
||||
del self._store[plugin][key]
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def keys(self, plugin: str) -> list[str]:
|
||||
return sorted(self._store.get(plugin, {}).keys())
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
"""Minimal bot stand-in that captures sent/action messages."""
|
||||
|
||||
def __init__(self, *, admin: bool = True, webhook_cfg: dict | None = None):
|
||||
self.sent: list[tuple[str, str]] = []
|
||||
self.replied: list[str] = []
|
||||
self.actions: list[tuple[str, str]] = []
|
||||
self.state = _FakeState()
|
||||
self._admin = admin
|
||||
self.prefix = "!"
|
||||
self.config = {
|
||||
"webhook": webhook_cfg or {"enabled": False},
|
||||
}
|
||||
|
||||
async def send(self, target: str, text: str) -> None:
|
||||
self.sent.append((target, text))
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
async def action(self, target: str, text: str) -> None:
|
||||
self.actions.append((target, text))
|
||||
|
||||
def _is_admin(self, message) -> bool:
|
||||
return self._admin
|
||||
|
||||
|
||||
def _msg(text: str, nick: str = "admin", target: str = "#test") -> Message:
|
||||
return Message(
|
||||
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||
command="PRIVMSG", params=[target, text], tags={},
|
||||
)
|
||||
|
||||
|
||||
def _sign(secret: str, body: bytes) -> str:
|
||||
"""Generate HMAC-SHA256 signature."""
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return f"sha256={sig}"
|
||||
|
||||
|
||||
class _FakeReader:
|
||||
"""Mock asyncio.StreamReader from raw HTTP bytes."""
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
|
||||
async def readline(self) -> bytes:
|
||||
start = self._pos
|
||||
idx = self._data.find(b"\n", start)
|
||||
if idx == -1:
|
||||
self._pos = len(self._data)
|
||||
return self._data[start:]
|
||||
self._pos = idx + 1
|
||||
return self._data[start:self._pos]
|
||||
|
||||
async def readexactly(self, n: int) -> bytes:
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += n
|
||||
return chunk
|
||||
|
||||
|
||||
class _FakeWriter:
|
||||
"""Mock asyncio.StreamWriter that captures output."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.data = b""
|
||||
self._closed = False
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self.data += data
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _build_request(method: str, body: bytes, headers: dict[str, str] | None = None) -> bytes:
|
||||
"""Build raw HTTP request bytes."""
|
||||
hdrs = headers or {}
|
||||
if "Content-Length" not in hdrs:
|
||||
hdrs["Content-Length"] = str(len(body))
|
||||
lines = [f"{method} / HTTP/1.1"]
|
||||
for k, v in hdrs.items():
|
||||
lines.append(f"{k}: {v}")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
return "\r\n".join(lines).encode("utf-8") + body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestVerifySignature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVerifySignature:
|
||||
def test_valid_signature(self):
|
||||
body = b'{"channel":"#test","text":"hello"}'
|
||||
sig = _sign("secret", body)
|
||||
assert _verify_signature("secret", body, sig) is True
|
||||
|
||||
def test_invalid_signature(self):
|
||||
body = b'{"channel":"#test","text":"hello"}'
|
||||
assert _verify_signature("secret", body, "sha256=bad") is False
|
||||
|
||||
def test_empty_secret_allows_all(self):
|
||||
body = b'{"channel":"#test","text":"hello"}'
|
||||
assert _verify_signature("", body, "") is True
|
||||
|
||||
def test_missing_prefix(self):
|
||||
body = b'{"channel":"#test","text":"hello"}'
|
||||
sig = hmac.new(b"secret", body, hashlib.sha256).hexdigest()
|
||||
assert _verify_signature("secret", body, sig) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestHttpResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHttpResponse:
|
||||
def test_200_response(self):
|
||||
resp = _http_response(200, "OK", "sent")
|
||||
assert b"200 OK" in resp
|
||||
assert b"sent" in resp
|
||||
|
||||
def test_400_with_body(self):
|
||||
resp = _http_response(400, "Bad Request", "invalid JSON")
|
||||
assert b"400 Bad Request" in resp
|
||||
assert b"invalid JSON" in resp
|
||||
|
||||
def test_405_response(self):
|
||||
resp = _http_response(405, "Method Not Allowed", "POST only")
|
||||
assert b"405 Method Not Allowed" in resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestRequestHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRequestHandler:
|
||||
def test_valid_post(self):
|
||||
bot = _FakeBot()
|
||||
body = json.dumps({"channel": "#ops", "text": "deploy done"}).encode()
|
||||
sig = _sign("secret", body)
|
||||
raw = _build_request("POST", body, {
|
||||
"Content-Length": str(len(body)),
|
||||
"Content-Type": "application/json",
|
||||
"X-Signature": sig,
|
||||
})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, "secret"))
|
||||
assert b"200 OK" in writer.data
|
||||
assert ("#ops", "deploy done") in bot.sent
|
||||
|
||||
def test_action_post(self):
|
||||
bot = _FakeBot()
|
||||
body = json.dumps({
|
||||
"channel": "#ops", "text": "deployed", "action": True,
|
||||
}).encode()
|
||||
sig = _sign("s", body)
|
||||
raw = _build_request("POST", body, {
|
||||
"Content-Length": str(len(body)),
|
||||
"X-Signature": sig,
|
||||
})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, "s"))
|
||||
assert b"200 OK" in writer.data
|
||||
assert ("#ops", "deployed") in bot.actions
|
||||
|
||||
def test_get_405(self):
|
||||
bot = _FakeBot()
|
||||
raw = _build_request("GET", b"")
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, ""))
|
||||
assert b"405" in writer.data
|
||||
|
||||
def test_bad_signature_401(self):
|
||||
bot = _FakeBot()
|
||||
body = json.dumps({"channel": "#test", "text": "x"}).encode()
|
||||
raw = _build_request("POST", body, {
|
||||
"Content-Length": str(len(body)),
|
||||
"X-Signature": "sha256=wrong",
|
||||
})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, "real-secret"))
|
||||
assert b"401" in writer.data
|
||||
assert len(bot.sent) == 0
|
||||
|
||||
def test_bad_json_400(self):
|
||||
bot = _FakeBot()
|
||||
body = b"not json"
|
||||
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, ""))
|
||||
assert b"400" in writer.data
|
||||
assert b"invalid JSON" in writer.data
|
||||
|
||||
def test_missing_channel_400(self):
|
||||
bot = _FakeBot()
|
||||
body = json.dumps({"text": "no channel"}).encode()
|
||||
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, ""))
|
||||
assert b"400" in writer.data
|
||||
assert b"invalid channel" in writer.data
|
||||
|
||||
def test_invalid_channel_400(self):
|
||||
bot = _FakeBot()
|
||||
body = json.dumps({"channel": "nochanprefix", "text": "x"}).encode()
|
||||
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, ""))
|
||||
assert b"400" in writer.data
|
||||
|
||||
def test_empty_text_400(self):
|
||||
bot = _FakeBot()
|
||||
body = json.dumps({"channel": "#test", "text": ""}).encode()
|
||||
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, ""))
|
||||
assert b"400" in writer.data
|
||||
assert b"empty text" in writer.data
|
||||
|
||||
def test_body_too_large_413(self):
|
||||
bot = _FakeBot()
|
||||
raw = _build_request("POST", b"", {
|
||||
"Content-Length": str(_MAX_BODY + 1),
|
||||
})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, ""))
|
||||
assert b"413" in writer.data
|
||||
|
||||
def test_counter_increments(self):
|
||||
bot = _FakeBot()
|
||||
# Reset counter
|
||||
_mod._request_count = 0
|
||||
body = json.dumps({"channel": "#test", "text": "hi"}).encode()
|
||||
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, ""))
|
||||
assert _mod._request_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestServerLifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestServerLifecycle:
|
||||
def test_disabled_config(self):
|
||||
"""Server does not start when webhook is disabled."""
|
||||
bot = _FakeBot(webhook_cfg={"enabled": False})
|
||||
msg = _msg("", target="")
|
||||
msg = Message(raw="", prefix="", nick="", command="001",
|
||||
params=["test", "Welcome"], tags={})
|
||||
# Reset global state
|
||||
_mod._server = None
|
||||
asyncio.run(on_connect(bot, msg))
|
||||
assert _mod._server is None
|
||||
|
||||
def test_duplicate_guard(self):
|
||||
"""Second on_connect does not create a second server."""
|
||||
sentinel = object()
|
||||
_mod._server = sentinel
|
||||
bot = _FakeBot(webhook_cfg={"enabled": True, "port": 0})
|
||||
msg = Message(raw="", prefix="", nick="", command="001",
|
||||
params=["test", "Welcome"], tags={})
|
||||
asyncio.run(on_connect(bot, msg))
|
||||
assert _mod._server is sentinel
|
||||
_mod._server = None # cleanup
|
||||
|
||||
def test_on_connect_starts(self):
|
||||
"""on_connect starts the server when enabled."""
|
||||
_mod._server = None
|
||||
bot = _FakeBot(webhook_cfg={
|
||||
"enabled": True, "host": "127.0.0.1", "port": 0, "secret": "",
|
||||
})
|
||||
msg = Message(raw="", prefix="", nick="", command="001",
|
||||
params=["test", "Welcome"], tags={})
|
||||
|
||||
async def _run():
|
||||
await on_connect(bot, msg)
|
||||
assert _mod._server is not None
|
||||
_mod._server.close()
|
||||
await _mod._server.wait_closed()
|
||||
_mod._server = None
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestWebhookCommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWebhookCommand:
|
||||
def test_not_running(self):
|
||||
bot = _FakeBot()
|
||||
_mod._server = None
|
||||
asyncio.run(cmd_webhook(bot, _msg("!webhook")))
|
||||
assert any("not running" in r for r in bot.replied)
|
||||
|
||||
def test_running_shows_status(self):
|
||||
bot = _FakeBot()
|
||||
_mod._request_count = 42
|
||||
_mod._started = time.monotonic() - 90 # 1m 30s ago
|
||||
|
||||
async def _run():
|
||||
# Start a real server on port 0 to get a valid socket
|
||||
srv = await asyncio.start_server(lambda r, w: None,
|
||||
"127.0.0.1", 0)
|
||||
_mod._server = srv
|
||||
try:
|
||||
await cmd_webhook(bot, _msg("!webhook"))
|
||||
finally:
|
||||
srv.close()
|
||||
await srv.wait_closed()
|
||||
_mod._server = None
|
||||
|
||||
asyncio.run(_run())
|
||||
assert len(bot.replied) == 1
|
||||
reply = bot.replied[0]
|
||||
assert "Webhook:" in reply
|
||||
assert "42 requests" in reply
|
||||
assert "127.0.0.1:" in reply
|
||||
Reference in New Issue
Block a user