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 + '
';
}
+ 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 ─────────────────────────────────────────────── */