Panel Update

This commit is contained in:
2026-05-10 17:52:36 -03:00
parent 51aedfd3c7
commit 03c43debf4
8 changed files with 3408 additions and 1819 deletions

300
admin/assets/app.css Normal file
View File

@@ -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;}

1847
admin/assets/app.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
// ─── State ─────────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────────
let sessionToken = localStorage.getItem("SESSION_TOKEN") || ""; let sessionToken = localStorage.getItem("SESSION_TOKEN") || "";
let currentRole = ""; let currentRole = "";
@@ -18,6 +19,26 @@ const mainApp = document.getElementById("mainApp");
const meUsername = document.getElementById("meUsername"); const meUsername = document.getElementById("meUsername");
const roleChip = document.getElementById("roleChip"); const roleChip = document.getElementById("roleChip");
const logoutBtn = document.getElementById("logoutBtn"); 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 // Users
const usersBody = document.getElementById("usersBody"); const usersBody = document.getElementById("usersBody");
@@ -153,15 +174,48 @@ function genUUID() {
(c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16)); (c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16));
} }
// ─── Tab switching ──────────────────────────────────────────────────────────── // ─── Navigation / shell ──────────────────────────────────────────────────────
document.querySelectorAll(".tab-btn").forEach(btn => { const tabTitles = {
btn.addEventListener("click", () => { 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-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active")); document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
document.getElementById("tab-" + btn.dataset.tab).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 ─────────────────────────────────────────────────────────── // ─── Login / Logout ───────────────────────────────────────────────────────────
loginBtn.addEventListener("click", doLogin); loginBtn.addEventListener("click", doLogin);
@@ -215,31 +269,34 @@ function clearTimers() {
function initAfterLogin() { function initAfterLogin() {
meUsername.textContent = currentUser; meUsername.textContent = currentUser;
if (sidebarUsername) sidebarUsername.textContent = currentUser;
if (sidebarRole) sidebarRole.textContent = currentRole === "superadmin" ? "Super Admin" : "Revendedor";
roleChip.innerHTML = currentRole === "superadmin" roleChip.innerHTML = currentRole === "superadmin"
? `<span class="chip green">superadmin</span>` ? `<span class="chip green">superadmin</span>`
: `<span class="chip warn">reseller</span>`; : `<span class="chip warn">reseller</span>`;
// Show/hide superadmin-only elements
document.querySelectorAll(".superadmin-only").forEach(el => { document.querySelectorAll(".superadmin-only").forEach(el => {
el.classList.toggle("hidden", currentRole !== "superadmin"); el.classList.toggle("hidden", currentRole !== "superadmin");
}); });
document.querySelectorAll(".xray-admin-only").forEach(el => {
el.classList.toggle("hidden", currentRole !== "superadmin");
});
// Reset to SSH tab resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller");
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller");
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
document.querySelector("[data-tab='ssh']").classList.add("active"); selectTab("dashboard");
document.getElementById("tab-ssh").classList.add("active");
if (currentRole === "superadmin") { if (currentRole === "superadmin") {
loadStats(); loadStats();
statsTimer = setInterval(loadStats, 2000); statsTimer = setInterval(loadStats, 2000);
xrayTimer = setInterval(loadXrayStatus, 5000);
} else { } else {
resellerInfoCard.classList.remove("hidden");
loadMe(); loadMe();
} }
xrayTimer = setInterval(loadXrayStatus, 7000);
loadUsers(); loadUsers();
loadInbounds();
usersTimer = setInterval(() => loadUsersSilent(), 3000); usersTimer = setInterval(() => loadUsersSilent(), 3000);
} }
@@ -248,13 +305,56 @@ async function loadMe() {
try { try {
const res = await api("/api/auth/me"); const res = await api("/api/auth/me");
const d = await res.json(); 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"; rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : "No limit";
rStatus.textContent = d.is_active ? "Active" : "Suspended"; rStatus.textContent = d.is_active ? "Active" : "Suspended";
rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)"; rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0);
} catch {} } 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 ──────────────────────────────────────────────────────────────── // ─── SSH Users ────────────────────────────────────────────────────────────────
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers); document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
newUserBtn.addEventListener("click", () => { newUserBtn.addEventListener("click", () => {
@@ -304,6 +404,7 @@ async function loadUsersSilent() {
} }
function renderUsers(users) { function renderUsers(users) {
updateDashboardFromUsers(users);
const isSA = currentRole === "superadmin"; const isSA = currentRole === "superadmin";
userCountChip.textContent = users.length; userCountChip.textContent = users.length;
if (isSA) ownerColHead.classList.remove("hidden"); if (isSA) ownerColHead.classList.remove("hidden");
@@ -418,7 +519,9 @@ document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs); document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
document.querySelector("[data-tab='xray']")?.addEventListener("click", () => { document.querySelector("[data-tab='xray']")?.addEventListener("click", () => {
loadXrayStatus(); loadInbounds(); loadWizardFromConfig(); loadXrayStatus();
loadInbounds();
if (currentRole === "superadmin") loadWizardFromConfig();
}); });
async function loadXrayStatus() { async function loadXrayStatus() {
@@ -432,6 +535,9 @@ async function loadXrayStatus() {
xRunning.style.color = run ? "var(--success)" : "var(--danger)"; xRunning.style.color = run ? "var(--success)" : "var(--danger)";
xPID.textContent = s.pid || "--"; xPID.textContent = s.pid || "--";
xUptime.textContent = s.uptime || "--"; 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; if (s.error) xStatus.textContent = "Error: " + s.error;
} catch (e) { if (e.message==="auth") doAuthError(); } } catch (e) { if (e.message==="auth") doAuthError(); }
} }
@@ -463,6 +569,7 @@ async function loadInbounds() {
} }
function renderInbounds(inbounds) { function renderInbounds(inbounds) {
updateDashboardXray(inbounds);
if (!inbounds.length) { if (!inbounds.length) {
inboundsContainer.innerHTML = '<div class="hint" style="padding:8px 0;">No VLESS/VMess/Trojan inbounds found.</div>'; inboundsContainer.innerHTML = '<div class="hint" style="padding:8px 0;">No VLESS/VMess/Trojan inbounds found.</div>';
return; return;
@@ -694,7 +801,7 @@ function renderResellers(list) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` tr.innerHTML = `
<td>${r.username}</td> <td>${r.username}</td>
<td>${r.used_users} / ${r.max_users || "∞"}</td> <td>${r.used_users} / ${r.max_users || "∞"}<div class="hint">SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div></td>
<td>${r.expires_at ? fmtDate(r.expires_at) : "—"}</td> <td>${r.expires_at ? fmtDate(r.expires_at) : "—"}</td>
<td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td> <td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td>
<td></td>`; <td></td>`;

20
auth.go
View File

@@ -443,6 +443,7 @@ func startResellerExpiryChecker(store *Store) {
u.IsActive = false u.IsActive = false
adminUsers.set(u) adminUsers.set(u)
disconnectOwnerUsers(u.Username) disconnectOwnerUsers(u.Username)
removeOwnerXrayClients(ctx, store, u.Username)
} }
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil) // 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 s.Role == RoleReseller {
if u, ok := adminUsers.get(s.Username); ok { if u, ok := adminUsers.get(s.Username); ok {
resp["max_users"] = u.MaxUsers 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["expires_at"] = u.ExpiresAt
resp["is_active"] = u.IsActive resp["is_active"] = u.IsActive
} }
@@ -554,6 +557,8 @@ type ResellerDTO struct {
Role string `json:"role"` Role string `json:"role"`
MaxUsers int `json:"max_users"` MaxUsers int `json:"max_users"`
UsedUsers int `json:"used_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"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -577,7 +582,9 @@ func handleListResellers(store *Store) http.HandlerFunc {
Username: u.Username, Username: u.Username,
Role: u.Role, Role: u.Role,
MaxUsers: u.MaxUsers, 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, ExpiresAt: u.ExpiresAt,
IsActive: u.IsActive, IsActive: u.IsActive,
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
@@ -652,8 +659,12 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
} }
adminUsers.set(u) adminUsers.set(u)
// If reseller was reactivated, users can reconnect automatically. if u.Role == RoleReseller {
// Reconnect of existing SSH connections happens via the expiry checker. if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
disconnectOwnerUsers(u.Username)
removeOwnerXrayClients(ctx, store, u.Username)
}
}
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
} }
@@ -676,6 +687,7 @@ func handleDeleteReseller(store *Store) http.HandlerFunc {
return return
} }
disconnectOwnerUsers(username) disconnectOwnerUsers(username)
removeOwnerXrayClients(ctx, store, username)
adminUsers.delete(username) adminUsers.delete(username)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }

16
main.go
View File

@@ -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/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store)))) mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
// Superadmin-only: Xray-core management // Xray-core management. Service/config/log actions are superadmin-only;
mux.Handle("/api/xray/status", saSession(http.HandlerFunc(handleXrayStatus))) // 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/start", saSession(http.HandlerFunc(handleXrayStart)))
mux.Handle("/api/xray/stop", saSession(http.HandlerFunc(handleXrayStop))) mux.Handle("/api/xray/stop", saSession(http.HandlerFunc(handleXrayStop)))
mux.Handle("/api/xray/restart", saSession(http.HandlerFunc(handleXrayRestart))) 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/config", saSession(http.HandlerFunc(handleXrayConfig)))
mux.Handle("/api/xray/logs", saSession(http.HandlerFunc(handleXrayLogs))) mux.Handle("/api/xray/logs", saSession(http.HandlerFunc(handleXrayLogs)))
mux.Handle("/api/xray/inbounds", saSession(http.HandlerFunc(handleXrayInbounds))) mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
mux.Handle("/api/xray/clients/add", saSession(http.HandlerFunc(handleXrayClientAdd))) mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
mux.Handle("/api/xray/clients/update", saSession(http.HandlerFunc(handleXrayClientUpdate))) mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
mux.Handle("/api/xray/clients/remove", saSession(http.HandlerFunc(handleXrayClientRemove))) mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
// Superadmin-only: TLS certificate generation // Superadmin-only: TLS certificate generation
mux.Handle("/api/tls/generate-selfsigned", saSession(http.HandlerFunc(handleTLSGenerateSelfSigned))) mux.Handle("/api/tls/generate-selfsigned", saSession(http.HandlerFunc(handleTLSGenerateSelfSigned)))
@@ -1508,7 +1510,7 @@ func handleCreateUser(store *Store) http.HandlerFunc {
).Scan(&existsInDB) ).Scan(&existsInDB)
if !existsInDB { if !existsInDB {
owner, ok := adminUsers.get(sess.Username) 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) http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return return
} }

View File

@@ -8,29 +8,39 @@ import (
) )
// XrayClientMeta holds metadata stored in PostgreSQL for an Xray client. // 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 { type XrayClientMeta struct {
UUID string UUID string
Name string Name string
Email string Email string
InboundTag string InboundTag string
OwnerUsername string
ExpiresAt *time.Time ExpiresAt *time.Time
MaxConns int MaxConns int
CreatedAt time.Time CreatedAt time.Time
} }
func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error { func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, ` stmts := []string{
CREATE TABLE IF NOT EXISTS xray_clients ( `CREATE TABLE IF NOT EXISTS xray_clients (
uuid TEXT PRIMARY KEY, uuid TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
inbound_tag TEXT NOT NULL DEFAULT '', inbound_tag TEXT NOT NULL DEFAULT '',
owner_username TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ, expires_at TIMESTAMPTZ,
max_conns INT NOT NULL DEFAULT 0, max_conns INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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 err
}
}
return nil
} }
func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) error { 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 expiresAt = *m.ExpiresAt
} }
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO xray_clients (uuid, name, email, inbound_tag, expires_at, max_conns) INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (uuid) DO UPDATE SET ON CONFLICT (uuid) DO UPDATE SET
name = EXCLUDED.name, name = EXCLUDED.name,
email = EXCLUDED.email, email = EXCLUDED.email,
inbound_tag = EXCLUDED.inbound_tag, 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, expires_at = EXCLUDED.expires_at,
max_conns = EXCLUDED.max_conns`, max_conns = EXCLUDED.max_conns`,
m.UUID, m.Name, m.Email, m.InboundTag, expiresAt, m.MaxConns) m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns)
return err return err
} }
@@ -55,9 +66,9 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient
m := &XrayClientMeta{} m := &XrayClientMeta{}
var expiresAt sql.NullTime var expiresAt sql.NullTime
err := s.db.QueryRowContext(ctx, ` 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). 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 { if err != nil {
return nil, err 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) { func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, ` 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`) FROM xray_clients ORDER BY created_at DESC`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() 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 var out []*XrayClientMeta
for rows.Next() { for rows.Next() {
m := &XrayClientMeta{} m := &XrayClientMeta{}
var expiresAt sql.NullTime 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 return nil, err
} }
if expiresAt.Valid { if expiresAt.Valid {
@@ -95,27 +138,49 @@ func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, erro
return out, rows.Err() return out, rows.Err()
} }
func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) { func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int {
rows, err := s.db.QueryContext(ctx, ` if store == nil || ownerUsername == "" {
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at return 0
FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`) }
n, err := store.CountXrayClientsByOwner(ctx, ownerUsername)
if err != nil { if err != nil {
return nil, err log.Printf("count xray clients for %s: %v", ownerUsername, err)
return 0
} }
defer rows.Close() return n
var out []*XrayClientMeta }
for rows.Next() {
m := &XrayClientMeta{} func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int {
var expiresAt sql.NullTime return countOwnedUsers(ownerUsername) + countOwnedXrayClients(ctx, store, ownerUsername)
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil { }
return nil, err
func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
if store == nil || ownerUsername == "" {
return
} }
if expiresAt.Valid { clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
m.ExpiresAt = &expiresAt.Time 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)
} }
out = append(out, m)
} }
return out, rows.Err()
} }
// startXrayClientExpiryChecker runs a background goroutine that removes expired // startXrayClientExpiryChecker runs a background goroutine that removes expired

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -8,6 +9,8 @@ import (
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -19,6 +22,13 @@ type XrayConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
BinPath string `json:"bin_path"` // e.g. /opt/sshpanel/xray BinPath string `json:"bin_path"` // e.g. /opt/sshpanel/xray
ConfigFile string `json:"config_file"` // e.g. /opt/sshpanel/xray_config.json 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. // xrayLogRing is a fixed-capacity circular buffer for captured log lines.
@@ -81,6 +91,24 @@ type XrayManager struct {
cfg *XrayConfig cfg *XrayConfig
startTime time.Time startTime time.Time
lastErr string 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{} var xrayMgr = &XrayManager{}
@@ -93,6 +121,7 @@ func initXrayManager(cfg *XrayConfig) {
xrayMgr.mu.Lock() xrayMgr.mu.Lock()
xrayMgr.cfg = cfg xrayMgr.cfg = cfg
xrayMgr.mu.Unlock() xrayMgr.mu.Unlock()
xrayMgr.startStatsPoller()
if cfg.Enabled { if cfg.Enabled {
if err := xrayMgr.Start(); err != nil { if err := xrayMgr.Start(); err != nil {
@@ -129,6 +158,11 @@ func (m *XrayManager) Start() error {
if _, err := os.Stat(m.cfg.BinPath); err != nil { if _, err := os.Stat(m.cfg.BinPath); err != nil {
return fmt.Errorf("xray binary not found at %s", m.cfg.BinPath) 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"} args := []string{"run"}
if m.cfg.ConfigFile != "" { if m.cfg.ConfigFile != "" {
@@ -206,13 +240,18 @@ type XrayStatusDTO struct {
PID int `json:"pid,omitempty"` PID int `json:"pid,omitempty"`
Uptime string `json:"uptime,omitempty"` Uptime string `json:"uptime,omitempty"`
Error string `json:"error,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. // Status returns a snapshot of the current xray process state.
func (m *XrayManager) Status() XrayStatusDTO { func (m *XrayManager) Status() XrayStatusDTO {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
s := XrayStatusDTO{} s := XrayStatusDTO{}
if m.cfg != nil { if m.cfg != nil {
s.Enabled = m.cfg.Enabled s.Enabled = m.cfg.Enabled
@@ -225,9 +264,263 @@ func (m *XrayManager) Status() XrayStatusDTO {
if m.lastErr != "" { if m.lastErr != "" {
s.Error = 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 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. // GetConfig reads the current xray JSON config file.
func (m *XrayManager) GetConfig() ([]byte, error) { func (m *XrayManager) GetConfig() ([]byte, error) {
m.mu.Lock() m.mu.Lock()
@@ -245,10 +538,362 @@ func (m *XrayManager) SetConfig(data []byte) error {
if m.cfg == nil || m.cfg.ConfigFile == "" { if m.cfg == nil || m.cfg.ConfigFile == "" {
return fmt.Errorf("xray config file not configured") return fmt.Errorf("xray config file not configured")
} }
if !json.Valid(data) { patched, changed, err := patchXrayStatsAPIBytes(data)
return fmt.Errorf("invalid JSON") 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"])) == "<nil>" {
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"])) == "<nil>" {
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 == "<nil>" || 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 ---- // ---- Admin HTTP handlers ----
@@ -258,8 +903,23 @@ func handleXrayStatus(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return 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") 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) { 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) { func handleXrayLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
@@ -346,11 +1036,19 @@ type XrayClientInfo struct {
UUID string `json:"id"` UUID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Level int `json:"level,omitempty"` 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) // Metadata from PostgreSQL (enriched by handleXrayInbounds)
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
ExpirationDays int `json:"expiration_days"` ExpirationDays int `json:"expiration_days"`
MaxConns int `json:"max_conns"` MaxConns int `json:"max_conns"`
OwnerUsername string `json:"owner_username,omitempty"`
Expired bool `json:"expired,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 { if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("parse xray config: %w", err) 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 { if err := fn(raw); err != nil {
return err return err
} }
@@ -449,6 +1150,7 @@ func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
return m.modifyRawConfig(func(raw map[string]interface{}) error { return m.modifyRawConfig(func(raw map[string]interface{}) error {
_, _ = ensureXrayStatsAPIConfig(raw)
inbounds, _ := raw["inbounds"].([]interface{}) inbounds, _ := raw["inbounds"].([]interface{})
for _, ib := range inbounds { for _, ib := range inbounds {
ibMap, ok := ib.(map[string]interface{}) 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) http.Error(w, err.Error(), http.StatusInternalServerError)
return 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 { if statsStore != nil {
metas, err := statsStore.ListAllXrayClients(r.Context()) 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)) metaMap := make(map[string]*XrayClientMeta, len(metas))
for _, m := range metas { for _, m := range metas {
metaMap[m.UUID] = m metaMap[m.UUID] = m
} }
now := time.Now() now := time.Now()
for i := range inbounds { for i := range inbounds {
filtered := make([]XrayClientInfo, 0, len(inbounds[i].Clients))
for j := range 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] m, ok := metaMap[c.UUID]
if isReseller && (!ok || m.OwnerUsername != sess.Username) {
continue
}
if !ok { if !ok {
c.ExpirationDays = -1 c.ExpirationDays = -1
filtered = append(filtered, c)
continue continue
} }
c.Name = m.Name c.Name = m.Name
c.ExpiresAt = m.ExpiresAt c.ExpiresAt = m.ExpiresAt
c.MaxConns = m.MaxConns c.MaxConns = m.MaxConns
c.OwnerUsername = m.OwnerUsername
if m.ExpiresAt == nil { if m.ExpiresAt == nil {
c.ExpirationDays = -1 c.ExpirationDays = -1
} else if m.ExpiresAt.Before(now) { } else if m.ExpiresAt.Before(now) {
@@ -565,7 +1292,19 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
} else { } else {
c.ExpirationDays = int(m.ExpiresAt.Sub(now).Hours() / 24) 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) _ = 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) { func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
@@ -594,6 +1353,33 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest) http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
return 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 { if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -604,6 +1390,7 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
Name: req.Name, Name: req.Name,
Email: req.Email, Email: req.Email,
InboundTag: req.InboundTag, InboundTag: req.InboundTag,
OwnerUsername: ownerUsername,
MaxConns: req.MaxConnections, MaxConns: req.MaxConnections,
} }
if req.ExpiresAt != "" { if req.ExpiresAt != "" {
@@ -653,10 +1440,24 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
http.Error(w, "storage not available", http.StatusInternalServerError) http.Error(w, "storage not available", http.StatusInternalServerError)
return 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{ meta := XrayClientMeta{
UUID: req.UUID, UUID: req.UUID,
Name: req.Name, Name: req.Name,
Email: req.Email, Email: req.Email,
InboundTag: existing.InboundTag,
OwnerUsername: existing.OwnerUsername,
MaxConns: req.MaxConnections, MaxConns: req.MaxConnections,
} }
if req.ExpiresAt != "" { if req.ExpiresAt != "" {
@@ -685,6 +1486,23 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest) http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
return 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 { if err := xrayMgr.RemoveXrayClient(inboundTag, uuid); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return