Implement security overhaul: remove seal/legacy system, add Security modal and random passwords
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6e7593c4-f741-4ddc-9bce-8c558a4af014 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
477d265de8
commit
2fae4ccc79
@@ -61,10 +61,9 @@ REBOOT_COMMAND = ["reboot"]
|
|||||||
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
|
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
|
||||||
AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
|
AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
|
||||||
|
|
||||||
# ── Legacy security check constants ──────────────────────────────
|
# ── Security constants ────────────────────────────────────────────
|
||||||
|
|
||||||
SECURITY_STATUS_FILE = "/var/lib/sovran/security-status"
|
SECURITY_BANNER_DISMISSED_FLAG = "/var/lib/sovran/security-banner-dismissed"
|
||||||
SECURITY_WARNING_FILE = "/var/lib/sovran/security-warning"
|
|
||||||
|
|
||||||
# ── Tech Support constants ────────────────────────────────────────
|
# ── Tech Support constants ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -2927,112 +2926,190 @@ async def api_domains_check(req: DomainCheckRequest):
|
|||||||
return {"domains": list(check_results)}
|
return {"domains": list(check_results)}
|
||||||
|
|
||||||
|
|
||||||
# ── Legacy security check ─────────────────────────────────────────
|
# ── Security endpoints ────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/security/status")
|
|
||||||
async def api_security_status():
|
|
||||||
"""Return the legacy security status and warning message, if present.
|
|
||||||
|
|
||||||
Reads /var/lib/sovran/security-status and /var/lib/sovran/security-warning.
|
@app.get("/api/security/banner-status")
|
||||||
Returns {"status": "legacy", "warning": "<message>"} for legacy machines,
|
async def api_security_banner_status():
|
||||||
or {"status": "ok", "warning": ""} when the files are absent.
|
"""Return whether the first-login security banner should be shown.
|
||||||
|
|
||||||
|
The banner is shown only when:
|
||||||
|
1. The machine has completed onboarding (ONBOARDING_FLAG exists).
|
||||||
|
2. The banner has not been dismissed yet (SECURITY_BANNER_DISMISSED_FLAG absent).
|
||||||
|
|
||||||
|
Legacy machines (no ONBOARDING_FLAG) will never see the banner.
|
||||||
"""
|
"""
|
||||||
|
onboarded = os.path.isfile(ONBOARDING_FLAG)
|
||||||
|
dismissed = os.path.isfile(SECURITY_BANNER_DISMISSED_FLAG)
|
||||||
|
return {"show": onboarded and not dismissed}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/security/banner-dismiss")
|
||||||
|
async def api_security_banner_dismiss():
|
||||||
|
"""Mark the first-login security banner as dismissed."""
|
||||||
try:
|
try:
|
||||||
with open(SECURITY_STATUS_FILE, "r") as f:
|
os.makedirs(os.path.dirname(SECURITY_BANNER_DISMISSED_FLAG), exist_ok=True)
|
||||||
status = f.read().strip()
|
open(SECURITY_BANNER_DISMISSED_FLAG, "w").close()
|
||||||
except FileNotFoundError:
|
except OSError as exc:
|
||||||
status = "ok"
|
raise HTTPException(status_code=500, detail=f"Could not write dismiss flag: {exc}")
|
||||||
|
return {"ok": True}
|
||||||
warning = ""
|
|
||||||
if status == "legacy":
|
|
||||||
try:
|
|
||||||
with open(SECURITY_WARNING_FILE, "r") as f:
|
|
||||||
warning = f.read().strip()
|
|
||||||
except FileNotFoundError:
|
|
||||||
warning = (
|
|
||||||
"This machine was manufactured before the factory-seal process. "
|
|
||||||
"The default system password may be known to the factory. "
|
|
||||||
"Please change your system and application passwords immediately."
|
|
||||||
)
|
|
||||||
elif status == "unsealed":
|
|
||||||
try:
|
|
||||||
with open(SECURITY_WARNING_FILE, "r") as f:
|
|
||||||
warning = f.read().strip()
|
|
||||||
except FileNotFoundError:
|
|
||||||
warning = (
|
|
||||||
"This machine was set up without the factory seal process. "
|
|
||||||
"Factory test data — including SSH keys, database contents, and wallet information — "
|
|
||||||
"may still be present on this system."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": status, "warning": warning}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_free_password_default() -> bool:
|
@app.post("/api/security/reset")
|
||||||
"""Check /etc/shadow directly to see if 'free' still has a factory default password.
|
async def api_security_reset():
|
||||||
|
"""Perform a full security reset.
|
||||||
|
|
||||||
Hashes each known factory default against the current shadow hash so that
|
Wipes secrets, LND wallet, SSH keys, drops databases, removes app configs,
|
||||||
password changes made via GNOME, passwd, or any method other than the Hub
|
clears Vaultwarden data, removes the onboarding-complete flag so onboarding
|
||||||
are detected correctly.
|
re-runs, and reboots the system.
|
||||||
"""
|
"""
|
||||||
import subprocess
|
wipe_paths = [
|
||||||
import re as _re
|
"/var/lib/secrets",
|
||||||
|
"/var/lib/sovran",
|
||||||
|
"/root/.ssh",
|
||||||
|
"/home/free/.ssh",
|
||||||
|
"/var/lib/lnd",
|
||||||
|
"/var/lib/vaultwarden",
|
||||||
|
]
|
||||||
|
|
||||||
FACTORY_DEFAULTS = ["free", "gosovransystems"]
|
errors: list[str] = []
|
||||||
# Map shadow algorithm IDs to openssl passwd flags (SHA-512 and SHA-256 only,
|
|
||||||
# matching the shell-script counterpart in factory-seal.nix)
|
# Wipe filesystem paths
|
||||||
ALGO_FLAGS = {"6": "-6", "5": "-5"}
|
for path in wipe_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
try:
|
try:
|
||||||
with open("/etc/shadow", "r") as f:
|
import shutil as _shutil
|
||||||
for line in f:
|
_shutil.rmtree(path, ignore_errors=True)
|
||||||
parts = line.strip().split(":")
|
except Exception as exc:
|
||||||
if parts[0] == "free" and len(parts) > 1:
|
errors.append(f"rm {path}: {exc}")
|
||||||
current_hash = parts[1]
|
|
||||||
if not current_hash or current_hash in ("!", "*", "!!"):
|
# Drop PostgreSQL databases (matrix-synapse, nextcloud, etc.)
|
||||||
return True # locked/no password — treat as default
|
|
||||||
# Parse hash: $id$[rounds=N$]salt$hash
|
|
||||||
hash_fields = current_hash.split("$")
|
|
||||||
# hash_fields: ["", id, salt_or_rounds, ...]
|
|
||||||
if len(hash_fields) < 4:
|
|
||||||
return True # unrecognized format — assume default for safety
|
|
||||||
algo_id = hash_fields[1]
|
|
||||||
salt_field = hash_fields[2]
|
|
||||||
if algo_id not in ALGO_FLAGS:
|
|
||||||
return True # unrecognized algorithm — assume default for safety
|
|
||||||
if salt_field.startswith("rounds="):
|
|
||||||
return True # can't extract real salt simply — assume default for safety
|
|
||||||
# Validate salt contains only safe characters (alphanumeric, '.', '/', '-', '_')
|
|
||||||
# to guard against unexpected shadow file content before passing to subprocess
|
|
||||||
if not _re.fullmatch(r"[A-Za-z0-9./\-_]+", salt_field):
|
|
||||||
return True # unexpected salt format — assume default for safety
|
|
||||||
openssl_flag = ALGO_FLAGS[algo_id]
|
|
||||||
for default_pw in FACTORY_DEFAULTS:
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["openssl", "passwd", openssl_flag, "-salt", salt_field, default_pw],
|
["sudo", "-u", "postgres", "psql", "-c",
|
||||||
capture_output=True,
|
"SELECT datname FROM pg_database WHERE datistemplate = false AND datname NOT IN ('postgres')"],
|
||||||
text=True,
|
capture_output=True, text=True, timeout=30,
|
||||||
timeout=5,
|
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip() == current_hash:
|
if result.returncode == 0:
|
||||||
return True
|
for line in result.stdout.splitlines():
|
||||||
except Exception:
|
dbname = line.strip()
|
||||||
return True # if openssl fails, assume default for safety
|
if dbname and not dbname.startswith("-") and dbname != "datname":
|
||||||
return False
|
subprocess.run(
|
||||||
except (FileNotFoundError, PermissionError):
|
["sudo", "-u", "postgres", "dropdb", "--if-exists", dbname],
|
||||||
pass
|
capture_output=True, text=True, timeout=30,
|
||||||
return True # if /etc/shadow is unreadable, assume default for safety
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"postgres wipe: {exc}")
|
||||||
|
|
||||||
|
# Drop MariaDB databases
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["mysql", "-u", "root", "-e",
|
||||||
|
"SHOW DATABASES"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
skip = {"Database", "information_schema", "performance_schema", "mysql", "sys"}
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
dbname = line.strip()
|
||||||
|
if dbname and dbname not in skip:
|
||||||
|
subprocess.run(
|
||||||
|
["mysql", "-u", "root", "-e", f"DROP DATABASE IF EXISTS `{dbname}`"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"mariadb wipe: {exc}")
|
||||||
|
|
||||||
|
# Reboot
|
||||||
|
try:
|
||||||
|
subprocess.Popen(["systemctl", "reboot"])
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Reboot failed: {exc}")
|
||||||
|
|
||||||
|
return {"ok": True, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/security/password-is-default")
|
@app.post("/api/security/verify-integrity")
|
||||||
async def api_password_is_default():
|
async def api_security_verify_integrity():
|
||||||
"""Check if the free account password is still the factory default.
|
"""Verify system integrity using NixOS reproducibility features.
|
||||||
|
|
||||||
Uses /etc/shadow as the authoritative source so that password changes made
|
Reads /etc/nixos/flake.lock for the current commit hash, runs
|
||||||
via GNOME Settings, the passwd command, or any other method are detected
|
`nix store verify --all` to check binary integrity, and compares the
|
||||||
correctly — not just changes made through the Hub or change-free-password.
|
current running system to what the flake says it should be.
|
||||||
"""
|
"""
|
||||||
return {"is_default": _is_free_password_default()}
|
import json as _json
|
||||||
|
|
||||||
|
# ── 1. Read flake commit ──────────────────────────────────────
|
||||||
|
flake_commit = ""
|
||||||
|
repo_url = ""
|
||||||
|
try:
|
||||||
|
with open("/etc/nixos/flake.lock", "r") as f:
|
||||||
|
lock_data = _json.load(f)
|
||||||
|
# The root node's inputs → find the first locked input with a rev
|
||||||
|
nodes = lock_data.get("nodes", {})
|
||||||
|
root_inputs = nodes.get("root", {}).get("inputs", {})
|
||||||
|
for input_name in root_inputs.values():
|
||||||
|
node_name = input_name if isinstance(input_name, str) else (input_name[0] if input_name else "")
|
||||||
|
node = nodes.get(node_name, {})
|
||||||
|
locked = node.get("locked", {})
|
||||||
|
if locked.get("rev"):
|
||||||
|
flake_commit = locked["rev"]
|
||||||
|
# Build repo URL from locked info if available
|
||||||
|
owner = locked.get("owner", "")
|
||||||
|
repo = locked.get("repo", "")
|
||||||
|
if owner and repo:
|
||||||
|
repo_url = f"https://github.com/{owner}/{repo}/commit/{flake_commit}"
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── 2. Verify Nix store ───────────────────────────────────────
|
||||||
|
store_verified = False
|
||||||
|
store_errors: list[str] = []
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["nix", "store", "verify", "--all", "--no-trust"],
|
||||||
|
capture_output=True, text=True, timeout=300,
|
||||||
|
)
|
||||||
|
combined = (result.stdout + result.stderr).strip()
|
||||||
|
if result.returncode == 0:
|
||||||
|
store_verified = True
|
||||||
|
else:
|
||||||
|
store_errors = [line for line in combined.splitlines() if line.strip()]
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
store_errors = ["Verification timed out after 5 minutes."]
|
||||||
|
except Exception as exc:
|
||||||
|
store_errors = [str(exc)]
|
||||||
|
|
||||||
|
# ── 3. Compare running system to flake build ──────────────────
|
||||||
|
system_matches = False
|
||||||
|
current_system_path = ""
|
||||||
|
expected_system_path = ""
|
||||||
|
try:
|
||||||
|
current_system_path = os.path.realpath("/run/current-system")
|
||||||
|
result = subprocess.run(
|
||||||
|
["nixos-rebuild", "build", "--flake", "/etc/nixos", "--no-build-output"],
|
||||||
|
capture_output=True, text=True, timeout=600,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# nixos-rebuild build creates ./result symlink in cwd
|
||||||
|
result_path = os.path.realpath("result")
|
||||||
|
expected_system_path = result_path
|
||||||
|
system_matches = (current_system_path == expected_system_path)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
expected_system_path = "Build timed out"
|
||||||
|
except Exception as exc:
|
||||||
|
expected_system_path = str(exc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"flake_commit": flake_commit,
|
||||||
|
"repo_url": repo_url,
|
||||||
|
"store_verified": store_verified,
|
||||||
|
"store_errors": store_errors,
|
||||||
|
"system_matches": system_matches,
|
||||||
|
"current_system_path": current_system_path,
|
||||||
|
"expected_system_path": expected_system_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── System password change ────────────────────────────────────────
|
# ── System password change ────────────────────────────────────────
|
||||||
@@ -3051,8 +3128,6 @@ async def api_change_password(req: ChangePasswordRequest):
|
|||||||
|
|
||||||
Updates /etc/shadow via chpasswd and writes the new password to
|
Updates /etc/shadow via chpasswd and writes the new password to
|
||||||
/var/lib/secrets/free-password so the Hub credentials view stays in sync.
|
/var/lib/secrets/free-password so the Hub credentials view stays in sync.
|
||||||
Also clears the legacy security-status and security-warning files so the
|
|
||||||
security banner disappears after a successful change.
|
|
||||||
"""
|
"""
|
||||||
if not req.new_password:
|
if not req.new_password:
|
||||||
raise HTTPException(status_code=400, detail="New password must not be empty.")
|
raise HTTPException(status_code=400, detail="New password must not be empty.")
|
||||||
@@ -3098,22 +3173,6 @@ async def api_change_password(req: ChangePasswordRequest):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to write secrets file: {exc}")
|
raise HTTPException(status_code=500, detail=f"Failed to write secrets file: {exc}")
|
||||||
|
|
||||||
# Clear legacy security status so the warning banner is removed — but only
|
|
||||||
# for "legacy" machines (pre-seal era). For "unsealed" machines, changing
|
|
||||||
# passwords is not enough; the factory residue (SSH keys, wallet data,
|
|
||||||
# databases) remains until a proper re-seal or re-install is performed.
|
|
||||||
try:
|
|
||||||
with open(SECURITY_STATUS_FILE, "r") as f:
|
|
||||||
current_status = f.read().strip()
|
|
||||||
if current_status == "legacy":
|
|
||||||
os.remove(SECURITY_STATUS_FILE)
|
|
||||||
try:
|
|
||||||
os.remove(SECURITY_WARNING_FILE)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
except (FileNotFoundError, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,270 @@
|
|||||||
|
/* ── Security Modal sections ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.security-section {
|
||||||
|
padding: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-section-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Security Reset warning box ──────────────────────────────────── */
|
||||||
|
|
||||||
|
.security-warning-box {
|
||||||
|
background-color: rgba(180, 40, 40, 0.10);
|
||||||
|
border-left: 3px solid #c94040;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-warning-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-warning-list {
|
||||||
|
margin: 6px 0 6px 20px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-erase-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-erase-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-erase-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--input-bg, var(--card-color));
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-reset-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #c94040;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background-color: #a83030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-status-msg {
|
||||||
|
font-size: 0.83rem;
|
||||||
|
margin-top: 10px;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-status-info { color: var(--text-secondary); }
|
||||||
|
.security-status-ok { color: #2ec27e; }
|
||||||
|
.security-status-error { color: #e05252; }
|
||||||
|
|
||||||
|
/* ── Verify System Integrity ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.security-verify-list {
|
||||||
|
margin: 8px 0 10px 20px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-loading {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-result-card {
|
||||||
|
background: var(--card-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 130px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-value {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-mono {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-link {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--accent-color, #2ec27e);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.security-verify-badge {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-pass {
|
||||||
|
background-color: rgba(46, 194, 126, 0.15);
|
||||||
|
color: #2ec27e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-fail {
|
||||||
|
background-color: rgba(224, 82, 82, 0.15);
|
||||||
|
color: #e05252;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-errors {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--card-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-path-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-verify-path-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── First-login security banner ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.security-first-login-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background-color: rgba(46, 100, 194, 0.12);
|
||||||
|
border-bottom: 2px solid rgba(46, 100, 194, 0.35);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-banner-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-banner-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-banner-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-banner-dismiss {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2px 7px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-banner-dismiss:hover {
|
||||||
|
background-color: rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Legacy security inline warning banner ───────────────────────── */
|
/* ── Legacy security inline warning banner ───────────────────────── */
|
||||||
|
|
||||||
.security-inline-banner {
|
.security-inline-banner {
|
||||||
|
|||||||
@@ -70,6 +70,47 @@ async function doUpgradeToServer() {
|
|||||||
|
|
||||||
if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", doUpgradeToServer);
|
if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", doUpgradeToServer);
|
||||||
|
|
||||||
|
// ── First-login security banner ───────────────────────────────────
|
||||||
|
|
||||||
|
function showSecurityBanner() {
|
||||||
|
var existing = document.getElementById("security-first-login-banner");
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
var banner = document.createElement("div");
|
||||||
|
banner.id = "security-first-login-banner";
|
||||||
|
banner.className = "security-first-login-banner";
|
||||||
|
banner.innerHTML =
|
||||||
|
'<div class="security-banner-content">' +
|
||||||
|
'<span class="security-banner-icon">\uD83D\uDEE1</span>' +
|
||||||
|
'<span class="security-banner-text">' +
|
||||||
|
'<strong>Did someone else set up this machine?</strong> ' +
|
||||||
|
'If this computer was pre-configured by another person, go to ' +
|
||||||
|
'<strong>Menu \u2192 Security</strong> to reset all passwords and keys. ' +
|
||||||
|
'This ensures only you have access.' +
|
||||||
|
'</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
|
||||||
|
|
||||||
|
var mainContent = document.querySelector(".main-content");
|
||||||
|
if (mainContent) {
|
||||||
|
mainContent.insertAdjacentElement("beforebegin", banner);
|
||||||
|
} else {
|
||||||
|
document.body.insertAdjacentElement("afterbegin", banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dismissBtn = document.getElementById("security-banner-dismiss-btn");
|
||||||
|
if (dismissBtn) {
|
||||||
|
dismissBtn.addEventListener("click", async function() {
|
||||||
|
banner.remove();
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/security/banner-dismiss", { method: "POST" });
|
||||||
|
} catch (_) {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -84,8 +125,16 @@ async function init() {
|
|||||||
// If we can't reach the endpoint, continue to normal dashboard
|
// If we can't reach the endpoint, continue to normal dashboard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for legacy machine security warning
|
// Show first-login security banner only for machines that went through onboarding
|
||||||
await checkLegacySecurity();
|
// (legacy machines without the onboarding flag will never see this)
|
||||||
|
try {
|
||||||
|
var bannerData = await apiFetch("/api/security/banner-status");
|
||||||
|
if (bannerData && bannerData.show) {
|
||||||
|
showSecurityBanner();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Non-fatal — silently ignore
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var cfg = await apiFetch("/api/config");
|
var cfg = await apiFetch("/api/config");
|
||||||
|
|||||||
@@ -599,29 +599,9 @@ function renderAutolaunchToggle(enabled) {
|
|||||||
var section = document.createElement("div");
|
var section = document.createElement("div");
|
||||||
section.className = "category-section autolaunch-section";
|
section.className = "category-section autolaunch-section";
|
||||||
|
|
||||||
var securityBanner = "";
|
|
||||||
if (_securityIsLegacy) {
|
|
||||||
var msg = _securityWarningMessage || "Your system may have factory default passwords. Please change your passwords to secure your system.";
|
|
||||||
var linkText, linkAction;
|
|
||||||
if (_securityStatus === "unsealed") {
|
|
||||||
linkText = "Contact Support";
|
|
||||||
linkAction = "openSupportModal(); return false;";
|
|
||||||
} else {
|
|
||||||
linkText = "Change Passwords";
|
|
||||||
linkAction = "openServiceDetailModal('root-password-setup.service', 'System Passwords', 'passwords'); return false;";
|
|
||||||
}
|
|
||||||
securityBanner =
|
|
||||||
'<div class="security-inline-banner">' +
|
|
||||||
'<span class="security-inline-icon">⚠</span>' +
|
|
||||||
'<span class="security-inline-text">' + msg + '</span>' +
|
|
||||||
'<a class="security-inline-link" href="#" onclick="' + linkAction + '">' + linkText + '</a>' +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
section.innerHTML =
|
section.innerHTML =
|
||||||
'<div class="section-header">Preferences</div>' +
|
'<div class="section-header">Preferences</div>' +
|
||||||
'<hr class="section-divider" />' +
|
'<hr class="section-divider" />' +
|
||||||
securityBanner +
|
|
||||||
'<div class="feature-card">' +
|
'<div class="feature-card">' +
|
||||||
'<div class="feature-card-top">' +
|
'<div class="feature-card-top">' +
|
||||||
'<div class="feature-card-info">' +
|
'<div class="feature-card-info">' +
|
||||||
|
|||||||
@@ -1,16 +1,201 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// ── Legacy security warning ───────────────────────────────────────
|
// ── Security Modal ────────────────────────────────────────────────
|
||||||
|
|
||||||
async function checkLegacySecurity() {
|
function openSecurityModal() {
|
||||||
try {
|
if ($supportModal) $supportModal.classList.add("open");
|
||||||
var data = await apiFetch("/api/security/status");
|
var title = document.getElementById("support-modal-title");
|
||||||
if (data && (data.status === "legacy" || data.status === "unsealed")) {
|
if (title) title.textContent = "\uD83D\uDEE1 Security";
|
||||||
_securityIsLegacy = true;
|
|
||||||
_securityStatus = data.status;
|
if ($supportBody) {
|
||||||
_securityWarningMessage = data.warning || "This machine may have a security issue. Please review your system security.";
|
$supportBody.innerHTML =
|
||||||
|
// ── Section A: Security Reset ──────────────────────────────
|
||||||
|
'<div class="security-section">' +
|
||||||
|
'<h3 class="security-section-title">Security Reset</h3>' +
|
||||||
|
'<p class="security-section-desc">' +
|
||||||
|
'Run this if you are using this physical computer for the first time <strong>AND</strong> ' +
|
||||||
|
'it was not set up by you. This will complete the security setup by resetting all passwords ' +
|
||||||
|
'and your Bitcoin Lightning Node\u2019s private keys.' +
|
||||||
|
'</p>' +
|
||||||
|
'<p class="security-section-desc">' +
|
||||||
|
'You can also run this if you wish to reset all your passwords and your Bitcoin Lightning ' +
|
||||||
|
'Node\u2019s private keys. If you have not transferred the Bitcoin out of this node and did ' +
|
||||||
|
'not back up the private keys, <strong>you will lose your Bitcoin.</strong>' +
|
||||||
|
'</p>' +
|
||||||
|
'<button class="btn btn-primary" id="security-reset-open-btn">Proceed with Security Reset</button>' +
|
||||||
|
'<div id="security-reset-confirm" style="display:none;margin-top:16px;">' +
|
||||||
|
'<div class="security-warning-box">' +
|
||||||
|
'<p class="security-warning-text">' +
|
||||||
|
'<strong>\u26A0\uFE0F This will permanently delete:</strong>' +
|
||||||
|
'</p>' +
|
||||||
|
'<ul class="security-warning-list">' +
|
||||||
|
'<li>All generated passwords and SSH keys</li>' +
|
||||||
|
'<li>LND wallet data (seed words, channels, macaroons)</li>' +
|
||||||
|
'<li>Application databases</li>' +
|
||||||
|
'<li>Vaultwarden data</li>' +
|
||||||
|
'</ul>' +
|
||||||
|
'<p class="security-warning-text">You will go through onboarding again. <strong>This cannot be undone.</strong></p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="security-erase-group">' +
|
||||||
|
'<label class="security-erase-label" for="security-erase-input">Type <strong>ERASE</strong> to confirm:</label>' +
|
||||||
|
'<input class="security-erase-input" type="text" id="security-erase-input" autocomplete="off" placeholder="ERASE" />' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="security-reset-actions">' +
|
||||||
|
'<button class="btn btn-close-modal" id="security-reset-cancel-btn">Cancel</button>' +
|
||||||
|
'<button class="btn btn-danger" id="security-reset-confirm-btn" disabled>Erase & Reset</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="security-reset-status" class="security-status-msg"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
|
||||||
|
'<hr class="security-divider" />' +
|
||||||
|
|
||||||
|
// ── Section B: Verify System Integrity ────────────────────
|
||||||
|
'<div class="security-section">' +
|
||||||
|
'<h3 class="security-section-title">Verify System Integrity</h3>' +
|
||||||
|
'<p class="security-section-desc">' +
|
||||||
|
'Your Sovran_SystemsOS is built with NixOS \u2014 a system designed for complete transparency ' +
|
||||||
|
'and reproducibility. Every piece of software on this machine is built from publicly auditable ' +
|
||||||
|
'source code and verified using cryptographic hashes.' +
|
||||||
|
'</p>' +
|
||||||
|
'<p class="security-section-desc">This verification confirms three things:</p>' +
|
||||||
|
'<ol class="security-verify-list">' +
|
||||||
|
'<li>' +
|
||||||
|
'<strong>Source Code Match</strong> \u2014 The system configuration on this machine matches ' +
|
||||||
|
'the exact commit published in the public repository. No hidden changes were added.' +
|
||||||
|
'</li>' +
|
||||||
|
'<li>' +
|
||||||
|
'<strong>Binary Integrity</strong> \u2014 Every installed package in the system store is ' +
|
||||||
|
'verified against its expected cryptographic hash. If any binary, library, or config file ' +
|
||||||
|
'was tampered with, it will be detected.' +
|
||||||
|
'</li>' +
|
||||||
|
'<li>' +
|
||||||
|
'<strong>Running System Match</strong> \u2014 The currently running system matches what the ' +
|
||||||
|
'configuration says it should be. No unauthorized modifications are active.' +
|
||||||
|
'</li>' +
|
||||||
|
'</ol>' +
|
||||||
|
'<p class="security-section-desc">' +
|
||||||
|
'In short: if this verification passes, you can be confident that the software running on ' +
|
||||||
|
'your machine is exactly what is published \u2014 nothing more, nothing less.' +
|
||||||
|
'</p>' +
|
||||||
|
'<button class="btn btn-primary" id="security-verify-btn">Verify Now</button>' +
|
||||||
|
'<div id="security-verify-results" style="display:none;margin-top:16px;"></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// ── Wire Security Reset flow
|
||||||
|
var resetOpenBtn = document.getElementById("security-reset-open-btn");
|
||||||
|
var resetConfirmDiv = document.getElementById("security-reset-confirm");
|
||||||
|
var eraseInput = document.getElementById("security-erase-input");
|
||||||
|
var resetConfirmBtn = document.getElementById("security-reset-confirm-btn");
|
||||||
|
var resetCancelBtn = document.getElementById("security-reset-cancel-btn");
|
||||||
|
var resetStatus = document.getElementById("security-reset-status");
|
||||||
|
|
||||||
|
if (resetOpenBtn) {
|
||||||
|
resetOpenBtn.addEventListener("click", function() {
|
||||||
|
resetOpenBtn.style.display = "none";
|
||||||
|
if (resetConfirmDiv) resetConfirmDiv.style.display = "";
|
||||||
|
if (eraseInput) eraseInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eraseInput && resetConfirmBtn) {
|
||||||
|
eraseInput.addEventListener("input", function() {
|
||||||
|
resetConfirmBtn.disabled = eraseInput.value.trim() !== "ERASE";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetCancelBtn) {
|
||||||
|
resetCancelBtn.addEventListener("click", function() {
|
||||||
|
if (resetConfirmDiv) resetConfirmDiv.style.display = "none";
|
||||||
|
if (resetOpenBtn) resetOpenBtn.style.display = "";
|
||||||
|
if (eraseInput) eraseInput.value = "";
|
||||||
|
if (resetConfirmBtn) resetConfirmBtn.disabled = true;
|
||||||
|
if (resetStatus) { resetStatus.textContent = ""; resetStatus.className = "security-status-msg"; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetConfirmBtn) {
|
||||||
|
resetConfirmBtn.addEventListener("click", async function() {
|
||||||
|
if (!eraseInput || eraseInput.value.trim() !== "ERASE") return;
|
||||||
|
resetConfirmBtn.disabled = true;
|
||||||
|
resetConfirmBtn.textContent = "Erasing\u2026";
|
||||||
|
if (resetStatus) { resetStatus.textContent = "Running security reset\u2026"; resetStatus.className = "security-status-msg security-status-info"; }
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/security/reset", { method: "POST" });
|
||||||
|
if (resetStatus) { resetStatus.textContent = "\u2713 Reset complete. Rebooting\u2026"; resetStatus.className = "security-status-msg security-status-ok"; }
|
||||||
|
if ($rebootOverlay) $rebootOverlay.classList.add("open");
|
||||||
|
} catch (err) {
|
||||||
|
if (resetStatus) { resetStatus.textContent = "\u2717 Error: " + (err.message || "Reset failed."); resetStatus.className = "security-status-msg security-status-error"; }
|
||||||
|
resetConfirmBtn.disabled = false;
|
||||||
|
resetConfirmBtn.textContent = "Erase & Reset";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire Verify System Integrity
|
||||||
|
var verifyBtn = document.getElementById("security-verify-btn");
|
||||||
|
var verifyResults = document.getElementById("security-verify-results");
|
||||||
|
|
||||||
|
if (verifyBtn && verifyResults) {
|
||||||
|
verifyBtn.addEventListener("click", async function() {
|
||||||
|
verifyBtn.disabled = true;
|
||||||
|
verifyBtn.textContent = "Verifying\u2026";
|
||||||
|
verifyResults.style.display = "";
|
||||||
|
verifyResults.innerHTML = '<p class="security-verify-loading">\u231B Running verification checks\u2026 This may take a few minutes.</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var data = await apiFetch("/api/security/verify-integrity", { method: "POST" });
|
||||||
|
var html = '<div class="security-verify-result-card">';
|
||||||
|
|
||||||
|
// Flake commit
|
||||||
|
html += '<div class="security-verify-row">';
|
||||||
|
html += '<span class="security-verify-label">Source Commit:</span>';
|
||||||
|
html += '<span class="security-verify-value security-verify-mono">' + escHtml(data.flake_commit || "unknown") + '</span>';
|
||||||
|
if (data.repo_url) {
|
||||||
|
html += '<a class="security-verify-link" href="' + escHtml(data.repo_url) + '" target="_blank" rel="noopener noreferrer">View on Gitea \u2197</a>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Store verification
|
||||||
|
var storeOk = data.store_verified === true;
|
||||||
|
html += '<div class="security-verify-row">';
|
||||||
|
html += '<span class="security-verify-label">Binary Integrity:</span>';
|
||||||
|
html += '<span class="security-verify-badge ' + (storeOk ? "security-verify-pass" : "security-verify-fail") + '">';
|
||||||
|
html += storeOk ? "\u2705 PASS" : "\u274C FAIL";
|
||||||
|
html += '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
if (!storeOk && data.store_errors && data.store_errors.length > 0) {
|
||||||
|
html += '<details class="security-verify-errors"><summary>Show errors (' + data.store_errors.length + ')</summary>';
|
||||||
|
html += '<pre class="security-verify-pre">' + escHtml(data.store_errors.join("\n")) + '</pre>';
|
||||||
|
html += '</details>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// System match
|
||||||
|
var sysOk = data.system_matches === true;
|
||||||
|
html += '<div class="security-verify-row">';
|
||||||
|
html += '<span class="security-verify-label">Running System Match:</span>';
|
||||||
|
html += '<span class="security-verify-badge ' + (sysOk ? "security-verify-pass" : "security-verify-fail") + '">';
|
||||||
|
html += sysOk ? "\u2705 PASS" : "\u274C FAIL";
|
||||||
|
html += '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
if (!sysOk) {
|
||||||
|
html += '<div class="security-verify-path-row">';
|
||||||
|
html += '<span class="security-verify-path-label">Current:</span><code class="security-verify-mono">' + escHtml(data.current_system_path || "") + '</code>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div class="security-verify-path-row">';
|
||||||
|
html += '<span class="security-verify-path-label">Expected:</span><code class="security-verify-mono">' + escHtml(data.expected_system_path || "") + '</code>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
verifyResults.innerHTML = html;
|
||||||
|
} catch (err) {
|
||||||
|
verifyResults.innerHTML = '<p class="security-status-msg security-status-error">\u274C Verification failed: ' + escHtml(err.message || "Unknown error") + '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyBtn.disabled = false;
|
||||||
|
verifyBtn.textContent = "Verify Now";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
|
||||||
// Non-fatal — silently ignore if the endpoint is unreachable
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -612,15 +612,6 @@ function openSystemChangePasswordModal(unit, name, icon) {
|
|||||||
resultEl.textContent = "✅ System password changed successfully.";
|
resultEl.textContent = "✅ System password changed successfully.";
|
||||||
submitBtn.textContent = "Change Password";
|
submitBtn.textContent = "Change Password";
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
// Hide the legacy security banner if it's visible — but only for
|
|
||||||
// "legacy" status machines. For "unsealed" machines, changing passwords
|
|
||||||
// is not enough; the factory residue remains until a proper re-seal or re-install.
|
|
||||||
if (typeof _securityIsLegacy !== "undefined" && _securityIsLegacy &&
|
|
||||||
(typeof _securityStatus === "undefined" || _securityStatus !== "unsealed")) {
|
|
||||||
_securityIsLegacy = false;
|
|
||||||
var banner = document.querySelector(".security-inline-banner");
|
|
||||||
if (banner) banner.remove();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
resultEl.className = "matrix-form-result error";
|
resultEl.className = "matrix-form-result error";
|
||||||
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
|
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
|
||||||
|
|||||||
@@ -99,10 +99,5 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
|
|||||||
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
|
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
|
||||||
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
|
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
|
||||||
|
|
||||||
// Legacy security warning state (populated by checkLegacySecurity in security.js)
|
|
||||||
var _securityIsLegacy = false;
|
|
||||||
var _securityStatus = "ok"; // "ok", "legacy", or "unsealed"
|
|
||||||
var _securityWarningMessage = "";
|
|
||||||
|
|
||||||
// System status banner
|
// System status banner
|
||||||
// (removed — health is now shown per-tile via the composite health field)
|
// (removed — health is now shown per-tile via the composite health field)
|
||||||
@@ -89,6 +89,18 @@ function renderSidebarSupport(supportServices) {
|
|||||||
backupBtn.addEventListener("click", function() { openBackupModal(); });
|
backupBtn.addEventListener("click", function() { openBackupModal(); });
|
||||||
$sidebarSupport.appendChild(backupBtn);
|
$sidebarSupport.appendChild(backupBtn);
|
||||||
|
|
||||||
|
// ── Security button
|
||||||
|
var securityBtn = document.createElement("button");
|
||||||
|
securityBtn.className = "sidebar-support-btn";
|
||||||
|
securityBtn.innerHTML =
|
||||||
|
'<span class="sidebar-support-icon">\uD83D\uDEE1</span>' +
|
||||||
|
'<span class="sidebar-support-text">' +
|
||||||
|
'<span class="sidebar-support-title">Security</span>' +
|
||||||
|
'<span class="sidebar-support-hint">Reset & verify system</span>' +
|
||||||
|
'</span>';
|
||||||
|
securityBtn.addEventListener("click", function() { openSecurityModal(); });
|
||||||
|
$sidebarSupport.appendChild(securityBtn);
|
||||||
|
|
||||||
// ── Upgrade button (Node role only)
|
// ── Upgrade button (Node role only)
|
||||||
if (_currentRole === "node") {
|
if (_currentRole === "node") {
|
||||||
var upgradeBtn = document.createElement("button");
|
var upgradeBtn = document.createElement("button");
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
|
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
|
||||||
Drives the 6-step post-install setup flow. */
|
Drives the 5-step post-install setup flow. */
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TOTAL_STEPS = 6;
|
const TOTAL_STEPS = 5;
|
||||||
|
|
||||||
// Steps to skip per role (steps 4 and 5 involve domain/port setup)
|
// Steps to skip per role (steps 3 and 4 involve domain/port setup)
|
||||||
// Steps 2 (timezone/locale) and 3 (password) are NEVER skipped — all roles need them.
|
// Step 2 (timezone/locale) is NEVER skipped — all roles need it.
|
||||||
const ROLE_SKIP_STEPS = {
|
const ROLE_SKIP_STEPS = {
|
||||||
"desktop": [4, 5],
|
"desktop": [3, 4],
|
||||||
"node": [4, 5],
|
"node": [3, 4],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Role state (loaded at init) ───────────────────────────────────
|
// ── Role state (loaded at init) ───────────────────────────────────
|
||||||
|
|
||||||
var _onboardingRole = "server_plus_desktop";
|
var _onboardingRole = "server_plus_desktop";
|
||||||
|
|
||||||
// Password default state (loaded at step 2)
|
|
||||||
var _passwordIsDefault = true;
|
|
||||||
|
|
||||||
// Domains that may need configuration, with service unit mapping for enabled check
|
// Domains that may need configuration, with service unit mapping for enabled check
|
||||||
const DOMAIN_DEFS = [
|
const DOMAIN_DEFS = [
|
||||||
{ name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true },
|
{ name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true },
|
||||||
@@ -96,8 +93,7 @@ function showStep(step) {
|
|||||||
if (step === 2) loadStep2();
|
if (step === 2) loadStep2();
|
||||||
if (step === 3) loadStep3();
|
if (step === 3) loadStep3();
|
||||||
if (step === 4) loadStep4();
|
if (step === 4) loadStep4();
|
||||||
if (step === 5) loadStep5();
|
// Step 5 (Complete) is static — no lazy-load needed
|
||||||
// Step 6 (Complete) is static — no lazy-load needed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the next step number, skipping over role-excluded steps
|
// Return the next step number, skipping over role-excluded steps
|
||||||
@@ -273,135 +269,12 @@ async function saveStep2() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 3: Create Your Password ─────────────────────────────────
|
// ── Step 3: Domain Configuration ─────────────────────────────────
|
||||||
|
|
||||||
async function loadStep3() {
|
async function loadStep3() {
|
||||||
var body = document.getElementById("step-3-body");
|
var body = document.getElementById("step-3-body");
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
|
||||||
var nextBtn = document.getElementById("step-3-next");
|
|
||||||
|
|
||||||
try {
|
|
||||||
var result = await apiFetch("/api/security/password-is-default");
|
|
||||||
_passwordIsDefault = result.is_default !== false;
|
|
||||||
} catch (_) {
|
|
||||||
_passwordIsDefault = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_passwordIsDefault) {
|
|
||||||
// Factory-sealed scenario: password must be set before continuing
|
|
||||||
if (nextBtn) nextBtn.textContent = "Set Password & Continue \u2192";
|
|
||||||
|
|
||||||
body.innerHTML =
|
|
||||||
'<div class="onboarding-password-group">' +
|
|
||||||
'<label class="onboarding-domain-label" for="pw-new">New Password</label>' +
|
|
||||||
'<div class="onboarding-password-input-wrap">' +
|
|
||||||
'<input class="onboarding-password-input" type="password" id="pw-new" autocomplete="new-password" placeholder="At least 8 characters" />' +
|
|
||||||
'<button type="button" class="onboarding-password-toggle" data-target="pw-new" aria-label="Show password">👁</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<p class="onboarding-password-hint">Minimum 8 characters</p>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="onboarding-password-group">' +
|
|
||||||
'<label class="onboarding-domain-label" for="pw-confirm">Confirm Password</label>' +
|
|
||||||
'<div class="onboarding-password-input-wrap">' +
|
|
||||||
'<input class="onboarding-password-input" type="password" id="pw-confirm" autocomplete="new-password" placeholder="Re-enter your password" />' +
|
|
||||||
'<button type="button" class="onboarding-password-toggle" data-target="pw-confirm" aria-label="Show password">👁</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="onboarding-password-warning">⚠️ Write this password down — it cannot be recovered.</div>';
|
|
||||||
|
|
||||||
// Wire show/hide toggles
|
|
||||||
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
|
|
||||||
btn.addEventListener("click", function() {
|
|
||||||
var inp = document.getElementById(btn.dataset.target);
|
|
||||||
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// DIY install scenario: password already set by installer
|
|
||||||
if (nextBtn) nextBtn.textContent = "Continue \u2192";
|
|
||||||
|
|
||||||
body.innerHTML =
|
|
||||||
'<div class="onboarding-password-success">✅ Your password was already set during installation.</div>' +
|
|
||||||
'<details class="onboarding-password-optional">' +
|
|
||||||
'<summary>Change it anyway</summary>' +
|
|
||||||
'<div style="margin-top:14px;">' +
|
|
||||||
'<div class="onboarding-password-group">' +
|
|
||||||
'<label class="onboarding-domain-label" for="pw-new">New Password</label>' +
|
|
||||||
'<div class="onboarding-password-input-wrap">' +
|
|
||||||
'<input class="onboarding-password-input" type="password" id="pw-new" autocomplete="new-password" placeholder="At least 8 characters" />' +
|
|
||||||
'<button type="button" class="onboarding-password-toggle" data-target="pw-new" aria-label="Show password">👁</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<p class="onboarding-password-hint">Minimum 8 characters</p>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="onboarding-password-group">' +
|
|
||||||
'<label class="onboarding-domain-label" for="pw-confirm">Confirm Password</label>' +
|
|
||||||
'<div class="onboarding-password-input-wrap">' +
|
|
||||||
'<input class="onboarding-password-input" type="password" id="pw-confirm" autocomplete="new-password" placeholder="Re-enter your password" />' +
|
|
||||||
'<button type="button" class="onboarding-password-toggle" data-target="pw-confirm" aria-label="Show password">👁</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="onboarding-password-warning">⚠️ Write this password down — it cannot be recovered.</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</details>';
|
|
||||||
|
|
||||||
// Wire show/hide toggles
|
|
||||||
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
|
|
||||||
btn.addEventListener("click", function() {
|
|
||||||
var inp = document.getElementById(btn.dataset.target);
|
|
||||||
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveStep3() {
|
|
||||||
var newPw = document.getElementById("pw-new");
|
|
||||||
var confirmPw = document.getElementById("pw-confirm");
|
|
||||||
|
|
||||||
// If no fields visible or both empty and password already set → skip
|
|
||||||
if (!newPw || !newPw.value.trim()) {
|
|
||||||
if (!_passwordIsDefault) return true; // already set, no change requested
|
|
||||||
setStatus("step-3-status", "⚠ Please enter a password.", "error");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var pw = newPw.value;
|
|
||||||
var cpw = confirmPw ? confirmPw.value : "";
|
|
||||||
|
|
||||||
if (pw.length < 8) {
|
|
||||||
setStatus("step-3-status", "⚠ Password must be at least 8 characters.", "error");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (pw !== cpw) {
|
|
||||||
setStatus("step-3-status", "⚠ Passwords do not match.", "error");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("step-3-status", "Saving password…", "info");
|
|
||||||
try {
|
|
||||||
await apiFetch("/api/change-password", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ new_password: pw, confirm_password: cpw }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setStatus("step-3-status", "⚠ " + err.message, "error");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("step-3-status", "✓ Password saved", "ok");
|
|
||||||
_passwordIsDefault = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 4: Domain Configuration ─────────────────────────────────
|
|
||||||
|
|
||||||
async function loadStep4() {
|
|
||||||
var body = document.getElementById("step-4-body");
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch services, domains, and network info in parallel
|
// Fetch services, domains, and network info in parallel
|
||||||
var results = await Promise.all([
|
var results = await Promise.all([
|
||||||
@@ -542,8 +415,8 @@ async function loadStep4() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveStep4() {
|
async function saveStep3() {
|
||||||
setStatus("step-4-status", "Saving domains…", "info");
|
setStatus("step-3-status", "Saving domains…", "info");
|
||||||
var errors = [];
|
var errors = [];
|
||||||
|
|
||||||
// Save each domain input
|
// Save each domain input
|
||||||
@@ -583,18 +456,18 @@ async function saveStep4() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
setStatus("step-4-status", "⚠ Some errors: " + errors.join("; "), "error");
|
setStatus("step-3-status", "⚠ Some errors: " + errors.join("; "), "error");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("step-4-status", "✓ Saved", "ok");
|
setStatus("step-3-status", "✓ Saved", "ok");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5: Port Forwarding ───────────────────────────────────────
|
// ── Step 4: Port Forwarding ───────────────────────────────────────
|
||||||
|
|
||||||
async function loadStep5() {
|
async function loadStep4() {
|
||||||
var body = document.getElementById("step-5-body");
|
var body = document.getElementById("step-4-body");
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
|
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
|
||||||
|
|
||||||
@@ -675,10 +548,10 @@ async function loadStep5() {
|
|||||||
body.innerHTML = html;
|
body.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 6: Complete ──────────────────────────────────────────────
|
// ── Step 5: Complete ──────────────────────────────────────────────
|
||||||
|
|
||||||
async function completeOnboarding() {
|
async function completeOnboarding() {
|
||||||
var btn = document.getElementById("step-6-finish");
|
var btn = document.getElementById("step-5-finish");
|
||||||
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
|
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -709,36 +582,24 @@ function wireNavButtons() {
|
|||||||
if (ok) showStep(nextStep(2));
|
if (ok) showStep(nextStep(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 3 → 4 (save password first)
|
// Step 3 → 4 (save domains first)
|
||||||
var s3next = document.getElementById("step-3-next");
|
var s3next = document.getElementById("step-3-next");
|
||||||
if (s3next) s3next.addEventListener("click", async function() {
|
if (s3next) s3next.addEventListener("click", async function() {
|
||||||
s3next.disabled = true;
|
s3next.disabled = true;
|
||||||
var origText = s3next.textContent;
|
|
||||||
s3next.textContent = "Saving…";
|
s3next.textContent = "Saving…";
|
||||||
var ok = await saveStep3();
|
await saveStep3();
|
||||||
s3next.disabled = false;
|
s3next.disabled = false;
|
||||||
s3next.textContent = origText;
|
s3next.textContent = "Save & Continue →";
|
||||||
if (ok) showStep(nextStep(3));
|
showStep(nextStep(3));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4 → 5 (save domains first)
|
// Step 4 → 5 (port forwarding — no save needed)
|
||||||
var s4next = document.getElementById("step-4-next");
|
var s4next = document.getElementById("step-4-next");
|
||||||
if (s4next) s4next.addEventListener("click", async function() {
|
if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); });
|
||||||
s4next.disabled = true;
|
|
||||||
s4next.textContent = "Saving…";
|
|
||||||
await saveStep4();
|
|
||||||
s4next.disabled = false;
|
|
||||||
s4next.textContent = "Save & Continue →";
|
|
||||||
showStep(nextStep(4));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 5 → 6 (port forwarding — no save needed)
|
// Step 5: finish
|
||||||
var s5next = document.getElementById("step-5-next");
|
var s5finish = document.getElementById("step-5-finish");
|
||||||
if (s5next) s5next.addEventListener("click", function() { showStep(nextStep(5)); });
|
if (s5finish) s5finish.addEventListener("click", completeOnboarding);
|
||||||
|
|
||||||
// Step 6: finish
|
|
||||||
var s6finish = document.getElementById("step-6-finish");
|
|
||||||
if (s6finish) s6finish.addEventListener("click", completeOnboarding);
|
|
||||||
|
|
||||||
// Back buttons
|
// Back buttons
|
||||||
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
||||||
|
|||||||
@@ -36,8 +36,6 @@
|
|||||||
<span class="onboarding-step-dot" data-step="4">4</span>
|
<span class="onboarding-step-dot" data-step="4">4</span>
|
||||||
<span class="onboarding-step-connector"></span>
|
<span class="onboarding-step-connector"></span>
|
||||||
<span class="onboarding-step-dot" data-step="5">5</span>
|
<span class="onboarding-step-dot" data-step="5">5</span>
|
||||||
<span class="onboarding-step-connector"></span>
|
|
||||||
<span class="onboarding-step-dot" data-step="6">6</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step panels -->
|
<!-- Step panels -->
|
||||||
@@ -96,29 +94,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Step 3: Create Your Password ── -->
|
<!-- ── Step 3: Domain Configuration ── -->
|
||||||
<div class="onboarding-panel" id="step-3" style="display:none">
|
<div class="onboarding-panel" id="step-3" style="display:none">
|
||||||
<div class="onboarding-step-header">
|
|
||||||
<span class="onboarding-step-icon">🔒</span>
|
|
||||||
<h2 class="onboarding-step-title">Create Your Password</h2>
|
|
||||||
<p class="onboarding-step-desc">
|
|
||||||
Choose a strong password for your <strong>'free'</strong> user account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="onboarding-card" id="step-3-body">
|
|
||||||
<p class="onboarding-loading">Checking password status…</p>
|
|
||||||
</div>
|
|
||||||
<div id="step-3-status" class="onboarding-save-status"></div>
|
|
||||||
<div class="onboarding-footer">
|
|
||||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
|
|
||||||
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
|
|
||||||
Set Password & Continue →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Step 4: Domain Configuration ── -->
|
|
||||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
|
||||||
<div class="onboarding-step-header">
|
<div class="onboarding-step-header">
|
||||||
<span class="onboarding-step-icon">🌐</span>
|
<span class="onboarding-step-icon">🌐</span>
|
||||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||||
@@ -129,20 +106,20 @@
|
|||||||
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
|
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card" id="step-4-body">
|
<div class="onboarding-card" id="step-3-body">
|
||||||
<p class="onboarding-loading">Loading service information…</p>
|
<p class="onboarding-loading">Loading service information…</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="step-4-status" class="onboarding-save-status"></div>
|
<div id="step-3-status" class="onboarding-save-status"></div>
|
||||||
<div class="onboarding-footer">
|
<div class="onboarding-footer">
|
||||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
|
||||||
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
|
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
|
||||||
Save & Continue →
|
Save & Continue →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Step 5: Port Forwarding ── -->
|
<!-- ── Step 4: Port Forwarding ── -->
|
||||||
<div class="onboarding-panel" id="step-5" style="display:none">
|
<div class="onboarding-panel" id="step-4" style="display:none">
|
||||||
<div class="onboarding-step-header">
|
<div class="onboarding-step-header">
|
||||||
<span class="onboarding-step-icon">🔌</span>
|
<span class="onboarding-step-icon">🔌</span>
|
||||||
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
||||||
@@ -151,19 +128,19 @@
|
|||||||
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card" id="step-5-body">
|
<div class="onboarding-card" id="step-4-body">
|
||||||
<p class="onboarding-loading">Checking ports…</p>
|
<p class="onboarding-loading">Checking ports…</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-footer">
|
<div class="onboarding-footer">
|
||||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
|
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||||
<button class="btn btn-primary onboarding-btn-next" id="step-5-next">
|
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
|
||||||
Continue →
|
Continue →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Step 6: Complete ── -->
|
<!-- ── Step 5: Complete ── -->
|
||||||
<div class="onboarding-panel" id="step-6" style="display:none">
|
<div class="onboarding-panel" id="step-5" style="display:none">
|
||||||
<div class="onboarding-hero">
|
<div class="onboarding-hero">
|
||||||
<div class="onboarding-logo">✅</div>
|
<div class="onboarding-logo">✅</div>
|
||||||
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
||||||
@@ -176,14 +153,13 @@
|
|||||||
</p>
|
</p>
|
||||||
<ul class="onboarding-checklist" id="onboarding-checklist">
|
<ul class="onboarding-checklist" id="onboarding-checklist">
|
||||||
<li>✅ Timezone & locale configured</li>
|
<li>✅ Timezone & locale configured</li>
|
||||||
<li>✅ Password configured</li>
|
|
||||||
<li>✅ Domain configuration saved</li>
|
<li>✅ Domain configuration saved</li>
|
||||||
<li>✅ Port forwarding reviewed</li>
|
<li>✅ Port forwarding reviewed</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-footer">
|
<div class="onboarding-footer">
|
||||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="5">← Back</button>
|
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
|
||||||
<button class="btn btn-primary" id="step-6-finish">
|
<button class="btn btn-primary" id="step-5-finish">
|
||||||
Go to Dashboard →
|
Go to Dashboard →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
sovran-factory-seal = pkgs.writeShellScriptBin "sovran-factory-seal" ''
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
echo "Error: must be run as root." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ ⚠ SOVRAN FACTORY SEAL — WARNING ⚠ ║"
|
|
||||||
echo "╠══════════════════════════════════════════════════════════════╣"
|
|
||||||
echo "║ This command will PERMANENTLY DELETE: ║"
|
|
||||||
echo "║ • All generated passwords and secrets ║"
|
|
||||||
echo "║ • LND wallet data (seed words, channels, macaroons) ║"
|
|
||||||
echo "║ • SSH factory login key ║"
|
|
||||||
echo "║ • Application databases (Matrix, Nextcloud, WordPress) ║"
|
|
||||||
echo "║ • Vaultwarden database ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ After sealing, all credentials will be regenerated fresh ║"
|
|
||||||
echo "║ when the customer boots the device for the first time. ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ DO NOT run this on a customer's live system. ║"
|
|
||||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
||||||
echo ""
|
|
||||||
echo -n "Type SEAL to confirm: "
|
|
||||||
read -r CONFIRM
|
|
||||||
if [ "$CONFIRM" != "SEAL" ]; then
|
|
||||||
echo "Aborted." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Sealing system..."
|
|
||||||
|
|
||||||
# ── 1. Delete all generated secrets ──────────────────────────────
|
|
||||||
echo " Wiping secrets..."
|
|
||||||
[ -d /var/lib/secrets ] && find /var/lib/secrets -mindepth 1 -delete || true
|
|
||||||
rm -rf /var/lib/matrix-synapse/registration-secret
|
|
||||||
rm -rf /var/lib/matrix-synapse/db-password
|
|
||||||
rm -rf /var/lib/gnome-remote-desktop/rdp-password
|
|
||||||
rm -rf /var/lib/gnome-remote-desktop/rdp-username
|
|
||||||
rm -rf /var/lib/gnome-remote-desktop/rdp-credentials
|
|
||||||
rm -rf /var/lib/livekit/livekit_keyFile
|
|
||||||
rm -rf /etc/nix-bitcoin-secrets/*
|
|
||||||
|
|
||||||
# ── 2. Wipe LND wallet (seed words, wallet DB, macaroons) ────────
|
|
||||||
echo " Wiping LND wallet data..."
|
|
||||||
rm -rf /var/lib/lnd/*
|
|
||||||
|
|
||||||
# ── 3. Wipe SSH factory key so it regenerates with new passphrase ─
|
|
||||||
echo " Removing SSH factory key..."
|
|
||||||
rm -f /home/free/.ssh/factory_login /home/free/.ssh/factory_login.pub
|
|
||||||
if [ -f /root/.ssh/authorized_keys ]; then
|
|
||||||
sed -i '/factory_login/d' /root/.ssh/authorized_keys
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 4. Drop application databases ────────────────────────────────
|
|
||||||
echo " Dropping application databases..."
|
|
||||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"matrix-synapse\";" 2>/dev/null || true
|
|
||||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS nextclouddb;" 2>/dev/null || true
|
|
||||||
mysql -u root -e "DROP DATABASE IF EXISTS wordpressdb;" 2>/dev/null || true
|
|
||||||
|
|
||||||
# ── 5. Remove application config files (so init services re-run) ─
|
|
||||||
echo " Removing application config files..."
|
|
||||||
rm -rf /var/lib/www/wordpress/wp-config.php
|
|
||||||
rm -rf /var/lib/www/nextcloud/config/config.php
|
|
||||||
|
|
||||||
# ── 6. Wipe Vaultwarden database ──────────────────────────────────
|
|
||||||
echo " Wiping Vaultwarden data..."
|
|
||||||
rm -rf /var/lib/bitwarden_rs/*
|
|
||||||
rm -rf /var/lib/vaultwarden/*
|
|
||||||
|
|
||||||
# ── 7. Set sealed flag and remove onboarded flag ─────────────────
|
|
||||||
echo " Setting sealed flag..."
|
|
||||||
touch /var/lib/sovran-factory-sealed
|
|
||||||
rm -f /var/lib/sovran-customer-onboarded
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "System sealed. Power off now or the system will shut down in 10 seconds."
|
|
||||||
sleep 10
|
|
||||||
poweroff
|
|
||||||
'';
|
|
||||||
|
|
||||||
in
|
|
||||||
{
|
|
||||||
environment.systemPackages = [ sovran-factory-seal ];
|
|
||||||
|
|
||||||
# ── Auto-seal on first customer boot ───────────────────────────────
|
|
||||||
systemd.services.sovran-auto-seal = {
|
|
||||||
description = "Auto-seal Sovran system on first customer boot";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
before = [ "sovran-hub.service" "sovran-legacy-security-check.service" ];
|
|
||||||
after = [ "local-fs.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
path = [ pkgs.coreutils pkgs.e2fsprogs pkgs.openssl pkgs.postgresql pkgs.mariadb pkgs.shadow ];
|
|
||||||
script = ''
|
|
||||||
# ── Idempotency check ─────────────────────────────────────────
|
|
||||||
if [ -f /var/lib/sovran-factory-sealed ]; then
|
|
||||||
echo "sovran-auto-seal: already sealed, nothing to do."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "sovran-auto-seal: seal flag missing — checking system state..."
|
|
||||||
|
|
||||||
# ── Safety guard 1: customer has already onboarded ────────────
|
|
||||||
if [ -f /var/lib/sovran-customer-onboarded ]; then
|
|
||||||
echo "sovran-auto-seal: /var/lib/sovran-customer-onboarded exists — live system detected. Restoring flag and exiting."
|
|
||||||
touch /var/lib/sovran-factory-sealed
|
|
||||||
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Safety guard 2: onboarding was completed ──────────────────
|
|
||||||
if [ -f /var/lib/sovran/onboarding-complete ]; then
|
|
||||||
echo "sovran-auto-seal: /var/lib/sovran/onboarding-complete exists — live system detected. Restoring flag and exiting."
|
|
||||||
touch /var/lib/sovran-factory-sealed
|
|
||||||
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Safety guard 3: password has been changed from factory defaults ──
|
|
||||||
if [ -f /etc/shadow ]; then
|
|
||||||
FREE_HASH=$(grep '^free:' /etc/shadow | cut -d: -f2)
|
|
||||||
if [ -n "$FREE_HASH" ] && [ "$FREE_HASH" != "!" ] && [ "$FREE_HASH" != "*" ]; then
|
|
||||||
ALGO_ID=$(printf '%s' "$FREE_HASH" | cut -d'$' -f2)
|
|
||||||
SALT=$(printf '%s' "$FREE_HASH" | cut -d'$' -f3)
|
|
||||||
STILL_DEFAULT=false
|
|
||||||
# If the salt field starts with "rounds=", we cannot extract the real salt
|
|
||||||
# with a simple cut — treat as still-default for safety
|
|
||||||
if printf '%s' "$SALT" | grep -q '^rounds='; then
|
|
||||||
STILL_DEFAULT=true
|
|
||||||
else
|
|
||||||
for DEFAULT_PW in "free" "gosovransystems"; do
|
|
||||||
case "$ALGO_ID" in
|
|
||||||
6) EXPECTED=$(openssl passwd -6 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
|
|
||||||
5) EXPECTED=$(openssl passwd -5 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
|
|
||||||
*)
|
|
||||||
# Unknown hash algorithm — treat as still-default for safety
|
|
||||||
STILL_DEFAULT=true
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$FREE_HASH" ]; then
|
|
||||||
STILL_DEFAULT=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
if [ "$STILL_DEFAULT" = "false" ]; then
|
|
||||||
echo "sovran-auto-seal: password has been changed from factory defaults — live system detected. Restoring flag and exiting."
|
|
||||||
touch /var/lib/sovran-factory-sealed
|
|
||||||
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── All safety guards passed: this is a fresh/unsealed system ─
|
|
||||||
echo "sovran-auto-seal: fresh system confirmed — performing auto-seal..."
|
|
||||||
|
|
||||||
# ── 1. Wipe generated secrets ─────────────────────────────────
|
|
||||||
echo "sovran-auto-seal: wiping secrets..."
|
|
||||||
[ -d /var/lib/secrets ] && find /var/lib/secrets -mindepth 1 -delete || true
|
|
||||||
rm -rf /var/lib/matrix-synapse/registration-secret
|
|
||||||
rm -rf /var/lib/matrix-synapse/db-password
|
|
||||||
rm -rf /var/lib/gnome-remote-desktop/rdp-password
|
|
||||||
rm -rf /var/lib/gnome-remote-desktop/rdp-username
|
|
||||||
rm -rf /var/lib/gnome-remote-desktop/rdp-credentials
|
|
||||||
rm -rf /var/lib/livekit/livekit_keyFile
|
|
||||||
rm -rf /etc/nix-bitcoin-secrets/*
|
|
||||||
|
|
||||||
# ── 2. Wipe LND wallet data ───────────────────────────────────
|
|
||||||
echo "sovran-auto-seal: wiping LND wallet data..."
|
|
||||||
rm -rf /var/lib/lnd/*
|
|
||||||
|
|
||||||
# ── 3. Remove SSH factory key ─────────────────────────────────
|
|
||||||
echo "sovran-auto-seal: removing SSH factory key..."
|
|
||||||
rm -f /home/free/.ssh/factory_login /home/free/.ssh/factory_login.pub
|
|
||||||
if [ -f /root/.ssh/authorized_keys ]; then
|
|
||||||
sed -i '/factory_login/d' /root/.ssh/authorized_keys
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 4. Drop application databases ────────────────────────────
|
|
||||||
echo "sovran-auto-seal: dropping application databases..."
|
|
||||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"matrix-synapse\";" 2>/dev/null || true
|
|
||||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS nextclouddb;" 2>/dev/null || true
|
|
||||||
mysql -u root -e "DROP DATABASE IF EXISTS wordpressdb;" 2>/dev/null || true
|
|
||||||
|
|
||||||
# ── 5. Remove application config files ───────────────────────
|
|
||||||
echo "sovran-auto-seal: removing application config files..."
|
|
||||||
rm -rf /var/lib/www/wordpress/wp-config.php
|
|
||||||
rm -rf /var/lib/www/nextcloud/config/config.php
|
|
||||||
|
|
||||||
# ── 6. Wipe Vaultwarden data ──────────────────────────────────
|
|
||||||
echo "sovran-auto-seal: wiping Vaultwarden data..."
|
|
||||||
rm -rf /var/lib/bitwarden_rs/*
|
|
||||||
rm -rf /var/lib/vaultwarden/*
|
|
||||||
|
|
||||||
# ── 7. Set sealed flag and make it immutable ──────────────────
|
|
||||||
echo "sovran-auto-seal: setting sealed flag..."
|
|
||||||
touch /var/lib/sovran-factory-sealed
|
|
||||||
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
|
|
||||||
|
|
||||||
# ── 8. Remove onboarded flag so onboarding runs fresh ─────────
|
|
||||||
rm -f /var/lib/sovran-customer-onboarded
|
|
||||||
|
|
||||||
echo "sovran-auto-seal: auto-seal complete. Continuing boot into onboarding."
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
# ── Legacy security check: warn existing (pre-seal) machines ───────
|
|
||||||
systemd.services.sovran-legacy-security-check = {
|
|
||||||
description = "Check for legacy (pre-factory-seal) security status";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "local-fs.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
path = [ pkgs.coreutils pkgs.openssl ];
|
|
||||||
script = ''
|
|
||||||
# If sealed AND onboarded — fully clean, nothing to do
|
|
||||||
[ -f /var/lib/sovran-factory-sealed ] && [ -f /var/lib/sovran-customer-onboarded ] && exit 0
|
|
||||||
|
|
||||||
# If sealed but not yet onboarded — seal was run, customer hasn't finished setup yet, that's fine
|
|
||||||
[ -f /var/lib/sovran-factory-sealed ] && exit 0
|
|
||||||
|
|
||||||
# If onboarded but NOT sealed — installer ran without factory seal!
|
|
||||||
if [ -f /var/lib/sovran-customer-onboarded ] && [ ! -f /var/lib/sovran-factory-sealed ]; then
|
|
||||||
mkdir -p /var/lib/sovran
|
|
||||||
echo "unsealed" > /var/lib/sovran/security-status
|
|
||||||
cat > /var/lib/sovran/security-warning << 'EOF'
|
|
||||||
This machine was set up without the factory seal process. Factory test data — including SSH keys, database contents, and wallet information — may still be present on this system. It is strongly recommended to back up any important data and re-install using a fresh ISO, or contact Sovran Systems support for assistance.
|
|
||||||
EOF
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If the user completed Hub onboarding, they've addressed security
|
|
||||||
[ -f /var/lib/sovran/onboarding-complete ] && exit 0
|
|
||||||
|
|
||||||
# If the free password has been changed from ALL known factory defaults, no warning needed
|
|
||||||
if [ -f /etc/shadow ]; then
|
|
||||||
FREE_HASH=$(grep '^free:' /etc/shadow | cut -d: -f2)
|
|
||||||
if [ -n "$FREE_HASH" ] && [ "$FREE_HASH" != "!" ] && [ "$FREE_HASH" != "*" ]; then
|
|
||||||
ALGO_ID=$(printf '%s' "$FREE_HASH" | cut -d'$' -f2)
|
|
||||||
SALT=$(printf '%s' "$FREE_HASH" | cut -d'$' -f3)
|
|
||||||
STILL_DEFAULT=false
|
|
||||||
# If the salt field starts with "rounds=", we cannot extract the real salt
|
|
||||||
# with a simple cut — treat as still-default for safety
|
|
||||||
if printf '%s' "$SALT" | grep -q '^rounds='; then
|
|
||||||
STILL_DEFAULT=true
|
|
||||||
else
|
|
||||||
for DEFAULT_PW in "free" "gosovransystems"; do
|
|
||||||
case "$ALGO_ID" in
|
|
||||||
6) EXPECTED=$(openssl passwd -6 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
|
|
||||||
5) EXPECTED=$(openssl passwd -5 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
|
|
||||||
*)
|
|
||||||
# Unknown hash algorithm — treat as still-default for safety
|
|
||||||
STILL_DEFAULT=true
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$FREE_HASH" ]; then
|
|
||||||
STILL_DEFAULT=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
if [ "$STILL_DEFAULT" = "false" ]; then
|
|
||||||
# Password was changed — clear any legacy warning and exit
|
|
||||||
rm -f /var/lib/sovran/security-status /var/lib/sovran/security-warning
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# No flags at all + secrets exist = legacy (pre-seal era) machine
|
|
||||||
if [ -f /var/lib/secrets/root-password ]; then
|
|
||||||
mkdir -p /var/lib/sovran
|
|
||||||
echo "legacy" > /var/lib/sovran/security-status
|
|
||||||
echo "This system was deployed before the factory seal feature. Your passwords may be known to the factory. Please change your passwords through the Sovran Hub." > /var/lib/sovran/security-warning
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -95,20 +95,22 @@ in
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── 1b. Save 'free' password on first boot ─────────────────
|
# ── 1b. Generate random 'free' password on first boot ──────
|
||||||
systemd.services.free-password-setup = {
|
systemd.services.free-password-setup = {
|
||||||
description = "Save the initial 'free' user password";
|
description = "Generate and set a random 'free' user password";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
};
|
};
|
||||||
path = [ pkgs.coreutils ];
|
path = [ pkgs.pwgen pkgs.shadow pkgs.coreutils ];
|
||||||
script = ''
|
script = ''
|
||||||
SECRET_FILE="/var/lib/secrets/free-password"
|
SECRET_FILE="/var/lib/secrets/free-password"
|
||||||
if [ ! -f "$SECRET_FILE" ]; then
|
if [ ! -f "$SECRET_FILE" ]; then
|
||||||
mkdir -p /var/lib/secrets
|
mkdir -p /var/lib/secrets
|
||||||
echo "free" > "$SECRET_FILE"
|
FREE_PASS=$(pwgen -s 20 1)
|
||||||
|
echo "free:$FREE_PASS" | chpasswd
|
||||||
|
echo "$FREE_PASS" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
./core/sovran_systemsos-desktop.nix
|
./core/sovran_systemsos-desktop.nix
|
||||||
./core/sshd-localhost.nix
|
./core/sshd-localhost.nix
|
||||||
./core/sovran-hub.nix
|
./core/sovran-hub.nix
|
||||||
./core/factory-seal.nix
|
|
||||||
|
|
||||||
# ── Always on (no flag) ───────────────────────────────────
|
# ── Always on (no flag) ───────────────────────────────────
|
||||||
./php.nix
|
./php.nix
|
||||||
|
|||||||
Reference in New Issue
Block a user