Merge: vhost webui reskin to certs look + read certs from HAProxy PEM store off-loop (#858)
Some checks are pending
License Headers / check (push) Waiting to run

This commit is contained in:
CyberMind-FR 2026-07-15 16:01:45 +02:00
commit 99feea1057
2 changed files with 282 additions and 369 deletions

View File

@ -11,6 +11,8 @@ import subprocess
import os
import json
import re
import asyncio
import time
from pathlib import Path
from typing import Optional, List, Dict, Any
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks
@ -26,6 +28,17 @@ config = get_config("vhost")
NGINX_VHOST_DIR = Path(config.get("nginx_vhost_dir", "/etc/nginx/sites-available") if config else "/etc/nginx/sites-available")
NGINX_ENABLED_DIR = Path(config.get("nginx_enabled_dir", "/etc/nginx/sites-enabled") if config else "/etc/nginx/sites-enabled")
ACME_DIR = Path(config.get("acme_dir", "/etc/acme") if config else "/etc/acme")
# The real cert store is the combined HAProxy PEMs (same source the certs module
# reads); the legacy acme.sh /etc/acme layout is empty on this platform.
CERTS_PEM_DIR = Path(config.get("certs_pem_dir", "/data/haproxy/certs")
if config else "/data/haproxy/certs")
def _pem_cert_count() -> int:
try:
return len(list(CERTS_PEM_DIR.glob("*.pem"))) if CERTS_PEM_DIR.exists() else 0
except Exception:
return 0
DATA_PATH = Path(config.get("data_path", "/srv/vhost") if config else "/srv/vhost")
@ -81,9 +94,7 @@ async def status():
enabled_count = len([f for f in NGINX_ENABLED_DIR.glob("*.conf") if f.is_symlink() or f.is_file()])
# Count certificates
cert_count = 0
if ACME_DIR.exists():
cert_count = len([d for d in ACME_DIR.iterdir() if d.is_dir() and (d / "fullchain.cer").exists()])
cert_count = _pem_cert_count()
return {
"module": "vhost",
@ -134,9 +145,7 @@ async def get_components():
if NGINX_VHOST_DIR.exists():
vhost_count = len(list(NGINX_VHOST_DIR.glob("*.conf")))
cert_count = 0
if ACME_DIR.exists():
cert_count = len([d for d in ACME_DIR.iterdir() if d.is_dir() and (d / "fullchain.cer").exists()])
cert_count = _pem_cert_count()
return {
"components": [
@ -593,41 +602,69 @@ async def delete_vhost(domain: str):
@app.get("/certificates")
async def list_certificates():
"""List all certificates (public)"""
certs = []
if ACME_DIR.exists():
for cert_dir in ACME_DIR.iterdir():
if not cert_dir.is_dir():
continue
cert_file = cert_dir / "fullchain.cer"
if not cert_file.exists():
continue
domain = cert_dir.name
expires = ""
issuer = ""
success, out, _ = run_cmd([
"openssl", "x509", "-in", str(cert_file), "-noout", "-enddate", "-issuer"
])
if success:
for line in out.split("\n"):
if "notAfter" in line:
expires = line.split("=")[-1]
if "issuer" in line.lower():
issuer = line.split("=", 1)[-1]
certs.append({
"domain": domain,
"expires": expires,
"issuer": issuer,
"cert_file": str(cert_file),
})
"""List all certificates from the combined HAProxy PEM store (the real cert
source, same as the certs module). Parsing shells out to openssl once per
PEM dozens of blocking subprocesses so it runs OFF the event loop and is
cached; otherwise it would freeze the shared aggregator loop (board-wide)."""
now = time.time()
if _CERT_CACHE["data"] is not None and now - _CERT_CACHE["ts"] < 60:
certs = _CERT_CACHE["data"]
else:
certs = await asyncio.to_thread(_read_pem_certs)
_CERT_CACHE.update(ts=now, data=certs)
return {"certificates": certs, "count": len(certs)}
_CERT_CACHE: dict = {"ts": 0.0, "data": None}
def _read_pem_certs() -> list:
"""Blocking: parse every PEM in the store IN-PROCESS via cryptography (no
per-cert subprocess dozens of openssl spawns would exhaust the box). Run
via to_thread + cached by the caller."""
certs = []
if not CERTS_PEM_DIR.exists():
return certs
try:
from cryptography import x509
except Exception:
x509 = None
for cert_file in sorted(CERTS_PEM_DIR.glob("*.pem")):
stem = cert_file.stem
# _wildcard_.example.com.pem -> *.example.com
domain = (f"*.{stem[len('_wildcard_.'):]}"
if stem.startswith("_wildcard_.") else stem)
expires = ""
issuer = ""
try:
data = cert_file.read_bytes()
leaf = None
if x509 is not None:
# combined PEM = key + leaf + chain; take the first certificate.
if hasattr(x509, "load_pem_x509_certificates"):
parsed = x509.load_pem_x509_certificates(data)
leaf = parsed[0] if parsed else None
else:
leaf = x509.load_pem_x509_certificate(data)
if leaf is not None:
dt = getattr(leaf, "not_valid_after_utc", None) or leaf.not_valid_after
# match the OpenSSL notAfter format the webui's new Date() parses
expires = dt.strftime("%b %e %H:%M:%S %Y GMT").replace(" ", " ")
try:
issuer = leaf.issuer.rfc4514_string()
except Exception:
issuer = ""
except Exception:
pass
certs.append({
"domain": domain,
"expires": expires,
"issuer": issuer,
"cert_file": str(cert_file),
})
return certs
class CertRequest(BaseModel):
domain: str
email: str

View File

@ -6,318 +6,148 @@
<title>SecuBox - VHost Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared/crt-light.css">
<link rel="stylesheet" href="/shared/sidebar-light.css">
<link rel="stylesheet" href="/shared/hybrid-dark.css">
<link rel="stylesheet" href="/shared/hybrid-skin.css">
<style>
:root {
/* P31 Phosphor spectrum */
--p31-peak: #00dd44;
--p31-hot: #00ff55;
--p31-mid: #009933;
--p31-dim: #006622;
--p31-ghost: #003311;
--p31-decay: #ffb347;
--p31-decay-dim: #cc7722;
/* Tube glass */
--tube-light: #e8f5e9;
--tube-pale: #c8e6c9;
--tube-soft: #a5d6a7;
/* Legacy mappings */
--bg-dark: var(--tube-light);
--bg-card: var(--tube-pale);
--bg-sidebar: var(--tube-black);
--border: var(--tube-soft);
--text: var(--tube-dark);
--text-dim: var(--p31-dim);
--primary: var(--p31-peak);
--cyan: var(--p31-peak);
--green: var(--p31-peak);
--cyan: #00d4ff;
--bg-dark: #0d1117;
--bg-card: rgba(30, 40, 55, 0.8);
--border: rgba(100, 150, 200, 0.2);
--text: #e8e6d9;
--red: #ff4466;
--yellow: var(--p31-decay);
--orange: var(--p31-decay-dim);
--purple: #a371f7;
/* Bloom effects */
--bloom-text: 0 0 2px var(--p31-peak), 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
--bloom-soft: 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
--bloom-amber: 0 0 3px var(--p31-decay), 0 0 10px rgba(255,179,71,0.4);
--orange: #ff9944;
--yellow: #ffcc00;
--green: #00dd44;
--blue: #4488ff;
--dim: #6b7b8b;
/* compat aliases used by inline styles / JS */
--primary: var(--cyan);
--text-dim: var(--dim);
--p31-dim: var(--dim);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Courier Prime', 'Courier New', monospace;
background: var(--tube-light);
background-image: radial-gradient(ellipse at 50% 40%, rgba(51,255,102,0.025) 0%, transparent 70%);
color: var(--tube-dark);
display: flex;
min-height: 100vh;
}
.sidebar { width: 220px; background: var(--bg-sidebar); border-right: 1px solid var(--border); position: fixed; height: 100vh; overflow-y: auto; }
.main { flex: 1; margin-left: 220px; padding: 1.5rem; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.header h1 { font-size: 1.5rem; }
.btn { padding: 0.5rem 1rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); cursor: pointer; margin-left: 0.5rem; }
.btn:hover { background: var(--bg-dark); }
.btn.primary { background: rgba(88,166,255,0.1); border-color: var(--primary); color: var(--primary); }
.btn.success { background: rgba(63,185,80,0.1); border-color: var(--green); color: var(--green); }
.btn.danger { background: rgba(248,81,73,0.1); border-color: var(--red); color: var(--red); }
.btn.warning { background: rgba(210,153,34,0.1); border-color: var(--yellow); color: var(--yellow); }
body { font-family: 'Courier Prime', monospace; background: var(--bg-dark); color: var(--text); display: flex; min-height: 100vh; }
.sidebar { width: 220px; position: fixed; height: 100vh; overflow-y: auto; }
.main { flex: 1; margin-left: 220px; padding: 1.5rem; padding-top: 60px; background: linear-gradient(135deg, rgba(10,15,25,0.95), rgba(15,25,40,0.9)); min-height: 100vh; }
@media (max-width: 768px) { .main { margin-left: 0; padding-top: 60px; } }
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1rem; }
.tab { padding: 0.75rem 1.5rem; cursor: pointer; border-bottom: 2px solid transparent; color: var(--text-dim); }
.tab:hover { color: var(--text); }
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
.header h1 { font-size: 1.4rem; color: var(--cyan); text-shadow: 0 0 10px var(--cyan); }
.btn-group { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.btn { padding: 0.4rem 0.8rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); cursor: pointer; font-size: 0.8rem; font-family: 'Courier Prime', monospace; text-transform: uppercase; transition: all 0.2s; }
.btn:hover { border-color: var(--cyan); color: var(--cyan); box-shadow: 0 0 8px rgba(0,212,255,0.3); }
.btn.primary { background: rgba(0,212,255,0.15); border-color: var(--cyan); color: var(--cyan); }
.btn.success { background: rgba(0,221,68,0.15); border-color: var(--green); color: var(--green); }
.btn.success:hover { border-color: var(--green); color: var(--green); box-shadow: 0 0 8px rgba(0,221,68,0.3); }
.btn.warning { background: rgba(255,204,0,0.15); border-color: var(--yellow); color: var(--yellow); }
.btn.warning:hover { border-color: var(--yellow); color: var(--yellow); box-shadow: 0 0 8px rgba(255,204,0,0.3); }
.btn.danger { background: rgba(255,68,102,0.15); border-color: var(--red); color: var(--red); }
.btn.danger:hover { border-color: var(--red); color: var(--red); box-shadow: 0 0 8px rgba(255,68,102,0.3); }
.btn.sm { padding: 0.2rem 0.5rem; font-size: 0.7rem; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 1rem; backdrop-filter: blur(10px); }
.card h2 { font-size: 0.95rem; margin-bottom: 0.9rem; color: var(--cyan); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem; text-align: center; backdrop-filter: blur(5px); }
.stat-card .value { font-size: 1.4rem; font-weight: bold; color: var(--cyan); text-shadow: 0 0 10px currentColor; }
.stat-card .label { font-size: 0.6rem; color: var(--dim); letter-spacing: 0.1em; margin-top: 0.2rem; }
.stat-card.dead .value { color: var(--red); }
.stat-card.critical .value { color: var(--orange); }
.stat-card.warning .value { color: var(--yellow); }
.stat-card.healthy .value { color: var(--green); }
.stat-card.blue .value { color: var(--blue); }
.stat-card.cyan .value { color: var(--cyan); }
.tabs { display: flex; border-bottom: 1px solid var(--border); margin-bottom: 1rem; flex-wrap: wrap; gap: 0.2rem; }
.tab { padding: 0.5rem 0.9rem; cursor: pointer; color: var(--dim); border-bottom: 2px solid transparent; font-size: 0.72rem; text-transform: uppercase; transition: all 0.2s; }
.tab:hover { color: var(--cyan); }
.tab.active { color: var(--cyan); border-bottom-color: var(--cyan); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; text-align: center; }
.stat-card .value { font-size: 2rem; font-weight: bold; color: var(--cyan); }
.stat-card .label { font-size: 0.75rem; color: var(--text-dim); }
.components-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.component-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; backdrop-filter: blur(5px); }
.component-card h3 { font-size: 0.95rem; margin-bottom: 0.6rem; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; color: var(--cyan); }
.component-card .meta { font-size: 0.75rem; color: var(--dim); margin-bottom: 0.4rem; }
.components-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.component-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; }
.component-card h3 { font-size: 1rem; margin-bottom: 0.75rem; display: flex; justify-content: space-between; align-items: center; }
.component-card .meta { font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.5rem; }
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; }
.card h2 { font-size: 1rem; margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; }
.status-badge { display: inline-flex; align-items: center; gap: 0.5rem; padding: 4px 12px; border-radius: 12px; font-size: 0.8rem; }
.status-badge.running { background: rgba(63,185,80,0.15); color: var(--green); }
.status-badge.stopped { background: rgba(248,81,73,0.15); color: var(--red); }
.status-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 3px 10px; border-radius: 12px; font-size: 0.7rem; text-transform: uppercase; border: 1px solid var(--border); color: var(--dim); }
.status-badge.running { background: rgba(0,221,68,0.15); color: var(--green); border-color: var(--green); }
.status-badge.stopped { background: rgba(255,68,102,0.15); color: var(--red); border-color: var(--red); }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.6rem; text-align: left; border-bottom: 1px solid var(--border); font-size: 0.875rem; }
th { color: var(--text-dim); font-size: 0.75rem; text-transform: uppercase; }
.badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
.badge.enabled { background: rgba(63,185,80,0.2); color: var(--green); }
.badge.disabled { background: rgba(139,148,158,0.2); color: var(--text-dim); }
.badge.ssl { background: rgba(88,166,255,0.2); color: var(--primary); }
th, td { padding: 0.55rem 0.6rem; text-align: left; border-bottom: 1px solid var(--border); font-size: 0.8rem; }
th { color: var(--dim); font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; }
tr:hover td { background: rgba(0,212,255,0.04); }
.badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.7rem; border: 1px solid var(--border); }
.badge.enabled { background: rgba(0,221,68,0.15); color: var(--green); border-color: var(--green); }
.badge.disabled { background: rgba(107,123,139,0.15); color: var(--dim); }
.badge.ssl { background: rgba(0,212,255,0.15); color: var(--cyan); border-color: var(--cyan); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.8rem; color: var(--text-dim); }
.form-group input, .form-group select { width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-dark); color: var(--text); }
/* Certificate list — certs-style status rows */
.cert-list { display: flex; flex-direction: column; gap: 0.25rem; }
.cert-item { display: grid; grid-template-columns: 1.5em 1fr auto auto auto; align-items: center; gap: 0.6rem; padding: 0.45rem 0.6rem; background: rgba(20,30,45,0.6); border-radius: 4px; font-size: 0.78rem; border-left: 3px solid var(--border); transition: all 0.15s; }
.cert-item:hover { background: rgba(0,212,255,0.08); transform: translateX(2px); }
.cert-item.dead { border-left-color: var(--red); background: rgba(255,68,102,0.1); }
.cert-item.critical { border-left-color: var(--orange); background: rgba(255,153,68,0.08); }
.cert-item.warning { border-left-color: var(--yellow); background: rgba(255,204,0,0.05); }
.cert-item.healthy { border-left-color: var(--green); }
.cert-item .dot { text-align: center; }
.cert-item .domain { font-weight: 500; color: var(--cyan); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cert-item .issuer { font-size: 0.68rem; color: var(--dim); text-align: right; }
.cert-item .days { font-size: 0.72rem; text-align: right; min-width: 70px; font-weight: bold; }
.cert-item .days.dead { color: var(--red); }
.cert-item .days.critical { color: var(--orange); }
.cert-item .days.warning { color: var(--yellow); }
.cert-item .days.healthy { color: var(--green); }
.cert-item .exp { font-size: 0.68rem; color: var(--dim); text-align: right; min-width: 95px; }
@media (max-width: 768px) { .cert-item { grid-template-columns: 1.2em 1fr auto; } .cert-item .issuer, .cert-item .exp { display: none; } }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 0.6rem; }
.form-group { margin-bottom: 0.6rem; }
.form-group label { display: block; margin-bottom: 0.2rem; font-size: 0.7rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.05em; }
.form-group input, .form-group select { width: 100%; padding: 0.45rem; background: rgba(20,30,45,0.8); border: 1px solid var(--border); border-radius: 4px; font-family: 'Courier Prime', monospace; font-size: 0.8rem; color: var(--text); }
.form-group input:focus, .form-group select:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 5px rgba(0,212,255,0.3); }
.form-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
.url-box { background: var(--bg-dark); padding: 0.75rem; border-radius: 6px; font-family: monospace; font-size: 0.85rem; margin-bottom: 0.75rem; display: flex; justify-content: space-between; align-items: center; }
.url-label { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; margin-bottom: 0.25rem; }
.url-box a { color: var(--primary); }
.url-box { background: rgba(20,30,45,0.6); padding: 0.6rem 0.75rem; border-radius: 6px; border: 1px solid var(--border); font-family: 'Courier Prime', monospace; font-size: 0.8rem; margin-bottom: 0.6rem; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
.url-label { font-size: 0.65rem; color: var(--dim); text-transform: uppercase; margin-bottom: 0.2rem; }
.url-box a { color: var(--cyan); text-decoration: none; }
.url-box a:hover { text-shadow: 0 0 6px var(--cyan); }
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.9); z-index: 1000; align-items: center; justify-content: center; }
.modal.show { display: flex; }
.modal-content { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; min-width: 400px; max-width: 90vw; }
.modal-content h3 { margin-bottom: 1rem; }
/* CRT Enhancements */
.main { margin-left: 220px; padding: 1.5rem; }
.modal-content { background: rgba(20,30,45,0.95); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; width: 480px; max-width: 90vw; max-height: 85vh; overflow-y: auto; backdrop-filter: blur(10px); }
.modal-content h3 { margin-bottom: 1rem; color: var(--cyan); }
.header h1 {
color: var(--p31-hot);
text-shadow: var(--bloom-text);
font-weight: 700;
letter-spacing: 0.05em;
}
.badge {
font-family: 'Courier Prime', monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
border: 1px solid;
}
.badge-blue, .badge-primary { background: rgba(51,255,102,0.1); border-color: var(--p31-peak); color: var(--p31-peak); }
.badge-green { background: rgba(51,255,102,0.15); border-color: var(--p31-peak); color: var(--p31-peak); text-shadow: var(--bloom-soft); }
.badge-cyan { background: rgba(51,255,102,0.1); border-color: var(--p31-mid); color: var(--p31-mid); }
.badge-purple { background: rgba(163,113,247,0.1); border-color: #a371f7; color: #a371f7; }
.badge-yellow, .badge-amber { background: rgba(255,179,71,0.1); border-color: var(--p31-decay); color: var(--p31-decay); }
.badge-red { background: rgba(255,68,102,0.1); border-color: #ff4466; color: #ff4466; }
.btn {
font-family: 'Courier Prime', monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid var(--tube-soft);
background: var(--tube-pale);
color: var(--p31-mid);
transition: all 0.2s;
}
.btn:hover {
border-color: var(--p31-dim);
color: var(--p31-peak);
text-shadow: var(--bloom-soft);
box-shadow: 0 0 12px rgba(51,255,102,0.1);
}
.btn-green, .btn-primary, .btn.primary, .btn.success {
border-color: var(--p31-peak);
color: var(--p31-peak);
background: rgba(51,255,102,0.1);
}
.btn-green:hover, .btn-primary:hover, .btn.primary:hover, .btn.success:hover {
background: rgba(51,255,102,0.2);
box-shadow: 0 0 16px rgba(51,255,102,0.2);
}
.btn-red, .btn.danger {
border-color: #ff4466;
color: #ff4466;
background: rgba(255,68,102,0.1);
}
.btn-red:hover, .btn.danger:hover {
background: rgba(255,68,102,0.2);
}
.btn-blue {
border-color: var(--p31-mid);
color: var(--p31-mid);
background: rgba(51,255,102,0.05);
}
.btn-blue:hover {
border-color: var(--p31-peak);
color: var(--p31-peak);
}
.card {
background: var(--tube-pale);
border: 1px solid var(--tube-soft);
box-shadow: inset 0 0 20px rgba(0,0,0,0.3);
}
.card:hover {
border-color: var(--p31-dim);
box-shadow: 0 0 8px rgba(51,255,102,0.05), inset 0 0 20px rgba(0,0,0,0.3);
}
.card-title, .card-header h2 {
color: var(--p31-decay);
text-shadow: var(--bloom-amber);
letter-spacing: 0.1em;
text-transform: uppercase;
font-size: 0.85rem;
}
.stat-card {
background: var(--tube-pale);
border: 1px solid var(--tube-soft);
}
.stat-value, .stat-card .value {
text-shadow: 0 0 10px currentColor;
}
.stat-label, .stat-card .label {
color: var(--p31-dim);
letter-spacing: 0.15em;
}
.stat-card.cyan .value, .stat-card.green .value { color: var(--p31-peak); }
.stat-card.yellow .value { color: var(--p31-decay); }
.stat-card.red .value { color: #ff4466; }
table, .table {
font-family: 'Courier Prime', monospace;
}
th, .table th {
color: var(--p31-decay);
text-shadow: var(--bloom-amber);
letter-spacing: 0.15em;
border-bottom: 1px solid var(--p31-ghost);
}
td, .table td {
border-bottom: 1px solid var(--p31-ghost);
color: var(--p31-mid);
}
tr:hover, .table tr:hover {
background: rgba(51,255,102,0.03);
}
.status-dot.active, .status-running {
color: var(--p31-peak);
text-shadow: var(--bloom-soft);
}
.status-dot.active {
background: var(--p31-peak);
box-shadow: 0 0 8px var(--p31-peak);
}
.toast {
background: var(--tube-pale);
border: 1px solid var(--p31-dim);
font-family: 'Courier Prime', monospace;
}
.toast-success { border-color: var(--p31-peak); }
.toast-error { border-color: #ff4466; }
.modal {
background: rgba(0,0,0,0.85);
}
.modal-content {
background: var(--tube-pale);
border: 1px solid var(--p31-dim);
box-shadow: 0 0 30px rgba(51,255,102,0.1);
}
.modal-title {
color: var(--p31-decay);
text-shadow: var(--bloom-amber);
letter-spacing: 0.1em;
}
.tabs {
background: var(--tube-pale);
border-bottom: 1px solid var(--p31-ghost);
}
.tab {
color: var(--p31-dim);
letter-spacing: 0.1em;
text-transform: uppercase;
font-size: 0.8rem;
}
.tab:hover { color: var(--p31-mid); }
.tab.active {
color: var(--p31-peak);
text-shadow: var(--bloom-soft);
border-bottom-color: var(--p31-peak);
}
input, select, textarea {
font-family: 'Courier Prime', monospace;
background: var(--tube-light);
border: 1px solid var(--tube-soft);
color: var(--p31-mid);
padding: 0.5rem;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--p31-dim);
box-shadow: 0 0 8px rgba(51,255,102,0.1);
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--tube-light); }
::-webkit-scrollbar-thumb { background: var(--p31-dim); }
::-webkit-scrollbar-thumb:hover { background: var(--p31-mid); }
/* Empty states */
.empty { color: var(--p31-dim); }
@media (max-width: 768px) {
.main { margin-left: 0; }
}
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: rgba(20,30,45,0.5); }
::-webkit-scrollbar-thumb { background: var(--dim); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--cyan); }
</style>
</head>
<body class="crt-light hybrid-dark">
<body class="hybrid-dark">
<nav class="sidebar" id="sidebar"></nav>
<script src="/shared/sidebar.js"></script>
<main class="main">
<header class="header">
<h1>VHost Manager</h1>
<div>
<h1>🌐 VHost Manager</h1>
<div class="btn-group">
<span class="status-badge" id="status-badge">Loading...</span>
<button class="btn" onclick="reloadNginx()">Reload Nginx</button>
<button class="btn" onclick="refresh()">Refresh</button>
<button class="btn" onclick="reloadNginx()">↻ Reload Nginx</button>
<button class="btn" onclick="refresh()">🔄 Refresh</button>
</div>
</header>
<!-- Three-fold navigation -->
<div class="tabs">
<div class="tab active" onclick="showTab('components')">Components</div>
<div class="tab" onclick="showTab('status')">Status</div>
<div class="tab" onclick="showTab('access')">Access</div>
<div class="tab" onclick="showTab('vhosts')">VHosts</div>
<div class="tab" onclick="showTab('certs')">Certificates</div>
<div class="tab" onclick="showTab('actions')">Actions</div>
<div class="tab active" onclick="showTab('components')">📦 Components</div>
<div class="tab" onclick="showTab('status')">📊 Status</div>
<div class="tab" onclick="showTab('access')">🔗 Access</div>
<div class="tab" onclick="showTab('vhosts')">🌐 VHosts</div>
<div class="tab" onclick="showTab('certs')">🔐 Certificates</div>
</div>
<!-- COMPONENTS TAB -->
@ -337,10 +167,10 @@
<div class="card">
<h2>Quick Stats</h2>
<div class="stats-row">
<div class="stat-card"><div class="value" id="vhost-count">-</div><div class="label">Total VHosts</div></div>
<div class="stat-card"><div class="value" id="enabled-count">-</div><div class="label">Enabled</div></div>
<div class="stat-card"><div class="value" id="cert-count">-</div><div class="label">Certificates</div></div>
<div class="grid">
<div class="stat-card cyan"><div class="value" id="vhost-count">-</div><div class="label">Total VHosts</div></div>
<div class="stat-card healthy"><div class="value" id="enabled-count">-</div><div class="label">Enabled</div></div>
<div class="stat-card blue"><div class="value" id="cert-count">-</div><div class="label">Certificates</div></div>
<div class="stat-card"><div class="value" id="nginx-status">-</div><div class="label">Nginx Status</div></div>
</div>
</div>
@ -391,11 +221,7 @@
<!-- VHOSTS TAB -->
<div class="tab-content" id="tab-vhosts">
<div class="card">
<h2>Add VHost <button class="btn success" style="padding:0.25rem 0.5rem;font-size:0.75rem" onclick="showAddVhost()">+ New</button></h2>
</div>
<div class="card">
<h2>Virtual Hosts</h2>
<h2>Virtual Hosts <button class="btn success sm" onclick="showAddVhost()">+ New</button></h2>
<table>
<thead><tr><th>Domain</th><th>Backend</th><th>TLS</th><th>Exposition</th><th>Status</th><th>Actions</th></tr></thead>
<tbody id="vhost-list"><tr><td colspan="6">Loading...</td></tr></tbody>
@ -405,31 +231,19 @@
<!-- CERTS TAB -->
<div class="tab-content" id="tab-certs">
<div class="grid">
<div class="stat-card dead"><div class="value" id="cert-expired">-</div><div class="label">💀 Expired</div></div>
<div class="stat-card warning"><div class="value" id="cert-expiring">-</div><div class="label">⚠️ Expiring &lt;30d</div></div>
<div class="stat-card healthy"><div class="value" id="cert-valid">-</div><div class="label">💚 Valid</div></div>
<div class="stat-card blue"><div class="value" id="cert-total">-</div><div class="label">📜 Total</div></div>
</div>
<div class="card">
<h2>Certificates <button class="btn success" style="padding:0.25rem 0.5rem;font-size:0.75rem" onclick="showRequestCert()">+ Request</button></h2>
<table>
<thead><tr><th>Domain</th><th>Expires</th><th>Issuer</th><th>Actions</th></tr></thead>
<tbody id="cert-list"><tr><td colspan="4">Loading...</td></tr></tbody>
</table>
<h2>Certificates <button class="btn success sm" onclick="showRequestCert()">+ Request</button></h2>
<div class="cert-list" id="cert-list">Loading...</div>
</div>
</div>
<!-- ACTIONS TAB -->
<div class="tab-content" id="tab-actions">
<div class="card">
<h2>Service Control</h2>
<button class="btn" onclick="reloadNginx()">Reload Nginx</button>
<button class="btn" onclick="testConfig()">Test Config</button>
</div>
<div class="card">
<h2>Migration</h2>
<p style="margin-bottom:1rem;color:var(--text-dim);font-size:0.85rem">
Import vhosts and certificates from existing OpenWrt SecuBox installation.
</p>
<button class="btn warning" onclick="showMigrate()">Migrate from OpenWrt</button>
</div>
</div>
</main>
<!-- Add VHost Modal -->
@ -477,7 +291,7 @@
<div class="modal" id="migrate-modal">
<div class="modal-content">
<h3>Migrate from OpenWrt</h3>
<p style="margin-bottom:1rem;color:var(--text-dim);font-size:0.85rem">
<p style="margin-bottom:1rem;color:var(--dim);font-size:0.8rem">
Import vhosts and certificates from existing SecuBox.
</p>
<div class="form-group">
@ -496,6 +310,12 @@
const token = () => localStorage.getItem('sbx_token');
const headers = () => ({ 'Content-Type': 'application/json', ...(token() ? { 'Authorization': 'Bearer ' + token() } : {}) });
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
async function api(path, opts = {}) {
try {
const res = await fetch(API + path, { ...opts, headers: headers() });
@ -561,41 +381,98 @@
const list = document.getElementById('vhost-list');
const vhosts = d.vhosts || [];
if (!vhosts.length) {
list.innerHTML = '<tr><td colspan="6" style="color:var(--text-dim)">No vhosts configured</td></tr>';
list.innerHTML = '<tr><td colspan="6" style="color:var(--dim)">No vhosts configured</td></tr>';
return;
}
list.innerHTML = vhosts.map(v => {
const exp = v.exposure || { reach: '?', mesh: false };
const reachLabel = exp.reach === 'localhost' ? 'localhost' : (exp.reach || '?').toUpperCase();
const reachLabel = exp.reach === 'localhost' ? 'localhost' : esc((exp.reach || '?').toUpperCase());
const reachClass = exp.reach === 'wan' ? 'ssl' : (exp.reach === 'lan' ? 'enabled' : 'disabled');
const dom = esc(v.domain);
return `<tr>
<td><a href="${v.ssl ? 'https' : 'http'}://${v.domain}" target="_blank" style="color:var(--primary)">${v.domain}</a></td>
<td style="color:var(--text-dim);font-family:monospace;font-size:0.8rem">${v.backend || '-'}</td>
<td>${v.ssl ? '<span class="badge ssl">' + (v.tls_mode || 'SSL') + '</span>' : '-'}</td>
<td><a href="${v.ssl ? 'https' : 'http'}://${encodeURIComponent(v.domain)}" target="_blank" style="color:var(--cyan)">${dom}</a></td>
<td style="color:var(--dim);font-family:monospace;font-size:0.75rem">${esc(v.backend || '-')}</td>
<td>${v.ssl ? '<span class="badge ssl">' + esc(v.tls_mode || 'SSL') + '</span>' : '-'}</td>
<td><span class="badge ${reachClass}">${reachLabel}${exp.mesh ? ' 🕸️' : ''}</span></td>
<td><span class="badge ${v.enabled ? 'enabled' : 'disabled'}">${v.enabled ? '✅ Enabled' : '⏸️ Disabled'}</span></td>
<td>
<button class="btn" onclick="toggleVhost('${v.domain}', ${!v.enabled})" style="padding:2px 8px;font-size:0.7rem">${v.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn danger" onclick="deleteVhost('${v.domain}')" style="padding:2px 8px;font-size:0.7rem">Delete</button>
<button class="btn sm" onclick="toggleVhost('${dom}', ${!v.enabled})">${v.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn danger sm" onclick="deleteVhost('${dom}')">Delete</button>
</td>
</tr>`;
}).join('');
}
function certStatus(days) {
if (days === null) return { cls: '', dot: '❔', txt: '—' };
if (days < 0) return { cls: 'dead', dot: '💀', txt: 'EXPIRED' };
if (days <= 13) return { cls: 'critical', dot: '🔴', txt: days + 'd' };
if (days <= 29) return { cls: 'warning', dot: '🟡', txt: days + 'd' };
return { cls: 'healthy', dot: '💚', txt: days + 'd' };
}
function shortenIssuer(s) {
if (!s) return '—';
const m = s.match(/CN\s*=\s*([^,\/]+)/i) || s.match(/O\s*=\s*([^,\/]+)/i);
return esc((m ? m[1] : s).trim());
}
function certDays(expires) {
if (!expires) return null;
const d = new Date(expires);
if (isNaN(d.getTime())) return null;
return Math.floor((d.getTime() - Date.now()) / 86400000);
}
async function loadCerts() {
const d = await api('/certificates');
const list = document.getElementById('cert-list');
const certs = d.certificates || [];
let expired = 0, expiring = 0, valid = 0;
certs.forEach(c => {
const days = certDays(c.expires);
if (days === null) return;
if (days < 0) expired++;
else if (days < 30) expiring++;
else valid++;
});
document.getElementById('cert-expired').textContent = expired;
document.getElementById('cert-expiring').textContent = expiring;
document.getElementById('cert-valid').textContent = valid;
document.getElementById('cert-total').textContent = certs.length;
document.getElementById('cert-count').textContent = certs.length;
if (!certs.length) {
list.innerHTML = '<tr><td colspan="4" style="color:var(--text-dim)">No certificates</td></tr>';
list.innerHTML = '<div style="color:var(--dim);padding:1rem;text-align:center">No certificates</div>';
return;
}
list.innerHTML = certs.map(c => `<tr>
<td>${c.domain}</td>
<td>${c.expires || '-'}</td>
<td style="color:var(--text-dim);font-size:0.8rem">${c.issuer || '-'}</td>
<td><button class="btn" onclick="renewCert('${c.domain}')" style="padding:2px 8px;font-size:0.7rem">Renew</button></td>
</tr>`).join('');
const rows = certs.slice().map(c => {
const days = certDays(c.expires);
return { c, days };
}).sort((a, b) => {
if (a.days === null) return 1;
if (b.days === null) return -1;
return a.days - b.days;
});
list.innerHTML = rows.map(({ c, days }) => {
const st = certStatus(days);
const dom = esc(c.domain);
const parsed = c.expires ? new Date(c.expires) : null;
const expText = parsed && !isNaN(parsed.getTime())
? parsed.toISOString().slice(0, 10)
: (c.expires ? esc(c.expires) : '—');
return `<div class="cert-item ${st.cls}">
<span class="dot">${st.dot}</span>
<span class="domain" title="${dom}">${dom}</span>
<span class="issuer" title="${esc(c.issuer || '')}">${shortenIssuer(c.issuer)}</span>
<span class="days ${st.cls}">${st.txt}</span>
<span class="exp">${expText}</span>
<button class="btn sm" onclick="renewCert('${dom}')">↻ Renew</button>
</div>`;
}).join('');
}
async function loadAccess() {
@ -603,14 +480,14 @@
const container = document.getElementById('access-domains');
const vhosts = d.vhosts || [];
if (!vhosts.length) {
container.innerHTML = '<p style="color:var(--text-dim)">No domains configured</p>';
container.innerHTML = '<p style="color:var(--dim)">No domains configured</p>';
return;
}
container.innerHTML = vhosts.map(v => `
<div class="url-label">${v.domain}</div>
<div class="url-label">${esc(v.domain)}</div>
<div class="url-box">
<a href="${v.url}" target="_blank">${v.url}</a>
<span style="color:var(--text-dim);font-size:0.8rem">${v.backend}</span>
<a href="${esc(v.url)}" target="_blank">${esc(v.url)}</a>
<span style="color:var(--dim);font-size:0.75rem">${esc(v.backend)}</span>
</div>
`).join('');
}
@ -709,6 +586,5 @@
function refresh() { loadStatus(); loadVhosts(); loadCerts(); }
refresh();
</script>
<script src="/shared/crt-engine.js"></script>
</body>
</html>