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
- Mag-snapshot o mag-backup ng VPS kung hindi ito bago.
- Mag-login bilang
root(osudo -i). Hihinto ang script kung hindi root. - Walang ibang gumagamit ng port na pipiliin mo. Default ay 80.
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> </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> </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'>×</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 mo | Command |
|---|---|
| Default (port 80, random na password) | bash install.sh |
| Ibang port | bash install.sh 8080 |
| Sarili mong password | bash 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/7] Installing dependencies (curl, nginx, python3, openssh-server)
- [2/7] Installing V2Ray
- [3/7] Setting up SSH accounts
- [4/7] Installing panel and SSH-WebSocket bridge
- [5/7] Configuring Nginx at HTTPS ng panel
- [6/7] Starting services, tapos kukuha ng libreng Let's Encrypt certificate para sa IP mo (kung pumalya, self-signed ang gagamitin)
- [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.
- Kung hindi bumubukas ang page (timeout), buksan ang HTTPS port na nasa Panel URL sa firewall ng VPS provider (Security Group / Firewall rules). Ang
ufway awtomatikong inaayos ng script. - Kapag 5 beses kang nagkamali ng password, naka-lock ang login sa loob ng 5 minuto.
- Kung 404 ang lumabas, kulang ang secret path o ang
/panel/sa dulo. - Kung self-signed pa rin ang lumalabas, tingnan ang mensahe sa install ("Could not get a Let's Encrypt certificate"), siguraduhing bukas ang port 80 sa firewall ng provider, at i-run ulit ang script.
- Nakalimutan ang HTTPS port?
cat /opt/v2panel/https_portocat /root/v2panel-info.txt. - Kung nakalimutan mo ang password, i-reset:
PW='bagongpassword' python3 /opt/v2panel/panel.py --setpw
Ano ang laman ng panel
May tatlong tab sa ibaba ng screen: Users, Server at Settings.
- Users: apat na stat (Users, Online, Expiring, Off), search, at filter chips na may bilang (All, VMess, VLESS, SSH, malapit nang mag-expire, disabled/expired).
- + Add user: pumili ng protocol, may live check ng name, preset ng araw (7, 15, 30, 60, 90 o walang expiry), at Generate ng password at Max device para sa SSH. Pagkatapos mag-create, lilitaw at iha-highlight ang bagong user.
- Bawat user: isang tap na Copy link (V2Ray) o Copy login (SSH). Sa Manage: buong link o login, SSH payload, +7d / +30d / +90d o sarili mong bilang ng araw, Disable/Enable, at Delete (may kumpirmasyon).
- Server: tatlong bahagi. Dashboard ang live na status (nagre-refresh bawat 5 segundo): kung tumatakbo ang V2Ray, SSH-WS at Nginx, CPU, RAM, Swap at Storage na may graph (AVG at PEAK), Network (bilis ng upload/download at kabuuang na-send/received), bilang ng bukas na koneksyon (TCP/UDP), uptime ng V2Ray at ng OS, RAM at threads ng panel, at ang mga IP address ng server (naka-blur hanggang pindutin ang Show). Ang Koneksyon ay ang IP, port, paths, SSH port at SSH-WS payload na may Copy button. Ang Optimize ay may BBR + tuning (mas stable at mas mabilis na koneksyon, lalo na sa mobile data; may button para ibalik sa dati) at Swap para sa maliliit na VPS. Wala itong inilalagay na limit sa users at hindi nito tinataasan ang bandwidth ng VPS.
- Settings: host header at pagpapalit ng password ng panel.
6. Gumawa ng account at subukan
Sa panel, pindutin ang + Add user at ilagay ang mga field na ito.
| Field | Halimbawa / Patakaran |
|---|---|
| Name | 3 hanggang 32 na character: letra, numero, _ at - lang (hal. ACERO-USER01) |
| Protocol | VMess WS, VLESS WS o SSH |
| Days | Ilang araw ang valid. 0 ay walang expiry. |
| Password | SSH lang. Blangko = random na password. 4 hanggang 32 na character; letra, numero at @ # % + = _ . - lang. |
| Max device | SSH lang. Ilang sabay na koneksyon ang pwede. 0 ay walang limit. |
| Note | Opsyonal, 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
| Protocol | Transport | Path | Security |
|---|---|---|---|
| VMess | ws | /vmess | none |
| VLESS | ws | /vless | none |
| SSH | direct, o ws payload | /ssh | none |
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:
| Paraan | Server / Port | Gamit |
|---|---|---|
| Direct SSH | IP ng VPS, port 22 (o ang SSH port mo) | Karaniwang SSH tunnel; username at password ng account. |
| SSH over WebSocket | IP 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
| Sintomas | Malamang na dahilan |
|---|---|
| Hindi nag-start ang Nginx | May ibang gumagamit ng port. I-check gamit ang ss -tulpn at gumamit ng ibang port. |
| Hindi mabuksan ang panel | Mali ang URL, kulang ang secret path, o sarado ang port sa firewall ng provider. |
| Nakakapasok sa panel pero hindi maka-connect ang client | Hindi tugma ang port, protocol, path (/vmess o /vless) o UUID; o expired na ang account. |
| Hindi maka-login ang SSH account | Mali 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 WebSocket | Tingnan kung active ang v2sshws at kung tama ang payload (path /ssh, may Upgrade: websocket). |
| Hindi gumana ang V2Ray | Basahin 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
| Gawain | Command |
|---|---|
| Status | systemctl status v2ray v2panel v2sshws nginx |
| I-restart lahat | systemctl restart v2ray v2panel v2sshws nginx |
| Panel URL at password | cat /root/v2panel-info.txt |
| I-reset ang password | PW='bago' python3 /opt/v2panel/panel.py --setpw |
| Live logs | journalctl -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
- Plain HTTP at plain WebSocket ito. Hindi naka-encrypt ang traffic, at dumadaan sa HTTP ang password mo sa panel. Gamitin lang sa test o sa mga taong tanggap ito.
- Iisang port ang gamit ng panel at ng VPN traffic, kaya hindi mo mailalagay ang panel sa IP-lang na firewall nang hindi nasisira ang connection ng customers. Ang proteksyon mo ay ang malakas na password at ang secret path.
- Nakabukas sa internet ang SSH (port 22 at ang
/sshsa port ng Nginx). Random ang password na ginagawa ng panel; kung sarili mong password ang ilalagay mo, gawing mahaba. Isaalang-alang angPermitRootLogin prohibit-passwordsa/etc/ssh/sshd_configat ang pag-install ngfail2ban. - Naka-save ang SSH password sa
/opt/v2panel/users.json(root lang ang may access) para makita mo sa panel. Nililimitahan ang mga SSH account sa tunnel lang: walang shell at walang terminal. - V2Ray v4.45.2 ang ini-install, na lumang bersyon. Gumagana ito, pero alam mo dapat na hindi ito updated.
- Para sa mas ligtas na setup, magdagdag ng domain at TLS. Kapag kailangan mo iyon, sabihin lang at idadagdag ko sa gabay.
- Huwag mag-post ng totoong IP, URL o password sa public na pahina.
10. Progress ko
Naka-save ang tsek sa browser na ito lang.
0 sa 9 tapos na