#!/usr/bin/env bash
# ACERO SCRIPT v16
# V2Ray (VMess + VLESS, WebSocket) + SSH accounts (direct + SSH-over-WebSocket)
# + Nginx + lightweight web panel.
# Debian/Ubuntu, run as root.
#
# Usage:
#   bash install-v2ray-panel.sh                 # port 80, random panel password
#   bash install-v2ray-panel.sh 8080            # custom port
#   bash install-v2ray-panel.sh 80 MyPassword   # custom panel password
#
# The panel is served over HTTPS on its own random port (port 443 stays free for V2Ray).
# The script asks Let's Encrypt for a free, trusted certificate for your server IP
# (needs Nginx on port 80 reachable from the internet). If that fails it falls back to a
# self-signed certificate (browser warning). Renews itself (6-day certificates).
# Open the HTTPS port in your VPS provider firewall too (it is shown at the end).
#
# Already installed? Run it again: it upgrades the panel and keeps your
# users, password, secret path and port (arguments are ignored on upgrade).
# Upgrading from v9 adds the HTTPS panel port and certificate automatically.
# Upgrading from v7 or older removes OpenVPN (service, NAT rules, OpenVPN users).
# A backup of the old users.json is saved first.
#
# Reset panel password later:
#   PW='newpass' python3 /opt/v2panel/panel.py --setpw

set -euo pipefail

PORT="${1:-80}"
PANEL_PW="${2:-$(head -c 12 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 12)}"
SECRET="$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')"
PANEL_LOCAL=2053
SSHWS_LOCAL=10022
V2RAY_VERSION="v4.45.2"
APP=/opt/v2panel

if [[ $EUID -ne 0 ]]; then
  echo "Run as root (sudo -i)." >&2
  exit 1
fi

echo "[1/7] Installing dependencies..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y curl nginx python3 ca-certificates openssh-server openssl

UPGRADE=0
if [[ -f ${APP}/settings.json && -f ${APP}/secret_path ]]; then
  UPGRADE=1
  PORT="$(python3 -c 'import json;print(json.load(open("/opt/v2panel/settings.json"))["port"])')"
  SECRET="$(cat ${APP}/secret_path)"
  echo "Existing install found: upgrading (keeping users, password, port ${PORT})."
fi

# ---- v8: OpenVPN is no longer part of ACERO SCRIPT. Remove it if an older version installed it.
if [[ -f /etc/openvpn/server/acero.conf || -f /etc/systemd/system/acero-ovpn-nat.service ]]; then
  echo "Old OpenVPN setup found: removing it (V2Ray and SSH only from v8)."
  systemctl disable --now openvpn-server@acero 2>/dev/null || true
  systemctl disable --now acero-ovpn-nat 2>/dev/null || true
  while iptables -t nat -D POSTROUTING -s 10.8.0.0/24 ! -d 10.8.0.0/24 -j MASQUERADE 2>/dev/null; do :; done
  while iptables -D FORWARD -s 10.8.0.0/24 -j ACCEPT 2>/dev/null; do :; done
  while iptables -D FORWARD -d 10.8.0.0/24 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; do :; done
  rm -f /etc/systemd/system/acero-ovpn-nat.service /usr/local/sbin/acero-ovpn-nat.sh /etc/sysctl.d/99-acero-ovpn.conf
  rm -f /etc/openvpn/server/acero.conf /etc/openvpn/server/acero-*
  systemctl daemon-reload
fi

SSH_PORT="$( (sshd -T 2>/dev/null || true) | awk '/^port /{print $2; exit}')"
SSH_PORT="${SSH_PORT:-22}"

echo "[2/7] Installing V2Ray ${V2RAY_VERSION}..."
if [[ -x /usr/local/bin/v2ray ]]; then
  echo "V2Ray already installed, skipping."
else
  bash <(curl -fsSL https://raw.githubusercontent.com/v2fly/fhs-install-v2ray/master/install-release.sh) --version "${V2RAY_VERSION}"
fi

echo "[3/7] Setting up SSH accounts..."
groupadd -f acerossh
mkdir -p /etc/ssh/sshd_config.d
if ! grep -qE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/' /etc/ssh/sshd_config; then
  sed -i '1i Include /etc/ssh/sshd_config.d/*.conf' /etc/ssh/sshd_config
fi
cat > /etc/ssh/sshd_config.d/acero-ssh.conf <<'SSHEOF'
# ACERO panel SSH accounts: tunnel only (no shell, no tty), password login allowed.
Match Group acerossh
    PasswordAuthentication yes
    AllowTcpForwarding yes
    PermitTTY no
    X11Forwarding no
    AllowAgentForwarding no
    PermitTunnel no
    ClientAliveInterval 30
    ClientAliveCountMax 4
SSHEOF
if sshd -t 2>/dev/null; then
  systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
else
  echo "WARNING: sshd config test failed, removing ACERO SSH drop-in (SSH accounts will not log in)." >&2
  rm -f /etc/ssh/sshd_config.d/acero-ssh.conf
fi

echo "[4/7] Installing panel and SSH-WebSocket bridge..."
mkdir -p ${APP}
cat > ${APP}/panel.py <<'PYEOF'
#!/usr/bin/env python3
# ACERO SCRIPT panel: VMess WS + VLESS WS + SSH accounts
import base64, grp, hashlib, hmac, html, json, os, pwd, re, secrets, shutil, signal, socket, subprocess, sys, threading, time, uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, urlsplit

PANEL_VER = "v16"
BASE = os.environ.get("V2PANEL_BASE", "/opt/v2panel")
V2CONF = os.environ.get("V2PANEL_CONF", "/usr/local/etc/v2ray/config.json")
USERS_F = os.path.join(BASE, "users.json")
SETTINGS_F = os.path.join(BASE, "settings.json")
TEST = bool(os.environ.get("V2PANEL_TEST"))
CALLS = []  # recorded system commands (test mode only)
MANAGED_TEST = set()  # accounts "created" during a test run
SSH_GROUP = "acerossh"
LOCK = threading.RLock()
SESSIONS = {}
FAILS = {}
V2_PROTOS = ("vmess", "vless")
PROTOS = ("vmess", "vless", "ssh")
SYS_PROTOS = ("ssh",)  # accounts with a username + password
LABEL = {"vmess": "VMess WS", "vless": "VLESS WS", "ssh": "SSH"}
NAME_RE = re.compile(r"^[A-Za-z0-9_-]{3,32}$")
SSH_RE = re.compile(r"^[a-z][a-z0-9_-]{2,31}$")
SSHPW_RE = re.compile(r"^[A-Za-z0-9@#%+=_.-]{4,32}$")


# ---------------------------------------------------------------- storage
def load(path, default):
    try:
        with open(path) as f:
            return json.load(f)
    except Exception:
        return default


def save(path, data):
    tmp = path + ".tmp"
    with open(tmp, "w") as f:
        json.dump(data, f, indent=2)
    os.chmod(tmp, 0o600)
    os.replace(tmp, path)


def hash_pw(pw, salt):
    return hashlib.pbkdf2_hmac("sha256", pw.encode(), bytes.fromhex(salt), 120000).hex()


def gen_pw(n=10):
    a = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
    return "".join(secrets.choice(a) for _ in range(n))


# ---------------------------------------------------------------- system helpers
def run(cmd, inp=None):
    """Run a system command. In test mode it is only recorded."""
    if TEST:
        CALLS.append(cmd)
        if cmd[0] == "useradd":
            MANAGED_TEST.add(cmd[-1])
        return 0, ""
    try:
        r = subprocess.run(cmd, input=inp, capture_output=True, text=True, timeout=30)
        return r.returncode, (r.stderr or r.stdout or "").strip()
    except Exception as e:
        return 1, str(e)


def is_managed(name):
    """True only for system accounts this panel created (in the acerossh group, nologin shell)."""
    if TEST:
        return name in MANAGED_TEST
    try:
        p = pwd.getpwnam(name)
        return p.pw_gid == grp.getgrnam(SSH_GROUP).gr_gid and p.pw_shell.endswith("nologin")
    except KeyError:
        return False


def sys_user_exists(name):
    try:
        pwd.getpwnam(name)
        return True
    except KeyError:
        return False


def exp_date(u):
    return time.strftime("%Y-%m-%d", time.gmtime(u["expires"])) if u.get("expires") else ""


def ssh_expiry(u):
    return run(["usermod", "-e", exp_date(u), u["name"]])


def ssh_kill(name):
    run(["pkill", "-KILL", "-u", name])


def ssh_create(u):
    name = u["name"]
    run(["groupadd", "-f", SSH_GROUP])
    if not sys_user_exists(name):
        rc, err = run(["useradd", "-M", "-N", "-g", SSH_GROUP, "-s", "/usr/sbin/nologin", "-c", "acero-ssh", name])
        if rc != 0:
            return False, "useradd: " + err
    elif not is_managed(name):
        return False, "May system account na may ganyang name."
    rc, err = run(["chpasswd"], f"{name}:{u['pw']}\n")
    if rc != 0:
        return False, "chpasswd: " + err
    ssh_expiry(u)
    run(["usermod", "-L" if u.get("disabled") else "-U", name])
    return True, ""


def ssh_delete(name):
    if not is_managed(name):
        return
    ssh_kill(name)
    run(["userdel", name])


def ssh_state(u):
    if u.get("disabled"):
        run(["usermod", "-L", u["name"]])
        ssh_kill(u["name"])
    else:
        run(["usermod", "-U", u["name"]])


def ssh_sessions():
    """{username: [pid, ...]} of live SSH connections (one entry per connection)."""
    if TEST:
        return {}
    rc, out = 0, ""
    try:
        out = subprocess.run(["ps", "-eo", "pid=,args="], capture_output=True, text=True, timeout=10).stdout
    except Exception:
        return {}
    res = {}
    for line in out.splitlines():
        m = re.match(r"\s*(\d+)\s+sshd: ([a-z][a-z0-9_-]*)@", line)
        if m:
            res.setdefault(m.group(2), []).append(int(m.group(1)))
    return res


def ssh_enforce(users):
    """Kick the newest connections of SSH users that went over their device limit."""
    limits = {u["name"]: u.get("maxdev", 0) for u in users if u["proto"] == "ssh" and u.get("maxdev", 0) > 0}
    if not limits:
        return
    for name, pids in ssh_sessions().items():
        lim = limits.get(name)
        if lim and len(pids) > lim:
            for pid in sorted(pids)[lim:]:
                try:
                    os.kill(pid, signal.SIGTERM)
                except Exception:
                    pass


def ssh_ensure_all():
    """After a restore/reinstall, recreate missing SSH system accounts from users.json."""
    for u in load(USERS_F, []):
        if u["proto"] == "ssh" and not sys_user_exists(u["name"]):
            ssh_create(u)


# ---------------------------------------------------------------- v2ray
def build_config(users, st):
    live = [u for u in users if not u.get("disabled")]
    vm = [{"id": u["uuid"], "alterId": 0, "email": u["name"]} for u in live if u["proto"] == "vmess"]
    vl = [{"id": u["uuid"], "email": u["name"]} for u in live if u["proto"] == "vless"]
    # placeholder clients keep the inbound valid when no real users exist
    vm.append({"id": st["ph_vmess"], "alterId": 0, "email": "_placeholder"})
    vl.append({"id": st["ph_vless"], "email": "_placeholder"})
    return {
        "log": {"loglevel": "warning"},
        "inbounds": [
            {"tag": "vmess-ws", "listen": "127.0.0.1", "port": 10001, "protocol": "vmess",
             "settings": {"clients": vm},
             "streamSettings": {"network": "ws", "wsSettings": {"path": "/vmess"}}},
            {"tag": "vless-ws", "listen": "127.0.0.1", "port": 10002, "protocol": "vless",
             "settings": {"clients": vl, "decryption": "none"},
             "streamSettings": {"network": "ws", "wsSettings": {"path": "/vless"}}},
        ],
        "outbounds": [{"protocol": "freedom", "tag": "direct"},
                      {"protocol": "blackhole", "tag": "blocked"}],
    }


def apply_config():
    with LOCK:
        st = load(SETTINGS_F, {})
        users = load(USERS_F, [])
        os.makedirs(os.path.dirname(V2CONF), exist_ok=True)
        save(V2CONF, build_config(users, st))
        os.chmod(V2CONF, 0o644)
        if not TEST:
            subprocess.run(["systemctl", "restart", "v2ray"], check=False)


def make_link(u, st):
    ip, port, host = st["ip"], st["port"], st.get("host", "")
    if u["proto"] == "vmess":
        j = {"v": "2", "ps": u["name"], "add": ip, "port": str(port), "id": u["uuid"], "aid": "0",
             "scy": "auto", "net": "ws", "type": "none", "host": host, "path": "/vmess", "tls": ""}
        return "vmess://" + base64.b64encode(json.dumps(j).encode()).decode()
    link = f"vless://{u['uuid']}@{ip}:{port}?encryption=none&security=none&type=ws&path=%2Fvless"
    if host:
        link += "&host=" + quote(host)
    return link + "#" + quote(u["name"])


def ssh_cred(u, st):
    return f"{st['ip']}:{st.get('ssh_port', 22)}@{u['name']}:{u['pw']}"


def ssh_payload(st):
    host = st.get("host") or st["ip"]
    return f"GET /ssh HTTP/1.1[crlf]Host: {host}[crlf]Upgrade: websocket[crlf][crlf]"


def reaper():
    while True:
        time.sleep(30)
        try:
            with LOCK:
                users = load(USERS_F, [])
                now = int(time.time())
                keep = [u for u in users if not u.get("expires") or u["expires"] > now]
                if len(keep) != len(users):
                    gone = [u for u in users if u not in keep]
                    for u in gone:
                        if u["proto"] == "ssh":
                            ssh_delete(u["name"])
                    save(USERS_F, keep)
                    if any(u["proto"] in V2_PROTOS for u in gone):
                        apply_config()
                    users = keep
                ssh_enforce(users)
        except Exception as e:
            print("reaper error:", e, flush=True)


# ---------------------------------------------------------------- live stats (Server tab)
HIST_N = 120   # samples kept (120 x 5s = 10 minutes)
HIST_STEP = 5  # seconds between samples
HIST = {k: [] for k in ("cpu", "ram", "swap", "tx", "rx", "conn")}
MON = {"cpu_prev": None, "net_prev": None, "net": {"tx": 0.0, "rx": 0.0}, "slow": None, "slow_t": 0, "v2ver": None}
MON_LOCK = threading.Lock()


def _read(path):
    with open(path) as f:
        return f.read()


def cpu_pct():
    try:
        v = [int(x) for x in _read("/proc/stat").split("\n", 1)[0].split()[1:9]]
    except Exception:
        return None
    idle, tot = v[3] + v[4], sum(v)
    prev, MON["cpu_prev"] = MON["cpu_prev"], (idle, tot)
    if not prev or tot <= prev[1]:
        return None
    return max(0.0, min(100.0, 100.0 * (1 - (idle - prev[0]) / (tot - prev[1]))))


def mem_info():
    m = {}
    for line in _read("/proc/meminfo").splitlines():
        k, _, v = line.partition(":")
        try:
            m[k] = int(v.split()[0]) * 1024
        except Exception:
            pass
    tot = m.get("MemTotal", 0)
    st = m.get("SwapTotal", 0)
    return tot, tot - m.get("MemAvailable", 0), st, st - m.get("SwapFree", 0)


def net_bytes():
    rx = tx = 0
    for line in _read("/proc/net/dev").splitlines()[2:]:
        name, _, rest = line.partition(":")
        name = name.strip()
        if not rest or name == "lo" or name.startswith(("docker", "veth", "br-")):
            continue
        f = rest.split()
        rx += int(f[0])
        tx += int(f[8])
    return rx, tx


def sock_counts():
    tcp = udp = 0
    for fn, is_tcp in (("tcp", 1), ("tcp6", 1), ("udp", 0), ("udp6", 0)):
        try:
            lines = _read("/proc/net/" + fn).splitlines()[1:]
        except Exception:
            continue
        for ln in lines:
            f = ln.split()
            if len(f) < 4:
                continue
            if is_tcp and f[3] != "0A":  # 0A = LISTEN
                tcp += 1
            elif not is_tcp and f[3] != "07":  # 07 = unconnected UDP
                udp += 1
    return tcp, udp


def slow_info():
    """Things that need a subprocess: service state, V2Ray uptime/version, IP addresses."""
    out = {"svc": {}, "v2up": None, "ips": []}
    for svc in ("v2ray", "v2sshws", "nginx"):
        out["svc"][svc] = run(["systemctl", "is-active", "--quiet", svc])[0] == 0
    try:
        os_up = float(_read("/proc/uptime").split()[0])
        rc, txt = run(["systemctl", "show", "v2ray", "-p", "ActiveEnterTimestampMonotonic", "--value"])
        if rc == 0 and txt.strip().isdigit() and int(txt) > 0:
            out["v2up"] = max(0, int(os_up - int(txt) / 1e6))
    except Exception:
        pass
    if MON["v2ver"] is None:
        rc, txt = run(["/usr/local/bin/v2ray", "version"])
        mm = re.search(r"(\d+\.\d+\.\d+)", txt or "")
        MON["v2ver"] = mm.group(1) if (rc == 0 and mm) else ""
    ips = []
    st = load(SETTINGS_F, {})
    if st.get("ip"):
        ips.append(st["ip"])
    rc, txt = run(["ip", "-o", "addr", "show", "scope", "global"])
    if rc == 0:
        for ln in txt.splitlines():
            f = ln.split()
            if len(f) > 3 and f[1] not in ("lo",) and not f[1].startswith(("docker", "veth", "br-")) and f[2] in ("inet", "inet6"):
                a = f[3].split("/")[0]
                if a not in ips:
                    ips.append(a)
    out["ips"] = ips
    return out


# ---------------------------------------------------------------- optimize (BBR tuning + swap)
ETC = os.path.join(BASE, "etc-test") if TEST else ""
TUNE_F = ETC + "/etc/sysctl.d/99-acero-tune.conf"
MODS_F = ETC + "/etc/modules-load.d/acero-bbr.conf"
LIM_D = ETC + "/etc/systemd/system/v2ray.service.d"
LIM_F = LIM_D + "/acero-limits.conf"
SWAP_F = ETC + "/acero.swap"
FSTAB = ETC + "/etc/fstab"
TUNE = (
    ("net.core.default_qdisc", "fq"),
    ("net.ipv4.tcp_congestion_control", "bbr"),
    ("net.ipv4.tcp_fastopen", "3"),
    ("net.ipv4.tcp_slow_start_after_idle", "0"),
    ("net.ipv4.tcp_mtu_probing", "1"),
    ("net.ipv4.tcp_keepalive_time", "300"),
    ("net.ipv4.tcp_keepalive_intvl", "30"),
    ("net.ipv4.tcp_keepalive_probes", "5"),
    ("net.core.somaxconn", "4096"),
    ("net.core.netdev_max_backlog", "16384"),
    ("net.ipv4.tcp_max_syn_backlog", "8192"),
    ("net.core.rmem_max", "16777216"),
    ("net.core.wmem_max", "16777216"),
    ("net.ipv4.tcp_rmem", "4096 131072 16777216"),
    ("net.ipv4.tcp_wmem", "4096 65536 16777216"),
    ("fs.file-max", "1048576"),
)


def sysctl_get(key):
    try:
        return _read("/proc/sys/" + key.replace(".", "/")).strip()
    except Exception:
        return ""


def default_iface():
    try:
        for ln in _read("/proc/net/route").splitlines()[1:]:
            f = ln.split()
            if len(f) > 3 and f[1] == "00000000" and int(f[3], 16) & 2:
                return f[0]
    except Exception:
        pass
    return ""


def swap_info():
    total = 0
    try:
        for ln in _read("/proc/meminfo").splitlines():
            if ln.startswith("SwapTotal:"):
                total = int(ln.split()[1]) // 1024
    except Exception:
        pass
    ours = False
    try:
        ours = any(ln.split()[0] == "/acero.swap" for ln in _read("/proc/swaps").splitlines()[1:] if ln.strip())
    except Exception:
        pass
    return total, ours or os.path.exists(SWAP_F)


def bbr_module():
    """True if tcp_bbr exists as a loadable kernel module (it is not listed as available until loaded)."""
    import glob
    return bool(glob.glob(f"/lib/modules/{os.uname().release}/kernel/net/ipv4/tcp_bbr.ko*"))


def opt_status():
    avail = sysctl_get("net.ipv4.tcp_available_congestion_control")
    cc = sysctl_get("net.ipv4.tcp_congestion_control")
    return {
        "kernel": os.uname().release,
        "cc": cc or "?",
        "qdisc": sysctl_get("net.core.default_qdisc") or "?",
        "avail": avail,
        "mod": bbr_module(),
        "tuned": os.path.exists(TUNE_F) and (cc == "bbr" or TEST),
        "ram": mem_info()[0] // 1048576,
    }


def tune_on():
    """Apply BBR + TCP tuning. Returns (ok, message). Nothing stays if BBR cannot be enabled."""
    with LOCK:
        st = load(SETTINGS_F, {})
        if not TEST and "bbr" not in sysctl_get("net.ipv4.tcp_available_congestion_control").split():
            run(["modprobe", "tcp_bbr"])
        if not TEST and "bbr" not in sysctl_get("net.ipv4.tcp_available_congestion_control").split():
            return False, "Walang BBR ang kernel ng VPS na ito (o hindi pinapayagan ng provider; karaniwan sa OpenVZ/LXC)."
        prev = st.get("opt_prev") or {k: sysctl_get(k) for k, _ in TUNE}
        applied = []
        for k, v in TUNE:
            rc, _ = run(["sysctl", "-w", f"{k}={v}"])
            if rc == 0:
                applied.append((k, v))
        if not TEST and sysctl_get("net.ipv4.tcp_congestion_control") != "bbr":
            for k, v in prev.items():
                if v:
                    run(["sysctl", "-w", f"{k}={v}"])
            return False, "Hindi na-enable ang BBR. Walang ibinago."
        os.makedirs(os.path.dirname(TUNE_F), exist_ok=True)
        os.makedirs(os.path.dirname(MODS_F), exist_ok=True)
        with open(TUNE_F, "w") as f:
            f.write("# ACERO SCRIPT tuning (galing sa panel: Server > Optimize)\n")
            for k, v in applied:
                f.write(f"{k} = {v}\n")
        with open(MODS_F, "w") as f:
            f.write("tcp_bbr\n")
        os.makedirs(LIM_D, exist_ok=True)
        with open(LIM_F, "w") as f:
            f.write("[Service]\nLimitNOFILE=1048576\n")
        run(["systemctl", "daemon-reload"])
        dev = default_iface()
        if dev:
            run(["tc", "qdisc", "replace", "dev", dev, "root", "fq"])
        st["opt_prev"] = prev
        save(SETTINGS_F, st)
        return True, ""


def tune_off():
    with LOCK:
        st = load(SETTINGS_F, {})
        prev = st.get("opt_prev") or {}
        for k, v in prev.items():
            if v:
                run(["sysctl", "-w", f"{k}={v}"])
        for f in (TUNE_F, MODS_F, LIM_F):
            try:
                os.remove(f)
            except OSError:
                pass
        run(["systemctl", "daemon-reload"])
        dev = default_iface()
        if dev:
            run(["tc", "qdisc", "replace", "dev", dev, "root", prev.get("net.core.default_qdisc") or "fq_codel"])
        st.pop("opt_prev", None)
        save(SETTINGS_F, st)
        return True, ""


def swap_on(mb):
    with LOCK:
        total, ours = swap_info()
        if ours or total > 0:
            return False, "May swap na ang VPS na ito."
        if mb not in (512, 1024, 2048):
            return False, "Invalid na laki."
        if not TEST:
            try:
                free = shutil.disk_usage("/").free // (1024 * 1024)
            except Exception:
                free = 0
            if free < mb + 1024:
                return False, "Kulang ang libreng disk para sa swap."
        os.makedirs(os.path.dirname(SWAP_F) or "/", exist_ok=True)
        rc, err = run(["fallocate", "-l", f"{mb}M", SWAP_F])
        if rc != 0:
            rc, err = run(["dd", "if=/dev/zero", f"of={SWAP_F}", "bs=1M", f"count={mb}"])
        if rc != 0:
            return False, "Hindi nagawa ang swap file: " + err[:80]
        run(["chmod", "600", SWAP_F])
        rc, err = run(["mkswap", SWAP_F])
        if rc == 0:
            rc, err = run(["swapon", SWAP_F])
        if rc != 0:
            try:
                os.remove(SWAP_F)
            except OSError:
                pass
            return False, "Hindi ma-enable ang swap dito (baka hindi pinapayagan ng provider): " + err[:80]
        try:
            os.makedirs(os.path.dirname(FSTAB), exist_ok=True)
            cur = open(FSTAB).read() if os.path.exists(FSTAB) else ""
            if "/acero.swap" not in cur:
                with open(FSTAB, "a") as f:
                    f.write(("" if cur.endswith("\n") or not cur else "\n") + "/acero.swap none swap sw 0 0\n")
        except OSError:
            pass
        run(["sysctl", "-w", "vm.swappiness=10"])
        return True, ""


def swap_off():
    with LOCK:
        run(["swapoff", SWAP_F])
        try:
            os.remove(SWAP_F)
        except OSError:
            pass
        try:
            if os.path.exists(FSTAB):
                keep = [ln for ln in open(FSTAB).read().splitlines() if "/acero.swap" not in ln]
                with open(FSTAB, "w") as f:
                    f.write("\n".join(keep) + ("\n" if keep else ""))
        except OSError:
            pass
        return True, ""


def sample_once():
    now = time.time()
    c = cpu_pct()
    tot, used, st, su = mem_info()
    rx, tx = net_bytes()
    tcp, udp = sock_counts()
    with MON_LOCK:
        prev, MON["net_prev"] = MON["net_prev"], (now, rx, tx)
        if prev and now > prev[0]:
            MON["net"] = {"rx": max(0, rx - prev[1]) / (now - prev[0]), "tx": max(0, tx - prev[2]) / (now - prev[0])}
        row = {"cpu": c, "ram": used * 100.0 / tot if tot else 0.0, "swap": su * 100.0 / st if st else 0.0,
               "tx": MON["net"]["tx"], "rx": MON["net"]["rx"], "conn": tcp + udp}
        for k, v in row.items():
            if v is None:
                continue
            HIST[k].append(round(v, 2))
            del HIST[k][:-HIST_N]


def sampler():
    n = 0
    while True:
        try:
            sample_once()
            if n % 3 == 0 or MON["slow"] is None:
                MON["slow"] = slow_info()
        except Exception as e:
            print("sampler error:", e, flush=True)
        n += 1
        time.sleep(HIST_STEP)


def stats_snapshot():
    if not HIST["ram"]:
        sample_once()
    if MON["slow"] is None:
        MON["slow"] = slow_info()
    tot, used, st, su = mem_info()
    d = shutil.disk_usage("/")
    tcp, udp = sock_counts()
    rx, tx = net_bytes()
    mhz = 0
    try:
        for line in _read("/proc/cpuinfo").splitlines():
            if line.lower().startswith("cpu mhz"):
                mhz = float(line.split(":")[1])
                break
    except Exception:
        pass
    try:
        load_1 = _read("/proc/loadavg").split()[0]
        os_up = int(float(_read("/proc/uptime").split()[0]))
    except Exception:
        load_1, os_up = "-", None
    rss = thr = 0
    try:
        for line in _read("/proc/self/status").splitlines():
            if line.startswith("VmRSS:"):
                rss = int(line.split()[1]) * 1024
            elif line.startswith("Threads:"):
                thr = int(line.split()[1])
    except Exception:
        pass
    slow = MON["slow"] or {}
    with MON_LOCK:
        hist = {k: list(v) for k, v in HIST.items()}
        cur = dict(MON["net"])
        last_cpu = hist["cpu"][-1] if hist["cpu"] else None
    return {
        "ver": PANEL_VER, "step": HIST_STEP,
        "cpu": {"pct": last_cpu, "cores": os.cpu_count() or 1, "mhz": mhz, "load": load_1},
        "ram": {"used": used, "total": tot, "pct": used * 100.0 / tot if tot else 0},
        "swap": {"used": su, "total": st, "pct": su * 100.0 / st if st else 0},
        "disk": {"used": d.used, "total": d.total, "free": d.free, "pct": d.used * 100.0 / d.total},
        "net": {"sent": tx, "recv": rx, "tx": cur["tx"], "rx": cur["rx"]},
        "conn": {"tcp": tcp, "udp": udp},
        "up": {"os": os_up, "v2ray": slow.get("v2up")},
        "panel": {"ram": rss, "threads": thr},
        "svc": slow.get("svc", {}), "v2ver": MON["v2ver"] or "", "ips": slow.get("ips", []),
        "hist": hist,
    }


# ---------------------------------------------------------------- html
CSS = r"""
:root{color-scheme:dark;--bg:#0b1120;--card:#141c32;--card2:#1b2540;--line:#25304d;--tx:#eaeef8;--mu:#97a3c2;--ac:#ffc83d;--acx:#1a1400;--ok:#4cd6a0;--wn:#ff9d5c;--er:#ff7a8a;--nav:62px}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);background-image:radial-gradient(700px 320px at 50% -100px,#1b2750,transparent 70%);background-repeat:no-repeat;color:var(--tx);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.w{max-width:720px;margin:0 auto;padding:0 14px calc(var(--nav) + 90px + env(safe-area-inset-bottom,0px))}
:focus-visible{outline:2px solid var(--ac);outline-offset:2px}
h1{font-size:24px;line-height:1.2;letter-spacing:-.02em;margin:0}
h2{font-size:16px;margin:0 0 10px}
.m{color:var(--mu);font-size:13px}
.hide{display:none!important}
/* top bar */
.bar{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0 -14px 14px;padding:calc(10px + env(safe-area-inset-top,0px)) 14px 10px;background:rgba(11,17,32,.92);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:17px;letter-spacing:-.01em;line-height:1.1}
.brand small{display:block;font-size:10px;font-weight:700;letter-spacing:.18em;color:var(--ac)}
.bt{display:flex;align-items:center;gap:10px}.bt form{margin:0}
.live{display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#7ee7a0;background:#123a22;padding:4px 10px;border-radius:99px;white-space:nowrap}
.live i{width:7px;height:7px;border-radius:50%;background:#4cd6a0;box-shadow:0 0 0 0 rgba(76,214,160,.6);animation:pl 2s infinite}
@keyframes pl{70%{box-shadow:0 0 0 6px rgba(76,214,160,0)}100%{box-shadow:0 0 0 0 rgba(76,214,160,0)}}
/* form controls */
label{display:block;font-size:13px;color:var(--mu);margin-top:10px}
input,select{font:inherit;width:100%;min-height:44px;padding:0 12px;margin:4px 0 6px;border-radius:12px;border:1px solid var(--line);background:#0a0f1d;color:var(--tx)}
input:focus{border-color:var(--ac);outline:0}
button{font:inherit;font-weight:700;min-height:44px;padding:0 16px;border-radius:12px;border:1px solid var(--ac);background:var(--ac);color:var(--acx);cursor:pointer;white-space:nowrap}
button:disabled{opacity:.45;cursor:not-allowed}
button.g{background:var(--card2);border-color:var(--line);color:var(--tx);font-weight:600}
button.d{background:#3a1620;border-color:#7a2332;color:#ffb4bd;font-weight:600}
button.sm{min-height:36px;padding:0 12px;font-size:14px}
button.w100{width:100%}
button.okc{background:var(--ok)!important;border-color:var(--ok)!important;color:#06251a!important}
.row{display:flex;gap:10px}.row>*{flex:1;min-width:0}
.hint{font-size:12px;color:var(--mu);min-height:18px}.hint.good{color:var(--ok)}.hint.bad{color:var(--er)}
.err{color:var(--er);background:#2a1118;border:1px solid #5b1e2b;padding:10px 12px;border-radius:12px;margin-bottom:12px}
/* tabs */
.tab{display:none}.tab.on{display:block}
.nav{position:fixed;left:0;right:0;bottom:0;z-index:20;background:rgba(11,17,32,.94);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-top:1px solid var(--line);padding-bottom:env(safe-area-inset-bottom,0px)}
.nav div{max-width:720px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr)}
.nav button{background:none;border:0;border-radius:0;color:var(--mu);font-weight:600;font-size:12px;min-height:var(--nav);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px}
.nav button svg{width:22px;height:22px}
.nav button.on{color:var(--ac)}
.fabw{position:fixed;left:0;right:0;z-index:15;bottom:calc(var(--nav) + 14px + env(safe-area-inset-bottom,0px));pointer-events:none}
.fabw div{max-width:720px;margin:0 auto;padding:0 14px;display:flex;justify-content:flex-end}
.fab{pointer-events:auto;min-height:52px;padding:0 20px;border-radius:99px;font-size:16px;box-shadow:0 8px 24px rgba(0,0,0,.55)}
/* stats */
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:14px}
.st{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:10px 6px;text-align:center}
.st b{display:block;font-size:24px;line-height:1.1;letter-spacing:-.02em}
.st span{font-size:12px;color:var(--mu)}
.st.hot b{color:var(--wn)}.st.live2 b{color:var(--ok)}.st.bad b{color:var(--er)}
/* search + chips */
.tools input{margin:0 0 10px}
.chips{display:flex;gap:6px;overflow-x:auto;margin:0 -14px 12px;padding:0 14px 2px;scrollbar-width:none}
.chips::-webkit-scrollbar{display:none}
.chip{min-height:36px;padding:0 13px;border-radius:99px;background:var(--card2);border-color:var(--line);color:var(--tx);font-weight:600;font-size:14px;flex:none}
.chip em{font-style:normal;color:var(--mu);font-weight:500;margin-left:4px}
.chip.on{background:var(--ac);border-color:var(--ac);color:var(--acx)}.chip.on em{color:#5a4600}
/* users */
.list{display:grid;grid-template-columns:minmax(0,1fr);gap:10px;margin-bottom:14px}
.u{min-width:0;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:14px}
.u.new{border-color:var(--ac);animation:glow 2.2s ease-out}
@keyframes glow{0%{box-shadow:0 0 0 0 rgba(255,200,61,.55)}100%{box-shadow:0 0 0 14px rgba(255,200,61,0)}}
.uh{display:flex;align-items:center;gap:10px}
.un{flex:1;min-width:0}.un b{display:block;font-size:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.un .m{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dot{flex:none;width:10px;height:10px;border-radius:50%;background:var(--ok)}.dot.soon{background:var(--wn)}.dot.off{background:var(--er)}
.tag{font-size:12px;font-weight:600;padding:2px 9px;border-radius:99px;background:var(--card2);color:var(--mu);white-space:nowrap}
.tag.vmess{background:#1b2a52;color:#9bb8ff}.tag.vless{background:#173b3a;color:#7ee7d9}.tag.ssh{background:#3a2a14;color:#ffcf8a}
.tag.off{background:#3a1620;color:#ffb4bd}.tag.soon{background:#3a2e10;color:#ffd77a}
.tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.pb{height:4px;border-radius:99px;background:var(--card2);margin:10px 0 0;overflow:hidden}.pb i{display:block;height:100%;background:var(--ok)}
.pb.soon i{background:var(--wn)}.pb.off i{background:var(--er)}
.ua{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.ua>button{flex:1}
.mg{flex:1}
.mg summary{list-style:none;cursor:pointer;min-height:44px;display:flex;align-items:center;justify-content:center;border-radius:12px;border:1px solid var(--line);background:var(--card2);font-weight:600}
.mg summary::-webkit-details-marker{display:none}
.mg[open]{flex:1 1 100%;order:3}
.mg[open] summary{background:#0a0f1d}
.mgb{padding-top:12px;display:grid;gap:12px}
.cr{display:flex;gap:8px;align-items:center}.cr input{margin:0;font-size:13px}
.blk>.m{margin-bottom:4px;display:block}
.acts{display:flex;flex-wrap:wrap;gap:8px}
.acts form{display:flex;gap:6px;margin:0}
.acts input{width:92px;margin:0}
.ext{display:flex;gap:6px;flex-wrap:wrap;align-items:center}.ext form{margin:0}
.none{text-align:center;padding:28px 14px;background:var(--card);border:1px dashed var(--line);border-radius:16px}
/* server + settings */
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:14px;margin-bottom:14px}
.ir{display:flex;align-items:center;gap:10px;padding:10px 0;border-top:1px solid var(--line)}.ir:first-of-type{border-top:0}
.ir>div{flex:1;min-width:0}.ir .m{display:block}.ir b{display:block;font-weight:600;overflow-wrap:anywhere;font-size:14px}
.meter{height:8px;border-radius:99px;background:var(--card2);overflow:hidden;margin-top:6px}.meter i{display:block;height:100%;background:var(--ok)}
.meter.hi i{background:var(--wn)}
/* server dashboard */
.sub{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:12px}.sub .chip{padding:0 11px}.sub .live{margin-left:auto;cursor:pointer}
.live.off{color:#ffb4bd;background:#3a1620}.live.off i{background:var(--er);animation:none}
.svc{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
.pill{display:inline-flex;align-items:center;gap:7px;padding:6px 12px;border-radius:99px;background:var(--card);border:1px solid var(--line);font-size:13px;font-weight:600}
.pill i{width:8px;height:8px;border-radius:50%;background:var(--mu)}.pill.ok i{background:var(--ok)}.pill.bad i{background:var(--er)}
.pill em{font-style:normal;color:var(--mu);font-weight:500}.pill em:empty{display:none}
.gr{display:grid;grid-template-columns:minmax(0,1fr);gap:12px;margin-bottom:14px}
@media(min-width:600px){.gr{grid-template-columns:repeat(2,minmax(0,1fr))}.gr .wide{grid-column:1/-1}}
.mc{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:14px 14px 10px;overflow:hidden;min-width:0}
.mh{display:flex;align-items:center;gap:8px;color:var(--mu);font-size:12px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}
.mh svg{width:18px;height:18px;color:#5b9bff;flex:none}.mhr{display:flex;align-items:center;justify-content:space-between;gap:10px}
.big{margin-top:6px;line-height:1.05;display:flex;align-items:baseline;gap:5px}.big b{font-size:38px;letter-spacing:-.03em}.big small{font-size:16px;color:var(--mu)}
.mc.hi .big b{color:var(--wn)}
.sb{color:var(--mu);font-size:13px;margin-top:6px;min-height:20px}
.ag{display:flex;justify-content:space-between;color:var(--mu);font-size:11px;font-weight:700;letter-spacing:.1em;margin:10px 0 2px}.ag b{color:var(--tx)}
.sp{display:block;width:100%;height:64px;margin-top:4px}.sp path,.sp line{vector-effect:non-scaling-stroke}
.lb{display:block;font-size:11px;font-weight:700;letter-spacing:.12em;color:var(--mu);text-transform:uppercase}
.dual{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px}.dual b{display:block;font-size:22px;letter-spacing:-.02em;margin-top:2px;overflow-wrap:anywhere}
.kv{padding-top:10px}.kv b{display:block;font-size:17px;margin-top:2px}
.leg{display:flex;gap:18px;margin:10px 0 0;font-size:14px;font-weight:600}.leg i{display:inline-block;width:18px;height:3px;border-radius:2px;margin-right:7px;vertical-align:middle}
.ipx{padding:6px 0;font-size:14px;overflow-wrap:anywhere;transition:filter .15s}.ips.blur .ipx{filter:blur(6px);user-select:none;-webkit-user-select:none}
/* dialogs */
dialog{width:min(480px,calc(100% - 24px));max-height:calc(100dvh - 24px);overflow:auto;border:1px solid var(--line);border-radius:20px;background:var(--card);color:var(--tx);padding:18px}
dialog::backdrop{background:rgba(5,8,16,.74)}
.dh{display:flex;align-items:center;justify-content:space-between;margin-bottom:2px}.dh h2{font-size:19px;margin:0}
.dlgb{display:flex;gap:10px;margin-top:12px}.dlgb button{flex:1}
.seg{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:6px 0 2px}
.seg label{position:relative;margin:0;color:inherit;font-size:inherit}
.seg input{position:absolute;opacity:0;pointer-events:none;width:1px;min-height:0}
.seg span{display:flex;flex-direction:column;align-items:center;gap:1px;min-height:58px;justify-content:center;border:1px solid var(--line);border-radius:12px;background:var(--card2);cursor:pointer}
.seg span i{font-style:normal;font-size:11px;color:var(--mu)}
.seg input:checked+span{border-color:var(--ac);background:#2a2410;box-shadow:inset 0 0 0 1px var(--ac)}
.seg input:focus-visible+span{outline:2px solid var(--ac);outline-offset:2px}
.presets{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:4px}
.pwrow{display:flex;gap:8px}.pwrow input{flex:1}.pwrow button{margin-top:4px}
.toast{position:fixed;left:50%;top:calc(12px + env(safe-area-inset-top,0px));transform:translateX(-50%);z-index:40;max-width:calc(100% - 28px);background:#0f2a1b;color:#9af0b8;border:1px solid #1f5a38;padding:10px 18px;border-radius:99px;font-size:14px;box-shadow:0 6px 24px rgba(0,0,0,.5);animation:tin .25s ease-out}
@keyframes tin{from{opacity:0;transform:translate(-50%,-10px)}}
/* login */
.lg{min-height:100vh;min-height:100dvh;display:grid;place-items:center;padding:24px 0}
.lgc{width:100%;max-width:380px;background:var(--card);border:1px solid var(--line);border-radius:22px;padding:24px 20px;box-shadow:0 20px 60px rgba(0,0,0,.45)}
.lgc .brand{margin-bottom:18px;font-size:20px}.lgc .m{margin:6px 0 14px}
.w.lw{padding-bottom:24px}
@media(max-width:520px){
  dialog{margin:auto auto 0;width:100%;max-width:none;border-radius:20px 20px 0 0;padding-bottom:calc(18px + env(safe-area-inset-bottom,0px))}
  .st b{font-size:21px}.live{font-size:12px;padding:3px 8px}
}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
"""

JS = r"""
(function(){
  var $=function(s,r){return(r||document).querySelector(s)},$$=function(s,r){return[].slice.call((r||document).querySelectorAll(s))};
  function openD(d){if(d.showModal)d.showModal();else d.setAttribute('open','')}
  function closeD(d){if(d.close)d.close();else d.removeAttribute('open')}
  /* copy */
  function copy(t,b){
    var done=function(){var o=b.getAttribute('data-l')||b.textContent;b.setAttribute('data-l',o);b.textContent='Nakopya \u2713';b.classList.add('okc');clearTimeout(b._t);b._t=setTimeout(function(){b.textContent=o;b.classList.remove('okc')},1300)};
    var fb=function(){var a=document.createElement('textarea');a.value=t;a.style.cssText='position:fixed;opacity:0';document.body.appendChild(a);a.select();try{document.execCommand('copy')}catch(x){}document.body.removeChild(a);done()};
    if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(t).then(done,fb);else fb()
  }
  document.addEventListener('click',function(e){var b=e.target.closest('[data-copy]');if(b)copy(b.getAttribute('data-copy'),b)});
  /* login: show/hide password */
  var sp=$('[data-pw]');
  if(sp)sp.addEventListener('click',function(){var i=$('#pw'),s=i.type==='password';i.type=s?'text':'password';sp.textContent=s?'Hide':'Show'});
  var w=$('.w');if(!w||!$('.nav'))return;
  /* server dashboard (live stats) */
  var BL='#5b9bff',GR='#9aa4bd',pt=null,busy=false,subv=(document.querySelector('[data-sub].on')||{getAttribute:function(){return 'dash'}}).getAttribute('data-sub'),inSrv=false;
  function fb(n){n=n||0;var u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i<4){n/=1024;i++}return(i<2?n.toFixed(i):n.toFixed(2))+' '+u[i]}
  function fup(s){if(s==null)return'-';var d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?d+'d '+h+'h':h?h+'h '+m+'m':m+'m'}
  function avg(a){var t=0;a.forEach(function(v){t+=v});return a.length?t/a.length:0}
  function pk(a){var m=0;a.forEach(function(v){if(v>m)m=v});return m}
  function pc(v){return v==null?'-':v.toFixed(1)}
  function setv(k,v){$$('[data-v="'+k+'"]').forEach(function(e){e.textContent=v})}
  function spark(c,ser,max){
    var svg=$('[data-sp="'+c+'"]');if(!svg)return;
    var W=300,H=64,n=30,mx=max||0,out='';
    ser.forEach(function(s){n=Math.max(n,s.d.length);if(!max)s.d.forEach(function(v){if(v>mx)mx=v})});
    if(!max)mx=Math.max(mx*1.25,1);
    function Y(v){return(H-2-Math.min(v,mx)/mx*(H-6)).toFixed(1)}
    ser.forEach(function(s,k){
      var d=s.d;if(d.length<2)return;
      var p=d.map(function(v,i){return((n-d.length+i)*W/(n-1)).toFixed(1)+' '+Y(v)}),
          x0=((n-d.length)*W/(n-1)).toFixed(1);
      var l='M'+p.join(' L');
      if(k===0)out+='<path d="'+l+' L'+W+' '+H+' L'+x0+' '+H+'Z" fill="'+s.c+'" fill-opacity=".16"/>';
      out+='<path d="'+l+'" fill="none" stroke="'+s.c+'" stroke-width="1.8" stroke-linejoin="round"/>'
    });
    if(ser[0]&&ser[0].d.length>1){var y=Y(avg(ser[0].d));out='<line x1="0" x2="'+W+'" y1="'+y+'" y2="'+y+'" stroke="#97a3c2" stroke-opacity=".5" stroke-dasharray="4 4"/>'+out}
    svg.innerHTML=out
  }
  function setLive(ok){var l=$('#lv');if(!l)return;l.classList.toggle('off',!ok);$('span',l).textContent=ok?'Live':'Offline'}
  function hot(id,p){var c=$('#c-'+id);if(c)c.classList.toggle('hi',p>=85)}
  function render(d){
    var h=d.hist||{};
    setv('cpu',pc(d.cpu.pct));
    setv('cpu-sub',d.cpu.cores+' Core'+(d.cpu.cores>1?'s':'')+(d.cpu.mhz?' \u00b7 '+(d.cpu.mhz/1000).toFixed(2)+' GHz':'')+' \u00b7 Load '+d.cpu.load);
    setv('cpu-avg',avg(h.cpu||[]).toFixed(0)+'%');setv('cpu-pk',pk(h.cpu||[]).toFixed(0)+'%');
    setv('ram',pc(d.ram.pct));setv('ram-sub',fb(d.ram.used)+' / '+fb(d.ram.total));
    setv('ram-avg',avg(h.ram||[]).toFixed(0)+'%');setv('ram-pk',pk(h.ram||[]).toFixed(0)+'%');
    setv('swap',pc(d.swap.pct));setv('swap-sub',d.swap.total?fb(d.swap.used)+' / '+fb(d.swap.total):'Walang swap');
    setv('swap-avg',avg(h.swap||[]).toFixed(1)+'%');setv('swap-pk',pk(h.swap||[]).toFixed(1)+'%');
    setv('disk',pc(d.disk.pct));setv('disk-sub',fb(d.disk.used)+' / '+fb(d.disk.total));
    setv('disk-free',fb(d.disk.free));setv('disk-total',fb(d.disk.total));
    var bar=$('[data-bar="disk"]');if(bar){bar.style.width=Math.min(100,d.disk.pct)+'%';bar.parentNode.classList.toggle('hi',d.disk.pct>=85)}
    hot('cpu',d.cpu.pct||0);hot('ram',d.ram.pct);hot('swap',d.swap.total?d.swap.pct:0);hot('disk',d.disk.pct);
    setv('tx',fb(d.net.tx)+'/s');setv('rx',fb(d.net.rx)+'/s');setv('sent',fb(d.net.sent));setv('recv',fb(d.net.recv));
    setv('netavg','\u2191 '+fb(avg(h.tx||[]))+'/s  \u2193 '+fb(avg(h.rx||[]))+'/s');
    setv('sock',String(d.conn.tcp+d.conn.udp));setv('tcp',String(d.conn.tcp));setv('udp',String(d.conn.udp));
    setv('upv',fup(d.up.v2ray));setv('upos',fup(d.up.os));
    setv('pram',fb(d.panel.ram));setv('pthr',String(d.panel.threads));
    setv('v2ver',d.v2ver?'v'+d.v2ver:'');
    $$('[data-svc]').forEach(function(e){var k=e.getAttribute('data-svc'),on=d.svc&&d.svc[k];e.classList.toggle('ok',!!on);e.classList.toggle('bad',d.svc&&k in d.svc&&!on)});
    var ips=$('#ips');if(ips){ips.innerHTML='';(d.ips.length?d.ips:['-']).forEach(function(a){var x=document.createElement('div');x.className='ipx';x.textContent=a;ips.appendChild(x)})}
    spark('cpu',[{d:h.cpu||[],c:BL}]);spark('ram',[{d:h.ram||[],c:BL}],100);spark('swap',[{d:h.swap||[],c:BL}],100);
    spark('net',[{d:h.tx||[],c:BL},{d:h.rx||[],c:GR}]);spark('conn',[{d:h.conn||[],c:BL}])
  }
  function poll(){
    if(busy||document.hidden)return;busy=true;
    fetch('stats',{credentials:'same-origin',cache:'no-store'}).then(function(r){if(r.status===401){location.reload();throw 0}return r.json()})
      .then(function(d){busy=false;render(d);setLive(true)}).catch(function(){busy=false;setLive(false)})
  }
  function stopPoll(){if(pt){clearInterval(pt);pt=null}}
  function sync(){stopPoll();if(inSrv&&subv==='dash'&&!document.hidden){poll();pt=setInterval(poll,5000)}}
  function svcTab(n){inSrv=(n==='server');sync()}
  document.addEventListener('visibilitychange',sync);
  $$('[data-sub]').forEach(function(b){b.addEventListener('click',function(){
    subv=b.getAttribute('data-sub');
    $$('[data-sub]').forEach(function(x){x.classList.toggle('on',x===b)});
    $('#sv-dash').classList.toggle('hide',subv!=='dash');$('#sv-conn').classList.toggle('hide',subv!=='conn');$('#sv-opt').classList.toggle('hide',subv!=='opt');
    $('#lv').classList.toggle('hide',subv!=='dash');sync()})});
  var eye=$('#eye');if(eye)eye.addEventListener('click',function(){var b=$('#ips').classList.toggle('blur');eye.textContent=b?'Show':'Hide'});
  var lvb=$('#lv');if(lvb)lvb.addEventListener('click',function(){poll()});
  /* tabs */
  var tabs=['users','server','settings'];
  function show(n,top){
    if(tabs.indexOf(n)<0)n='users';
    $$('.tab').forEach(function(t){t.classList.toggle('on',t.id==='t-'+n)});
    $$('.nav button').forEach(function(b){var on=b.getAttribute('data-tab')===n;b.classList.toggle('on',on);b.setAttribute('aria-current',on?'page':'false')});
    var f=$('#fabw');if(f)f.classList.toggle('hide',n!=='users');
    svcTab(n);
    try{history.replaceState(null,'',location.pathname+'#'+n)}catch(x){}
    if(top)window.scrollTo(0,0)
  }
  $$('.nav button').forEach(function(b){b.addEventListener('click',function(){show(b.getAttribute('data-tab'),true)})});
  show((location.hash||'').slice(1)||w.getAttribute('data-tab0')||'users',false);
  /* search + filter */
  var q=$('#q'),mode='all';
  function filt(){
    var t=(q&&q.value||'').toLowerCase().trim(),n=0,all=$$('.u');
    all.forEach(function(u){
      var m=mode==='all'||(mode==='soon'||mode==='off'?u.getAttribute('data-state')===mode:u.getAttribute('data-proto')===mode);
      var ok=m&&(!t||u.getAttribute('data-q').indexOf(t)>-1);u.classList.toggle('hide',!ok);if(ok)n++});
    var e=$('#empty');if(e)e.classList.toggle('hide',n>0||!all.length)
  }
  if(q)q.addEventListener('input',filt);
  $$('.chip[data-f]').forEach(function(c){c.addEventListener('click',function(){mode=c.getAttribute('data-f');$$('.chip[data-f]').forEach(function(x){x.classList.toggle('on',x===c)});filt()})});
  /* add-user sheet */
  var dlg=$('#dlg'),nm=$('#nm'),hint=$('#nmh'),go=$('#go'),days=$('#days'),npw=$('#npw');
  var RX={ssh:/^[a-z][a-z0-9_-]{2,31}$/,v2:/^[A-Za-z0-9_-]{3,32}$/};
  function check(){
    var r=$('input[name=proto]:checked',dlg),ssh=!!r&&r.value==='ssh';
    $$('.sshonly',dlg).forEach(function(x){x.classList.toggle('hide',!ssh)});
    if(ssh&&nm.value!==nm.value.toLowerCase())nm.value=nm.value.toLowerCase();
    var v=nm.value,ok=(ssh?RX.ssh:RX.v2).test(v);
    hint.textContent=ok?'Ayos \u2713':(ssh?'Lowercase lang, simula sa letra, 3-32 (a-z 0-9 _ -)':'3-32 na letra, numero, _ o -');
    hint.className='hint '+(!v?'':(ok?'good':'bad'));
    go.disabled=!ok
  }
  function presets(){$$('[data-days]',dlg).forEach(function(b){b.classList.toggle('on',b.getAttribute('data-days')===String(days.value))})}
  if(dlg){
    $('#addBtn').addEventListener('click',function(){openD(dlg);check();presets();nm.focus()});
    $$('[data-close]',dlg).forEach(function(b){b.addEventListener('click',function(){closeD(dlg)})});
    dlg.addEventListener('click',function(e){if(e.target===dlg)closeD(dlg)});
    $$('input[name=proto]',dlg).forEach(function(r){r.addEventListener('change',check)});
    nm.addEventListener('input',check);
    days.addEventListener('input',presets);
    $$('[data-days]',dlg).forEach(function(b){b.addEventListener('click',function(){days.value=b.getAttribute('data-days');presets()})});
    $('#genpw').addEventListener('click',function(){
      var a='abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789',r=new Uint32Array(10),s='';
      (window.crypto||window.msCrypto).getRandomValues(r);for(var i=0;i<10;i++)s+=a.charAt(r[i]%a.length);npw.value=s});
    check();presets()
  }
  /* confirm dialog for delete */
  var cfm=$('#cfm'),pend=null;
  document.addEventListener('submit',function(e){
    var f=e.target,m=f.getAttribute&&f.getAttribute('data-confirm');if(!m||f._ok)return;
    e.preventDefault();
    if(!cfm||!cfm.showModal){if(window.confirm(m)){f._ok=1;f.submit()}return}
    pend=f;$('#cfmm').textContent=m;openD(cfm)
  },true);
  if(cfm){
    $('#cfmy').addEventListener('click',function(){closeD(cfm);if(pend){pend._ok=1;pend.submit()}});
    $('#cfmn').addEventListener('click',function(){pend=null;closeD(cfm)});
    cfm.addEventListener('click',function(e){if(e.target===cfm){pend=null;closeD(cfm)}})
  }
  /* highlight the user that was just added, then clean the URL */
  var m=/[?&]ok=added&n=([^&]*)/.exec(location.search);
  if(m){
    var who=decodeURIComponent(m[1].replace(/\+/g,' '));
    var c=$$('.u').filter(function(x){return x.getAttribute('data-name')===who})[0];
    if(c){c.classList.add('new');var d=$('details',c);if(d)d.open=true;setTimeout(function(){c.scrollIntoView({block:'center'})},60)}
  }
  if(location.search){try{history.replaceState(null,'',location.pathname+location.hash)}catch(x){}}
  var t=$('#toast');if(t){setTimeout(function(){t.classList.add('hide')},3500);t.addEventListener('click',function(){t.classList.add('hide')})}
})();
"""

LOGO = ("<svg viewBox='0 0 32 32' width=32 height=32 aria-hidden=true><path d='M16 2l11 4v9c0 7-4.6 12.3-11 15C9.6 27.3 5 22 5 15V6z' fill='#ffc83d'/>"
        "<path d='M12 21l4-11 4 11M13.5 17.5h5' stroke='#1a1400' stroke-width='2.4' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>")
ICON = {
    "users": "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='9' cy='8' r='3.2'/><path d='M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 5.2a3.2 3.2 0 010 5.6M18 14.8c1.8.7 3 2.4 3 5.2'/></svg>",
    "server": "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='4' width='18' height='7' rx='2'/><rect x='3' y='13' width='18' height='7' rx='2'/><path d='M7 7.5h.01M7 16.5h.01'/></svg>",
    "settings": "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M4 7h10M18 7h2M4 17h2M10 17h10'/><circle cx='16' cy='7' r='2'/><circle cx='8' cy='17' r='2'/></svg>",
}


def page(body, nav=False, tab0="users"):
    cls = "w" if nav else "w lw"
    return ("<!doctype html><html lang=fil><head><meta charset=utf-8>"
            "<meta name=viewport content='width=device-width,initial-scale=1,viewport-fit=cover'>"
            "<meta name=theme-color content='#0b1120'><meta name=robots content=noindex><title>ACERO Panel</title>"
            f"<style>{CSS}</style></head><body><div class='{cls}' data-tab0='{tab0}'>{body}</div><script>{JS}</script></body></html>")


def login_page(err=""):
    e = f"<div class=err role=alert>{html.escape(err)}</div>" if err else ""
    return page(f"<main class=lg><div class=lgc><div class=brand>{LOGO}<div>ACERO<small>SCRIPT</small></div></div>"
                "<h1>Mag-login</h1><p class=m>Ilagay ang password ng panel para makapasok.</p>"
                f"{e}<form method=post action=login><label for=pw>Password</label>"
                "<div class=pwrow><input id=pw type=password name=pw autofocus required autocomplete=current-password>"
                "<button type=button class=g data-pw>Show</button></div>"
                "<button class=w100 style='margin-top:8px'>Login</button></form></div></main>")


def fmt_exp(u):
    if not u.get("expires"):
        return "walang expiry"
    left = u["expires"] - int(time.time())
    if left <= 0:
        return "expired"
    d, h = left // 86400, (left % 86400) // 3600
    return f"{d}d {h}h na lang"


FLASH = {"added": "Naidagdag si {n}.", "deleted": "Nabura si {n}.", "renewed": "Na-extend si {n}.",
         "on": "Na-enable si {n}.", "off": "Na-disable si {n}.", "host": "Na-save ang host.",
         "pw": "Napalitan ang password. Mag-login ulit.",
         "tune_on": "Naka-on na ang BBR + tuning.", "tune_off": "Naibalik na ang dating network settings.",
         "swap_on": "Nagawa at na-enable ang swap.", "swap_off": "Natanggal na ang swap."}


def user_state(u, now):
    exp = u.get("expires") or 0
    if u.get("disabled") or (exp and exp <= now):
        return "off"
    return "soon" if exp and exp - now <= 3 * 86400 else "ok"


def user_card(u, st, online):
    e = html.escape
    name, proto = u["name"], u["proto"]
    now = int(time.time())
    exp = u.get("expires") or 0
    state = user_state(u, now)
    tags = f"<span class='tag {proto}'>{LABEL[proto]}</span>"
    if u.get("disabled"):
        tags += "<span class='tag off'>DISABLED</span>"
    elif exp and exp <= now:
        tags += "<span class='tag off'>EXPIRED</span>"
    elif state == "soon":
        tags += "<span class='tag soon'>malapit nang mag-expire</span>"
    meta = fmt_exp(u)
    if proto in SYS_PROTOS:
        n = len(online.get(name, []))
        lim = u.get("maxdev", 0)
        meta += f" · online: {n}" + (f"/{lim}" if lim else "")
        cred = ssh_cred(u, st)
    else:
        cred = make_link(u, st)
    if u.get("note"):
        meta += " · " + u["note"]
    cp_label = "Copy login" if proto == "ssh" else "Copy link"
    extra = ""
    if proto == "ssh":
        payload = ssh_payload(st)
        extra = ("<div class=blk><span class=m>SSH-over-WebSocket payload · "
                 f"WS port {e(str(st['port']))} · path /ssh</span>"
                 f"<div class=cr><input readonly value=\"{e(payload)}\" onclick='this.select()' aria-label='Payload'>"
                 f"<button type=button class='g sm' data-copy=\"{e(payload)}\">Copy</button></div></div>"
                 f"<div class=m>User: {e(name)} · Password: {e(u['pw'])}</div>")
    nm = e(name)
    hid = f"<input type=hidden name=name value=\"{nm}\">"
    tog = "Enable" if u.get("disabled") else "Disable"
    bar = ""
    if exp and exp > now and u.get("created"):
        pct = max(0, min(100, int((exp - now) * 100 / max(exp - u["created"], 1))))
        bar = f"<div class='pb {state}'><i style='width:{pct}%'></i></div>"
    q = e((name + " " + u.get("note", "")).lower())
    dot = "" if state == "ok" else state
    return (f"<article class=u data-name=\"{nm}\" data-proto={proto} data-state={state} data-q=\"{q}\">"
            f"<div class=uh><i class='dot {dot}'></i><div class=un><b>{nm}</b><span class=m>{e(meta)}</span></div></div>"
            f"<div class=tags>{tags}</div>{bar}"
            f"<div class=ua><button type=button data-copy=\"{e(cred)}\">{cp_label}</button>"
            "<details class=mg><summary>Manage</summary><div class=mgb>"
            f"<div class=blk><span class=m>{'Login' if proto == 'ssh' else 'Link'}</span>"
            f"<div class=cr><input readonly value=\"{e(cred)}\" onclick='this.select()' aria-label='Credential'>"
            f"<button type=button class='g sm' data-copy=\"{e(cred)}\">Copy</button></div></div>"
            f"{extra}"
            "<div class=blk><span class=m>I-extend ang account</span><div class=ext>"
            f"<form method=post action=renew>{hid}<button class='g sm' name=days value=7>+7d</button></form>"
            f"<form method=post action=renew>{hid}<button class='g sm' name=days value=30>+30d</button></form>"
            f"<form method=post action=renew>{hid}<button class='g sm' name=days value=90>+90d</button></form>"
            f"<form method=post action=renew class=acts>{hid}<input type=number name=days placeholder=Days min=1 max=3650 required aria-label='Custom days'>"
            "<button class='g sm'>Extend</button></form></div></div>"
            "<div class=acts>"
            f"<form method=post action=toggle>{hid}<button class=g>{tog}</button></form>"
            f"<form method=post action=delete data-confirm=\"Burahin si {nm}? Hindi na ito mababawi.\">{hid}"
            "<button class=d>Delete</button></form></div>"
            "</div></details></div></article>")


def info_row(label, value, copy=True):
    e = html.escape
    btn = f"<button type=button class='g sm' data-copy=\"{e(value)}\">Copy</button>" if copy and value else ""
    return f"<div class=ir><div><span class=m>{e(label)}</span><b>{e(value) or '-'}</b></div>{btn}</div>"


def _ic(p):
    return ("<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' "
            f"stroke-linejoin='round' aria-hidden=true>{p}</svg>")


DI = {
    "cpu": _ic("<path d='M4 17a8 8 0 1116 0'/><path d='M12 17l4-6'/>"),
    "ram": _ic("<rect x='4' y='3' width='16' height='18' rx='2'/><path d='M8 8h8M8 12h8M8 16h4'/>"),
    "swap": _ic("<path d='M4 9h14l-3-3M20 15H6l3 3'/>"),
    "disk": _ic("<rect x='3' y='4' width='18' height='7' rx='2'/><rect x='3' y='13' width='18' height='7' rx='2'/><path d='M7 7.5h.01M7 16.5h.01'/>"),
    "net": _ic("<path d='M7 20V8M7 8L4 11M7 8l3 3M17 4v12M17 16l-3-3M17 16l3-3'/>"),
    "conn": _ic("<path d='M9 7V3M15 7V3M7 7h10v4a5 5 0 01-10 0zM12 16v5'/>"),
    "up": _ic("<circle cx='12' cy='12' r='9'/><path d='M12 7v5l3 2'/>"),
    "panel": _ic("<rect x='3' y='4' width='18' height='16' rx='2'/><path d='M3 9h18M8 14h8'/>"),
    "ip": _ic("<circle cx='12' cy='12' r='9'/><path d='M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'/>"),
}


def dash_html():
    def spark(c):
        return f"<svg class=sp data-sp={c} viewBox='0 0 300 64' preserveAspectRatio=none aria-hidden=true></svg>"

    def ag(c):
        return f"<div class=ag><span>AVG <b data-v={c}-avg>-</b></span><span>PEAK <b data-v={c}-pk>-</b></span></div>"

    def pct_card(c, title):
        return (f"<div class=mc id=c-{c}><div class=mh>{DI[c]}<span>{title}</span></div>"
                f"<div class=big><b data-v={c}>-</b><small>%</small></div><div class=sb data-v={c}-sub>&nbsp;</div>"
                f"{ag(c)}{spark(c)}</div>")

    pills = ("<div class=svc>"
             "<span class=pill data-svc=v2ray><i></i>V2Ray<em data-v=v2ver></em></span>"
             "<span class=pill data-svc=v2sshws><i></i>SSH-WS</span>"
             "<span class=pill data-svc=nginx><i></i>Nginx</span>"
             f"<span class='pill ok'><i></i>Panel<em>{PANEL_VER}</em></span></div>")
    disk = (f"<div class=mc id=c-disk><div class=mh>{DI['disk']}<span>Storage</span></div>"
            "<div class=big><b data-v=disk>-</b><small>%</small></div><div class=sb data-v=disk-sub>&nbsp;</div>"
            "<div class=meter><i data-bar=disk style='width:0'></i></div>"
            "<div class=ag><span>FREE <b data-v=disk-free>-</b></span><span>TOTAL <b data-v=disk-total>-</b></span></div></div>")
    net = (f"<div class='mc wide' id=c-net><div class=mh>{DI['net']}<span>Network</span></div>"
           "<div class=dual><div><span class=lb>Upload</span><b data-v=tx>-</b></div>"
           "<div><span class=lb>Download</span><b data-v=rx>-</b></div></div>"
           f"{spark('net')}"
           "<div class=dual><div><span class=lb>Sent</span><b data-v=sent>-</b></div>"
           "<div><span class=lb>Received</span><b data-v=recv>-</b></div></div>"
           "<div class=kv><span class=lb>Avg over window</span><b data-v=netavg>-</b></div></div>")
    conn = (f"<div class='mc wide' id=c-conn><div class=mh>{DI['conn']}<span>Connection stats</span></div>"
            "<div class=big><b data-v=sock>-</b><small>open sockets</small></div>"
            "<div class=leg><span><i style='background:#5b9bff'></i>TCP <b data-v=tcp>-</b></span>"
            "<span><i style='background:#9aa4bd'></i>UDP <b data-v=udp>-</b></span></div>"
            f"{spark('conn')}</div>")
    up = (f"<div class='mc wide' id=c-up><div class=mh>{DI['up']}<span>Uptime</span></div>"
          "<div class=dual><div><span class=lb>V2Ray</span><b data-v=upv>-</b></div>"
          "<div><span class=lb>OS</span><b data-v=upos>-</b></div></div>"
          f"<div class=mh style='margin-top:16px'>{DI['panel']}<span>Panel</span></div>"
          "<div class=dual><div><span class=lb>RAM</span><b data-v=pram>-</b></div>"
          "<div><span class=lb>Threads</span><b data-v=pthr>-</b></div></div></div>")
    ips = (f"<div class='mc wide' id=c-ip><div class=mhr><div class=mh>{DI['ip']}<span>IP addresses</span></div>"
           "<button type=button class='g sm' id=eye>Show</button></div>"
           "<div class='ips blur' id=ips><div class=ipx>-</div></div></div>")
    return pills + "<div class=gr>" + pct_card("cpu", "CPU") + pct_card("ram", "RAM") + pct_card("swap", "Swap") + disk + net + conn + up + ips + "</div>"


def opt_html():
    e = html.escape
    o = opt_status()
    swap_mb, swap_ours = swap_info()
    on = o["tuned"]
    built = "bbr" in o["avail"].split() or TEST
    if built:
        bbr_txt = "Oo"
    elif o["mod"]:
        bbr_txt = "Oo (module; ilo-load ng panel pag-enable)"
    else:
        bbr_txt = "Hindi sigurado; susubukan, at walang ibabago kung hindi kaya"

    def row(label, value):
        return f"<div class=ir><div><span class=m>{e(label)}</span><b>{e(str(value)) or '-'}</b></div></div>"
    tag = ("<span class='tag vless'>ON</span>" if on else "<span class='tag off'>OFF</span>")
    tune = (f"<div class=card><h2>Network tuning (BBR) {tag}</h2>"
            "<p class=m>BBR + fq at TCP tuning para mas stable at mas mabilis ang koneksyon, lalo na sa mobile data na may "
            "packet loss. Hindi nito tinataasan ang bandwidth ng VPS o ng linya mo, at walang limit na inilalagay sa users. "
            "Instant at hindi pinuputol ang mga nakakonekta.</p>"
            + row("Kernel", o["kernel"]) + row("Congestion control ngayon", o["cc"]) + row("Qdisc ngayon", o["qdisc"])
            + row("Suportado ang BBR", bbr_txt)
            + ("<form method=post action=opt><input type=hidden name=do value=tune_off>"
               "<button class='g w100'>Ibalik sa dati (OFF)</button></form>" if on else
               "<form method=post action=opt><input type=hidden name=do value=tune_on>"
               "<button class='w100'>I-enable ang BBR + tuning</button></form>")
            + "<p class=m>Kasama ang mas mataas na open-files limit ng V2Ray (epektibo sa susunod na restart ng V2Ray).</p></div>")
    if swap_ours:
        swform = ("<form method=post action=opt><input type=hidden name=do value=swap_off>"
                  "<button class='g w100'>Tanggalin ang swap</button></form>")
    elif swap_mb > 0:
        swform = "<p class=m>May swap na ang VPS na ito, kaya walang idadagdag.</p>"
    else:
        swform = ("<form method=post action=opt><input type=hidden name=do value=swap_on>"
                  "<label for=swsz>Laki</label><select id=swsz name=size><option value=512>512 MB</option>"
                  "<option value=1024 selected>1 GB</option><option value=2048>2 GB</option></select>"
                  "<button class='w100'>Gumawa ng swap</button></form>")
    swap = ("<div class=card><h2>Swap</h2>"
            "<p class=m>Pang-safety sa maliliit na VPS (1 GB RAM pababa) para hindi ma-kill ang V2Ray kapag napuno ang RAM. "
            "Gumagamit ng disk space.</p>"
            + row("RAM", f"{o['ram']} MB") + row("Swap ngayon", f"{swap_mb} MB" if swap_mb else "Wala")
            + swform + "</div>")
    return f"<div id=sv-opt class=hide>{tune}{swap}</div>"


def main_page(flash="", err="", tab="users", sub="dash"):
    e = html.escape
    st = load(SETTINGS_F, {})
    users = [u for u in load(USERS_F, []) if u.get("proto") in PROTOS]
    online = ssh_sessions()
    now = int(time.time())
    states = [user_state(u, now) for u in users]
    cnt = {p: sum(1 for u in users if u["proto"] == p) for p in PROTOS}
    n_soon, n_off = states.count("soon"), states.count("off")
    on_total = sum(len(v) for k, v in online.items() if any(u["name"] == k for u in users))
    errb = f"<div class=err role=alert>{e(err)}</div>" if err else ""
    toast = f"<div class=toast id=toast role=status>{e(flash)}</div>" if flash else ""
    rows = "".join(user_card(u, st, online) for u in users)
    none = ("<div class='none m' id=none>Wala pang user.<br>Pindutin ang <b>+ Add user</b> para magsimula.</div>" if not users else "")
    empty = "<div class='none m hide' id=empty>Walang tugma sa hinahanap mo.</div>"
    chips = ("<div class=chips>"
             f"<button type=button class='chip on' data-f=all>All<em>{len(users)}</em></button>"
             f"<button type=button class=chip data-f=vmess>VMess<em>{cnt['vmess']}</em></button>"
             f"<button type=button class=chip data-f=vless>VLESS<em>{cnt['vless']}</em></button>"
             f"<button type=button class=chip data-f=ssh>SSH<em>{cnt['ssh']}</em></button>"
             f"<button type=button class=chip data-f=soon>Malapit nang mag-expire<em>{n_soon}</em></button>"
             f"<button type=button class=chip data-f=off>Disabled / expired<em>{n_off}</em></button></div>")
    ip, port = st.get("ip", ""), str(st.get("port", ""))
    ssh_port = str(st.get("ssh_port", 22))
    host = st.get("host", "")

    tabs = ("users", "server", "settings")
    cls = {t: "tab on" if t == tab else "tab" for t in tabs}
    nav = "".join(f"<button type=button data-tab={t}{' class=on' if t == tab else ''}>{ICON[t]}<span>{lbl}</span></button>"
                  for t, lbl in (("users", "Users"), ("server", "Server"), ("settings", "Settings")))
    body = (
        f"<header class=bar><div class=brand>{LOGO}<div>ACERO<small>SCRIPT</small></div></div>"
        f"<div class=bt><span class=live><i></i>{on_total} online</span>"
        "<form method=post action=logout><button class='g sm'>Logout</button></form></header>"
        f"{errb}"
        # ---- users tab
        f"<section class='{cls['users']}' id=t-users>"
        "<div class=stats>"
        f"<div class=st><b>{len(users)}</b><span>Users</span></div>"
        f"<div class='st live2'><b>{on_total}</b><span>Online</span></div>"
        f"<div class='st{' hot' if n_soon else ''}'><b>{n_soon}</b><span>Expiring</span></div>"
        f"<div class='st{' bad' if n_off else ''}'><b>{n_off}</b><span>Off</span></div></div>"
        "<div class=tools><input id=q type=search placeholder='Hanapin ang user o note...' autocomplete=off aria-label='Hanapin ang user'></div>"
        f"{chips}<div class=list>{rows}</div>{none}{empty}</section>"
        # ---- server tab
        f"<section class='{cls['server']}' id=t-server>"
        "<div class=sub>"
        f"<button type=button class='chip{' on' if sub == 'dash' else ''}' data-sub=dash>Dashboard</button>"
        f"<button type=button class='chip{' on' if sub == 'conn' else ''}' data-sub=conn>Koneksyon</button>"
        f"<button type=button class='chip{' on' if sub == 'opt' else ''}' data-sub=opt>Optimize</button>"
        f"<span class='live{'' if sub == 'dash' else ' hide'}' id=lv title='I-tap para mag-refresh'><i></i><span>Live</span></span></div>"
        f"<div id=sv-dash{'' if sub == 'dash' else ' class=hide'}>{dash_html()}</div>"
        + opt_html().replace("id=sv-opt class=hide", "id=sv-opt" + ("" if sub == "opt" else " class=hide")) +
        f"<div id=sv-conn{'' if sub == 'conn' else ' class=hide'}><div class=card><h2>Koneksyon</h2>"
        + info_row("Server IP", ip) + info_row("Port (V2Ray + SSH-WS)", port) + info_row("SSH port (direct)", ssh_port)
        + info_row("VMess WS path", "/vmess") + info_row("VLESS WS path", "/vless") + info_row("SSH WS path", "/ssh")
        + (info_row("Host header", host) if host else "")
        + info_row("SSH-WS payload", ssh_payload(st))
        + "</div></div></section>"
        # ---- settings tab
        f"<section class='{cls['settings']}' id=t-settings><div class=card><h2>Host header</h2>"
        "<p class=m>Opsyonal. Para sa bug host / SNI. Isasama sa mga bagong link at payload.</p>"
        "<form method=post action=settings><label for=host>Host header</label>"
        f"<input id=host name=host value=\"{e(host)}\" autocomplete=off autocapitalize=none>"
        "<button class='g w100'>Save host</button></form></div>"
        "<div class=card><h2>Palitan ang password ng panel</h2><form method=post action=password>"
        "<label for=old>Kasalukuyang password</label><input id=old type=password name=old autocomplete=current-password>"
        "<div class=row><div><label for=n1>Bago (8+ na character)</label><input id=n1 type=password name=new1 autocomplete=new-password></div>"
        "<div><label for=n2>Ulitin</label><input id=n2 type=password name=new2 autocomplete=new-password></div></div>"
        "<button class='g w100'>Palitan</button></form></div>"
        "<p class=m>Ang pag-add/delete ng VMess o VLESS ay nagre-restart ng V2Ray (saglit na maputol ang V2Ray users). "
        "Hindi naaapektuhan ng restart ang SSH accounts.</p></section>"
        # ---- floating add + nav
        "<div class=fabw id=fabw><div><button type=button class=fab id=addBtn>+ Add user</button></div></div>"
        f"<nav class=nav aria-label='Sections'><div>{nav}</div></nav>"
        # ---- add sheet
        "<dialog id=dlg><form method=post action=add autocomplete=off>"
        "<div class=dh><h2>Add user</h2><button type=button class='g sm' data-close aria-label='Isara'>&times;</button></div>"
        "<label>Protocol</label><div class=seg>"
        "<label><input type=radio name=proto value=vmess checked><span><b>VMess</b><i>V2Ray WS</i></span></label>"
        "<label><input type=radio name=proto value=vless><span><b>VLESS</b><i>V2Ray WS</i></span></label>"
        "<label><input type=radio name=proto value=ssh><span><b>SSH</b><i>Tunnel</i></span></label></div>"
        "<label for=nm>Name</label><input id=nm name=name required autocomplete=off autocapitalize=none spellcheck=false>"
        "<div class=hint id=nmh></div>"
        "<label for=days>Valid na ilang araw (0 = walang expiry)</label>"
        "<input id=days type=number name=days value=30 min=0 max=3650 inputmode=numeric>"
        "<div class=presets><button type=button class='chip' data-days=7>7d</button><button type=button class='chip' data-days=15>15d</button>"
        "<button type=button class='chip' data-days=30>30d</button><button type=button class='chip' data-days=60>60d</button>"
        "<button type=button class='chip' data-days=90>90d</button><button type=button class='chip' data-days=0>Walang expiry</button></div>"
        "<div class='sshonly hide'><label for=npw>Password (blangko = auto)</label>"
        "<div class=pwrow><input id=npw name=pw autocomplete=off autocapitalize=none><button type=button class=g id=genpw>Generate</button></div>"
        "<label for=maxdev>Max device (0 = unlimited)</label><input id=maxdev type=number name=maxdev value=0 min=0 max=20 inputmode=numeric></div>"
        "<label for=note>Note (opsyonal)</label><input id=note name=note maxlength=60 placeholder='Pangalan ng customer'>"
        "<div class=dlgb><button type=button class=g data-close>Cancel</button><button id=go disabled>Create</button></div></form></dialog>"
        "<dialog id=cfm><h2>Sigurado ka ba?</h2><p class=m id=cfmm></p>"
        "<div class=dlgb><button type=button class=g id=cfmn>Cancel</button><button type=button class=d id=cfmy>Delete</button></div></dialog>"
        f"{toast}")
    return page(body, nav=True, tab0=tab)


# ---------------------------------------------------------------- http
class H(BaseHTTPRequestHandler):
    server_version = "v2panel"

    def log_message(self, *a):
        pass

    def ip(self):
        return self.headers.get("X-Real-IP") or self.client_address[0]

    def authed(self):
        c = self.headers.get("Cookie", "")
        for part in c.split(";"):
            k, _, v = part.strip().partition("=")
            if k == "sid" and SESSIONS.get(v, 0) > time.time():
                return True
        return False

    def send(self, code, body="", headers=None):
        b = body.encode()
        self.send_response(code)
        hdr = {"Content-Type": "text/html; charset=utf-8", "Content-Length": str(len(b)), "Cache-Control": "no-store",
               "X-Frame-Options": "DENY", "Referrer-Policy": "no-referrer"}
        hdr.update(headers or {})
        for k, v in hdr.items():
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(b)

    def redirect(self, extra=None, ok=None, n="", to="./"):
        loc = to
        if ok:
            loc += ("&" if "?" in loc else "?") + "ok=" + ok + "&n=" + quote(n)
        h = {"Location": loc}
        h.update(extra or {})
        self.send(303, "", h)

    def fail(self, msg, code=400, tab="users", sub="dash"):
        self.send(code, main_page(err=msg, tab=tab, sub=sub))

    def do_GET(self):
        u = urlsplit(self.path)
        if u.path in ("", "/", "/panel"):
            return self.redirect(to="panel/")  # old /SECRET/ links keep working
        if u.path == "/panel/stats":
            if not self.authed():
                return self.send(401, "{}", {"Content-Type": "application/json"})
            try:
                return self.send(200, json.dumps(stats_snapshot()), {"Content-Type": "application/json"})
            except Exception as e:
                return self.send(500, json.dumps({"error": str(e)}), {"Content-Type": "application/json"})
        if u.path != "/panel/":
            return self.send(404, "not found")
        if not self.authed():
            return self.send(200, login_page())
        q = parse_qs(u.query)
        flash = ""
        code = q.get("ok", [""])[0]
        if code in FLASH:
            flash = FLASH[code].format(n=q.get("n", [""])[0][:40])
        tab = q.get("tab", ["users"])[0]
        sub = q.get("sub", ["dash"])[0]
        self.send(200, main_page(flash, tab=tab if tab in ("users", "server", "settings") else "users",
                                 sub=sub if sub in ("dash", "conn", "opt") else "dash"))

    def do_POST(self):
        n = int(self.headers.get("Content-Length", 0) or 0)
        form = {k: v[0] for k, v in parse_qs(self.rfile.read(min(n, 8192)).decode()).items()}
        path = self.path.split("?")[0]
        if path.startswith("/panel/"):
            path = path[6:]  # /panel/login -> /login

        if path == "/login":
            ip = self.ip()
            cnt, until = FAILS.get(ip, (0, 0))
            if until > time.time():
                return self.send(429, login_page("Masyadong maraming mali. Subukan ulit mamaya."))
            st = load(SETTINGS_F, {})
            ok = hmac.compare_digest(hash_pw(form.get("pw", ""), st["salt"]), st["pw_hash"])
            if not ok:
                cnt += 1
                FAILS[ip] = (cnt, time.time() + 300 if cnt >= 5 else 0)
                time.sleep(1)
                return self.send(401, login_page("Maling password."))
            FAILS.pop(ip, None)
            sid = secrets.token_hex(24)
            SESSIONS[sid] = time.time() + 12 * 3600
            return self.redirect({"Set-Cookie": f"sid={sid}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200"})

        if not self.authed():
            return self.redirect()

        if path == "/logout":
            return self.redirect({"Set-Cookie": "sid=; Path=/; Max-Age=0"})

        if path == "/add":
            name, proto = form.get("name", "").strip(), form.get("proto", "")
            note = re.sub(r"[^\w .,@#()+-]", "", form.get("note", "")).strip()[:60]
            try:
                days = int(form.get("days", "0"))
            except ValueError:
                days = -1
            try:
                maxdev = int(form.get("maxdev", "0") or 0)
            except ValueError:
                maxdev = -1
            if proto not in PROTOS or not 0 <= days <= 3650:
                return self.fail("Invalid input.")
            if not (SSH_RE if proto in SYS_PROTOS else NAME_RE).match(name):
                return self.fail("Invalid name. SSH: lowercase lang, simula sa letra (3-32). V2Ray: a-z, 0-9, _ -.")
            u = {"name": name, "proto": proto, "created": int(time.time()), "note": note,
                 "expires": int(time.time()) + days * 86400 if days else 0}
            if proto in SYS_PROTOS:
                pw = form.get("pw", "").strip() or gen_pw()
                if not SSHPW_RE.match(pw):
                    return self.fail("Password: 4-32 na character, letra/numero at @ # % + = _ . - lang.")
                if not 0 <= maxdev <= 20:
                    return self.fail("Max device: 0 hanggang 20.")
                u.update({"pw": pw, "maxdev": maxdev})
            else:
                u["uuid"] = str(uuid.uuid4())
            with LOCK:
                users = load(USERS_F, [])
                if any(x["name"].lower() == name.lower() for x in users):
                    return self.fail("Existing na ang name na iyon.")
                if proto == "ssh":
                    if sys_user_exists(name) and not is_managed(name):
                        return self.fail("May system account na may ganyang name. Pumili ng iba.")
                    ok, err = ssh_create(u)
                    if not ok:
                        return self.fail("Hindi nagawa ang SSH account: " + err, 500)
                users.append(u)
                save(USERS_F, users)
                if proto in V2_PROTOS:
                    apply_config()
            return self.redirect(ok="added", n=name)

        if path in ("/delete", "/toggle", "/renew"):
            name = form.get("name", "")
            with LOCK:
                users = load(USERS_F, [])
                u = next((x for x in users if x["name"] == name), None)
                if not u:
                    return self.fail("Hindi nahanap ang user.", 404)
                v2 = u["proto"] in V2_PROTOS
                if path == "/delete":
                    if not v2:
                        ssh_delete(name)
                    users = [x for x in users if x is not u]
                    code = "deleted"
                elif path == "/toggle":
                    u["disabled"] = not u.get("disabled", False)
                    if not v2:
                        ssh_state(u)
                    code = "off" if u["disabled"] else "on"
                else:
                    try:
                        days = int(form.get("days", "0"))
                    except ValueError:
                        days = 0
                    if not 1 <= days <= 3650:
                        return self.fail("Days: 1 hanggang 3650.")
                    now = int(time.time())
                    u["expires"] = max(now, u.get("expires", 0)) + days * 86400
                    if not v2:
                        ssh_expiry(u)
                    code = "renewed"
                save(USERS_F, users)
                if v2:
                    apply_config()
            return self.redirect(ok=code, n=name)

        if path == "/opt":
            act = form.get("do", "")
            if act == "tune_on":
                ok, msg = tune_on()
            elif act == "tune_off":
                ok, msg = tune_off()
            elif act == "swap_on":
                try:
                    mb = int(form.get("size", "1024"))
                except ValueError:
                    mb = 0
                ok, msg = swap_on(mb)
            elif act == "swap_off":
                ok, msg = swap_off()
            else:
                return self.fail("Invalid na aksyon.", tab="server", sub="opt")
            if not ok:
                return self.fail(msg, 500, tab="server", sub="opt")
            return self.redirect(ok=act, to="./?tab=server&sub=opt")

        if path == "/settings":
            with LOCK:
                st = load(SETTINGS_F, {})
                host = form.get("host", "").strip()[:100]
                if not re.match(r"^[A-Za-z0-9.\-]*$", host):
                    return self.fail("Invalid host header.", tab="settings")
                st["host"] = host
                save(SETTINGS_F, st)
            return self.redirect(ok="host")

        if path == "/password":
            with LOCK:
                st = load(SETTINGS_F, {})
                if not hmac.compare_digest(hash_pw(form.get("old", ""), st["salt"]), st["pw_hash"]):
                    return self.fail("Mali ang kasalukuyang password.", tab="settings")
                new1 = form.get("new1", "")
                if len(new1) < 8 or new1 != form.get("new2", ""):
                    return self.fail("Dapat 8+ na character at magkapareho ang dalawang bagong password.", tab="settings")
                st["salt"] = secrets.token_hex(16)
                st["pw_hash"] = hash_pw(new1, st["salt"])
                save(SETTINGS_F, st)
                SESSIONS.clear()
            return self.redirect({"Set-Cookie": "sid=; Path=/; Max-Age=0"})

        self.send(404, "not found")


if __name__ == "__main__":
    if "--setpw" in sys.argv:
        st = load(SETTINGS_F, {})
        st["salt"] = secrets.token_hex(16)
        st["pw_hash"] = hash_pw(os.environ["PW"], st["salt"])
        save(SETTINGS_F, st)
        print("Password updated.")
    elif "--apply" in sys.argv:
        apply_config()
    else:
        st = load(SETTINGS_F, {})
        ssh_ensure_all()
        threading.Thread(target=reaper, daemon=True).start()
        threading.Thread(target=sampler, daemon=True).start()
        ThreadingHTTPServer(("127.0.0.1", int(st.get("panel_port", 2053))), H).serve_forever()
PYEOF
chmod 700 ${APP}/panel.py
cat > ${APP}/sshws.py <<'PYEOF'
#!/usr/bin/env python3
# SSH-over-WebSocket bridge: answers "101 Switching Protocols", then relays raw bytes to local sshd.
# Listens on 127.0.0.1 only; Nginx forwards /ssh (and "/" with an Upgrade header) to it.
import asyncio, os, socket

LISTEN = ("127.0.0.1", int(os.environ.get("SSHWS_PORT", "10022")))
TARGET = ("127.0.0.1", int(os.environ.get("SSH_PORT", "22")))
OK = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"
BAD = b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"


def nodelay(w):
    s = w.get_extra_info("socket")
    if s is not None:
        try:
            s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
        except OSError:
            pass


async def pipe(r, w):
    try:
        while True:
            d = await r.read(65536)
            if not d:
                break
            w.write(d)
            await w.drain()
    except Exception:
        pass
    finally:
        try:
            w.close()
        except Exception:
            pass


async def handle(cr, cw):
    try:
        await asyncio.wait_for(cr.readuntil(b"\r\n\r\n"), 15)  # HTTP request head; extra bytes stay buffered
    except Exception:
        cw.close()
        return
    try:
        sr, sw = await asyncio.open_connection(*TARGET)
    except Exception:
        try:
            cw.write(BAD)
            await cw.drain()
        finally:
            cw.close()
        return
    nodelay(cw)
    nodelay(sw)
    cw.write(OK)
    await cw.drain()
    await asyncio.gather(pipe(cr, sw), pipe(sr, cw))


async def main():
    srv = await asyncio.start_server(handle, *LISTEN, limit=65536)
    async with srv:
        await srv.serve_forever()


if __name__ == "__main__":
    asyncio.run(main())
PYEOF
chmod 755 ${APP}/sshws.py

if [[ $UPGRADE -eq 1 ]]; then
  python3 - <<PYEOF
import json, os, shutil
p = "${APP}/settings.json"
s = json.load(open(p))
s["ssh_port"] = ${SSH_PORT}
s.pop("ovpn_port", None)
s.pop("ovpn_proto", None)
json.dump(s, open(p, "w"), indent=2)
up = "${APP}/users.json"
try:
    users = json.load(open(up))
except Exception:
    users = []
keep = [u for u in users if u.get("proto") != "ovpn"]
if len(keep) != len(users):
    shutil.copy2(up, up + ".bak-v7")
    os.chmod(up + ".bak-v7", 0o600)
    json.dump(keep, open(up, "w"), indent=2)
    print("Removed %d OpenVPN user(s). Backup: %s.bak-v7" % (len(users) - len(keep), up))
PYEOF
  chmod 600 ${APP}/settings.json ${APP}/users.json
else
  IP="$(curl -fsS4 https://api.ipify.org || hostname -I | awk '{print $1}')"
  [[ -f ${APP}/users.json ]] || echo "[]" > ${APP}/users.json
  cat > ${APP}/settings.json <<SETEOF
{
  "ip": "${IP}",
  "port": ${PORT},
  "host": "",
  "panel_port": ${PANEL_LOCAL},
  "ssh_port": ${SSH_PORT},
  "ph_vmess": "$(cat /proc/sys/kernel/random/uuid)",
  "ph_vless": "$(cat /proc/sys/kernel/random/uuid)",
  "salt": "",
  "pw_hash": ""
}
SETEOF
  chmod 600 ${APP}/settings.json ${APP}/users.json
  PW="${PANEL_PW}" python3 ${APP}/panel.py --setpw
  echo "${SECRET}" > ${APP}/secret_path
fi

cat > /etc/systemd/system/v2panel.service <<SVCEOF
[Unit]
Description=V2 lightweight panel
After=network.target

[Service]
ExecStart=/usr/bin/python3 ${APP}/panel.py
Restart=always
User=root

[Install]
WantedBy=multi-user.target
SVCEOF

cat > /etc/systemd/system/v2sshws.service <<SVCEOF
[Unit]
Description=SSH over WebSocket bridge (127.0.0.1:${SSHWS_LOCAL} -> sshd :${SSH_PORT})
After=network.target

[Service]
Environment=SSHWS_PORT=${SSHWS_LOCAL}
Environment=SSH_PORT=${SSH_PORT}
ExecStart=/usr/bin/python3 ${APP}/sshws.py
Restart=always
User=nobody
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
SVCEOF

echo "[5/7] Configuring Nginx on port ${PORT} + HTTPS panel..."
mkdir -p /var/www/acero-acme
chmod 755 /var/www/acero-acme
IP="$(python3 -c 'import json;print(json.load(open("/opt/v2panel/settings.json"))["ip"])')"
CERT_DIR=${APP}/cert
if [[ ! -s ${CERT_DIR}/panel.crt || ! -s ${CERT_DIR}/panel.key ]]; then
  mkdir -p ${CERT_DIR}
  openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
    -keyout ${CERT_DIR}/panel.key -out ${CERT_DIR}/panel.crt \
    -subj "/CN=${IP}" -addext "subjectAltName=IP:${IP}" 2>/dev/null
  chmod 600 ${CERT_DIR}/panel.key
fi
HTTPS_PORT=""
[[ -f ${APP}/https_port ]] && HTTPS_PORT="$(cat ${APP}/https_port)"
if ! [[ "${HTTPS_PORT}" =~ ^[0-9]+$ ]]; then
  for _ in $(seq 1 50); do
    cand=$(( RANDOM % 40000 + 20000 ))
    if [[ $cand -ne $PORT && $cand -ne $SSH_PORT ]] && ! ss -ltn 2>/dev/null | awk '{print $4}' | grep -q ":${cand}$"; then
      HTTPS_PORT=$cand
      break
    fi
  done
  HTTPS_PORT="${HTTPS_PORT:-27443}"
  echo "${HTTPS_PORT}" > ${APP}/https_port
fi
rm -f /etc/nginx/sites-enabled/default
cat > /etc/nginx/conf.d/v2ray-ws.conf <<NGXEOF
server {
    listen ${PORT} default_server;
    listen [::]:${PORT} default_server;
    server_name _;

    location /vmess {
        proxy_pass http://127.0.0.1:10001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }

    location /vless {
        proxy_pass http://127.0.0.1:10002;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }

    # SSH over WebSocket: payload path /ssh, or "/" when the request has an Upgrade header
    location /ssh {
        proxy_pass http://127.0.0.1:${SSHWS_LOCAL};
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_buffering off;
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }

    location = / {
        if (\$http_upgrade !~* "websocket") {
            return 404;
        }
        proxy_pass http://127.0.0.1:${SSHWS_LOCAL};
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_buffering off;
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }

    # Let's Encrypt HTTP-01 validation (used for the panel's IP certificate)
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/acero-acme;
        default_type text/plain;
    }

    # The panel is HTTPS only: old http links (with or without /panel/) are sent to HTTPS.
    location = /${SECRET} {
        return 301 https://\$host:${HTTPS_PORT}/${SECRET}/panel/;
    }

    location /${SECRET}/ {
        return 301 https://\$host:${HTTPS_PORT}\$request_uri;
    }

    location / {
        return 404;
    }
}

server {
    listen ${HTTPS_PORT} ssl default_server;
    listen [::]:${HTTPS_PORT} ssl default_server;
    server_name _;

    ssl_certificate ${CERT_DIR}/panel.crt;
    ssl_certificate_key ${CERT_DIR}/panel.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:acero:2m;

    location = /${SECRET} {
        return 301 /${SECRET}/panel/;
    }

    location /${SECRET}/ {
        proxy_pass http://127.0.0.1:${PANEL_LOCAL}/;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header Host \$host;
        proxy_set_header X-Forwarded-Proto https;
    }

    location / {
        return 404;
    }
}
NGXEOF
nginx -t

echo "[6/7] Starting services..."
python3 ${APP}/panel.py --apply
systemctl daemon-reload
systemctl enable --now v2ray
systemctl enable --now v2sshws
systemctl restart v2sshws
systemctl enable --now v2panel
systemctl restart v2panel
systemctl enable --now nginx
systemctl restart nginx
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
  ufw allow "${PORT}/tcp" || true
  ufw allow "${HTTPS_PORT}/tcp" || true
  ufw allow "${SSH_PORT}/tcp" || true
fi

# ---- Trusted HTTPS certificate: Let's Encrypt IP certificate (free, 6-day, auto-renewed).
# Falls back to the self-signed certificate if anything fails; never stops the install.
CERT_NOTE="Self-signed cert: the browser shows a warning, accept it once."
ACME_HOME=${APP}/acme
issue_ip_cert() {
  [[ "${PORT}" == "80" ]] || { echo "Nginx is not on port 80: skipping Let's Encrypt (needs port 80)."; return 1; }
  [[ "${IP}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
  mkdir -p ${ACME_HOME}
  if [[ ! -x ${ACME_HOME}/acme.sh ]]; then
    curl -fsSL https://raw.githubusercontent.com/acmesh-official/acme.sh/master/acme.sh -o ${ACME_HOME}/acme.sh || return 1
    chmod 700 ${ACME_HOME}/acme.sh
  fi
  ${ACME_HOME}/acme.sh --home ${ACME_HOME} --issue --server letsencrypt -d "${IP}" \
    -w /var/www/acero-acme --certificate-profile shortlived --days 3 || return 1
  ${ACME_HOME}/acme.sh --home ${ACME_HOME} --install-cert -d "${IP}" \
    --fullchain-file ${CERT_DIR}/panel.crt --key-file ${CERT_DIR}/panel.key \
    --reloadcmd "systemctl reload nginx" || return 1
  chmod 600 ${CERT_DIR}/panel.key
  touch ${CERT_DIR}/letsencrypt
  return 0
}
if [[ -f ${CERT_DIR}/letsencrypt && -x ${ACME_HOME}/acme.sh ]]; then
  CERT_NOTE="Trusted Let's Encrypt certificate (auto-renews)."
elif issue_ip_cert; then
  CERT_NOTE="Trusted Let's Encrypt certificate (auto-renews)."
else
  echo "Could not get a Let's Encrypt certificate (port 80 must be open to the internet). Using the self-signed certificate."
fi
if [[ -f ${CERT_DIR}/letsencrypt ]]; then
  cat > /etc/systemd/system/acero-cert-renew.service <<SVCEOF
[Unit]
Description=Renew ACERO panel Let's Encrypt certificate

[Service]
Type=oneshot
ExecStart=${ACME_HOME}/acme.sh --cron --home ${ACME_HOME}
SVCEOF
  cat > /etc/systemd/system/acero-cert-renew.timer <<SVCEOF
[Unit]
Description=Check ACERO panel certificate twice a day

[Timer]
OnCalendar=*-*-* 03,15:17:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target
SVCEOF
  systemctl daemon-reload
  systemctl enable --now acero-cert-renew.timer || true
fi

echo "[7/7] Done."
OUT=/root/v2panel-info.txt
if [[ $UPGRADE -eq 1 ]]; then
  IP="$(python3 -c 'import json;print(json.load(open("/opt/v2panel/settings.json"))["ip"])')"
  if [[ -f "$OUT" ]]; then sed -i -e "s#^Panel URL : .*#Panel URL : https://${IP}:${HTTPS_PORT}/${SECRET}/panel/#" -e '/^OpenVPN/d' -e 's/ -u openvpn-server@acero//' "$OUT"; fi
  echo "Upgraded. Same panel URL and password as before:"
  echo "  https://${IP}:${HTTPS_PORT}/${SECRET}/panel/"
  echo "Open port ${HTTPS_PORT}/tcp in your VPS provider firewall too. ${CERT_NOTE}"
  grep -q '^SSH ' "$OUT" 2>/dev/null || cat >> "$OUT" <<INFOEOF
SSH       : port ${SSH_PORT} (direct)  |  SSH-WS payload path /ssh on port ${PORT}
INFOEOF
else
  cat > "$OUT" <<INFOEOF
Panel URL : https://${IP}:${HTTPS_PORT}/${SECRET}/panel/
Password  : ${PANEL_PW}
Panel HTTPS: port ${HTTPS_PORT}/tcp (open it in your VPS provider firewall). ${CERT_NOTE}

VMess WS  path /vmess   |   VLESS WS  path /vless   |   port ${PORT}
SSH       : port ${SSH_PORT} (direct)  |  SSH-WS payload path /ssh on port ${PORT}
Reset pw  : PW='newpass' python3 /opt/v2panel/panel.py --setpw
Logs      : journalctl -u v2panel -u v2ray -u v2sshws -f
INFOEOF
  chmod 600 "$OUT"
  echo
  echo "=========== DONE ==========="
  cat "$OUT"
  echo "============================"
  echo "Saved to ${OUT}"
fi
