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:
committed by
GitHub
parent
87529b0d3f
commit
dd3a20ed00
@@ -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")
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user