From 908beff5997a52890b6802d5be098c4f1182025a Mon Sep 17 00:00:00 2001 From: Thary Date: Sat, 23 Aug 2025 05:02:47 +0300 Subject: [PATCH] Initial commit --- .editorconfig | 3 + .gitignore | 1 + flake.lock | 24 ++++++ flake.nix | 51 ++++++++++++ module.nix | 63 +++++++++++++++ result | 1 + src/get_config.py | 20 +++++ src/main.py | 35 ++++++++ src/revitalize.py | 198 ++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 396 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 module.nix create mode 120000 result create mode 100644 src/get_config.py create mode 100644 src/main.py create mode 100644 src/revitalize.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..74d6498 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,3 @@ +[*.py] +indent_style = space +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6e2c40f --- /dev/null +++ b/flake.lock @@ -0,0 +1,24 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1750776420, + "narHash": "sha256-/CG+w0o0oJ5itVklOoLbdn2dGB0wbZVOoDm4np6w09A=", + "path": "/nix/store/1l4nm55xcq55cdp1xz3x5mfgr4c24058-source", + "rev": "30a61f056ac492e3b7cdcb69c1e6abdcf00e39cf", + "type": "path" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9ea96da --- /dev/null +++ b/flake.nix @@ -0,0 +1,51 @@ +{ + description = "synapse-revitalization"; + + outputs = { + self, + nixpkgs, + }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + in { + devShells.x86_64-linux.default = pkgs.mkShell { + packages = with pkgs; [ + python313Full + python313Packages.signedjson + python313Packages.requests + python313Packages.systemd + python313Packages.setuptools + black + ]; + }; + + nixosModules = { + synapse-revitalization = import ./module.nix self; + default = self.nixosModules.synapse-revitalization; + }; + + packages.x86_64-linux.synapse-revitalization = pkgs.stdenv.mkDerivation { + projectDir = ./.; + pname = "synapse-revitalization"; + version = "0.1.0"; + + src = ./.; + + installPhase = + let python = pkgs.python313.withPackages (ps: with ps; [ requests signedjson systemd ]); + script = pkgs.writeShellScript "synapse-revitalization" "${python}/bin/python $out/main.py '$1'"; + in '' + mkdir -p $out/bin + cp $src/src/* $out + # cp ${script} $out/bin + cat > $out/bin/synapse-revitalization << EOF +#!/bin/sh +exec ${python}/bin/python $out/main.py "\$1" +EOF + chmod +x $out/bin/synapse-revitalization + ''; + }; + packages.x86_64-linux.default = self.packages.x86_64-linux.synapse-revitalization; + }; +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..8515dcb --- /dev/null +++ b/module.nix @@ -0,0 +1,63 @@ +self: { pkgs, config, lib, ... }: +let + inherit (lib) + mkOption + mkEnableOption + mkIf + types + ; + + cfg = config.services.synapse-revitalization; +in { + options.services.synapse-revitalization = { + enable = mkEnableOption "Enable synapse-revitalization service"; + adminAuthTokenFile = mkOption { + type = types.path; + description = "File containing admin user's authentication token"; + }; + serverKeyFile = mkOption { + type = types.path; + description = "Synapse server signing key file"; + default = "/var/lib/matrix-synapse/homeserver.signing.key"; + }; + serverName = mkOption { + type = types.str; + description = "Synapse server's name"; + default = config.services.matrix-synapse.settings.server_name; + }; + serverFQDN = mkOption { + type = types.str; + description = "Synapse server's fqdn"; + }; + package = self.packages.x86_64-linux.synapse-revitalization; + }; + + config = mkIf (cfg.enable) { + systemd.services."synapse-revitalization" = + let pkg = "${pkgs.synapse-revitalization}/bin/synapse-revitalization"; + script = pkgs.writeShellScript "synapse-revitalization-script" '' + journalctl -f -u matrix-synapse -o cat | + while read -r line; do + echo "$line" | grep "as we're not in the room" && ${pkg} "$line" & + echo "$line" | grep "Ignoring PDU for unknown room_id" && ${pkg} "$line" & + done + ''; + in { + enable = true; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + User = "root"; + Group = "root"; + ExecStart = script; + Restart = "always"; + }; + environment = { + "SYNAPSE_REVITALIZATION_ADMIN_AUTH_TOKEN_FILE" = cfg.adminAuthTokenFile; + "SYNAPSE_REVITALIZATION_SERVER_KEY_FILE" = cfg.serverKeyFile; + "SYNAPSE_REVITALIZATION_SERVER_NAME" = cfg.serverName; + "SYNAPSE_REVITALIZATION_SERVER_ADDRESS" = cfg.serverFQDN; + }; + }; + }; +} diff --git a/result b/result new file mode 120000 index 0000000..f1abf90 --- /dev/null +++ b/result @@ -0,0 +1 @@ +/nix/store/0jq0qq7dp05d494flf6a6qa1gjq2kfzf-synapse-revitalization-0.1.0 \ No newline at end of file diff --git a/src/get_config.py b/src/get_config.py new file mode 100644 index 0000000..acd854c --- /dev/null +++ b/src/get_config.py @@ -0,0 +1,20 @@ +import os + +def read_vars(): + if os.environ.get("SYNAPSE_REVITALIZATION_AUTH_TOKEN_TERM") == None: + auth_token_term = 180 + else: + auth_token_term = os.environ["SYNAPSE_REVITALIZATION_AUTH_TOKEN_TERM"] + + admin_auth_token_file = os.environ["SYNAPSE_REVITALIZATION_ADMIN_AUTH_TOKEN_FILE"] + + with open(admin_auth_token_file, "r") as file: + admin_auth_token = file.read().split("\n")[0] + + return { + "auth_token_term": auth_token_term, + "admin_auth_token": admin_auth_token, + "server_key_file": os.environ["SYNAPSE_REVITALIZATION_SERVER_KEY_FILE"], + "origin_server_name": os.environ["SYNAPSE_REVITALIZATION_SERVER_NAME"], + "origin_server": os.environ["SYNAPSE_REVITALIZATION_SERVER_ADDRESS"], + } diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..f34d6a6 --- /dev/null +++ b/src/main.py @@ -0,0 +1,35 @@ +from systemd import journal +from revitalize import revitalize, parse_roomid +import sys + +if len(sys.argv) != 2: # 2, because running the script looks like 'python [1]main.py [2]"..."' + print("synapse-revitalization accepts only 1 argument") + exit() +else: + message = sys.argv[1] + +if "Ignoring PDU for unknown room_id" in message: + journal.send("[info] Got error message: %s" % message) + roomid = message.rsplit(maxsplit=1)[1] + x = revitalize(roomid, parse_roomid(roomid)) + if x == True: + journal.send("[info] Successfully made all users rejoin {roomid} via {server}") + journal.send("[info] {roomid} is available now") + elif x == False: + journal.send("ERROR! Couldn't make {roomid} available via {parse_roomid(roomid)}") + + +elif "as we're not in the room" in message: + journal.send("[info] Got error message: %s" % message) + roomid = message.split("'")[1] + server = message.split("from server ")[1].split(" ")[0] + for i in [server, parse_roomid(roomid)]: + x = revitalize(roomid, i) + if x == True: + journal.send(f"[info] Successfully made all users rejoin {roomid} via {server}") + journal.send(f"[info] {roomid} is available now") + break + elif x == False: + journal.send(f"[WARNING] Couldn't make users rejoin {roomid} via {server}") + if i == parse_roomid(roomid): + journal.send(f"ERROR! Couldn't make {roomid} available via {server} nor {parse_roomid(roomid)}") diff --git a/src/revitalize.py b/src/revitalize.py new file mode 100644 index 0000000..8792ea4 --- /dev/null +++ b/src/revitalize.py @@ -0,0 +1,198 @@ +from signedjson.key import read_signing_keys +from signedjson.sign import sign_json +import json + +import requests +from systemd import journal +from get_config import read_vars + +import datetime + +# Importing configuration from environment variables +config = read_vars() + + +# Getting signing key +with open(config["server_key_file"]) as f: + skey = read_signing_keys(f)[0] + + +# Defining additional functions +def current_time(): + now = datetime.datetime.now() + return int(now.timestamp() * 1000) + +def parse_roomid(roomid): + return roomid.split(":")[1] + +def serveraddr(servername): + x = requests.get("https://%s/.well-known/matrix/server" % servername) + if x.status_code == 200: + j = json.loads(x.text) + return j["m.server"] + else: + journal.send(f"[WARNING] Got {x.status_code} discovering server address of {servername}") + return False + + +# Function for getting users' access tokens +def get_access_token(userid): + time = current_time() + config["auth_token_term"] * 1000 + x = requests.post( + f"https://{config['origin_server']}/_synapse/admin/v1/users/{userid}/login", + json={"valid_until_ms": time}, + headers={ + "Authorization": "Bearer %s" % config["admin_auth_token"], + "Content-Type": "application/json", + }, + ).text + if "You are not a server admin" in x: + journal.send("ERROR! The given token doesn't belogn to the server admin!") + exit() + y = json.loads(x) + return y["access_token"] + + +# Sign Matrix federation API requests +## From Matrix protocol documentation +def authorization_headers( + destination_name, request_method, request_target, content=None +): + origin_name = config["origin_server_name"] + request_json = { + "method": request_method, + "uri": request_target, + "origin": origin_name, + "destination": destination_name, + } + + if content is not None: + request_json["content"] = content + + signed_json = sign_json(request_json, origin_name, skey) + + for key, sig in signed_json["signatures"][origin_name].items(): + return 'X-Matrix origin="%s",destination="%s",key="%s",sig="%s"' % ( + origin_name, + destination_name, + key, + sig, + ) + + +# Request the last event +def request_last_event(destination, roomid): + servers = [destination, "matrix.org", "inex.rocks", "sibnsk.net", "kde.org"] + for i in servers: + journal.send(f"[info] Trying access event_to_timestamp via {i}") + try: + server = serveraddr(i) + except requests.exceptions.ConnectionError: + journal.send(f"[WARNING] {i}'s well-known's are unreachable") + continue + except: + journal.send(f"[WARNING] Can't request {i}'s well-known's for unknown reason. Likely {i} is unreachable or ssl certs expired") + continue + if server == False: + continue + + res = current_time() + auth = authorization_headers( + i, + "GET", + f"/_matrix/federation/v1/timestamp_to_event/{roomid}?dir=b&ts={res}", + ) + try: + x = requests.get( + f"https://{server}/_matrix/federation/v1/timestamp_to_event/{roomid}", + params={"dir": "b", "ts": res}, + headers={"Authorization": auth}, + ).text + except requests.exceptions.ConnectionError: + journal.send(f"[WARNING] {server} is unreachable") + continue + except: + journal.send(f"[WARNING] Couldn't request timestamp_to_event from {server} for unknown reason. Likely {server} is unreachable or ssl certs expired") + continue + + if "M_UNRECOGNIZED" in x: + journal.send(f"[WARNING] Trying request {server} timestamp_to_event for {roomid}: got M_UNRECOGNIZED error") + continue + elif "M_NOT_FOUND" in x: + journal.send(f"[WARNING] Unable to get the last event from {roomid}. {i} doesn't know room {roomid}") + journal.send(f"{i} says: '{x}'") + continue + elif "event_id" in x: + j = json.loads(x) + return (i, server, j["event_id"]) + else: + journal.send(f"[WARNING] Unable to get the last event from {roomid}. Unknown error from {server}: '{x}'") + continue + + journal.send(f"ERROR! Unable to get the last event from {roomid}. All {servers} don't know room {roomid} or support timestamp_to_event") + return False + + +# Request states of specified room +def get_states(destination, server, roomid, last_event_id): + auth = authorization_headers( + destination, "GET", "/_matrix/federation/v1/state/%s?event_id=%s" % (roomid, last_event_id) + ) + return requests.get( + f"https://{server}/_matrix/federation/v1/state/{roomid}?event_id={last_event_id}", + headers={"Authorization": auth}, + ).text + + +# Get users list from states +def get_users(states): + x = json.loads(states) + # jq .pdus.[].type + users = [] + x = x["pdus"] + for i in x: + if i["type"] == "m.room.member": + users.append(i["sender"]) + return users + +def filter_users(users): + x = [] + for i in users: + if f":{config["origin_server_name"]}" in i: + x.append(i) + return x + + +# Make a user join a room +def mkjoins(roomid, users, server1, server2): + for i in users: + token = get_access_token(i) + x = requests.post( + f"https://{config['origin_server']}/_matrix/client/v3/join/{roomid}", + params={"via": server1, "via": server2}, + headers={"Authorization": "Bearer %s" % token}, + ).text + if roomid in x: + journal.send(f"[info] Joined {i} to {roomid}") + journal.send(x) + return True + else: + journal.send(f"[info] Failed to join {i} to {roomid}") + journal.send(x) + return False + + +def revitalize(roomid, server): + journal.send(f"Got new roomid {roomid}") + fe = request_last_event(server, roomid) + if fe == False: + return False + else: + x = get_states(fe[0], fe[1], roomid, fe[2]) + y = get_users(x) + z = filter_users(y) + journal.send(f"Trying to add {z} to {roomid}") + if mkjoins(roomid, z, server, fe[0]): + return True + else: + return False