diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index f165e78..6844370 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -10,6 +10,8 @@ import os import re import socket import subprocess +import urllib.error +import urllib.parse import urllib.request from fastapi import FastAPI, HTTPException @@ -1110,6 +1112,185 @@ async def api_domains_status(): return {"domains": domains} +# ── Matrix user management ──────────────────────────────────────── + +MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users" +MATRIX_DOMAINS_FILE = "/var/lib/domains/matrix" + +_SAFE_USERNAME_RE = re.compile(r'^[a-z0-9._\-]+$') + + +def _validate_matrix_username(username: str) -> bool: + """Return True if username is a valid Matrix localpart.""" + return bool(username) and len(username) <= 255 and bool(_SAFE_USERNAME_RE.match(username)) + + +def _parse_matrix_admin_creds() -> tuple[str, str]: + """Parse admin username and password from the matrix-users credentials file. + + Returns (localpart, password) for the admin account. + Raises FileNotFoundError if the file does not exist. + Raises ValueError if the file cannot be parsed. + """ + with open(MATRIX_USERS_FILE, "r") as f: + content = f.read() + + admin_user: str | None = None + admin_pass: str | None = None + in_admin_section = False + + for line in content.splitlines(): + stripped = line.strip() + if stripped == "[ Admin Account ]": + in_admin_section = True + continue + if stripped.startswith("[ ") and in_admin_section: + break + if in_admin_section: + if stripped.startswith("Username:"): + raw = stripped[len("Username:"):].strip() + # Format is @localpart:domain — extract localpart + if raw.startswith("@") and ":" in raw: + admin_user = raw[1:raw.index(":")] + else: + admin_user = raw + elif stripped.startswith("Password:"): + admin_pass = stripped[len("Password:"):].strip() + + if not admin_user or not admin_pass: + raise ValueError("Could not parse admin credentials from matrix-users file") + if "(pre-existing" in admin_pass: + raise ValueError( + "Admin password is not stored (user was pre-existing). " + "Please reset the admin password manually before using this feature." + ) + return admin_user, admin_pass + + +def _matrix_get_admin_token(domain: str, admin_user: str, admin_pass: str) -> str: + """Log in to the local Synapse instance and return an access token.""" + url = "http://[::1]:8008/_matrix/client/v3/login" + payload = json.dumps({ + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": admin_user}, + "password": admin_pass, + }).encode() + req = urllib.request.Request( + url, data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + body = json.loads(resp.read()) + token: str = body.get("access_token", "") + if not token: + raise ValueError("No access_token in Synapse login response") + return token + + +class MatrixCreateUserRequest(BaseModel): + username: str + password: str + admin: bool = False + + +@app.post("/api/matrix/create-user") +async def api_matrix_create_user(req: MatrixCreateUserRequest): + """Create a new Matrix user via register_new_matrix_user.""" + if not _validate_matrix_username(req.username): + raise HTTPException(status_code=400, detail="Invalid username. Use only lowercase letters, digits, '.', '_', '-'.") + if not req.password: + raise HTTPException(status_code=400, detail="Password must not be empty.") + + admin_flag = ["-a"] if req.admin else ["--no-admin"] + cmd = [ + "register_new_matrix_user", + "-c", "/run/matrix-synapse/runtime-config.yaml", + "-u", req.username, + "-p", req.password, + *admin_flag, + "http://localhost:8008", + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + except FileNotFoundError: + raise HTTPException(status_code=500, detail="register_new_matrix_user not found on this system.") + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="Command timed out.") + + output = (result.stdout + result.stderr).strip() + if result.returncode != 0: + # Surface the actual error from the tool (e.g. "User ID already taken") + raise HTTPException(status_code=400, detail=output or "Failed to create user.") + + return {"ok": True, "username": req.username} + + +class MatrixChangePasswordRequest(BaseModel): + username: str + new_password: str + + +@app.post("/api/matrix/change-password") +async def api_matrix_change_password(req: MatrixChangePasswordRequest): + """Change a Matrix user's password via the Synapse Admin API.""" + if not _validate_matrix_username(req.username): + raise HTTPException(status_code=400, detail="Invalid username. Use only lowercase letters, digits, '.', '_', '-'.") + if not req.new_password: + raise HTTPException(status_code=400, detail="New password must not be empty.") + + # Read domain + try: + with open(MATRIX_DOMAINS_FILE, "r") as f: + domain = f.read().strip() + except FileNotFoundError: + raise HTTPException(status_code=500, detail="Matrix domain not configured.") + + # Parse admin credentials + try: + admin_user, admin_pass = _parse_matrix_admin_creds() + except FileNotFoundError: + raise HTTPException(status_code=500, detail="Matrix credentials file not found.") + except ValueError as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + # Obtain admin access token + loop = asyncio.get_event_loop() + try: + token = await loop.run_in_executor( + None, _matrix_get_admin_token, domain, admin_user, admin_pass + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Could not authenticate as admin: {exc}") + + # Call Synapse Admin API to reset the password + target_user_id = f"@{req.username}:{domain}" + url = f"http://[::1]:8008/_synapse/admin/v2/users/{urllib.parse.quote(target_user_id, safe='@:')}" + payload = json.dumps({"password": req.new_password}).encode() + api_req = urllib.request.Request( + url, data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + method="PUT", + ) + try: + with urllib.request.urlopen(api_req, timeout=15) as resp: + resp.read() + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors="replace") + try: + detail = json.loads(body).get("error", body) + except Exception: + detail = body + raise HTTPException(status_code=400, detail=detail) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Admin API call failed: {exc}") + + return {"ok": True, "username": req.username} + + # ── Startup: seed the internal IP file immediately ─────────────── @app.on_event("startup") diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 2bb4ec1..09e6f89 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -154,7 +154,11 @@ function formatDuration(seconds) { async function apiFetch(path, options) { const res = await fetch(path, options || {}); - if (!res.ok) throw new Error(res.status + " " + res.statusText); + if (!res.ok) { + let detail = res.status + " " + res.statusText; + try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {} + throw new Error(detail); + } return res.json(); } @@ -306,6 +310,12 @@ async function openCredsModal(unit, name) { } html += '
' + escHtml(cred.label) + '
' + qrBlock + '
' + displayValue + '
'; } + if (unit === "matrix-synapse.service") { + html += '
' + + '' + + '' + + '
'; + } $credsBody.innerHTML = html; $credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) { btn.addEventListener("click", function() { @@ -340,11 +350,125 @@ async function openCredsModal(unit, name) { } }); }); + if (unit === "matrix-synapse.service") { + var addBtn = document.getElementById("matrix-add-user-btn"); + var changePwBtn = document.getElementById("matrix-change-pw-btn"); + if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); }); + if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); }); + } } catch (err) { $credsBody.innerHTML = '

Could not load credentials.

'; } } +function openMatrixCreateUserModal(unit, name) { + if (!$credsBody) return; + $credsBody.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + document.getElementById("matrix-create-back-btn").addEventListener("click", function() { + openCredsModal(unit, name); + }); + + document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() { + var submitBtn = document.getElementById("matrix-create-submit-btn"); + var resultEl = document.getElementById("matrix-create-result"); + var username = (document.getElementById("matrix-new-username").value || "").trim(); + var password = document.getElementById("matrix-new-password").value || ""; + var isAdmin = document.getElementById("matrix-new-admin").checked; + + if (!username || !password) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "Username and password are required."; + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = "Creating…"; + resultEl.className = "matrix-form-result"; + resultEl.textContent = ""; + + try { + var resp = await apiFetch("/api/matrix/create-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, password: password, admin: isAdmin }) + }); + resultEl.className = "matrix-form-result success"; + resultEl.textContent = "✅ User @" + escHtml(resp.username) + " created successfully."; + submitBtn.textContent = "Create User"; + submitBtn.disabled = false; + } catch (err) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "❌ " + (err.message || "Failed to create user."); + submitBtn.textContent = "Create User"; + submitBtn.disabled = false; + } + }); +} + +function openMatrixChangePasswordModal(unit, name) { + if (!$credsBody) return; + $credsBody.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { + openCredsModal(unit, name); + }); + + document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() { + var submitBtn = document.getElementById("matrix-chpw-submit-btn"); + var resultEl = document.getElementById("matrix-chpw-result"); + var username = (document.getElementById("matrix-chpw-username").value || "").trim(); + var newPassword = document.getElementById("matrix-chpw-password").value || ""; + + if (!username || !newPassword) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "Username and new password are required."; + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = "Changing…"; + resultEl.className = "matrix-form-result"; + resultEl.textContent = ""; + + try { + var resp = await apiFetch("/api/matrix/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, new_password: newPassword }) + }); + resultEl.className = "matrix-form-result success"; + resultEl.textContent = "✅ Password for @" + escHtml(resp.username) + " changed successfully."; + submitBtn.textContent = "Change Password"; + submitBtn.disabled = false; + } catch (err) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "❌ " + (err.message || "Failed to change password."); + submitBtn.textContent = "Change Password"; + submitBtn.disabled = false; + } + }); +} + function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } // ── Tech Support modal ──────────────────────────────────────────── diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 068ec04..a65ffa4 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -592,6 +592,143 @@ button.btn-reboot:hover:not(:disabled) { color: #defce6; } +/* ── Matrix action buttons ───────────────────────────────────────── */ + +.matrix-actions-divider { + border: none; + border-top: 1px solid var(--border-color); + margin: 18px 0 14px; +} + +.matrix-actions-row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.matrix-action-btn { + background-color: var(--accent-color); + color: #0f0f19; + font-size: 0.88rem; + font-weight: 700; + padding: 10px 18px; + border-radius: 8px; + border: none; + cursor: pointer; + flex: 1; + min-width: 140px; +} + +.matrix-action-btn:hover { + background-color: #a8c8ff; +} + +.matrix-form-group { + margin-bottom: 14px; +} + +.matrix-form-label { + display: block; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} + +.matrix-form-input { + width: 100%; + background-color: #12121c; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.9rem; + box-sizing: border-box; +} + +.matrix-form-input:focus { + outline: none; + border-color: var(--accent-color); +} + +.matrix-form-checkbox-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.matrix-form-checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent-color); +} + +.matrix-form-actions { + display: flex; + gap: 10px; + margin-top: 18px; +} + +.matrix-form-submit { + background-color: var(--accent-color); + color: #0f0f19; + font-size: 0.88rem; + font-weight: 700; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; + flex: 1; +} + +.matrix-form-submit:hover:not(:disabled) { + background-color: #a8c8ff; +} + +.matrix-form-submit:disabled { + opacity: 0.6; + cursor: default; +} + +.matrix-form-back { + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.88rem; + font-weight: 600; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; +} + +.matrix-form-back:hover { + background-color: #5a5c72; +} + +.matrix-form-result { + margin-top: 14px; + padding: 12px 16px; + border-radius: 8px; + font-size: 0.88rem; + line-height: 1.5; + display: none; +} + +.matrix-form-result.success { + background-color: rgba(74, 222, 128, 0.12); + border: 1px solid var(--green); + color: var(--green); + display: block; +} + +.matrix-form-result.error { + background-color: rgba(239, 68, 68, 0.12); + border: 1px solid #ef4444; + color: #f87171; + display: block; +} + /* ── QR code in credentials modal ────────────────────────────────── */ .creds-qr-wrap { @@ -756,6 +893,7 @@ button.btn-reboot:hover:not(:disabled) { width: 200px; height: 200px; } +} /* ── Tech Support tile ───────────────────────────────────────────── */ @@ -955,10 +1093,6 @@ button.btn-reboot:hover:not(:disabled) { .support-btn-done:hover:not(:disabled) { background-color: #5a5c72; - - -} - } /* ── Feature Manager ─────────────────────────────────────────────── */