Merge pull request #42 from naturallaw777/copilot/feature-tech-support-tile

[WIP] Add tech support tile with user wallet privacy control
This commit is contained in:
Sovran_Systems
2026-04-03 20:04:46 -05:00
committed by GitHub
3 changed files with 668 additions and 29 deletions

View File

@@ -7,9 +7,11 @@ import base64
import hashlib
import json
import os
import pwd
import re
import socket
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
@@ -63,6 +65,30 @@ SOVRAN_SUPPORT_PUBKEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFLY8hjksaWzQmIQVut
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 = [
("infrastructure", "Infrastructure"),
("bitcoin-base", "Bitcoin Base"),
@@ -714,7 +740,15 @@ def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
# ── Tech Support helpers ──────────────────────────────────────────
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:
with open(AUTHORIZED_KEYS, "r") as f:
content = f.read()
@@ -732,48 +766,222 @@ def _get_support_session_info() -> dict:
return {}
def _enable_support() -> bool:
"""Add the Sovran support public key to root's authorized_keys."""
def _log_support_audit(event: str, details: str = "") -> None:
"""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:
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
with open(SUPPORT_KEY_FILE, "w") as f:
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
os.chmod(SUPPORT_KEY_FILE, 0o600)
# Append to authorized_keys if not already present
existing = ""
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:
with open(AUTHORIZED_KEYS, "r") as f:
existing = f.read()
result = subprocess.run(
["setfacl", "-R", "-m", f"u:{SUPPORT_USER}:---", path],
capture_output=True, timeout=15,
)
if result.returncode != 0:
success = False
except FileNotFoundError:
pass
# setfacl not installed
return False
except Exception:
success = False
return success
if SUPPORT_KEY_COMMENT not in existing:
with open(AUTHORIZED_KEYS, "a") as f:
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(AUTHORIZED_KEYS, 0o600)
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:
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
os.chmod(SUPPORT_KEY_FILE, 0o600)
existing = ""
try:
with open(AUTHORIZED_KEYS, "r") as f:
existing = f.read()
except FileNotFoundError:
pass
if SUPPORT_KEY_COMMENT not in existing:
with open(AUTHORIZED_KEYS, "a") as f:
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
os.chmod(AUTHORIZED_KEYS, 0o600)
acl_applied = _apply_wallet_acls() if use_restricted_user else False
wallet_paths = _get_existing_wallet_paths()
# Write session metadata
import time
session_info = {
"enabled_at": time.time(),
"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)
with open(SUPPORT_STATUS_FILE, "w") as 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
except Exception:
return False
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:
# 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:
with open(AUTHORIZED_KEYS, "r") as f:
lines = f.readlines()
@@ -790,19 +998,35 @@ def _disable_support() -> bool:
except FileNotFoundError:
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
try:
os.remove(SUPPORT_STATUS_FILE)
except FileNotFoundError:
pass
_log_support_audit("SUPPORT_DISABLED")
return True
except Exception:
return False
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:
with open(AUTHORIZED_KEYS, "r") as f:
content = f.read()
@@ -1164,10 +1388,18 @@ async def api_support_status():
loop = asyncio.get_event_loop()
active = await loop.run_in_executor(None, _is_support_active)
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 {
"active": active,
"enabled_at": session.get("enabled_at"),
"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"}
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 ─────────────────────────────────────
@app.get("/api/features")

View File

@@ -42,8 +42,10 @@ let _updatePollTimer = null;
let _updateLogOffset = 0;
let _serverWasDown = false;
let _updateFinished = false;
let _supportTimerInt = null;
let _supportEnabledAt = null;
let _supportTimerInt = null;
let _supportEnabledAt = null;
let _supportStatus = null; // last fetched /api/support/status payload
let _walletUnlockTimerInt = null;
let _cachedExternalIp = null;
// Feature Manager state
@@ -572,7 +574,8 @@ async function openSupportModal() {
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
try {
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(); }
} catch (err) {
$supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>';
@@ -582,19 +585,114 @@ async function openSupportModal() {
function renderSupportInactive() {
stopSupportTimer();
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);
}
function renderSupportActive() {
function renderSupportActive(status) {
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-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();
if (walletUnlocked && status.wallet_unlocked_until) {
startWalletUnlockTimer(status.wallet_unlocked_until);
}
}
function renderSupportRemoved(verified) {
stopSupportTimer();
stopWalletUnlockTimer();
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 vclass = verified ? "verified-gone" : "verify-warning";
@@ -609,8 +707,9 @@ async function enableSupport() {
try {
await apiFetch("/api/support/enable", { method: "POST" });
var status = await apiFetch("/api/support/status");
_supportStatus = status;
_supportEnabledAt = status.enabled_at;
renderSupportActive();
renderSupportActive(status);
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
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() {
stopSupportTimer();
updateSupportTimer();
@@ -646,9 +802,28 @@ function updateSupportTimer() {
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() {
if ($supportModal) $supportModal.classList.remove("open");
stopSupportTimer();
stopWalletUnlockTimer();
}
// ── Update modal ──────────────────────────────────────────────────

View File

@@ -1316,7 +1316,168 @@ button.btn-reboot:hover:not(:disabled) {
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 {
margin-bottom: 32px;