From 03c43debf4029f3dfecd72a5ff574f09b32407a8 Mon Sep 17 00:00:00 2001 From: penguinehis Date: Sun, 10 May 2026 17:52:36 -0300 Subject: [PATCH] Panel Update --- admin/assets/app.css | 300 +++++++ admin/assets/app.js | 1847 +++++++++++++++++++++++++++++++++++++++++ admin/index.html | 1870 ++++-------------------------------------- admin_script.js | 147 +++- auth.go | 20 +- main.go | 16 +- xray_clients.go | 163 ++-- xray_integration.go | 864 ++++++++++++++++++- 8 files changed, 3408 insertions(+), 1819 deletions(-) create mode 100644 admin/assets/app.css create mode 100644 admin/assets/app.js diff --git a/admin/assets/app.css b/admin/assets/app.css new file mode 100644 index 0000000..18ba131 --- /dev/null +++ b/admin/assets/app.css @@ -0,0 +1,300 @@ + +:root{ + --bg:#0f172a;--bg2:#111827;--card:#020617;--accent:#3b82f6; + --accent-soft:rgba(59,130,246,.15);--border:#1f2937;--text:#e5e7eb; + --muted:#9ca3af;--danger:#ef4444;--success:#22c55e;--warn:#f59e0b; +} +*{box-sizing:border-box;margin:0;padding:0;} +body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:radial-gradient(circle at top,#1e293b,#020617 50%);color:var(--text);min-height:100vh;} +.app{padding:10px;} +@media(min-width:768px){.app{padding:20px;}} + +/* ── Shell ── */ +.shell{max-width:1100px;margin:0 auto;background:linear-gradient(145deg,rgba(15,23,42,.97),rgba(15,23,42,.99));border-radius:16px;border:1px solid rgba(148,163,184,.1);box-shadow:0 18px 60px rgba(15,23,42,.9);padding:14px;} +@media(min-width:768px){.shell{padding:20px;}} + +/* ── Header ── */ +header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:14px;padding-bottom:12px;border-bottom:1px solid var(--border);} +.logo{font-size:1.1rem;font-weight:700;letter-spacing:.03em;} +.logo span{color:var(--accent);} +nav{display:flex;gap:4px;flex-wrap:wrap;} +.tab-btn{background:transparent;border:1px solid transparent;border-radius:999px;padding:5px 12px;font-size:.75rem;color:var(--muted);cursor:pointer;} +.tab-btn:hover{border-color:var(--border);color:var(--text);} +.tab-btn.active{background:var(--accent-soft);border-color:var(--accent);color:var(--accent);} +.hright{display:flex;align-items:center;gap:8px;font-size:.75rem;color:var(--muted);} +.hright strong{color:var(--text);} + +/* ── Cards / Grid ── */ +.grid2{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:12px;} +@media(max-width:860px){.grid2{grid-template-columns:1fr;}} +.card{background:radial-gradient(circle at top left,rgba(59,130,246,.07),var(--card));border-radius:14px;border:1px solid rgba(31,41,55,.95);padding:12px;position:relative;} +.card+.card{margin-top:12px;} +.card-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;gap:6px;flex-wrap:wrap;} +.card-title{font-size:.85rem;font-weight:600;display:flex;align-items:center;gap:6px;} +.chip{font-size:.62rem;padding:2px 7px;border-radius:999px;background:rgba(15,23,42,.85);border:1px solid rgba(55,65,81,.9);color:var(--muted);} +.chip.green{border-color:rgba(34,197,94,.5);color:var(--success);} +.chip.red{border-color:rgba(239,68,68,.5);color:var(--danger);} +.chip.warn{border-color:rgba(245,158,11,.5);color:var(--warn);} + +/* ── Buttons ── */ +.btn{border:none;border-radius:999px;padding:5px 11px;font-size:.72rem;font-weight:500;background:var(--accent);color:#fff;cursor:pointer;display:inline-flex;align-items:center;gap:4px;transition:opacity .15s;} +.btn:hover{opacity:.85;} +.btn:disabled{opacity:.45;cursor:default;} +.btn-ghost{background:transparent;border:1px solid rgba(55,65,81,.9);color:var(--muted);} +.btn-ghost:hover{border-color:var(--accent);color:var(--accent);} +.btn-danger{background:rgba(239,68,68,.08);color:var(--danger);border:1px solid rgba(248,113,113,.4);} +.btn-warn{background:rgba(245,158,11,.08);color:var(--warn);border:1px solid rgba(245,158,11,.4);} +.btn-sm{padding:3px 9px;font-size:.68rem;} + +/* ── Tables ── */ +.tbl-wrap{overflow-x:auto;margin:4px 0;} +table{width:100%;border-collapse:collapse;font-size:.73rem;} +thead{background:rgba(15,23,42,.9);} +th,td{padding:6px 7px;text-align:left;border-bottom:1px solid rgba(31,41,55,.95);white-space:nowrap;} +th{font-weight:500;color:var(--muted);font-size:.68rem;} +tbody tr:hover{background:rgba(15,23,42,.85);} + +/* ── Metrics ── */ +.metrics{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 10px;} +.metric{flex:1 1 80px;min-width:80px;padding:8px;border-radius:10px;background:rgba(15,23,42,.9);border:1px solid rgba(31,41,55,.9);} +.m-label{font-size:.68rem;color:var(--muted);} +.m-val{font-size:.9rem;font-weight:600;margin-top:2px;} +.bar{height:5px;border-radius:999px;background:rgba(31,41,55,.9);overflow:hidden;margin-top:5px;} +.bar-inner{height:100%;background:linear-gradient(90deg,#22c55e,#eab308,#ef4444);transition:width .25s;} + +/* ── Forms ── */ +.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:8px;} +@media(max-width:600px){.form-grid{grid-template-columns:1fr;}} +.field{display:flex;flex-direction:column;gap:3px;} +.field label{font-size:.68rem;color:var(--muted);} +.field input,.field select,.field textarea{border-radius:8px;border:1px solid rgba(55,65,81,.9);padding:6px 10px;background:rgba(15,23,42,.9);color:var(--text);font-size:.73rem;outline:none;font-family:inherit;} +.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--accent);box-shadow:0 0 0 1px rgba(59,130,246,.4);} +.field-row{display:flex;gap:6px;align-items:center;} +.field-row input{flex:1 1 0;} +.hint{font-size:.68rem;color:var(--muted);} +.form-actions{margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;} +.statusbar{margin-top:6px;font-size:.68rem;color:var(--muted);display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;} + +/* ── Tabs ── */ +.tab-pane{display:none;} +.tab-pane.active{display:block;} + +/* ── Collapsible ── */ +.collapsible.collapsed{display:none;} + +/* ── Login overlay ── */ +.overlay{position:fixed;inset:0;background:radial-gradient(circle at top,rgba(15,23,42,.98),rgba(15,23,42,.995));display:flex;align-items:center;justify-content:center;z-index:30;} +.overlay-inner{width:100%;max-width:340px;margin:0 14px;padding:22px;border-radius:16px;background:linear-gradient(145deg,rgba(15,23,42,.98),rgba(2,6,23,1));border:1px solid rgba(55,65,81,.9);box-shadow:0 20px 60px rgba(15,23,42,1);} +.ov-title{font-size:1.05rem;font-weight:700;margin-bottom:4px;} +.ov-sub{font-size:.8rem;color:var(--muted);margin-bottom:14px;} +.ov-field{width:100%;margin:6px 0;border-radius:8px;border:1px solid rgba(55,65,81,.9);background:rgba(15,23,42,.9);color:var(--text);padding:8px 12px;font-size:.82rem;outline:none;} +.ov-field:focus{border-color:var(--accent);} +.hidden{display:none!important;} +.badge-on{color:var(--success);font-size:.68rem;} +.badge-off{color:var(--muted);font-size:.68rem;} + +/* ── Xray log / config editor ── */ +.code-area{width:100%;background:rgba(15,23,42,.9);color:#e5e7eb;border:1px solid rgba(55,65,81,.9);border-radius:8px;padding:10px;font-family:monospace;font-size:.7rem;resize:vertical;outline:none;} +pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,65,81,.9);border-radius:8px;padding:10px;font-size:.68rem;overflow-y:auto;max-height:180px;white-space:pre-wrap;word-break:break-all;} + +/* ── Mobile hardening ── + Keep the shell/cards inside the phone viewport and let wide tables scroll + inside their own card instead of pushing the whole panel sideways. */ +html,body{width:100%;max-width:100%;overflow-x:hidden;} +.app,.shell,.tab-pane,.grid2,.grid2>*,.card,.tbl-wrap,.form-grid,.field,.metric{min-width:0;max-width:100%;} +.card-title{flex-wrap:wrap;min-width:0;} +.card-hdr>*{min-width:0;} +.field input,.field select,.field textarea,.ov-field,.code-area{max-width:100%;min-width:0;} + +@media(max-width:640px){ + .app{padding:8px;} + .shell{width:100%;padding:12px;border-radius:16px;overflow:hidden;} + header{align-items:flex-start;} + header nav{order:2;width:100%;flex-wrap:nowrap;overflow-x:auto;overflow-y:hidden;padding-bottom:4px;-webkit-overflow-scrolling:touch;scrollbar-width:thin;} + .tab-btn{flex:0 0 auto;padding:5px 10px;} + .hright{order:3;width:100%;flex-wrap:wrap;} + .grid2{display:block;} + .grid2>.card+.card,.grid2>div+.card,.grid2>.card+div,.grid2>div+div{margin-top:12px;} + .card{width:100%;padding:12px;overflow:hidden;} + .card-hdr{align-items:flex-start;} + .card-hdr>div[style*="display:flex"],.card-hdr .form-actions{flex-wrap:wrap;} + .metrics{display:grid;grid-template-columns:1fr;gap:8px;} + .metric{width:100%;} + .tbl-wrap{width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:4px;} + .tbl-wrap table{width:max-content;min-width:100%;} + table{font-size:.7rem;} + th,td{padding:6px 8px;} + .statusbar{display:block;} + .statusbar>span{display:block;margin-top:3px;} + .field-row{flex-wrap:wrap;} + .field-row input{flex:1 1 160px;min-width:0;} + .field-row .btn{flex:0 0 auto;} + /* Override inline 2-column grids in Xray/TLS forms on phones. */ + .form-grid[style*="grid-template-columns"], + #wzVlessFields,#wzSSFields,#wzCertSrcFile,#wzCertSrcPaste>div, + #tlsLEFields,#tlsCustomFields,#tlsPasteFields .form-grid{grid-template-columns:1fr!important;} +} + +/* ────────────────────────────────────────────────────────────── + DragonCore professional panel shell + ────────────────────────────────────────────────────────────── */ +:root{ + --bg:#121212; + --bg2:#1b1b1c; + --panel:#222223; + --panel2:#282829; + --card:#242425; + --card2:#2a2a2c; + --accent:#5a49f5; + --accent2:#ae2ff3; + --accent-soft:rgba(90,73,245,.18); + --border:rgba(255,255,255,.08); + --text:#f6f7fb; + --muted:#a2a2aa; + --danger:#ff7070; + --success:#36d37a; + --warn:#ffbe4c; + --shadow:0 18px 50px rgba(0,0,0,.35); +} +html,body{background:var(--bg);color:var(--text);} +body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;} +body.light-mode{ + --bg:#f4f6fb;--bg2:#fff;--panel:#fff;--panel2:#f7f8fc;--card:#fff;--card2:#f8f9fd; + --border:rgba(15,23,42,.10);--text:#172033;--muted:#667085;--shadow:0 18px 45px rgba(31,41,55,.12); +} +.app{padding:0;background:linear-gradient(180deg,var(--bg),#101010);min-height:100vh;} +.shell{max-width:none;margin:0;background:transparent;border:0;border-radius:0;box-shadow:none;padding:0;min-height:100vh;} +.panel-layout{display:grid;grid-template-columns:280px minmax(0,1fr);min-height:100vh;background:var(--bg);} +.sidebar{position:sticky;top:0;height:100vh;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:20;box-shadow:18px 0 45px rgba(0,0,0,.18);} +.brand-block{height:112px;display:flex;align-items:center;gap:14px;padding:22px 22px;border-bottom:1px solid var(--border);} +.brand-mark{width:54px;height:54px;border-radius:18px;display:grid;place-items:center;background:linear-gradient(135deg,rgba(90,73,245,.18),rgba(174,47,243,.18));font-size:2rem;filter:drop-shadow(0 10px 18px rgba(0,0,0,.35));} +.brand-copy{display:flex;flex-direction:column;gap:2px;} +.brand-copy strong{font-size:1.05rem;letter-spacing:.02em;} +.brand-copy span{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.18em;} +.side-nav{display:flex;flex-direction:column;gap:5px;padding:20px 16px;overflow-y:auto;flex:1;} +.nav-group-label{font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:.16em;margin:14px 12px 4px;} +.side-nav .tab-btn{width:100%;justify-content:flex-start;text-align:left;border-radius:13px;padding:12px 13px;color:#d9d9df;border:1px solid transparent;background:transparent;font-size:.92rem;font-weight:650;display:flex;align-items:center;gap:12px;} +.side-nav .tab-btn:hover{background:rgba(255,255,255,.04);border-color:var(--border);color:#fff;} +.side-nav .tab-btn.active{background:linear-gradient(135deg,rgba(90,73,245,.72),rgba(59,54,136,.78));color:#fff;border-color:rgba(255,255,255,.08);box-shadow:0 12px 28px rgba(90,73,245,.22);} +.nav-icon{width:25px;text-align:center;font-size:1.05rem;opacity:.94;} +.sidebar-foot{border-top:1px solid var(--border);padding:16px 18px;display:flex;align-items:center;gap:13px;background:rgba(0,0,0,.08);} +.avatar-dragon{width:42px;height:42px;border-radius:14px;background:#111;display:grid;place-items:center;font-size:1.45rem;} +.sidebar-foot strong{display:block;font-size:.93rem;} +.sidebar-foot span{display:block;color:var(--muted);font-size:.78rem;margin-top:2px;text-transform:capitalize;} +.workspace{min-width:0;display:flex;flex-direction:column;background:radial-gradient(circle at 35% -10%,rgba(90,73,245,.12),transparent 32%),var(--bg);} +.topbar{height:84px;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:0 26px;border-bottom:1px solid var(--border);background:rgba(31,31,32,.86);backdrop-filter:blur(18px);position:sticky;top:0;z-index:15;margin:0;} +body.light-mode .topbar{background:rgba(255,255,255,.82);} +.topbar-left,.topbar-actions{display:flex;align-items:center;gap:14px;min-width:0;} +.topbar-title{display:flex;flex-direction:column;min-width:0;} +.topbar-title span{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.18em;} +.topbar-title strong{font-size:1.08rem;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.icon-btn,.toolbar-pill{border:0;background:rgba(255,255,255,.05);color:var(--text);border:1px solid var(--border);height:42px;min-width:42px;padding:0 12px;border-radius:14px;display:inline-flex;align-items:center;justify-content:center;font-weight:700;cursor:pointer;} +.icon-btn:hover,.toolbar-pill:hover{background:rgba(255,255,255,.09);} +.toolbar-pill{gap:8px;font-size:.85rem;} +.user-pill{height:42px;border:1px solid var(--border);background:rgba(255,255,255,.04);border-radius:999px;display:flex;align-items:center;gap:8px;padding:0 12px;} +.user-pill strong{font-size:.88rem;max-width:120px;overflow:hidden;text-overflow:ellipsis;} +.workspace-main{padding:28px;min-width:0;} +.tab-pane{animation:fadeIn .18s ease-out;} +@keyframes fadeIn{from{opacity:.4;transform:translateY(4px)}to{opacity:1;transform:none}} +.card{background:linear-gradient(180deg,var(--card2),var(--card));border:1px solid var(--border);border-radius:18px;padding:18px;box-shadow:var(--shadow);} +.card-title{font-size:1rem;font-weight:760;} +.card-hdr{margin-bottom:14px;} +.grid2{gap:18px;} +.card+.card{margin-top:18px;} +.chip{border-color:var(--border);background:rgba(255,255,255,.05);padding:4px 9px;font-size:.7rem;} +.chip.green{background:rgba(54,211,122,.12);} +.chip.warn{background:rgba(255,190,76,.12);} +.chip.red{background:rgba(255,112,112,.12);} +.btn{border-radius:12px;padding:9px 14px;font-size:.82rem;background:linear-gradient(135deg,var(--accent),#3b82f6);font-weight:750;box-shadow:0 10px 24px rgba(90,73,245,.22);} +.btn-sm{padding:7px 11px;font-size:.76rem;} +.btn-ghost{box-shadow:none;background:rgba(255,255,255,.04);border:1px solid var(--border);color:var(--text);} +.btn-ghost:hover{border-color:rgba(90,73,245,.55);color:#fff;background:rgba(90,73,245,.12);} +.btn-danger{box-shadow:none;background:rgba(255,112,112,.12);border:1px solid rgba(255,112,112,.30);color:var(--danger);} +.btn-warn{box-shadow:none;background:rgba(255,190,76,.12);border:1px solid rgba(255,190,76,.30);color:var(--warn);} +.btn-light{background:#fff;color:#4e3dde;box-shadow:none;} +.btn-soft{background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.2);box-shadow:none;color:#fff;} +.metrics{gap:12px;} +.metric{background:rgba(255,255,255,.045);border:1px solid var(--border);border-radius:15px;padding:14px;} +.m-label{font-size:.76rem;text-transform:uppercase;letter-spacing:.08em;} +.m-val{font-size:1.2rem;margin-top:5px;} +.tbl-wrap{border:1px solid var(--border);border-radius:15px;overflow:auto;background:rgba(255,255,255,.025);} +table{font-size:.82rem;} +thead{background:rgba(255,255,255,.04);} +th,td{padding:11px 12px;border-bottom:1px solid var(--border);} +th{font-size:.72rem;text-transform:uppercase;letter-spacing:.07em;} +tbody tr:hover{background:rgba(90,73,245,.08);} +.field label{font-size:.76rem;font-weight:680;} +.field input,.field select,.field textarea,.ov-field,.code-area{border-radius:12px;border:1px solid var(--border);background:rgba(255,255,255,.045);color:var(--text);padding:10px 12px;font-size:.84rem;} +.field input:focus,.field select:focus,.field textarea:focus,.ov-field:focus,.code-area:focus{border-color:rgba(90,73,245,.70);box-shadow:0 0 0 3px rgba(90,73,245,.15);} +.form-grid{gap:12px;} +.statusbar,.hint{font-size:.76rem;} +.overlay{background:radial-gradient(circle at top,rgba(90,73,245,.34),rgba(18,18,18,.96) 42%,#0b0b0c);} +.overlay-inner{border-radius:24px;background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--border);box-shadow:0 30px 80px rgba(0,0,0,.5);padding:28px;} +.ov-title{font-size:1.4rem;} +.welcome-card{background:linear-gradient(135deg,#5547f4,#af2ff4);border-radius:22px;padding:30px 28px;display:flex;justify-content:space-between;gap:20px;align-items:center;box-shadow:0 20px 55px rgba(90,73,245,.28);margin-bottom:22px;} +.welcome-kicker{font-size:.78rem;text-transform:uppercase;letter-spacing:.18em;color:rgba(255,255,255,.75);margin-bottom:8px;} +.welcome-card h1{font-size:1.9rem;line-height:1.08;margin:0 0 7px;} +.welcome-card p{color:rgba(255,255,255,.82);font-size:1.02rem;margin:0;} +.welcome-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end;} +.dash-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px;margin-bottom:20px;} +.dash-card{position:relative;overflow:hidden;min-height:156px;background:linear-gradient(180deg,var(--card2),var(--card));border:1px solid var(--border);border-left:6px solid rgba(90,73,245,.8);border-radius:20px;padding:22px;display:flex;justify-content:space-between;align-items:center;box-shadow:var(--shadow);} +.dash-card:after{content:"";position:absolute;right:-45px;top:-45px;width:140px;height:140px;border-radius:50%;background:rgba(255,255,255,.035);} +.dash-card-main{display:flex;flex-direction:column;gap:8px;z-index:1;} +.dash-label{font-size:.82rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);} +.dash-card strong{font-size:2.45rem;line-height:1;} +.dash-card small{color:var(--muted);font-size:.88rem;} +.dash-icon{width:68px;height:68px;border-radius:999px;display:grid;place-items:center;font-size:1.7rem;color:#fff;z-index:1;background:rgba(90,73,245,.38);} +.accent-green{border-left-color:#25c266}.accent-green .dash-icon{background:rgba(37,194,102,.38)} +.accent-purple{border-left-color:#af2ff4}.accent-purple .dash-icon{background:rgba(175,47,244,.38)} +.accent-orange{border-left-color:#ff9f43}.accent-orange .dash-icon{background:rgba(255,159,67,.38)} +.dashboard-lower{align-items:start;} +.quick-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;} +.quick-action{border:1px solid var(--border);background:rgba(255,255,255,.04);border-radius:16px;color:var(--text);padding:16px;text-align:left;cursor:pointer;display:flex;flex-direction:column;gap:5px;} +.quick-action:hover{background:rgba(90,73,245,.12);border-color:rgba(90,73,245,.45);} +.quick-action strong{font-size:.92rem;}.quick-action span{font-size:.78rem;color:var(--muted);} +.quota-meter{height:12px;background:rgba(255,255,255,.06);border-radius:99px;overflow:hidden;border:1px solid var(--border);} +.quota-meter span{display:block;height:100%;width:0%;border-radius:inherit;background:linear-gradient(90deg,#36d37a,#ffbe4c,#ff7070);transition:width .25s;} +.badge-on{color:var(--success);font-weight:750}.badge-off{color:var(--muted);font-weight:700} +pre.log-box{background:#121214;border-color:var(--border);border-radius:15px;} +.drawer-backdrop{display:none;} +@media(max-width:1180px){.dash-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.panel-layout{grid-template-columns:250px minmax(0,1fr);}} +@media(max-width:820px){ + .panel-layout{grid-template-columns:1fr;} + .sidebar{position:fixed;left:0;top:0;bottom:0;width:min(82vw,300px);height:100vh;transform:translateX(-105%);transition:transform .22s ease;} + body.sidebar-open .sidebar{transform:translateX(0);} + .drawer-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.58);z-index:18;backdrop-filter:blur(4px);} + body.sidebar-open .drawer-backdrop{display:block;} + .topbar{height:auto;min-height:76px;padding:12px 14px;align-items:flex-start;} + .topbar-actions{gap:8px;justify-content:flex-end;flex-wrap:wrap;} + .toolbar-pill,.topbar-actions .icon-btn,.user-pill{display:none;} + .workspace-main{padding:18px 14px 28px;} + .welcome-card{padding:28px 22px;align-items:flex-start;flex-direction:column;} + .welcome-card h1{font-size:1.6rem;} + .dash-grid{grid-template-columns:1fr;gap:14px;} + .dash-card{min-height:148px;} + .grid2{display:block;} + .grid2>div+div,.grid2>.card+div,.grid2>div+.card,.grid2>.card+.card{margin-top:16px;} + .quick-actions{grid-template-columns:1fr;} + .card{padding:16px;border-radius:18px;} + .tbl-wrap table{min-width:780px;} + .form-grid,.form-grid[style*="grid-template-columns"],#wzVlessFields,#wzSSFields,#wzCertSrcFile,#wzCertSrcPaste>div,#tlsLEFields,#tlsCustomFields,#tlsPasteFields .form-grid{grid-template-columns:1fr!important;} +} +@media(max-width:520px){ + .topbar-title strong{font-size:.98rem}.topbar-title span{font-size:.62rem;} + .welcome-card{border-radius:20px;margin-bottom:16px;}.dash-card{padding:20px;} + .dash-card strong{font-size:2.2rem}.dash-icon{width:62px;height:62px;} + th,td{padding:10px 10px;} +} + +/* Better reseller counters and quota summaries */ +.reseller-helper-card{margin-bottom:14px;background:linear-gradient(135deg,rgba(90,73,245,.16),rgba(54,211,122,.06)),var(--card);} +.mini-meter{width:100%;height:8px;background:rgba(255,255,255,.07);border:1px solid var(--border);border-radius:999px;overflow:hidden;margin-top:2px;max-width:210px;} +.mini-meter span{display:block;height:100%;width:0%;border-radius:inherit;background:linear-gradient(90deg,#36d37a,#ffbe4c,#ff7070);transition:width .25s ease;} +.mini-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:12px;} +.mini-summary span{display:flex;flex-direction:column;gap:3px;background:rgba(255,255,255,.045);border:1px solid var(--border);border-radius:14px;padding:12px;min-width:0;} +.mini-summary strong{font-size:1.05rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.mini-summary small{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;} +.quota-good{color:var(--success)!important}.quota-warn{color:var(--warn)!important}.quota-danger{color:var(--danger)!important} +@media(max-width:620px){.mini-summary{grid-template-columns:1fr}.mini-meter{max-width:none}} +.table-meter{height:6px;min-width:130px;max-width:220px;background:rgba(255,255,255,.07);border:1px solid var(--border);border-radius:999px;overflow:hidden;margin-top:6px;} +.table-meter span{display:block;height:100%;background:linear-gradient(90deg,#36d37a,#ffbe4c,#ff7070);border-radius:inherit;} diff --git a/admin/assets/app.js b/admin/assets/app.js new file mode 100644 index 0000000..61b9836 --- /dev/null +++ b/admin/assets/app.js @@ -0,0 +1,1847 @@ + +// ─── State ─────────────────────────────────────────────────────────────────── +let sessionToken = localStorage.getItem("SESSION_TOKEN") || ""; +let currentRole = ""; +let currentUser = ""; +let statsTimer = null, usersTimer = null, xrayTimer = null; +let formCollapsed = true; +let tlsForwardersState = []; +let editingXrayClientId = null; +let wzInbounds = []; +let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null }; + +// ─── DOM refs ───────────────────────────────────────────────────────────────── +const loginOverlay = document.getElementById("loginOverlay"); +const loginUser = document.getElementById("loginUser"); +const loginPass = document.getElementById("loginPass"); +const loginBtn = document.getElementById("loginBtn"); +const loginErr = document.getElementById("loginErr"); +const mainApp = document.getElementById("mainApp"); +const meUsername = document.getElementById("meUsername"); +const roleChip = document.getElementById("roleChip"); +const logoutBtn = document.getElementById("logoutBtn"); +const menuToggle = document.getElementById("menuToggle"); +const drawerBackdrop = document.getElementById("drawerBackdrop"); +const themeToggle = document.getElementById("themeToggle"); +const pageTitle = document.getElementById("pageTitle"); +const pageEyebrow = document.getElementById("pageEyebrow"); +const sidebarUsername = document.getElementById("sidebarUsername"); +const sidebarRole = document.getElementById("sidebarRole"); +const dashTotalUsers = document.getElementById("dashTotalUsers"); +const dashActiveUsers = document.getElementById("dashActiveUsers"); +const dashExpiredUsers = document.getElementById("dashExpiredUsers"); +const dashAccountBreakdown = document.getElementById("dashAccountBreakdown"); +const dashConnections = document.getElementById("dashConnections"); +const dashConnectionsText = document.getElementById("dashConnectionsText"); +const dashServers = document.getElementById("dashServers"); +const dashServerStatus = document.getElementById("dashServerStatus"); +const dashXrayClients = document.getElementById("dashXrayClients"); +const dashXrayStatus = document.getElementById("dashXrayStatus"); +const dashQuotaChip = document.getElementById("dashQuotaChip"); +const dashQuotaBar = document.getElementById("dashQuotaBar"); +const dashQuotaText = document.getElementById("dashQuotaText"); +const dashQuotaBreakdown = document.getElementById("dashQuotaBreakdown"); +const dashQuotaRemaining = document.getElementById("dashQuotaRemaining"); +const dashQuotaSummaryText = document.getElementById("dashQuotaSummaryText"); +const dashQuotaMiniBar = document.getElementById("dashQuotaMiniBar"); +const xrayResellerQuotaUsed = document.getElementById("xrayResellerQuotaUsed"); +const xrayResellerQuotaRemaining = document.getElementById("xrayResellerQuotaRemaining"); +const xrayResellerQuotaMix = document.getElementById("xrayResellerQuotaMix"); +const dashboardQuotaCard = document.getElementById("dashboardQuotaCard"); + +// Users +const usersBody = document.getElementById("usersBody"); +const userCountChip = document.getElementById("userCountChip"); +const userStatus = document.getElementById("userStatus"); +const lastReload = document.getElementById("lastReload"); +const ownerColHead = document.getElementById("ownerColHead"); +const resellerInfoCard = document.getElementById("resellerInfoCard"); +const rUsedMax = document.getElementById("rUsedMax"); +const rExpiry = document.getElementById("rExpiry"); +const rStatus = document.getElementById("rStatus"); + +// User form +const userForm = document.getElementById("userForm"); +const userFormWrap = document.getElementById("userFormWrap"); +const toggleFormBtn = document.getElementById("toggleFormBtn"); +const cancelUserBtn = document.getElementById("cancelUserBtn"); +const newUserBtn = document.getElementById("newUserBtn"); +const saveUserBtn = document.getElementById("saveUserBtn"); +const fUsername = document.getElementById("fUsername"); +const fPassword = document.getElementById("fPassword"); +const fTotpSecret = document.getElementById("fTotpSecret"); +const fTotpPeriod = document.getElementById("fTotpPeriod"); +const fTotpWindow = document.getElementById("fTotpWindow"); +const fTotpDigits = document.getElementById("fTotpDigits"); +const fAllowStatic = document.getElementById("fAllowStatic"); +const fMaxConn = document.getElementById("fMaxConn"); +const fExpires = document.getElementById("fExpires"); +const fUp = document.getElementById("fUp"); +const fDown = document.getElementById("fDown"); + +// Xray +const xrayChip = document.getElementById("xrayChip"); +const xRunning = document.getElementById("xRunning"); +const xPID = document.getElementById("xPID"); +const xUptime = document.getElementById("xUptime"); +const xStatus = document.getElementById("xStatus"); +const xCfgEditor = document.getElementById("xCfgEditor"); +const xCfgStatus = document.getElementById("xCfgStatus"); +const xLogsBox = document.getElementById("xLogsBox"); +const inboundsContainer = document.getElementById("inboundsContainer"); + +// Resellers +const resellersBody = document.getElementById("resellersBody"); +const resellerCountChip = document.getElementById("resellerCountChip"); +const resellerStatus = document.getElementById("resellerStatus"); +const resellerFormTitle = document.getElementById("resellerFormTitle"); +const resellerForm = document.getElementById("resellerForm"); +const rUsername = document.getElementById("rUsername"); +const rPassword = document.getElementById("rPassword"); +const rMaxUsers = document.getElementById("rMaxUsers"); +const rExpires = document.getElementById("rExpires"); +const rActive = document.getElementById("rActive"); + +// Stats +const cpuVal = document.getElementById("cpuVal"); +const cpuBar = document.getElementById("cpuBar"); +const memVal = document.getElementById("memVal"); +const memBar = document.getElementById("memBar"); +const memDetail = document.getElementById("memDetail"); +const ifaceBody = document.getElementById("ifaceBody"); +const ifaceSummary = document.getElementById("ifaceSummary"); +const statsUpdated = document.getElementById("statsUpdated"); +const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn"); + +// VnStat +const vnstatDailyBody = document.getElementById("vnstatDailyBody"); +const vnstatMonthlyBody = document.getElementById("vnstatMonthlyBody"); +const vnstatStatus = document.getElementById("vnstatStatus"); +const vnTodayTotal = document.getElementById("vnTodayTotal"); +const vnMonthTotal = document.getElementById("vnMonthTotal"); +const vnIfaceCount = document.getElementById("vnIfaceCount"); +const reloadVnstatBtn = document.getElementById("reloadVnstatBtn"); +const resetVnstatBtn = document.getElementById("resetVnstatBtn"); + +// ─── API helper ─────────────────────────────────────────────────────────────── +async function api(path, opts = {}) { + const o = Object.assign({ headers: {} }, opts); + o.headers = Object.assign({}, o.headers, { + "Content-Type": "application/json", + "X-Session-Token": sessionToken, + }); + const res = await fetch(path, o); + if (res.status === 401 || res.status === 403) throw new Error("auth"); + return res; +} + +// ─── Formatters ────────────────────────────────────────────────────────────── +const fmtPct = n => (n == null || isNaN(n)) ? "--%" : n.toFixed(1)+"%"; +const fmtMbps = n => (n == null || isNaN(n)) ? "--" : n.toFixed(2); +function fmtBytes(n) { + if (!Number.isFinite(n)) return "--"; + if (n<1024) return n+" B"; + const k=n/1024; if(k<1024) return k.toFixed(1)+" KiB"; + const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB"; + return (m/1024).toFixed(1)+" GiB"; +} +function localDateKey(d = new Date()) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} +function fmtDate(iso) { + if (!iso) return "—"; + const d = new Date(iso); + if (isNaN(d.getTime())) return "—"; + return d.toLocaleDateString()+" "+d.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}); +} +function isoFromLocal(v) { + if (!v) return ""; + const d = new Date(v); + return isNaN(d.getTime()) ? "" : d.toISOString(); +} +function localFromISO(iso) { + if (!iso) return ""; + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + const pad = n => String(n).padStart(2,"0"); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} +function genBase32(len=20) { + const alpha="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const bytes=new Uint8Array(len); + crypto.getRandomValues(bytes); + let bits=0,val=0,out=""; + for(const b of bytes){val=(val<<8)|b;bits+=8;while(bits>=5){out+=alpha[(val>>>(bits-5))&31];bits-=5;}} + if(bits>0) out+=alpha[(val<<(5-bits))&31]; + return out; +} +function genUUID() { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => + (c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16)); +} + +// ─── Navigation / shell ────────────────────────────────────────────────────── +const tabTitles = { + dashboard: ["Painel", "Visão geral"], + ssh: ["Contas", "SSH / SlowDNS"], + xray: ["Contas", "Xray Users"], + resellers: ["Administração", "Revendedores"], + stats: ["Servidor", "Monitoramento"], + vnstat: ["Tráfego", "VnStat"], + logs: ["Sistema", "Logs"], + server: ["Sistema", "Configurações"], +}; + +function selectTab(tab) { + const pane = document.getElementById("tab-" + tab); + const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`); + if (!pane || !btn) return; + document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); + document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active")); + btn.classList.add("active"); + pane.classList.add("active"); + const [eyebrow, title] = tabTitles[tab] || ["Painel", tab]; + if (pageEyebrow) pageEyebrow.textContent = eyebrow; + if (pageTitle) pageTitle.textContent = title; + document.body.classList.remove("sidebar-open"); + + if (tab === "dashboard") refreshDashboard(); + if (tab === "xray") { + loadXrayStatus(); + loadInbounds(); + if (currentRole === "superadmin") loadWizardFromConfig(); + } + if (tab === "stats" && currentRole === "superadmin") loadStats(); + if (tab === "resellers" && currentRole === "superadmin") loadResellers(); +} + +document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab))); +menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open")); +drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open")); +themeToggle?.addEventListener("click", () => document.body.classList.toggle("light-mode")); +document.querySelectorAll(".quick-action[data-jump]").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.jump))); +document.getElementById("quickCreateUserBtn")?.addEventListener("click", () => { selectTab("ssh"); setFormCollapsed(false); fUsername?.focus(); }); +document.getElementById("quickOpenXrayBtn")?.addEventListener("click", () => selectTab("xray")); + +// ─── Login / Logout ─────────────────────────────────────────────────────────── +loginBtn.addEventListener("click", doLogin); +loginPass.addEventListener("keydown", e => { if (e.key==="Enter") doLogin(); }); +logoutBtn.addEventListener("click", async () => { + try { await api("/api/auth/logout", { method: "POST" }); } catch {} + sessionToken = ""; + localStorage.removeItem("SESSION_TOKEN"); + clearTimers(); + mainApp.classList.add("hidden"); + loginOverlay.classList.remove("hidden"); + loginErr.textContent = ""; + loginUser.value = loginPass.value = ""; +}); + +async function doLogin() { + loginErr.textContent = ""; + loginBtn.disabled = true; + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ username: loginUser.value.trim(), password: loginPass.value }), + }); + if (!res.ok) { + loginErr.textContent = res.status === 401 ? "Invalid credentials." : + res.status === 403 ? "Account suspended or expired." : + "Login failed."; + return; + } + const data = await res.json(); + sessionToken = data.token; + currentRole = data.role; + currentUser = data.username; + localStorage.setItem("SESSION_TOKEN", sessionToken); + loginOverlay.classList.add("hidden"); + mainApp.classList.remove("hidden"); + initAfterLogin(); + } catch (e) { + loginErr.textContent = "Network error."; + } finally { + loginBtn.disabled = false; + } +} + +// ─── Init after login ───────────────────────────────────────────────────────── +function clearTimers() { + [statsTimer, usersTimer, xrayTimer].forEach(t => t && clearInterval(t)); + statsTimer = usersTimer = xrayTimer = null; +} + +function initAfterLogin() { + meUsername.textContent = currentUser; + if (sidebarUsername) sidebarUsername.textContent = currentUser; + if (sidebarRole) sidebarRole.textContent = currentRole === "superadmin" ? "Super Admin" : "Revendedor"; + roleChip.innerHTML = currentRole === "superadmin" + ? `superadmin` + : `reseller`; + + document.querySelectorAll(".superadmin-only").forEach(el => { + el.classList.toggle("hidden", currentRole !== "superadmin"); + }); + document.querySelectorAll(".reseller-only").forEach(el => { + el.classList.toggle("hidden", currentRole !== "reseller"); + }); + document.querySelectorAll(".xray-admin-only").forEach(el => { + el.classList.toggle("hidden", currentRole !== "superadmin"); + }); + + resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller"); + dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller"); + updateRoleWelcome(); + + selectTab("dashboard"); + + if (currentRole === "superadmin") { + loadStats(); + statsTimer = setInterval(loadStats, 2000); + } else { + loadMe(); + } + xrayTimer = setInterval(loadXrayStatus, 7000); + + loadUsers(); + loadInbounds(); + usersTimer = setInterval(() => loadUsersSilent(), 3000); +} + +function updateRoleWelcome() { + const title = document.getElementById("welcomeTitle"); + const sub = document.getElementById("welcomeSub"); + const kicker = document.getElementById("welcomeKicker"); + if (!title || !sub || !kicker) return; + const name = currentUser || "admin"; + title.textContent = `Bem-vindo de volta, ${name} 👋`; + if (currentRole === "reseller") { + kicker.textContent = "Painel do revendedor"; + sub.textContent = "Crie contas SSH e Xray com cota única, sem precisar tocar em configurações técnicas."; + } else { + kicker.textContent = "Painel operacional"; + sub.textContent = "Gerencie SSH, Xray, revendedores e servidor em poucos cliques."; + } +} + +// ─── Me (reseller info) ─────────────────────────────────────────────────────── +async function loadMe() { + try { + const res = await api("/api/auth/me"); + const d = await res.json(); + dashboardCache.me = d; + const used = d.used_users ?? 0; + const max = d.max_users || 0; + rUsedMax.textContent = used + " / " + (max || "∞"); + rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : "Sem vencimento"; + rStatus.textContent = d.is_active ? "Ativo" : "Suspenso"; + rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)"; + updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0); + renderDashboardCounters(); + } catch {} +} + +function quotaToneClass(pct, remaining) { + if (remaining === 0 || pct >= 90) return "quota-danger"; + if (pct >= 75) return "quota-warn"; + return "quota-good"; +} + +function setQuotaTone(el, tone) { + if (!el) return; + el.classList.remove("quota-good", "quota-warn", "quota-danger"); + el.classList.add(tone); +} + +function updateQuotaCard(used, max, sshUsed = 0, xrayUsed = 0) { + if (!dashQuotaText) return; + const unlimited = !max; + const remaining = unlimited ? "∞" : Math.max(0, max - used); + const pct = unlimited ? 0 : Math.min(100, Math.round((used / max) * 100)); + const tone = quotaToneClass(pct, remaining === "∞" ? 999999 : remaining); + const labelMax = unlimited ? "∞" : max; + + dashQuotaChip.textContent = `${used} / ${labelMax}`; + dashQuotaChip.className = `chip ${pct >= 90 ? "red" : pct >= 75 ? "warn" : "green"}`; + dashQuotaText.textContent = unlimited + ? "Sem limite definido pelo admin" + : `${remaining} contas disponíveis · ${pct}% usado`; + dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`; + dashQuotaBar.style.width = `${pct}%`; + + if (dashQuotaRemaining) { + dashQuotaRemaining.textContent = String(remaining); + setQuotaTone(dashQuotaRemaining, tone); + } + if (dashQuotaSummaryText) { + dashQuotaSummaryText.textContent = unlimited + ? `${used} usadas · sem limite` + : `${used}/${max} usadas · ${pct}% do plano`; + } + if (dashQuotaMiniBar) dashQuotaMiniBar.style.width = `${pct}%`; + if (xrayResellerQuotaUsed) xrayResellerQuotaUsed.textContent = `${used}/${labelMax}`; + if (xrayResellerQuotaRemaining) { + xrayResellerQuotaRemaining.textContent = String(remaining); + setQuotaTone(xrayResellerQuotaRemaining, tone); + } + if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`; +} + +function flattenXrayClients(inbounds = []) { + return inbounds.flatMap(ib => (ib.clients || []).map(c => Object.assign({ inbound_tag: ib.tag }, c))); +} + +function isExpiredDate(value) { + return !!value && new Date(value) < new Date(); +} + +function formatBytes(bytes) { + const n = Number(bytes || 0); + if (!n) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = n, i = 0; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`; +} + +function formatLastActive(value) { + if (!value) return "--"; + const diff = Math.max(0, Date.now() - new Date(value).getTime()); + const sec = Math.floor(diff / 1000); + if (sec < 60) return `${sec}s atrás`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m atrás`; + const hrs = Math.floor(min / 60); + if (hrs < 24) return `${hrs}h atrás`; + return new Date(value).toLocaleString(); +} + +function renderDashboardCounters() { + if (!dashTotalUsers) return; + const sshUsers = dashboardCache.sshUsers || []; + const xrayClients = flattenXrayClients(dashboardCache.xrayInbounds || []); + const sshExpired = sshUsers.filter(u => isExpiredDate(u.expires_at)).length; + const xrayExpired = xrayClients.filter(c => c.expired || isExpiredDate(c.expires_at)).length; + const sshActive = Math.max(0, sshUsers.length - sshExpired); + const xrayActive = Math.max(0, xrayClients.length - xrayExpired); + const total = sshUsers.length + xrayClients.length; + const active = sshActive + xrayActive; + const expired = sshExpired + xrayExpired; + const sshConns = sshUsers.reduce((sum, u) => sum + Number(u.active_conns || 0), 0); + const xrayOnline = xrayClients.filter(c => !!c.online).length; + const liveTotal = sshConns + xrayOnline; + + dashTotalUsers.textContent = total; + dashActiveUsers.textContent = active; + dashExpiredUsers.textContent = expired; + if (dashAccountBreakdown) dashAccountBreakdown.textContent = `SSH ${sshUsers.length} · Xray ${xrayClients.length}`; + dashConnections.textContent = liveTotal; + if (dashConnectionsText) dashConnectionsText.textContent = `${sshConns} SSH · ${xrayOnline} Xray online`; + if (dashXrayClients) dashXrayClients.textContent = xrayClients.length; + if (dashXrayStatus) { + const running = xrayChip?.textContent || "--"; + dashXrayStatus.textContent = `${xrayOnline} online · ${xrayActive} ativos · ${xrayExpired} expirados · Core: ${running}`; + } + + const me = dashboardCache.me; + if (currentRole === "reseller" && me) { + updateQuotaCard(me.used_users ?? total, me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length); + } +} + +function updateDashboardFromUsers(users = []) { + dashboardCache.sshUsers = users || []; + renderDashboardCounters(); +} + +function updateDashboardXray(inbounds = []) { + dashboardCache.xrayInbounds = inbounds || []; + renderDashboardCounters(); +} + +function refreshDashboard() { + loadUsersSilent(); + loadInbounds(); + loadXrayStatus(); + if (currentRole === "reseller") loadMe(); +} + +// ─── SSH Users ──────────────────────────────────────────────────────────────── +document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers); +newUserBtn.addEventListener("click", () => { + setFormCollapsed(false); + userForm.reset(); + fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6; + userStatus.textContent = "New user."; + fUsername.focus(); +}); +cancelUserBtn.addEventListener("click", () => setFormCollapsed(true)); +toggleFormBtn.addEventListener("click", () => setFormCollapsed(!formCollapsed)); +document.getElementById("genTotpBtn").addEventListener("click", () => { + fTotpSecret.value = genBase32(); + if (!fTotpPeriod.value) fTotpPeriod.value = 60; + if (!fTotpWindow.value) fTotpWindow.value = 1; + if (!fTotpDigits.value) fTotpDigits.value = 6; + userStatus.textContent = "TOTP secret generated."; +}); +document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; }); + +function setFormCollapsed(v) { + formCollapsed = v; + userFormWrap.classList.toggle("collapsed", v); + toggleFormBtn.textContent = v ? "Show form" : "Hide form"; +} + +async function loadUsers() { + userStatus.textContent = "Loading…"; + try { + const res = await api("/api/users"); + const data = await res.json(); + renderUsers(data || []); + userStatus.textContent = "Loaded."; + lastReload.textContent = "Last reload: " + new Date().toLocaleTimeString(); + } catch (e) { + if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = "Error loading users."; } + } +} +async function loadUsersSilent() { + try { + const res = await api("/api/users"); + const data = await res.json(); + renderUsers(data || []); + } catch (e) { + if (e.message==="auth") doAuthError(); + } +} + +function renderUsers(users) { + updateDashboardFromUsers(users); + const isSA = currentRole === "superadmin"; + userCountChip.textContent = users.length; + if (isSA) ownerColHead.classList.remove("hidden"); + usersBody.innerHTML = ""; + let online = 0; + let expiredCount = 0; + users.forEach(u => { + const on = (u.active_conns || 0) > 0; + if (on) online++; + if (isExpiredDate(u.expires_at)) expiredCount++; + const tr = document.createElement("tr"); + const cells = [ + u.username, + on ? 'online' : 'idle', + u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password", + u.active_conns ?? 0, + u.max_connections || 0, + u.limit_mbps_up || 0, + u.limit_mbps_down || 0, + u.expires_at ? fmtDate(u.expires_at) : "—", + ]; + if (isSA) cells.push(u.owner_username || "—"); + cells.forEach((c, i) => { + const td = document.createElement("td"); + if (i === 1) td.innerHTML = c; else td.textContent = c; + tr.appendChild(td); + }); + const tdA = document.createElement("td"); + const editBtn = Object.assign(document.createElement("button"), { + className:"btn btn-ghost btn-sm", textContent:"Edit", + onclick: () => fillUserForm(u), + }); + const delBtn = Object.assign(document.createElement("button"), { + className:"btn btn-danger btn-sm", textContent:"Del", + style: "margin-left:4px;", + onclick: () => deleteUser(u.username), + }); + tdA.append(editBtn, delBtn); + tr.appendChild(tdA); + usersBody.appendChild(tr); + }); + const activeCount = Math.max(0, users.length - expiredCount); + userCountChip.textContent = `${users.length} total · ${activeCount} ativas · ${online} online`; +} + +function fillUserForm(u) { + setFormCollapsed(false); + fUsername.value = u.username || ""; + fPassword.value = ""; + fTotpSecret.value = u.totp_secret || ""; + fTotpPeriod.value = u.totp_period || 60; + fTotpWindow.value = u.totp_window ?? 1; + fTotpDigits.value = u.totp_digits || 6; + fAllowStatic.checked = !!u.allow_static_password; + fMaxConn.value = u.max_connections || ""; + fUp.value = u.limit_mbps_up || ""; + fDown.value = u.limit_mbps_down || ""; + fExpires.value = u.expires_at ? localFromISO(u.expires_at) : ""; + userStatus.textContent = `Editing ${u.username}`; +} + +userForm.addEventListener("submit", async e => { + e.preventDefault(); + saveUserBtn.disabled = true; + userStatus.textContent = "Saving…"; + const payload = { + username: fUsername.value.trim(), + password: fPassword.value || undefined, + totp_secret: fTotpSecret.value.trim(), + totp_period: parseInt(fTotpPeriod.value||"60",10), + totp_window: parseInt(fTotpWindow.value||"1",10), + totp_digits: parseInt(fTotpDigits.value||"6",10), + allow_static_password: !!fAllowStatic.checked, + max_connections: parseInt(fMaxConn.value||"0",10), + expires_at: isoFromLocal(fExpires.value), + limit_mbps_up: parseInt(fUp.value||"0",10), + limit_mbps_down: parseInt(fDown.value||"0",10), + }; + try { + const res = await api("/api/users/create", { method:"POST", body: JSON.stringify(payload) }); + if (!res.ok) throw new Error(await res.text()); + userStatus.textContent = "Saved."; + fPassword.value = ""; + loadUsers(); + if (currentRole === "reseller") loadMe(); + } catch (e) { + if (e.message==="auth") doAuthError(); + else userStatus.textContent = "Error: " + e.message; + } finally { + saveUserBtn.disabled = false; + } +}); + +async function deleteUser(username) { + if (!confirm(`Delete user "${username}"?`)) return; + userStatus.textContent = `Deleting ${username}…`; + try { + const res = await api(`/api/users/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" }); + if (!res.ok && res.status !== 204) throw new Error("delete failed"); + userStatus.textContent = "Deleted."; + loadUsers(); + if (currentRole === "reseller") loadMe(); + } catch (e) { + if (e.message==="auth") doAuthError(); + else userStatus.textContent = "Error deleting."; + } +} + +// ─── Xray ───────────────────────────────────────────────────────────────────── +document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("start")); +document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop")); +document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart")); +document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats); +document.getElementById("xRefreshBtn").addEventListener("click", loadXrayStatus); +document.getElementById("xLoadInboundsBtn").addEventListener("click", loadInbounds); +document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg); +document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg); +document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs); + +document.querySelector("[data-tab='xray']")?.addEventListener("click", () => { + loadXrayStatus(); + loadInbounds(); + if (currentRole === "superadmin") loadWizardFromConfig(); +}); + +async function loadXrayStatus() { + try { + const res = await api("/api/xray/status"); + const s = await res.json(); + const run = !!s.running; + xrayChip.textContent = run ? "running" : (s.enabled ? "stopped" : "disabled"); + xrayChip.className = "chip " + (run ? "green" : "red"); + xRunning.textContent = run ? "Running" : "Stopped"; + xRunning.style.color = run ? "var(--success)" : "var(--danger)"; + xPID.textContent = s.pid || "--"; + xUptime.textContent = s.uptime || "--"; + const statsCfgEl = document.getElementById("xStatsConfig"); + const repairBtn = document.getElementById("xRepairStatsBtn"); + if (statsCfgEl) { + statsCfgEl.textContent = s.stats_configured ? "OK" : "Needs repair"; + statsCfgEl.style.color = s.stats_configured ? "var(--success)" : "var(--warning)"; + } + if (repairBtn) repairBtn.style.display = s.stats_configured ? "none" : ""; + if (xOnlineUsers) xOnlineUsers.textContent = String(s.online_users ?? 0); + if (!s.stats_configured && xStatus) { + const missing = Array.isArray(s.stats_missing) && s.stats_missing.length ? ` Missing: ${s.stats_missing.join(", ")}.` : ""; + xStatus.textContent = "Online counters need Stats API repair." + missing; + } else if (s.stats_error && xStatus) { + xStatus.textContent = "Online counters: " + s.stats_error; + } else if (xStatus) { + xStatus.textContent = s.api_server ? `Counters API ready at ${s.api_server}.` : "Counters API ready."; + } + if (dashServers) dashServers.textContent = s.enabled ? "1" : "0"; + if (dashServerStatus) dashServerStatus.textContent = run ? "1 online" : (s.enabled ? "parado" : "desativado"); + renderDashboardCounters(); + if (s.error) xStatus.textContent = "Error: " + s.error; + } catch (e) { if (e.message==="auth") doAuthError(); } +} + +async function repairXrayStats() { + const btn = document.getElementById("xRepairStatsBtn"); + if (btn) btn.disabled = true; + xStatus.textContent = "Checking and repairing Xray counters API…"; + try { + const res = await api("/api/xray/stats/repair", { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + const d = await res.json().catch(() => ({})); + xStatus.textContent = d.changed + ? (d.restarted ? "Counters API repaired and Xray restarted." : "Counters API repaired. Restart Xray to apply it.") + : "Counters API already looks correct."; + setTimeout(loadXrayStatus, 700); + setTimeout(loadInbounds, 1200); + } catch (e) { + if (e.message==="auth") doAuthError(); + else xStatus.textContent = "Error repairing counters: "+e.message; + } finally { + if (btn) btn.disabled = false; + } +} + +async function xrayCtrl(action) { + xStatus.textContent = action.charAt(0).toUpperCase()+action.slice(1)+"ing Xray…"; + try { + const res = await api(`/api/xray/${action}`, { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + xStatus.textContent = "Xray "+action+" OK."; + setTimeout(loadXrayStatus, 700); + setTimeout(loadInbounds, 1200); + } catch (e) { + if (e.message==="auth") doAuthError(); + else xStatus.textContent = "Error: "+e.message; + } +} + +async function loadInbounds() { + inboundsContainer.innerHTML = '
Loading…
'; + try { + const res = await api("/api/xray/inbounds"); + const inbounds = await res.json(); + renderInbounds(inbounds || []); + } catch (e) { + inboundsContainer.textContent = "Error loading inbounds."; + if (e.message==="auth") doAuthError(); + } +} + +function renderInbounds(inbounds) { + updateDashboardXray(inbounds); + if (!inbounds.length) { + inboundsContainer.innerHTML = '
No VLESS/VMess/Trojan inbounds found.
'; + return; + } + inboundsContainer.innerHTML = ""; + inbounds.forEach(ib => { + const section = document.createElement("div"); + section.style = "margin-bottom:14px;"; + + const hdr = document.createElement("div"); + hdr.className = "card-hdr"; + hdr.style = "margin-bottom:6px;"; + const clients = ib.clients || []; + const onlineCount = clients.filter(c => !!c.online).length; + hdr.innerHTML = ` +
+ ${ib.protocol} + ${ib.tag || "untagged"} + :${ib.port ?? "?"} + ${onlineCount} online +
+ `; + section.appendChild(hdr); + + // Add client mini-form (hidden by default) + const addForm = document.createElement("div"); + addForm.id = `add-form-${ib.tag}`; + addForm.className = "hidden"; + addForm.style = "background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;"; + addForm.innerHTML = ` +
+
+ +
+ + +
+
+
+
+
+
+
+
+ + +
`; + section.appendChild(addForm); + + // Clients table + const tblWrap = document.createElement("div"); + tblWrap.className = "tbl-wrap"; + if (!clients.length) { + tblWrap.innerHTML = '
No clients.
'; + } else { + const tbl = document.createElement("table"); + tbl.innerHTML = `NameUUIDEmailExpiryStatusOnlineTrafficMaxActions`; + const tbody = document.createElement("tbody"); + clients.forEach(c => { + const exp = c.expires_at ? new Date(c.expires_at) : null; + const expStr = exp ? exp.toLocaleDateString() : "Unlimited"; + const isExpired = !!c.expired; + const daysLeft = c.expiration_days; + let statusHtml; + if (isExpired) { + statusHtml = `Expired`; + } else if (daysLeft === -1 || !exp) { + statusHtml = `Active`; + } else { + statusHtml = `Active (${daysLeft}d)`; + } + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${c.name || "—"} + ${c.id} + ${c.email || "—"} + ${expStr} + ${statusHtml} + ${c.online ? 'online' : 'offline'}
${formatLastActive(c.last_active)}
+ ${formatBytes(c.total_bytes)} + ${c.max_conns || "∞"}`; + const actTd = document.createElement("td"); + actTd.style.whiteSpace = "nowrap"; + const copyBtn = document.createElement("button"); + copyBtn.className = "btn btn-ghost btn-sm"; + copyBtn.textContent = "Copy"; + copyBtn.onclick = () => navigator.clipboard.writeText(c.id); + const editBtn = document.createElement("button"); + editBtn.className = "btn btn-warn btn-sm"; + editBtn.style.marginLeft = "4px"; + editBtn.textContent = "Edit"; + editBtn.onclick = () => openEditXrayClient(ib.tag, c); + const delBtn = document.createElement("button"); + delBtn.className = "btn btn-danger btn-sm"; + delBtn.style.marginLeft = "4px"; + delBtn.textContent = "Del"; + delBtn.onclick = () => removeClient(ib.tag, c.id); + actTd.append(copyBtn, editBtn, delBtn); + tr.appendChild(actTd); + tbody.appendChild(tr); + }); + tbl.appendChild(tbody); + tblWrap.appendChild(tbl); + } + section.appendChild(tblWrap); + + const divider = document.createElement("hr"); + divider.style = "border:none;border-top:1px solid var(--border);margin-top:10px;"; + section.appendChild(divider); + + inboundsContainer.appendChild(section); + }); +} + +function openAddClient(tag) { + const form = document.getElementById(`add-form-${tag}`); + if (form) { form.classList.remove("hidden"); } + const uuidField = document.getElementById(`newUUID-${tag}`); + if (uuidField && !uuidField.value) uuidField.value = genUUID(); +} + +async function addClient(tag) { + const uuidEl = document.getElementById(`newUUID-${tag}`); + const emailEl = document.getElementById(`newEmail-${tag}`); + const nameEl = document.getElementById(`newName-${tag}`); + const expiryEl = document.getElementById(`newExpiry-${tag}`); + const maxConnsEl = document.getElementById(`newMaxConns-${tag}`); + const uuid = (uuidEl?.value || "").trim(); + const email = (emailEl?.value || "").trim(); + const name = (nameEl?.value || "").trim(); + const expiresAt = isoFromLocal(expiryEl?.value || ""); + const maxConns = parseInt(maxConnsEl?.value || "0", 10) || 0; + if (!uuid) { xStatus.textContent = "UUID required."; return; } + try { + const res = await api("/api/xray/clients/add", { + method: "POST", + body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns }), + }); + if (!res.ok) throw new Error(await res.text()); + xStatus.textContent = `Client ${uuid.slice(0,8)}… added. Restarting Xray…`; + setTimeout(() => { loadInbounds(); if (currentRole === "reseller") loadMe(); }, 1500); + } catch (e) { + if (e.message==="auth") doAuthError(); + else xStatus.textContent = "Error: "+e.message; + } +} + +async function removeClient(tag, uuid) { + if (!confirm(`Remove client ${uuid.slice(0,8)}… from ${tag}?`)) return; + try { + const res = await api(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, { method:"DELETE" }); + if (!res.ok && res.status !== 204) throw new Error(await res.text()); + xStatus.textContent = "Client removed. Restarting Xray…"; + setTimeout(() => { loadInbounds(); if (currentRole === "reseller") loadMe(); }, 1500); + } catch (e) { + if (e.message==="auth") doAuthError(); + else xStatus.textContent = "Error: "+e.message; + } +} + +async function loadXrayCfg() { + try { + const res = await api("/api/xray/config"); + if (!res.ok) throw new Error(await res.text()); + const text = await res.text(); + try { xCfgEditor.value = JSON.stringify(JSON.parse(text), null, 2); } + catch { xCfgEditor.value = text; } + xCfgStatus.textContent = "Config loaded."; + } catch (e) { + if (e.message==="auth") doAuthError(); + else xCfgStatus.textContent = "Error: "+e.message; + } +} + +async function saveXrayCfg() { + const text = xCfgEditor.value.trim(); + try { JSON.parse(text); } catch(e) { xCfgStatus.textContent = "Invalid JSON: "+e.message; return; } + xCfgStatus.textContent = "Saving…"; + try { + const res = await api("/api/xray/config", { method:"POST", body: text }); + if (!res.ok) throw new Error(await res.text()); + xCfgStatus.textContent = "Saved. Restarting Xray…"; + await xrayCtrl("restart"); + } catch (e) { + if (e.message==="auth") doAuthError(); + else xCfgStatus.textContent = "Error: "+e.message; + } +} + +async function loadXrayLogs() { + try { + const res = await api("/api/xray/logs"); + const data = await res.json(); + xLogsBox.textContent = (data.lines||[]).join("\n"); + xLogsBox.scrollTop = xLogsBox.scrollHeight; + } catch (e) { if (e.message==="auth") doAuthError(); } +} + +// ─── Resellers ──────────────────────────────────────────────────────────────── +document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers); +document.getElementById("newResellerBtn").addEventListener("click", () => { + resellerFormTitle.textContent = "Create Reseller"; + resellerForm.reset(); + rActive.checked = true; + resellerStatus.textContent = "New reseller."; +}); +document.getElementById("cancelResellerBtn").addEventListener("click", () => { + resellerForm.reset(); + rActive.checked = true; + resellerFormTitle.textContent = "Create Reseller"; +}); + +document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers); + +async function loadResellers() { + resellerStatus.textContent = "Loading…"; + try { + const res = await api("/api/resellers"); + const data = await res.json(); + renderResellers(data || []); + resellerStatus.textContent = "Loaded."; + } catch (e) { + if (e.message==="auth") doAuthError(); + else resellerStatus.textContent = "Error loading."; + } +} + +function renderResellers(list) { + resellerCountChip.textContent = list.length; + resellersBody.innerHTML = ""; + list.forEach(r => { + const expired = r.expires_at && new Date(r.expires_at) < new Date(); + const max = r.max_users || 0; + const used = r.used_users || 0; + const remaining = max ? Math.max(0, max - used) : "∞"; + const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0; + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${r.username} + + ${used} / ${max || "∞"} +
Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}
+
+ + ${r.expires_at ? fmtDate(r.expires_at) : "—"} + ${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"} + `; + const tdA = tr.lastElementChild; + const editBtn = Object.assign(document.createElement("button"),{ + className:"btn btn-ghost btn-sm", textContent:"Edit", + onclick: () => fillResellerForm(r), + }); + const delBtn = Object.assign(document.createElement("button"),{ + className:"btn btn-danger btn-sm", textContent:"Del", + style: "margin-left:4px;", + onclick: () => deleteReseller(r.username), + }); + tdA.append(editBtn, delBtn); + resellersBody.appendChild(tr); + }); +} + +function fillResellerForm(r) { + resellerFormTitle.textContent = `Edit: ${r.username}`; + rUsername.value = r.username; + rPassword.value = ""; + rMaxUsers.value = r.max_users || 0; + rExpires.value = r.expires_at ? localFromISO(r.expires_at) : ""; + rActive.checked = r.is_active; + resellerStatus.textContent = `Editing ${r.username}.`; +} + +resellerForm.addEventListener("submit", async e => { + e.preventDefault(); + const btn = document.getElementById("saveResellerBtn"); + btn.disabled = true; + resellerStatus.textContent = "Saving…"; + const payload = { + username: rUsername.value.trim(), + password: rPassword.value || undefined, + max_users: parseInt(rMaxUsers.value||"0",10), + expires_at: isoFromLocal(rExpires.value), + is_active: rActive.checked, + }; + try { + const res = await api("/api/resellers/create", { method:"POST", body: JSON.stringify(payload) }); + if (!res.ok) throw new Error(await res.text()); + resellerStatus.textContent = "Saved."; + resellerForm.reset(); rActive.checked = true; + resellerFormTitle.textContent = "Create Reseller"; + loadResellers(); + } catch (e) { + if (e.message==="auth") doAuthError(); + else resellerStatus.textContent = "Error: "+e.message; + } finally { btn.disabled = false; } +}); + +async function deleteReseller(username) { + if (!confirm(`Delete reseller "${username}"? All their SSH sessions will be disconnected.`)) return; + resellerStatus.textContent = `Deleting ${username}…`; + try { + const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" }); + if (!res.ok && res.status !== 204) throw new Error("failed"); + resellerStatus.textContent = "Deleted."; + loadResellers(); + } catch (e) { + if (e.message==="auth") doAuthError(); + else resellerStatus.textContent = "Error deleting."; + } +} + +// ─── Stats ──────────────────────────────────────────────────────────────────── +document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats); + +async function loadStats() { + try { + const res = await api("/api/stats"); + const s = await res.json(); + const cpu = s?.cpu_percent ?? 0; + cpuVal.textContent = fmtPct(cpu); + cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%"; + const mp = s?.mem_percent ?? null; + memVal.textContent = mp == null ? "--%" : fmtPct(mp); + memBar.style.width = mp == null ? "0%" : Math.min(100, Math.max(0, mp)) + "%"; + const mu = s?.mem_used_bytes, mt = s?.mem_total_bytes; + memDetail.textContent = (mu != null && mt != null) ? `${fmtBytes(mu)} / ${fmtBytes(mt)}` : ""; + const ifaces = Array.isArray(s.interfaces) ? s.interfaces : []; + ifaceBody.innerHTML = ""; + let totRx = 0, totTx = 0; + ifaces.forEach(it => { + totRx += it.rx_bytes||0; totTx += it.tx_bytes||0; + const tr = document.createElement("tr"); + tr.innerHTML = `${it.name}${fmtMbps(it.rx_mbps)}${fmtMbps(it.tx_mbps)}${fmtBytes(it.rx_bytes)}${fmtBytes(it.tx_bytes)}`; + ifaceBody.appendChild(tr); + }); + ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; + statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString(); + } catch (e) { if (e.message==="auth") doAuthError(); } +} + +resetIfaceStatsBtn?.addEventListener("click", resetInterfaceStats); + +async function resetInterfaceStats() { + if (!confirm("Clean the live Interface totals now? This does not delete VnStat daily/monthly history.")) return; + resetIfaceStatsBtn.disabled = true; + ifaceSummary.textContent = "Cleaning interface totals…"; + try { + const res = await api("/api/stats/interfaces/reset", { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + ifaceSummary.textContent = "Interface totals cleaned. Auto-clean remains every 30 days."; + loadStats(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else ifaceSummary.textContent = "Error cleaning totals: " + e.message; + } finally { + resetIfaceStatsBtn.disabled = false; + } +} + +// ─── VnStat ─────────────────────────────────────────────────────────────────── +document.querySelector("[data-tab='vnstat']")?.addEventListener("click", loadVnstat); +reloadVnstatBtn?.addEventListener("click", loadVnstat); +resetVnstatBtn?.addEventListener("click", resetVnstatHistory); + +function renderVnstatRows(body, rows, emptyLabel) { + body.innerHTML = ""; + if (!rows.length) { + const tr = document.createElement("tr"); + tr.innerHTML = `${emptyLabel}`; + body.appendChild(tr); + return; + } + rows.forEach(r => { + const tr = document.createElement("tr"); + tr.innerHTML = `${r.period || "--"}${r.iface || "--"}${fmtBytes(r.rx_bytes||0)}${fmtBytes(r.tx_bytes||0)}${fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0)))}`; + body.appendChild(tr); + }); +} + +async function loadVnstat() { + vnstatStatus.textContent = "Loading VnStat usage…"; + try { + const res = await api("/api/vnstat?days=31&months=12"); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + const daily = Array.isArray(data.daily) ? data.daily : []; + const monthly = Array.isArray(data.monthly) ? data.monthly : []; + renderVnstatRows(vnstatDailyBody, daily, "No daily usage recorded yet."); + renderVnstatRows(vnstatMonthlyBody, monthly, "No monthly usage recorded yet."); + + // Use the server/database periods when available. Falling back to the + // newest row avoids browser UTC/local-time mismatches that can make + // "Today total" show 0 while the daily table has data. + const today = data.today_period || daily[0]?.period || localDateKey(); + const month = data.month_period || today.slice(0,7); + const todayTotal = data.today_total_bytes ?? daily.filter(r => r.period === today).reduce((sum, r) => sum + (r.total_bytes||0), 0); + const monthTotal = data.month_total_bytes ?? monthly.filter(r => r.period === month).reduce((sum, r) => sum + (r.total_bytes||0), 0); + const ifaces = new Set([...daily, ...monthly].map(r => r.iface).filter(Boolean)); + vnTodayTotal.textContent = fmtBytes(todayTotal); + vnMonthTotal.textContent = fmtBytes(monthTotal); + vnIfaceCount.textContent = String(data.interface_count ?? ifaces.size ?? 0); + vnstatStatus.textContent = "Updated: " + new Date().toLocaleTimeString() + " · history is kept until manually cleaned."; + } catch (e) { + if (e.message === "auth") doAuthError(); + else vnstatStatus.textContent = "Error loading VnStat usage: " + e.message; + } +} + +async function resetVnstatHistory() { + if (!confirm("Clean all VnStat daily/monthly usage history? This does not reset the live Interface totals.")) return; + resetVnstatBtn.disabled = true; + vnstatStatus.textContent = "Cleaning VnStat history…"; + try { + const res = await api("/api/vnstat/reset", { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + vnstatStatus.textContent = "VnStat history cleaned."; + loadVnstat(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else vnstatStatus.textContent = "Error cleaning VnStat history: " + e.message; + } finally { + resetVnstatBtn.disabled = false; + } +} + +// ─── Logs ───────────────────────────────────────────────────────────────────── +document.querySelector("[data-tab='logs']")?.addEventListener("click", loadSystemLogs); +document.getElementById("logSource")?.addEventListener("change", loadSystemLogs); +document.getElementById("clearPanelLogBtn")?.addEventListener("click", clearPanelLog); + +async function loadSystemLogs() { + const box = document.getElementById("systemLogBox"); + const st = document.getElementById("systemLogStatus"); + const source = document.getElementById("logSource")?.value || "panel"; + const clearBtn = document.getElementById("clearPanelLogBtn"); + if (clearBtn) clearBtn.disabled = source !== "panel"; + st.textContent = "Loading…"; + try { + const res = await api(`/api/system/logs?source=${encodeURIComponent(source)}&lines=500`); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + const lines = Array.isArray(data.lines) ? data.lines : []; + box.textContent = lines.length ? lines.join("\n") : "No log lines yet."; + box.scrollTop = box.scrollHeight; + st.textContent = `${data.source || source} logs${data.path ? " · " + data.path : ""} · ${lines.length} lines · ` + new Date().toLocaleTimeString(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else st.textContent = "Error: " + e.message; + } +} + +async function clearPanelLog() { + const st = document.getElementById("systemLogStatus"); + if (!confirm("Clean the panel log now? Logs are already auto-cleaned after 1 MiB.")) return; + st.textContent = "Cleaning panel log…"; + try { + const res = await api("/api/system/logs/reset", { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + st.textContent = `Panel log cleaned · ${data.path || "panel.log"} · max ${fmtBytes(data.max_bytes || 1048576)}`; + await loadSystemLogs(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else st.textContent = "Error cleaning panel log: " + e.message; + } +} + +// ─── Server Config ──────────────────────────────────────────────────────────── +document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig); + +function toggleDnsttFields(on) { + const el = document.getElementById("dnsttFields"); + el.style.opacity = on ? "1" : ".4"; + el.style.pointerEvents = on ? "" : "none"; +} +function toggleUdpgwFields(on) { + const el = document.getElementById("udpgwFields"); + el.style.opacity = on ? "1" : ".4"; + el.style.pointerEvents = on ? "" : "none"; +} + +async function loadServerConfig() { + const st = document.getElementById("srvCfgStatus"); + st.textContent = "Loading…"; + try { + const res = await api("/api/server/config"); + if (!res.ok) throw new Error(await res.text()); + const c = await res.json(); + + // Network + document.getElementById("cfgListen").value = c.listen || ""; + document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n"); + + // SSH / general + document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0; + document.getElementById("cfgLimitDown").value = c.default_limit_mbps_down || 0; + document.getElementById("cfgQuiet").checked = !!c.quiet; + document.getElementById("cfgUserCount").checked = !!c.user_count; + + // Banner + document.getElementById("cfgBanner").value = c.banner || ""; + + // DNSTT + const hasDnstt = !!c.dnstt; + document.getElementById("cfgDnsttEnabled").checked = hasDnstt; + toggleDnsttFields(hasDnstt); + const d = c.dnstt || {}; + document.getElementById("cfgDnsttDomain").value = d.domain || ""; + document.getElementById("cfgDnsttUDP").value = d.udp_listen || ""; + document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key"; + document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log; + document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log; + + // UDPGW + const hasUdpgw = !!c.udpgw; + document.getElementById("cfgUdpgwEnabled").checked = hasUdpgw; + toggleUdpgwFields(hasUdpgw); + const u = c.udpgw || {}; + document.getElementById("cfgUdpgwListen").value = u.listen || ""; + document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0; + document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || ""; + document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || ""; + document.getElementById("cfgUdpgwDebug").checked = !!u.debug; + + // TLS forwarders + tlsForwardersState = c.tls_forwarders || []; + renderTLSForwarders(); + + // Xray + const x = c.xray || {}; + document.getElementById("cfgXrayEnabled").checked = !!x.enabled; + + st.textContent = "Config loaded."; + } catch (e) { + if (e.message === "auth") doAuthError(); + else st.textContent = "Error: " + e.message; + } +} + +async function saveServerConfig() { + const st = document.getElementById("srvCfgStatus"); + st.textContent = "Saving…"; + + const tlsArr = tlsForwardersState; + + const extraLines = document.getElementById("cfgExtraListen").value + .split("\n").map(s => s.trim()).filter(Boolean); + + const cfg = { + listen: document.getElementById("cfgListen").value.trim(), + extra_listen: extraLines, + host_key_file: "/opt/sshpanel/ssh_host_rsa_key", + admin_dir: "/opt/sshpanel/admin", + default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10), + default_limit_mbps_down: parseInt(document.getElementById("cfgLimitDown").value || "0", 10), + quiet: document.getElementById("cfgQuiet").checked, + user_count: document.getElementById("cfgUserCount").checked, + banner: document.getElementById("cfgBanner").value, + banner_file: "/opt/sshpanel/banner.txt", + dnstt: document.getElementById("cfgDnsttEnabled").checked ? { + domain: document.getElementById("cfgDnsttDomain").value.trim(), + udp_listen: document.getElementById("cfgDnsttUDP").value.trim(), + privkey_file: document.getElementById("cfgDnsttKey").value.trim(), + disable_stats_log: document.getElementById("cfgDnsttNoStats").checked, + disable_console_log: document.getElementById("cfgDnsttNoConsole").checked, + } : null, + udpgw: document.getElementById("cfgUdpgwEnabled").checked ? { + listen: document.getElementById("cfgUdpgwListen").value.trim(), + max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10), + idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(), + map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(), + debug: document.getElementById("cfgUdpgwDebug").checked, + } : null, + tls_forwarders: tlsArr, + xray: { + enabled: document.getElementById("cfgXrayEnabled").checked, + bin_path: "/opt/sshpanel/xray", + config_file: "/opt/sshpanel/xray_config.json", + api_server: "127.0.0.1:10085", + online_window_seconds: 90, + stats_poll_seconds: 15, + }, + }; + + try { + const res = await api("/api/server/config", { method: "POST", body: JSON.stringify(cfg) }); + if (!res.ok) throw new Error(await res.text()); + const report = await res.json().catch(() => null); + const warnings = report?.warnings || []; + const bad = Object.entries(report?.services || {}).filter(([_, v]) => v?.enabled && !v?.running); + if (warnings.length || bad.length) { + const badText = bad.map(([name, v]) => `${name}: ${v.error || "not running"}`).join(" | "); + st.textContent = "Saved live with warnings: " + [...warnings, badText].filter(Boolean).join(" | "); + } else { + st.textContent = "Saved and applied live."; + } + } catch (e) { + if (e.message === "auth") doAuthError(); + else st.textContent = "Error: " + e.message; + } +} + +// ─── TLS Forwarders ──────────────────────────────────────────────────────────── +function renderTLSForwarders() { + const list = document.getElementById("tlsForwardersList"); + const chip = document.getElementById("tlsCountChip"); + if (!list) return; + chip.textContent = tlsForwardersState.length; + if (!tlsForwardersState.length) { + list.innerHTML = '
No TLS forwarders configured.
'; + return; + } + list.innerHTML = ""; + tlsForwardersState.forEach((fw, i) => { + const row = document.createElement("div"); + row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;"; + row.innerHTML = `${fw.listen} + ${fw.cert_file ? fw.cert_file.split("/").pop() : "no cert"}`; + const delBtn = document.createElement("button"); + delBtn.className = "btn btn-danger btn-sm"; + delBtn.textContent = "Remove"; + delBtn.onclick = () => { tlsForwardersState.splice(i,1); renderTLSForwarders(); }; + row.appendChild(delBtn); + list.appendChild(row); + }); +} + +function toggleAddTLSForm() { + const panel = document.getElementById("addTLSPanel"); + panel.classList.toggle("hidden"); + if (!panel.classList.contains("hidden")) { + document.getElementById("tlsAddStatus").textContent = ""; + document.getElementById("tlsListenAddr").value = ""; + document.getElementById("tlsSSLDomain").value = ""; + document.getElementById("tlsCertType").value = "selfsigned"; + onTLSTypeChange("selfsigned"); + } +} + +function onTLSTypeChange(val) { + document.getElementById("tlsSSFields").style.display = val === "selfsigned" ? "" : "none"; + document.getElementById("tlsLEFields").style.display = val === "letsencrypt" ? "grid" : "none"; + document.getElementById("tlsPasteFields").style.display = val === "paste" ? "" : "none"; + document.getElementById("tlsCustomFields").style.display = val === "custom" ? "grid" : "none"; +} + +async function addTLSForwarder() { + const st = document.getElementById("tlsAddStatus"); + const listen = document.getElementById("tlsListenAddr").value.trim(); + const certType = document.getElementById("tlsCertType").value; + if (!listen) { st.textContent = "Listen address required."; return; } + let certFile = "", keyFile = ""; + st.textContent = "Processing…"; + if (certType === "selfsigned") { + const domain = document.getElementById("tlsSSLDomain").value.trim(); + if (!domain) { st.textContent = "Domain required."; return; } + try { + const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + certFile = data.cert_file; keyFile = data.key_file; + st.textContent = "Self-signed cert generated."; + } catch (e) { + if (e.message==="auth") doAuthError(); + else st.textContent = "Cert error: " + e.message; + return; + } + } else if (certType === "letsencrypt") { + const domain = document.getElementById("tlsLEDomain").value.trim(); + const email = document.getElementById("tlsLEEmail").value.trim(); + if (!domain || !email) { st.textContent = "Domain and email required."; return; } + st.textContent = "Running certbot… (may take ~30s)"; + try { + const res = await api("/api/tls/letsencrypt", { method:"POST", body: JSON.stringify({ domain, email }) }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + certFile = data.cert_file; keyFile = data.key_file; + st.textContent = "Let's Encrypt cert issued."; + } catch (e) { + if (e.message==="auth") doAuthError(); + else st.textContent = "certbot error: " + e.message; + return; + } + } else if (certType === "paste") { + const name = document.getElementById("tlsPasteName").value.trim(); + const cert = document.getElementById("tlsPasteCert").value.trim(); + const key = document.getElementById("tlsPasteKey").value.trim(); + if (!name || !cert || !key) { st.textContent = "Name, cert PEM, and key PEM required."; return; } + try { + const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + certFile = data.cert_file; keyFile = data.key_file; + st.textContent = "PEM saved."; + } catch (e) { + if (e.message==="auth") doAuthError(); + else st.textContent = "Upload error: " + e.message; + return; + } + } else { + certFile = document.getElementById("tlsCustomCert").value.trim(); + keyFile = document.getElementById("tlsCustomKey").value.trim(); + if (!certFile || !keyFile) { st.textContent = "Cert and key paths required."; return; } + } + tlsForwardersState.push({ listen, cert_file: certFile, key_file: keyFile }); + renderTLSForwarders(); + document.getElementById("addTLSPanel").classList.add("hidden"); + st.textContent = "Added. Save config to apply."; +} + +// ─── Xray wizard cert source picker ────────────────────────────────────────── +function setWzCertSrc(mode) { + ["file","paste","gen"].forEach(m => { + const cap = m.charAt(0).toUpperCase() + m.slice(1); + document.getElementById("wzCertSrc"+cap).style.display = m === mode ? "" : "none"; + const btn = document.getElementById("wzCertSrc"+cap+"Btn"); + if (btn) btn.className = (m === mode ? "btn btn-sm" : "btn btn-ghost btn-sm"); + }); + document.getElementById("wzPasteCertStatus").textContent = ""; + document.getElementById("wzGenCertStatus").textContent = ""; +} + +async function wzSavePastedCert() { + const st = document.getElementById("wzPasteCertStatus"); + const name = document.getElementById("wzPastedName").value.trim(); + const cert = document.getElementById("wzPastedCert").value.trim(); + const key = document.getElementById("wzPastedKey").value.trim(); + if (!name || !cert || !key) { st.textContent = "Name, cert, and key required."; return; } + st.textContent = "Saving…"; + try { + const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + document.getElementById("wzTLSCert").value = data.cert_file; + document.getElementById("wzTLSKey").value = data.key_file; + st.textContent = "Saved ✓ paths set."; + } catch (e) { + if (e.message === "auth") doAuthError(); + else st.textContent = "Error: " + e.message; + } +} + +async function wzGenerateCert() { + const st = document.getElementById("wzGenCertStatus"); + const domain = document.getElementById("wzGenDomain").value.trim(); + if (!domain) { st.textContent = "Domain required."; return; } + st.textContent = "Generating…"; + try { + const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + document.getElementById("wzTLSCert").value = data.cert_file; + document.getElementById("wzTLSKey").value = data.key_file; + st.textContent = "Generated ✓ paths set."; + } catch (e) { + if (e.message === "auth") doAuthError(); + else st.textContent = "Error: " + e.message; + } +} + +// ─── DNSTT Key Management ───────────────────────────────────────────────────── +async function generateDnsttKey() { + const st = document.getElementById("dnsttKeyStatus"); + st.textContent = "Generating key…"; + try { + const res = await api("/api/dnstt/genkey", { method: "POST" }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + document.getElementById("cfgDnsttKey").value = data.privkey_file; + document.getElementById("dnsttPubkeyVal").value = data.pubkey; + document.getElementById("dnsttPubkeyWrap").classList.remove("hidden"); + st.textContent = "Key generated. Save config to apply."; + } catch (e) { + if (e.message==="auth") doAuthError(); + else st.textContent = "Error: " + e.message; + } +} + +async function loadDnsttPubkey() { + const st = document.getElementById("dnsttKeyStatus"); + st.textContent = "Loading public key…"; + try { + const res = await api("/api/dnstt/pubkey"); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + document.getElementById("dnsttPubkeyVal").value = data.pubkey; + document.getElementById("dnsttPubkeyWrap").classList.remove("hidden"); + st.textContent = ""; + } catch (e) { + if (e.message==="auth") doAuthError(); + else st.textContent = "Error: " + e.message; + } +} + +// ─── Xray Client Edit ───────────────────────────────────────────────────────── +function openEditXrayClient(tag, client) { + editingXrayClientId = client.id; + document.getElementById("editClientUUID").textContent = client.id; + document.getElementById("editXrayName").value = client.name || ""; + document.getElementById("editXrayEmail").value = client.email || ""; + document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : ""; + document.getElementById("editXrayMaxConns").value = client.max_conns || 0; + document.getElementById("editXrayClientStatus").textContent = ""; + document.getElementById("editXrayClientPanel").classList.remove("hidden"); + document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" }); +} + +function closeEditXrayClient() { + editingXrayClientId = null; + document.getElementById("editXrayClientPanel").classList.add("hidden"); +} + +async function saveEditXrayClient() { + if (!editingXrayClientId) return; + const st = document.getElementById("editXrayClientStatus"); + st.textContent = "Saving…"; + const payload = { + uuid: editingXrayClientId, + name: document.getElementById("editXrayName").value.trim(), + email: document.getElementById("editXrayEmail").value.trim(), + expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value), + max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10), + }; + try { + const res = await api("/api/xray/clients/update", { method:"POST", body: JSON.stringify(payload) }); + if (!res.ok) throw new Error(await res.text()); + st.textContent = "Saved."; + setTimeout(() => { closeEditXrayClient(); loadInbounds(); }, 700); + } catch (e) { + if (e.message==="auth") doAuthError(); + else st.textContent = "Error: " + e.message; + } +} + +// ─── Xray Config Wizard ──────────────────────────────────────────────────────── +function setXrayCfgMode(mode) { + const wizPane = document.getElementById("xrayWizardPane"); + const jsonPane = document.getElementById("xrayCfgPaneJson"); + const wizBtn = document.getElementById("xrayWizardTabBtn"); + const jsonBtn = document.getElementById("xrayJsonTabBtn"); + if (mode === "wizard") { + wizPane.classList.remove("hidden"); + jsonPane.classList.add("hidden"); + wizBtn.classList.remove("btn-ghost"); + jsonBtn.classList.add("btn-ghost"); + loadWizardFromConfig(); + } else { + wizPane.classList.add("hidden"); + jsonPane.classList.remove("hidden"); + jsonBtn.classList.remove("btn-ghost"); + wizBtn.classList.add("btn-ghost"); + loadXrayCfg(); + } +} + +function loadWizardFromConfig() { + api("/api/xray/config").then(async res => { + if (!res.ok) return; + try { + const cfg = JSON.parse(await res.text()); + document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning"; + wzInbounds = (cfg.inbounds || []).filter(ib => ib.tag !== "api"); + renderWzInbounds(); + } catch {} + }).catch(() => {}); +} + +function renderWzInbounds() { + const list = document.getElementById("wzInboundsList"); + if (!list) return; + if (!wzInbounds.length) { + list.innerHTML = '
No inbounds. Click + Add to create one.
'; + return; + } + list.innerHTML = ""; + wzInbounds.forEach((ib, i) => { + const row = document.createElement("div"); + row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;"; + const portStr = ib.port !== undefined ? `:${ib.port}` : ""; + const ss = ib.streamSettings || {}; + const net = ss.network || ""; + const sec = ss.security || ""; + const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : ""; + const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : ""; + row.innerHTML = `${ib.protocol} + ${ib.tag||"untagged"}${portStr} + ${ib.listen||"0.0.0.0"}${net?" · "+net:""}${modeLabel}${secLabel}`; + const clients = ib.settings?.clients; + if (Array.isArray(clients) && clients.length) { + const badge = document.createElement("span"); + badge.className = "chip green"; + badge.textContent = clients.length + " client" + (clients.length!==1?"s":""); + row.appendChild(badge); + } + const delBtn = document.createElement("button"); + delBtn.className = "btn btn-danger btn-sm"; + delBtn.textContent = "Remove"; + delBtn.onclick = () => { wzInbounds.splice(i,1); renderWzInbounds(); }; + row.appendChild(delBtn); + list.appendChild(row); + }); +} + +function wzToggleAddInbound() { + const form = document.getElementById("wzAddInboundForm"); + form.classList.toggle("hidden"); + if (!form.classList.contains("hidden")) { + onWzProtoChange(document.getElementById("wzProtocol").value); + onWzNetworkChange(document.getElementById("wzNetwork").value); + onWzTLSChange(document.getElementById("wzTLS").value); + } +} + +function onWzProtoChange(val) { + const usesClientTransport = val === "vless" || val === "vmess"; + document.getElementById("wzVlessFields").style.display = usesClientTransport ? "grid" : "none"; + document.getElementById("wzTrojanFields").style.display = val === "trojan" ? "" : "none"; + document.getElementById("wzSSFields").style.display = val === "shadowsocks" ? "grid" : "none"; + + const tlsSel = document.getElementById("wzTLS"); + const realityOpt = document.querySelector("#wzTLS option[value='reality']"); + if (realityOpt) { + realityOpt.disabled = val === "vmess"; + if (val === "vmess" && tlsSel.value === "reality") { + tlsSel.value = "none"; + onWzTLSChange("none"); + } + } + + const portMap = { vless:10086, vmess:10087, trojan:8443, shadowsocks:8388, socks:10808 }; + const tagMap = { vless:"vless-in", vmess:"vmess-in", trojan:"trojan-in", shadowsocks:"ss-in", socks:"socks-local" }; + const portEl = document.getElementById("wzPort"); + const tagEl = document.getElementById("wzTag"); + const lisEl = document.getElementById("wzListenIP"); + const knownPorts = Object.values(portMap).map(String); + const knownTags = Object.values(tagMap); + if (!portEl.value || knownPorts.includes(portEl.value)) portEl.value = portMap[val] || ""; + if (!tagEl.value || knownTags.includes(tagEl.value)) tagEl.value = tagMap[val] || val+"-in"; + if (!lisEl.value || lisEl.value === "0.0.0.0" || lisEl.value === "127.0.0.1") { + lisEl.value = val === "socks" ? "127.0.0.1" : "0.0.0.0"; + } +} + +function onWzNetworkChange(val) { + const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none"; + // WebSocket + show("wzWSPathField", val === "ws"); + // XHTTP + show("wzXHTTPPathField", val === "xhttp"); + show("wzXHTTPHostField", val === "xhttp"); + show("wzXHTTPModeField", val === "xhttp"); + // HTTPUpgrade + show("wzHUPathField", val === "httpupgrade"); + show("wzHUHostField", val === "httpupgrade"); + // H2 + show("wzH2PathField", val === "h2"); + show("wzH2HostField", val === "h2"); + // gRPC + show("wzGRPCServiceField", val === "grpc"); + show("wzGRPCMultiField", val === "grpc"); + // Auto-select TLS defaults + const tlsSel = document.getElementById("wzTLS"); + if ((val === "h2" || val === "grpc") && tlsSel.value === "none") { + tlsSel.value = "tls"; onWzTLSChange("tls"); + } +} + +function onWzTLSChange(val) { + const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none"; + show("wzTLSCertBlock", val === "tls"); + show("wzRealityDestField", val === "reality"); + show("wzRealitySNIField", val === "reality"); + show("wzRealityPrivField", val === "reality"); + show("wzRealityShortIDField",val === "reality"); +} + +function wzSaveInbound() { + const proto = document.getElementById("wzProtocol").value; + const port = parseInt(document.getElementById("wzPort").value || "0", 10); + const listen = document.getElementById("wzListenIP").value.trim() || "0.0.0.0"; + const tag = document.getElementById("wzTag").value.trim() || proto+"-in"; + if (!port) { alert("Port required."); return; } + const ib = { tag, port, listen, protocol: proto, settings: {} }; + if (proto === "vless" || proto === "vmess") { + ib.settings = proto === "vless" ? { clients: [], decryption: "none" } : { clients: [] }; + const net = document.getElementById("wzNetwork").value; + const tlsVal = document.getElementById("wzTLS").value; + ib.streamSettings = { network: net }; + // Transport-specific settings + switch (net) { + case "ws": + ib.streamSettings.wsSettings = { path: document.getElementById("wzWSPath").value.trim() || "/" }; + break; + case "xhttp": + ib.streamSettings.xhttpSettings = { + path: document.getElementById("wzXHTTPPath").value.trim() || "/", + host: document.getElementById("wzXHTTPHost").value.trim() || undefined, + mode: document.getElementById("wzXHTTPMode").value, + }; + if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host; + break; + case "httpupgrade": + ib.streamSettings.httpupgradeSettings = { + path: document.getElementById("wzHUPath").value.trim() || "/", + host: document.getElementById("wzHUHost").value.trim() || undefined, + }; + if (!ib.streamSettings.httpupgradeSettings.host) delete ib.streamSettings.httpupgradeSettings.host; + break; + case "h2": + ib.streamSettings.httpSettings = { + path: document.getElementById("wzH2Path").value.trim() || "/", + host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean), + }; + break; + case "grpc": + ib.streamSettings.grpcSettings = { + serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc", + multiMode: document.getElementById("wzGRPCMulti").checked, + }; + break; + } + // TLS / Reality + if (tlsVal === "tls") { + ib.streamSettings.security = "tls"; + ib.streamSettings.tlsSettings = { + certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }], + }; + } else if (tlsVal === "reality" && proto === "vless") { + ib.streamSettings.security = "reality"; + ib.streamSettings.realitySettings = { + dest: document.getElementById("wzRealityDest").value.trim(), + serverNames: [document.getElementById("wzRealitySNI").value.trim()].filter(Boolean), + privateKey: document.getElementById("wzRealityPriv").value.trim(), + shortIds: [document.getElementById("wzRealityShortID").value.trim()].filter(Boolean), + }; + } + } else if (proto === "trojan") { + ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] }; + ib.streamSettings = { network: "tcp", security: "tls", tlsSettings: {} }; + } else if (proto === "shadowsocks") { + ib.settings = { method: document.getElementById("wzSSMethod").value, password: document.getElementById("wzSSPass").value.trim() || "change-me", network: "tcp,udp" }; + } else if (proto === "socks") { + ib.settings = { auth: "noauth", udp: true }; + ib.streamSettings = { network: "tcp" }; + } + wzInbounds.push(ib); + renderWzInbounds(); + document.getElementById("wzAddInboundForm").classList.add("hidden"); + document.getElementById("wzPort").value = ""; + document.getElementById("wzTag").value = ""; + document.getElementById("wzListenIP").value = ""; +} + +async function applyWizardConfig() { + const st = document.getElementById("wzStatus"); + st.textContent = "Saving…"; + const apiInbound = { + tag: "api", + listen: "127.0.0.1", + port: 10085, + protocol: "dokodemo-door", + settings: { address: "127.0.0.1" } + }; + const userInbounds = (wzInbounds || []).filter(ib => ib.tag !== "api"); + const cfg = { + log: { loglevel: document.getElementById("wzLogLevel").value }, + api: { tag: "api", services: ["HandlerService", "LoggerService", "StatsService"] }, + stats: {}, + policy: { levels: { "0": { statsUserUplink: true, statsUserDownlink: true } }, system: { statsInboundUplink: true, statsInboundDownlink: true } }, + inbounds: [apiInbound, ...userInbounds], + outbounds: [ + { tag:"direct", protocol:"freedom", settings:{} }, + { tag:"blocked", protocol:"blackhole", settings:{} }, + { tag:"api", protocol:"freedom", settings:{} } + ], + routing: { rules: [{ type: "field", inboundTag: ["api"], outboundTag: "api" }] } + }; + try { + const res = await api("/api/xray/config", { method:"POST", body: JSON.stringify(cfg, null, 2) }); + if (!res.ok) throw new Error(await res.text()); + st.textContent = "Saved. Restarting Xray…"; + await xrayCtrl("restart"); + st.textContent = "Config saved and Xray restarted."; + } catch (e) { + if (e.message==="auth") doAuthError(); + else st.textContent = "Error: " + e.message; + } +} + +// ─── Auth error ─────────────────────────────────────────────────────────────── +function doAuthError() { + sessionToken = ""; + localStorage.removeItem("SESSION_TOKEN"); + clearTimers(); + mainApp.classList.add("hidden"); + loginOverlay.classList.remove("hidden"); + loginErr.textContent = "Session expired — please sign in again."; +} + +// ─── Boot ───────────────────────────────────────────────────────────────────── +window.addEventListener("load", () => { + if (sessionToken) { + // Try to validate the stored token + api("/api/auth/me").then(async res => { + if (!res.ok) { doAuthError(); return; } + const d = await res.json(); + currentRole = d.role; + currentUser = d.username; + loginOverlay.classList.add("hidden"); + mainApp.classList.remove("hidden"); + initAfterLogin(); + }).catch(() => doAuthError()); + } else { + loginOverlay.classList.remove("hidden"); + } +}); diff --git a/admin/index.html b/admin/index.html index 48cb837..efb9596 100644 --- a/admin/index.html +++ b/admin/index.html @@ -2,145 +2,9 @@ -SSH Panel +DragonCore Panel - +
@@ -160,28 +24,140 @@ html,body{width:100%;max-width:100%;overflow-x:hidden;}
- + diff --git a/admin_script.js b/admin_script.js index 64ce1ad..e595009 100644 --- a/admin_script.js +++ b/admin_script.js @@ -1,3 +1,4 @@ + // ─── State ─────────────────────────────────────────────────────────────────── let sessionToken = localStorage.getItem("SESSION_TOKEN") || ""; let currentRole = ""; @@ -18,6 +19,26 @@ const mainApp = document.getElementById("mainApp"); const meUsername = document.getElementById("meUsername"); const roleChip = document.getElementById("roleChip"); const logoutBtn = document.getElementById("logoutBtn"); +const menuToggle = document.getElementById("menuToggle"); +const drawerBackdrop = document.getElementById("drawerBackdrop"); +const themeToggle = document.getElementById("themeToggle"); +const pageTitle = document.getElementById("pageTitle"); +const pageEyebrow = document.getElementById("pageEyebrow"); +const sidebarUsername = document.getElementById("sidebarUsername"); +const sidebarRole = document.getElementById("sidebarRole"); +const dashTotalUsers = document.getElementById("dashTotalUsers"); +const dashActiveUsers = document.getElementById("dashActiveUsers"); +const dashExpiredUsers = document.getElementById("dashExpiredUsers"); +const dashConnections = document.getElementById("dashConnections"); +const dashServers = document.getElementById("dashServers"); +const dashServerStatus = document.getElementById("dashServerStatus"); +const dashXrayClients = document.getElementById("dashXrayClients"); +const dashXrayStatus = document.getElementById("dashXrayStatus"); +const dashQuotaChip = document.getElementById("dashQuotaChip"); +const dashQuotaBar = document.getElementById("dashQuotaBar"); +const dashQuotaText = document.getElementById("dashQuotaText"); +const dashQuotaBreakdown = document.getElementById("dashQuotaBreakdown"); +const dashboardQuotaCard = document.getElementById("dashboardQuotaCard"); // Users const usersBody = document.getElementById("usersBody"); @@ -153,15 +174,48 @@ function genUUID() { (c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16)); } -// ─── Tab switching ──────────────────────────────────────────────────────────── -document.querySelectorAll(".tab-btn").forEach(btn => { - btn.addEventListener("click", () => { - document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); - document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active")); - btn.classList.add("active"); - document.getElementById("tab-" + btn.dataset.tab).classList.add("active"); - }); -}); +// ─── Navigation / shell ────────────────────────────────────────────────────── +const tabTitles = { + dashboard: ["Painel", "Visão geral"], + ssh: ["Contas", "SSH / SlowDNS"], + xray: ["Contas", "Xray Users"], + resellers: ["Administração", "Revendedores"], + stats: ["Servidor", "Monitoramento"], + vnstat: ["Tráfego", "VnStat"], + logs: ["Sistema", "Logs"], + server: ["Sistema", "Configurações"], +}; + +function selectTab(tab) { + const pane = document.getElementById("tab-" + tab); + const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`); + if (!pane || !btn) return; + document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); + document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active")); + btn.classList.add("active"); + pane.classList.add("active"); + const [eyebrow, title] = tabTitles[tab] || ["Painel", tab]; + if (pageEyebrow) pageEyebrow.textContent = eyebrow; + if (pageTitle) pageTitle.textContent = title; + document.body.classList.remove("sidebar-open"); + + if (tab === "dashboard") refreshDashboard(); + if (tab === "xray") { + loadXrayStatus(); + loadInbounds(); + if (currentRole === "superadmin") loadWizardFromConfig(); + } + if (tab === "stats" && currentRole === "superadmin") loadStats(); + if (tab === "resellers" && currentRole === "superadmin") loadResellers(); +} + +document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab))); +menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open")); +drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open")); +themeToggle?.addEventListener("click", () => document.body.classList.toggle("light-mode")); +document.querySelectorAll(".quick-action[data-jump]").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.jump))); +document.getElementById("quickCreateUserBtn")?.addEventListener("click", () => { selectTab("ssh"); setFormCollapsed(false); fUsername?.focus(); }); +document.getElementById("quickOpenXrayBtn")?.addEventListener("click", () => selectTab("xray")); // ─── Login / Logout ─────────────────────────────────────────────────────────── loginBtn.addEventListener("click", doLogin); @@ -215,31 +269,34 @@ function clearTimers() { function initAfterLogin() { meUsername.textContent = currentUser; + if (sidebarUsername) sidebarUsername.textContent = currentUser; + if (sidebarRole) sidebarRole.textContent = currentRole === "superadmin" ? "Super Admin" : "Revendedor"; roleChip.innerHTML = currentRole === "superadmin" ? `superadmin` : `reseller`; - // Show/hide superadmin-only elements document.querySelectorAll(".superadmin-only").forEach(el => { el.classList.toggle("hidden", currentRole !== "superadmin"); }); + document.querySelectorAll(".xray-admin-only").forEach(el => { + el.classList.toggle("hidden", currentRole !== "superadmin"); + }); - // Reset to SSH tab - document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); - document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active")); - document.querySelector("[data-tab='ssh']").classList.add("active"); - document.getElementById("tab-ssh").classList.add("active"); + resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller"); + dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller"); + + selectTab("dashboard"); if (currentRole === "superadmin") { loadStats(); statsTimer = setInterval(loadStats, 2000); - xrayTimer = setInterval(loadXrayStatus, 5000); } else { - resellerInfoCard.classList.remove("hidden"); loadMe(); } + xrayTimer = setInterval(loadXrayStatus, 7000); loadUsers(); + loadInbounds(); usersTimer = setInterval(() => loadUsersSilent(), 3000); } @@ -248,13 +305,56 @@ async function loadMe() { try { const res = await api("/api/auth/me"); const d = await res.json(); - rUsedMax.textContent = (d.used_users ?? "--") + " / " + (d.max_users || "∞"); + const used = d.used_users ?? 0; + const max = d.max_users || 0; + rUsedMax.textContent = used + " / " + (max || "∞"); rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : "No limit"; rStatus.textContent = d.is_active ? "Active" : "Suspended"; rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)"; + updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0); } catch {} } +function updateQuotaCard(used, max, sshUsed = 0, xrayUsed = 0) { + if (!dashQuotaText) return; + const labelMax = max || "∞"; + dashQuotaChip.textContent = `${used} / ${labelMax}`; + dashQuotaText.textContent = max ? `${Math.max(0, max - used)} contas disponíveis` : "Sem limite definido pelo admin"; + dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`; + const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0; + dashQuotaBar.style.width = `${pct}%`; +} + +function updateDashboardFromUsers(users = []) { + if (!dashTotalUsers) return; + const now = new Date(); + let active = 0, expired = 0, conns = 0; + users.forEach(u => { + conns += Number(u.active_conns || 0); + if (u.expires_at && new Date(u.expires_at) < now) expired++; + else active++; + }); + dashTotalUsers.textContent = users.length; + dashActiveUsers.textContent = active; + dashExpiredUsers.textContent = expired; + dashConnections.textContent = conns; +} + +function updateDashboardXray(inbounds = []) { + if (!dashXrayClients) return; + const total = inbounds.reduce((sum, ib) => sum + ((ib.clients || []).length), 0); + dashXrayClients.textContent = total; + const running = xrayChip?.textContent || "--"; + dashXrayStatus.textContent = `Core: ${running}`; +} + +function refreshDashboard() { + loadUsersSilent(); + loadInbounds(); + loadXrayStatus(); + if (currentRole === "reseller") loadMe(); +} + // ─── SSH Users ──────────────────────────────────────────────────────────────── document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers); newUserBtn.addEventListener("click", () => { @@ -304,6 +404,7 @@ async function loadUsersSilent() { } function renderUsers(users) { + updateDashboardFromUsers(users); const isSA = currentRole === "superadmin"; userCountChip.textContent = users.length; if (isSA) ownerColHead.classList.remove("hidden"); @@ -418,7 +519,9 @@ document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg); document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs); document.querySelector("[data-tab='xray']")?.addEventListener("click", () => { - loadXrayStatus(); loadInbounds(); loadWizardFromConfig(); + loadXrayStatus(); + loadInbounds(); + if (currentRole === "superadmin") loadWizardFromConfig(); }); async function loadXrayStatus() { @@ -432,6 +535,9 @@ async function loadXrayStatus() { xRunning.style.color = run ? "var(--success)" : "var(--danger)"; xPID.textContent = s.pid || "--"; xUptime.textContent = s.uptime || "--"; + if (dashServers) dashServers.textContent = s.enabled ? "1" : "0"; + if (dashServerStatus) dashServerStatus.textContent = run ? "1 online" : (s.enabled ? "parado" : "desativado"); + if (dashXrayStatus) dashXrayStatus.textContent = `Core: ${xrayChip.textContent}`; if (s.error) xStatus.textContent = "Error: " + s.error; } catch (e) { if (e.message==="auth") doAuthError(); } } @@ -463,6 +569,7 @@ async function loadInbounds() { } function renderInbounds(inbounds) { + updateDashboardXray(inbounds); if (!inbounds.length) { inboundsContainer.innerHTML = '
No VLESS/VMess/Trojan inbounds found.
'; return; @@ -694,7 +801,7 @@ function renderResellers(list) { const tr = document.createElement("tr"); tr.innerHTML = ` ${r.username} - ${r.used_users} / ${r.max_users || "∞"} + ${r.used_users} / ${r.max_users || "∞"}
SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}
${r.expires_at ? fmtDate(r.expires_at) : "—"} ${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"} `; diff --git a/auth.go b/auth.go index eb47f5b..930853c 100644 --- a/auth.go +++ b/auth.go @@ -443,6 +443,7 @@ func startResellerExpiryChecker(store *Store) { u.IsActive = false adminUsers.set(u) disconnectOwnerUsers(u.Username) + removeOwnerXrayClients(ctx, store, u.Username) } // Reactivate resellers that have been renewed (inactive but expiry now in future/nil) @@ -537,7 +538,9 @@ func handleMe(w http.ResponseWriter, r *http.Request) { if s.Role == RoleReseller { if u, ok := adminUsers.get(s.Username); ok { resp["max_users"] = u.MaxUsers - resp["used_users"] = countOwnedUsers(s.Username) + resp["used_users"] = countOwnedQuota(r.Context(), statsStore, s.Username) + resp["used_ssh_users"] = countOwnedUsers(s.Username) + resp["used_xray_users"] = countOwnedXrayClients(r.Context(), statsStore, s.Username) resp["expires_at"] = u.ExpiresAt resp["is_active"] = u.IsActive } @@ -554,6 +557,8 @@ type ResellerDTO struct { Role string `json:"role"` MaxUsers int `json:"max_users"` UsedUsers int `json:"used_users"` + UsedSSH int `json:"used_ssh_users"` + UsedXray int `json:"used_xray_users"` ExpiresAt *time.Time `json:"expires_at,omitempty"` IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` @@ -577,7 +582,9 @@ func handleListResellers(store *Store) http.HandlerFunc { Username: u.Username, Role: u.Role, MaxUsers: u.MaxUsers, - UsedUsers: countOwnedUsers(u.Username), + UsedUsers: countOwnedQuota(r.Context(), store, u.Username), + UsedSSH: countOwnedUsers(u.Username), + UsedXray: countOwnedXrayClients(r.Context(), store, u.Username), ExpiresAt: u.ExpiresAt, IsActive: u.IsActive, CreatedAt: u.CreatedAt, @@ -652,8 +659,12 @@ func handleCreateReseller(store *Store) http.HandlerFunc { } adminUsers.set(u) - // If reseller was reactivated, users can reconnect automatically. - // Reconnect of existing SSH connections happens via the expiry checker. + if u.Role == RoleReseller { + if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) { + disconnectOwnerUsers(u.Username) + removeOwnerXrayClients(ctx, store, u.Username) + } + } w.WriteHeader(http.StatusCreated) } @@ -676,6 +687,7 @@ func handleDeleteReseller(store *Store) http.HandlerFunc { return } disconnectOwnerUsers(username) + removeOwnerXrayClients(ctx, store, username) adminUsers.delete(username) w.WriteHeader(http.StatusNoContent) } diff --git a/main.go b/main.go index 81af77c..6bce33e 100644 --- a/main.go +++ b/main.go @@ -1325,17 +1325,19 @@ func startAdminAPI(store *Store, addr string, adminDir string) { mux.Handle("/api/resellers/create", saSession(http.HandlerFunc(handleCreateReseller(store)))) mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store)))) - // Superadmin-only: Xray-core management - mux.Handle("/api/xray/status", saSession(http.HandlerFunc(handleXrayStatus))) + // Xray-core management. Service/config/log actions are superadmin-only; + // authenticated resellers may list inbounds and manage their own Xray clients. + mux.Handle("/api/xray/status", sessionMiddleware(http.HandlerFunc(handleXrayStatus))) mux.Handle("/api/xray/start", saSession(http.HandlerFunc(handleXrayStart))) mux.Handle("/api/xray/stop", saSession(http.HandlerFunc(handleXrayStop))) mux.Handle("/api/xray/restart", saSession(http.HandlerFunc(handleXrayRestart))) + mux.Handle("/api/xray/stats/repair", saSession(http.HandlerFunc(handleXrayRepairStats))) mux.Handle("/api/xray/config", saSession(http.HandlerFunc(handleXrayConfig))) mux.Handle("/api/xray/logs", saSession(http.HandlerFunc(handleXrayLogs))) - mux.Handle("/api/xray/inbounds", saSession(http.HandlerFunc(handleXrayInbounds))) - mux.Handle("/api/xray/clients/add", saSession(http.HandlerFunc(handleXrayClientAdd))) - mux.Handle("/api/xray/clients/update", saSession(http.HandlerFunc(handleXrayClientUpdate))) - mux.Handle("/api/xray/clients/remove", saSession(http.HandlerFunc(handleXrayClientRemove))) + mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds))) + mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd))) + mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate))) + mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove))) // Superadmin-only: TLS certificate generation mux.Handle("/api/tls/generate-selfsigned", saSession(http.HandlerFunc(handleTLSGenerateSelfSigned))) @@ -1508,7 +1510,7 @@ func handleCreateUser(store *Store) http.HandlerFunc { ).Scan(&existsInDB) if !existsInDB { owner, ok := adminUsers.get(sess.Username) - if ok && owner.MaxUsers > 0 && countOwnedUsers(sess.Username) >= owner.MaxUsers { + if ok && owner.MaxUsers > 0 && countOwnedQuota(ctx, store, sess.Username) >= owner.MaxUsers { http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) return } diff --git a/xray_clients.go b/xray_clients.go index 4813038..3cae163 100644 --- a/xray_clients.go +++ b/xray_clients.go @@ -8,29 +8,39 @@ import ( ) // XrayClientMeta holds metadata stored in PostgreSQL for an Xray client. -// Xray's own config only stores uuid/email/level; expiry and display name live here. +// Xray's own config only stores uuid/email/level; expiry, display name, +// reseller owner, and connection policy live here. type XrayClientMeta struct { - UUID string - Name string - Email string - InboundTag string - ExpiresAt *time.Time - MaxConns int - CreatedAt time.Time + UUID string + Name string + Email string + InboundTag string + OwnerUsername string + ExpiresAt *time.Time + MaxConns int + CreatedAt time.Time } func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error { - _, err := s.db.ExecContext(ctx, ` - CREATE TABLE IF NOT EXISTS xray_clients ( - uuid TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - email TEXT NOT NULL DEFAULT '', - inbound_tag TEXT NOT NULL DEFAULT '', - expires_at TIMESTAMPTZ, - max_conns INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - )`) - return err + stmts := []string{ + `CREATE TABLE IF NOT EXISTS xray_clients ( + uuid TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + inbound_tag TEXT NOT NULL DEFAULT '', + owner_username TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ, + max_conns INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`, + } + for _, stmt := range stmts { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return err + } + } + return nil } func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) error { @@ -39,15 +49,16 @@ func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) erro expiresAt = *m.ExpiresAt } _, err := s.db.ExecContext(ctx, ` - INSERT INTO xray_clients (uuid, name, email, inbound_tag, expires_at, max_conns) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (uuid) DO UPDATE SET - name = EXCLUDED.name, - email = EXCLUDED.email, - inbound_tag = EXCLUDED.inbound_tag, - expires_at = EXCLUDED.expires_at, - max_conns = EXCLUDED.max_conns`, - m.UUID, m.Name, m.Email, m.InboundTag, expiresAt, m.MaxConns) + name = EXCLUDED.name, + email = EXCLUDED.email, + inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END, + owner_username = CASE WHEN EXCLUDED.owner_username <> '' THEN EXCLUDED.owner_username ELSE xray_clients.owner_username END, + expires_at = EXCLUDED.expires_at, + max_conns = EXCLUDED.max_conns`, + m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns) return err } @@ -55,9 +66,9 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient m := &XrayClientMeta{} var expiresAt sql.NullTime err := s.db.QueryRowContext(ctx, ` - SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at + SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at FROM xray_clients WHERE uuid = $1`, uuid). - Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt) + Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt) if err != nil { return nil, err } @@ -74,17 +85,49 @@ func (s *Store) DeleteXrayClientMeta(ctx context.Context, uuid string) error { func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at + SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at FROM xray_clients ORDER BY created_at DESC`) if err != nil { return nil, err } defer rows.Close() + return scanXrayClientMetaRows(rows) +} + +func (s *Store) ListXrayClientsByOwner(ctx context.Context, ownerUsername string) ([]*XrayClientMeta, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at + FROM xray_clients WHERE owner_username = $1 ORDER BY created_at DESC`, ownerUsername) + if err != nil { + return nil, err + } + defer rows.Close() + return scanXrayClientMetaRows(rows) +} + +func (s *Store) CountXrayClientsByOwner(ctx context.Context, ownerUsername string) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM xray_clients WHERE owner_username = $1`, ownerUsername).Scan(&n) + return n, err +} + +func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at + FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanXrayClientMetaRows(rows) +} + +func scanXrayClientMetaRows(rows *sql.Rows) ([]*XrayClientMeta, error) { var out []*XrayClientMeta for rows.Next() { m := &XrayClientMeta{} var expiresAt sql.NullTime - if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil { + if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil { return nil, err } if expiresAt.Valid { @@ -95,27 +138,49 @@ func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, erro return out, rows.Err() } -func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) { - rows, err := s.db.QueryContext(ctx, ` - SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at - FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`) +func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int { + if store == nil || ownerUsername == "" { + return 0 + } + n, err := store.CountXrayClientsByOwner(ctx, ownerUsername) if err != nil { - return nil, err + log.Printf("count xray clients for %s: %v", ownerUsername, err) + return 0 } - defer rows.Close() - var out []*XrayClientMeta - for rows.Next() { - m := &XrayClientMeta{} - var expiresAt sql.NullTime - if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil { - return nil, err - } - if expiresAt.Valid { - m.ExpiresAt = &expiresAt.Time - } - out = append(out, m) + return n +} + +func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int { + return countOwnedUsers(ownerUsername) + countOwnedXrayClients(ctx, store, ownerUsername) +} + +func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) { + if store == nil || ownerUsername == "" { + return + } + clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername) + if err != nil { + log.Printf("xray owner cleanup: list %s: %v", ownerUsername, err) + return + } + needRestart := false + for _, m := range clients { + if m.InboundTag != "" { + if err := xrayMgr.RemoveXrayClient(m.InboundTag, m.UUID); err != nil { + log.Printf("xray owner cleanup: remove %s from %s: %v", m.UUID, m.InboundTag, err) + } else { + needRestart = true + } + } + if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil { + log.Printf("xray owner cleanup: delete meta %s: %v", m.UUID, err) + } + } + if needRestart { + if err := xrayMgr.Restart(); err != nil { + log.Printf("xray owner cleanup: restart: %v", err) + } } - return out, rows.Err() } // startXrayClientExpiryChecker runs a background goroutine that removes expired diff --git a/xray_integration.go b/xray_integration.go index bfdbc9c..af38267 100644 --- a/xray_integration.go +++ b/xray_integration.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "io" @@ -8,6 +9,8 @@ import ( "net/http" "os" "os/exec" + "regexp" + "strconv" "strings" "sync" "syscall" @@ -19,6 +22,13 @@ type XrayConfig struct { Enabled bool `json:"enabled"` BinPath string `json:"bin_path"` // e.g. /opt/sshpanel/xray ConfigFile string `json:"config_file"` // e.g. /opt/sshpanel/xray_config.json + + // Optional Xray API endpoint used for online client counters. If empty, + // the panel auto-detects a local inbound tagged "api" from the Xray config. + APIServer string `json:"api_server,omitempty"` // e.g. 127.0.0.1:10085 + // A client is considered online when its Xray stats traffic changed recently. + OnlineWindowSeconds int `json:"online_window_seconds,omitempty"` // default 90 + StatsPollSeconds int `json:"stats_poll_seconds,omitempty"` // default 15 } // xrayLogRing is a fixed-capacity circular buffer for captured log lines. @@ -81,6 +91,24 @@ type XrayManager struct { cfg *XrayConfig startTime time.Time lastErr string + + statsMu sync.RWMutex + statsByEmail map[string]xrayRuntimeStat + lastStatsErr string + lastStatsPoll time.Time + pollStarted bool +} + +type xrayTrafficCounters struct { + Uplink int64 + Downlink int64 +} + +type xrayRuntimeStat struct { + Email string + Uplink int64 + Downlink int64 + LastActive time.Time } var xrayMgr = &XrayManager{} @@ -93,6 +121,7 @@ func initXrayManager(cfg *XrayConfig) { xrayMgr.mu.Lock() xrayMgr.cfg = cfg xrayMgr.mu.Unlock() + xrayMgr.startStatsPoller() if cfg.Enabled { if err := xrayMgr.Start(); err != nil { @@ -129,6 +158,11 @@ func (m *XrayManager) Start() error { if _, err := os.Stat(m.cfg.BinPath); err != nil { return fmt.Errorf("xray binary not found at %s", m.cfg.BinPath) } + if changed, err := m.ensureStatsAPIConfigLocked(); err != nil { + return fmt.Errorf("xray stats api check failed: %w", err) + } else if changed { + log.Printf("xray: repaired Stats API support in config before start") + } args := []string{"run"} if m.cfg.ConfigFile != "" { @@ -201,18 +235,23 @@ func (m *XrayManager) Restart() error { // XrayStatusDTO is returned by /api/xray/status. type XrayStatusDTO struct { - Enabled bool `json:"enabled"` - Running bool `json:"running"` - PID int `json:"pid,omitempty"` - Uptime string `json:"uptime,omitempty"` - Error string `json:"error,omitempty"` + Enabled bool `json:"enabled"` + Running bool `json:"running"` + PID int `json:"pid,omitempty"` + Uptime string `json:"uptime,omitempty"` + Error string `json:"error,omitempty"` + OnlineUsers int `json:"online_users"` + StatsError string `json:"stats_error,omitempty"` + StatsConfigured bool `json:"stats_configured"` + StatsMissing []string `json:"stats_missing,omitempty"` + APIServer string `json:"api_server,omitempty"` + LastStatsPoll *time.Time `json:"last_stats_poll,omitempty"` + OnlineWindowSec int `json:"online_window_seconds"` } // Status returns a snapshot of the current xray process state. func (m *XrayManager) Status() XrayStatusDTO { m.mu.Lock() - defer m.mu.Unlock() - s := XrayStatusDTO{} if m.cfg != nil { s.Enabled = m.cfg.Enabled @@ -225,9 +264,263 @@ func (m *XrayManager) Status() XrayStatusDTO { if m.lastErr != "" { s.Error = m.lastErr } + m.mu.Unlock() + + s.OnlineUsers = m.CountOnlineUsers() + s.OnlineWindowSec = int(m.onlineWindow().Seconds()) + m.statsMu.RLock() + if m.lastStatsErr != "" { + s.StatsError = m.lastStatsErr + } + if !m.lastStatsPoll.IsZero() { + t := m.lastStatsPoll + s.LastStatsPoll = &t + } + m.statsMu.RUnlock() + if check, err := m.CheckStatsAPIConfig(); err == nil { + s.StatsConfigured = check.Configured + s.StatsMissing = check.Missing + s.APIServer = check.APIServer + } else if err != nil { + s.StatsConfigured = false + s.StatsMissing = []string{err.Error()} + } return s } +func (m *XrayManager) pollInterval() time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + sec := 15 + if m.cfg != nil && m.cfg.StatsPollSeconds > 0 { + sec = m.cfg.StatsPollSeconds + } + if sec < 5 { + sec = 5 + } + return time.Duration(sec) * time.Second +} + +func (m *XrayManager) onlineWindow() time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + sec := 90 + if m.cfg != nil && m.cfg.OnlineWindowSeconds > 0 { + sec = m.cfg.OnlineWindowSeconds + } + if sec < 15 { + sec = 15 + } + return time.Duration(sec) * time.Second +} + +func (m *XrayManager) startStatsPoller() { + m.mu.Lock() + if m.pollStarted { + m.mu.Unlock() + return + } + m.pollStarted = true + m.mu.Unlock() + + go func() { + // First sample establishes a baseline. Active Xray users appear online + // after their traffic counters change on a later poll. + for { + m.refreshRuntimeStats() + time.Sleep(m.pollInterval()) + } + }() +} + +func (m *XrayManager) isRunningSnapshot() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.isRunning() +} + +func (m *XrayManager) apiCommandConfig() (binPath, apiServer string, ok bool) { + m.mu.Lock() + defer m.mu.Unlock() + if m.cfg == nil || m.cfg.BinPath == "" || m.cfg.ConfigFile == "" { + return "", "", false + } + binPath = m.cfg.BinPath + apiServer = strings.TrimSpace(m.cfg.APIServer) + if apiServer == "" { + apiServer = m.discoverAPIServerLocked() + } + return binPath, apiServer, apiServer != "" +} + +func (m *XrayManager) discoverAPIServerLocked() string { + if m.cfg == nil || m.cfg.ConfigFile == "" { + return "" + } + data, err := os.ReadFile(m.cfg.ConfigFile) + if err != nil { + return "" + } + var cfg struct { + Inbounds []struct { + Tag string `json:"tag"` + Listen string `json:"listen"` + Protocol string `json:"protocol"` + Port json.RawMessage `json:"port"` + } `json:"inbounds"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return "" + } + for _, ib := range cfg.Inbounds { + if ib.Tag != "api" || !strings.EqualFold(ib.Protocol, "dokodemo-door") { + continue + } + host := strings.TrimSpace(ib.Listen) + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + port := strings.Trim(string(ib.Port), `"`) + if port == "" || port == "null" { + continue + } + return host + ":" + port + } + return "" +} + +func (m *XrayManager) refreshRuntimeStats() { + if !m.isRunningSnapshot() { + return + } + traffic, err := m.queryUserTraffic() + now := time.Now() + m.statsMu.Lock() + defer m.statsMu.Unlock() + m.lastStatsPoll = now + if err != nil { + m.lastStatsErr = err.Error() + return + } + m.lastStatsErr = "" + if m.statsByEmail == nil { + m.statsByEmail = make(map[string]xrayRuntimeStat, len(traffic)) + } + for email, counters := range traffic { + prev := m.statsByEmail[email] + st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive} + if prev.Email != "" && (counters.Uplink != prev.Uplink || counters.Downlink != prev.Downlink) { + st.LastActive = now + } + m.statsByEmail[email] = st + } +} + +func (m *XrayManager) queryUserTraffic() (map[string]xrayTrafficCounters, error) { + binPath, apiServer, ok := m.apiCommandConfig() + if !ok { + return nil, fmt.Errorf("Xray API stats not configured; add an api inbound or set xray.api_server") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + attempts := [][]string{ + {"api", "statsquery", "--server=" + apiServer, "-pattern", "user>>>", "-reset=false"}, + {"api", "statsquery", "--server", apiServer, "-pattern", "user>>>"}, + } + var lastErr error + for _, args := range attempts { + cmd := exec.CommandContext(ctx, binPath, args...) + out, err := cmd.CombinedOutput() + if err == nil { + return parseXrayStatsOutput(out), nil + } + lastErr = fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) + } + return nil, fmt.Errorf("Xray stats query failed: %v", lastErr) +} + +func parseXrayStatsOutput(out []byte) map[string]xrayTrafficCounters { + result := map[string]xrayTrafficCounters{} + var js struct { + Stat []struct { + Name string `json:"name"` + Value int64 `json:"value"` + } `json:"stat"` + Stats []struct { + Name string `json:"name"` + Value int64 `json:"value"` + } `json:"stats"` + } + if json.Unmarshal(out, &js) == nil { + for _, st := range js.Stat { + addXrayCounter(result, st.Name, st.Value) + } + for _, st := range js.Stats { + addXrayCounter(result, st.Name, st.Value) + } + } + if len(result) > 0 { + return result + } + + re := regexp.MustCompile(`(?s)name:\s*"([^"]+)"\s+value:\s*([0-9]+)`) + for _, m := range re.FindAllSubmatch(out, -1) { + v, _ := strconv.ParseInt(string(m[2]), 10, 64) + addXrayCounter(result, string(m[1]), v) + } + return result +} + +func addXrayCounter(result map[string]xrayTrafficCounters, name string, value int64) { + parts := strings.Split(name, ">>>") + if len(parts) < 5 || parts[0] != "user" || parts[2] != "traffic" { + return + } + email := parts[1] + direction := parts[4] + if email == "" { + return + } + c := result[email] + switch direction { + case "uplink": + c.Uplink = value + case "downlink": + c.Downlink = value + default: + return + } + result[email] = c +} + +func (m *XrayManager) RuntimeStatsForEmail(email string) (xrayRuntimeStat, bool) { + email = strings.TrimSpace(email) + if email == "" { + return xrayRuntimeStat{}, false + } + m.statsMu.RLock() + st, ok := m.statsByEmail[email] + m.statsMu.RUnlock() + if !ok || st.Email == "" { + return xrayRuntimeStat{}, false + } + return st, true +} + +func (m *XrayManager) CountOnlineUsers() int { + window := m.onlineWindow() + now := time.Now() + m.statsMu.RLock() + defer m.statsMu.RUnlock() + n := 0 + for _, st := range m.statsByEmail { + if !st.LastActive.IsZero() && now.Sub(st.LastActive) <= window { + n++ + } + } + return n +} + // GetConfig reads the current xray JSON config file. func (m *XrayManager) GetConfig() ([]byte, error) { m.mu.Lock() @@ -245,10 +538,362 @@ func (m *XrayManager) SetConfig(data []byte) error { if m.cfg == nil || m.cfg.ConfigFile == "" { return fmt.Errorf("xray config file not configured") } - if !json.Valid(data) { - return fmt.Errorf("invalid JSON") + patched, changed, err := patchXrayStatsAPIBytes(data) + if err != nil { + return err } - return os.WriteFile(m.cfg.ConfigFile, data, 0o600) + if changed { + log.Printf("xray: added/repaired Stats API support while saving config") + } + return os.WriteFile(m.cfg.ConfigFile, patched, 0o600) +} + +type xrayStatsConfigCheck struct { + Configured bool + Missing []string + APIServer string +} + +func (m *XrayManager) CheckStatsAPIConfig() (xrayStatsConfigCheck, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.checkStatsAPIConfigLocked() +} + +func (m *XrayManager) checkStatsAPIConfigLocked() (xrayStatsConfigCheck, error) { + if m.cfg == nil || m.cfg.ConfigFile == "" { + return xrayStatsConfigCheck{}, fmt.Errorf("xray config file not configured") + } + data, err := os.ReadFile(m.cfg.ConfigFile) + if err != nil { + return xrayStatsConfigCheck{}, err + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return xrayStatsConfigCheck{}, fmt.Errorf("parse xray config: %w", err) + } + if raw == nil { + return xrayStatsConfigCheck{}, fmt.Errorf("xray config must be a JSON object") + } + check := checkXrayStatsAPIConfig(raw) + if check.APIServer == "" { + check.APIServer = m.discoverAPIServerFromRaw(raw) + } + return check, nil +} + +func (m *XrayManager) EnsureStatsAPIConfig() (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.ensureStatsAPIConfigLocked() +} + +func (m *XrayManager) ensureStatsAPIConfigLocked() (bool, error) { + if m.cfg == nil || m.cfg.ConfigFile == "" { + return false, fmt.Errorf("xray config file not configured") + } + data, err := os.ReadFile(m.cfg.ConfigFile) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + patched, changed, err := patchXrayStatsAPIBytes(data) + if err != nil { + return false, err + } + if !changed { + return false, nil + } + return true, os.WriteFile(m.cfg.ConfigFile, patched, 0o600) +} + +func patchXrayStatsAPIBytes(data []byte) ([]byte, bool, error) { + if !json.Valid(data) { + return nil, false, fmt.Errorf("invalid JSON") + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil, false, fmt.Errorf("parse xray config: %w", err) + } + if raw == nil { + return nil, false, fmt.Errorf("xray config must be a JSON object") + } + changed, _ := ensureXrayStatsAPIConfig(raw) + if !changed { + return data, false, nil + } + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return nil, false, err + } + return out, true, nil +} + +func ensureXrayStatsAPIConfig(raw map[string]interface{}) (bool, xrayStatsConfigCheck) { + changed := false + + api := asObject(raw["api"]) + if api == nil { + api = map[string]interface{}{} + raw["api"] = api + changed = true + } + if tag, _ := api["tag"].(string); tag != "api" { + api["tag"] = "api" + changed = true + } + services, ok := api["services"].([]interface{}) + if !ok { + services = []interface{}{} + } + for _, svc := range []string{"HandlerService", "LoggerService", "StatsService"} { + if !sliceHasString(services, svc) { + services = append(services, svc) + changed = true + } + } + api["services"] = services + + if asObject(raw["stats"]) == nil { + raw["stats"] = map[string]interface{}{} + changed = true + } + + policy := asObject(raw["policy"]) + if policy == nil { + policy = map[string]interface{}{} + raw["policy"] = policy + changed = true + } + levels := asObject(policy["levels"]) + if levels == nil { + levels = map[string]interface{}{} + policy["levels"] = levels + changed = true + } + level0 := asObject(levels["0"]) + if level0 == nil { + level0 = map[string]interface{}{} + levels["0"] = level0 + changed = true + } + for _, key := range []string{"statsUserUplink", "statsUserDownlink"} { + if v, _ := level0[key].(bool); !v { + level0[key] = true + changed = true + } + } + system := asObject(policy["system"]) + if system == nil { + system = map[string]interface{}{} + policy["system"] = system + changed = true + } + for _, key := range []string{"statsInboundUplink", "statsInboundDownlink"} { + if v, _ := system[key].(bool); !v { + system[key] = true + changed = true + } + } + + inbounds, _ := raw["inbounds"].([]interface{}) + apiInbound := findObjectByTag(inbounds, "api") + if apiInbound == nil { + apiInbound = map[string]interface{}{ + "tag": "api", + "listen": "127.0.0.1", + "port": float64(10085), + "protocol": "dokodemo-door", + "settings": map[string]interface{}{"address": "127.0.0.1"}, + } + inbounds = append([]interface{}{apiInbound}, inbounds...) + raw["inbounds"] = inbounds + changed = true + } else { + if proto, _ := apiInbound["protocol"].(string); !strings.EqualFold(proto, "dokodemo-door") { + apiInbound["protocol"] = "dokodemo-door" + changed = true + } + if strings.TrimSpace(fmt.Sprint(apiInbound["listen"])) == "" { + apiInbound["listen"] = "127.0.0.1" + changed = true + } + if _, ok := apiInbound["port"]; !ok || strings.TrimSpace(fmt.Sprint(apiInbound["port"])) == "" || strings.TrimSpace(fmt.Sprint(apiInbound["port"])) == "" { + apiInbound["port"] = float64(10085) + changed = true + } + settings := asObject(apiInbound["settings"]) + if settings == nil { + settings = map[string]interface{}{} + apiInbound["settings"] = settings + changed = true + } + if strings.TrimSpace(fmt.Sprint(settings["address"])) == "" || strings.TrimSpace(fmt.Sprint(settings["address"])) == "" { + settings["address"] = "127.0.0.1" + changed = true + } + } + + outbounds, _ := raw["outbounds"].([]interface{}) + if findObjectByTag(outbounds, "api") == nil { + outbounds = append(outbounds, map[string]interface{}{"tag": "api", "protocol": "freedom", "settings": map[string]interface{}{}}) + raw["outbounds"] = outbounds + changed = true + } + + routing := asObject(raw["routing"]) + if routing == nil { + routing = map[string]interface{}{} + raw["routing"] = routing + changed = true + } + rules, _ := routing["rules"].([]interface{}) + if !hasAPIRoutingRule(rules) { + rules = append([]interface{}{map[string]interface{}{"type": "field", "inboundTag": []interface{}{"api"}, "outboundTag": "api"}}, rules...) + routing["rules"] = rules + changed = true + } + + return changed, checkXrayStatsAPIConfig(raw) +} + +func checkXrayStatsAPIConfig(raw map[string]interface{}) xrayStatsConfigCheck { + missing := []string{} + api := asObject(raw["api"]) + if api == nil { + missing = append(missing, "api.services") + } else { + services, _ := api["services"].([]interface{}) + if !sliceHasString(services, "StatsService") { + missing = append(missing, "api.services StatsService") + } + } + if asObject(raw["stats"]) == nil { + missing = append(missing, "stats") + } + policy := asObject(raw["policy"]) + levels := asObject(nil) + level0 := asObject(nil) + if policy != nil { + levels = asObject(policy["levels"]) + if levels != nil { + level0 = asObject(levels["0"]) + } + } + if level0 == nil { + missing = append(missing, "policy.levels.0") + } else { + if v, _ := level0["statsUserUplink"].(bool); !v { + missing = append(missing, "policy.levels.0.statsUserUplink") + } + if v, _ := level0["statsUserDownlink"].(bool); !v { + missing = append(missing, "policy.levels.0.statsUserDownlink") + } + } + + inbounds, _ := raw["inbounds"].([]interface{}) + apiInbound := findObjectByTag(inbounds, "api") + if apiInbound == nil { + missing = append(missing, "api inbound") + } + outbounds, _ := raw["outbounds"].([]interface{}) + if findObjectByTag(outbounds, "api") == nil { + missing = append(missing, "api outbound") + } + routing := asObject(raw["routing"]) + if routing == nil { + missing = append(missing, "routing api rule") + } else { + rules, _ := routing["rules"].([]interface{}) + if !hasAPIRoutingRule(rules) { + missing = append(missing, "routing api rule") + } + } + return xrayStatsConfigCheck{Configured: len(missing) == 0, Missing: missing, APIServer: discoverAPIServerFromRaw(raw)} +} + +func (m *XrayManager) discoverAPIServerFromRaw(raw map[string]interface{}) string { + return discoverAPIServerFromRaw(raw) +} + +func discoverAPIServerFromRaw(raw map[string]interface{}) string { + inbounds, _ := raw["inbounds"].([]interface{}) + for _, item := range inbounds { + ib, ok := item.(map[string]interface{}) + if !ok { + continue + } + tag, _ := ib["tag"].(string) + proto, _ := ib["protocol"].(string) + if tag != "api" || !strings.EqualFold(proto, "dokodemo-door") { + continue + } + host, _ := ib["listen"].(string) + host = strings.TrimSpace(host) + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + port := strings.TrimSpace(fmt.Sprint(ib["port"])) + port = strings.Trim(port, `"`) + if port == "" || port == "" || port == "null" { + continue + } + return host + ":" + port + } + return "" +} + +func asObject(v interface{}) map[string]interface{} { + m, _ := v.(map[string]interface{}) + return m +} + +func findObjectByTag(items []interface{}, tag string) map[string]interface{} { + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + if t, _ := m["tag"].(string); t == tag { + return m + } + } + return nil +} + +func sliceHasString(items []interface{}, want string) bool { + for _, item := range items { + if s, ok := item.(string); ok && strings.EqualFold(s, want) { + return true + } + } + return false +} + +func hasAPIRoutingRule(rules []interface{}) bool { + for _, item := range rules { + rule, ok := item.(map[string]interface{}) + if !ok { + continue + } + outbound, _ := rule["outboundTag"].(string) + if outbound != "api" { + continue + } + tags, ok := rule["inboundTag"].([]interface{}) + if !ok { + if tag, _ := rule["inboundTag"].(string); tag == "api" { + return true + } + continue + } + if sliceHasString(tags, "api") { + return true + } + } + return false } // ---- Admin HTTP handlers ---- @@ -258,8 +903,23 @@ func handleXrayStatus(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusMethodNotAllowed) return } + status := xrayMgr.Status() + if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && statsStore != nil { + metas, err := statsStore.ListXrayClientsByOwner(r.Context(), sess.Username) + if err == nil { + status.OnlineUsers = 0 + window := xrayMgr.onlineWindow() + now := time.Now() + for _, m := range metas { + st, ok := xrayMgr.RuntimeStatsForEmail(m.Email) + if ok && !st.LastActive.IsZero() && now.Sub(st.LastActive) <= window { + status.OnlineUsers++ + } + } + } + } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(xrayMgr.Status()) + _ = json.NewEncoder(w).Encode(status) } func handleXrayStart(w http.ResponseWriter, r *http.Request) { @@ -326,6 +986,36 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) { } } +func handleXrayRepairStats(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + wasRunning := xrayMgr.isRunningSnapshot() + changed, err := xrayMgr.EnsureStatsAPIConfig() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + restarted := false + if wasRunning { + if err := xrayMgr.Restart(); err != nil { + http.Error(w, "config repaired but restart failed: "+err.Error(), http.StatusInternalServerError) + return + } + restarted = true + } + check, _ := xrayMgr.CheckStatsAPIConfig() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "changed": changed, + "restarted": restarted, + "stats_configured": check.Configured, + "stats_missing": check.Missing, + "api_server": check.APIServer, + }) +} + func handleXrayLogs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) @@ -346,11 +1036,19 @@ type XrayClientInfo struct { UUID string `json:"id"` Email string `json:"email"` Level int `json:"level,omitempty"` + // Runtime counters from the Xray stats API. Online means this user's + // traffic counters changed inside the configured online window. + Online bool `json:"online"` + LastActive *time.Time `json:"last_active,omitempty"` + UplinkBytes int64 `json:"uplink_bytes,omitempty"` + DownlinkBytes int64 `json:"downlink_bytes,omitempty"` + TotalBytes int64 `json:"total_bytes,omitempty"` // Metadata from PostgreSQL (enriched by handleXrayInbounds) Name string `json:"name,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` ExpirationDays int `json:"expiration_days"` MaxConns int `json:"max_conns"` + OwnerUsername string `json:"owner_username,omitempty"` Expired bool `json:"expired,omitempty"` } @@ -434,6 +1132,9 @@ func (m *XrayManager) modifyRawConfig(fn func(cfg map[string]interface{}) error) if err := json.Unmarshal(data, &raw); err != nil { return fmt.Errorf("parse xray config: %w", err) } + if raw == nil { + return fmt.Errorf("xray config must be a JSON object") + } if err := fn(raw); err != nil { return err } @@ -449,6 +1150,7 @@ func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error { m.mu.Lock() defer m.mu.Unlock() return m.modifyRawConfig(func(raw map[string]interface{}) error { + _, _ = ensureXrayStatsAPIConfig(raw) inbounds, _ := raw["inbounds"].([]interface{}) for _, ib := range inbounds { ibMap, ok := ib.(map[string]interface{}) @@ -537,26 +1239,51 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - // Enrich clients with metadata from PostgreSQL when available. + + sess := sessionFromCtx(r.Context()) + isReseller := sess != nil && sess.Role == RoleReseller + + // Enrich clients with metadata from PostgreSQL when available. Resellers only + // see their own Xray clients, but they still see all available inbounds so + // they know where they can create new users. if statsStore != nil { metas, err := statsStore.ListAllXrayClients(r.Context()) - if err == nil { + if err != nil { + if isReseller { + for i := range inbounds { + inbounds[i].Clients = []XrayClientInfo{} + } + } else { + for i := range inbounds { + for j := range inbounds[i].Clients { + applyXrayRuntimeStats(&inbounds[i].Clients[j]) + } + } + } + } else { metaMap := make(map[string]*XrayClientMeta, len(metas)) for _, m := range metas { metaMap[m.UUID] = m } now := time.Now() for i := range inbounds { + filtered := make([]XrayClientInfo, 0, len(inbounds[i].Clients)) for j := range inbounds[i].Clients { - c := &inbounds[i].Clients[j] + c := inbounds[i].Clients[j] + applyXrayRuntimeStats(&c) m, ok := metaMap[c.UUID] + if isReseller && (!ok || m.OwnerUsername != sess.Username) { + continue + } if !ok { c.ExpirationDays = -1 + filtered = append(filtered, c) continue } c.Name = m.Name c.ExpiresAt = m.ExpiresAt c.MaxConns = m.MaxConns + c.OwnerUsername = m.OwnerUsername if m.ExpiresAt == nil { c.ExpirationDays = -1 } else if m.ExpiresAt.Before(now) { @@ -565,7 +1292,19 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) { } else { c.ExpirationDays = int(m.ExpiresAt.Sub(now).Hours() / 24) } + filtered = append(filtered, c) } + inbounds[i].Clients = filtered + } + } + } else if isReseller { + for i := range inbounds { + inbounds[i].Clients = []XrayClientInfo{} + } + } else { + for i := range inbounds { + for j := range inbounds[i].Clients { + applyXrayRuntimeStats(&inbounds[i].Clients[j]) } } } @@ -573,6 +1312,26 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(inbounds) } +func applyXrayRuntimeStats(c *XrayClientInfo) { + if c == nil { + return + } + st, ok := xrayMgr.RuntimeStatsForEmail(c.Email) + if !ok { + return + } + window := xrayMgr.onlineWindow() + now := time.Now() + c.UplinkBytes = st.Uplink + c.DownlinkBytes = st.Downlink + c.TotalBytes = st.Uplink + st.Downlink + if !st.LastActive.IsZero() { + t := st.LastActive + c.LastActive = &t + c.Online = now.Sub(st.LastActive) <= window + } +} + func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) @@ -594,17 +1353,45 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) { http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest) return } + req.Email = strings.TrimSpace(req.Email) + if req.Email == "" { + req.Email = strings.TrimSpace(req.Name) + } + if req.Email == "" { + req.Email = req.UUID + } + + sess := sessionFromCtx(r.Context()) + ownerUsername := "" + if sess != nil && sess.Role == RoleReseller { + ownerUsername = sess.Username + if statsStore == nil { + http.Error(w, "storage not available", http.StatusInternalServerError) + return + } + owner, ok := adminUsers.get(sess.Username) + if !ok || !owner.IsActive || (owner.ExpiresAt != nil && time.Now().After(*owner.ExpiresAt)) { + http.Error(w, "reseller account suspended or expired", http.StatusForbidden) + return + } + if owner.MaxUsers > 0 && countOwnedQuota(r.Context(), statsStore, sess.Username) >= owner.MaxUsers { + http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) + return + } + } + if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if statsStore != nil { meta := XrayClientMeta{ - UUID: req.UUID, - Name: req.Name, - Email: req.Email, - InboundTag: req.InboundTag, - MaxConns: req.MaxConnections, + UUID: req.UUID, + Name: req.Name, + Email: req.Email, + InboundTag: req.InboundTag, + OwnerUsername: ownerUsername, + MaxConns: req.MaxConnections, } if req.ExpiresAt != "" { var t time.Time @@ -653,11 +1440,25 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) { http.Error(w, "storage not available", http.StatusInternalServerError) return } + + existing, err := statsStore.GetXrayClientMeta(r.Context(), req.UUID) + if err != nil { + http.Error(w, "client metadata not found", http.StatusNotFound) + return + } + sess := sessionFromCtx(r.Context()) + if sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + meta := XrayClientMeta{ - UUID: req.UUID, - Name: req.Name, - Email: req.Email, - MaxConns: req.MaxConnections, + UUID: req.UUID, + Name: req.Name, + Email: req.Email, + InboundTag: existing.InboundTag, + OwnerUsername: existing.OwnerUsername, + MaxConns: req.MaxConnections, } if req.ExpiresAt != "" { for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} { @@ -685,6 +1486,23 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) { http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest) return } + + sess := sessionFromCtx(r.Context()) + if sess != nil && sess.Role == RoleReseller { + if statsStore == nil { + http.Error(w, "storage not available", http.StatusInternalServerError) + return + } + meta, err := statsStore.GetXrayClientMeta(r.Context(), uuid) + if err != nil || meta.OwnerUsername != sess.Username { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if meta.InboundTag != "" { + inboundTag = meta.InboundTag + } + } + if err := xrayMgr.RemoveXrayClient(inboundTag, uuid); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return