-
Notifications
You must be signed in to change notification settings - Fork 23
Matrix Notification Plugin #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| # Matrix Notification Plugin | ||
|
|
||
| This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically send attack notifications to a Matrix room of your choice. | ||
|
|
||
| # Table of contents | ||
|
|
||
| - [Matrix Notification Plugin](#matrix-notification-plugin) | ||
| - [Table of contents](#table-of-contents) | ||
| - [Prerequisites](#prerequisites) | ||
| - [Setup](#setup) | ||
| - [Docker](#docker) | ||
| - [Swarm](#swarm) | ||
| - [Kubernetes](#kubernetes) | ||
| - [Settings](#settings) | ||
|
|
||
| # Prerequisites | ||
|
|
||
| Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first. | ||
|
|
||
| You will need: | ||
|
|
||
| - A Matrix server URL (e.g., `https://matrix.org`). | ||
| - A valid access token for the Matrix user you want to send notifications from. | ||
| - A room ID where notifications will be sent to. The matrix user has to be Member of that room. | ||
|
|
||
| Please refer to your homeserver's docs if you need help setting these up. | ||
|
|
||
| # Setup | ||
|
|
||
| See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation for the installation procedure depending on your integration. | ||
|
|
||
| There is no additional service setup required beyond configuring the plugin itself. | ||
|
|
||
| ## Docker | ||
|
|
||
| ```yaml | ||
| version: '3' | ||
|
|
||
| services: | ||
|
|
||
| bunkerweb: | ||
| image: bunkerity/bunkerweb:1.5.9 | ||
| ... | ||
| environment: | ||
| - USE_MATRIX=yes | ||
| - MATRIX_BASE_URL=https://matrix.org | ||
| - MATRIX_ROOM_ID=!yourRoomID:matrix.org | ||
| - MATRIX_ACCESS_TOKEN=your-access-token | ||
| ... | ||
| ``` | ||
|
|
||
| ## Swarm | ||
|
|
||
| ```yaml | ||
| version: '3' | ||
|
|
||
| services: | ||
|
|
||
| mybunker: | ||
| image: bunkerity/bunkerweb:1.5.9 | ||
| .. | ||
| environment: | ||
| - USE_MATRIX=yes | ||
| - MATRIX_BASE_URL=https://matrix.org | ||
| - MATRIX_ROOM_ID=!yourRoomID:matrix.org | ||
| - MATRIX_ACCESS_TOKEN=your-access-token | ||
| ... | ||
| ``` | ||
|
|
||
| ## Kubernetes | ||
|
|
||
| ```yaml | ||
| apiVersion: networking.k8s.io/v1 | ||
| kind: Ingress | ||
| metadata: | ||
| name: ingress | ||
| annotations: | ||
| bunkerweb.io/USE_MATRIX: "yes" | ||
| bunkerweb.io/MATRIX_BASE_URL: "https://matrix.org" | ||
| bunkerweb.io/MATRIX_ROOM_ID: "!yourRoomID:matrix.org" | ||
| bunkerweb.io/MATRIX_ACCESS_TOKEN: "your-access-token" | ||
| ``` | ||
|
|
||
| # Settings | ||
|
|
||
| | Setting | Default | Context | Multiple | Description | | ||
| | ------------------------ | ------------------------ | --------- | -------- | --------------------------------------------------------- | | ||
| | `USE_MATRIX` | `no` | multisite | no | Enable sending alerts to a Matrix room. | | ||
| | `MATRIX_BASE_URL` | `https://matrix.org` | global | no | Base URL of the Matrix server (e.g., https://matrix.org). | | ||
| | `MATRIX_ROOM_ID` | `!yourRoomID:matrix.org` | global | no | Room ID of the Matrix room to send notifications to. | | ||
| | `MATRIX_ACCESS_TOKEN` | | global | no | Access token to authenticate with the Matrix server. | | ||
| | `MATRIX_ANONYMIZE_IP` | `no` | global | no | Mask the IP address in notifications. | | ||
| | `MATRIX_INCLUDE_HEADERS` | `no` | global | no | Include request headers in notifications. | | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,290 @@ | ||
| local cjson = require("cjson") | ||
| local class = require("middleclass") | ||
| local http = require("resty.http") | ||
| local matrix_utils = require("matrix.utils") | ||
| local plugin = require("bunkerweb.plugin") | ||
| local utils = require("bunkerweb.utils") | ||
|
|
||
| local matrix = class("matrix", plugin) | ||
|
|
||
| local ngx = ngx | ||
| local ngx_req = ngx.req | ||
| local ERR = ngx.ERR | ||
| local INFO = ngx.INFO | ||
| local ngx_timer = ngx.timer | ||
| local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR | ||
| local HTTP_OK = ngx.HTTP_OK | ||
| local http_new = http.new | ||
| local has_variable = utils.has_variable | ||
| local get_variable = utils.get_variable | ||
| local get_reason = utils.get_reason | ||
| local get_country = utils.get_country | ||
| local get_asn = utils.get_asn | ||
| local get_asn_org = matrix_utils.get_asn_org | ||
| local tostring = tostring | ||
| local encode = cjson.encode | ||
| local escape_uri = ngx.escape_uri | ||
|
|
||
| -- Escape characters that are significant in the org.matrix.custom.html body so that | ||
| -- attacker-controlled values (URI, Host, header names/values) can't break the markup. | ||
| local function html_escape(str) | ||
| return (string.gsub(tostring(str), "[&<>]", { ["&"] = "&", ["<"] = "<", [">"] = ">" })) | ||
| end | ||
|
|
||
| -- Escape Lua pattern magic characters so a literal string can be used as a gsub pattern. | ||
| local function escape_pattern(str) | ||
| return (string.gsub(str, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1")) | ||
| end | ||
|
|
||
| -- Mask an IP for notifications. Handles both IPv4 and IPv6. | ||
| local function anonymize_ip(ip) | ||
| if string.find(ip, ":", 1, true) then | ||
| -- IPv6: keep the first three hextets, mask the remainder | ||
| local prefix = string.match(ip, "^(%x*:%x*:%x*):") | ||
| return prefix and (prefix .. ":xxxx") or "xxxx::xxxx" | ||
| end | ||
| -- IPv4: mask the last two octets | ||
| return (string.gsub(ip, "%d+%.%d+$", "xxx.xxx")) | ||
| end | ||
|
|
||
| -- Per-worker, monotonically increasing counter to guarantee transaction-ID uniqueness. | ||
| -- ngx.now() is cached per event-loop cycle, so time + pid alone can still collide. | ||
| local txn_counter = 0 | ||
|
|
||
| -- Build the Matrix "send message" endpoint URL. | ||
| -- The room ID (e.g. "!abc:matrix.org") must be percent-encoded in the path, and the | ||
| -- transaction ID must be unique: os.time() has 1s resolution and Matrix silently drops | ||
| -- duplicate transaction IDs, so bursts within the same second would lose notifications. | ||
| local function message_url(self) | ||
| local base_url = string.gsub(self.variables["MATRIX_BASE_URL"], "/+$", "") | ||
| local room_id = self.variables["MATRIX_ROOM_ID"] | ||
| txn_counter = txn_counter + 1 | ||
| local txn_id = string.format("%d_%d_%d", math.floor(ngx.now() * 1000), ngx.worker.pid(), txn_counter) | ||
| return string.format("%s/_matrix/client/r0/rooms/%s/send/m.room.message/%s", base_url, escape_uri(room_id), txn_id) | ||
| end | ||
|
|
||
| function matrix:initialize(ctx) | ||
| -- Call parent initialize | ||
| plugin.initialize(self, "matrix", ctx) | ||
| end | ||
|
|
||
| function matrix:log(bypass_use_matrix) | ||
| -- Check if matrix is enabled | ||
| if not bypass_use_matrix then | ||
| if self.variables["USE_MATRIX"] ~= "yes" then | ||
| return self:ret(true, "matrix plugin not enabled") | ||
| end | ||
| end | ||
| -- Check if request is denied | ||
| local reason, reason_data = get_reason(self.ctx) | ||
| if reason == nil then | ||
| return self:ret(true, "request not denied") | ||
| end | ||
| -- Compute data | ||
| local request_host = ngx.var.host or "unknown host" | ||
| local remote_addr = self.ctx.bw.remote_addr | ||
| local request_method = self.ctx.bw.request_method | ||
| local country, err = get_country(remote_addr) | ||
| if not country then | ||
| self.logger:log(ERR, "can't get Country of IP " .. remote_addr .. " : " .. err) | ||
| country = "Country unknown" | ||
| else | ||
| country = tostring(country) | ||
| end | ||
| local asn | ||
| asn, err = get_asn(remote_addr) | ||
| if not asn then | ||
| self.logger:log(ERR, "can't get ASN of IP " .. remote_addr .. " : " .. err) | ||
| asn = "ASN unknown" | ||
| else | ||
| asn = "ASN " .. tostring(asn) | ||
| end | ||
| local asn_org | ||
| asn_org, err = get_asn_org(remote_addr) | ||
| if not asn_org then | ||
| self.logger:log(ERR, "can't get Organization of IP " .. remote_addr .. " : " .. err) | ||
| asn_org = "AS Organization unknown" | ||
| else | ||
| asn_org = tostring(asn_org) | ||
| end | ||
| local data = {} | ||
| data["formatted_body"] = "<p>Denied " | ||
| .. html_escape(request_method) | ||
| .. " from <b>" | ||
| .. remote_addr | ||
| .. "</b> (" | ||
| .. country | ||
| .. ' • "<i>' | ||
| .. html_escape(asn_org) | ||
| .. '</i>" • ' | ||
| .. asn | ||
| .. ") to " | ||
| .. html_escape(request_host) | ||
| .. html_escape(self.ctx.bw.uri) | ||
| .. "<br>" | ||
| data["formatted_body"] = data["formatted_body"] | ||
| .. "Reason <b>" | ||
| .. html_escape(reason) | ||
| .. "</b> (" | ||
| .. html_escape(encode(reason_data or {})) | ||
| .. ").</p>" | ||
| data["body"] = "Denied " | ||
| .. request_method | ||
| .. " from " | ||
| .. remote_addr | ||
| .. " (" | ||
| .. country | ||
| .. ' • "' | ||
| .. asn_org | ||
| .. '" • ' | ||
| .. asn | ||
| .. ") to " | ||
| .. request_host | ||
| .. self.ctx.bw.uri | ||
| .. "\n" | ||
| data["body"] = data["body"] .. "Reason " .. reason .. " (" .. encode(reason_data or {}) .. ")." | ||
| -- Add headers if enabled | ||
| if self.variables["MATRIX_INCLUDE_HEADERS"] == "yes" then | ||
| local headers | ||
| headers, err = ngx_req.get_headers() | ||
| if not headers then | ||
| data["formatted_body"] = data["formatted_body"] .. "error while getting headers: " .. err | ||
| data["body"] = data["body"] .. "\n error while getting headers: " .. err | ||
| else | ||
| data["formatted_body"] = data["formatted_body"] .. "<table><tr><th>Header</th><th>Value</th></tr>" | ||
| data["body"] = data["body"] .. "\n\n" | ||
| for header, value in pairs(headers) do | ||
| -- Repeated headers are returned as a table by ngx.req.get_headers() | ||
| local header_value = type(value) == "table" and table.concat(value, ", ") or value | ||
| data["formatted_body"] = data["formatted_body"] | ||
| .. "<tr><td>" | ||
| .. html_escape(header) | ||
| .. "</td><td>" | ||
| .. html_escape(header_value) | ||
| .. "</td></tr>" | ||
| data["body"] = data["body"] .. header .. ": " .. header_value .. "\n" | ||
| end | ||
| data["formatted_body"] = data["formatted_body"] .. "</table>" | ||
| end | ||
| end | ||
| -- Anonymize IP if enabled | ||
| if self.variables["MATRIX_ANONYMIZE_IP"] == "yes" then | ||
| local masked = anonymize_ip(remote_addr) | ||
| local pattern = escape_pattern(remote_addr) | ||
| data["formatted_body"] = (string.gsub(data["formatted_body"], pattern, masked)) | ||
| data["body"] = (string.gsub(data["body"], pattern, masked)) | ||
| end | ||
| -- Send request | ||
| local hdr | ||
| hdr, err = ngx_timer.at(0, self.send, self, data) | ||
| if not hdr then | ||
| return self:ret(true, "can't create report timer: " .. err) | ||
| end | ||
| return self:ret(true, "scheduled timer") | ||
| end | ||
|
|
||
| -- luacheck: ignore 212 | ||
| function matrix.send(premature, self, data) | ||
| local httpc, err = http_new() | ||
| if not httpc then | ||
| self.logger:log(ERR, "can't instantiate http object : " .. err) | ||
| return | ||
| end | ||
| -- Prepare data | ||
| local access_token = self.variables["MATRIX_ACCESS_TOKEN"] | ||
| local url = message_url(self) | ||
| local message_data = { | ||
| msgtype = "m.text", | ||
| body = data["body"], | ||
| format = "org.matrix.custom.html", | ||
| formatted_body = data["formatted_body"], | ||
| } | ||
| local post_data = encode(message_data) | ||
| -- Send request | ||
| local res, err_http = httpc:request_uri(url, { | ||
| method = "PUT", | ||
| body = post_data, | ||
| headers = { | ||
| ["Content-Type"] = "application/json", | ||
| ["Authorization"] = "Bearer " .. access_token, | ||
| }, | ||
| }) | ||
|
Comment on lines
+204
to
+211
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Locate the file and check its existence
find . -name "matrix.lua" -type fRepository: bunkerity/bunkerweb-plugins Length of output: 90 🏁 Script executed: # Read the relevant sections of the file
if [ -f "matrix/matrix.lua" ]; then
echo "=== Lines 124-131 ===" && sed -n '124,131p' matrix/matrix.lua
echo ""
echo "=== Lines 190-197 ===" && sed -n '190,197p' matrix/matrix.lua
echo ""
echo "=== Full file context (first check size) ===" && wc -l matrix/matrix.lua
fiRepository: bunkerity/bunkerweb-plugins Length of output: 662 🏁 Script executed: # Search for all httpc:request_uri calls to understand timeout usage
rg "httpc:request_uri" -A 5 matrix/Repository: bunkerity/bunkerweb-plugins Length of output: 723 🏁 Script executed: # Check how other plugins (coraza, virustotal, clamav) handle HTTP timeouts
rg "set_timeout|timeout" -A 2 -B 2 --type lua | head -100Repository: bunkerity/bunkerweb-plugins Length of output: 1683 🏁 Script executed: # Search for resty.http usage in other plugins to see timeout patterns
rg "httpc.*=" -A 10 --type lua | grep -A 10 "resty.http" | head -80Repository: bunkerity/bunkerweb-plugins Length of output: 53 🏁 Script executed: # Look for timeout patterns in virustotal.lua and coraza.lua specifically
for file in virustotal.lua coraza.lua clamav.lua; do
echo "=== $file ==="
if [ -f "*/$file" ]; then
find . -name "$file" -exec cat {} \; | grep -A 5 -B 5 "timeout\|request_uri" | head -50
fi
doneRepository: bunkerity/bunkerweb-plugins Length of output: 131 🏁 Script executed: # Search for httpc initialization and set_timeout calls
rg "set_timeout|connect_timeout|send_timeout|read_timeout" --type luaRepository: bunkerity/bunkerweb-plugins Length of output: 496 Add explicit HTTP timeouts to Matrix requests. Both 🤖 Prompt for AI Agents |
||
| httpc:close() | ||
| if not res then | ||
| self.logger:log(ERR, "error while sending request : " .. err_http) | ||
| return | ||
| end | ||
| if res.status < 200 or res.status > 299 then | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| self.logger:log(ERR, "request returned status " .. tostring(res.status)) | ||
| return | ||
| end | ||
| self.logger:log(INFO, "request sent to matrix") | ||
| end | ||
|
|
||
| function matrix:log_default() | ||
| -- Check if matrix is activated | ||
| local check, err = has_variable("USE_MATRIX", "yes") | ||
| if check == nil then | ||
| return self:ret(false, "error while checking variable USE_MATRIX (" .. err .. ")") | ||
| end | ||
| if not check then | ||
| return self:ret(true, "matrix plugin not enabled") | ||
| end | ||
| -- Check if default server is disabled | ||
| check, err = get_variable("DISABLE_DEFAULT_SERVER", false) | ||
| if check == nil then | ||
| return self:ret(false, "error while getting variable DISABLE_DEFAULT_SERVER (" .. err .. ")") | ||
| end | ||
| if check ~= "yes" then | ||
| return self:ret(true, "default server not disabled") | ||
| end | ||
| -- Call log method | ||
| return self:log(true) | ||
| end | ||
|
|
||
| function matrix:api() | ||
| if self.ctx.bw.uri == "/matrix/ping" and self.ctx.bw.request_method == "POST" then | ||
| -- Check matrix connection | ||
| local check, err = has_variable("USE_MATRIX", "yes") | ||
| if check == nil then | ||
| return self:ret(true, "error while checking variable USE_MATRIX (" .. err .. ")") | ||
| end | ||
| if not check then | ||
| return self:ret(true, "matrix plugin not enabled") | ||
| end | ||
| -- Prepare data | ||
| local access_token = self.variables["MATRIX_ACCESS_TOKEN"] | ||
| local url = message_url(self) | ||
| local message_data = { | ||
| msgtype = "m.text", | ||
| body = "Test message from bunkerweb.", | ||
| } | ||
| -- Send request | ||
| local httpc | ||
| httpc, err = http_new() | ||
| if not httpc then | ||
| self.logger:log(ERR, "can't instantiate http object : " .. err) | ||
| return self:ret(true, "can't instantiate http object", HTTP_INTERNAL_SERVER_ERROR) | ||
| end | ||
| local res, err_http = httpc:request_uri(url, { | ||
| method = "PUT", | ||
| headers = { | ||
| ["Content-Type"] = "application/json", | ||
| ["Authorization"] = "Bearer " .. access_token, | ||
| }, | ||
| body = encode(message_data), | ||
| }) | ||
| httpc:close() | ||
| if not res then | ||
| self.logger:log(ERR, "error while sending request : " .. err_http) | ||
| return self:ret(true, "error while sending request", HTTP_INTERNAL_SERVER_ERROR) | ||
| end | ||
| if res.status < 200 or res.status > 299 then | ||
| return self:ret(true, "request returned status " .. tostring(res.status), HTTP_INTERNAL_SERVER_ERROR) | ||
| end | ||
| return self:ret(true, "request sent to matrix", HTTP_OK) | ||
| end | ||
| return self:ret(false, "success") | ||
| end | ||
|
|
||
| return matrix | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update the examples to the 1.6 image series.
Lines 42 and 60 still pin
bunkerity/bunkerweb:1.5.10, but this PR is explicitly about 1.6 compatibility. The examples should not steer users onto the wrong runtime version.As per coding guidelines,
Prefer concrete instructions, accurate examples, and explicit prerequisites.Also applies to: 52-68
🤖 Prompt for AI Agents