matrix-server-isenguard 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: matrix-server-isenguard
3
+ Version: 0.1.1
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: matrix-synapse>=1.148.0
7
+ Requires-Dist: aiohttp>=3.8.0
8
+ Requires-Dist: fhirclient>=4.0.0
@@ -0,0 +1,75 @@
1
+ # Isenguard - FHIR plugins for Matrix Synapse
2
+
3
+ ## Development
4
+
5
+ ### Docker startup
6
+ Start the 3 Matrix servers:
7
+ ``` bash
8
+ uv run demo/start.sh
9
+ ```
10
+
11
+ They are named:
12
+ * `localhost:8080`
13
+ * `localhost:8081`
14
+ * `localhost:8082`
15
+
16
+ At first time setup, it will generate https certificates the servers need to federate.
17
+
18
+ You have to use exactly those names as homeserver when you register/login with your matrix client.
19
+
20
+ For federation, they are named:
21
+ * `localhost:8480`
22
+ * `localhost:8481`
23
+ * `localhost:8482`
24
+
25
+ If you want to invite a user from a different server, use the following syntax: `@some_user:localhost:8480`
26
+
27
+
28
+
29
+ ### Server lifecycle
30
+
31
+ Start 3 federating demo servers:
32
+ ``` bash
33
+ uv run demo/start.sh
34
+ ```
35
+
36
+ Stop the servers:
37
+ ``` bash
38
+ uv run demo/stop.sh
39
+ ```
40
+
41
+ Delete all demo server data:
42
+ ``` bash
43
+ uv run demo/clean.sh
44
+ ```
45
+
46
+
47
+ ### Development REST Services
48
+
49
+ The repository includes a flask server that mimics a REST API for the federation whitelist.
50
+ Start it by calling `flask --debug --app rest_services.app run`
51
+
52
+ The server will be available at `http://localhost:5000` with the followind APIs:
53
+
54
+ * `GET /federation/whitelist` : returns the current whitelist as JSON. It reads the whitelist from a file named
55
+ `server_whitelist.json` in the project root, so you can edit it locally.
56
+
57
+
58
+ ## Testing
59
+
60
+ Tests use the synapse test suite, so you must have synapse installed not from pypi, but from git:
61
+ Clone the synapse repository and install it in editable mode one directory up, to avoid VCS conflicts (we didn't want
62
+ to create a git submodule):
63
+
64
+ ```bash
65
+ git clone --depth=1 https://github.com/element-hq/synapse.git ../synapse
66
+ uv pip install -e ../synapse
67
+ ```
68
+
69
+
70
+ Then you can use the tst suite:
71
+
72
+
73
+ ```bash
74
+ uv run pytest
75
+ ```
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "matrix-server-isenguard"
3
+ version = "0.1.1"
4
+ description = "Add your description here"
5
+ requires-python = ">=3.13"
6
+ dependencies = [
7
+ "matrix-synapse>=1.148.0",
8
+ "aiohttp>=3.8.0",
9
+ "fhirclient>=4.0.0",
10
+ ]
11
+
12
+
13
+ [dependency-groups]
14
+ dev = [
15
+ "black",
16
+ "flask>=3.1.3",
17
+ "mkdocs>=1.6.1",
18
+ "mypy",
19
+ "pytest",
20
+ "pytest-asyncio>=1.3.0",
21
+ ]
22
+
23
+ [tool.pytest.ini_options]
24
+ asyncio_mode = "auto"
25
+
26
+ [tool.uv]
27
+ package = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .dynamic_federation_whitelist import DynamicFederationWhitelist
2
+ from .user_search_fhir import UserSearchFHIR
3
+
4
+ __all__ = ["DynamicFederationWhitelist", "UserSearchFHIR"]
@@ -0,0 +1,83 @@
1
+ """Token providers for FHIR server authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import Optional, Protocol
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class TokenProvider(Protocol):
14
+ """Returns the bearer token to send to the FHIR server."""
15
+
16
+ async def get_bearer_token(self) -> Optional[str]: ...
17
+ async def invalidate(self) -> None: ...
18
+
19
+
20
+ class OAuthClientCredentialsProvider:
21
+ """Fetches and caches an id_token via OAuth2 client_credentials.
22
+
23
+ Concurrent refreshes are coalesced via an asyncio.Lock. The cached
24
+ token is refreshed once `now >= expires_at - safety_margin`.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ token_url: str,
30
+ client_id: str,
31
+ client_secret: str,
32
+ scope: str,
33
+ http_client,
34
+ safety_margin_seconds: int = 60,
35
+ ) -> None:
36
+ self._token_url = token_url
37
+ self._client_id = client_id
38
+ self._client_secret = client_secret
39
+ self._scope = scope
40
+ self._http_client = http_client
41
+ self._safety_margin = safety_margin_seconds
42
+ self._token: Optional[str] = None
43
+ self._expires_at: float = 0.0
44
+ self._lock = asyncio.Lock()
45
+
46
+ async def get_bearer_token(self) -> Optional[str]:
47
+ if self._is_fresh():
48
+ return self._token
49
+ async with self._lock:
50
+ if self._is_fresh():
51
+ return self._token
52
+ await self._refresh()
53
+ return self._token
54
+
55
+ async def invalidate(self) -> None:
56
+ async with self._lock:
57
+ self._token = None
58
+ self._expires_at = 0.0
59
+
60
+ def _is_fresh(self) -> bool:
61
+ return bool(self._token) and time.monotonic() < self._expires_at - self._safety_margin
62
+
63
+ async def _refresh(self) -> None:
64
+ data = await self._http_client.post_urlencoded_get_json(
65
+ self._token_url,
66
+ args={
67
+ "grant_type": "client_credentials",
68
+ "client_id": self._client_id,
69
+ "client_secret": self._client_secret,
70
+ "scope": self._scope,
71
+ },
72
+ )
73
+ if not isinstance(data, dict):
74
+ raise RuntimeError(
75
+ f"Unexpected token response type: {type(data).__name__}"
76
+ )
77
+ token = data.get("id_token")
78
+ if not token:
79
+ raise RuntimeError("OAuth token response missing 'id_token'")
80
+ expires_in = int(data.get("expires_in", 3600))
81
+ self._token = token
82
+ self._expires_at = time.monotonic() + expires_in
83
+ logger.info("Refreshed FHIR OAuth id_token (expires in %ss)", expires_in)
@@ -0,0 +1,343 @@
1
+ """
2
+ Dynamic Federation Whitelist Synapse Module
3
+
4
+ This module controls federation by maintaining a whitelist of allowed servers
5
+ that is periodically fetched from a remote REST API. Only servers on the
6
+ whitelist are allowed to federate with this Synapse instance.
7
+ """
8
+
9
+ import logging
10
+ import re
11
+ from typing import Any, Dict, List, Optional, Set
12
+ from datetime import datetime
13
+ from synapse.api.errors import HttpResponseException
14
+ from synapse.module_api import ModuleApi
15
+ from synapse.events import EventBase
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ # Conservative server-name grammar: lowercase letters, digits, dots, hyphens,
21
+ # colons (for ports), brackets (for IPv6 literals).
22
+ _SERVER_NAME_RE = re.compile(r"\A[a-z0-9.\-:\[\]]+\Z")
23
+
24
+
25
+ def _is_valid_server_name(name: str) -> bool:
26
+ """Return True if `name` looks like a Matrix server name.
27
+
28
+ Defensive: rejects empty strings, whitespace, wildcards, control chars,
29
+ and anything else that wouldn't ever match a real sender domain.
30
+ """
31
+ if not name or len(name) > 255:
32
+ return False
33
+ return bool(_SERVER_NAME_RE.match(name))
34
+
35
+
36
+ class DynamicFederationWhitelist:
37
+ """A Synapse module that maintains a dynamic whitelist of federation servers."""
38
+
39
+ def __init__(self, config: Dict[str, Any], api: ModuleApi):
40
+ """
41
+ Initialize the Dynamic Federation Whitelist module.
42
+
43
+ Args:
44
+ config: Module configuration from homeserver.yaml
45
+ api: Synapse ModuleApi instance
46
+ """
47
+ self.api = api
48
+ self.config = config
49
+ main_config = api._hs.config
50
+
51
+ # Get configuration parameters
52
+ self.whitelist_api_url = config.get("whitelist_api_url", "")
53
+ self.api_key = config.get("api_key", "")
54
+ self.update_interval = config.get("update_interval_minutes", 10)
55
+ self.timeout = config.get("timeout", 10)
56
+ self.enabled = config.get("enabled", True)
57
+ # Normalise the initial whitelist to lowercase; matrix server names are
58
+ # case-insensitive (RFC 1035 / Matrix spec) and we compare lowercase below.
59
+ self.initial_whitelist = {
60
+ s.lower() for s in (main_config.federation.federation_domain_whitelist or set())
61
+ }
62
+
63
+ # In-memory whitelist storage
64
+ self.whitelisted_servers: Set[str] = self.initial_whitelist.copy()
65
+ self.last_update: Optional[datetime] = None
66
+ self.last_fetch_success: bool = False
67
+
68
+ # Validate configuration
69
+ if self.enabled and not self.whitelist_api_url and not self.initial_whitelist:
70
+ logger.error("Neither Whitelist API URL nor initial_whitelist configured. Module will be disabled.")
71
+ self.enabled = False
72
+
73
+ if self.enabled and self.whitelist_api_url.startswith("http://"):
74
+ logger.warning(
75
+ "whitelist_api_url is configured with plain HTTP (%s) — the "
76
+ "API key will be transmitted in clear text. Use HTTPS in "
77
+ "production.",
78
+ self.whitelist_api_url,
79
+ )
80
+
81
+ # Register federation spam checker callbacks
82
+ if self.enabled:
83
+ # Register the should_drop_federated_event callback to control federation
84
+ api.register_spam_checker_callbacks(
85
+ should_drop_federated_event=self.should_drop_server_federation_event,
86
+ user_may_invite=self.user_may_invite,
87
+ federated_user_may_invite=self.federated_user_may_invite,
88
+ )
89
+
90
+ # Only start the update task if we have an API URL
91
+ if self.whitelist_api_url:
92
+ # Start the periodic update task
93
+ self.api.looping_background_call(
94
+ self._update_whitelist,
95
+ msec=self.update_interval * 60 * 1000,
96
+ desc="Dynamic Federation Whitelist Update",
97
+ )
98
+
99
+ # Also, run an initial update immediately
100
+ self.api.run_as_background_process(
101
+ "Initial Federation Whitelist Update",
102
+ self._update_whitelist,
103
+ )
104
+
105
+ logger.info(
106
+ f"Dynamic Federation Whitelist module initialized with API: {self.whitelist_api_url}, "
107
+ f"update interval: {self.update_interval} minutes"
108
+ )
109
+ else:
110
+ logger.info(
111
+ f"Dynamic Federation Whitelist module initialized with static whitelist: {self.whitelisted_servers}"
112
+ )
113
+ else:
114
+ logger.info("Dynamic Federation Whitelist module is disabled")
115
+
116
+ @staticmethod
117
+ def parse_config(config: Dict[str, Any]) -> Dict[str, Any]:
118
+ """
119
+ Parse and validate the module configuration.
120
+
121
+ Args:
122
+ config: Raw configuration dictionary
123
+
124
+ Returns:
125
+ Parsed configuration dictionary
126
+ """
127
+ return {
128
+ "whitelist_api_url": config.get("whitelist_api_url", ""),
129
+ "api_key": config.get("api_key", ""),
130
+ "update_interval_minutes": config.get("update_interval_minutes", 10),
131
+ "timeout": config.get("timeout", 10),
132
+ "enabled": config.get("enabled", True),
133
+ "initial_whitelist": config.get("initial_whitelist", []),
134
+ "headers": config.get("additional_headers", {}),
135
+ }
136
+
137
+ async def should_drop_server_federation_event(self, event: EventBase) -> bool:
138
+ """
139
+ Check if a event's origin server name should be blocked based on whitelisted_servers.
140
+
141
+ Args:
142
+ event: event that comes in from other servers
143
+
144
+ Returns:
145
+ True if the event should be dropped, False otherwise.
146
+ """
147
+
148
+ # Check if the sender's server is whitelisted. The whitelist is stored
149
+ # lowercase; lowercase the origin too because Matrix server names are
150
+ # case-insensitive (RFC 1035, Matrix spec). NOTE: this approximates
151
+ # "origin server" via event.sender — federation signing makes that
152
+ # safe in practice, but a fully strict implementation would also
153
+ # check the PDU's signing origin.
154
+ try:
155
+ _, origin = event.sender.split(":", 1)
156
+ except (ValueError, AttributeError):
157
+ return True # Drop if the sender format is invalid or missing
158
+ origin = origin.lower()
159
+ if origin not in self.whitelisted_servers:
160
+ logger.info("Blocking federation from non-whitelisted server: %s", origin)
161
+ return True
162
+
163
+ logger.debug("Federation from whitelisted server: %s", origin)
164
+ return False
165
+
166
+ async def user_may_invite(self, inviter_userid: str, invitee_userid: str, room_id: str) -> bool:
167
+ """Check if a local user is allowed to invite a user from another server."""
168
+
169
+ try:
170
+ _, remote_domain = invitee_userid.split(":", 1)
171
+ except (ValueError, AttributeError):
172
+ return True # Allow if format is invalid (let Synapse handle it)
173
+
174
+ # If it's a local user, always allow
175
+ # TODO: maybe this could be denied too, if we were strict (or a config option?)
176
+ if self.api.is_mine(invitee_userid):
177
+ return True
178
+
179
+ if remote_domain.lower() not in self.whitelisted_servers:
180
+ logger.info("Blocking invite to non-whitelisted server: %s", remote_domain)
181
+ return False
182
+
183
+ return True
184
+
185
+ async def federated_user_may_invite(self, event: EventBase) -> bool:
186
+ """Check if a federated user is allowed to invite a local user."""
187
+
188
+ inviter_userid = event.sender
189
+ try:
190
+ _, remote_domain = inviter_userid.split(":", 1)
191
+ except (ValueError, AttributeError):
192
+ return True # Allow if format is invalid (let Synapse handle it)
193
+
194
+ # If it's a local user, always allow (shouldn't happen for this callback)
195
+ # TODO: maybe this could be denied too, if we were strict (or a config option?)
196
+ if self.api.is_mine(inviter_userid):
197
+ return True
198
+
199
+ if remote_domain.lower() not in self.whitelisted_servers:
200
+ logger.info("Blocking invite from non-whitelisted server: %s", remote_domain)
201
+ return False
202
+
203
+ return True
204
+
205
+ async def _update_whitelist(self):
206
+ """
207
+ Fetch the current whitelist from the remote API and update the in-memory list.
208
+ """
209
+ try:
210
+ logger.info("Fetching federation whitelist from remote API...")
211
+
212
+ servers = await self._fetch_whitelist_from_api()
213
+
214
+ if servers is not None:
215
+ old_count = len(self.whitelisted_servers)
216
+
217
+ # Build the new set fully before assigning, so concurrent
218
+ # readers in should_drop_server_federation_event never see
219
+ # a half-updated state (without the initial whitelist merged in).
220
+ new_set: Set[str] = set(servers)
221
+ if self.initial_whitelist:
222
+ new_set.update(self.initial_whitelist)
223
+ self.whitelisted_servers = new_set
224
+
225
+ self.last_update = datetime.now()
226
+ self.last_fetch_success = True
227
+
228
+ new_count = len(self.whitelisted_servers)
229
+ logger.info(
230
+ "Federation whitelist updated: %d -> %d servers. Current whitelist: %s",
231
+ old_count,
232
+ new_count,
233
+ sorted(self.whitelisted_servers),
234
+ )
235
+ else:
236
+ self.last_fetch_success = False
237
+ logger.warning(
238
+ "Failed to fetch whitelist from remote API. "
239
+ f"Keeping existing whitelist with {len(self.whitelisted_servers)} servers."
240
+ )
241
+
242
+ except Exception as e:
243
+ self.last_fetch_success = False
244
+ logger.error(f"Error updating federation whitelist: {e}")
245
+
246
+ async def _fetch_whitelist_from_api(self) -> Optional[List[str]]:
247
+ """
248
+ Fetch the whitelist from the remote REST API.
249
+
250
+ Returns:
251
+ List of whitelisted server names or None if request fails
252
+ """
253
+ # Convert headers to Synapse format (bytes -> List[bytes])
254
+ synapse_headers = {}
255
+ for k, v in self.config.get("headers", {}).items():
256
+ synapse_headers[k.encode("utf-8")] = [v.encode("utf-8")]
257
+
258
+ # Add API key if configured
259
+ if self.api_key:
260
+ synapse_headers[b"Authorization"] = [f"Bearer {self.api_key}".encode("utf-8")]
261
+
262
+ try:
263
+ data = await self.api.http_client.get_json(
264
+ self.whitelist_api_url,
265
+ headers=synapse_headers,
266
+ )
267
+
268
+ # Parse the response - expecting a list of server names
269
+ servers = []
270
+
271
+ # Handle different possible response formats
272
+ if isinstance(data, list):
273
+ # Direct list of servers
274
+ servers = data
275
+ elif isinstance(data, dict):
276
+ # Check for common field names
277
+ if "servers" in data:
278
+ servers = data["servers"]
279
+ elif "whitelist" in data:
280
+ servers = data["whitelist"]
281
+ elif "allowed" in data:
282
+ servers = data["allowed"]
283
+ elif "domains" in data:
284
+ servers = data["domains"]
285
+ else:
286
+ logger.warning(
287
+ f"Unexpected API response format: {list(data.keys())}"
288
+ )
289
+ return None
290
+ else:
291
+ logger.warning(f"Unexpected API response type: {type(data)}")
292
+ return None
293
+
294
+ # Validate that we got a list of plausible hostnames. Reject any
295
+ # entry containing whitespace, control characters, or characters
296
+ # outside the hostname grammar — a compromised whitelist API
297
+ # should not be able to inject arbitrary tokens that get matched
298
+ # against parsed sender domains downstream.
299
+ validated_servers: List[str] = []
300
+ for server in servers:
301
+ name: Optional[str] = None
302
+ if isinstance(server, str):
303
+ name = server
304
+ elif isinstance(server, dict):
305
+ name = (
306
+ server.get("server")
307
+ or server.get("domain")
308
+ or server.get("name")
309
+ or server.get("hostname")
310
+ )
311
+ if not isinstance(name, str):
312
+ continue
313
+ name = name.strip().lower()
314
+ if name and _is_valid_server_name(name):
315
+ validated_servers.append(name)
316
+
317
+ logger.debug(
318
+ f"Fetched {len(validated_servers)} servers from remote API"
319
+ )
320
+ return validated_servers
321
+
322
+ except HttpResponseException as e:
323
+ logger.error(f"Remote API returned status {e.code} when fetching federation whitelist")
324
+ return None
325
+ except Exception as e:
326
+ logger.error(f"Error fetching federation whitelist: {e}")
327
+ return None
328
+
329
+
330
+ def create_module(config: Dict[str, Any], api: ModuleApi) -> DynamicFederationWhitelist:
331
+ """
332
+ Factory function to create the module instance.
333
+
334
+ This is the entry point that Synapse will call to instantiate the module.
335
+
336
+ Args:
337
+ config: Module configuration from homeserver.yaml
338
+ api: Synapse ModuleApi instance
339
+
340
+ Returns:
341
+ Initialized DynamicFederationWhitelist instance
342
+ """
343
+ return DynamicFederationWhitelist(config, api)
@@ -0,0 +1,70 @@
1
+ """Parse and validate Matrix user identifiers from various URI forms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # Matrix MXID grammar (https://spec.matrix.org/v1.10/appendices/#user-identifiers).
8
+ # Localpart: lowercase ASCII letters, digits, and a small punctuation set.
9
+ # Historical localparts include uppercase; we accept either to avoid
10
+ # rejecting legacy directory entries, but otherwise stay strict.
11
+ _LOCALPART_RE = re.compile(r"\A[a-zA-Z0-9._=/+\-]+\Z")
12
+
13
+ # Server-part: hostname (or IP) with optional :port. ASCII only — dots,
14
+ # hyphens, colons, brackets (IPv6), alphanumerics. No whitespace, no
15
+ # control characters, no other special chars.
16
+ _SERVER_RE = re.compile(r"\A[a-zA-Z0-9.\-:\[\]]+\Z")
17
+
18
+ # matrix: URI scheme — user form ("u/") and historical alias ("user/").
19
+ _MATRIX_URI_RE = re.compile(r"\Amatrix:(?:u|user)/([^:/?#]+):(.+)\Z")
20
+
21
+ # Hard upper bound from the Matrix spec.
22
+ _MXID_MAX_LEN = 255
23
+
24
+
25
+ def parse_mxid(value: str | None) -> str | None:
26
+ """Normalise a string to a syntactically valid MXID '@localpart:server', or None.
27
+
28
+ Validates:
29
+ - non-empty localpart and server
30
+ - localpart against the Matrix grammar
31
+ - server against a conservative hostname/port grammar
32
+ - total length <= 255
33
+
34
+ Accepts the same surface forms as before:
35
+ - '@localpart:server'
36
+ - 'matrix:u/localpart:server'
37
+ - 'matrix:user/localpart:server'
38
+
39
+ Returns None for anything that fails validation, with no exceptions
40
+ propagated. Untrusted input (FHIR responses, URI fields) should always
41
+ flow through this function before being handed to Synapse.
42
+ """
43
+ if not value:
44
+ return None
45
+ s = value.strip()
46
+ if not s or len(s) > _MXID_MAX_LEN:
47
+ return None
48
+
49
+ if s.startswith("@"):
50
+ return _validated(s[1:])
51
+
52
+ m = _MATRIX_URI_RE.match(s)
53
+ if m:
54
+ return _validated(f"{m.group(1)}:{m.group(2)}")
55
+
56
+ return None
57
+
58
+
59
+ def _validated(rest: str) -> str | None:
60
+ """Validate '<localpart>:<server>' and return '@<localpart>:<server>' or None."""
61
+ if ":" not in rest:
62
+ return None
63
+ localpart, server = rest.split(":", 1)
64
+ if not localpart or not server:
65
+ return None
66
+ if not _LOCALPART_RE.match(localpart):
67
+ return None
68
+ if not _SERVER_RE.match(server):
69
+ return None
70
+ return f"@{localpart}:{server}"