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"] .. "| Header | Value |
"
+ data["body"] = data["body"] .. "\n\n"
+ for header, value in pairs(headers) do
+ data["formatted_body"] = data["formatted_body"] .. "| " .. header .. " | " .. value .. " |
"
+ data["body"] = data["body"] .. header .. ": " .. value .. "\n"
+ end
+ data["formatted_body"] = data["formatted_body"] .. "
"
+ 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