Skip to content

Commit 8d4fcce

Browse files
committed
feat: harden Matrix onboarding and registration
1 parent 1055f91 commit 8d4fcce

26 files changed

Lines changed: 827 additions & 80 deletions

mesh/infra/homeserver/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
MESH_SERVER_NAME=mesh.dhawal.org
44
MESH_HOMESERVER_HOST=matrix.mesh.dhawal.org
55
MESH_RTC_HOST=rtc.mesh.dhawal.org
6+
MESH_RTC_ENABLED=0
67
MESH_PUBLIC_ENABLED=0
8+
ACME_EMAIL=admin@dhawal.org
79

810
# Keep Synapse private on the Mac. Caddy is the only public HTTP entry point.
911
SYNAPSE_CONTROL_BIND=127.0.0.1

mesh/infra/homeserver/Caddyfile

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,33 @@
44
}
55

66
{$MESH_SERVER_NAME} {
7-
@matrix_client path /.well-known/matrix/client
8-
header @matrix_client Content-Type "application/json"
9-
header @matrix_client Access-Control-Allow-Origin "*"
10-
respond @matrix_client `{"m.homeserver":{"base_url":"https://{$MESH_HOMESERVER_HOST}"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://{$MESH_RTC_HOST}/livekit/jwt"}]}` 200
11-
12-
@matrix_server path /.well-known/matrix/server
13-
header @matrix_server Content-Type "application/json"
14-
respond @matrix_server `{"m.server":"{$MESH_HOMESERVER_HOST}:443"}` 200
15-
167
header {
178
Strict-Transport-Security "max-age=31536000; includeSubDomains"
189
X-Content-Type-Options "nosniff"
1910
Referrer-Policy "no-referrer"
2011
}
21-
respond "Mesh service is online." 200
12+
13+
handle /.well-known/matrix/client {
14+
root * /srv/well-known
15+
rewrite * /matrix-client.json
16+
header Content-Type "application/json"
17+
header Access-Control-Allow-Origin "*"
18+
file_server
19+
}
20+
21+
handle /.well-known/matrix/server {
22+
header Content-Type "application/json"
23+
respond `{"m.server":"{$MESH_HOMESERVER_HOST}:443"}` 200
24+
}
25+
26+
handle /invite* {
27+
header Content-Type "text/plain; charset=utf-8"
28+
respond "Open Mesh, choose Create account, and paste this full invitation link into Invitation code." 200
29+
}
30+
31+
handle {
32+
respond "Mesh service is online." 200
33+
}
2234
}
2335

2436
{$MESH_HOMESERVER_HOST} {
@@ -27,5 +39,7 @@
2739
X-Content-Type-Options "nosniff"
2840
Referrer-Policy "no-referrer"
2941
}
42+
@admin_api path /_synapse/admin*
43+
respond @admin_api 404
3044
reverse_proxy synapse:8008
3145
}

mesh/infra/homeserver/README.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ cd mesh/infra/homeserver
1919
./backup.sh
2020
```
2121

22-
The local Synapse control port binds only to `127.0.0.1:8008`. Registration is
23-
closed by default. Create alpha accounts with `register_new_matrix_user` after
24-
the service is healthy.
22+
The local Synapse control port binds only to `127.0.0.1:8008`. Public account
23+
creation is enabled only through bounded registration invitations. Open,
24+
unverified registration remains disabled. The public reverse proxy returns
25+
`404` for Synapse's administrative API; invitation creation runs locally.
2526

26-
The initial operator account is `@dhawal:mesh.dhawal.org`. Its generated
27-
password is stored in macOS Keychain and can be retrieved locally with:
27+
On first start, Mesh creates the operator account
28+
`@dhawal:mesh.dhawal.org` and stores its generated password in macOS Keychain.
29+
Retrieve it locally with:
2830

2931
```sh
3032
security find-generic-password \
@@ -33,6 +35,22 @@ security find-generic-password \
3335
-w
3436
```
3537

38+
Create a one-use invitation that expires after seven days:
39+
40+
```sh
41+
sh ./create-registration-invite.sh
42+
```
43+
44+
Optional arguments set expiry days and allowed uses:
45+
46+
```sh
47+
sh ./create-registration-invite.sh 2 1
48+
```
49+
50+
Give the resulting link to the invited person. In Mesh they choose
51+
**Create account** and paste the full link into **Invitation code**. Never use
52+
an unlimited or non-expiring token for public onboarding.
53+
3654
The installed `org.mesh.homeserver` user LaunchAgent opens Docker after login.
3755
Docker's `unless-stopped` policy then restarts the Mesh services. The
3856
`org.mesh.homeserver.backup` LaunchAgent makes a local backup daily at 3:15 AM
@@ -72,8 +90,16 @@ curl https://mesh.dhawal.org/.well-known/matrix/client
7290
curl https://matrix.mesh.dhawal.org/_matrix/client/versions
7391
```
7492

93+
Both commands must succeed repeatedly, not just once. Intermittent TCP
94+
timeouts mean the router forwarding, Mac sleep/power state, Docker listener,
95+
or firewall is still unhealthy.
96+
7597
The MatrixRTC stack under `../matrixrtc` is activated separately after the
76-
homeserver is publicly healthy.
98+
homeserver is publicly healthy. `MESH_RTC_ENABLED` must remain `0` until RTC
99+
DNS, TLS, authorization, SFU signalling, TURN allocation, and a real encrypted
100+
two-party call pass. After they pass, set it to `1`, rerun `./setup.sh`, and
101+
restart the public proxy. This prevents discovery from advertising a calling
102+
service that does not exist.
77103

78104
## Critical data
79105

mesh/infra/homeserver/configure_synapse.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ def main() -> None:
6868
"remote_media_lifetime": "30d",
6969
},
7070
"url_preview_enabled": False,
71-
"enable_registration": False,
71+
"enable_registration": True,
7272
"enable_registration_without_verification": False,
73+
"registration_requires_token": True,
7374
"allow_guest_access": False,
7475
"registration_shared_secret": required("REGISTRATION_SHARED_SECRET"),
7576
"macaroon_secret_key": required("MACAROON_SECRET_KEY"),
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
5+
cd "$script_dir"
6+
7+
if [ ! -f .env ]; then
8+
echo "Missing .env. Run ./setup.sh and ./start.sh first." >&2
9+
exit 1
10+
fi
11+
12+
set -a
13+
# shellcheck disable=SC1091
14+
. ./.env
15+
set +a
16+
17+
days="${1:-7}"
18+
uses="${2:-1}"
19+
case "$days" in *[!0-9]*|'') echo "Days must be a number." >&2; exit 1;; esac
20+
case "$uses" in *[!0-9]*|'') echo "Uses must be a number." >&2; exit 1;; esac
21+
22+
admin_user="@dhawal:${MESH_SERVER_NAME}"
23+
MESH_ADMIN_PASSWORD="$(security find-generic-password \
24+
-a "$admin_user" \
25+
-s 'Mesh Homeserver Admin' \
26+
-w 2>/dev/null)" || {
27+
echo "The Mesh operator password is missing from macOS Keychain. Run ./start.sh first." >&2
28+
exit 1
29+
}
30+
export MESH_ADMIN_PASSWORD
31+
32+
docker compose exec -T \
33+
-e MESH_ADMIN_PASSWORD \
34+
-e MESH_SERVER_NAME \
35+
synapse python /mesh/create_registration_invite.py \
36+
--days "$days" \
37+
--uses "$uses"
38+
39+
unset MESH_ADMIN_PASSWORD
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env python3
2+
"""Create a bounded Synapse registration token without exposing admin credentials."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import os
9+
import time
10+
import urllib.error
11+
import urllib.parse
12+
import urllib.request
13+
14+
15+
BASE_URL = "http://127.0.0.1:8008"
16+
17+
18+
def request_json(
19+
method: str,
20+
path: str,
21+
body: dict[str, object],
22+
access_token: str | None = None,
23+
) -> dict[str, object]:
24+
headers = {"Content-Type": "application/json"}
25+
if access_token:
26+
headers["Authorization"] = f"Bearer {access_token}"
27+
request = urllib.request.Request(
28+
f"{BASE_URL}{path}",
29+
data=json.dumps(body).encode("utf-8"),
30+
headers=headers,
31+
method=method,
32+
)
33+
try:
34+
with urllib.request.urlopen(request, timeout=15) as response:
35+
return json.load(response)
36+
except urllib.error.HTTPError as error:
37+
raise SystemExit(f"Mesh account service returned HTTP {error.code}") from None
38+
except urllib.error.URLError:
39+
raise SystemExit("Mesh account service is not reachable on the local control port") from None
40+
41+
42+
def main() -> None:
43+
parser = argparse.ArgumentParser()
44+
parser.add_argument("--days", type=int, default=7)
45+
parser.add_argument("--uses", type=int, default=1)
46+
args = parser.parse_args()
47+
if not 1 <= args.days <= 30:
48+
raise SystemExit("--days must be between 1 and 30")
49+
if not 1 <= args.uses <= 25:
50+
raise SystemExit("--uses must be between 1 and 25")
51+
52+
server_name = os.environ.get("MESH_SERVER_NAME", "").strip()
53+
admin_password = os.environ.get("MESH_ADMIN_PASSWORD", "")
54+
if not server_name or not admin_password:
55+
raise SystemExit("operator identity is not available")
56+
57+
login = request_json(
58+
"POST",
59+
"/_matrix/client/v3/login",
60+
{
61+
"type": "m.login.password",
62+
"identifier": {
63+
"type": "m.id.user",
64+
"user": f"@dhawal:{server_name}",
65+
},
66+
"password": admin_password,
67+
"initial_device_display_name": "Mesh invitation operator",
68+
},
69+
)
70+
access_token = str(login.get("access_token", ""))
71+
if not access_token:
72+
raise SystemExit("operator sign-in returned no access token")
73+
74+
try:
75+
created = request_json(
76+
"POST",
77+
"/_synapse/admin/v1/registration_tokens/new",
78+
{
79+
"length": 32,
80+
"uses_allowed": args.uses,
81+
"expiry_time": int((time.time() + args.days * 86400) * 1000),
82+
},
83+
access_token,
84+
)
85+
finally:
86+
try:
87+
request_json("POST", "/_matrix/client/v3/logout", {}, access_token)
88+
except SystemExit:
89+
pass
90+
91+
token = str(created.get("token", ""))
92+
if not token:
93+
raise SystemExit("account service returned no invitation code")
94+
95+
query = {"registration_token": token}
96+
invite_url = f"https://{server_name}/invite?{urllib.parse.urlencode(query)}"
97+
98+
print(f"Invitation code: {token}")
99+
print(f"Invitation link: {invite_url}")
100+
print(f"Valid for {args.days} day(s), with {args.uses} allowed use(s).")
101+
102+
103+
if __name__ == "__main__":
104+
main()

mesh/infra/homeserver/docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ services:
4242
SYNAPSE_CONFIG_PATH: /data/homeserver.yaml
4343
volumes:
4444
- ./runtime/synapse:/data
45+
- ./create_registration_invite.py:/mesh/create_registration_invite.py:ro
4546
ports:
4647
- "${SYNAPSE_CONTROL_BIND:-127.0.0.1}:8008:8008/tcp"
4748
healthcheck:
@@ -83,6 +84,7 @@ services:
8384
- "443:443/udp"
8485
volumes:
8586
- ./Caddyfile:/etc/caddy/Caddyfile:ro
87+
- ./runtime/well-known:/srv/well-known:ro
8688
- ./runtime/caddy/data:/data
8789
- ./runtime/caddy/config:/config
8890
mem_limit: 256m

mesh/infra/homeserver/setup.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ if [ ! -f .env ]; then
2626
'MESH_SERVER_NAME=mesh.dhawal.org' \
2727
'MESH_HOMESERVER_HOST=matrix.mesh.dhawal.org' \
2828
'MESH_RTC_HOST=rtc.mesh.dhawal.org' \
29+
'MESH_RTC_ENABLED=0' \
2930
'MESH_PUBLIC_ENABLED=0' \
3031
'SYNAPSE_CONTROL_BIND=127.0.0.1' \
3132
'SYNAPSE_CACHE_FACTOR=0.25' \
@@ -48,6 +49,7 @@ set +a
4849

4950
: "${MESH_SERVER_NAME:?MESH_SERVER_NAME is required}"
5051
: "${MESH_HOMESERVER_HOST:?MESH_HOMESERVER_HOST is required}"
52+
: "${MESH_RTC_ENABLED:=0}"
5153
: "${POSTGRES_USER:?POSTGRES_USER is required}"
5254
: "${POSTGRES_DB:?POSTGRES_DB is required}"
5355
: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
@@ -80,5 +82,18 @@ docker run --rm \
8082
"$synapse_image" \
8183
/configure_synapse.py /data/homeserver.yaml
8284

85+
mkdir -p runtime/well-known
86+
if [ "$MESH_RTC_ENABLED" = "1" ]; then
87+
: "${MESH_RTC_HOST:?MESH_RTC_HOST is required when MESH_RTC_ENABLED=1}"
88+
printf '%s\n' \
89+
"{\"m.homeserver\":{\"base_url\":\"https://$MESH_HOMESERVER_HOST\"},\"org.matrix.msc4143.rtc_foci\":[{\"type\":\"livekit\",\"livekit_service_url\":\"https://$MESH_RTC_HOST/livekit/jwt\"}]}" \
90+
> runtime/well-known/matrix-client.json
91+
else
92+
printf '%s\n' \
93+
"{\"m.homeserver\":{\"base_url\":\"https://$MESH_HOMESERVER_HOST\"}}" \
94+
> runtime/well-known/matrix-client.json
95+
fi
96+
chmod 644 runtime/well-known/matrix-client.json
97+
8398
docker compose config --quiet
8499
echo "Homeserver configuration is ready."

mesh/infra/homeserver/start.sh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,32 @@ if [ "${MESH_PUBLIC_ENABLED:-0}" = "1" ]; then
3636
else
3737
docker compose up -d postgres synapse
3838
fi
39+
40+
deadline=$(( $(date +%s) + 120 ))
41+
until curl --fail --silent http://127.0.0.1:8008/health >/dev/null 2>&1; do
42+
if [ "$(date +%s)" -ge "$deadline" ]; then
43+
echo "Synapse did not become healthy within 120 seconds." >&2
44+
exit 1
45+
fi
46+
sleep 2
47+
done
48+
49+
admin_user="@dhawal:${MESH_SERVER_NAME}"
50+
admin_service="Mesh Homeserver Admin"
51+
if ! security find-generic-password -a "$admin_user" -s "$admin_service" -w >/dev/null 2>&1; then
52+
admin_password="$(openssl rand -base64 36 | tr -d '\n')"
53+
if ! docker compose exec -T synapse register_new_matrix_user \
54+
-c /data/homeserver.yaml http://127.0.0.1:8008 \
55+
-u dhawal -p "$admin_password" --admin >/dev/null; then
56+
echo "Could not bootstrap $admin_user. The account may already exist without a matching Keychain entry." >&2
57+
echo "Recover or reset that account locally, then store its password under '$admin_service'." >&2
58+
unset admin_password
59+
exit 1
60+
fi
61+
security add-generic-password -U \
62+
-a "$admin_user" \
63+
-s "$admin_service" \
64+
-w "$admin_password" >/dev/null
65+
unset admin_password
66+
echo "Created the local Mesh operator account and stored its password in macOS Keychain."
67+
fi

0 commit comments

Comments
 (0)