diff --git a/README.md b/README.md index bd2de83..6c8576d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Each plugin is located in a subdirectory of this repository. A README file locat - [CrowdSec](https://github.com/bunkerity/bunkerweb-plugins/tree/main/crowdsec) - [Coraza](https://github.com/bunkerity/bunkerweb-plugins/tree/main/coraza) - [Discord](https://github.com/bunkerity/bunkerweb-plugins/tree/main/discord) +- [Matrix](https://github.com/bunkerity/bunkerweb-plugins/tree/main/matrix) - [Slack](https://github.com/bunkerity/bunkerweb-plugins/tree/main/slack) - [VirusTotal](https://github.com/bunkerity/bunkerweb-plugins/tree/main/virustotal) - [WebHook](https://github.com/bunkerity/bunkerweb-plugins/tree/main/webhook) diff --git a/matrix/README.md b/matrix/README.md new file mode 100644 index 0000000..86aa719 --- /dev/null +++ b/matrix/README.md @@ -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. | diff --git a/matrix/matrix.lua b/matrix/matrix.lua new file mode 100644 index 0000000..c1c8546 --- /dev/null +++ b/matrix/matrix.lua @@ -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"] = "

Denied " + .. html_escape(request_method) + .. " from " + .. remote_addr + .. " (" + .. country + .. ' • "' + .. html_escape(asn_org) + .. '" • ' + .. asn + .. ") to " + .. html_escape(request_host) + .. html_escape(self.ctx.bw.uri) + .. "
" + data["formatted_body"] = data["formatted_body"] + .. "Reason " + .. html_escape(reason) + .. " (" + .. html_escape(encode(reason_data or {})) + .. ").

" + 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"] .. "" + 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"] + .. "" + data["body"] = data["body"] .. header .. ": " .. header_value .. "\n" + end + data["formatted_body"] = data["formatted_body"] .. "
HeaderValue
" + .. html_escape(header) + .. "" + .. html_escape(header_value) + .. "
" + 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, + }, + }) + 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 + 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 diff --git a/matrix/plugin.json b/matrix/plugin.json new file mode 100644 index 0000000..8d806b9 --- /dev/null +++ b/matrix/plugin.json @@ -0,0 +1,63 @@ +{ + "id": "matrix", + "name": "Matrix", + "description": "Send alerts to a Matrix room via the Matrix API.", + "version": "1.10", + "stream": "yes", + "settings": { + "USE_MATRIX": { + "context": "multisite", + "default": "no", + "help": "Enable sending alerts to a Matrix room.", + "id": "use-matrix", + "label": "Use Matrix", + "regex": "^(yes|no)$", + "type": "check" + }, + "MATRIX_BASE_URL": { + "context": "global", + "default": "https://matrix.org", + "help": "Base URL of the Matrix server (e.g., https://matrix.org).", + "id": "matrix-base-url", + "label": "Matrix Base URL", + "regex": "^.*$", + "type": "text" + }, + "MATRIX_ROOM_ID": { + "context": "global", + "default": "!yourRoomID:matrix.org", + "help": "Room ID of the Matrix room to send notifications to.", + "id": "matrix-room-id", + "label": "Matrix Room ID", + "regex": "^.*$", + "type": "text" + }, + "MATRIX_ACCESS_TOKEN": { + "context": "global", + "default": "", + "help": "Access token to authenticate with the Matrix server.", + "id": "matrix-access-token", + "label": "Matrix Access Token", + "regex": "^.*$", + "type": "password" + }, + "MATRIX_ANONYMIZE_IP": { + "context": "global", + "default": "no", + "help": "Mask the IP address in notifications.", + "id": "matrix-anonymize-ip", + "label": "Anonymize IP", + "regex": "^(yes|no)$", + "type": "check" + }, + "MATRIX_INCLUDE_HEADERS": { + "context": "global", + "default": "no", + "help": "Include request headers in notifications.", + "id": "matrix-include-headers", + "label": "Include Headers", + "regex": "^(yes|no)$", + "type": "check" + } + } +} diff --git a/matrix/ui/actions.py b/matrix/ui/actions.py new file mode 100644 index 0000000..d5fe30a --- /dev/null +++ b/matrix/ui/actions.py @@ -0,0 +1,30 @@ +from logging import getLogger +from traceback import format_exc + + +def pre_render(**kwargs): + logger = getLogger("UI") + ret = { + "ping_status": { + "title": "MATRIX STATUS", + "value": "error", + "col-size": "col-12 col-md-6", + "card-classes": "h-100", + }, + } + try: + ping_data = kwargs["bw_instances_utils"].get_ping("matrix") + ret["ping_status"]["value"] = ping_data["status"] + except BaseException as e: + logger.debug(format_exc()) + logger.error(f"Failed to get matrix ping: {e}") + ret["error"] = str(e) + + if "error" in ret: + return ret + + return ret + + +def matrix(**kwargs): + pass diff --git a/matrix/utils.lua b/matrix/utils.lua new file mode 100644 index 0000000..1f311bd --- /dev/null +++ b/matrix/utils.lua @@ -0,0 +1,21 @@ +local mmdb = require("bunkerweb.mmdb") + +local _utils = {} + +_utils.get_asn_org = function(ip) + -- Check if mmdb is loaded + if not mmdb.asn_db then + return false, "mmdb asn not loaded" + end + -- Perform lookup + local ok, result, err = pcall(mmdb.asn_db.lookup, mmdb.asn_db, ip) + if not ok then + return nil, result + end + if not result then + return nil, err + end + return result.autonomous_system_organization, "success" +end + +return _utils