ACERO VPS Guide

ACERO SCRIPT

V2Ray (VMess + VLESS WebSocket), SSH, Nginx at bagong mobile panel. Isang script, isang run.

Ano ang script na ito

Hindi pa naka-install ang kahit ano. Ang install-v2ray-panel.sh ay file lang na dadalhin mo sa VPS at ire-run doon. Wala itong nagagawa sa VPS mo hangga't hindi mo ito pinapatakbo.

OS
Debian / Ubuntu, bilang root
Core
V2Ray v4.45.2
Protocols
V2Ray (VMess at VLESS, WebSocket) at SSH
WS paths
/vmess, /vless at /ssh
Web server
Nginx, port 80 (pwedeng palitan)
Panel
Python, nasa likod ng secret path, HTTPS sa sariling port
SSH
Direct (port 22) at SSH-over-WebSocket (/ssh, port ng Nginx)

Tandaan: ginagalaw ng script ang Nginx (tinatanggal ang default site at kinukuha ang port), nag-i-install ng V2Ray at nag-e-enable ng mga service. Mas ligtas sa bagong VPS, o mag-snapshot muna ng kasalukuyan mong VPS. Nagdadagdag din ito ng SSH accounts (system users na pang-tunnel lang, walang shell). Na-test ko ang panel at ang SSH-WebSocket bridge sa isang test environment at na-check ang syntax ng script, pero hindi ko pa nasusubukan ang buong install sa totoong VPS. Subukan muna sa test account.

Na-install mo na ang lumang bersyon? I-run lang ulit ang bagong script (Step 3). Ia-upgrade nito ang panel at itatago ang mga user, password, secret path at port mo. Mula v8, V2Ray at SSH na lang ang protocols: kung may OpenVPN ang dati mong install, aalisin ito ng script (service, NAT rules at OpenVPN users). May backup ang lumang listahan sa /opt/v2panel/users.json.bak-v7.

1. Bago magsimula

I-check kung may gumagamit ng port

ss -tulpn | grep -E ':(80|2053|10001|10002|10022) '

Walang lumabas? Ayos. Kapag may lumabas sa port 80 (halimbawa ibang web server o lumang tunnel), i-stop muna iyon o gumamit ng ibang port, halimbawa 8080, sa Step 3. Ginagamit ng script ang 2053, 10001, 10002 at 10022 sa loob lang ng VPS (127.0.0.1).

2. Ilagay ang script sa VPS

Pumili ng isa sa tatlo.

Paraan 1: I-download direkta mula sa site na ito

Pinakamabilis. Sa VPS:

curl -fsSL -o install.sh https://YOUR-SITE/files/install-v2ray-panel.sh

Kapag nakabukas mo ito sa sarili mong hosting, awtomatikong ang address ng site na ito ang lalabas sa command sa taas. Kung hindi, palitan ang YOUR-SITE.

Paraan 2: I-upload gamit ang SFTP

I-download ang scriptPagkatapos, i-upload sa /root ng VPS gamit ang SFTP (Termius o ibang SFTP app) at pangalanan itong install.sh.

Paraan 3: Copy-paste

nano install.sh

I-paste ang buong script, tapos CTRL + O, Enter, CTRL + X. Mahaba ito (mga 1971 na linya), kaya hintaying matapos ang pag-paste.

Ipakita ang buong script (para i-kopya)
#!/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

I-verify na kumpleto ang file

wc -l install.sh
bash -n install.sh && echo OK

Dapat mga 1971 na linya at lumabas ang OK.

3. I-run ang script

bash install.sh

May opsyonal na dalawang argument: port at password ng panel.

Gusto moCommand
Default (port 80, random na password)bash install.sh
Ibang portbash install.sh 8080
Sarili mong passwordbash install.sh 80 MyPassword
Na-install na dati (upgrade)bash install.sh (walang argument; itatago ang lahat ng settings at users)

Mga hakbang na makikita mo habang tumatakbo:

  1. [1/7] Installing dependencies (curl, nginx, python3, openssh-server)
  2. [2/7] Installing V2Ray
  3. [3/7] Setting up SSH accounts
  4. [4/7] Installing panel and SSH-WebSocket bridge
  5. [5/7] Configuring Nginx at HTTPS ng panel
  6. [6/7] Starting services, tapos kukuha ng libreng Let's Encrypt certificate para sa IP mo (kung pumalya, self-signed ang gagamitin)
  7. [7/7] Done, lalabas ang detalye

Tumatagal ito ng ilang minuto. Kailangan ng internet ang VPS dahil magda-download ito ng V2Ray mula sa GitHub. Kapag may error, tumigil ang script sa mismong hakbang na iyon; i-screenshot ang huling mga linya.

4. Kunin ang detalye

Sa dulo, ipapakita ang Panel URL at password. Naka-save rin ito sa file na ito:

cat /root/v2panel-info.txt

Ganito ang itsura (halimbawa lang):

Panel URL : https://IP-NG-VPS:27443/a1b2c3d4e5f6a7b8/panel/
Password  : AbCd1234EfGh
Panel HTTPS: port 27443/tcp (open it in your VPS provider firewall). Trusted Let's Encrypt certificate (auto-renews).
VMess WS  path /vmess   |   VLESS WS  path /vless   |   port 80
SSH       : port 22 (direct)  |  SSH-WS payload path /ssh on port 80

Huwag i-post ang Panel URL o password kahit saan. Ang secret path at ang password ang nagpoprotekta sa panel.

5. Buksan ang panel

I-paste ang buong Panel URL sa browser ng phone mo, kasama ang secret path at ang /panel/ sa dulo. Ilagay ang password. Gumagana pa rin ang lumang http:// link (at ang walang /panel/); ire-redirect ito sa HTTPS.

HTTPS ng panel: kumukuha ang script ng libreng Let's Encrypt certificate para sa IP mo (6 na araw ang bisa, kusang nare-renew dalawang beses isang araw), kaya walang babala at walang pulang "Not secure" sa browser. Kailangang bukas ang port 80 sa internet (dito dumadaan ang validation) at nasa port 80 ang Nginx. Kung pumalya (halimbawa, naka-block ang port 80 o ibang port ang ginamit mo), self-signed certificate ang gagamitin at lalabas ang "Your connection is not private": pindutin ang Advanced, tapos Proceed. Naka-encrypt pa rin ang password mo. Random ang HTTPS port (20000 hanggang 59999) at hiwalay sa V2Ray, kaya walang nagbabago sa 80 at 443 mo.

PW='bagongpassword' python3 /opt/v2panel/panel.py --setpw

Ano ang laman ng panel

May tatlong tab sa ibaba ng screen: Users, Server at Settings.

6. Gumawa ng account at subukan

Sa panel, pindutin ang + Add user at ilagay ang mga field na ito.

FieldHalimbawa / Patakaran
Name3 hanggang 32 na character: letra, numero, _ at - lang (hal. ACERO-USER01)
ProtocolVMess WS, VLESS WS o SSH
DaysIlang araw ang valid. 0 ay walang expiry.
PasswordSSH lang. Blangko = random na password. 4 hanggang 32 na character; letra, numero at @ # % + = _ . - lang.
Max deviceSSH lang. Ilang sabay na koneksyon ang pwede. 0 ay walang limit.
NoteOpsyonal, hal. pangalan ng customer.

Sa SSH, lowercase lang ang name at dapat nagsisimula sa letra (3 hanggang 32), dahil system user ang ginagawa nito. Hindi ito gagawa kung may system account na may parehong name (hal. root). Iisa ang listahan ng names ng lahat ng protocol, kaya hindi pwedeng magkapareho.

Pagkatapos ma-save, kopyahin ang link na lumabas at i-import sa V2Ray client (v2rayNG o katulad). Nagre-restart ang V2Ray sa bawat dagdag o bura ng account, kaya pwedeng maputol sandali ang mga nakakonekta.

Mga detalye ng link

ProtocolTransportPathSecurity
VMessws/vmessnone
VLESSws/vlessnone
SSHdirect, o ws payload/sshnone

May Host setting sa panel kung kailangan mong maglagay ng host header; kapag nilagyan mo, isasama na ito sa mga bagong link.

SSH account

Sa SSH user, ang makikita mo sa panel ay IP:PORT@username:password (may Copy button). Dalawa ang paraan ng pag-connect:

ParaanServer / PortGamit
Direct SSHIP ng VPS, port 22 (o ang SSH port mo)Karaniwang SSH tunnel; username at password ng account.
SSH over WebSocketIP ng VPS, port ng Nginx (80 default)May payload na GET /ssh HTTP/1.1[crlf]Host: HOST[crlf]Upgrade: websocket[crlf][crlf]. Nasa ilalim ng bawat SSH user ang kumpletong payload; palitan ang HOST kung kailangan.

Sa ACERO J-TUNNEL (Add Server), ilagay ang IP sa Server Host, ang username at password ng account, at ang port ayon sa paraang gagamitin (80 para sa WebSocket, 22 para sa direct). Hindi ko nakikita kung paano hinahawakan ng app ang payload, kaya subukan muna sa isang test account. Walang TLS ang script na ito, kaya hindi gagana ang SSL Port 443 hangga't hindi ka nagdadagdag ng domain at certificate.

Max device: sinusuri bawat 30 segundo. Kapag lumampas, pinuputol ang pinakabagong koneksyon ng SSH account. Bawat SSH connection ang binibilang, at may mga app na gumagawa ng higit sa isa, kaya huwag masyadong ibaba ang limit.

Expiry: tinatanggal nang tuluyan ng panel ang account na lampas na sa takdang araw (sinusuri bawat minuto). Hindi lang ito nade-disable.

7. Kung may mali

Tingnan ang status ng mga service

systemctl status v2ray v2panel v2sshws nginx --no-pager
nginx -t
sshd -t

Tingnan ang logs

journalctl -u v2panel -u v2ray -u v2sshws -n 50 --no-pager
SintomasMalamang na dahilan
Hindi nag-start ang NginxMay ibang gumagamit ng port. I-check gamit ang ss -tulpn at gumamit ng ibang port.
Hindi mabuksan ang panelMali ang URL, kulang ang secret path, o sarado ang port sa firewall ng provider.
Nakakapasok sa panel pero hindi maka-connect ang clientHindi tugma ang port, protocol, path (/vmess o /vless) o UUID; o expired na ang account.
Hindi maka-login ang SSH accountMali ang username o password; naka-disable o expired ang account; o bagsak ang sshd -t. Basahin ang journalctl -u ssh -n 50 (o -u sshd).
Gumagana ang direct SSH pero hindi ang WebSocketTingnan kung active ang v2sshws at kung tama ang payload (path /ssh, may Upgrade: websocket).
Hindi gumana ang V2RayBasahin ang journalctl -u v2ray; tiyaking nag-download nang buo ang V2Ray sa Step 2 ng install.

Kung naka-on ang UFW, binubuksan lang ng script ang port ng Nginx. Bukas ba rin ang port sa firewall ng provider mismo?

I-screenshot ang error at ipadala dito para maayos ang script.

8. Araw-araw na commands

GawainCommand
Statussystemctl status v2ray v2panel v2sshws nginx
I-restart lahatsystemctl restart v2ray v2panel v2sshws nginx
Panel URL at passwordcat /root/v2panel-info.txt
I-reset ang passwordPW='bago' python3 /opt/v2panel/panel.py --setpw
Live logsjournalctl -u v2panel -u v2ray -u v2sshws -f

Sa panel mo gagawin ang pagdagdag, pag-extend, pag-disable at pagbura ng accounts.

9. Seguridad at limitasyon

10. Progress ko

Naka-save ang tsek sa browser na ito lang.

0 sa 9 tapos na