feat: wallet privacy control and audit logging for tech support sessions

- Add dedicated `sovran-support` restricted user (non-root) for SSH sessions
- Apply POSIX ACLs via setfacl to block support user from wallet directories
  (LND, Sparrow, Bisq, nix-bitcoin-secrets) by default
- Graceful fallback to root authorized_keys if user creation fails (with UI warning)
- Add time-limited wallet unlock consent: POST /api/support/wallet-unlock
- Add wallet re-lock: POST /api/support/wallet-lock
- Add audit log: GET /api/support/audit-log (append-only, all events logged)
- Expand /api/support/status with wallet_protected, wallet_unlocked,
  wallet_unlocked_until, protected_paths, acl_applied fields
- Update frontend to show wallet protection status box with protected path list
- Show wallet unlock/re-lock controls with duration selector (30min/1h/2h)
- Show audit log viewer in support modal (toggleable)
- Add wallet unlock expiry auto-refresh timer in JS
- Add CSS styles for wallet protection box, unlock/lock buttons, audit log

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/70330ce3-1ed7-46b1-ac66-4cdc50de6017

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-04 01:02:58 +00:00
committed by GitHub
parent 87529b0d3f
commit dd3a20ed00
3 changed files with 668 additions and 29 deletions

View File

@@ -7,9 +7,11 @@ import base64
import hashlib import hashlib
import json import json
import os import os
import pwd
import re import re
import socket import socket
import subprocess import subprocess
import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -63,6 +65,30 @@ SOVRAN_SUPPORT_PUBKEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFLY8hjksaWzQmIQVut
SUPPORT_KEY_COMMENT = "sovransystemsos-support" SUPPORT_KEY_COMMENT = "sovransystemsos-support"
# Dedicated restricted support user (non-root) for wallet privacy
SUPPORT_USER = "sovran-support"
SUPPORT_USER_HOME = "/var/lib/sovran-support"
SUPPORT_USER_SSH_DIR = "/var/lib/sovran-support/.ssh"
SUPPORT_USER_AUTH_KEYS = "/var/lib/sovran-support/.ssh/authorized_keys"
# Audit log for all support session events
SUPPORT_AUDIT_LOG = "/var/log/sovran-support-audit.log"
# Time-limited wallet unlock state
WALLET_UNLOCK_FILE = "/var/lib/secrets/support-wallet-unlock"
WALLET_UNLOCK_DURATION_DEFAULT = 3600 # seconds (1 hour)
# Wallet paths protected by default from the support user
PROTECTED_WALLET_PATHS: list[str] = [
"/var/lib/lnd",
"/root/.lnd",
"/var/lib/sparrow",
"/root/.sparrow",
"/root/.bisq",
"/etc/nix-bitcoin-secrets",
"/var/lib/bitcoind",
]
CATEGORY_ORDER = [ CATEGORY_ORDER = [
("infrastructure", "Infrastructure"), ("infrastructure", "Infrastructure"),
("bitcoin-base", "Bitcoin Base"), ("bitcoin-base", "Bitcoin Base"),
@@ -714,7 +740,15 @@ def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
# ── Tech Support helpers ────────────────────────────────────────── # ── Tech Support helpers ──────────────────────────────────────────
def _is_support_active() -> bool: def _is_support_active() -> bool:
"""Check if the support key is currently in authorized_keys.""" """Check if the support key is currently in authorized_keys or support user's authorized_keys."""
# Check support user's authorized_keys first
try:
with open(SUPPORT_USER_AUTH_KEYS, "r") as f:
if SUPPORT_KEY_COMMENT in f.read():
return True
except FileNotFoundError:
pass
# Fall back to root authorized_keys
try: try:
with open(AUTHORIZED_KEYS, "r") as f: with open(AUTHORIZED_KEYS, "r") as f:
content = f.read() content = f.read()
@@ -732,17 +766,175 @@ def _get_support_session_info() -> dict:
return {} return {}
def _enable_support() -> bool: def _log_support_audit(event: str, details: str = "") -> None:
"""Add the Sovran support public key to root's authorized_keys.""" """Append a timestamped event to the support audit log."""
timestamp = time.strftime("%Y-%m-%d %H:%M:%S %Z")
line = f"[{timestamp}] {event}"
if details:
line += f": {details}"
line += "\n"
try: try:
os.makedirs("/root/.ssh", mode=0o700, exist_ok=True) os.makedirs(os.path.dirname(SUPPORT_AUDIT_LOG), exist_ok=True)
with open(SUPPORT_AUDIT_LOG, "a") as f:
f.write(line)
except Exception:
pass
# Write the key to the dedicated support key file
def _get_support_audit_log(max_lines: int = 100) -> list:
"""Return the last N lines from the audit log."""
try:
with open(SUPPORT_AUDIT_LOG, "r") as f:
lines = f.readlines()
return [l.rstrip("\n") for l in lines[-max_lines:]]
except FileNotFoundError:
return []
def _get_existing_wallet_paths() -> list:
"""Return the subset of PROTECTED_WALLET_PATHS that actually exist on disk."""
return [p for p in PROTECTED_WALLET_PATHS if os.path.exists(p)]
def _ensure_support_user() -> bool:
"""Ensure the sovran-support restricted user exists. Returns True on success."""
try:
result = subprocess.run(
["id", SUPPORT_USER], capture_output=True, timeout=5,
)
if result.returncode == 0:
return True
except Exception:
return False
try:
subprocess.run(
[
"useradd",
"--system",
"--no-create-home",
"--home-dir", SUPPORT_USER_HOME,
"--shell", "/bin/bash",
"--comment", "Sovran Systems Support (restricted)",
SUPPORT_USER,
],
check=True, capture_output=True, timeout=15,
)
os.makedirs(SUPPORT_USER_HOME, mode=0o700, exist_ok=True)
os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True)
pw = pwd.getpwnam(SUPPORT_USER)
os.chown(SUPPORT_USER_HOME, pw.pw_uid, pw.pw_gid)
os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid)
return True
except Exception:
return False
def _apply_wallet_acls() -> bool:
"""Apply POSIX ACLs to deny the support user access to wallet directories.
Sets a deny-all ACL entry (u:sovran-support:---) on each existing protected
path. Returns True if all existing paths were handled without error.
setfacl is tried; if it is not available the function returns False without
raising so callers can warn the user appropriately.
"""
existing = _get_existing_wallet_paths()
if not existing:
return True
success = True
for path in existing:
try:
result = subprocess.run(
["setfacl", "-R", "-m", f"u:{SUPPORT_USER}:---", path],
capture_output=True, timeout=15,
)
if result.returncode != 0:
success = False
except FileNotFoundError:
# setfacl not installed
return False
except Exception:
success = False
return success
def _revoke_wallet_acls() -> bool:
"""Remove the support user's deny ACL from wallet directories."""
existing = _get_existing_wallet_paths()
if not existing:
return True
success = True
for path in existing:
try:
result = subprocess.run(
["setfacl", "-R", "-x", f"u:{SUPPORT_USER}", path],
capture_output=True, timeout=15,
)
if result.returncode != 0:
success = False
except FileNotFoundError:
return False
except Exception:
success = False
return success
def _is_wallet_unlocked() -> bool:
"""Return True if the user has granted time-limited wallet access and it has not expired."""
try:
with open(WALLET_UNLOCK_FILE, "r") as f:
data = json.load(f)
return time.time() < data.get("expires_at", 0)
except (FileNotFoundError, json.JSONDecodeError, KeyError):
return False
def _get_wallet_unlock_info() -> dict:
"""Read wallet unlock state. Re-locks and returns {} if the grant has expired."""
try:
with open(WALLET_UNLOCK_FILE, "r") as f:
data = json.load(f)
if time.time() >= data.get("expires_at", 0):
try:
os.remove(WALLET_UNLOCK_FILE)
except FileNotFoundError:
pass
_apply_wallet_acls()
_log_support_audit("WALLET_RELOCKED", "auto-expired")
return {}
return data
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _enable_support() -> bool:
"""Add the Sovran support public key to the restricted support user's authorized_keys.
Falls back to root's authorized_keys if the support user cannot be created.
Applies POSIX ACLs to wallet directories to prevent access by the support
user without explicit user consent.
"""
try:
use_restricted_user = _ensure_support_user()
if use_restricted_user:
os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True)
with open(SUPPORT_USER_AUTH_KEYS, "w") as f:
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
os.chmod(SUPPORT_USER_AUTH_KEYS, 0o600)
try:
pw = pwd.getpwnam(SUPPORT_USER)
os.chown(SUPPORT_USER_AUTH_KEYS, pw.pw_uid, pw.pw_gid)
os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid)
except Exception:
pass
else:
# Fallback: add key to root's authorized_keys
os.makedirs("/root/.ssh", mode=0o700, exist_ok=True)
with open(SUPPORT_KEY_FILE, "w") as f: with open(SUPPORT_KEY_FILE, "w") as f:
f.write(SOVRAN_SUPPORT_PUBKEY + "\n") f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
os.chmod(SUPPORT_KEY_FILE, 0o600) os.chmod(SUPPORT_KEY_FILE, 0o600)
# Append to authorized_keys if not already present
existing = "" existing = ""
try: try:
with open(AUTHORIZED_KEYS, "r") as f: with open(AUTHORIZED_KEYS, "r") as f:
@@ -755,25 +947,41 @@ def _enable_support() -> bool:
f.write(SOVRAN_SUPPORT_PUBKEY + "\n") f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
os.chmod(AUTHORIZED_KEYS, 0o600) os.chmod(AUTHORIZED_KEYS, 0o600)
# Write session metadata acl_applied = _apply_wallet_acls() if use_restricted_user else False
import time wallet_paths = _get_existing_wallet_paths()
session_info = { session_info = {
"enabled_at": time.time(), "enabled_at": time.time(),
"enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"), "enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"),
"use_restricted_user": use_restricted_user,
"wallet_protected": use_restricted_user,
"acl_applied": acl_applied,
"protected_paths": wallet_paths,
} }
os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True) os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True)
with open(SUPPORT_STATUS_FILE, "w") as f: with open(SUPPORT_STATUS_FILE, "w") as f:
json.dump(session_info, f) json.dump(session_info, f)
_log_support_audit(
"SUPPORT_ENABLED",
f"restricted_user={use_restricted_user} acl_applied={acl_applied} "
f"protected_paths={len(wallet_paths)}",
)
return True return True
except Exception: except Exception:
return False return False
def _disable_support() -> bool: def _disable_support() -> bool:
"""Remove the Sovran support public key from authorized_keys.""" """Remove the Sovran support public key and revoke all wallet access."""
try: try:
# Remove from authorized_keys # Remove from support user's authorized_keys
try:
os.remove(SUPPORT_USER_AUTH_KEYS)
except FileNotFoundError:
pass
# Remove from root's authorized_keys (fallback / legacy)
try: try:
with open(AUTHORIZED_KEYS, "r") as f: with open(AUTHORIZED_KEYS, "r") as f:
lines = f.readlines() lines = f.readlines()
@@ -790,19 +998,35 @@ def _disable_support() -> bool:
except FileNotFoundError: except FileNotFoundError:
pass pass
# Revoke any outstanding wallet unlock
try:
os.remove(WALLET_UNLOCK_FILE)
except FileNotFoundError:
pass
# Re-apply ACLs to ensure wallet access is revoked
_revoke_wallet_acls()
# Remove session metadata # Remove session metadata
try: try:
os.remove(SUPPORT_STATUS_FILE) os.remove(SUPPORT_STATUS_FILE)
except FileNotFoundError: except FileNotFoundError:
pass pass
_log_support_audit("SUPPORT_DISABLED")
return True return True
except Exception: except Exception:
return False return False
def _verify_support_removed() -> bool: def _verify_support_removed() -> bool:
"""Verify the support key is truly gone from authorized_keys.""" """Verify the support key is truly gone from all authorized_keys files."""
try:
with open(SUPPORT_USER_AUTH_KEYS, "r") as f:
if SUPPORT_KEY_COMMENT in f.read():
return False
except FileNotFoundError:
pass
try: try:
with open(AUTHORIZED_KEYS, "r") as f: with open(AUTHORIZED_KEYS, "r") as f:
content = f.read() content = f.read()
@@ -1164,10 +1388,18 @@ async def api_support_status():
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
active = await loop.run_in_executor(None, _is_support_active) active = await loop.run_in_executor(None, _is_support_active)
session = await loop.run_in_executor(None, _get_support_session_info) session = await loop.run_in_executor(None, _get_support_session_info)
unlock_info = await loop.run_in_executor(None, _get_wallet_unlock_info)
wallet_unlocked = bool(unlock_info)
return { return {
"active": active, "active": active,
"enabled_at": session.get("enabled_at"), "enabled_at": session.get("enabled_at"),
"enabled_at_human": session.get("enabled_at_human"), "enabled_at_human": session.get("enabled_at_human"),
"wallet_protected": session.get("wallet_protected", False),
"acl_applied": session.get("acl_applied", False),
"protected_paths": session.get("protected_paths", []),
"wallet_unlocked": wallet_unlocked,
"wallet_unlocked_until": unlock_info.get("expires_at") if wallet_unlocked else None,
"wallet_unlocked_until_human": unlock_info.get("expires_at_human") if wallet_unlocked else None,
} }
@@ -1194,6 +1426,77 @@ async def api_support_disable():
return {"ok": True, "verified": verified, "message": "Support access removed and verified"} return {"ok": True, "verified": verified, "message": "Support access removed and verified"}
class WalletUnlockRequest(BaseModel):
duration: int = WALLET_UNLOCK_DURATION_DEFAULT # seconds
@app.post("/api/support/wallet-unlock")
async def api_support_wallet_unlock(req: WalletUnlockRequest):
"""Grant the support user time-limited access to wallet directories.
Removes the deny ACL for the support user on all protected wallet paths.
Access is automatically revoked when the timer expires (checked lazily on
next status call) or when the support session is ended.
"""
loop = asyncio.get_event_loop()
active = await loop.run_in_executor(None, _is_support_active)
if not active:
raise HTTPException(status_code=400, detail="No active support session")
duration = max(300, min(req.duration, 14400)) # clamp: 5 min 4 hours
expires_at = time.time() + duration
expires_human = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime(expires_at))
# Remove ACL restrictions
await loop.run_in_executor(None, _revoke_wallet_acls)
unlock_info = {
"unlocked_at": time.time(),
"expires_at": expires_at,
"expires_at_human": expires_human,
"duration": duration,
}
os.makedirs(os.path.dirname(WALLET_UNLOCK_FILE), exist_ok=True)
with open(WALLET_UNLOCK_FILE, "w") as f:
json.dump(unlock_info, f)
_log_support_audit(
"WALLET_UNLOCKED",
f"duration={duration}s expires={expires_human}",
)
return {
"ok": True,
"expires_at": expires_at,
"expires_at_human": expires_human,
"message": f"Wallet access granted for {duration // 60} minutes",
}
@app.post("/api/support/wallet-lock")
async def api_support_wallet_lock():
"""Revoke wallet access and re-apply ACL protections."""
loop = asyncio.get_event_loop()
try:
os.remove(WALLET_UNLOCK_FILE)
except FileNotFoundError:
pass
await loop.run_in_executor(None, _apply_wallet_acls)
_log_support_audit("WALLET_LOCKED", "user-initiated")
return {"ok": True, "message": "Wallet access revoked"}
@app.get("/api/support/audit-log")
async def api_support_audit_log(limit: int = 100):
"""Return the last N lines of the support audit log."""
limit = max(1, min(limit, 500))
loop = asyncio.get_event_loop()
lines = await loop.run_in_executor(None, _get_support_audit_log, limit)
return {"entries": lines}
# ── Feature Manager endpoints ───────────────────────────────────── # ── Feature Manager endpoints ─────────────────────────────────────
@app.get("/api/features") @app.get("/api/features")

View File

@@ -44,6 +44,8 @@ let _serverWasDown = false;
let _updateFinished = false; let _updateFinished = false;
let _supportTimerInt = null; let _supportTimerInt = null;
let _supportEnabledAt = null; let _supportEnabledAt = null;
let _supportStatus = null; // last fetched /api/support/status payload
let _walletUnlockTimerInt = null;
let _cachedExternalIp = null; let _cachedExternalIp = null;
// Feature Manager state // Feature Manager state
@@ -572,7 +574,8 @@ async function openSupportModal() {
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>'; $supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
try { try {
var status = await apiFetch("/api/support/status"); var status = await apiFetch("/api/support/status");
if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(); } _supportStatus = status;
if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(status); }
else { renderSupportInactive(); } else { renderSupportInactive(); }
} catch (err) { } catch (err) {
$supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>'; $supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>';
@@ -582,19 +585,114 @@ async function openSupportModal() {
function renderSupportInactive() { function renderSupportInactive() {
stopSupportTimer(); stopSupportTimer();
var ip = _cachedExternalIp || "loading…"; var ip = _cachedExternalIp || "loading…";
$supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big">🛟</div><h3 class="support-heading">Need help from Sovran Systems?</h3><p class="support-desc">This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.</p><div class="support-info-box"><div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div><div class="support-info-hint">This IP will be shared with Sovran Systems support</div></div><div class="support-steps"><div class="support-steps-title">What happens:</div><ol><li>Our public SSH key is added to your machine</li><li>We connect and help fix the issue</li><li>You click "End Session" to remove our access</li></ol></div><button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button><p class="support-fine-print">You can revoke access at any time</p></div>'; $supportBody.innerHTML = [
'<div class="support-section">',
'<div class="support-icon-big">🛟</div>',
'<h3 class="support-heading">Need help from Sovran Systems?</h3>',
'<p class="support-desc">This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.</p>',
'<div class="support-info-box">',
'<div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div>',
'<div class="support-info-hint">This IP will be shared with Sovran Systems support</div>',
'</div>',
'<div class="support-wallet-box support-wallet-protected">',
'<div class="support-wallet-header"><span class="support-wallet-icon">🔒</span><span class="support-wallet-title">Wallet Protection</span></div>',
'<p class="support-wallet-desc">Wallet files (LND, Sparrow, Bisq) are <strong>protected by default</strong>. Support staff cannot access your private keys unless you explicitly grant access.</p>',
'</div>',
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
'<li>Our SSH key is added only to that restricted account</li>',
'<li>Wallet files are locked via access controls — not visible to support</li>',
'<li>You control if and when wallet access is granted (time-limited)</li>',
'<li>All session events are logged for your audit</li>',
'</ol></div>',
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
'<p class="support-fine-print">You can revoke access at any time. Wallet files are protected unless you unlock them.</p>',
'</div>',
].join("");
document.getElementById("btn-support-enable").addEventListener("click", enableSupport); document.getElementById("btn-support-enable").addEventListener("click", enableSupport);
} }
function renderSupportActive() { function renderSupportActive(status) {
var ip = _cachedExternalIp || "loading…"; var ip = _cachedExternalIp || "loading…";
$supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big support-active-icon">🔓</div><h3 class="support-heading support-active-heading">Support Access is Active</h3><p class="support-active-note">Sovran Systems can currently connect to your machine via SSH.</p><div class="support-info-box support-active-box"><div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div><div class="support-info-row"><span class="support-info-label">Duration</span><span class="support-info-value" id="support-timer">…</span></div></div><button class="btn support-btn-disable" id="btn-support-disable">End Support Session</button><p class="support-fine-print">This will remove the SSH key immediately</p></div>'; var walletProtected = status && status.wallet_protected;
var walletUnlocked = status && status.wallet_unlocked;
var unlockUntil = status && status.wallet_unlocked_until_human ? status.wallet_unlocked_until_human : "";
var protectedPaths = (status && status.protected_paths && status.protected_paths.length)
? status.protected_paths : [];
var walletSection;
if (walletProtected) {
if (walletUnlocked) {
walletSection = [
'<div class="support-wallet-box support-wallet-unlocked">',
'<div class="support-wallet-header"><span class="support-wallet-icon">🔓</span><span class="support-wallet-title">Wallet Access: UNLOCKED</span></div>',
'<p class="support-wallet-desc">You have granted support temporary access to wallet files' + (unlockUntil ? ' until <strong>' + escHtml(unlockUntil) + '</strong>' : '') + '.</p>',
'<button class="btn support-btn-wallet-lock" id="btn-wallet-lock">Re-lock Wallet Now</button>',
'</div>',
].join("");
} else {
var pathList = protectedPaths.length
? '<ul class="support-wallet-paths">' + protectedPaths.map(function(p){ return '<li>' + escHtml(p) + '</li>'; }).join("") + '</ul>'
: '';
walletSection = [
'<div class="support-wallet-box support-wallet-protected">',
'<div class="support-wallet-header"><span class="support-wallet-icon">🔒</span><span class="support-wallet-title">Wallet Files: Protected</span></div>',
'<p class="support-wallet-desc">Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.</p>',
pathList,
'<div class="support-wallet-unlock-row">',
'<select id="wallet-unlock-duration" class="support-unlock-select">',
'<option value="3600">1 hour</option>',
'<option value="1800">30 minutes</option>',
'<option value="7200">2 hours</option>',
'</select>',
'<button class="btn support-btn-wallet-unlock" id="btn-wallet-unlock">Grant Wallet Access</button>',
'</div>',
'</div>',
].join("");
}
} else {
walletSection = [
'<div class="support-wallet-box support-wallet-warning">',
'<div class="support-wallet-header"><span class="support-wallet-icon">⚠️</span><span class="support-wallet-title">Wallet Protection Unavailable</span></div>',
'<p class="support-wallet-desc">The restricted support user could not be created. Support is running with root access — wallet files may be accessible. End the session if you are concerned.</p>',
'</div>',
].join("");
}
$supportBody.innerHTML = [
'<div class="support-section">',
'<div class="support-icon-big support-active-icon">🔓</div>',
'<h3 class="support-heading support-active-heading">Support Access is Active</h3>',
'<p class="support-active-note">Sovran Systems can currently connect to your machine via SSH.</p>',
'<div class="support-info-box support-active-box">',
'<div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div>',
'<div class="support-info-row"><span class="support-info-label">Duration</span><span class="support-info-value" id="support-timer">…</span></div>',
'</div>',
walletSection,
'<button class="btn support-btn-disable" id="btn-support-disable">End Support Session</button>',
'<p class="support-fine-print">This will remove the SSH key and revoke all wallet access immediately.</p>',
'<button class="btn support-btn-auditlog" id="btn-support-audit">View Audit Log</button>',
'</div>',
'<div id="support-audit-container" class="support-audit-container" style="display:none;"></div>',
].join("");
document.getElementById("btn-support-disable").addEventListener("click", disableSupport); document.getElementById("btn-support-disable").addEventListener("click", disableSupport);
document.getElementById("btn-support-audit").addEventListener("click", toggleAuditLog);
if (walletProtected && !walletUnlocked) {
document.getElementById("btn-wallet-unlock").addEventListener("click", walletUnlock);
}
if (walletProtected && walletUnlocked) {
document.getElementById("btn-wallet-lock").addEventListener("click", walletLock);
}
startSupportTimer(); startSupportTimer();
if (walletUnlocked && status.wallet_unlocked_until) {
startWalletUnlockTimer(status.wallet_unlocked_until);
}
} }
function renderSupportRemoved(verified) { function renderSupportRemoved(verified) {
stopSupportTimer(); stopSupportTimer();
stopWalletUnlockTimer();
var icon = verified ? "✅" : "⚠️"; var icon = verified ? "✅" : "⚠️";
var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone."; var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone.";
var vclass = verified ? "verified-gone" : "verify-warning"; var vclass = verified ? "verified-gone" : "verify-warning";
@@ -609,8 +707,9 @@ async function enableSupport() {
try { try {
await apiFetch("/api/support/enable", { method: "POST" }); await apiFetch("/api/support/enable", { method: "POST" });
var status = await apiFetch("/api/support/status"); var status = await apiFetch("/api/support/status");
_supportStatus = status;
_supportEnabledAt = status.enabled_at; _supportEnabledAt = status.enabled_at;
renderSupportActive(); renderSupportActive(status);
} catch (err) { } catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
alert("Failed to enable support access. Please try again."); alert("Failed to enable support access. Please try again.");
@@ -629,6 +728,63 @@ async function disableSupport() {
} }
} }
async function walletUnlock() {
var btn = document.getElementById("btn-wallet-unlock");
var sel = document.getElementById("wallet-unlock-duration");
var duration = sel ? parseInt(sel.value, 10) : 3600;
if (btn) { btn.disabled = true; btn.textContent = "Unlocking…"; }
try {
var result = await apiFetch("/api/support/wallet-unlock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ duration: duration }),
});
var status = await apiFetch("/api/support/status");
_supportStatus = status;
renderSupportActive(status);
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "Grant Wallet Access"; }
alert("Failed to unlock wallet access: " + (err.message || "Unknown error"));
}
}
async function walletLock() {
var btn = document.getElementById("btn-wallet-lock");
if (btn) { btn.disabled = true; btn.textContent = "Locking…"; }
try {
await apiFetch("/api/support/wallet-lock", { method: "POST" });
var status = await apiFetch("/api/support/status");
_supportStatus = status;
renderSupportActive(status);
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "Re-lock Wallet Now"; }
alert("Failed to re-lock wallet: " + (err.message || "Unknown error"));
}
}
async function toggleAuditLog() {
var container = document.getElementById("support-audit-container");
if (!container) return;
if (container.style.display !== "none") {
container.style.display = "none";
return;
}
container.style.display = "block";
container.innerHTML = '<p class="creds-loading">Loading audit log…</p>';
try {
var data = await apiFetch("/api/support/audit-log");
if (!data.entries || data.entries.length === 0) {
container.innerHTML = '<p class="support-audit-empty">No audit events recorded yet.</p>';
} else {
container.innerHTML = '<div class="support-audit-log">' +
data.entries.map(function(e) { return '<div class="support-audit-entry">' + escHtml(e) + '</div>'; }).join("") +
'</div>';
}
} catch (err) {
container.innerHTML = '<p class="creds-empty">Could not load audit log.</p>';
}
}
function startSupportTimer() { function startSupportTimer() {
stopSupportTimer(); stopSupportTimer();
updateSupportTimer(); updateSupportTimer();
@@ -646,9 +802,28 @@ function updateSupportTimer() {
el.textContent = formatDuration(Math.max(0, elapsed)); el.textContent = formatDuration(Math.max(0, elapsed));
} }
function startWalletUnlockTimer(expiresAt) {
stopWalletUnlockTimer();
_walletUnlockTimerInt = setInterval(function() {
if (Date.now() / 1000 >= expiresAt) {
stopWalletUnlockTimer();
// Refresh the support modal to show re-locked state
apiFetch("/api/support/status").then(function(status) {
_supportStatus = status;
renderSupportActive(status);
}).catch(function() {});
}
}, 10000);
}
function stopWalletUnlockTimer() {
if (_walletUnlockTimerInt) { clearInterval(_walletUnlockTimerInt); _walletUnlockTimerInt = null; }
}
function closeSupportModal() { function closeSupportModal() {
if ($supportModal) $supportModal.classList.remove("open"); if ($supportModal) $supportModal.classList.remove("open");
stopSupportTimer(); stopSupportTimer();
stopWalletUnlockTimer();
} }
// ── Update modal ────────────────────────────────────────────────── // ── Update modal ──────────────────────────────────────────────────

View File

@@ -1316,7 +1316,168 @@ button.btn-reboot:hover:not(:disabled) {
background-color: #5a5c72; background-color: #5a5c72;
} }
/* ── Feature Manager ─────────────────────────────────────────────── */ /* ── Tech Support — wallet protection ────────────────────────────── */
.support-wallet-box {
border-radius: 10px;
border: 1px solid var(--border-color);
padding: 14px 18px;
margin: 0 auto 20px;
max-width: 460px;
text-align: left;
}
.support-wallet-protected {
border-color: var(--green);
background-color: rgba(30, 150, 96, 0.08);
}
.support-wallet-unlocked {
border-color: var(--yellow);
background-color: rgba(230, 180, 0, 0.08);
}
.support-wallet-warning {
border-color: var(--red);
background-color: rgba(220, 38, 38, 0.08);
}
.support-wallet-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.support-wallet-icon {
font-size: 1.2rem;
}
.support-wallet-title {
font-size: 0.88rem;
font-weight: 700;
color: var(--text-primary);
}
.support-wallet-desc {
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.55;
margin-bottom: 10px;
}
.support-wallet-paths {
list-style: none;
padding: 0;
margin: 0 0 12px;
}
.support-wallet-paths li {
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
font-size: 0.78rem;
color: var(--text-dim);
padding: 2px 0;
}
.support-wallet-paths li::before {
content: "🗂 ";
}
.support-wallet-unlock-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.support-unlock-select {
background-color: #1c1c2e;
border: 1px solid var(--border-color);
color: var(--text-primary);
border-radius: 6px;
padding: 6px 10px;
font-size: 0.85rem;
}
.support-btn-wallet-unlock {
background-color: var(--yellow);
color: #111;
padding: 7px 18px;
font-size: 0.85rem;
font-weight: 700;
border-radius: 8px;
}
.support-btn-wallet-unlock:hover:not(:disabled) {
background-color: #c9a200;
}
.support-btn-wallet-lock {
background-color: var(--green);
color: #fff;
padding: 7px 18px;
font-size: 0.85rem;
font-weight: 700;
border-radius: 8px;
}
.support-btn-wallet-lock:hover:not(:disabled) {
background-color: #1a8557;
}
.support-btn-auditlog {
background-color: transparent;
color: var(--accent-color);
border: 1px solid var(--accent-color);
padding: 6px 18px;
font-size: 0.82rem;
font-weight: 600;
border-radius: 8px;
margin-top: 10px;
}
.support-btn-auditlog:hover:not(:disabled) {
background-color: rgba(100, 130, 220, 0.12);
}
/* ── Tech Support — audit log ────────────────────────────────────── */
.support-audit-container {
margin: 0 auto;
max-width: 520px;
padding: 0 4px 12px;
}
.support-audit-log {
background-color: #0d0d1a;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 14px;
max-height: 220px;
overflow-y: auto;
}
.support-audit-entry {
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
font-size: 0.76rem;
color: var(--text-secondary);
line-height: 1.7;
border-bottom: 1px solid #1e1e30;
padding: 2px 0;
}
.support-audit-entry:last-child {
border-bottom: none;
}
.support-audit-empty {
font-size: 0.82rem;
color: var(--text-dim);
text-align: center;
padding: 12px 0;
}
.feature-manager-section { .feature-manager-section {
margin-bottom: 32px; margin-bottom: 32px;