diff --git a/scripts/deploy_synapse.py b/scripts/deploy_synapse.py new file mode 100755 index 000000000..862d09c0e --- /dev/null +++ b/scripts/deploy_synapse.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +"""Deploy Synapse Matrix homeserver on a remote VPS. + +Phase 1 of Matrix integration (Epic #269). Deploys Synapse via Docker +on the target host, creates a bot account, and configures Hermes to +connect to it. + +Usage: + python scripts/deploy_synapse.py --host --user root --domain matrix.example.com + python scripts/deploy_synapse.py --host 143.198.27.163 --user root --domain matrix.timmy.dev --dry-run + +Requires SSH access to the target host. +""" + +import argparse +import getpass +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +def _ssh_cmd(host: str, user: str, port: int = 22, key_path: str = "") -> list: + """Build base SSH command.""" + cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=15"] + if port != 22: + cmd.extend(["-p", str(port)]) + if key_path: + cmd.extend(["-i", key_path]) + cmd.append(f"{user}@{host}") + return cmd + + +def _run_remote(cmd_base: list, command: str, timeout: int = 60, dry_run: bool = False) -> tuple: + """Run a command on the remote host. Returns (success, stdout, stderr).""" + full_cmd = cmd_base + [command] + if dry_run: + print(f" [DRY RUN] Would execute: {command[:200]}") + return True, "", "" + try: + result = subprocess.run(full_cmd, capture_output=True, text=True, timeout=timeout) + return result.returncode == 0, result.stdout, result.stderr + except subprocess.TimeoutExpired: + return False, "", f"Command timed out after {timeout}s" + + +def check_prerequisites(cmd_base: list) -> bool: + """Check that Docker and docker-compose are available on the remote host.""" + print("\n[1/6] Checking prerequisites...") + + checks = [ + ("Docker", "command -v docker && docker --version"), + ("Docker Compose", "command -v docker-compose || docker compose version 2>/dev/null"), + ("curl", "command -v curl"), + ] + + all_ok = True + for name, check_cmd in checks: + ok, stdout, stderr = _run_remote(cmd_base, check_cmd, timeout=15) + if ok: + print(f" ✓ {name}: {stdout.strip()[:80]}") + else: + print(f" ✗ {name}: not found") + all_ok = False + + return all_ok + + +def install_docker(cmd_base: list, dry_run: bool = False) -> bool: + """Install Docker on the remote host if not present.""" + print("\n[1b] Installing Docker...") + install_cmd = ( + "curl -fsSL https://get.docker.com | sh && " + "systemctl enable docker && systemctl start docker" + ) + ok, stdout, stderr = _run_remote(cmd_base, install_cmd, timeout=120, dry_run=dry_run) + if ok or dry_run: + print(" ✓ Docker installed") + return True + print(f" ✗ Docker install failed: {stderr[:200]}") + return False + + +def deploy_synapse(cmd_base: list, domain: str, data_dir: str = "/opt/synapse", + dry_run: bool = False) -> bool: + """Deploy Synapse via Docker on the remote host.""" + print(f"\n[2/6] Deploying Synapse for {domain}...") + + # Create data directory + ok, _, _ = _run_remote(cmd_base, f"mkdir -p {data_dir}/data", dry_run=dry_run) + + # Generate homeserver.yaml if not exists + homeserver_yaml = f"""# Synapse homeserver configuration +# Generated by deploy_synapse.py for {domain} + +server_name: "{domain}" +pid_file: /data/homeserver.pid +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + resources: + - names: [client, federation] + compress: false + +database: + name: sqlite3 + args: + database: /data/homeserver.db + +media_store_path: /data/media_store +signing_key_path: /data/signing.key +log_config: "/data/{domain}.log.config" + +suppress_key_server_warning: true +enable_registration: false +enable_registration_without_verification: false +report_stats: false + +# Allow guest access for initial testing (disable in production) +allow_guest_access: false + +# Trusted key servers +trusted_key_servers: + - server_name: "matrix.org" +""" + + # Write homeserver.yaml + write_cmd = f"cat > {data_dir}/homeserver.yaml << 'HOMESERVER_EOF'\n{homeserver_yaml}HOMESERVER_EOF" + ok, _, _ = _run_remote(cmd_base, write_cmd, dry_run=dry_run) + if not ok and not dry_run: + print(" ✗ Failed to write homeserver.yaml") + return False + + # Generate log config + log_config = f"""version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s' + +handlers: + console: + class: logging.StreamHandler + formatter: precise + level: INFO + +loggers: + synapse.storage.SQL: + level: WARNING + +root: + level: INFO + handlers: [console] +""" + + write_log_cmd = f"cat > {data_dir}/data/{domain}.log.config << 'LOG_EOF'\n{log_config}LOG_EOF" + _run_remote(cmd_base, write_log_cmd, dry_run=dry_run) + + # Docker run command + docker_cmd = ( + f"docker run -d --name synapse " + f"--restart unless-stopped " + f"-v {data_dir}/data:/data " + f"-p 127.0.0.1:8008:8008 " + f"-e SYNAPSE_CONFIG_PATH=/data/homeserver.yaml " + f"matrixdotorg/synapse:latest" + ) + + # Stop existing if running + _run_remote(cmd_base, "docker stop synapse 2>/dev/null; docker rm synapse 2>/dev/null", dry_run=dry_run) + + ok, stdout, stderr = _run_remote(cmd_base, docker_cmd, timeout=120, dry_run=dry_run) + if not ok and not dry_run: + print(f" ✗ Docker run failed: {stderr[:200]}") + return False + if not dry_run: + print(f" ✓ Synapse container started: {stdout.strip()[:12]}") + else: + print(" ✓ Synapse container (dry run)") + + return True + + +def wait_for_synapse(cmd_base: list, max_wait: int = 60, dry_run: bool = False) -> bool: + """Wait for Synapse to become healthy.""" + print("\n[3/6] Waiting for Synapse to start...") + if dry_run: + print(" ✓ Skipped (dry run)") + return True + + start = time.time() + while time.time() - start < max_wait: + ok, stdout, _ = _run_remote( + cmd_base, + "curl -sf http://127.0.0.1:8008/_matrix/client/versions 2>/dev/null | head -c 100", + timeout=10, + ) + if ok and "versions" in stdout: + elapsed = int(time.time() - start) + print(f" ✓ Synapse is up (took {elapsed}s)") + return True + time.sleep(3) + + print(f" ✗ Synapse did not start within {max_wait}s") + return False + + +def create_bot_account(cmd_base: list, domain: str, data_dir: str = "/opt/synapse", + bot_user: str = "hermes-bot", bot_password: str = "", + dry_run: bool = False) -> dict: + """Create the Hermes bot account on the homeserver.""" + print(f"\n[4/6] Creating bot account @{bot_user}:{domain}...") + + if not bot_password: + import secrets + bot_password = secrets.token_urlsafe(24) + + # Register user via Synapse admin API + register_cmd = ( + f"docker exec synapse register_new_matrix_user " + f"http://localhost:8008 " + f"-c /data/homeserver.yaml " + f"-u {bot_user} " + f"-p '{bot_password}' " + f"--no-admin" + ) + + ok, stdout, stderr = _run_remote(cmd_base, register_cmd, timeout=30, dry_run=dry_run) + result = { + "user_id": f"@{bot_user}:{domain}", + "password": bot_password, + "homeserver_url": f"https://{domain}", + } + + if ok or dry_run: + print(f" ✓ Bot account created: {result['user_id']}") + elif "User ID already taken" in stderr: + print(f" ⚠ Bot account already exists: @{bot_user}:{domain}") + else: + print(f" ⚠ Bot registration: {stderr[:100]}") + + return result + + +def login_and_get_token(cmd_base: list, domain: str, bot_user: str, bot_password: str, + dry_run: bool = False) -> str: + """Login and get an access token for the bot.""" + print("\n[5/6] Getting access token...") + + if dry_run: + print(" ✓ Skipped (dry run)") + return "dry-run-token" + + login_data = json.dumps({ + "type": "m.login.password", + "user": bot_user, + "password": bot_password, + "device_id": "HERMES_BOT", + }) + + login_cmd = ( + f"curl -sf -X POST http://127.0.0.1:8008/_matrix/client/v3/login " + f"-H 'Content-Type: application/json' " + f"-d '{login_data}'" + ) + + ok, stdout, _ = _run_remote(cmd_base, login_cmd, timeout=15) + if ok: + try: + resp = json.loads(stdout) + token = resp.get("access_token", "") + device_id = resp.get("device_id", "") + if token: + print(f" ✓ Access token obtained (device: {device_id})") + return token + except json.JSONDecodeError: + pass + + print(" ✗ Failed to get access token") + return "" + + +def print_config(domain: str, bot_user: str, token: str, bot_password: str): + """Print the configuration needed for Hermes.""" + print("\n[6/6] Configuration for Hermes") + print("=" * 60) + print(f"Add these to ~/.hermes/.env:") + print() + print(f"MATRIX_HOMESERVER=https://{domain}") + print(f"MATRIX_ACCESS_TOKEN={token}") + print(f"MATRIX_USER_ID=@{bot_user}:{domain}") + print(f"MATRIX_DEVICE_ID=HERMES_BOT") + print() + print(f"Bot password (save securely): {bot_password}") + print("=" * 60) + + +def main(): + parser = argparse.ArgumentParser(description="Deploy Synapse on a VPS for Hermes Matrix integration") + parser.add_argument("--host", required=True, help="VPS hostname or IP") + parser.add_argument("--user", default="root", help="SSH user (default: root)") + parser.add_argument("--port", type=int, default=22, help="SSH port") + parser.add_argument("--key", default="", help="SSH key path") + parser.add_argument("--domain", required=True, help="Matrix domain (e.g., matrix.timmy.dev)") + parser.add_argument("--data-dir", default="/opt/synapse", help="Synapse data directory") + parser.add_argument("--bot-user", default="hermes-bot", help="Bot username") + parser.add_argument("--bot-password", default="", help="Bot password (auto-generated if empty)") + parser.add_argument("--dry-run", action="store_true", help="Print commands without executing") + parser.add_argument("--skip-docker-install", action="store_true", help="Skip Docker installation") + + args = parser.parse_args() + + print(f"Synapse Deployment for Hermes") + print(f" Host: {args.user}@{args.host}:{args.port}") + print(f" Domain: {args.domain}") + print(f" Data dir: {args.data_dir}") + if args.dry_run: + print(f" Mode: DRY RUN") + + cmd_base = _ssh_cmd(args.host, args.user, args.port, args.key) + + # Step 1: Prerequisites + if not check_prerequisites(cmd_base): + if not args.skip_docker_install: + if not install_docker(cmd_base, args.dry_run): + print("\n✗ Deployment failed: could not install Docker") + sys.exit(1) + else: + print("\n✗ Deployment failed: prerequisites not met") + sys.exit(1) + + # Step 2: Deploy Synapse + if not deploy_synapse(cmd_base, args.domain, args.data_dir, args.dry_run): + print("\n✗ Deployment failed: could not start Synapse") + sys.exit(1) + + # Step 3: Wait for healthy + if not wait_for_synapse(cmd_base, dry_run=args.dry_run): + print("\n✗ Deployment failed: Synapse not healthy") + sys.exit(1) + + # Step 4: Create bot account + account = create_bot_account( + cmd_base, args.domain, args.data_dir, + args.bot_user, args.bot_password, args.dry_run, + ) + + # Step 5: Get access token + token = login_and_get_token( + cmd_base, args.domain, args.bot_user, + account["password"], args.dry_run, + ) + + # Step 6: Print config + print_config(args.domain, args.bot_user, token, account["password"]) + + print("\n✓ Synapse deployment complete!") + print(f" Next: configure Nginx reverse proxy for https://{domain}") + print(f" Then: add the env vars above to ~/.hermes/.env and restart the gateway") + + +if __name__ == "__main__": + main() diff --git a/tests/test_deploy_synapse.py b/tests/test_deploy_synapse.py new file mode 100644 index 000000000..5ba3bc1d8 --- /dev/null +++ b/tests/test_deploy_synapse.py @@ -0,0 +1,76 @@ +"""Tests for deploy_synapse.py helpers.""" +import json +import pytest +from unittest.mock import MagicMock, patch, call +import subprocess + + +class TestSshCmd: + def test_basic(self): + from scripts.deploy_synapse import _ssh_cmd + cmd = _ssh_cmd("1.2.3.4", "root") + assert "root@1.2.3.4" in cmd + assert "ssh" in cmd[0] + + def test_custom_port(self): + from scripts.deploy_synapse import _ssh_cmd + cmd = _ssh_cmd("1.2.3.4", "root", port=2222) + assert "-p" in cmd + assert "2222" in cmd + + def test_key_path(self): + from scripts.deploy_synapse import _ssh_cmd + cmd = _ssh_cmd("1.2.3.4", "root", key_path="/root/.ssh/id_rsa") + assert "-i" in cmd + assert "/root/.ssh/id_rsa" in cmd + + +class TestRunRemote: + def test_dry_run(self): + from scripts.deploy_synapse import _run_remote + ok, stdout, stderr = _run_remote(["ssh", "root@host"], "echo hi", dry_run=True) + assert ok is True + assert stdout == "" + + @patch("scripts.deploy_synapse.subprocess.run") + def test_success(self, mock_run): + from scripts.deploy_synapse import _run_remote + mock_run.return_value = MagicMock(returncode=0, stdout="hello\n", stderr="") + ok, stdout, stderr = _run_remote(["ssh", "root@host"], "echo hello") + assert ok is True + assert "hello" in stdout + + @patch("scripts.deploy_synapse.subprocess.run") + def test_failure(self, mock_run): + from scripts.deploy_synapse import _run_remote + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error") + ok, stdout, stderr = _run_remote(["ssh", "root@host"], "bad cmd") + assert ok is False + + @patch("scripts.deploy_synapse.subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 10)) + def test_timeout(self, mock_run): + from scripts.deploy_synapse import _run_remote + ok, stdout, stderr = _run_remote(["ssh", "root@host"], "slow cmd", timeout=10) + assert ok is False + assert "timed out" in stderr + + +class TestCreateBotAccount: + def test_returns_correct_structure(self): + from scripts.deploy_synapse import create_bot_account + with patch("scripts.deploy_synapse._run_remote") as mock: + mock.return_value = (True, "success", "") + result = create_bot_account(["ssh", "root@x"], "example.com", dry_run=True) + assert "user_id" in result + assert "password" in result + assert "homeserver_url" in result + assert result["user_id"] == "@hermes-bot:example.com" + + +class TestPrintConfig: + def test_runs_without_error(self, capsys): + from scripts.deploy_synapse import print_config + print_config("example.com", "hermes-bot", "tok_abc", "pass123") + captured = capsys.readouterr() + assert "MATRIX_HOMESERVER=https://example.com" in captured.out + assert "MATRIX_ACCESS_TOKEN=tok_abc" in captured.out