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..2661e1b --- /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) +- [TODO](#todo) + +# 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 sent 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.10 + ... + 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.10 + .. + 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. | +| `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..7f043c8 --- /dev/null +++ b/matrix/matrix.lua @@ -0,0 +1,210 @@ +local cjson = require("cjson") +local class = require("middleclass") +local http = require("resty.http") +local plugin = require("bunkerweb.plugin") +local utils = require("bunkerweb.utils") +local matrix_utils = require("matrix.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 + +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(self.ctx.bw.remote_addr) + if not country then + elf.logger:log(ERR, "can't get Country of IP " .. remote_addr .. " : " .. err) + country = "Country unknown" + else + country = tostring(country) + end + local 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, 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 " .. request_method .. " from " .. remote_addr .. " (" .. country .. " • \"" .. asn_org .. "\" • " .. asn .. ") to " .. request_host .. self.ctx.bw.uri .. "
" + data["formatted_body"] = data["formatted_body"] .. "Reason " .. reason .. " (" .. 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, 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 + data["formatted_body"] = data["formatted_body"] .. "" + data["body"] = data["body"] .. header .. ": " .. value .. "\n" + end + data["formatted_body"] = data["formatted_body"] .. "
HeaderValue
" .. header .. "" .. value .. "
" + end + end + -- Anonymize IP if enabled + if self.variables["MATRIX_ANONYMIZE_IP"] == "yes" then + remote_addr = string.gsub(remote_addr, "%d+%.%d+$", "xxx.xxx") + data["formatted_body"] = string.gsub(data["formatted_body"], self.ctx.bw.remote_addr, remote_addr) + data["body"] = string.gsub(data["body"], self.ctx.bw.remote_addr, remote_addr) + end + -- Send request + local 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) + end + -- Prapare data + local base_url = self.variables["MATRIX_BASE_URL"] + local access_token = self.variables["MATRIX_ACCESS_TOKEN"] + local room_id = self.variables["MATRIX_ROOM_ID"] + local txn_id = tostring(os.time()) + local url = string.format("%s/_matrix/client/r0/rooms/%s/send/m.room.message/%s", base_url, room_id, txn_id) + local message_data = { + msgtype = "m.text", + body = data["body"], + format = "org.matrix.custom.html", + formatted_body = data["formatted_body"] + } + local post_data = cjson.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 -- Access Token im Header + } + }) + httpc:close() + if not res then + self.logger:log(ERR, "error while sending request : " .. err_http) + 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 base_url = self.variables["MATRIX_BASE_URL"] + local access_token = self.variables["MATRIX_ACCESS_TOKEN"] + local room_id = self.variables["MATRIX_ROOM_ID"] + local txn_id = tostring(os.time()) + local url = string.format("%s/_matrix/client/r0/rooms/%s/send/m.room.message/%s", base_url, room_id, txn_id) + 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) + 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) + 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 \ No newline at end of file diff --git a/matrix/plugin.json b/matrix/plugin.json new file mode 100644 index 0000000..683cc2d --- /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.1", + "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..8045d94 --- /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 \ No newline at end of file diff --git a/matrix/utils.lua b/matrix/utils.lua new file mode 100644 index 0000000..6ad8f02 --- /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 mmdp 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 \ No newline at end of file