diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..594cf03 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/shell2.exe diff --git a/admin/assets/app.js b/admin/assets/app.js deleted file mode 100644 index 7ed774d..0000000 --- a/admin/assets/app.js +++ /dev/null @@ -1,3266 +0,0 @@ - -// ─── State ─────────────────────────────────────────────────────────────────── -let sessionToken = localStorage.getItem("SESSION_TOKEN") || ""; -let currentRole = ""; -let currentUser = ""; -let statsTimer = null, usersTimer = null, xrayTimer = null; -let formCollapsed = true; -let tlsForwardersState = []; -let managedTlsForwardersState = []; -let editingXrayClientId = null; -let wzInbounds = []; -let wzLoadedFullConfig = null; -let wzLoadedConfigText = ""; -let wzLoadedServerID = null; -let wzDirty = false; -let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null }; -let currentTab = "dashboard"; -let inboundsRefreshInFlight = false; -let lastInboundsStructure = ""; -let serversCache = []; -let selectedSSHServerID = localStorage.getItem("SSH_SERVER_ID") || "local"; -let selectedXrayServerID = localStorage.getItem("XRAY_SERVER_ID") || "local"; -let configuringServerID = ""; - - -// ─── Language / i18n ───────────────────────────────────────────────────────── -const SUPPORTED_LANGS = ["pt-BR", "en-US"]; -const LANG_STORAGE_KEY = "PANEL_LANG"; -const I18N_TEXT = { - "en-US": { - "Dashboard":"Dashboard","Overview":"Overview","Accounts":"Accounts","Administration":"Administration","Server":"Server","System":"System","Settings":"Settings","Traffic":"Traffic","Monitoring":"Monitoring", - "SSH / SlowDNS":"SSH / SlowDNS","Xray Users":"Xray Users","Resellers":"Resellers","Logs":"Logs","VnStat":"VnStat","VPN Control":"VPN Control","DragonCore":"DragonCore", - "SSH Panel":"SSH Panel","Sign in with your admin or reseller credentials.":"Sign in with your admin or reseller credentials.","Username":"Username","Password":"Password","Sign in":"Sign in","Logout":"Logout","Open menu":"Open menu","Toggle theme":"Toggle theme","Language":"Language", - "Total accounts":"Total accounts","active":"active","expired":"expired","available limit":"Available limit","Loading quota…":"Loading quota…","Active connections":"Active connections","SSH + Xray online now":"SSH + Xray online now","Ready for resellers":"Ready for resellers","Server monitoring in real time":"real-time monitoring","CPU":"CPU","RAM":"RAM","Network":"Network","Processor load":"Processor load","Memory used":"Memory used","Total":"Total","Total --":"Total --","RX -- · TX -- Mb/s":"RX -- · TX -- Mb/s", - "Quick actions":"Quick actions","simple":"simple","Create SSH":"Create SSH","Create Xray":"Create Xray","New reseller":"New reseller","Configure services":"Configure services","User, password, expiry and limit.":"User, password, expiry and limit.","UUID, label, expiry and connections.":"UUID, label, expiry and connections.","Plan, expiry and account limit.":"Plan, expiry and account limit.","Ports, DNSTT, UDPGW and TLS.":"Ports, DNSTT, UDPGW and TLS.","My quota":"My quota","Loading…":"Loading…","Loading...":"Loading...", - "My Account":"My Account","Users (used / max)":"Users (used / max)","Users (used/max)":"Users (used/max)","Expires":"Expires","Status":"Status","Users":"Users","User":"User","Auth":"Auth","Conn":"Conn","Max":"Max","Up":"Up","Dn":"Dn","Owner":"Owner","Actions":"Actions","Create / update user":"Create / update user","Create / edit user form":"Create / edit user form","Show form":"Show form","Hide form":"Hide form","TOTP Secret":"TOTP Secret","TOTP Period (s)":"TOTP Period (s)","TOTP Window":"TOTP Window","TOTP Digits":"TOTP Digits","Allow static password too":"Allow static password too","Max connections":"Max connections","Expires at":"Expires at","Max Upload (Mb/s)":"Max Upload (Mb/s)","Max Download (Mb/s)":"Max Download (Mb/s)","Save user":"Save user","Cancel":"Cancel","Gen":"Gen","Copy":"Copy","Edit":"Edit","Del":"Del","Reload":"Reload","Refresh":"Refresh","+ New":"+ New","+ Add":"+ Add","Add":"Add","Remove":"Remove", - "Running":"Running","Stopped":"Stopped","running":"running","stopped":"stopped","disabled":"disabled","Counters API":"Counters API","Repair counters":"Repair counters","Start":"Start","Stop":"Stop","Restart":"Restart","Inbounds & Clients":"Inbounds & Clients","Inbounds & clients":"Inbounds & clients","Xray Config":"Xray Config","Visual":"Visual","JSON":"JSON","Config editor":"Config editor","Load JSON":"Load JSON","Save & Restart":"Save & Restart","System Logs":"System Logs","last 200 lines":"last 200 lines","Xray clients":"Xray clients","Xray Core":"Xray Core","Enabled":"Enabled","Online":"Online","PID":"PID","Uptime":"Uptime","Counters API ready.":"Counters API ready.","Counters API ready at {server}.":"Counters API ready at {server}.","Online counters need Stats API repair.":"Online counters need Stats API repair.","Online counters: {error}":"Online counters: {error}","Needs repair":"Needs repair","OK":"OK", - "UUID":"UUID","Email":"Email","Email / label":"Email / label","Display Name":"Display Name","Expiry Date":"Expiry Date","Max Connections":"Max Connections","(0 = unlimited)":"(0 = unlimited)","auto-generate":"auto-generate","Name":"Name","Expiry":"Expiry","Online":"Online","Traffic":"Traffic","No clients.":"No clients.","No VLESS/VMess/Trojan inbounds found.":"No VLESS/VMess/Trojan inbounds found.","Add Client":"Add Client","+ Add Client":"+ Add Client","Copied client ID.":"Copied client ID.","UUID required.":"UUID required.","Client {id}… added. Restarting Xray…":"Client {id}… added. Restarting Xray…","Client removed. Restarting Xray…":"Client removed. Restarting Xray…","Remove client {id}… from {tag}?":"Remove client {id}… from {tag}?","New client data is available; editing was preserved.":"New client data is available; editing was preserved.","Config loaded.":"Config loaded.","Invalid JSON: {error}":"Invalid JSON: {error}","Saved. Restarting Xray…":"Saved. Restarting Xray…","Saved.":"Saved.","Saving…":"Saving…","Error: {error}":"Error: {error}","Error loading inbounds.":"Error loading inbounds.", - "Active":"Active","Suspended":"Suspended","Expired":"Expired","Unlimited":"Unlimited","No expiration":"No expiration","Idle":"idle","online":"online","offline":"offline","idle":"idle","ago":"ago","Active ({days}d)":"Active ({days}d)","No limit set by admin":"No limit set by admin","{remaining} accounts available · {pct}% used":"{remaining} accounts available · {pct}% used","{used} used · unlimited":"{used} used · unlimited","{used}/{max} used · {pct}% of plan":"{used}/{max} used · {pct}% of plan","SSH {ssh} · Xray {xray}":"SSH {ssh} · Xray {xray}","{ssh} SSH · {xray} Xray online":"{ssh} SSH · {xray} Xray online","{online} online · {active} active · {expired} expired · Core: {core}":"{online} online · {active} active · {expired} expired · Core: {core}","{count} online":"{count} online","{count} total · {active} active · {online} online":"{count} total · {active} active · {online} online", - "New user.":"New user.","TOTP secret generated.":"TOTP secret generated.","Loaded.":"Loaded.","Last reload: {time}":"Last reload: {time}","Error loading users.":"Error loading users.","Editing {name}":"Editing {name}","Deleting {name}…":"Deleting {name}…","Deleted.":"Deleted.","Error deleting.":"Error deleting.","Delete user \"{name}\"?":"Delete user \"{name}\"?","Invalid credentials.":"Invalid credentials.","Account suspended or expired.":"Account suspended or expired.","Login failed.":"Login failed.","Network error.":"Network error.","Session expired — please sign in again.":"Session expired — please sign in again.", - "Create Reseller":"Create Reseller","Create / edit reseller form":"Create / edit reseller form","Save reseller":"Save reseller","New reseller.":"New reseller.","Edit: {name}":"Edit: {name}","Editing {name}.":"Editing {name}.","Deleting {name}…":"Deleting {name}…","Error loading.":"Error loading.","Resellers list":"Resellers list","Max SSH users (0 = unlimited)":"Max SSH users (0 = unlimited)", - "Server Load":"Server Load","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Updated: {time}","Error loading stats.":"Error loading stats.","Normal load":"Normal load","Moderate load":"Moderate load","High load":"High load","Cleaning interface totals…":"Cleaning interface totals…","Interface totals cleaned. Auto-clean remains every 30 days.":"Interface totals cleaned. Auto-clean remains every 30 days.","Error cleaning totals: {error}":"Error cleaning totals: {error}", - "VnStat Usage":"VnStat Usage","Today total":"Today total","This month total":"This month total","Interfaces tracked":"Interfaces tracked","daily / monthly":"daily / monthly","Daily usage":"Daily usage","Monthly usage":"Monthly usage","Day":"Day","Month":"Month","Clean usage":"Clean usage","Clean VnStat history":"Clean VnStat history","VnStat history does not auto-clean. Use the button when you want to reset it.":"VnStat history does not auto-clean. Use the button when you want to reset it.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.","Loading VnStat usage…":"Loading VnStat usage…","VnStat history cleaned.":"VnStat history cleaned.","Error loading VnStat usage: {error}":"Error loading VnStat usage: {error}","Error cleaning VnStat history: {error}":"Error cleaning VnStat history: {error}", - "Panel / system":"Panel / system","Select a log source and click Refresh.":"Select a log source and click Refresh.","Clean panel log":"Clean panel log","No log lines yet.":"No log lines yet.","Panel log cleaned · {path} · max {max}":"Panel log cleaned · {path} · max {max}","Cleaning panel log…":"Cleaning panel log…", - "Network":"Network","Main Listen (SSH / HTTP)":"Main Listen (SSH / HTTP)","Extra Listen Addresses":"Extra Listen Addresses","(one per line, e.g. 0.0.0.0:8080)":"(one per line, e.g. 0.0.0.0:8080)","SSH & General":"SSH & General","Default Upload Limit (Mbps)":"Default Upload Limit (Mbps)","Default Download Limit (Mbps)":"Default Download Limit (Mbps)","Quiet Logs":"Quiet Logs","User Count Display":"User Count Display","SSH Banner":"SSH Banner","Banner Text":"Banner Text","(shown to connecting SSH clients)":"(shown to connecting SSH clients)","DNSTT Tunnel":"DNSTT Tunnel","Domain":"Domain","UDP Listen":"UDP Listen","Auto Restart Interval":"Auto Restart Interval","Restart Grace Delay":"Restart Grace Delay","0s/off disables":"0s/off disables","Private Key":"Private Key","Public Key":"Public Key","Disable Stats Log":"Disable Stats Log","Disable Console Log":"Disable Console Log","UDP Gateway":"UDP Gateway","Listen":"Listen","Idle Timeout":"Idle Timeout","Map TTL":"Map TTL","Debug Logging":"Debug Logging","TLS Forwarders":"TLS Forwarders","Listen Address":"Listen Address","Certificate":"Certificate","Generate Self-Signed":"Generate Self-Signed","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Paste PEM text","Custom file paths":"Custom file paths","Cert File":"Cert File","Key File":"Key File","Certificate PEM":"Certificate PEM","Private Key PEM":"Private Key PEM","Add Forwarder":"Add Forwarder","Save Config":"Save Config","All service changes apply live.":"All service changes apply live.","Saved and applied live.":"Saved and applied live.","Saved live with warnings: {warnings}":"Saved live with warnings: {warnings}","Processing…":"Processing…","Listen address required.":"Listen address required.","Domain required.":"Domain required.","Domain and email required.":"Domain and email required.","Cert and key paths required.":"Cert and key paths required.","Added. Save config to apply.":"Added. Save config to apply.","Generating…":"Generating…","Generated ✓ paths set.":"Generated ✓ paths set.","Generating key…":"Generating key…","Key generated. Save config to apply.":"Key generated. Save config to apply.","Loading public key…":"Loading public key…","Self-signed cert generated.":"Self-signed cert generated.","Let's Encrypt cert issued.":"Let's Encrypt cert issued.","PEM saved.":"PEM saved.","Saved ✓ paths set.":"Saved ✓ paths set.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Name, cert, and key required.":"Name, cert, and key required.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Save Changes":"Save Changes" - }, - "pt-BR": { - "Dashboard":"Painel","Overview":"Visão geral","Accounts":"Contas","Administration":"Administração","Server":"Servidor","System":"Sistema","Settings":"Configurações","Traffic":"Tráfego","Monitoring":"Monitoramento", - "SSH / SlowDNS":"SSH / SlowDNS","Xray Users":"Usuários Xray","Resellers":"Revendedores","Logs":"Logs","VnStat":"VnStat","VPN Control":"Controle VPN","DragonCore":"DragonCore", - "SSH Panel":"Painel SSH","Sign in with your admin or reseller credentials.":"Entre com suas credenciais de admin ou revendedor.","Username":"Usuário","Password":"Senha","Sign in":"Entrar","Logout":"Sair","Open menu":"Abrir menu","Toggle theme":"Alternar tema","Language":"Idioma", - "Total accounts":"Total de contas","active":"ativas","expired":"expiradas","available limit":"Limite disponível","Loading quota…":"Carregando cota…","Active connections":"Conexões ativas","SSH + Xray online now":"SSH + Xray online agora","Ready for resellers":"Pronto para revendedores","Server monitoring in real time":"monitoramento em tempo real","CPU":"CPU","RAM":"RAM","Network":"Rede","Processor load":"Carga do processador","Memory used":"Memória usada","Total":"Total","Total --":"Total --","RX -- · TX -- Mb/s":"RX -- · TX -- Mb/s", - "Quick actions":"Ações rápidas","simple":"simples","Create SSH":"Criar SSH","Create Xray":"Criar Xray","New reseller":"Novo revendedor","Configure services":"Configurar serviços","User, password, expiry and limit.":"Usuário, senha, validade e limite.","UUID, label, expiry and connections.":"UUID, label, validade e conexões.","Plan, expiry and account limit.":"Plano, validade e limite de contas.","Ports, DNSTT, UDPGW and TLS.":"Portas, DNSTT, UDPGW e TLS.","My quota":"Minha cota","Loading…":"Carregando…","Loading...":"Carregando...", - "My Account":"Minha conta","Users (used / max)":"Usuários (usado / máximo)","Users (used/max)":"Usuários (usado/máximo)","Expires":"Vence em","Status":"Status","Users":"Usuários","User":"Usuário","Auth":"Autenticação","Conn":"Conexões","Max":"Máximo","Up":"Upload","Dn":"Download","Owner":"Dono","Actions":"Ações","Create / update user":"Criar / atualizar usuário","Create / edit user form":"Formulário de criar / editar usuário","Show form":"Mostrar formulário","Hide form":"Ocultar formulário","TOTP Secret":"Segredo TOTP","TOTP Period (s)":"Período TOTP (s)","TOTP Window":"Janela TOTP","TOTP Digits":"Dígitos TOTP","Allow static password too":"Permitir senha estática também","Max connections":"Máx. conexões","Expires at":"Vence em","Max Upload (Mb/s)":"Upload máx. (Mb/s)","Max Download (Mb/s)":"Download máx. (Mb/s)","Save user":"Salvar usuário","Cancel":"Cancelar","Gen":"Gerar","Copy":"Copiar","Edit":"Editar","Del":"Excluir","Reload":"Recarregar","Refresh":"Atualizar","+ New":"+ Novo","+ Add":"+ Adicionar","Add":"Adicionar","Remove":"Remover", - "Running":"Rodando","Stopped":"Parado","running":"rodando","stopped":"parado","disabled":"desativado","Counters API":"API de contadores","Repair counters":"Reparar contadores","Start":"Iniciar","Stop":"Parar","Restart":"Reiniciar","Inbounds & Clients":"Inbounds e clientes","Inbounds & clients":"Inbounds e clientes","Xray Config":"Configuração Xray","Visual":"Visual","JSON":"JSON","Config editor":"Editor de configuração","Load JSON":"Carregar JSON","Save & Restart":"Salvar e reiniciar","System Logs":"Logs do sistema","last 200 lines":"últimas 200 linhas","Xray clients":"Clientes Xray","Xray Core":"Núcleo Xray","Enabled":"Ativado","Online":"Online","PID":"PID","Uptime":"Tempo ativo","Counters API ready.":"API de contadores pronta.","Counters API ready at {server}.":"API de contadores pronta em {server}.","Online counters need Stats API repair.":"Contadores online precisam de reparo da Stats API.","Online counters: {error}":"Contadores online: {error}","Needs repair":"Precisa de reparo","OK":"OK", - "UUID":"UUID","Email":"Email","Email / label":"Email / label","Display Name":"Nome de exibição","Expiry Date":"Data de vencimento","Max Connections":"Máx. conexões","(0 = unlimited)":"(0 = ilimitado)","auto-generate":"gerar automaticamente","Name":"Nome","Expiry":"Vencimento","Online":"Online","Traffic":"Tráfego","No clients.":"Nenhum cliente.","No VLESS/VMess/Trojan inbounds found.":"Nenhum inbound VLESS/VMess/Trojan encontrado.","Add Client":"Adicionar cliente","+ Add Client":"+ Adicionar cliente","Copied client ID.":"ID do cliente copiado.","UUID required.":"UUID obrigatório.","Client {id}… added. Restarting Xray…":"Cliente {id}… adicionado. Reiniciando Xray…","Client removed. Restarting Xray…":"Cliente removido. Reiniciando Xray…","Remove client {id}… from {tag}?":"Remover cliente {id}… de {tag}?","New client data is available; editing was preserved.":"Novos dados de cliente disponíveis; sua edição foi preservada.","Config loaded.":"Configuração carregada.","Invalid JSON: {error}":"JSON inválido: {error}","Saved. Restarting Xray…":"Salvo. Reiniciando Xray…","Saved.":"Salvo.","Saving…":"Salvando…","Error: {error}":"Erro: {error}","Error loading inbounds.":"Erro ao carregar inbounds.", - "Active":"Ativo","Suspended":"Suspenso","Expired":"Expirado","Unlimited":"Ilimitado","No expiration":"Sem vencimento","Idle":"ocioso","online":"online","offline":"offline","idle":"ocioso","ago":"atrás","Active ({days}d)":"Ativo ({days}d)","No limit set by admin":"Sem limite definido pelo admin","{remaining} accounts available · {pct}% used":"{remaining} contas disponíveis · {pct}% usado","{used} used · unlimited":"{used} usadas · sem limite","{used}/{max} used · {pct}% of plan":"{used}/{max} usadas · {pct}% do plano","SSH {ssh} · Xray {xray}":"SSH {ssh} · Xray {xray}","{ssh} SSH · {xray} Xray online":"{ssh} SSH · {xray} Xray online","{online} online · {active} active · {expired} expired · Core: {core}":"{online} online · {active} ativos · {expired} expirados · Core: {core}","{count} online":"{count} online","{count} total · {active} active · {online} online":"{count} total · {active} ativas · {online} online", - "New user.":"Novo usuário.","TOTP secret generated.":"Segredo TOTP gerado.","Loaded.":"Carregado.","Last reload: {time}":"Último reload: {time}","Error loading users.":"Erro ao carregar usuários.","Editing {name}":"Editando {name}","Deleting {name}…":"Excluindo {name}…","Deleted.":"Excluído.","Error deleting.":"Erro ao excluir.","Delete user \"{name}\"?":"Excluir usuário \"{name}\"?","Invalid credentials.":"Credenciais inválidas.","Account suspended or expired.":"Conta suspensa ou expirada.","Login failed.":"Falha no login.","Network error.":"Erro de rede.","Session expired — please sign in again.":"Sessão expirada — faça login novamente.", - "Create Reseller":"Criar revendedor","Create / edit reseller form":"Formulário de criar / editar revendedor","Save reseller":"Salvar revendedor","New reseller.":"Novo revendedor.","Edit: {name}":"Editar: {name}","Editing {name}.":"Editando {name}.","Deleting {name}…":"Excluindo {name}…","Error loading.":"Erro ao carregar.","Resellers list":"Lista de revendedores","Max SSH users (0 = unlimited)":"Máximo de usuários SSH (0 = ilimitado)", - "Server Load":"Carga do servidor","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Atualizado: {time}","Error loading stats.":"Erro ao carregar stats.","Normal load":"Carga normal","Moderate load":"Carga moderada","High load":"Carga alta","Cleaning interface totals…":"Limpando totais das interfaces…","Interface totals cleaned. Auto-clean remains every 30 days.":"Totais das interfaces limpos. A limpeza automática continua a cada 30 dias.","Error cleaning totals: {error}":"Erro ao limpar totais: {error}", - "VnStat Usage":"Uso do VnStat","Today total":"Total hoje","This month total":"Total este mês","Interfaces tracked":"Interfaces monitoradas","daily / monthly":"diário / mensal","Daily usage":"Uso diário","Monthly usage":"Uso mensal","Day":"Dia","Month":"Mês","Clean usage":"Limpar uso","Clean VnStat history":"Limpar histórico VnStat","VnStat history does not auto-clean. Use the button when you want to reset it.":"O histórico VnStat não é limpo automaticamente. Use o botão quando quiser resetar.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Os totais podem ser limpos aqui e têm limpeza automática a cada 30 dias. O histórico VnStat é separado.","Loading VnStat usage…":"Carregando uso do VnStat…","VnStat history cleaned.":"Histórico VnStat limpo.","Error loading VnStat usage: {error}":"Erro ao carregar uso do VnStat: {error}","Error cleaning VnStat history: {error}":"Erro ao limpar histórico VnStat: {error}", - "Panel / system":"Painel / sistema","Select a log source and click Refresh.":"Selecione uma fonte de log e clique em Atualizar.","Clean panel log":"Limpar log do painel","No log lines yet.":"Ainda não há linhas de log.","Panel log cleaned · {path} · max {max}":"Log do painel limpo · {path} · máx {max}","Cleaning panel log…":"Limpando log do painel…", - "Network":"Rede","Main Listen (SSH / HTTP)":"Listen principal (SSH / HTTP)","Extra Listen Addresses":"Endereços extras de listen","Proxy Auto Restart":"Reinício automático do proxy","Proxy Auto Restart Interval":"Intervalo de reinício automático do proxy","Proxy Restart Grace Delay":"Atraso para reiniciar proxy","(one per line, e.g. 0.0.0.0:8080)":"(um por linha, ex. 0.0.0.0:8080)","SSH & General":"SSH e geral","Default Upload Limit (Mbps)":"Limite padrão de upload (Mbps)","Default Download Limit (Mbps)":"Limite padrão de download (Mbps)","Quiet Logs":"Logs silenciosos","User Count Display":"Exibir contagem de usuários","SSH Banner":"Banner SSH","Banner Text":"Texto do banner","(shown to connecting SSH clients)":"(mostrado aos clientes SSH ao conectar)","DNSTT Tunnel":"Túnel DNSTT","Domain":"Domínio","UDP Listen":"Listen UDP","Auto Restart Interval":"Intervalo de reinício automático","Restart Grace Delay":"Atraso para reiniciar","0s/off disables":"0s/off desativa","Private Key":"Chave privada","Public Key":"Chave pública","Disable Stats Log":"Desativar log de stats","Disable Console Log":"Desativar log do console","UDP Gateway":"Gateway UDP","Listen":"Listen","Idle Timeout":"Timeout ocioso","Map TTL":"TTL do mapa","Debug Logging":"Log de debug","TLS Forwarders":"Encaminhadores TLS","Listen Address":"Endereço de listen","Certificate":"Certificado","Generate Self-Signed":"Gerar autoassinado","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Colar texto PEM","Custom file paths":"Caminhos personalizados","Cert File":"Arquivo cert","Key File":"Arquivo key","Certificate PEM":"Certificado PEM","Private Key PEM":"Chave privada PEM","Add Forwarder":"Adicionar forwarder","Save Config":"Salvar config","All service changes apply live.":"Todas as mudanças de serviço aplicam ao vivo.","Saved and applied live.":"Salvo e aplicado ao vivo.","Saved live with warnings: {warnings}":"Salvo ao vivo com avisos: {warnings}","Processing…":"Processando…","Listen address required.":"Endereço de listen obrigatório.","Domain required.":"Domínio obrigatório.","Domain and email required.":"Domínio e email obrigatórios.","Cert and key paths required.":"Caminhos do certificado e da chave obrigatórios.","Added. Save config to apply.":"Adicionado. Salve a config para aplicar.","Generating…":"Gerando…","Generated ✓ paths set.":"Gerado ✓ caminhos definidos.","Generating key…":"Gerando chave…","Key generated. Save config to apply.":"Chave gerada. Salve a config para aplicar.","Loading public key…":"Carregando chave pública…","Self-signed cert generated.":"Certificado autoassinado gerado.","Let's Encrypt cert issued.":"Certificado Let's Encrypt emitido.","PEM saved.":"PEM salvo.","Saved ✓ paths set.":"Salvo ✓ caminhos definidos.","Name, cert PEM, and key PEM required.":"Nome, cert PEM e chave PEM obrigatórios.","Name, cert, and key required.":"Nome, cert e chave obrigatórios.","Save Changes":"Salvar alterações" - } -}; -const I18N_ALIASES = { - "Painel":"Dashboard","Visão geral":"Overview","Contas":"Accounts","Administração":"Administration","Servidor":"Server","Sistema":"System","Configurações":"Settings","Tráfego":"Traffic","Revendedores":"Resellers","Usuários Xray":"Xray Users","Controle VPN":"VPN Control","Sair":"Logout", - "Total de contas":"Total accounts","ativas":"active","expiradas":"expired","Limite disponível":"available limit","Carregando cota…":"Loading quota…","Conexões ativas":"Active connections","SSH + Xray online agora":"SSH + Xray online now","Pronto para revendedores":"Ready for resellers","monitoramento em tempo real":"Server monitoring in real time","Carga do processador":"Processor load","Memória usada":"Memory used", - "Ações rápidas":"Quick actions","Criar SSH":"Create SSH","Criar Xray":"Create Xray","Novo revendedor":"New reseller","Configurar serviços":"Configure services","Usuário, senha, validade e limite.":"User, password, expiry and limit.","UUID, label, validade e conexões.":"UUID, label, expiry and connections.","Plano, validade e limite de contas.":"Plan, expiry and account limit.","Portas, DNSTT, UDPGW e TLS.":"Ports, DNSTT, UDPGW and TLS.","Minha cota":"My quota","Carregando…":"Loading…", - "Minha conta":"My Account","Usuários":"Users","Usuário":"User","Autenticação":"Auth","Conexões":"Conn","Máximo":"Max","Dono":"Owner","Ações":"Actions","Criar / atualizar usuário":"Create / update user","Mostrar formulário":"Show form","Ocultar formulário":"Hide form","Salvar usuário":"Save user","Cancelar":"Cancel","Gerar":"Gen","Copiar":"Copy","Editar":"Edit","Excluir":"Del","Recarregar":"Reload","Atualizar":"Refresh", - "Rodando":"Running","Parado":"Stopped","rodando":"running","parado":"stopped","desativado":"disabled","API de contadores":"Counters API","Reparar contadores":"Repair counters","Iniciar":"Start","Parar":"Stop","Reiniciar":"Restart","Inbounds e clientes":"Inbounds & Clients","Configuração Xray":"Xray Config","Editor de configuração":"Config editor","Carregar JSON":"Load JSON","Salvar e reiniciar":"Save & Restart","Logs do sistema":"System Logs","últimas 200 linhas":"last 200 lines","Clientes Xray":"Xray clients","Núcleo Xray":"Xray Core","Ativado":"Enabled","Tempo ativo":"Uptime","Precisa de reparo":"Needs repair", - "Nome":"Name","Nome de exibição":"Display Name","Data de vencimento":"Expiry Date","Máx. conexões":"Max Connections","Ilimitado":"Unlimited","Ativo":"Active","Suspenso":"Suspended","Expirado":"Expired","Sem vencimento":"No expiration","ocioso":"idle","Nenhum cliente.":"No clients.","Adicionar cliente":"Add Client","Novo usuário.":"New user.","Carregado.":"Loaded.","Salvo.":"Saved.","Salvando…":"Saving…","Erro ao carregar usuários.":"Error loading users.","Erro ao excluir.":"Error deleting.","Credenciais inválidas.":"Invalid credentials.","Conta suspensa ou expirada.":"Account suspended or expired.","Falha no login.":"Login failed.","Erro de rede.":"Network error.","Sessão expirada — faça login novamente.":"Session expired — please sign in again.", - "Rede":"Network","Listen principal (SSH / HTTP)":"Main Listen (SSH / HTTP)","Endereços extras de listen":"Extra Listen Addresses","Reinício automático do proxy":"Proxy Auto Restart","Intervalo de reinício automático do proxy":"Proxy Auto Restart Interval","Atraso para reiniciar proxy":"Proxy Restart Grace Delay","SSH e geral":"SSH & General","Limite padrão de upload (Mbps)":"Default Upload Limit (Mbps)","Limite padrão de download (Mbps)":"Default Download Limit (Mbps)","Logs silenciosos":"Quiet Logs","Exibir contagem de usuários":"User Count Display","Banner SSH":"SSH Banner","Texto do banner":"Banner Text","Túnel DNSTT":"DNSTT Tunnel","Domínio":"Domain","Chave privada":"Private Key","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Gateway UDP":"UDP Gateway","Endereço de listen":"Listen Address","Certificado":"Certificate","Gerar autoassinado":"Generate Self-Signed","Colar texto PEM":"Paste PEM text","Caminhos personalizados":"Custom file paths","Arquivo cert":"Cert File","Arquivo key":"Key File","Adicionar forwarder":"Add Forwarder","Salvar config":"Save Config","Todas as mudanças de serviço aplicam ao vivo.":"All service changes apply live." -}; - -Object.assign(I18N_TEXT["en-US"], { - "Servers":"Servers","Reseller area":"Reseller area","shared quota":"shared quota","available":"available","used":"used","breakdown":"breakdown", - "Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.", - "Loading inbounds…":"Loading inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"active ·","expired":"expired", - "Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085", - "Public Key — share with dnstt clients":"Public Key — share with DNSTT clients","auto-saved to /opt/sshpanel/dnstt.key":"auto-saved to /opt/sshpanel/dnstt.key", - "Max UDP Sessions Per Client":"Max UDP Sessions Per Client","(not total server users)":"(not total server users)","Service Name":"Service Name","Mode":"Mode","Protocol":"Protocol","Port":"Port","Tag":"Tag","Listen IP":"Listen IP","Method":"Method","Host":"Host","Path":"Path","Dest":"Dest","Short ID":"Short ID","Server Name":"Server Name","Cert File Path":"Cert File Path","Key File Path":"Key File Path","Certificate source:":"Certificate source:","Self-Signed":"Self-Signed","Paste PEM":"Paste PEM","File Path":"File Path","Save PEM":"Save PEM","Generate":"Generate","Public Key":"Public Key","Debug Logging":"Debug Logging","Name":"Name","Private Key PEM":"Private Key PEM","Certificate PEM":"Certificate PEM","Domain Name":"Domain Name" -}); -Object.assign(I18N_TEXT["pt-BR"], { - "Servers":"Servidores","Reseller area":"Área do revendedor","shared quota":"cota única","available":"disponíveis","used":"usadas","breakdown":"divisão", - "Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.", - "Loading inbounds…":"Carregando inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"ativas ·","expired":"expiradas", - "Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085", - "Public Key — share with dnstt clients":"Chave pública — compartilhe com clientes DNSTT","auto-saved to /opt/sshpanel/dnstt.key":"salva automaticamente em /opt/sshpanel/dnstt.key", - "Max UDP Sessions Per Client":"Máx. sessões UDP por cliente","(not total server users)":"(não é o total de usuários do servidor)","Service Name":"Nome do serviço","Mode":"Modo","Protocol":"Protocolo","Port":"Porta","Tag":"Tag","Listen IP":"IP de listen","Method":"Método","Host":"Host","Path":"Caminho","Dest":"Destino","Short ID":"ID curto","Server Name":"Nome do servidor","Cert File Path":"Caminho do arquivo cert","Key File Path":"Caminho do arquivo key","Certificate source:":"Fonte do certificado:","Self-Signed":"Autoassinado","Paste PEM":"Colar PEM","File Path":"Caminho do arquivo","Save PEM":"Salvar PEM","Generate":"Gerar","Public Key":"Chave pública","Debug Logging":"Log de debug","Name":"Nome","Private Key PEM":"Chave privada PEM","Certificate PEM":"Certificado PEM","Domain Name":"Nome do domínio" -}); -Object.assign(I18N_ALIASES, { - "Servidores":"Servers","Área do revendedor":"Reseller area","cota única":"shared quota","disponíveis":"available","usadas":"used","divisão":"breakdown", - "Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.", - "Carregando inbounds…":"Loading inbounds…","Loading inbounds…":"Loading inbounds…","ativas ·":"active ·","expiradas":"expired", - "Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085", - "Public Key — share with dnstt clients":"Public Key — share with dnstt clients","Chave pública — compartilhe com clientes DNSTT":"Public Key — share with dnstt clients", - "Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Nome do domínio":"Domain Name" -}); -const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))])); -let currentLang = detectInitialLanguage(); -let i18nTranslating = false; -let i18nQueued = false; - -function detectInitialLanguage() { - const saved = localStorage.getItem(LANG_STORAGE_KEY); - if (SUPPORTED_LANGS.includes(saved)) return saved; - const langs = (navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ""]).join(" ").toLowerCase(); - return langs.includes("pt") ? "pt-BR" : "en-US"; -} -function normalizeI18nText(value) { return String(value ?? "").replace(/\s+/g, " ").trim(); } -function i18nCanonicalKey(value) { - const text = normalizeI18nText(value); - if (!text) return ""; - if (Object.prototype.hasOwnProperty.call(I18N_TEXT["en-US"], text)) return text; - if (I18N_ALIASES[text]) return I18N_ALIASES[text]; - for (const lang of SUPPORTED_LANGS) { - if (I18N_REVERSE[lang][text]) return I18N_REVERSE[lang][text]; - } - return ""; -} -function t(key, vars = {}) { - const dict = I18N_TEXT[currentLang] || I18N_TEXT["en-US"]; - let out = dict[key] || I18N_TEXT["en-US"][key] || key; - return out.replace(/\{(\w+)\}/g, (_, name) => Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : ""); -} -function shouldSkipI18n(el) { - return !el || !el.closest || !!el.closest("script,style,textarea,pre,code,[data-no-i18n]"); -} -function translateTextNode(node) { - const raw = node.nodeValue || ""; - const key = i18nCanonicalKey(raw); - if (!key) return; - const translated = t(key); - const lead = raw.match(/^\s*/)?.[0] || ""; - const tail = raw.match(/\s*$/)?.[0] || ""; - const next = lead + translated + tail; - if (node.nodeValue !== next) node.nodeValue = next; -} -function translateStatic(root = document.body) { - if (!root) return; - i18nTranslating = true; - try { - if (root.nodeType === Node.TEXT_NODE) translateTextNode(root); - const base = root.nodeType === Node.ELEMENT_NODE ? root : document.body; - if (!base) return; - const walker = document.createTreeWalker(base, NodeFilter.SHOW_TEXT, { - acceptNode(node) { - return shouldSkipI18n(node.parentElement) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; - } - }); - const nodes = []; - while (walker.nextNode()) nodes.push(walker.currentNode); - nodes.forEach(translateTextNode); - base.querySelectorAll?.("[placeholder],[title],[aria-label]").forEach(el => { - if (shouldSkipI18n(el)) return; - ["placeholder", "title", "aria-label"].forEach(attr => { - const value = el.getAttribute(attr); - const key = i18nCanonicalKey(value); - if (key) el.setAttribute(attr, t(key)); - }); - }); - } finally { - i18nTranslating = false; - } -} -function queueI18nRefresh() { - if (i18nTranslating || i18nQueued) return; - i18nQueued = true; - requestAnimationFrame(() => { - i18nQueued = false; - translateStatic(document.body); - }); -} -function startI18nObserver() { - if (!document.body || window.__dragonI18nObserver) return; - window.__dragonI18nObserver = new MutationObserver(() => queueI18nRefresh()); - window.__dragonI18nObserver.observe(document.body, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ["placeholder", "title", "aria-label"] }); -} -function applyLanguage(lang, options = {}) { - currentLang = SUPPORTED_LANGS.includes(lang) ? lang : "en-US"; - if (options.persist !== false) localStorage.setItem(LANG_STORAGE_KEY, currentLang); - document.documentElement.lang = currentLang.toLowerCase(); - if (languageSelect) languageSelect.value = currentLang; - updatePageHeading(); - translateStatic(document.body); - document.documentElement.classList.remove("i18n-pending"); -} - -// ─── 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 languageSelect = document.getElementById("languageSelect"); -const pageTitle = document.getElementById("pageTitle"); -const pageEyebrow = document.getElementById("pageEyebrow"); -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 dashCpuVal = document.getElementById("dashCpuVal"); -const dashCpuText = document.getElementById("dashCpuText"); -const dashCpuBar = document.getElementById("dashCpuBar"); -const dashRamVal = document.getElementById("dashRamVal"); -const dashRamText = document.getElementById("dashRamText"); -const dashRamBar = document.getElementById("dashRamBar"); -const dashNetVal = document.getElementById("dashNetVal"); -const dashNetText = document.getElementById("dashNetText"); -const dashNetTotal = document.getElementById("dashNetTotal"); -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 xOnlineUsers = document.getElementById("xOnlineUsers"); -const xCoreMode = document.getElementById("xCoreMode"); -const xSaveModeBtn = document.getElementById("xSaveModeBtn"); -const xCfgEditor = document.getElementById("xCfgEditor"); -const xCfgStatus = document.getElementById("xCfgStatus"); -const xLogsBox = document.getElementById("xLogsBox"); -const inboundsContainer = document.getElementById("inboundsContainer"); -const sshServerPickerCard = document.getElementById("sshServerPickerCard"); -const xrayServerPickerCard = document.getElementById("xrayServerPickerCard"); -const sshServerSelect = document.getElementById("sshServerSelect"); -const xrayServerSelect = document.getElementById("xrayServerSelect"); -const sshServerHint = document.getElementById("sshServerHint"); -const xrayServerHint = document.getElementById("xrayServerHint"); - -// 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"); - -// Managed servers -const serversBody = document.getElementById("serversBody"); -const serversCountChip = document.getElementById("serversCountChip"); -const serversStatus = document.getElementById("serversStatus"); -const serverForm = document.getElementById("serverForm"); -const serverFormTitle = document.getElementById("serverFormTitle"); -const srvID = document.getElementById("srvID"); -const srvName = document.getElementById("srvName"); -const srvBaseURL = document.getElementById("srvBaseURL"); -const srvAdminUser = document.getElementById("srvAdminUser"); -const srvAdminKey = document.getElementById("srvAdminKey"); -const srvEnableSSH = document.getElementById("srvEnableSSH"); -const srvEnableXray = document.getElementById("srvEnableXray"); -const srvIsActive = document.getElementById("srvIsActive"); -const serverFormStatus = document.getElementById("serverFormStatus"); -const serversListView = document.getElementById("serversListView"); -const serverConfigSubpage = document.getElementById("serverConfigSubpage"); -const cfgServerName = document.getElementById("cfgServerName"); -const managedConfigEditor = document.getElementById("managedConfigEditor"); -const managedConfigStatus = document.getElementById("managedConfigStatus"); -const serversStatusGrid = document.getElementById("serversStatusGrid"); -const serversStatusPageStatus = document.getElementById("serversStatusPageStatus"); -const serversStatusCountChip = document.getElementById("serversStatusCountChip"); - -// 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"); -const dnsttDashboardCard = document.getElementById("dnsttDashboardCard"); -const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated"); -const dnsttActiveSessions = document.getElementById("dnsttActiveSessions"); -const dnsttActiveStreams = document.getElementById("dnsttActiveStreams"); -const dnsttDNSRx = document.getElementById("dnsttDNSRx"); -const dnsttQueueLen = document.getElementById("dnsttQueueLen"); -const dnsttHealthBody = document.getElementById("dnsttHealthBody"); -const dnsttHealthSummary = document.getElementById("dnsttHealthSummary"); - -// 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; -} -function withServerParam(path, serverID) { - serverID = serverID || "local"; - if (!serverID || serverID === "local") return path; - return path + (path.includes("?") ? "&" : "?") + "server_id=" + encodeURIComponent(serverID); -} -function selectedSSHServer() { return sshServerSelect?.value || selectedSSHServerID || "local"; } -function selectedXrayServer() { return xrayServerSelect?.value || selectedXrayServerID || "local"; } -function serverByID(id) { return serversCache.find(s => String(s.id) === String(id)); } -function selectedXrayServerLabel() { - const id = selectedXrayServer(); - const srv = serverByID(id); - if (srv) return srv.name || srv.base_url || id; - return id === "local" ? "Master node" : id; -} -function xrayModeFromConfig(x) { - const mode = String(x?.mode || "").toLowerCase(); - return mode === "external" ? "external" : "native"; -} - -function applyXrayModeToConfig(cfg, mode) { - mode = mode === "external" ? "external" : "native"; - cfg.xray = cfg.xray && typeof cfg.xray === "object" ? cfg.xray : {}; - cfg.xray.mode = mode; - cfg.xray.native = mode === "native"; - cfg.xray.bin_path = cfg.xray.bin_path || "/opt/sshpanel/xray"; - cfg.xray.config_file = cfg.xray.config_file || "/opt/sshpanel/xray_config.json"; - cfg.xray.native_config_file = cfg.xray.native_config_file || "/opt/sshpanel/xray_native_config.json"; - cfg.xray.api_server = cfg.xray.api_server || "127.0.0.1:10085"; - cfg.xray.online_window_seconds = cfg.xray.online_window_seconds || 90; - cfg.xray.stats_poll_seconds = cfg.xray.stats_poll_seconds || 15; - return cfg; -} - -function reloadXrayConfigForSelectedServer() { - const wizPane = document.getElementById("xrayWizardPane"); - const jsonPane = document.getElementById("xrayCfgPaneJson"); - if (jsonPane && !jsonPane.classList.contains("hidden")) return loadXrayCfg(); - if (wizPane && !wizPane.classList.contains("hidden")) return loadWizardFromConfig(); -} - -// ─── 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 fmtInt(n) { - const v = Number(n); - return Number.isFinite(v) ? v.toLocaleString() : "--"; -} -function fmtDnsttTimestamp(ts) { - if (!ts) return "Waiting for DNSTT stats…"; - const d = new Date(ts); - if (!Number.isFinite(d.getTime()) || d.getFullYear() < 2020) return "Waiting for DNSTT stats…"; - return "Updated: " + d.toLocaleTimeString(); -} -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)); -} - - -function escapeHTML(value) { - return String(value ?? "").replace(/[&<>'"]/g, ch => ({ - "&": "&", "<": "<", ">": ">", "'": "'", '"': """, - }[ch])); -} - -function isVisible(el) { - return !!el && !el.classList.contains("hidden") && getComputedStyle(el).display !== "none"; -} - -function isXrayClientEditorActive() { - const active = document.activeElement; - if (active && active.closest && (active.closest("#inboundsContainer") || active.closest("#editXrayClientPanel"))) return true; - if (isVisible(document.getElementById("editXrayClientPanel"))) return true; - return Array.from(document.querySelectorAll('#inboundsContainer [id^="add-form-"]')).some(isVisible); -} - -function inboundStructure(inbounds = []) { - return JSON.stringify((inbounds || []).map(ib => ({ - tag: ib.tag || "", - protocol: ib.protocol || "", - port: ib.port ?? "", - clients: (ib.clients || []).map(c => c.id || ""), - }))); -} - -function clientStatusHTML(c) { - const exp = c.expires_at ? new Date(c.expires_at) : null; - const daysLeft = c.expiration_days; - if (c.expired) return `${t("Expired")}`; - if (daysLeft === -1 || !exp) return `${t("Active")}`; - return `${t("Active ({days}d)", {days: escapeHTML(daysLeft)})}`; -} - -function clientExpiryLabel(c) { - const exp = c.expires_at ? new Date(c.expires_at) : null; - return exp ? exp.toLocaleDateString() : t("Unlimited"); -} - -function clientOnlineHTML(c) { - return `${c.online ? `${t("online")}` : `${t("offline")}`}
${escapeHTML(formatLastActive(c.last_active))}
`; -} - -function clientTrafficHTML(c) { - const up = Number(c.uplink_bytes || 0); - const down = Number(c.downlink_bytes || 0); - const total = Number(c.total_bytes || (up + down) || 0); - return `${escapeHTML(formatBytes(total))}
↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}
`; -} - -function updateCell(row, name, html) { - const cell = row?.querySelector?.(`[data-cell="${name}"]`); - if (cell && cell.innerHTML !== html) cell.innerHTML = html; -} - -function patchRenderedInbounds(inbounds) { - const sections = Array.from(inboundsContainer.querySelectorAll("[data-inbound-tag]")); - if (sections.length !== inbounds.length) return false; - - for (const ib of inbounds) { - const tag = String(ib.tag || ""); - const section = sections.find(el => el.dataset.inboundTag === tag); - if (!section) return false; - if (section.dataset.inboundProtocol !== String(ib.protocol || "") || section.dataset.inboundPort !== String(ib.port ?? "")) return false; - - const clients = ib.clients || []; - const rows = Array.from(section.querySelectorAll("tr[data-client-id]")); - if (rows.length !== clients.length) return false; - - const onlineCount = clients.filter(c => !!c.online).length; - const chip = section.querySelector('[data-role="inbound-online-chip"]'); - if (chip) { - chip.textContent = t("{count} online", {count: onlineCount}); - chip.classList.toggle("green", onlineCount > 0); - } - - for (const c of clients) { - const row = rows.find(el => el.dataset.clientId === String(c.id || "")); - if (!row) return false; - updateCell(row, "name", escapeHTML(c.name || "—")); - updateCell(row, "uuid", escapeHTML(c.id || "—")); - updateCell(row, "email", escapeHTML(c.email || "—")); - updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c))); - updateCell(row, "status", clientStatusHTML(c)); - updateCell(row, "online", clientOnlineHTML(c)); - updateCell(row, "traffic", clientTrafficHTML(c)); - updateCell(row, "max", escapeHTML(c.max_conns || "∞")); - } - } - return true; -} - -// ─── Navigation / shell ────────────────────────────────────────────────────── -const tabTitles = { - dashboard: ["Dashboard", "Overview"], - ssh: ["Accounts", "SSH / SlowDNS"], - xray: ["Accounts", "Xray Users"], - resellers: ["Administration", "Resellers"], - servers: ["Administration", "Servers"], - "servers-status": ["Administration", "Servers Status"], - stats: ["Server", "Monitoring"], - vnstat: ["Traffic", "VnStat"], - logs: ["System", "Logs"], - server: ["System", "Settings"], -}; -function updatePageHeading() { - const [eyebrow, title] = tabTitles[currentTab] || ["Dashboard", currentTab]; - if (pageEyebrow) pageEyebrow.textContent = t(eyebrow); - if (pageTitle) pageTitle.textContent = t(title); -} - -function selectTab(tab) { - currentTab = 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"); - updatePageHeading(); - document.body.classList.remove("sidebar-open"); - - if (tab === "dashboard") refreshDashboard(); - if (tab === "xray") { - loadXrayStatus(); - loadInbounds({ silent: true }); - if (currentRole === "superadmin") loadWizardFromConfig(); - } - if (tab === "stats" && currentRole === "superadmin") loadStats(); - if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus(); - if (tab === "resellers" && currentRole === "superadmin") loadResellers(); - if (tab === "servers" && currentRole === "superadmin") loadServers(); -} - -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")); -languageSelect?.addEventListener("change", () => { applyLanguage(languageSelect.value); renderDashboardCounters(); }); -applyLanguage(currentLang, { persist: false }); -startI18nObserver(); - -// ─── 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 ? t("Invalid credentials.") : - res.status === 403 ? t("Account suspended or expired.") : - t("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 = t("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; - mainApp.classList.remove("role-superadmin", "role-reseller"); - mainApp.classList.add(currentRole === "superadmin" ? "role-superadmin" : "role-reseller"); - 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"); - - selectTab("dashboard"); - loadServers(); - - if (currentRole === "superadmin") { - loadDashboardStats(); - statsTimer = setInterval(() => { - loadDashboardStats(); - if (currentTab === "stats") loadStats(); - if (currentTab === "servers-status") loadServersStatus({ silent: true }); - }, 2000); - } else { - loadMe(); - } - xrayTimer = setInterval(() => { - loadXrayStatus(); - if (currentTab === "xray") loadInbounds({ silent: true }); - }, 7000); - - loadUsers(); - loadXrayStatus(); - loadInbounds({ silent: true }); - usersTimer = setInterval(() => loadUsersSilent(), 3000); -} - -// ─── 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) : t("No expiration"); - rStatus.textContent = d.is_active ? t("Active") : t("Suspended"); - 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 - ? t("No limit set by admin") - : t("{remaining} accounts available · {pct}% used", {remaining, pct}); - dashQuotaBreakdown.textContent = t("SSH {ssh} · Xray {xray}", {ssh: sshUsed, xray: xrayUsed}); - dashQuotaBar.style.width = `${pct}%`; - - if (dashQuotaRemaining) { - dashQuotaRemaining.textContent = String(remaining); - setQuotaTone(dashQuotaRemaining, tone); - } - if (dashQuotaSummaryText) { - dashQuotaSummaryText.textContent = unlimited - ? t("{used} used · unlimited", {used}) - : t("{used}/{max} used · {pct}% of plan", {used, max, pct}); - } - 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 = t("SSH {ssh} · Xray {xray}", {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 ${t("ago")}`; - const min = Math.floor(sec / 60); - if (min < 60) return `${min}m ${t("ago")}`; - const hrs = Math.floor(min / 60); - if (hrs < 24) return `${hrs}h ${t("ago")}`; - 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 = t("{ssh} SSH · {xray} Xray online", {ssh: sshConns, xray: xrayOnline}); - if (dashXrayClients) dashXrayClients.textContent = xrayClients.length; - if (dashXrayStatus) { - const running = xrayChip?.textContent || "--"; - dashXrayStatus.textContent = t("{online} online · {active} active · {expired} expired · Core: {core}", {online: xrayOnline, active: xrayActive, expired: xrayExpired, 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({ silent: true }); - loadXrayStatus(); - if (currentRole === "superadmin") loadStats(); - 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 = t("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 = t("TOTP secret generated."); -}); -document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; }); - -function setFormCollapsed(v) { - formCollapsed = v; - userFormWrap.classList.toggle("collapsed", v); - toggleFormBtn.textContent = v ? t("Show form") : t("Hide form"); -} - -async function loadUsers() { - userStatus.textContent = t("Loading…"); - try { - const res = await api(withServerParam("/api/users", selectedSSHServer())); - const data = await res.json(); - renderUsers(data || []); - userStatus.textContent = t("Loaded."); - lastReload.textContent = t("Last reload: {time}", {time: new Date().toLocaleTimeString()}); - } catch (e) { - if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = t("Error loading users."); } - } -} -async function loadUsersSilent() { - try { - const res = await api(withServerParam("/api/users", selectedSSHServer())); - 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 ? `${t("online")}` : `${t("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:t("Edit"), - onclick: () => fillUserForm(u), - }); - const delBtn = Object.assign(document.createElement("button"), { - className:"btn btn-danger btn-sm", textContent:t("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 = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, 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 = t("Editing {name}", {name: u.username}); -} - -userForm.addEventListener("submit", async e => { - e.preventDefault(); - saveUserBtn.disabled = true; - userStatus.textContent = t("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), - server_id: selectedSSHServer(), - }; - 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 = t("Saved."); - fPassword.value = ""; - loadUsers(); - if (currentRole === "reseller") loadMe(); - } catch (e) { - if (e.message==="auth") doAuthError(); - else userStatus.textContent = t("Error: {error}", {error: e.message}); - } finally { - saveUserBtn.disabled = false; - } -}); - -async function deleteUser(username) { - if (!confirm(t("Delete user \"{name}\"?", {name: username}))) return; - userStatus.textContent = t("Deleting {name}…", {name: username}); - try { - const res = await api(withServerParam(`/api/users/delete?username=${encodeURIComponent(username)}`, selectedSSHServer()), { method:"DELETE" }); - if (!res.ok && res.status !== 204) throw new Error("delete failed"); - userStatus.textContent = t("Deleted."); - loadUsers(); - if (currentRole === "reseller") loadMe(); - } catch (e) { - if (e.message==="auth") doAuthError(); - else userStatus.textContent = t("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); -xSaveModeBtn?.addEventListener("click", saveXrayCoreMode); -document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds({ force: true }); }); -document.getElementById("xLoadInboundsBtn").addEventListener("click", () => loadInbounds({ force: true })); -document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg); -document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg); -document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs); - - -async function loadXrayStatus() { - try { - const res = await api(withServerParam("/api/xray/status", selectedXrayServer())); - const s = await res.json(); - const run = !!s.running; - xrayChip.textContent = run ? t("running") : (s.enabled ? t("stopped") : t("disabled")); - xrayChip.className = "chip " + (run ? "green" : "red"); - xRunning.textContent = run ? t("Running") : t("Stopped"); - xRunning.style.color = run ? "var(--success)" : "var(--danger)"; - xPID.textContent = s.pid || (s.native ? "internal" : "--"); - xUptime.textContent = s.uptime || "--"; - if (xCoreMode) xCoreMode.value = String(s.mode || (s.native ? "native" : "external")).toLowerCase() === "external" ? "external" : "native"; - const statsCfgEl = document.getElementById("xStatsConfig"); - const repairBtn = document.getElementById("xRepairStatsBtn"); - if (statsCfgEl) { - statsCfgEl.textContent = s.stats_configured ? t("OK") : t("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 = t("Online counters need Stats API repair.") + missing; - } else if (s.stats_error && xStatus) { - xStatus.textContent = t("Online counters: {error}", {error: s.stats_error}); - } else if (xStatus) { - xStatus.textContent = s.api_server ? t("Counters API ready at {server}.", {server: s.api_server}) : t("Counters API ready."); - } - if (dashServers) dashServers.textContent = String((serversCache || []).filter(n => n.is_active !== false).length || (s.enabled ? 1 : 0)); - if (dashServerStatus) dashServerStatus.textContent = (serversCache || []).length > 1 ? `${(serversCache || []).filter(n => n.is_active !== false).length} nodes configured` : (run ? t("{count} online", {count: 1}) : (s.enabled ? t("stopped") : t("disabled"))); - renderDashboardCounters(); - if (s.error) xStatus.textContent = t("Error: {error}", {error: s.error}); - } catch (e) { if (e.message==="auth") doAuthError(); } -} - -async function saveXrayCoreMode() { - const mode = xCoreMode?.value === "external" ? "external" : "native"; - const target = selectedXrayServerLabel(); - const selectedID = selectedXrayServer() || "local"; - if (xStatus) xStatus.textContent = `Saving Xray mode on ${target}...`; - try { - const getRes = await api(withServerParam("/api/servers/config", selectedID)); - if (!getRes.ok) throw new Error(await getRes.text()); - const cfg = await getRes.json(); - applyXrayModeToConfig(cfg, mode); - const postRes = await api(withServerParam("/api/servers/config", selectedID), { method:"POST", body: JSON.stringify(cfg) }); - if (!postRes.ok) throw new Error(await postRes.text()); - if (xStatus) xStatus.textContent = mode === "native" - ? `Saved on ${target}: using internal native emulator.` - : `Saved on ${target}: using external Xray binary.`; - setTimeout(loadXrayStatus, 700); - setTimeout(() => loadInbounds({ force: true }), 1200); - } catch (e) { - if (e.message === "auth") doAuthError(); - else if (xStatus) xStatus.textContent = t("Error: {error}", {error: e.message}); - } -} - -async function repairXrayStats() { - const btn = document.getElementById("xRepairStatsBtn"); - if (btn) btn.disabled = true; - xStatus.textContent = currentLang === "pt-BR" ? "Verificando e reparando a API de contadores do Xray…" : "Checking and repairing Xray counters API…"; - try { - const res = await api(withServerParam("/api/xray/stats/repair", selectedXrayServer()), { method:"POST" }); - if (!res.ok) throw new Error(await res.text()); - const d = await res.json().catch(() => ({})); - xStatus.textContent = d.changed - ? (d.restarted ? (currentLang === "pt-BR" ? "API de contadores reparada e Xray reiniciado." : "Counters API repaired and Xray restarted.") : (currentLang === "pt-BR" ? "API de contadores reparada. Reinicie o Xray para aplicar." : "Counters API repaired. Restart Xray to apply it.")) - : (currentLang === "pt-BR" ? "A API de contadores já parece correta." : "Counters API already looks correct."); - setTimeout(loadXrayStatus, 700); - setTimeout(() => loadInbounds({ force: true }), 1200); - } catch (e) { - if (e.message==="auth") doAuthError(); - else xStatus.textContent = (currentLang === "pt-BR" ? "Erro ao reparar contadores: " : "Error repairing counters: ")+e.message; - } finally { - if (btn) btn.disabled = false; - } -} - -async function xrayCtrl(action) { - xStatus.textContent = (currentLang === "pt-BR" ? "Processando Xray…" : action.charAt(0).toUpperCase()+action.slice(1)+"ing Xray…"); - try { - const res = await api(withServerParam(`/api/xray/${action}`, selectedXrayServer()), { method:"POST" }); - if (!res.ok) throw new Error(await res.text()); - xStatus.textContent = currentLang === "pt-BR" ? "Xray OK." : "Xray "+action+" OK."; - setTimeout(loadXrayStatus, 700); - setTimeout(() => loadInbounds({ force: true }), 1200); - } catch (e) { - if (e.message==="auth") doAuthError(); - else xStatus.textContent = t("Error: {error}", {error: e.message}); - } -} - -async function loadInbounds(options = {}) { - const { silent = false, force = false } = options || {}; - if (inboundsRefreshInFlight) return; - inboundsRefreshInFlight = true; - if (!silent) inboundsContainer.innerHTML = `
${t("Loading…")}
`; - else inboundsContainer.classList.add("xray-refreshing"); - try { - const res = await api(withServerParam("/api/xray/inbounds", selectedXrayServer())); - if (!res.ok) throw new Error(await res.text()); - const inbounds = await res.json(); - renderInbounds(inbounds || [], { silent, force }); - } catch (e) { - if (!silent) inboundsContainer.textContent = t("Error loading inbounds."); - if (e.message==="auth") doAuthError(); - } finally { - inboundsRefreshInFlight = false; - inboundsContainer.classList.remove("xray-refreshing"); - } -} - -async function copyText(text) { - try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - return true; - } - } catch {} - const ta = document.createElement("textarea"); - ta.value = text; - ta.style.position = "fixed"; - ta.style.left = "-9999px"; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - try { return document.execCommand("copy"); } - finally { document.body.removeChild(ta); } -} - -function renderInbounds(inbounds, options = {}) { - const { silent = false, force = false } = options || {}; - updateDashboardXray(inbounds); - const nextStructure = inboundStructure(inbounds); - - if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return; - if (silent && !force && isXrayClientEditorActive()) { - patchRenderedInbounds(inbounds); - if (xStatus) xStatus.textContent = t("New client data is available; editing was preserved."); - return; - } - - if (!inbounds.length) { - inboundsContainer.innerHTML = `
${t("No VLESS/VMess/Trojan inbounds found.")}
`; - lastInboundsStructure = nextStructure; - return; - } - inboundsContainer.innerHTML = ""; - lastInboundsStructure = nextStructure; - inbounds.forEach(ib => { - const section = document.createElement("div"); - section.dataset.inboundTag = String(ib.tag || ""); - section.dataset.inboundProtocol = String(ib.protocol || ""); - section.dataset.inboundPort = String(ib.port ?? ""); - 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 = ` -
- ${escapeHTML(ib.protocol)} - ${escapeHTML(ib.tag || "untagged")} - :${escapeHTML(ib.port ?? "?")} - ${t("{count} online", {count: onlineCount})} -
- `; - 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 = `
${t("No clients.")}
`; - } else { - const tbl = document.createElement("table"); - tbl.innerHTML = `${t("Name")}UUID${t("Email")}${t("Expiry")}${t("Status")}${t("Online")}${t("Traffic")}${t("Max")}${t("Actions")}`; - const tbody = document.createElement("tbody"); - clients.forEach(c => { - const tr = document.createElement("tr"); - tr.dataset.clientId = String(c.id || ""); - tr.innerHTML = ` - ${escapeHTML(c.name || "—")} - ${escapeHTML(c.id || "—")} - ${escapeHTML(c.email || "—")} - ${escapeHTML(clientExpiryLabel(c))} - ${clientStatusHTML(c)} - ${clientOnlineHTML(c)} - ${clientTrafficHTML(c)} - ${escapeHTML(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 = t("Copy"); - copyBtn.onclick = async () => { await copyText(c.id); xStatus.textContent = t("Copied client ID."); }; - const editBtn = document.createElement("button"); - editBtn.className = "btn btn-warn btn-sm"; - editBtn.style.marginLeft = "4px"; - editBtn.textContent = t("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 = t("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 = t("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, server_id: selectedXrayServer() }), - }); - if (!res.ok) throw new Error(await res.text()); - xStatus.textContent = t("Client {id}… added. Native mode hot-reloads without restart.", {id: uuid.slice(0,8)}); - setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500); - } catch (e) { - if (e.message==="auth") doAuthError(); - else xStatus.textContent = t("Error: {error}", {error: e.message}); - } -} - -async function removeClient(tag, uuid) { - if (!confirm(t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}))) return; - try { - const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" }); - if (!res.ok && res.status !== 204) throw new Error(await res.text()); - xStatus.textContent = t("Client removed. Native mode hot-reloads without restart."); - setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500); - } catch (e) { - if (e.message==="auth") doAuthError(); - else xStatus.textContent = t("Error: {error}", {error: e.message}); - } -} - -async function loadXrayCfg() { - if (!xCfgEditor) return; - const target = selectedXrayServerLabel(); - if (xCfgStatus) xCfgStatus.textContent = `Loading config from ${target}…`; - try { - const res = await api(withServerParam("/api/xray/config", selectedXrayServer())); - 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; } - if (xCfgStatus) xCfgStatus.textContent = `Config loaded from ${target}.`; - } catch (e) { - if (e.message==="auth") doAuthError(); - else if (xCfgStatus) xCfgStatus.textContent = t("Error: {error}", {error: e.message}); - } -} - -async function saveXrayCfg() { - const text = (xCfgEditor?.value || "").trim(); - const target = selectedXrayServerLabel(); - try { JSON.parse(text); } catch(e) { if (xCfgStatus) xCfgStatus.textContent = t("Invalid JSON: {error}", {error: e.message}); return; } - if (xCfgStatus) xCfgStatus.textContent = `Saving config to ${target}…`; - try { - const res = await api(withServerParam("/api/xray/config", selectedXrayServer()), { method:"POST", body: text }); - if (!res.ok) throw new Error(await res.text()); - if (xCfgStatus) xCfgStatus.textContent = `Saved on ${target}. Restarting Xray…`; - await xrayCtrl("restart"); - } catch (e) { - if (e.message==="auth") doAuthError(); - else if (xCfgStatus) xCfgStatus.textContent = t("Error: {error}", {error: e.message}); - } -} - -async function loadXrayLogs() { - try { - const res = await api(withServerParam("/api/xray/logs", selectedXrayServer())); - 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:t("Edit"), - onclick: () => fillResellerForm(r), - }); - const delBtn = Object.assign(document.createElement("button"),{ - className:"btn btn-danger btn-sm", textContent:t("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."; - } -} - -// ─── Managed Servers ───────────────────────────────────────────────────────── -sshServerSelect?.addEventListener("change", () => { - selectedSSHServerID = selectedSSHServer(); - localStorage.setItem("SSH_SERVER_ID", selectedSSHServerID); - loadUsers(); -}); -xrayServerSelect?.addEventListener("change", () => { - selectedXrayServerID = selectedXrayServer(); - localStorage.setItem("XRAY_SERVER_ID", selectedXrayServerID); - lastInboundsStructure = ""; - closeEditXrayClient?.(); - if (xStatus) xStatus.textContent = `Switched Xray target to ${selectedXrayServerLabel()}.`; - loadXrayStatus(); - loadInbounds({ force: true }); - reloadXrayConfigForSelectedServer(); - loadXrayLogs(); -}); -document.getElementById("reloadServersBtn")?.addEventListener("click", loadServers); -document.getElementById("reloadServersBtn2")?.addEventListener("click", loadServers); -document.getElementById("refreshServersBtn")?.addEventListener("click", loadServers); -document.getElementById("refreshServersStatusBtn")?.addEventListener("click", () => loadServersStatus()); -document.querySelector("[data-tab='servers']")?.addEventListener("click", loadServers); -document.querySelector("[data-tab='servers-status']")?.addEventListener("click", () => loadServersStatus()); -document.getElementById("clearServerFormBtn")?.addEventListener("click", clearServerForm); -document.getElementById("testServerBtn")?.addEventListener("click", testServerForm); -document.getElementById("backToServersBtn")?.addEventListener("click", () => showServerListView()); -document.getElementById("loadManagedConfigBtn")?.addEventListener("click", () => loadManagedServerConfig(configuringServerID)); -document.getElementById("saveManagedConfigBtn")?.addEventListener("click", saveManagedServerConfig); -document.getElementById("saveManagedConfigBottomBtn")?.addEventListener("click", saveManagedServerConfig); -document.getElementById("reloadManagedConfigBottomBtn")?.addEventListener("click", () => loadManagedServerConfig(configuringServerID)); -serverForm?.addEventListener("submit", async e => { - e.preventDefault(); - await saveServerForm(); -}); - -async function loadServers() { - try { - const res = await api("/api/servers"); - if (!res.ok) throw new Error(await res.text()); - serversCache = await res.json() || []; - } catch (e) { - serversCache = [{ id:"local", name:"Master node", base_url:"local", enable_ssh:true, enable_xray:true, is_active:true, is_local:true }]; - if (serversStatus) serversStatus.textContent = "Error loading servers: " + e.message; - if (e.message === "auth") doAuthError(); - } - renderServerSelectors(); - renderServersTable(); -} - - -async function loadServersStatus(options = {}) { - const silent = !!options.silent; - if (!serversStatusGrid) return; - try { - if (!Array.isArray(serversCache) || serversCache.length === 0) await loadServers(); - const nodes = (serversCache || []).filter(Boolean); - if (serversStatusCountChip) serversStatusCountChip.textContent = String(nodes.length); - if (!silent) { - serversStatusPageStatus && (serversStatusPageStatus.textContent = "Loading servers..."); - serversStatusGrid.innerHTML = `
Loading servers...
`; - } - const rows = await Promise.all(nodes.map(loadSingleServerStatus)); - renderServersStatusCards(rows); - if (serversStatusPageStatus) { - const online = rows.filter(r => r.ok).length; - serversStatusPageStatus.textContent = `${online}/${rows.length} servers online - Updated ${new Date().toLocaleTimeString()}`; - } - } catch (e) { - if (e.message === "auth") doAuthError(); - else { - serversStatusPageStatus && (serversStatusPageStatus.textContent = "Error loading server status: " + e.message); - if (!silent) serversStatusGrid.innerHTML = `
Error loading server status.
`; - } - } -} - -async function fetchJSONForServer(path, serverID) { - const res = await api(withServerParam(path, serverID)); - if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`); - return await res.json(); -} - -async function loadSingleServerStatus(server) { - const id = String(server.id || "local"); - const out = { server, ok: true, error: "", stats: null, users: [], inbounds: [], xray: null }; - if (server.is_active === false) { out.ok = false; out.error = "disabled"; return out; } - try { - out.stats = await fetchJSONForServer("/api/stats", id); - } catch (e) { - out.ok = false; - out.error = e.message || "stats failed"; - } - if (server.enable_ssh || server.is_local) { - try { out.users = await fetchJSONForServer("/api/users", id) || []; } - catch (e) { out.usersError = e.message || "users failed"; } - } - if (server.enable_xray || server.is_local) { - try { out.xray = await fetchJSONForServer("/api/xray/status", id); } - catch (e) { out.xrayError = e.message || "xray status failed"; } - try { out.inbounds = await fetchJSONForServer("/api/xray/inbounds", id) || []; } - catch (e) { out.inboundsError = e.message || "xray clients failed"; } - } - return out; -} - -function renderServersStatusCards(rows = []) { - if (!serversStatusGrid) return; - if (!rows.length) { - serversStatusGrid.innerHTML = `
No active servers configured.
`; - return; - } - serversStatusGrid.innerHTML = rows.map(serverStatusCardHTML).join(""); -} - -function serverStatusCardHTML(row) { - const s = row.server || {}; - const stats = row.stats || {}; - const ifaces = Array.isArray(stats.interfaces) ? stats.interfaces : []; - let rx = 0, tx = 0, rxTotal = 0, txTotal = 0; - ifaces.forEach(it => { - rx += Number(it.rx_mbps || 0); - tx += Number(it.tx_mbps || 0); - rxTotal += Number(it.rx_bytes || 0); - txTotal += Number(it.tx_bytes || 0); - }); - const cpu = Number(stats.cpu_percent || 0); - const mem = stats.mem_percent == null ? 0 : Number(stats.mem_percent || 0); - const users = Array.isArray(row.users) ? row.users : []; - const now = Date.now(); - const sshActive = users.filter(u => !u.expires_at || new Date(u.expires_at).getTime() > now).length; - const sshExpired = Math.max(0, users.length - sshActive); - const sshConns = users.reduce((sum, u) => sum + Number(u.active_conns || 0), 0); - const clients = []; - (Array.isArray(row.inbounds) ? row.inbounds : []).forEach(ib => (ib.clients || []).forEach(c => clients.push(c))); - const xrayOnline = clients.filter(c => !!c.online).length; - const xrayActive = clients.filter(c => !c.expired && (!c.expires_at || new Date(c.expires_at).getTime() > now)).length; - const xrayExpired = Math.max(0, clients.length - xrayActive); - const netNow = rx + tx; - const running = row.xray ? !!row.xray.running : false; - const nodeStatus = row.ok ? `online` : `offline`; - const options = `${s.enable_ssh ? "SSH" : ""}${s.enable_ssh && s.enable_xray ? " / " : ""}${s.enable_xray ? "Xray" : ""}` || "disabled"; - const err = row.ok ? "" : `
${escapeHTML(row.error || "connection failed")}
`; - return ` -
-
-
-
${escapeHTML(s.name || "Server")}
-
${escapeHTML(s.base_url || "local")}
-
-
- ${nodeStatus} - ${escapeHTML(options)} -
-
- ${err} -
- ${miniMetricHTML("CPU", fmtPct(cpu), cpu, cpu >= 85 ? "High load" : cpu >= 60 ? "Moderate load" : "Normal load")} - ${miniMetricHTML("RAM", fmtPct(mem), mem, stats.mem_used_bytes && stats.mem_total_bytes ? `${fmtBytes(stats.mem_used_bytes)} / ${fmtBytes(stats.mem_total_bytes)}` : "Memory used")} - ${miniMetricHTML("Network", `${fmtMbps(netNow)} Mb/s`, Math.min(100, netNow / 20), `RX ${fmtMbps(rx)} - TX ${fmtMbps(tx)}`)} - ${miniMetricHTML("Accounts", String(users.length + clients.length), Math.min(100, (users.length + clients.length) * 3), `SSH ${users.length} - Xray ${clients.length}`)} -
- -
`; -} - -function miniMetricHTML(label, value, pct, note) { - const width = Math.min(100, Math.max(0, Number(pct) || 0)); - return `
-
${escapeHTML(label)}
-
${escapeHTML(value)}
-
${escapeHTML(note || "")}
-
-
`; -} - -function renderServerSelectors() { - const active = serversCache.filter(s => s.is_active !== false); - const sshServers = active.filter(s => s.enable_ssh || s.is_local); - const xrayServers = active.filter(s => s.enable_xray || s.is_local); - populateServerSelect(sshServerSelect, sshServers, selectedSSHServerID, "ssh"); - populateServerSelect(xrayServerSelect, xrayServers, selectedXrayServerID, "xray"); - const hasMultiSSH = sshServers.length > 1; - const hasMultiXray = xrayServers.length > 1; - sshServerPickerCard?.classList.toggle("hidden", !hasMultiSSH); - xrayServerPickerCard?.classList.toggle("hidden", !hasMultiXray); - if (sshServerHint) { sshServerHint.textContent = ""; sshServerHint.classList.add("hidden"); } - if (xrayServerHint) { xrayServerHint.textContent = ""; xrayServerHint.classList.add("hidden"); } - if (dashServers) dashServers.textContent = String(active.length || 1); - if (dashServerStatus) dashServerStatus.textContent = active.length > 1 ? `${active.length} nodes configured` : "master only"; -} - -function populateServerSelect(select, list, selected, kind) { - if (!select) return; - const current = String(selected || select.value || "local"); - select.innerHTML = ""; - list.forEach(s => { - const opt = document.createElement("option"); - opt.value = String(s.id); - opt.textContent = `${s.name || s.base_url || s.id}${s.is_local ? " (master)" : ""}`; - select.appendChild(opt); - }); - const allowed = list.some(s => String(s.id) === current); - select.value = allowed ? current : "local"; - if (kind === "ssh") { - selectedSSHServerID = select.value || "local"; - localStorage.setItem("SSH_SERVER_ID", selectedSSHServerID); - } else { - selectedXrayServerID = select.value || "local"; - localStorage.setItem("XRAY_SERVER_ID", selectedXrayServerID); - } -} - -function renderServersTable() { - if (!serversBody) return; - const rows = serversCache || []; - serversCountChip && (serversCountChip.textContent = String(Math.max(0, rows.length - 1))); - serversBody.innerHTML = ""; - rows.forEach(s => { - const tr = document.createElement("tr"); - const opts = `${s.enable_ssh ? "SSH" : ""}${s.enable_ssh && s.enable_xray ? " / " : ""}${s.enable_xray ? "Xray" : ""}` || "disabled"; - tr.innerHTML = ` - ${escapeHTML(s.name || "—")}${s.is_local ? ' master' : ""} - ${escapeHTML(s.base_url || "local")} - ${escapeHTML(opts)} - ${s.is_active ? 'active' : 'disabled'}`; - const td = document.createElement("td"); - td.style.whiteSpace = "nowrap"; - const cfgBtn = document.createElement("button"); - cfgBtn.className = "btn btn-ghost btn-sm"; - cfgBtn.textContent = "Configure"; - cfgBtn.onclick = () => openManagedServerConfig(String(s.id)); - td.appendChild(cfgBtn); - if (!s.is_local) { - const editBtn = document.createElement("button"); - editBtn.className = "btn btn-warn btn-sm"; - editBtn.style.marginLeft = "4px"; - editBtn.textContent = "Edit"; - editBtn.onclick = () => fillServerForm(s); - const delBtn = document.createElement("button"); - delBtn.className = "btn btn-danger btn-sm"; - delBtn.style.marginLeft = "4px"; - delBtn.textContent = "Del"; - delBtn.onclick = () => deleteServer(s); - td.append(editBtn, delBtn); - } - tr.appendChild(td); - serversBody.appendChild(tr); - }); -} - -function clearServerForm() { - if (!serverForm) return; - srvID.value = ""; - srvName.value = ""; - srvBaseURL.value = ""; - srvAdminUser.value = "admin"; - srvAdminKey.value = ""; - srvEnableSSH.checked = true; - srvEnableXray.checked = true; - srvIsActive.checked = true; - if (serverFormTitle) serverFormTitle.textContent = "Add / edit server"; - if (serverFormStatus) serverFormStatus.textContent = ""; -} - -function fillServerForm(s) { - srvID.value = s.id || ""; - srvName.value = s.name || ""; - srvBaseURL.value = s.base_url || ""; - srvAdminUser.value = s.admin_username || "admin"; - srvAdminKey.value = ""; - srvEnableSSH.checked = !!s.enable_ssh; - srvEnableXray.checked = !!s.enable_xray; - srvIsActive.checked = s.is_active !== false; - if (serverFormTitle) serverFormTitle.textContent = "Edit: " + (s.name || s.base_url); - if (serverFormStatus) serverFormStatus.textContent = "Leave admin key blank to keep the saved key."; -} - -function serverPayloadFromForm() { - return { - id: srvID?.value || "", - name: srvName?.value.trim() || "", - base_url: srvBaseURL?.value.trim() || "", - admin_username: srvAdminUser?.value.trim() || "admin", - admin_key: srvAdminKey?.value || "", - enable_ssh: !!srvEnableSSH?.checked, - enable_xray: !!srvEnableXray?.checked, - is_active: !!srvIsActive?.checked, - }; -} - -async function saveServerForm() { - if (!serverFormStatus) return; - serverFormStatus.textContent = "Saving…"; - try { - const res = await api("/api/servers", { method:"POST", body: JSON.stringify(serverPayloadFromForm()) }); - if (!res.ok) throw new Error(await res.text()); - serverFormStatus.textContent = "Saved."; - clearServerForm(); - await loadServers(); - } catch (e) { - if (e.message === "auth") doAuthError(); - else serverFormStatus.textContent = "Error: " + e.message; - } -} - -async function testServerForm() { - if (!serverFormStatus) return; - serverFormStatus.textContent = "Testing remote login…"; - try { - const res = await api("/api/servers/test", { method:"POST", body: JSON.stringify(serverPayloadFromForm()) }); - if (!res.ok) throw new Error(await res.text()); - serverFormStatus.textContent = "Connection OK."; - } catch (e) { - if (e.message === "auth") doAuthError(); - else serverFormStatus.textContent = "Test failed: " + e.message; - } -} - -async function deleteServer(s) { - if (!confirm(`Delete server "${s.name || s.base_url}"?`)) return; - try { - const res = await api(`/api/servers?id=${encodeURIComponent(s.id)}`, { method:"DELETE" }); - if (!res.ok && res.status !== 204) throw new Error(await res.text()); - await loadServers(); - } catch (e) { - if (e.message === "auth") doAuthError(); - else serversStatus && (serversStatus.textContent = "Delete failed: " + e.message); - } -} - -function showServerListView() { - serversListView?.classList.remove("hidden"); - serverConfigSubpage?.classList.add("hidden"); - configuringServerID = ""; -} - -function openManagedServerConfig(id) { - configuringServerID = id || "local"; - const srv = serverByID(configuringServerID) || { name: "Master node" }; - if (cfgServerName) cfgServerName.textContent = srv.name || srv.base_url || configuringServerID; - serversListView?.classList.add("hidden"); - serverConfigSubpage?.classList.remove("hidden"); - loadManagedServerConfig(configuringServerID); -} - -function toggleManagedDnsttFields(on) { - const el = document.getElementById("managedDnsttFields"); - if (!el) return; - el.style.opacity = on ? "1" : ".4"; - el.style.pointerEvents = on ? "" : "none"; -} -function toggleManagedUdpgwFields(on) { - const el = document.getElementById("managedUdpgwFields"); - if (!el) return; - el.style.opacity = on ? "1" : ".4"; - el.style.pointerEvents = on ? "" : "none"; -} - -async function loadManagedServerConfig(id) { - if (!id) return; - const st = document.getElementById("managedConfigStatus"); - if (st) st.textContent = "Loading config…"; - try { - const res = await api(withServerParam("/api/servers/config", id)); - if (!res.ok) throw new Error(await res.text()); - const c = await res.json(); - - document.getElementById("managedCfgListen").value = c.listen || ""; - document.getElementById("managedCfgExtraListen").value = (c.extra_listen || []).join("\n"); - document.getElementById("managedCfgProxyAutoRestart").value = c.proxy_auto_restart_interval || ""; - document.getElementById("managedCfgProxyRestartGrace").value = c.proxy_auto_restart_grace || ""; - - document.getElementById("managedCfgLimitUp").value = c.default_limit_mbps_up || 0; - document.getElementById("managedCfgLimitDown").value = c.default_limit_mbps_down || 0; - document.getElementById("managedCfgQuiet").checked = !!c.quiet; - document.getElementById("managedCfgUserCount").checked = !!c.user_count; - document.getElementById("managedCfgBanner").value = c.banner || ""; - - const hasDnstt = !!c.dnstt; - document.getElementById("managedCfgDnsttEnabled").checked = hasDnstt; - toggleManagedDnsttFields(hasDnstt); - const d = c.dnstt || {}; - document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d); - document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || ""; - document.getElementById("managedCfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled; - document.getElementById("managedCfgDnsttFakeListen").value = d.fake_dns_listen || ""; - document.getElementById("managedCfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan"; - document.getElementById("managedCfgDnsttFakeWorkers").value = d.fake_dns_workers || 0; - document.getElementById("managedCfgDnsttRespWorkers").value = d.dns_response_workers || 0; - document.getElementById("managedCfgDnsttAutoRestart").value = d.auto_restart_interval || ""; - document.getElementById("managedCfgDnsttRestartGrace").value = d.auto_restart_grace || ""; - document.getElementById("managedCfgDnsttMaxSessions").value = d.max_sessions || 0; - document.getElementById("managedCfgDnsttMaxStreams").value = d.max_streams || 0; - document.getElementById("managedCfgDnsttPendingResponses").value = d.pending_responses || 0; - document.getElementById("managedCfgDnsttStreamBuffer").value = d.stream_buffer || 0; - document.getElementById("managedCfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0; - document.getElementById("managedCfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0; - document.getElementById("managedCfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key"; - document.getElementById("managedCfgDnsttNoStats").checked = !!d.disable_stats_log; - document.getElementById("managedCfgDnsttNoConsole").checked = !!d.disable_console_log; - document.getElementById("managedCfgDnsttLogConnections").checked = !!d.log_connections; - - const hasUdpgw = !!c.udpgw; - document.getElementById("managedCfgUdpgwEnabled").checked = hasUdpgw; - toggleManagedUdpgwFields(hasUdpgw); - const u = c.udpgw || {}; - document.getElementById("managedCfgUdpgwListen").value = u.listen || ""; - document.getElementById("managedCfgUdpgwMaxConns").value = u.max_client_conns || 0; - document.getElementById("managedCfgUdpgwIdle").value = u.idle_timeout || ""; - document.getElementById("managedCfgUdpgwMapTTL").value = u.map_ttl || ""; - document.getElementById("managedCfgUdpgwAutoRestart").value = u.auto_restart_interval || ""; - document.getElementById("managedCfgUdpgwRestartGrace").value = u.auto_restart_grace || ""; - document.getElementById("managedCfgUdpgwDebug").checked = !!u.debug; - - managedTlsForwardersState = c.tls_forwarders || []; - renderManagedTLSForwarders(); - - const x = c.xray || {}; - document.getElementById("managedCfgXrayEnabled").checked = !!x.enabled; - document.getElementById("managedCfgXrayMode").value = xrayModeFromConfig(x); - - document.getElementById("managedDnsttPubkeyWrap")?.classList.add("hidden"); - if (st) st.textContent = "Config loaded."; - } catch (e) { - if (e.message === "auth") doAuthError(); - else if (st) st.textContent = "Error: " + e.message; - } -} - -function managedConfigFromForm() { - const extraLines = document.getElementById("managedCfgExtraListen").value - .split("\n").map(s => s.trim()).filter(Boolean); - const dnsttDomains = readDnsttDomains("managedCfgDnsttDomains"); - return { - listen: document.getElementById("managedCfgListen").value.trim(), - extra_listen: extraLines, - proxy_auto_restart_interval: document.getElementById("managedCfgProxyAutoRestart").value.trim(), - proxy_auto_restart_grace: document.getElementById("managedCfgProxyRestartGrace").value.trim(), - host_key_file: "/opt/sshpanel/ssh_host_rsa_key", - admin_dir: "/opt/sshpanel/admin", - default_limit_mbps_up: parseInt(document.getElementById("managedCfgLimitUp").value || "0", 10), - default_limit_mbps_down: parseInt(document.getElementById("managedCfgLimitDown").value || "0", 10), - quiet: document.getElementById("managedCfgQuiet").checked, - user_count: document.getElementById("managedCfgUserCount").checked, - banner: document.getElementById("managedCfgBanner").value, - banner_file: "/opt/sshpanel/banner.txt", - dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? { - domain: dnsttDomains[0] || "", - domains: dnsttDomains, - udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim(), - fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked, - fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(), - fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(), - fake_dns_workers: parseInt(document.getElementById("managedCfgDnsttFakeWorkers").value || "0", 10), - dns_response_workers: parseInt(document.getElementById("managedCfgDnsttRespWorkers").value || "0", 10), - auto_restart_interval: document.getElementById("managedCfgDnsttAutoRestart").value.trim(), - auto_restart_grace: document.getElementById("managedCfgDnsttRestartGrace").value.trim(), - max_sessions: parseInt(document.getElementById("managedCfgDnsttMaxSessions").value || "0", 10), - max_streams: parseInt(document.getElementById("managedCfgDnsttMaxStreams").value || "0", 10), - pending_responses: parseInt(document.getElementById("managedCfgDnsttPendingResponses").value || "0", 10), - stream_buffer: parseInt(document.getElementById("managedCfgDnsttStreamBuffer").value || "0", 10), - udp_read_buffer: parseInt(document.getElementById("managedCfgDnsttUDPReadBuffer").value || "0", 10), - udp_write_buffer: parseInt(document.getElementById("managedCfgDnsttUDPWriteBuffer").value || "0", 10), - privkey_file: document.getElementById("managedCfgDnsttKey").value.trim(), - disable_stats_log: document.getElementById("managedCfgDnsttNoStats").checked, - disable_console_log: document.getElementById("managedCfgDnsttNoConsole").checked, - log_connections: document.getElementById("managedCfgDnsttLogConnections").checked, - } : null, - udpgw: document.getElementById("managedCfgUdpgwEnabled").checked ? { - listen: document.getElementById("managedCfgUdpgwListen").value.trim(), - max_client_conns: parseInt(document.getElementById("managedCfgUdpgwMaxConns").value || "0", 10), - idle_timeout: document.getElementById("managedCfgUdpgwIdle").value.trim(), - map_ttl: document.getElementById("managedCfgUdpgwMapTTL").value.trim(), - auto_restart_interval: document.getElementById("managedCfgUdpgwAutoRestart").value.trim(), - auto_restart_grace: document.getElementById("managedCfgUdpgwRestartGrace").value.trim(), - debug: document.getElementById("managedCfgUdpgwDebug").checked, - } : null, - tls_forwarders: managedTlsForwardersState, - xray: { - enabled: document.getElementById("managedCfgXrayEnabled").checked, - mode: document.getElementById("managedCfgXrayMode").value === "external" ? "external" : "native", - native: document.getElementById("managedCfgXrayMode").value !== "external", - bin_path: "/opt/sshpanel/xray", - config_file: "/opt/sshpanel/xray_config.json", - native_config_file: "/opt/sshpanel/xray_native_config.json", - api_server: "127.0.0.1:10085", - online_window_seconds: 90, - stats_poll_seconds: 15, - }, - }; -} - -async function saveManagedServerConfig() { - if (!configuringServerID) return; - const st = document.getElementById("managedConfigStatus"); - if (st) st.textContent = "Saving config…"; - try { - const cfg = managedConfigFromForm(); - const res = await api(withServerParam("/api/servers/config", configuringServerID), { 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(" | "); - if (st) st.textContent = "Saved live with warnings: " + [...warnings, badText].filter(Boolean).join(" | "); - } else if (st) { - st.textContent = "Saved and applied live."; - } - } catch (e) { - if (e.message === "auth") doAuthError(); - else if (st) st.textContent = "Error: " + e.message; - } -} - -function renderManagedTLSForwarders() { - const list = document.getElementById("managedTlsForwardersList"); - const chip = document.getElementById("managedTlsCountChip"); - if (!list) return; - if (chip) chip.textContent = managedTlsForwardersState.length; - if (!managedTlsForwardersState.length) { - list.innerHTML = '
No TLS forwarders configured.
'; - return; - } - list.innerHTML = ""; - managedTlsForwardersState.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 = `${escapeHTML(fw.listen || "")} - ${escapeHTML(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 = () => { managedTlsForwardersState.splice(i,1); renderManagedTLSForwarders(); }; - row.appendChild(delBtn); - list.appendChild(row); - }); -} - -function toggleManagedAddTLSForm() { - const panel = document.getElementById("managedAddTLSPanel"); - if (!panel) return; - panel.classList.toggle("hidden"); - if (!panel.classList.contains("hidden")) { - document.getElementById("managedTlsAddStatus").textContent = ""; - document.getElementById("managedTlsListenAddr").value = ""; - document.getElementById("managedTlsSSLDomain").value = ""; - document.getElementById("managedTlsCertType").value = "selfsigned"; - onManagedTLSTypeChange("selfsigned"); - } -} - -function onManagedTLSTypeChange(val) { - const setVisible = (id, on, display = "") => { - const el = document.getElementById(id); - if (!el) return; - el.classList.toggle("hidden", !on); - el.style.display = on ? display : "none"; - }; - setVisible("managedTlsSSFields", val === "selfsigned", ""); - setVisible("managedTlsLEFields", val === "letsencrypt", "grid"); - setVisible("managedTlsPasteFields", val === "paste", ""); - setVisible("managedTlsCustomFields", val === "custom", "grid"); -} - -async function addManagedTLSForwarder() { - const st = document.getElementById("managedTlsAddStatus"); - const listen = document.getElementById("managedTlsListenAddr").value.trim(); - const certType = document.getElementById("managedTlsCertType").value; - if (!listen) { st.textContent = "Listen address required."; return; } - let certFile = "", keyFile = ""; - st.textContent = "Processing…"; - if (certType === "selfsigned") { - const domain = document.getElementById("managedTlsSSLDomain").value.trim(); - if (!domain) { st.textContent = "Domain required."; return; } - try { - const res = await api(withServerParam("/api/tls/generate-selfsigned", configuringServerID), { 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("managedTlsLEDomain").value.trim(); - const email = document.getElementById("managedTlsLEEmail").value.trim(); - if (!domain || !email) { st.textContent = "Domain and email required."; return; } - st.textContent = "Running certbot… (may take ~30s)"; - try { - const res = await api(withServerParam("/api/tls/letsencrypt", configuringServerID), { 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("managedTlsPasteName").value.trim(); - const cert = document.getElementById("managedTlsPasteCert").value.trim(); - const key = document.getElementById("managedTlsPasteKey").value.trim(); - if (!name || !cert || !key) { st.textContent = "Name, cert PEM, and key PEM required."; return; } - try { - const res = await api(withServerParam("/api/tls/upload-pem", configuringServerID), { 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("managedTlsCustomCert").value.trim(); - keyFile = document.getElementById("managedTlsCustomKey").value.trim(); - if (!certFile || !keyFile) { st.textContent = "Cert and key paths required."; return; } - } - managedTlsForwardersState.push({ listen, cert_file: certFile, key_file: keyFile }); - renderManagedTLSForwarders(); - document.getElementById("managedAddTLSPanel").classList.add("hidden"); - st.textContent = "Added. Save config to apply."; -} - -async function generateManagedDnsttKey() { - const st = document.getElementById("managedDnsttKeyStatus"); - if (st) st.textContent = "Generating key…"; - try { - const res = await api(withServerParam("/api/dnstt/genkey", configuringServerID), { method:"POST" }); - if (!res.ok) throw new Error(await res.text()); - const data = await res.json(); - document.getElementById("managedCfgDnsttKey").value = data.privkey_file || "/opt/sshpanel/dnstt.key"; - if (st) st.textContent = "Key generated. Save config to apply."; - await loadManagedDnsttPubkey(); - } catch (e) { - if (e.message === "auth") doAuthError(); - else if (st) st.textContent = "Error: " + e.message; - } -} - -async function loadManagedDnsttPubkey() { - const st = document.getElementById("managedDnsttKeyStatus"); - if (st) st.textContent = "Loading public key…"; - try { - const res = await api(withServerParam("/api/dnstt/pubkey", configuringServerID)); - if (!res.ok) throw new Error(await res.text()); - const data = await res.json(); - const val = data.public_key || data.pubkey || ""; - document.getElementById("managedDnsttPubkeyVal").value = val; - document.getElementById("managedDnsttPubkeyWrap")?.classList.remove("hidden"); - if (st) st.textContent = val ? "Public key loaded." : "No public key returned."; - } catch (e) { - if (e.message === "auth") doAuthError(); - else if (st) st.textContent = "Error: " + e.message; - } -} - - -// ─── Stats ──────────────────────────────────────────────────────────────────── -document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats); - -async function loadDashboardStats() { - try { - const res = await api("/api/stats"); - if (!res.ok) throw new Error(await res.text()); - const s = await res.json(); - updateDashboardStats(s); - await loadDnsttHealth(); - } catch (e) { - if (e.message === "auth") doAuthError(); - else { - if (dashCpuVal) dashCpuVal.textContent = "erro"; - if (dashRamVal) dashRamVal.textContent = "erro"; - if (dashNetVal) dashNetVal.textContent = "erro"; - } - } -} - -function updateDashboardStats(s) { - if (!s) return; - const cpu = Number(s.cpu_percent ?? 0); - const mem = s.mem_percent == null ? null : Number(s.mem_percent); - if (dashCpuVal) dashCpuVal.textContent = fmtPct(cpu); - if (dashCpuBar) dashCpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%"; - if (dashCpuText) dashCpuText.textContent = cpu >= 85 ? "Carga alta" : cpu >= 60 ? "Carga moderada" : "Carga normal"; - if (dashRamVal) dashRamVal.textContent = mem == null ? "--%" : fmtPct(mem); - if (dashRamBar) dashRamBar.style.width = mem == null ? "0%" : Math.min(100, Math.max(0, mem)) + "%"; - if (dashRamText) { - const used = s.mem_used_bytes, total = s.mem_total_bytes; - dashRamText.textContent = used != null && total != null ? `${fmtBytes(used)} / ${fmtBytes(total)}` : "Memória usada"; - } - const ifaces = Array.isArray(s.interfaces) ? s.interfaces : []; - let rx = 0, tx = 0, rxTotal = 0, txTotal = 0; - ifaces.forEach(it => { - rx += Number(it.rx_mbps || 0); - tx += Number(it.tx_mbps || 0); - rxTotal += Number(it.rx_bytes || 0); - txTotal += Number(it.tx_bytes || 0); - }); - if (dashNetVal) dashNetVal.textContent = `${fmtMbps(rx + tx)} Mb/s`; - if (dashNetText) dashNetText.textContent = `RX ${fmtMbps(rx)} · TX ${fmtMbps(tx)} Mb/s`; - if (dashNetTotal) dashNetTotal.textContent = `Total ${fmtBytes(rxTotal + txTotal)}`; -} - -async function loadDnsttHealth() { - if (!dnsttDashboardCard && !dnsttHealthBody && !dnsttActiveSessions) return; - if (currentRole !== "superadmin") { - dnsttDashboardCard?.classList.add("hidden"); - return; - } - try { - const res = await api("/api/dnstt"); - if (!res.ok) throw new Error(await res.text()); - const d = await res.json(); - const enabled = d.enabled !== false; - if (dnsttDashboardCard) dnsttDashboardCard.classList.toggle("hidden", !enabled); - if (!enabled) return; - - if (dnsttActiveSessions) dnsttActiveSessions.textContent = fmtInt(d.active_sessions); - if (dnsttActiveStreams) dnsttActiveStreams.textContent = fmtInt(d.active_streams); - if (dnsttDNSRx) dnsttDNSRx.textContent = fmtInt(d.dns_rx); - if (dnsttQueueLen) dnsttQueueLen.textContent = fmtInt(d.ch_len); - if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = d.running === false ? "DNSTT stopped" : fmtDnsttTimestamp(d.timestamp); - - const rows = [ - ["Session rejected", d.sess_rejected], - ["Stream rejected", d.stream_rejected], - ["DNS parse errors", d.parse_err], - ["No EDNS", d.no_edns], - ["EDNS limit 512", d.limit512], - ["Local DNS workers", d.fake_dns_workers], - ["Response workers", d.dns_response_workers], - ["Responses queued", d.rec_queued], - ["Responses dropped", d.rec_dropped], - ["Responses sent", d.resp_sent], - ["Response bytes", d.resp_bytes], - ["Empty responses", d.resp_empty], - ["Data responses", d.resp_data], - ["Oversize responses", d.resp_oversize], - ["KCP sessions new", d.kcp_new], - ["KCP sessions ended", d.kcp_end], - ["SMUX streams new", d.smux_new], - ["SMUX streams ended", d.smux_end], - ["Panic recovered", d.panic_recovered], - ]; - if (dnsttHealthBody) { - dnsttHealthBody.innerHTML = ""; - for (let i = 0; i < rows.length; i += 2) { - const a = rows[i]; - const b = rows[i + 1] || ["", ""]; - const tr = document.createElement("tr"); - tr.innerHTML = `${a[0]}${fmtInt(a[1])}${b[0]}${b[0] ? fmtInt(b[1]) : ""}`; - dnsttHealthBody.appendChild(tr); - } - } - const bad = Number(d.sess_rejected || 0) + Number(d.stream_rejected || 0) + Number(d.rec_dropped || 0) + Number(d.panic_recovered || 0); - if (dnsttHealthSummary) { - if (d.running === false) { - dnsttHealthSummary.textContent = "DNSTT is enabled but not running. Check key/domain/listen config or recent logs."; - } else { - dnsttHealthSummary.textContent = bad > 0 - ? `Attention: ${fmtInt(bad)} overload/recovery events in the last DNSTT stats window.` - : "DNSTT health OK: no rejects, drops, or recovered panics in the last stats window."; - } - } - } catch (e) { - if (e.message === "auth") throw e; - dnsttDashboardCard?.classList.add("hidden"); - if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = "Error loading DNSTT stats."; - if (dnsttHealthSummary) dnsttHealthSummary.textContent = e.message || "DNSTT stats unavailable."; - } -} - - -async function loadStats() { - try { - const res = await api("/api/stats"); - if (!res.ok) throw new Error(await res.text()); - const s = await res.json(); - updateDashboardStats(s); - const cpu = Number(s?.cpu_percent ?? 0); - if (cpuVal) cpuVal.textContent = fmtPct(cpu); - if (cpuBar) cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%"; - const mp = s?.mem_percent == null ? null : Number(s.mem_percent); - if (memVal) memVal.textContent = mp == null ? "--%" : fmtPct(mp); - if (memBar) memBar.style.width = mp == null ? "0%" : Math.min(100, Math.max(0, mp)) + "%"; - const mu = s?.mem_used_bytes, mt = s?.mem_total_bytes; - if (memDetail) memDetail.textContent = (mu != null && mt != null) ? `${fmtBytes(mu)} / ${fmtBytes(mt)}` : ""; - const ifaces = Array.isArray(s.interfaces) ? s.interfaces : []; - if (ifaceBody) ifaceBody.innerHTML = ""; - let totRx = 0, totTx = 0; - ifaces.forEach(it => { - totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0); - if (!ifaceBody) return; - 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); - }); - if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; - if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString(); - await loadDnsttHealth(); - } catch (e) { - if (e.message==="auth") doAuthError(); - else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats."; - } -} - -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 dnsttDomainsText(d) { - const domains = Array.isArray(d?.domains) && d.domains.length ? d.domains : (d?.domain ? [d.domain] : []); - return domains.join("\n"); -} -function readDnsttDomains(id) { - const el = document.getElementById(id); - if (!el) return []; - const seen = new Set(); - return el.value.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\.$/, "").toLowerCase()).filter(s => { - if (seen.has(s)) return false; - seen.add(s); - return true; - }); -} - -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"; -} - - -const XRAY_NATIVE_TUNING_DEFAULTS = { - safe: { - mux_max_sessions: 64, - mux_global_sessions: 8192, - mux_udp_idle_ms: 30000, - mux_udp_read_buffer: 131072, - mux_udp_write_buffer: 131072, - xhttp_max_sessions: 4096, - xhttp_buffered_posts: 32, - xhttp_queue_timeout_ms: 500, - xhttp_flush_ms: 2, - xhttp_flush_bytes: 32768, - h2_max_concurrent_streams: 256, - h2_upload_buffer_conn: 1048576, - h2_upload_buffer_stream: 262144, - trace_packets: false, - }, - "2k": { - mux_max_sessions: 128, - mux_global_sessions: 32768, - mux_udp_idle_ms: 15000, - mux_udp_read_buffer: 262144, - mux_udp_write_buffer: 262144, - xhttp_max_sessions: 16384, - xhttp_buffered_posts: 64, - xhttp_queue_timeout_ms: 250, - xhttp_flush_ms: 5, - xhttp_flush_bytes: 131072, - h2_max_concurrent_streams: 1024, - h2_upload_buffer_conn: 1048576, - h2_upload_buffer_stream: 262144, - trace_packets: false, - }, -}; - -const XRAY_NATIVE_TUNING_FIELDS = { - mux_max_sessions: "cfgXrayMuxMaxSessions", - mux_global_sessions: "cfgXrayMuxGlobalSessions", - mux_udp_idle_ms: "cfgXrayMuxUdpIdleMs", - mux_udp_read_buffer: "cfgXrayMuxUdpRbuf", - mux_udp_write_buffer: "cfgXrayMuxUdpWbuf", - xhttp_max_sessions: "cfgXrayXhttpMaxSessions", - xhttp_buffered_posts: "cfgXrayXhttpBufferedPosts", - xhttp_queue_timeout_ms: "cfgXrayXhttpQueueTimeoutMs", - xhttp_flush_ms: "cfgXrayXhttpFlushMs", - xhttp_flush_bytes: "cfgXrayXhttpFlushBytes", - h2_max_concurrent_streams: "cfgXrayH2MaxStreams", - h2_upload_buffer_conn: "cfgXrayH2UploadConn", - h2_upload_buffer_stream: "cfgXrayH2UploadStream", -}; - -function setXrayNativeTuningDefaults(profile = "2k") { - const t = XRAY_NATIVE_TUNING_DEFAULTS[profile] || XRAY_NATIVE_TUNING_DEFAULTS.safe; - writeXrayNativeTuning(t); -} - -function writeXrayNativeTuning(t = {}) { - const defaults = XRAY_NATIVE_TUNING_DEFAULTS.safe; - Object.entries(XRAY_NATIVE_TUNING_FIELDS).forEach(([key, id]) => { - const el = document.getElementById(id); - if (el) el.value = Number(t[key] || defaults[key] || 0); - }); - const trace = document.getElementById("cfgXrayTracePackets"); - if (trace) trace.checked = !!t.trace_packets; -} - -function readXrayNativeTuning() { - const out = {}; - Object.entries(XRAY_NATIVE_TUNING_FIELDS).forEach(([key, id]) => { - const el = document.getElementById(id); - out[key] = parseInt(el?.value || "0", 10) || 0; - }); - out.trace_packets = !!document.getElementById("cfgXrayTracePackets")?.checked; - return out; -} - -// Inline onclick handlers in index.html need this on window when the bundled JS is served from admin/assets/app.js. -window.setXrayNativeTuningDefaults = setXrayNativeTuningDefaults; - -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"); - document.getElementById("cfgProxyAutoRestart").value = c.proxy_auto_restart_interval || ""; - document.getElementById("cfgProxyRestartGrace").value = c.proxy_auto_restart_grace || ""; - - // 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("cfgDnsttDomains").value = dnsttDomainsText(d); - document.getElementById("cfgDnsttUDP").value = d.udp_listen || ""; - document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled; - document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || ""; - document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan"; - document.getElementById("cfgDnsttFakeWorkers").value = d.fake_dns_workers || 0; - document.getElementById("cfgDnsttRespWorkers").value = d.dns_response_workers || 0; - document.getElementById("cfgDnsttAutoRestart").value = d.auto_restart_interval || ""; - document.getElementById("cfgDnsttRestartGrace").value = d.auto_restart_grace || ""; - document.getElementById("cfgDnsttMaxSessions").value = d.max_sessions || 0; - document.getElementById("cfgDnsttMaxStreams").value = d.max_streams || 0; - document.getElementById("cfgDnsttPendingResponses").value = d.pending_responses || 0; - document.getElementById("cfgDnsttStreamBuffer").value = d.stream_buffer || 0; - document.getElementById("cfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0; - document.getElementById("cfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0; - 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; - document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections; - - // 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("cfgUdpgwAutoRestart").value = u.auto_restart_interval || ""; - document.getElementById("cfgUdpgwRestartGrace").value = u.auto_restart_grace || ""; - document.getElementById("cfgUdpgwDebug").checked = !!u.debug; - - // TLS forwarders - tlsForwardersState = c.tls_forwarders || []; - renderTLSForwarders(); - - // Xray - const x = c.xray || {}; - document.getElementById("cfgXrayEnabled").checked = !!x.enabled; - document.getElementById("cfgXrayMode").value = xrayModeFromConfig(x); - writeXrayNativeTuning(x.native_tuning || XRAY_NATIVE_TUNING_DEFAULTS.safe); - - 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 dnsttDomains = readDnsttDomains("cfgDnsttDomains"); - - const cfg = { - listen: document.getElementById("cfgListen").value.trim(), - extra_listen: extraLines, - proxy_auto_restart_interval: document.getElementById("cfgProxyAutoRestart").value.trim(), - proxy_auto_restart_grace: document.getElementById("cfgProxyRestartGrace").value.trim(), - 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: dnsttDomains[0] || "", - domains: dnsttDomains, - udp_listen: document.getElementById("cfgDnsttUDP").value.trim(), - fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked, - fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(), - fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(), - fake_dns_workers: parseInt(document.getElementById("cfgDnsttFakeWorkers").value || "0", 10), - dns_response_workers: parseInt(document.getElementById("cfgDnsttRespWorkers").value || "0", 10), - auto_restart_interval: document.getElementById("cfgDnsttAutoRestart").value.trim(), - auto_restart_grace: document.getElementById("cfgDnsttRestartGrace").value.trim(), - max_sessions: parseInt(document.getElementById("cfgDnsttMaxSessions").value || "0", 10), - max_streams: parseInt(document.getElementById("cfgDnsttMaxStreams").value || "0", 10), - pending_responses: parseInt(document.getElementById("cfgDnsttPendingResponses").value || "0", 10), - stream_buffer: parseInt(document.getElementById("cfgDnsttStreamBuffer").value || "0", 10), - udp_read_buffer: parseInt(document.getElementById("cfgDnsttUDPReadBuffer").value || "0", 10), - udp_write_buffer: parseInt(document.getElementById("cfgDnsttUDPWriteBuffer").value || "0", 10), - privkey_file: document.getElementById("cfgDnsttKey").value.trim(), - disable_stats_log: document.getElementById("cfgDnsttNoStats").checked, - disable_console_log: document.getElementById("cfgDnsttNoConsole").checked, - log_connections: document.getElementById("cfgDnsttLogConnections").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(), - auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart").value.trim(), - auto_restart_grace: document.getElementById("cfgUdpgwRestartGrace").value.trim(), - debug: document.getElementById("cfgUdpgwDebug").checked, - } : null, - tls_forwarders: tlsArr, - xray: { - enabled: document.getElementById("cfgXrayEnabled").checked, - mode: document.getElementById("cfgXrayMode").value === "external" ? "external" : "native", - native: document.getElementById("cfgXrayMode").value !== "external", - bin_path: "/opt/sshpanel/xray", - config_file: "/opt/sshpanel/xray_config.json", - native_config_file: "/opt/sshpanel/xray_native_config.json", - api_server: "127.0.0.1:10085", - online_window_seconds: 90, - stats_poll_seconds: 15, - native_tuning: readXrayNativeTuning(), - }, - }; - - 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) { - const setVisible = (id, on, display = "") => { - const el = document.getElementById(id); - if (!el) return; - el.classList.toggle("hidden", !on); - el.style.display = on ? display : "none"; - }; - setVisible("tlsSSFields", val === "selfsigned", ""); - setVisible("tlsLEFields", val === "letsencrypt", "grid"); - setVisible("tlsPasteFields", val === "paste", ""); - setVisible("tlsCustomFields", val === "custom", "grid"); -} - -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), - server_id: selectedXrayServer(), - }; - 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({ force: true }); }, 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(); - } -} - -document.getElementById("wzLogLevel")?.addEventListener("change", () => { wzDirty = true; }); - -function cloneJsonSafe(obj) { - return obj && typeof obj === "object" ? JSON.parse(JSON.stringify(obj)) : obj; -} - -function loadWizardFromConfig() { - const serverID = selectedXrayServer(); - const target = selectedXrayServerLabel(); - const st = document.getElementById("wzStatus"); - wzLoadedServerID = null; - wzDirty = false; - if (st) st.textContent = `Loading config from ${target}...`; - api(withServerParam("/api/xray/config", serverID)).then(async res => { - if (!res.ok) throw new Error(await res.text()); - const raw = await res.text(); - const cfg = JSON.parse(raw); - wzLoadedServerID = serverID || "local"; - wzLoadedConfigText = raw; - wzLoadedFullConfig = cloneJsonSafe(cfg); - document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning"; - wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || []; - renderWzInbounds(); - wzDirty = false; - if (st) st.textContent = `Config loaded from ${target}.`; - }).catch(e => { - wzLoadedServerID = null; - wzLoadedConfigText = ""; - wzLoadedFullConfig = null; - wzInbounds = []; - renderWzInbounds(); - if (e.message === "auth") doAuthError(); - else if (st) st.textContent = "Error: " + e.message; - }); -} - -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); wzDirty = true; 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() || "/xhttp", - 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); - wzDirty = true; - renderWzInbounds(); - document.getElementById("wzAddInboundForm").classList.add("hidden"); - document.getElementById("wzPort").value = ""; - document.getElementById("wzTag").value = ""; - document.getElementById("wzListenIP").value = ""; -} - - -function buildConfigFromVisualEditor() { - const selectedID = selectedXrayServer() || "local"; - if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) { - throw new Error("config for this server is not loaded yet"); - } - - let cfg; - try { - cfg = JSON.parse(wzLoadedConfigText); - } catch (_) { - cfg = cloneJsonSafe(wzLoadedFullConfig || {}); - } - if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) { - throw new Error("loaded config is not an object"); - } - - // Preserve the selected server's full JSON exactly as the base. - // The visual tab is intentionally conservative: it only updates fields that - // are visible here, so pressing Save cannot wipe routing/outbounds/policy/etc. - cfg.log = cfg.log && typeof cfg.log === "object" ? cfg.log : {}; - cfg.log.loglevel = document.getElementById("wzLogLevel")?.value || cfg.log.loglevel || "warning"; - - const existingInbounds = Array.isArray(cfg.inbounds) ? cfg.inbounds : []; - const hiddenApiInbounds = existingInbounds.filter(ib => ib && ib.tag === "api"); - const visualInbounds = cloneJsonSafe((wzInbounds || []).filter(ib => ib && ib.tag !== "api")) || []; - cfg.inbounds = [...hiddenApiInbounds, ...visualInbounds]; - - return cfg; -} - -function updateFullConfigFromWizard() { - const cfg = buildConfigFromVisualEditor(); - wzLoadedFullConfig = cloneJsonSafe(cfg); - return cfg; -} - -async function applyWizardConfig() { - const st = document.getElementById("wzStatus"); - const target = selectedXrayServerLabel(); - const selectedID = selectedXrayServer() || "local"; - - if (String(wzLoadedServerID || "") !== String(selectedID) || !wzLoadedConfigText) { - if (st) st.textContent = `Reloading config from ${target} before saving...`; - loadWizardFromConfig(); - return; - } - - let cfg; - try { - cfg = buildConfigFromVisualEditor(); - } catch(e) { - if (st) st.textContent = `Invalid visual config: ${e.message}`; - return; - } - - if (st) st.textContent = `Saving config to ${target}...`; - try { - const body = JSON.stringify(cfg, null, 2); - const res = await api(withServerParam("/api/xray/config", selectedID), { method:"POST", body }); - if (!res.ok) throw new Error(await res.text()); - wzLoadedConfigText = body; - wzLoadedFullConfig = cloneJsonSafe(cfg); - wzLoadedServerID = selectedID; - wzDirty = false; - if (st) st.textContent = `Saved on ${target}. Restarting Xray...`; - await xrayCtrl("restart"); - if (st) st.textContent = `Config saved on ${target} and Xray restarted.`; - setTimeout(() => { loadXrayStatus(); loadInbounds({ force: true }); }, 700); - } catch (e) { - if (e.message==="auth") doAuthError(); - else if (st) 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 = t("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/assets/js/01-core.js b/admin/assets/js/01-core.js new file mode 100644 index 0000000..d8d01b4 --- /dev/null +++ b/admin/assets/js/01-core.js @@ -0,0 +1,551 @@ + +// ─── State ─────────────────────────────────────────────────────────────────── +let sessionToken = localStorage.getItem("SESSION_TOKEN") || ""; +let currentRole = ""; +let currentUser = ""; +let statsTimer = null, usersTimer = null, xrayTimer = null; +let formCollapsed = true; +let tlsForwardersState = []; +let managedTlsForwardersState = []; +let editingXrayClientId = null; +let wzInbounds = []; +let wzLoadedFullConfig = null; +let wzLoadedConfigText = ""; +let wzLoadedServerID = null; +let wzDirty = false; +let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null }; +let currentTab = "dashboard"; +let inboundsRefreshInFlight = false; +let lastInboundsStructure = ""; +let serversCache = []; +let selectedSSHServerID = localStorage.getItem("SSH_SERVER_ID") || "local"; +let selectedXrayServerID = localStorage.getItem("XRAY_SERVER_ID") || "local"; +let configuringServerID = ""; + + +// ─── Language / i18n ───────────────────────────────────────────────────────── +const SUPPORTED_LANGS = ["pt-BR", "en-US"]; +const LANG_STORAGE_KEY = "PANEL_LANG"; +const I18N_TEXT = { + "en-US": { + "Dashboard":"Dashboard","Overview":"Overview","Accounts":"Accounts","Administration":"Administration","Server":"Server","System":"System","Settings":"Settings","Traffic":"Traffic","Monitoring":"Monitoring", + "SSH / SlowDNS":"SSH / SlowDNS","Xray Users":"Xray Users","Resellers":"Resellers","Logs":"Logs","VnStat":"VnStat","VPN Control":"VPN Control","DragonCore":"DragonCore", + "SSH Panel":"SSH Panel","Sign in with your admin or reseller credentials.":"Sign in with your admin or reseller credentials.","Username":"Username","Password":"Password","Sign in":"Sign in","Logout":"Logout","Open menu":"Open menu","Toggle theme":"Toggle theme","Language":"Language", + "Total accounts":"Total accounts","active":"active","expired":"expired","available limit":"Available limit","Loading quota…":"Loading quota…","Active connections":"Active connections","SSH + Xray online now":"SSH + Xray online now","Ready for resellers":"Ready for resellers","Server monitoring in real time":"real-time monitoring","CPU":"CPU","RAM":"RAM","Network":"Network","Processor load":"Processor load","Memory used":"Memory used","Total":"Total","Total --":"Total --","RX -- · TX -- Mb/s":"RX -- · TX -- Mb/s", + "Quick actions":"Quick actions","simple":"simple","Create SSH":"Create SSH","Create Xray":"Create Xray","New reseller":"New reseller","Configure services":"Configure services","User, password, expiry and limit.":"User, password, expiry and limit.","UUID, label, expiry and connections.":"UUID, label, expiry and connections.","Plan, expiry and account limit.":"Plan, expiry and account limit.","Ports, DNSTT, UDPGW and TLS.":"Ports, DNSTT, UDPGW and TLS.","My quota":"My quota","Loading…":"Loading…","Loading...":"Loading...", + "My Account":"My Account","Users (used / max)":"Users (used / max)","Users (used/max)":"Users (used/max)","Expires":"Expires","Status":"Status","Users":"Users","User":"User","Auth":"Auth","Conn":"Conn","Max":"Max","Up":"Up","Dn":"Dn","Owner":"Owner","Actions":"Actions","Create / update user":"Create / update user","Create / edit user form":"Create / edit user form","Show form":"Show form","Hide form":"Hide form","TOTP Secret":"TOTP Secret","TOTP Period (s)":"TOTP Period (s)","TOTP Window":"TOTP Window","TOTP Digits":"TOTP Digits","Allow static password too":"Allow static password too","Max connections":"Max connections","Expires at":"Expires at","Max Upload (Mb/s)":"Max Upload (Mb/s)","Max Download (Mb/s)":"Max Download (Mb/s)","Save user":"Save user","Cancel":"Cancel","Gen":"Gen","Copy":"Copy","Edit":"Edit","Del":"Del","Reload":"Reload","Refresh":"Refresh","+ New":"+ New","+ Add":"+ Add","Add":"Add","Remove":"Remove", + "Running":"Running","Stopped":"Stopped","running":"running","stopped":"stopped","disabled":"disabled","Counters API":"Counters API","Repair counters":"Repair counters","Start":"Start","Stop":"Stop","Restart":"Restart","Inbounds & Clients":"Inbounds & Clients","Inbounds & clients":"Inbounds & clients","Xray Config":"Xray Config","Visual":"Visual","JSON":"JSON","Config editor":"Config editor","Load JSON":"Load JSON","Save & Restart":"Save & Restart","System Logs":"System Logs","last 200 lines":"last 200 lines","Xray clients":"Xray clients","Xray Core":"Xray Core","Enabled":"Enabled","Online":"Online","PID":"PID","Uptime":"Uptime","Counters API ready.":"Counters API ready.","Counters API ready at {server}.":"Counters API ready at {server}.","Online counters need Stats API repair.":"Online counters need Stats API repair.","Online counters: {error}":"Online counters: {error}","Needs repair":"Needs repair","OK":"OK", + "UUID":"UUID","Email":"Email","Email / label":"Email / label","Display Name":"Display Name","Expiry Date":"Expiry Date","Max Connections":"Max Connections","(0 = unlimited)":"(0 = unlimited)","auto-generate":"auto-generate","Name":"Name","Expiry":"Expiry","Online":"Online","Traffic":"Traffic","No clients.":"No clients.","No VLESS/VMess/Trojan inbounds found.":"No VLESS/VMess/Trojan inbounds found.","Add Client":"Add Client","+ Add Client":"+ Add Client","Copied client ID.":"Copied client ID.","UUID required.":"UUID required.","Client {id}… added. Restarting Xray…":"Client {id}… added. Restarting Xray…","Client removed. Restarting Xray…":"Client removed. Restarting Xray…","Remove client {id}… from {tag}?":"Remove client {id}… from {tag}?","New client data is available; editing was preserved.":"New client data is available; editing was preserved.","Config loaded.":"Config loaded.","Invalid JSON: {error}":"Invalid JSON: {error}","Saved. Restarting Xray…":"Saved. Restarting Xray…","Saved.":"Saved.","Saving…":"Saving…","Error: {error}":"Error: {error}","Error loading inbounds.":"Error loading inbounds.", + "Active":"Active","Suspended":"Suspended","Expired":"Expired","Unlimited":"Unlimited","No expiration":"No expiration","Idle":"idle","online":"online","offline":"offline","idle":"idle","ago":"ago","Active ({days}d)":"Active ({days}d)","No limit set by admin":"No limit set by admin","{remaining} accounts available · {pct}% used":"{remaining} accounts available · {pct}% used","{used} used · unlimited":"{used} used · unlimited","{used}/{max} used · {pct}% of plan":"{used}/{max} used · {pct}% of plan","SSH {ssh} · Xray {xray}":"SSH {ssh} · Xray {xray}","{ssh} SSH · {xray} Xray online":"{ssh} SSH · {xray} Xray online","{online} online · {active} active · {expired} expired · Core: {core}":"{online} online · {active} active · {expired} expired · Core: {core}","{count} online":"{count} online","{count} total · {active} active · {online} online":"{count} total · {active} active · {online} online", + "New user.":"New user.","TOTP secret generated.":"TOTP secret generated.","Loaded.":"Loaded.","Last reload: {time}":"Last reload: {time}","Error loading users.":"Error loading users.","Editing {name}":"Editing {name}","Deleting {name}…":"Deleting {name}…","Deleted.":"Deleted.","Error deleting.":"Error deleting.","Delete user \"{name}\"?":"Delete user \"{name}\"?","Invalid credentials.":"Invalid credentials.","Account suspended or expired.":"Account suspended or expired.","Login failed.":"Login failed.","Network error.":"Network error.","Session expired — please sign in again.":"Session expired — please sign in again.", + "Create Reseller":"Create Reseller","Create / edit reseller form":"Create / edit reseller form","Save reseller":"Save reseller","New reseller.":"New reseller.","Edit: {name}":"Edit: {name}","Editing {name}.":"Editing {name}.","Deleting {name}…":"Deleting {name}…","Error loading.":"Error loading.","Resellers list":"Resellers list","Max SSH users (0 = unlimited)":"Max SSH users (0 = unlimited)", + "Server Load":"Server Load","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Updated: {time}","Error loading stats.":"Error loading stats.","Normal load":"Normal load","Moderate load":"Moderate load","High load":"High load","Cleaning interface totals…":"Cleaning interface totals…","Interface totals cleaned. Auto-clean remains every 30 days.":"Interface totals cleaned. Auto-clean remains every 30 days.","Error cleaning totals: {error}":"Error cleaning totals: {error}", + "VnStat Usage":"VnStat Usage","Today total":"Today total","This month total":"This month total","Interfaces tracked":"Interfaces tracked","daily / monthly":"daily / monthly","Daily usage":"Daily usage","Monthly usage":"Monthly usage","Day":"Day","Month":"Month","Clean usage":"Clean usage","Clean VnStat history":"Clean VnStat history","VnStat history does not auto-clean. Use the button when you want to reset it.":"VnStat history does not auto-clean. Use the button when you want to reset it.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.","Loading VnStat usage…":"Loading VnStat usage…","VnStat history cleaned.":"VnStat history cleaned.","Error loading VnStat usage: {error}":"Error loading VnStat usage: {error}","Error cleaning VnStat history: {error}":"Error cleaning VnStat history: {error}", + "Panel / system":"Panel / system","Select a log source and click Refresh.":"Select a log source and click Refresh.","Clean panel log":"Clean panel log","No log lines yet.":"No log lines yet.","Panel log cleaned · {path} · max {max}":"Panel log cleaned · {path} · max {max}","Cleaning panel log…":"Cleaning panel log…", + "Network":"Network","Main Listen (SSH / HTTP)":"Main Listen (SSH / HTTP)","Extra Listen Addresses":"Extra Listen Addresses","(one per line, e.g. 0.0.0.0:8080)":"(one per line, e.g. 0.0.0.0:8080)","SSH & General":"SSH & General","Default Upload Limit (Mbps)":"Default Upload Limit (Mbps)","Default Download Limit (Mbps)":"Default Download Limit (Mbps)","Quiet Logs":"Quiet Logs","User Count Display":"User Count Display","SSH Banner":"SSH Banner","Banner Text":"Banner Text","(shown to connecting SSH clients)":"(shown to connecting SSH clients)","DNSTT Tunnel":"DNSTT Tunnel","Domain":"Domain","UDP Listen":"UDP Listen","Auto Restart Interval":"Auto Restart Interval","Restart Grace Delay":"Restart Grace Delay","0s/off disables":"0s/off disables","Private Key":"Private Key","Public Key":"Public Key","Disable Stats Log":"Disable Stats Log","Disable Console Log":"Disable Console Log","UDP Gateway":"UDP Gateway","Listen":"Listen","Idle Timeout":"Idle Timeout","Map TTL":"Map TTL","Debug Logging":"Debug Logging","TLS Forwarders":"TLS Forwarders","Listen Address":"Listen Address","Certificate":"Certificate","Generate Self-Signed":"Generate Self-Signed","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Paste PEM text","Custom file paths":"Custom file paths","Cert File":"Cert File","Key File":"Key File","Certificate PEM":"Certificate PEM","Private Key PEM":"Private Key PEM","Add Forwarder":"Add Forwarder","Save Config":"Save Config","All service changes apply live.":"All service changes apply live.","Saved and applied live.":"Saved and applied live.","Saved live with warnings: {warnings}":"Saved live with warnings: {warnings}","Processing…":"Processing…","Listen address required.":"Listen address required.","Domain required.":"Domain required.","Domain and email required.":"Domain and email required.","Cert and key paths required.":"Cert and key paths required.","Added. Save config to apply.":"Added. Save config to apply.","Generating…":"Generating…","Generated ✓ paths set.":"Generated ✓ paths set.","Generating key…":"Generating key…","Key generated. Save config to apply.":"Key generated. Save config to apply.","Loading public key…":"Loading public key…","Self-signed cert generated.":"Self-signed cert generated.","Let's Encrypt cert issued.":"Let's Encrypt cert issued.","PEM saved.":"PEM saved.","Saved ✓ paths set.":"Saved ✓ paths set.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Name, cert, and key required.":"Name, cert, and key required.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Save Changes":"Save Changes" + }, + "pt-BR": { + "Dashboard":"Painel","Overview":"Visão geral","Accounts":"Contas","Administration":"Administração","Server":"Servidor","System":"Sistema","Settings":"Configurações","Traffic":"Tráfego","Monitoring":"Monitoramento", + "SSH / SlowDNS":"SSH / SlowDNS","Xray Users":"Usuários Xray","Resellers":"Revendedores","Logs":"Logs","VnStat":"VnStat","VPN Control":"Controle VPN","DragonCore":"DragonCore", + "SSH Panel":"Painel SSH","Sign in with your admin or reseller credentials.":"Entre com suas credenciais de admin ou revendedor.","Username":"Usuário","Password":"Senha","Sign in":"Entrar","Logout":"Sair","Open menu":"Abrir menu","Toggle theme":"Alternar tema","Language":"Idioma", + "Total accounts":"Total de contas","active":"ativas","expired":"expiradas","available limit":"Limite disponível","Loading quota…":"Carregando cota…","Active connections":"Conexões ativas","SSH + Xray online now":"SSH + Xray online agora","Ready for resellers":"Pronto para revendedores","Server monitoring in real time":"monitoramento em tempo real","CPU":"CPU","RAM":"RAM","Network":"Rede","Processor load":"Carga do processador","Memory used":"Memória usada","Total":"Total","Total --":"Total --","RX -- · TX -- Mb/s":"RX -- · TX -- Mb/s", + "Quick actions":"Ações rápidas","simple":"simples","Create SSH":"Criar SSH","Create Xray":"Criar Xray","New reseller":"Novo revendedor","Configure services":"Configurar serviços","User, password, expiry and limit.":"Usuário, senha, validade e limite.","UUID, label, expiry and connections.":"UUID, label, validade e conexões.","Plan, expiry and account limit.":"Plano, validade e limite de contas.","Ports, DNSTT, UDPGW and TLS.":"Portas, DNSTT, UDPGW e TLS.","My quota":"Minha cota","Loading…":"Carregando…","Loading...":"Carregando...", + "My Account":"Minha conta","Users (used / max)":"Usuários (usado / máximo)","Users (used/max)":"Usuários (usado/máximo)","Expires":"Vence em","Status":"Status","Users":"Usuários","User":"Usuário","Auth":"Autenticação","Conn":"Conexões","Max":"Máximo","Up":"Upload","Dn":"Download","Owner":"Dono","Actions":"Ações","Create / update user":"Criar / atualizar usuário","Create / edit user form":"Formulário de criar / editar usuário","Show form":"Mostrar formulário","Hide form":"Ocultar formulário","TOTP Secret":"Segredo TOTP","TOTP Period (s)":"Período TOTP (s)","TOTP Window":"Janela TOTP","TOTP Digits":"Dígitos TOTP","Allow static password too":"Permitir senha estática também","Max connections":"Máx. conexões","Expires at":"Vence em","Max Upload (Mb/s)":"Upload máx. (Mb/s)","Max Download (Mb/s)":"Download máx. (Mb/s)","Save user":"Salvar usuário","Cancel":"Cancelar","Gen":"Gerar","Copy":"Copiar","Edit":"Editar","Del":"Excluir","Reload":"Recarregar","Refresh":"Atualizar","+ New":"+ Novo","+ Add":"+ Adicionar","Add":"Adicionar","Remove":"Remover", + "Running":"Rodando","Stopped":"Parado","running":"rodando","stopped":"parado","disabled":"desativado","Counters API":"API de contadores","Repair counters":"Reparar contadores","Start":"Iniciar","Stop":"Parar","Restart":"Reiniciar","Inbounds & Clients":"Inbounds e clientes","Inbounds & clients":"Inbounds e clientes","Xray Config":"Configuração Xray","Visual":"Visual","JSON":"JSON","Config editor":"Editor de configuração","Load JSON":"Carregar JSON","Save & Restart":"Salvar e reiniciar","System Logs":"Logs do sistema","last 200 lines":"últimas 200 linhas","Xray clients":"Clientes Xray","Xray Core":"Núcleo Xray","Enabled":"Ativado","Online":"Online","PID":"PID","Uptime":"Tempo ativo","Counters API ready.":"API de contadores pronta.","Counters API ready at {server}.":"API de contadores pronta em {server}.","Online counters need Stats API repair.":"Contadores online precisam de reparo da Stats API.","Online counters: {error}":"Contadores online: {error}","Needs repair":"Precisa de reparo","OK":"OK", + "UUID":"UUID","Email":"Email","Email / label":"Email / label","Display Name":"Nome de exibição","Expiry Date":"Data de vencimento","Max Connections":"Máx. conexões","(0 = unlimited)":"(0 = ilimitado)","auto-generate":"gerar automaticamente","Name":"Nome","Expiry":"Vencimento","Online":"Online","Traffic":"Tráfego","No clients.":"Nenhum cliente.","No VLESS/VMess/Trojan inbounds found.":"Nenhum inbound VLESS/VMess/Trojan encontrado.","Add Client":"Adicionar cliente","+ Add Client":"+ Adicionar cliente","Copied client ID.":"ID do cliente copiado.","UUID required.":"UUID obrigatório.","Client {id}… added. Restarting Xray…":"Cliente {id}… adicionado. Reiniciando Xray…","Client removed. Restarting Xray…":"Cliente removido. Reiniciando Xray…","Remove client {id}… from {tag}?":"Remover cliente {id}… de {tag}?","New client data is available; editing was preserved.":"Novos dados de cliente disponíveis; sua edição foi preservada.","Config loaded.":"Configuração carregada.","Invalid JSON: {error}":"JSON inválido: {error}","Saved. Restarting Xray…":"Salvo. Reiniciando Xray…","Saved.":"Salvo.","Saving…":"Salvando…","Error: {error}":"Erro: {error}","Error loading inbounds.":"Erro ao carregar inbounds.", + "Active":"Ativo","Suspended":"Suspenso","Expired":"Expirado","Unlimited":"Ilimitado","No expiration":"Sem vencimento","Idle":"ocioso","online":"online","offline":"offline","idle":"ocioso","ago":"atrás","Active ({days}d)":"Ativo ({days}d)","No limit set by admin":"Sem limite definido pelo admin","{remaining} accounts available · {pct}% used":"{remaining} contas disponíveis · {pct}% usado","{used} used · unlimited":"{used} usadas · sem limite","{used}/{max} used · {pct}% of plan":"{used}/{max} usadas · {pct}% do plano","SSH {ssh} · Xray {xray}":"SSH {ssh} · Xray {xray}","{ssh} SSH · {xray} Xray online":"{ssh} SSH · {xray} Xray online","{online} online · {active} active · {expired} expired · Core: {core}":"{online} online · {active} ativos · {expired} expirados · Core: {core}","{count} online":"{count} online","{count} total · {active} active · {online} online":"{count} total · {active} ativas · {online} online", + "New user.":"Novo usuário.","TOTP secret generated.":"Segredo TOTP gerado.","Loaded.":"Carregado.","Last reload: {time}":"Último reload: {time}","Error loading users.":"Erro ao carregar usuários.","Editing {name}":"Editando {name}","Deleting {name}…":"Excluindo {name}…","Deleted.":"Excluído.","Error deleting.":"Erro ao excluir.","Delete user \"{name}\"?":"Excluir usuário \"{name}\"?","Invalid credentials.":"Credenciais inválidas.","Account suspended or expired.":"Conta suspensa ou expirada.","Login failed.":"Falha no login.","Network error.":"Erro de rede.","Session expired — please sign in again.":"Sessão expirada — faça login novamente.", + "Create Reseller":"Criar revendedor","Create / edit reseller form":"Formulário de criar / editar revendedor","Save reseller":"Salvar revendedor","New reseller.":"Novo revendedor.","Edit: {name}":"Editar: {name}","Editing {name}.":"Editando {name}.","Deleting {name}…":"Excluindo {name}…","Error loading.":"Erro ao carregar.","Resellers list":"Lista de revendedores","Max SSH users (0 = unlimited)":"Máximo de usuários SSH (0 = ilimitado)", + "Server Load":"Carga do servidor","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Atualizado: {time}","Error loading stats.":"Erro ao carregar stats.","Normal load":"Carga normal","Moderate load":"Carga moderada","High load":"Carga alta","Cleaning interface totals…":"Limpando totais das interfaces…","Interface totals cleaned. Auto-clean remains every 30 days.":"Totais das interfaces limpos. A limpeza automática continua a cada 30 dias.","Error cleaning totals: {error}":"Erro ao limpar totais: {error}", + "VnStat Usage":"Uso do VnStat","Today total":"Total hoje","This month total":"Total este mês","Interfaces tracked":"Interfaces monitoradas","daily / monthly":"diário / mensal","Daily usage":"Uso diário","Monthly usage":"Uso mensal","Day":"Dia","Month":"Mês","Clean usage":"Limpar uso","Clean VnStat history":"Limpar histórico VnStat","VnStat history does not auto-clean. Use the button when you want to reset it.":"O histórico VnStat não é limpo automaticamente. Use o botão quando quiser resetar.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Os totais podem ser limpos aqui e têm limpeza automática a cada 30 dias. O histórico VnStat é separado.","Loading VnStat usage…":"Carregando uso do VnStat…","VnStat history cleaned.":"Histórico VnStat limpo.","Error loading VnStat usage: {error}":"Erro ao carregar uso do VnStat: {error}","Error cleaning VnStat history: {error}":"Erro ao limpar histórico VnStat: {error}", + "Panel / system":"Painel / sistema","Select a log source and click Refresh.":"Selecione uma fonte de log e clique em Atualizar.","Clean panel log":"Limpar log do painel","No log lines yet.":"Ainda não há linhas de log.","Panel log cleaned · {path} · max {max}":"Log do painel limpo · {path} · máx {max}","Cleaning panel log…":"Limpando log do painel…", + "Network":"Rede","Main Listen (SSH / HTTP)":"Listen principal (SSH / HTTP)","Extra Listen Addresses":"Endereços extras de listen","Proxy Auto Restart":"Reinício automático do proxy","Proxy Auto Restart Interval":"Intervalo de reinício automático do proxy","Proxy Restart Grace Delay":"Atraso para reiniciar proxy","(one per line, e.g. 0.0.0.0:8080)":"(um por linha, ex. 0.0.0.0:8080)","SSH & General":"SSH e geral","Default Upload Limit (Mbps)":"Limite padrão de upload (Mbps)","Default Download Limit (Mbps)":"Limite padrão de download (Mbps)","Quiet Logs":"Logs silenciosos","User Count Display":"Exibir contagem de usuários","SSH Banner":"Banner SSH","Banner Text":"Texto do banner","(shown to connecting SSH clients)":"(mostrado aos clientes SSH ao conectar)","DNSTT Tunnel":"Túnel DNSTT","Domain":"Domínio","UDP Listen":"Listen UDP","Auto Restart Interval":"Intervalo de reinício automático","Restart Grace Delay":"Atraso para reiniciar","0s/off disables":"0s/off desativa","Private Key":"Chave privada","Public Key":"Chave pública","Disable Stats Log":"Desativar log de stats","Disable Console Log":"Desativar log do console","UDP Gateway":"Gateway UDP","Listen":"Listen","Idle Timeout":"Timeout ocioso","Map TTL":"TTL do mapa","Debug Logging":"Log de debug","TLS Forwarders":"Encaminhadores TLS","Listen Address":"Endereço de listen","Certificate":"Certificado","Generate Self-Signed":"Gerar autoassinado","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Colar texto PEM","Custom file paths":"Caminhos personalizados","Cert File":"Arquivo cert","Key File":"Arquivo key","Certificate PEM":"Certificado PEM","Private Key PEM":"Chave privada PEM","Add Forwarder":"Adicionar forwarder","Save Config":"Salvar config","All service changes apply live.":"Todas as mudanças de serviço aplicam ao vivo.","Saved and applied live.":"Salvo e aplicado ao vivo.","Saved live with warnings: {warnings}":"Salvo ao vivo com avisos: {warnings}","Processing…":"Processando…","Listen address required.":"Endereço de listen obrigatório.","Domain required.":"Domínio obrigatório.","Domain and email required.":"Domínio e email obrigatórios.","Cert and key paths required.":"Caminhos do certificado e da chave obrigatórios.","Added. Save config to apply.":"Adicionado. Salve a config para aplicar.","Generating…":"Gerando…","Generated ✓ paths set.":"Gerado ✓ caminhos definidos.","Generating key…":"Gerando chave…","Key generated. Save config to apply.":"Chave gerada. Salve a config para aplicar.","Loading public key…":"Carregando chave pública…","Self-signed cert generated.":"Certificado autoassinado gerado.","Let's Encrypt cert issued.":"Certificado Let's Encrypt emitido.","PEM saved.":"PEM salvo.","Saved ✓ paths set.":"Salvo ✓ caminhos definidos.","Name, cert PEM, and key PEM required.":"Nome, cert PEM e chave PEM obrigatórios.","Name, cert, and key required.":"Nome, cert e chave obrigatórios.","Save Changes":"Salvar alterações" + } +}; +const I18N_ALIASES = { + "Painel":"Dashboard","Visão geral":"Overview","Contas":"Accounts","Administração":"Administration","Servidor":"Server","Sistema":"System","Configurações":"Settings","Tráfego":"Traffic","Revendedores":"Resellers","Usuários Xray":"Xray Users","Controle VPN":"VPN Control","Sair":"Logout", + "Total de contas":"Total accounts","ativas":"active","expiradas":"expired","Limite disponível":"available limit","Carregando cota…":"Loading quota…","Conexões ativas":"Active connections","SSH + Xray online agora":"SSH + Xray online now","Pronto para revendedores":"Ready for resellers","monitoramento em tempo real":"Server monitoring in real time","Carga do processador":"Processor load","Memória usada":"Memory used", + "Ações rápidas":"Quick actions","Criar SSH":"Create SSH","Criar Xray":"Create Xray","Novo revendedor":"New reseller","Configurar serviços":"Configure services","Usuário, senha, validade e limite.":"User, password, expiry and limit.","UUID, label, validade e conexões.":"UUID, label, expiry and connections.","Plano, validade e limite de contas.":"Plan, expiry and account limit.","Portas, DNSTT, UDPGW e TLS.":"Ports, DNSTT, UDPGW and TLS.","Minha cota":"My quota","Carregando…":"Loading…", + "Minha conta":"My Account","Usuários":"Users","Usuário":"User","Autenticação":"Auth","Conexões":"Conn","Máximo":"Max","Dono":"Owner","Ações":"Actions","Criar / atualizar usuário":"Create / update user","Mostrar formulário":"Show form","Ocultar formulário":"Hide form","Salvar usuário":"Save user","Cancelar":"Cancel","Gerar":"Gen","Copiar":"Copy","Editar":"Edit","Excluir":"Del","Recarregar":"Reload","Atualizar":"Refresh", + "Rodando":"Running","Parado":"Stopped","rodando":"running","parado":"stopped","desativado":"disabled","API de contadores":"Counters API","Reparar contadores":"Repair counters","Iniciar":"Start","Parar":"Stop","Reiniciar":"Restart","Inbounds e clientes":"Inbounds & Clients","Configuração Xray":"Xray Config","Editor de configuração":"Config editor","Carregar JSON":"Load JSON","Salvar e reiniciar":"Save & Restart","Logs do sistema":"System Logs","últimas 200 linhas":"last 200 lines","Clientes Xray":"Xray clients","Núcleo Xray":"Xray Core","Ativado":"Enabled","Tempo ativo":"Uptime","Precisa de reparo":"Needs repair", + "Nome":"Name","Nome de exibição":"Display Name","Data de vencimento":"Expiry Date","Máx. conexões":"Max Connections","Ilimitado":"Unlimited","Ativo":"Active","Suspenso":"Suspended","Expirado":"Expired","Sem vencimento":"No expiration","ocioso":"idle","Nenhum cliente.":"No clients.","Adicionar cliente":"Add Client","Novo usuário.":"New user.","Carregado.":"Loaded.","Salvo.":"Saved.","Salvando…":"Saving…","Erro ao carregar usuários.":"Error loading users.","Erro ao excluir.":"Error deleting.","Credenciais inválidas.":"Invalid credentials.","Conta suspensa ou expirada.":"Account suspended or expired.","Falha no login.":"Login failed.","Erro de rede.":"Network error.","Sessão expirada — faça login novamente.":"Session expired — please sign in again.", + "Rede":"Network","Listen principal (SSH / HTTP)":"Main Listen (SSH / HTTP)","Endereços extras de listen":"Extra Listen Addresses","Reinício automático do proxy":"Proxy Auto Restart","Intervalo de reinício automático do proxy":"Proxy Auto Restart Interval","Atraso para reiniciar proxy":"Proxy Restart Grace Delay","SSH e geral":"SSH & General","Limite padrão de upload (Mbps)":"Default Upload Limit (Mbps)","Limite padrão de download (Mbps)":"Default Download Limit (Mbps)","Logs silenciosos":"Quiet Logs","Exibir contagem de usuários":"User Count Display","Banner SSH":"SSH Banner","Texto do banner":"Banner Text","Túnel DNSTT":"DNSTT Tunnel","Domínio":"Domain","Chave privada":"Private Key","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Gateway UDP":"UDP Gateway","Endereço de listen":"Listen Address","Certificado":"Certificate","Gerar autoassinado":"Generate Self-Signed","Colar texto PEM":"Paste PEM text","Caminhos personalizados":"Custom file paths","Arquivo cert":"Cert File","Arquivo key":"Key File","Adicionar forwarder":"Add Forwarder","Salvar config":"Save Config","Todas as mudanças de serviço aplicam ao vivo.":"All service changes apply live." +}; + +Object.assign(I18N_TEXT["en-US"], { + "Servers":"Servers","Reseller area":"Reseller area","shared quota":"shared quota","available":"available","used":"used","breakdown":"breakdown", + "Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.", + "Loading inbounds…":"Loading inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"active ·","expired":"expired", + "Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085", + "Public Key — share with dnstt clients":"Public Key — share with DNSTT clients","auto-saved to /opt/sshpanel/dnstt.key":"auto-saved to /opt/sshpanel/dnstt.key", + "Max UDP Sessions Per Client":"Max UDP Sessions Per Client","(not total server users)":"(not total server users)","Service Name":"Service Name","Mode":"Mode","Protocol":"Protocol","Port":"Port","Tag":"Tag","Listen IP":"Listen IP","Method":"Method","Host":"Host","Path":"Path","Dest":"Dest","Short ID":"Short ID","Server Name":"Server Name","Cert File Path":"Cert File Path","Key File Path":"Key File Path","Certificate source:":"Certificate source:","Self-Signed":"Self-Signed","Paste PEM":"Paste PEM","File Path":"File Path","Save PEM":"Save PEM","Generate":"Generate","Public Key":"Public Key","Debug Logging":"Debug Logging","Name":"Name","Private Key PEM":"Private Key PEM","Certificate PEM":"Certificate PEM","Domain Name":"Domain Name" +}); +Object.assign(I18N_TEXT["pt-BR"], { + "Servers":"Servidores","Reseller area":"Área do revendedor","shared quota":"cota única","available":"disponíveis","used":"usadas","breakdown":"divisão", + "Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.", + "Loading inbounds…":"Carregando inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"ativas ·","expired":"expiradas", + "Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085", + "Public Key — share with dnstt clients":"Chave pública — compartilhe com clientes DNSTT","auto-saved to /opt/sshpanel/dnstt.key":"salva automaticamente em /opt/sshpanel/dnstt.key", + "Max UDP Sessions Per Client":"Máx. sessões UDP por cliente","(not total server users)":"(não é o total de usuários do servidor)","Service Name":"Nome do serviço","Mode":"Modo","Protocol":"Protocolo","Port":"Porta","Tag":"Tag","Listen IP":"IP de listen","Method":"Método","Host":"Host","Path":"Caminho","Dest":"Destino","Short ID":"ID curto","Server Name":"Nome do servidor","Cert File Path":"Caminho do arquivo cert","Key File Path":"Caminho do arquivo key","Certificate source:":"Fonte do certificado:","Self-Signed":"Autoassinado","Paste PEM":"Colar PEM","File Path":"Caminho do arquivo","Save PEM":"Salvar PEM","Generate":"Gerar","Public Key":"Chave pública","Debug Logging":"Log de debug","Name":"Nome","Private Key PEM":"Chave privada PEM","Certificate PEM":"Certificado PEM","Domain Name":"Nome do domínio" +}); +Object.assign(I18N_ALIASES, { + "Servidores":"Servers","Área do revendedor":"Reseller area","cota única":"shared quota","disponíveis":"available","usadas":"used","divisão":"breakdown", + "Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.", + "Carregando inbounds…":"Loading inbounds…","Loading inbounds…":"Loading inbounds…","ativas ·":"active ·","expiradas":"expired", + "Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085", + "Public Key — share with dnstt clients":"Public Key — share with dnstt clients","Chave pública — compartilhe com clientes DNSTT":"Public Key — share with dnstt clients", + "Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Nome do domínio":"Domain Name" +}); +const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))])); +let currentLang = detectInitialLanguage(); +let i18nTranslating = false; +let i18nQueued = false; + +function detectInitialLanguage() { + const saved = localStorage.getItem(LANG_STORAGE_KEY); + if (SUPPORTED_LANGS.includes(saved)) return saved; + const langs = (navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ""]).join(" ").toLowerCase(); + return langs.includes("pt") ? "pt-BR" : "en-US"; +} +function normalizeI18nText(value) { return String(value ?? "").replace(/\s+/g, " ").trim(); } +function i18nCanonicalKey(value) { + const text = normalizeI18nText(value); + if (!text) return ""; + if (Object.prototype.hasOwnProperty.call(I18N_TEXT["en-US"], text)) return text; + if (I18N_ALIASES[text]) return I18N_ALIASES[text]; + for (const lang of SUPPORTED_LANGS) { + if (I18N_REVERSE[lang][text]) return I18N_REVERSE[lang][text]; + } + return ""; +} +function t(key, vars = {}) { + const dict = I18N_TEXT[currentLang] || I18N_TEXT["en-US"]; + let out = dict[key] || I18N_TEXT["en-US"][key] || key; + return out.replace(/\{(\w+)\}/g, (_, name) => Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : ""); +} +function shouldSkipI18n(el) { + return !el || !el.closest || !!el.closest("script,style,textarea,pre,code,[data-no-i18n]"); +} +function translateTextNode(node) { + const raw = node.nodeValue || ""; + const key = i18nCanonicalKey(raw); + if (!key) return; + const translated = t(key); + const lead = raw.match(/^\s*/)?.[0] || ""; + const tail = raw.match(/\s*$/)?.[0] || ""; + const next = lead + translated + tail; + if (node.nodeValue !== next) node.nodeValue = next; +} +function translateStatic(root = document.body) { + if (!root) return; + i18nTranslating = true; + try { + if (root.nodeType === Node.TEXT_NODE) translateTextNode(root); + const base = root.nodeType === Node.ELEMENT_NODE ? root : document.body; + if (!base) return; + const walker = document.createTreeWalker(base, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + return shouldSkipI18n(node.parentElement) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; + } + }); + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + nodes.forEach(translateTextNode); + base.querySelectorAll?.("[placeholder],[title],[aria-label]").forEach(el => { + if (shouldSkipI18n(el)) return; + ["placeholder", "title", "aria-label"].forEach(attr => { + const value = el.getAttribute(attr); + const key = i18nCanonicalKey(value); + if (key) el.setAttribute(attr, t(key)); + }); + }); + } finally { + i18nTranslating = false; + } +} +function queueI18nRefresh() { + if (i18nTranslating || i18nQueued) return; + i18nQueued = true; + requestAnimationFrame(() => { + i18nQueued = false; + translateStatic(document.body); + }); +} +function startI18nObserver() { + if (!document.body || window.__dragonI18nObserver) return; + window.__dragonI18nObserver = new MutationObserver(() => queueI18nRefresh()); + window.__dragonI18nObserver.observe(document.body, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ["placeholder", "title", "aria-label"] }); +} +function applyLanguage(lang, options = {}) { + currentLang = SUPPORTED_LANGS.includes(lang) ? lang : "en-US"; + if (options.persist !== false) localStorage.setItem(LANG_STORAGE_KEY, currentLang); + document.documentElement.lang = currentLang.toLowerCase(); + if (languageSelect) languageSelect.value = currentLang; + updatePageHeading(); + translateStatic(document.body); + document.documentElement.classList.remove("i18n-pending"); +} + +// ─── 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 languageSelect = document.getElementById("languageSelect"); +const pageTitle = document.getElementById("pageTitle"); +const pageEyebrow = document.getElementById("pageEyebrow"); +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 dashCpuVal = document.getElementById("dashCpuVal"); +const dashCpuText = document.getElementById("dashCpuText"); +const dashCpuBar = document.getElementById("dashCpuBar"); +const dashRamVal = document.getElementById("dashRamVal"); +const dashRamText = document.getElementById("dashRamText"); +const dashRamBar = document.getElementById("dashRamBar"); +const dashNetVal = document.getElementById("dashNetVal"); +const dashNetText = document.getElementById("dashNetText"); +const dashNetTotal = document.getElementById("dashNetTotal"); +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 xOnlineUsers = document.getElementById("xOnlineUsers"); +const xCoreMode = document.getElementById("xCoreMode"); +const xSaveModeBtn = document.getElementById("xSaveModeBtn"); +const xCfgEditor = document.getElementById("xCfgEditor"); +const xCfgStatus = document.getElementById("xCfgStatus"); +const xLogsBox = document.getElementById("xLogsBox"); +const inboundsContainer = document.getElementById("inboundsContainer"); +const sshServerPickerCard = document.getElementById("sshServerPickerCard"); +const xrayServerPickerCard = document.getElementById("xrayServerPickerCard"); +const sshServerSelect = document.getElementById("sshServerSelect"); +const xrayServerSelect = document.getElementById("xrayServerSelect"); +const sshServerHint = document.getElementById("sshServerHint"); +const xrayServerHint = document.getElementById("xrayServerHint"); + +// 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"); + +// Managed servers +const serversBody = document.getElementById("serversBody"); +const serversCountChip = document.getElementById("serversCountChip"); +const serversStatus = document.getElementById("serversStatus"); +const serverForm = document.getElementById("serverForm"); +const serverFormTitle = document.getElementById("serverFormTitle"); +const srvID = document.getElementById("srvID"); +const srvName = document.getElementById("srvName"); +const srvBaseURL = document.getElementById("srvBaseURL"); +const srvAdminUser = document.getElementById("srvAdminUser"); +const srvAdminKey = document.getElementById("srvAdminKey"); +const srvEnableSSH = document.getElementById("srvEnableSSH"); +const srvEnableXray = document.getElementById("srvEnableXray"); +const srvIsActive = document.getElementById("srvIsActive"); +const serverFormStatus = document.getElementById("serverFormStatus"); +const serversListView = document.getElementById("serversListView"); +const serverConfigSubpage = document.getElementById("serverConfigSubpage"); +const cfgServerName = document.getElementById("cfgServerName"); +const managedConfigEditor = document.getElementById("managedConfigEditor"); +const managedConfigStatus = document.getElementById("managedConfigStatus"); +const serversStatusGrid = document.getElementById("serversStatusGrid"); +const serversStatusPageStatus = document.getElementById("serversStatusPageStatus"); +const serversStatusCountChip = document.getElementById("serversStatusCountChip"); + +// 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"); +const dnsttDashboardCard = document.getElementById("dnsttDashboardCard"); +const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated"); +const dnsttActiveSessions = document.getElementById("dnsttActiveSessions"); +const dnsttActiveStreams = document.getElementById("dnsttActiveStreams"); +const dnsttDNSRx = document.getElementById("dnsttDNSRx"); +const dnsttQueueLen = document.getElementById("dnsttQueueLen"); +const dnsttHealthBody = document.getElementById("dnsttHealthBody"); +const dnsttHealthSummary = document.getElementById("dnsttHealthSummary"); + +// 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; +} +function withServerParam(path, serverID) { + serverID = serverID || "local"; + if (!serverID || serverID === "local") return path; + return path + (path.includes("?") ? "&" : "?") + "server_id=" + encodeURIComponent(serverID); +} +function selectedSSHServer() { return sshServerSelect?.value || selectedSSHServerID || "local"; } +function selectedXrayServer() { return xrayServerSelect?.value || selectedXrayServerID || "local"; } +function serverByID(id) { return serversCache.find(s => String(s.id) === String(id)); } +function selectedXrayServerLabel() { + const id = selectedXrayServer(); + const srv = serverByID(id); + if (srv) return srv.name || srv.base_url || id; + return id === "local" ? "Master node" : id; +} +function xrayModeFromConfig(x) { + const mode = String(x?.mode || "").toLowerCase(); + return mode === "external" ? "external" : "native"; +} + +function applyXrayModeToConfig(cfg, mode) { + mode = mode === "external" ? "external" : "native"; + cfg.xray = cfg.xray && typeof cfg.xray === "object" ? cfg.xray : {}; + cfg.xray.mode = mode; + cfg.xray.native = mode === "native"; + cfg.xray.bin_path = cfg.xray.bin_path || "/opt/sshpanel/xray"; + cfg.xray.config_file = cfg.xray.config_file || "/opt/sshpanel/xray_config.json"; + cfg.xray.native_config_file = cfg.xray.native_config_file || "/opt/sshpanel/xray_native_config.json"; + cfg.xray.api_server = cfg.xray.api_server || "127.0.0.1:10085"; + cfg.xray.online_window_seconds = cfg.xray.online_window_seconds || 90; + cfg.xray.stats_poll_seconds = cfg.xray.stats_poll_seconds || 15; + return cfg; +} + +function reloadXrayConfigForSelectedServer() { + const wizPane = document.getElementById("xrayWizardPane"); + const jsonPane = document.getElementById("xrayCfgPaneJson"); + if (jsonPane && !jsonPane.classList.contains("hidden")) return loadXrayCfg(); + if (wizPane && !wizPane.classList.contains("hidden")) return loadWizardFromConfig(); +} + +// ─── 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 fmtInt(n) { + const v = Number(n); + return Number.isFinite(v) ? v.toLocaleString() : "--"; +} +function fmtDnsttTimestamp(ts) { + if (!ts) return "Waiting for DNSTT stats…"; + const d = new Date(ts); + if (!Number.isFinite(d.getTime()) || d.getFullYear() < 2020) return "Waiting for DNSTT stats…"; + return "Updated: " + d.toLocaleTimeString(); +} +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)); +} + + +function escapeHTML(value) { + return String(value ?? "").replace(/[&<>'"]/g, ch => ({ + "&": "&", "<": "<", ">": ">", "'": "'", '"': """, + }[ch])); +} + +function isVisible(el) { + return !!el && !el.classList.contains("hidden") && getComputedStyle(el).display !== "none"; +} + +function isXrayClientEditorActive() { + const active = document.activeElement; + if (active && active.closest && (active.closest("#inboundsContainer") || active.closest("#editXrayClientPanel"))) return true; + if (isVisible(document.getElementById("editXrayClientPanel"))) return true; + return Array.from(document.querySelectorAll('#inboundsContainer [id^="add-form-"]')).some(isVisible); +} + +function inboundStructure(inbounds = []) { + return JSON.stringify((inbounds || []).map(ib => ({ + tag: ib.tag || "", + protocol: ib.protocol || "", + port: ib.port ?? "", + clients: (ib.clients || []).map(c => c.id || ""), + }))); +} + +function clientStatusHTML(c) { + const exp = c.expires_at ? new Date(c.expires_at) : null; + const daysLeft = c.expiration_days; + if (c.expired) return `${t("Expired")}`; + if (daysLeft === -1 || !exp) return `${t("Active")}`; + return `${t("Active ({days}d)", {days: escapeHTML(daysLeft)})}`; +} + +function clientExpiryLabel(c) { + const exp = c.expires_at ? new Date(c.expires_at) : null; + return exp ? exp.toLocaleDateString() : t("Unlimited"); +} + +function clientOnlineHTML(c) { + return `${c.online ? `${t("online")}` : `${t("offline")}`}
${escapeHTML(formatLastActive(c.last_active))}
`; +} + +function clientTrafficHTML(c) { + const up = Number(c.uplink_bytes || 0); + const down = Number(c.downlink_bytes || 0); + const total = Number(c.total_bytes || (up + down) || 0); + return `${escapeHTML(formatBytes(total))}
↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}
`; +} + +function updateCell(row, name, html) { + const cell = row?.querySelector?.(`[data-cell="${name}"]`); + if (cell && cell.innerHTML !== html) cell.innerHTML = html; +} + +function patchRenderedInbounds(inbounds) { + const sections = Array.from(inboundsContainer.querySelectorAll("[data-inbound-tag]")); + if (sections.length !== inbounds.length) return false; + + for (const ib of inbounds) { + const tag = String(ib.tag || ""); + const section = sections.find(el => el.dataset.inboundTag === tag); + if (!section) return false; + if (section.dataset.inboundProtocol !== String(ib.protocol || "") || section.dataset.inboundPort !== String(ib.port ?? "")) return false; + + const clients = ib.clients || []; + const rows = Array.from(section.querySelectorAll("tr[data-client-id]")); + if (rows.length !== clients.length) return false; + + const onlineCount = clients.filter(c => !!c.online).length; + const chip = section.querySelector('[data-role="inbound-online-chip"]'); + if (chip) { + chip.textContent = t("{count} online", {count: onlineCount}); + chip.classList.toggle("green", onlineCount > 0); + } + + for (const c of clients) { + const row = rows.find(el => el.dataset.clientId === String(c.id || "")); + if (!row) return false; + updateCell(row, "name", escapeHTML(c.name || "—")); + updateCell(row, "uuid", escapeHTML(c.id || "—")); + updateCell(row, "email", escapeHTML(c.email || "—")); + updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c))); + updateCell(row, "status", clientStatusHTML(c)); + updateCell(row, "online", clientOnlineHTML(c)); + updateCell(row, "traffic", clientTrafficHTML(c)); + updateCell(row, "max", escapeHTML(c.max_conns || "∞")); + } + } + return true; +} + diff --git a/admin/assets/js/02-shell.js b/admin/assets/js/02-shell.js new file mode 100644 index 0000000..bd467bf --- /dev/null +++ b/admin/assets/js/02-shell.js @@ -0,0 +1,288 @@ +// ─── Navigation / shell ────────────────────────────────────────────────────── +const tabTitles = { + dashboard: ["Dashboard", "Overview"], + ssh: ["Accounts", "SSH / SlowDNS"], + xray: ["Accounts", "Xray Users"], + resellers: ["Administration", "Resellers"], + servers: ["Administration", "Servers"], + "servers-status": ["Administration", "Servers Status"], + stats: ["Server", "Monitoring"], + vnstat: ["Traffic", "VnStat"], + logs: ["System", "Logs"], + server: ["System", "Settings"], +}; +function updatePageHeading() { + const [eyebrow, title] = tabTitles[currentTab] || ["Dashboard", currentTab]; + if (pageEyebrow) pageEyebrow.textContent = t(eyebrow); + if (pageTitle) pageTitle.textContent = t(title); +} + +function selectTab(tab) { + currentTab = 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"); + updatePageHeading(); + document.body.classList.remove("sidebar-open"); + + if (tab === "dashboard") refreshDashboard(); + if (tab === "xray") { + loadXrayStatus(); + loadInbounds({ silent: true }); + if (currentRole === "superadmin") loadWizardFromConfig(); + } + if (tab === "stats" && currentRole === "superadmin") loadStats(); + if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus(); + if (tab === "resellers" && currentRole === "superadmin") loadResellers(); + if (tab === "servers" && currentRole === "superadmin") loadServers(); +} + +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")); +languageSelect?.addEventListener("change", () => { applyLanguage(languageSelect.value); renderDashboardCounters(); }); +applyLanguage(currentLang, { persist: false }); +startI18nObserver(); + +// ─── 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 ? t("Invalid credentials.") : + res.status === 403 ? t("Account suspended or expired.") : + t("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 = t("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; + mainApp.classList.remove("role-superadmin", "role-reseller"); + mainApp.classList.add(currentRole === "superadmin" ? "role-superadmin" : "role-reseller"); + 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"); + + selectTab("dashboard"); + loadServers(); + + if (currentRole === "superadmin") { + loadDashboardStats(); + statsTimer = setInterval(() => { + loadDashboardStats(); + if (currentTab === "stats") loadStats(); + if (currentTab === "servers-status") loadServersStatus({ silent: true }); + }, 2000); + } else { + loadMe(); + } + xrayTimer = setInterval(() => { + loadXrayStatus(); + if (currentTab === "xray") loadInbounds({ silent: true }); + }, 7000); + + loadUsers(); + loadXrayStatus(); + loadInbounds({ silent: true }); + usersTimer = setInterval(() => loadUsersSilent(), 3000); +} + +// ─── 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) : t("No expiration"); + rStatus.textContent = d.is_active ? t("Active") : t("Suspended"); + 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 + ? t("No limit set by admin") + : t("{remaining} accounts available · {pct}% used", {remaining, pct}); + dashQuotaBreakdown.textContent = t("SSH {ssh} · Xray {xray}", {ssh: sshUsed, xray: xrayUsed}); + dashQuotaBar.style.width = `${pct}%`; + + if (dashQuotaRemaining) { + dashQuotaRemaining.textContent = String(remaining); + setQuotaTone(dashQuotaRemaining, tone); + } + if (dashQuotaSummaryText) { + dashQuotaSummaryText.textContent = unlimited + ? t("{used} used · unlimited", {used}) + : t("{used}/{max} used · {pct}% of plan", {used, max, pct}); + } + 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 = t("SSH {ssh} · Xray {xray}", {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 ${t("ago")}`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ${t("ago")}`; + const hrs = Math.floor(min / 60); + if (hrs < 24) return `${hrs}h ${t("ago")}`; + 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 = t("{ssh} SSH · {xray} Xray online", {ssh: sshConns, xray: xrayOnline}); + if (dashXrayClients) dashXrayClients.textContent = xrayClients.length; + if (dashXrayStatus) { + const running = xrayChip?.textContent || "--"; + dashXrayStatus.textContent = t("{online} online · {active} active · {expired} expired · Core: {core}", {online: xrayOnline, active: xrayActive, expired: xrayExpired, 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({ silent: true }); + loadXrayStatus(); + if (currentRole === "superadmin") loadStats(); + if (currentRole === "reseller") loadMe(); +} + diff --git a/admin/assets/js/03-ssh-users.js b/admin/assets/js/03-ssh-users.js new file mode 100644 index 0000000..cc3fb0d --- /dev/null +++ b/admin/assets/js/03-ssh-users.js @@ -0,0 +1,159 @@ +// ─── 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 = t("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 = t("TOTP secret generated."); +}); +document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; }); + +function setFormCollapsed(v) { + formCollapsed = v; + userFormWrap.classList.toggle("collapsed", v); + toggleFormBtn.textContent = v ? t("Show form") : t("Hide form"); +} + +async function loadUsers() { + userStatus.textContent = t("Loading…"); + try { + const res = await api(withServerParam("/api/users", selectedSSHServer())); + const data = await res.json(); + renderUsers(data || []); + userStatus.textContent = t("Loaded."); + lastReload.textContent = t("Last reload: {time}", {time: new Date().toLocaleTimeString()}); + } catch (e) { + if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = t("Error loading users."); } + } +} +async function loadUsersSilent() { + try { + const res = await api(withServerParam("/api/users", selectedSSHServer())); + 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 ? `${t("online")}` : `${t("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:t("Edit"), + onclick: () => fillUserForm(u), + }); + const delBtn = Object.assign(document.createElement("button"), { + className:"btn btn-danger btn-sm", textContent:t("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 = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, 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 = t("Editing {name}", {name: u.username}); +} + +userForm.addEventListener("submit", async e => { + e.preventDefault(); + saveUserBtn.disabled = true; + userStatus.textContent = t("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), + server_id: selectedSSHServer(), + }; + 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 = t("Saved."); + fPassword.value = ""; + loadUsers(); + if (currentRole === "reseller") loadMe(); + } catch (e) { + if (e.message==="auth") doAuthError(); + else userStatus.textContent = t("Error: {error}", {error: e.message}); + } finally { + saveUserBtn.disabled = false; + } +}); + +async function deleteUser(username) { + if (!confirm(t("Delete user \"{name}\"?", {name: username}))) return; + userStatus.textContent = t("Deleting {name}…", {name: username}); + try { + const res = await api(withServerParam(`/api/users/delete?username=${encodeURIComponent(username)}`, selectedSSHServer()), { method:"DELETE" }); + if (!res.ok && res.status !== 204) throw new Error("delete failed"); + userStatus.textContent = t("Deleted."); + loadUsers(); + if (currentRole === "reseller") loadMe(); + } catch (e) { + if (e.message==="auth") doAuthError(); + else userStatus.textContent = t("Error deleting."); + } +} + diff --git a/admin/assets/js/04-xray.js b/admin/assets/js/04-xray.js new file mode 100644 index 0000000..3d96204 --- /dev/null +++ b/admin/assets/js/04-xray.js @@ -0,0 +1,352 @@ +// ─── 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); +xSaveModeBtn?.addEventListener("click", saveXrayCoreMode); +document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds({ force: true }); }); +document.getElementById("xLoadInboundsBtn").addEventListener("click", () => loadInbounds({ force: true })); +document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg); +document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg); +document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs); + + +async function loadXrayStatus() { + try { + const res = await api(withServerParam("/api/xray/status", selectedXrayServer())); + const s = await res.json(); + const run = !!s.running; + xrayChip.textContent = run ? t("running") : (s.enabled ? t("stopped") : t("disabled")); + xrayChip.className = "chip " + (run ? "green" : "red"); + xRunning.textContent = run ? t("Running") : t("Stopped"); + xRunning.style.color = run ? "var(--success)" : "var(--danger)"; + xPID.textContent = s.pid || (s.native ? "internal" : "--"); + xUptime.textContent = s.uptime || "--"; + if (xCoreMode) xCoreMode.value = String(s.mode || (s.native ? "native" : "external")).toLowerCase() === "external" ? "external" : "native"; + const statsCfgEl = document.getElementById("xStatsConfig"); + const repairBtn = document.getElementById("xRepairStatsBtn"); + if (statsCfgEl) { + statsCfgEl.textContent = s.stats_configured ? t("OK") : t("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 = t("Online counters need Stats API repair.") + missing; + } else if (s.stats_error && xStatus) { + xStatus.textContent = t("Online counters: {error}", {error: s.stats_error}); + } else if (xStatus) { + xStatus.textContent = s.api_server ? t("Counters API ready at {server}.", {server: s.api_server}) : t("Counters API ready."); + } + if (dashServers) dashServers.textContent = String((serversCache || []).filter(n => n.is_active !== false).length || (s.enabled ? 1 : 0)); + if (dashServerStatus) dashServerStatus.textContent = (serversCache || []).length > 1 ? `${(serversCache || []).filter(n => n.is_active !== false).length} nodes configured` : (run ? t("{count} online", {count: 1}) : (s.enabled ? t("stopped") : t("disabled"))); + renderDashboardCounters(); + if (s.error) xStatus.textContent = t("Error: {error}", {error: s.error}); + } catch (e) { if (e.message==="auth") doAuthError(); } +} + +async function saveXrayCoreMode() { + const mode = xCoreMode?.value === "external" ? "external" : "native"; + const target = selectedXrayServerLabel(); + const selectedID = selectedXrayServer() || "local"; + if (xStatus) xStatus.textContent = `Saving Xray mode on ${target}...`; + try { + const getRes = await api(withServerParam("/api/servers/config", selectedID)); + if (!getRes.ok) throw new Error(await getRes.text()); + const cfg = await getRes.json(); + applyXrayModeToConfig(cfg, mode); + const postRes = await api(withServerParam("/api/servers/config", selectedID), { method:"POST", body: JSON.stringify(cfg) }); + if (!postRes.ok) throw new Error(await postRes.text()); + if (xStatus) xStatus.textContent = mode === "native" + ? `Saved on ${target}: using internal native emulator.` + : `Saved on ${target}: using external Xray binary.`; + setTimeout(loadXrayStatus, 700); + setTimeout(() => loadInbounds({ force: true }), 1200); + } catch (e) { + if (e.message === "auth") doAuthError(); + else if (xStatus) xStatus.textContent = t("Error: {error}", {error: e.message}); + } +} + +async function repairXrayStats() { + const btn = document.getElementById("xRepairStatsBtn"); + if (btn) btn.disabled = true; + xStatus.textContent = currentLang === "pt-BR" ? "Verificando e reparando a API de contadores do Xray…" : "Checking and repairing Xray counters API…"; + try { + const res = await api(withServerParam("/api/xray/stats/repair", selectedXrayServer()), { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + const d = await res.json().catch(() => ({})); + xStatus.textContent = d.changed + ? (d.restarted ? (currentLang === "pt-BR" ? "API de contadores reparada e Xray reiniciado." : "Counters API repaired and Xray restarted.") : (currentLang === "pt-BR" ? "API de contadores reparada. Reinicie o Xray para aplicar." : "Counters API repaired. Restart Xray to apply it.")) + : (currentLang === "pt-BR" ? "A API de contadores já parece correta." : "Counters API already looks correct."); + setTimeout(loadXrayStatus, 700); + setTimeout(() => loadInbounds({ force: true }), 1200); + } catch (e) { + if (e.message==="auth") doAuthError(); + else xStatus.textContent = (currentLang === "pt-BR" ? "Erro ao reparar contadores: " : "Error repairing counters: ")+e.message; + } finally { + if (btn) btn.disabled = false; + } +} + +async function xrayCtrl(action) { + xStatus.textContent = (currentLang === "pt-BR" ? "Processando Xray…" : action.charAt(0).toUpperCase()+action.slice(1)+"ing Xray…"); + try { + const res = await api(withServerParam(`/api/xray/${action}`, selectedXrayServer()), { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + xStatus.textContent = currentLang === "pt-BR" ? "Xray OK." : "Xray "+action+" OK."; + setTimeout(loadXrayStatus, 700); + setTimeout(() => loadInbounds({ force: true }), 1200); + } catch (e) { + if (e.message==="auth") doAuthError(); + else xStatus.textContent = t("Error: {error}", {error: e.message}); + } +} + +async function loadInbounds(options = {}) { + const { silent = false, force = false } = options || {}; + if (inboundsRefreshInFlight) return; + inboundsRefreshInFlight = true; + if (!silent) inboundsContainer.innerHTML = `
${t("Loading…")}
`; + else inboundsContainer.classList.add("xray-refreshing"); + try { + const res = await api(withServerParam("/api/xray/inbounds", selectedXrayServer())); + if (!res.ok) throw new Error(await res.text()); + const inbounds = await res.json(); + renderInbounds(inbounds || [], { silent, force }); + } catch (e) { + if (!silent) inboundsContainer.textContent = t("Error loading inbounds."); + if (e.message==="auth") doAuthError(); + } finally { + inboundsRefreshInFlight = false; + inboundsContainer.classList.remove("xray-refreshing"); + } +} + +async function copyText(text) { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + } catch {} + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { return document.execCommand("copy"); } + finally { document.body.removeChild(ta); } +} + +function renderInbounds(inbounds, options = {}) { + const { silent = false, force = false } = options || {}; + updateDashboardXray(inbounds); + const nextStructure = inboundStructure(inbounds); + + if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return; + if (silent && !force && isXrayClientEditorActive()) { + patchRenderedInbounds(inbounds); + if (xStatus) xStatus.textContent = t("New client data is available; editing was preserved."); + return; + } + + if (!inbounds.length) { + inboundsContainer.innerHTML = `
${t("No VLESS/VMess/Trojan inbounds found.")}
`; + lastInboundsStructure = nextStructure; + return; + } + inboundsContainer.innerHTML = ""; + lastInboundsStructure = nextStructure; + inbounds.forEach(ib => { + const section = document.createElement("div"); + section.dataset.inboundTag = String(ib.tag || ""); + section.dataset.inboundProtocol = String(ib.protocol || ""); + section.dataset.inboundPort = String(ib.port ?? ""); + 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 = ` +
+ ${escapeHTML(ib.protocol)} + ${escapeHTML(ib.tag || "untagged")} + :${escapeHTML(ib.port ?? "?")} + ${t("{count} online", {count: onlineCount})} +
+ `; + 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 = `
${t("No clients.")}
`; + } else { + const tbl = document.createElement("table"); + tbl.innerHTML = `${t("Name")}UUID${t("Email")}${t("Expiry")}${t("Status")}${t("Online")}${t("Traffic")}${t("Max")}${t("Actions")}`; + const tbody = document.createElement("tbody"); + clients.forEach(c => { + const tr = document.createElement("tr"); + tr.dataset.clientId = String(c.id || ""); + tr.innerHTML = ` + ${escapeHTML(c.name || "—")} + ${escapeHTML(c.id || "—")} + ${escapeHTML(c.email || "—")} + ${escapeHTML(clientExpiryLabel(c))} + ${clientStatusHTML(c)} + ${clientOnlineHTML(c)} + ${clientTrafficHTML(c)} + ${escapeHTML(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 = t("Copy"); + copyBtn.onclick = async () => { await copyText(c.id); xStatus.textContent = t("Copied client ID."); }; + const editBtn = document.createElement("button"); + editBtn.className = "btn btn-warn btn-sm"; + editBtn.style.marginLeft = "4px"; + editBtn.textContent = t("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 = t("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 = t("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, server_id: selectedXrayServer() }), + }); + if (!res.ok) throw new Error(await res.text()); + xStatus.textContent = t("Client {id}… added. Native mode hot-reloads without restart.", {id: uuid.slice(0,8)}); + setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500); + } catch (e) { + if (e.message==="auth") doAuthError(); + else xStatus.textContent = t("Error: {error}", {error: e.message}); + } +} + +async function removeClient(tag, uuid) { + if (!confirm(t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}))) return; + try { + const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" }); + if (!res.ok && res.status !== 204) throw new Error(await res.text()); + xStatus.textContent = t("Client removed. Native mode hot-reloads without restart."); + setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500); + } catch (e) { + if (e.message==="auth") doAuthError(); + else xStatus.textContent = t("Error: {error}", {error: e.message}); + } +} + +async function loadXrayCfg() { + if (!xCfgEditor) return; + const target = selectedXrayServerLabel(); + if (xCfgStatus) xCfgStatus.textContent = `Loading config from ${target}…`; + try { + const res = await api(withServerParam("/api/xray/config", selectedXrayServer())); + 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; } + if (xCfgStatus) xCfgStatus.textContent = `Config loaded from ${target}.`; + } catch (e) { + if (e.message==="auth") doAuthError(); + else if (xCfgStatus) xCfgStatus.textContent = t("Error: {error}", {error: e.message}); + } +} + +async function saveXrayCfg() { + const text = (xCfgEditor?.value || "").trim(); + const target = selectedXrayServerLabel(); + try { JSON.parse(text); } catch(e) { if (xCfgStatus) xCfgStatus.textContent = t("Invalid JSON: {error}", {error: e.message}); return; } + if (xCfgStatus) xCfgStatus.textContent = `Saving config to ${target}…`; + try { + const res = await api(withServerParam("/api/xray/config", selectedXrayServer()), { method:"POST", body: text }); + if (!res.ok) throw new Error(await res.text()); + if (xCfgStatus) xCfgStatus.textContent = `Saved on ${target}. Restarting Xray…`; + await xrayCtrl("restart"); + } catch (e) { + if (e.message==="auth") doAuthError(); + else if (xCfgStatus) xCfgStatus.textContent = t("Error: {error}", {error: e.message}); + } +} + +async function loadXrayLogs() { + try { + const res = await api(withServerParam("/api/xray/logs", selectedXrayServer())); + const data = await res.json(); + xLogsBox.textContent = (data.lines||[]).join("\n"); + xLogsBox.scrollTop = xLogsBox.scrollHeight; + } catch (e) { if (e.message==="auth") doAuthError(); } +} + diff --git a/admin/assets/js/05-resellers.js b/admin/assets/js/05-resellers.js new file mode 100644 index 0000000..3e87b78 --- /dev/null +++ b/admin/assets/js/05-resellers.js @@ -0,0 +1,113 @@ +// ─── 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:t("Edit"), + onclick: () => fillResellerForm(r), + }); + const delBtn = Object.assign(document.createElement("button"),{ + className:"btn btn-danger btn-sm", textContent:t("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."; + } +} + diff --git a/admin/assets/js/06-servers.js b/admin/assets/js/06-servers.js new file mode 100644 index 0000000..ab9cfe0 --- /dev/null +++ b/admin/assets/js/06-servers.js @@ -0,0 +1,667 @@ +// ─── Managed Servers ───────────────────────────────────────────────────────── +sshServerSelect?.addEventListener("change", () => { + selectedSSHServerID = selectedSSHServer(); + localStorage.setItem("SSH_SERVER_ID", selectedSSHServerID); + loadUsers(); +}); +xrayServerSelect?.addEventListener("change", () => { + selectedXrayServerID = selectedXrayServer(); + localStorage.setItem("XRAY_SERVER_ID", selectedXrayServerID); + lastInboundsStructure = ""; + closeEditXrayClient?.(); + if (xStatus) xStatus.textContent = `Switched Xray target to ${selectedXrayServerLabel()}.`; + loadXrayStatus(); + loadInbounds({ force: true }); + reloadXrayConfigForSelectedServer(); + loadXrayLogs(); +}); +document.getElementById("reloadServersBtn")?.addEventListener("click", loadServers); +document.getElementById("reloadServersBtn2")?.addEventListener("click", loadServers); +document.getElementById("refreshServersBtn")?.addEventListener("click", loadServers); +document.getElementById("refreshServersStatusBtn")?.addEventListener("click", () => loadServersStatus()); +document.querySelector("[data-tab='servers']")?.addEventListener("click", loadServers); +document.querySelector("[data-tab='servers-status']")?.addEventListener("click", () => loadServersStatus()); +document.getElementById("clearServerFormBtn")?.addEventListener("click", clearServerForm); +document.getElementById("testServerBtn")?.addEventListener("click", testServerForm); +document.getElementById("backToServersBtn")?.addEventListener("click", () => showServerListView()); +document.getElementById("loadManagedConfigBtn")?.addEventListener("click", () => loadManagedServerConfig(configuringServerID)); +document.getElementById("saveManagedConfigBtn")?.addEventListener("click", saveManagedServerConfig); +document.getElementById("saveManagedConfigBottomBtn")?.addEventListener("click", saveManagedServerConfig); +document.getElementById("reloadManagedConfigBottomBtn")?.addEventListener("click", () => loadManagedServerConfig(configuringServerID)); +serverForm?.addEventListener("submit", async e => { + e.preventDefault(); + await saveServerForm(); +}); + +async function loadServers() { + try { + const res = await api("/api/servers"); + if (!res.ok) throw new Error(await res.text()); + serversCache = await res.json() || []; + } catch (e) { + serversCache = [{ id:"local", name:"Master node", base_url:"local", enable_ssh:true, enable_xray:true, is_active:true, is_local:true }]; + if (serversStatus) serversStatus.textContent = "Error loading servers: " + e.message; + if (e.message === "auth") doAuthError(); + } + renderServerSelectors(); + renderServersTable(); +} + + +async function loadServersStatus(options = {}) { + const silent = !!options.silent; + if (!serversStatusGrid) return; + try { + if (!Array.isArray(serversCache) || serversCache.length === 0) await loadServers(); + const nodes = (serversCache || []).filter(Boolean); + if (serversStatusCountChip) serversStatusCountChip.textContent = String(nodes.length); + if (!silent) { + serversStatusPageStatus && (serversStatusPageStatus.textContent = "Loading servers..."); + serversStatusGrid.innerHTML = `
Loading servers...
`; + } + const rows = await Promise.all(nodes.map(loadSingleServerStatus)); + renderServersStatusCards(rows); + if (serversStatusPageStatus) { + const online = rows.filter(r => r.ok).length; + serversStatusPageStatus.textContent = `${online}/${rows.length} servers online - Updated ${new Date().toLocaleTimeString()}`; + } + } catch (e) { + if (e.message === "auth") doAuthError(); + else { + serversStatusPageStatus && (serversStatusPageStatus.textContent = "Error loading server status: " + e.message); + if (!silent) serversStatusGrid.innerHTML = `
Error loading server status.
`; + } + } +} + +async function fetchJSONForServer(path, serverID) { + const res = await api(withServerParam(path, serverID)); + if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`); + return await res.json(); +} + +async function loadSingleServerStatus(server) { + const id = String(server.id || "local"); + const out = { server, ok: true, error: "", stats: null, users: [], inbounds: [], xray: null }; + if (server.is_active === false) { out.ok = false; out.error = "disabled"; return out; } + try { + out.stats = await fetchJSONForServer("/api/stats", id); + } catch (e) { + out.ok = false; + out.error = e.message || "stats failed"; + } + if (server.enable_ssh || server.is_local) { + try { out.users = await fetchJSONForServer("/api/users", id) || []; } + catch (e) { out.usersError = e.message || "users failed"; } + } + if (server.enable_xray || server.is_local) { + try { out.xray = await fetchJSONForServer("/api/xray/status", id); } + catch (e) { out.xrayError = e.message || "xray status failed"; } + try { out.inbounds = await fetchJSONForServer("/api/xray/inbounds", id) || []; } + catch (e) { out.inboundsError = e.message || "xray clients failed"; } + } + return out; +} + +function renderServersStatusCards(rows = []) { + if (!serversStatusGrid) return; + if (!rows.length) { + serversStatusGrid.innerHTML = `
No active servers configured.
`; + return; + } + serversStatusGrid.innerHTML = rows.map(serverStatusCardHTML).join(""); +} + +function serverStatusCardHTML(row) { + const s = row.server || {}; + const stats = row.stats || {}; + const ifaces = Array.isArray(stats.interfaces) ? stats.interfaces : []; + let rx = 0, tx = 0, rxTotal = 0, txTotal = 0; + ifaces.forEach(it => { + rx += Number(it.rx_mbps || 0); + tx += Number(it.tx_mbps || 0); + rxTotal += Number(it.rx_bytes || 0); + txTotal += Number(it.tx_bytes || 0); + }); + const cpu = Number(stats.cpu_percent || 0); + const mem = stats.mem_percent == null ? 0 : Number(stats.mem_percent || 0); + const users = Array.isArray(row.users) ? row.users : []; + const now = Date.now(); + const sshActive = users.filter(u => !u.expires_at || new Date(u.expires_at).getTime() > now).length; + const sshExpired = Math.max(0, users.length - sshActive); + const sshConns = users.reduce((sum, u) => sum + Number(u.active_conns || 0), 0); + const clients = []; + (Array.isArray(row.inbounds) ? row.inbounds : []).forEach(ib => (ib.clients || []).forEach(c => clients.push(c))); + const xrayOnline = clients.filter(c => !!c.online).length; + const xrayActive = clients.filter(c => !c.expired && (!c.expires_at || new Date(c.expires_at).getTime() > now)).length; + const xrayExpired = Math.max(0, clients.length - xrayActive); + const netNow = rx + tx; + const running = row.xray ? !!row.xray.running : false; + const nodeStatus = row.ok ? `online` : `offline`; + const options = `${s.enable_ssh ? "SSH" : ""}${s.enable_ssh && s.enable_xray ? " / " : ""}${s.enable_xray ? "Xray" : ""}` || "disabled"; + const err = row.ok ? "" : `
${escapeHTML(row.error || "connection failed")}
`; + return ` +
+
+
+
${escapeHTML(s.name || "Server")}
+
${escapeHTML(s.base_url || "local")}
+
+
+ ${nodeStatus} + ${escapeHTML(options)} +
+
+ ${err} +
+ ${miniMetricHTML("CPU", fmtPct(cpu), cpu, cpu >= 85 ? "High load" : cpu >= 60 ? "Moderate load" : "Normal load")} + ${miniMetricHTML("RAM", fmtPct(mem), mem, stats.mem_used_bytes && stats.mem_total_bytes ? `${fmtBytes(stats.mem_used_bytes)} / ${fmtBytes(stats.mem_total_bytes)}` : "Memory used")} + ${miniMetricHTML("Network", `${fmtMbps(netNow)} Mb/s`, Math.min(100, netNow / 20), `RX ${fmtMbps(rx)} - TX ${fmtMbps(tx)}`)} + ${miniMetricHTML("Accounts", String(users.length + clients.length), Math.min(100, (users.length + clients.length) * 3), `SSH ${users.length} - Xray ${clients.length}`)} +
+ +
`; +} + +function miniMetricHTML(label, value, pct, note) { + const width = Math.min(100, Math.max(0, Number(pct) || 0)); + return `
+
${escapeHTML(label)}
+
${escapeHTML(value)}
+
${escapeHTML(note || "")}
+
+
`; +} + +function renderServerSelectors() { + const active = serversCache.filter(s => s.is_active !== false); + const sshServers = active.filter(s => s.enable_ssh || s.is_local); + const xrayServers = active.filter(s => s.enable_xray || s.is_local); + populateServerSelect(sshServerSelect, sshServers, selectedSSHServerID, "ssh"); + populateServerSelect(xrayServerSelect, xrayServers, selectedXrayServerID, "xray"); + const hasMultiSSH = sshServers.length > 1; + const hasMultiXray = xrayServers.length > 1; + sshServerPickerCard?.classList.toggle("hidden", !hasMultiSSH); + xrayServerPickerCard?.classList.toggle("hidden", !hasMultiXray); + if (sshServerHint) { sshServerHint.textContent = ""; sshServerHint.classList.add("hidden"); } + if (xrayServerHint) { xrayServerHint.textContent = ""; xrayServerHint.classList.add("hidden"); } + if (dashServers) dashServers.textContent = String(active.length || 1); + if (dashServerStatus) dashServerStatus.textContent = active.length > 1 ? `${active.length} nodes configured` : "master only"; +} + +function populateServerSelect(select, list, selected, kind) { + if (!select) return; + const current = String(selected || select.value || "local"); + select.innerHTML = ""; + list.forEach(s => { + const opt = document.createElement("option"); + opt.value = String(s.id); + opt.textContent = `${s.name || s.base_url || s.id}${s.is_local ? " (master)" : ""}`; + select.appendChild(opt); + }); + const allowed = list.some(s => String(s.id) === current); + select.value = allowed ? current : "local"; + if (kind === "ssh") { + selectedSSHServerID = select.value || "local"; + localStorage.setItem("SSH_SERVER_ID", selectedSSHServerID); + } else { + selectedXrayServerID = select.value || "local"; + localStorage.setItem("XRAY_SERVER_ID", selectedXrayServerID); + } +} + +function renderServersTable() { + if (!serversBody) return; + const rows = serversCache || []; + serversCountChip && (serversCountChip.textContent = String(Math.max(0, rows.length - 1))); + serversBody.innerHTML = ""; + rows.forEach(s => { + const tr = document.createElement("tr"); + const opts = `${s.enable_ssh ? "SSH" : ""}${s.enable_ssh && s.enable_xray ? " / " : ""}${s.enable_xray ? "Xray" : ""}` || "disabled"; + tr.innerHTML = ` + ${escapeHTML(s.name || "—")}${s.is_local ? ' master' : ""} + ${escapeHTML(s.base_url || "local")} + ${escapeHTML(opts)} + ${s.is_active ? 'active' : 'disabled'}`; + const td = document.createElement("td"); + td.style.whiteSpace = "nowrap"; + const cfgBtn = document.createElement("button"); + cfgBtn.className = "btn btn-ghost btn-sm"; + cfgBtn.textContent = "Configure"; + cfgBtn.onclick = () => openManagedServerConfig(String(s.id)); + td.appendChild(cfgBtn); + if (!s.is_local) { + const editBtn = document.createElement("button"); + editBtn.className = "btn btn-warn btn-sm"; + editBtn.style.marginLeft = "4px"; + editBtn.textContent = "Edit"; + editBtn.onclick = () => fillServerForm(s); + const delBtn = document.createElement("button"); + delBtn.className = "btn btn-danger btn-sm"; + delBtn.style.marginLeft = "4px"; + delBtn.textContent = "Del"; + delBtn.onclick = () => deleteServer(s); + td.append(editBtn, delBtn); + } + tr.appendChild(td); + serversBody.appendChild(tr); + }); +} + +function clearServerForm() { + if (!serverForm) return; + srvID.value = ""; + srvName.value = ""; + srvBaseURL.value = ""; + srvAdminUser.value = "admin"; + srvAdminKey.value = ""; + srvEnableSSH.checked = true; + srvEnableXray.checked = true; + srvIsActive.checked = true; + if (serverFormTitle) serverFormTitle.textContent = "Add / edit server"; + if (serverFormStatus) serverFormStatus.textContent = ""; +} + +function fillServerForm(s) { + srvID.value = s.id || ""; + srvName.value = s.name || ""; + srvBaseURL.value = s.base_url || ""; + srvAdminUser.value = s.admin_username || "admin"; + srvAdminKey.value = ""; + srvEnableSSH.checked = !!s.enable_ssh; + srvEnableXray.checked = !!s.enable_xray; + srvIsActive.checked = s.is_active !== false; + if (serverFormTitle) serverFormTitle.textContent = "Edit: " + (s.name || s.base_url); + if (serverFormStatus) serverFormStatus.textContent = "Leave admin key blank to keep the saved key."; +} + +function serverPayloadFromForm() { + return { + id: srvID?.value || "", + name: srvName?.value.trim() || "", + base_url: srvBaseURL?.value.trim() || "", + admin_username: srvAdminUser?.value.trim() || "admin", + admin_key: srvAdminKey?.value || "", + enable_ssh: !!srvEnableSSH?.checked, + enable_xray: !!srvEnableXray?.checked, + is_active: !!srvIsActive?.checked, + }; +} + +async function saveServerForm() { + if (!serverFormStatus) return; + serverFormStatus.textContent = "Saving…"; + try { + const res = await api("/api/servers", { method:"POST", body: JSON.stringify(serverPayloadFromForm()) }); + if (!res.ok) throw new Error(await res.text()); + serverFormStatus.textContent = "Saved."; + clearServerForm(); + await loadServers(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else serverFormStatus.textContent = "Error: " + e.message; + } +} + +async function testServerForm() { + if (!serverFormStatus) return; + serverFormStatus.textContent = "Testing remote login…"; + try { + const res = await api("/api/servers/test", { method:"POST", body: JSON.stringify(serverPayloadFromForm()) }); + if (!res.ok) throw new Error(await res.text()); + serverFormStatus.textContent = "Connection OK."; + } catch (e) { + if (e.message === "auth") doAuthError(); + else serverFormStatus.textContent = "Test failed: " + e.message; + } +} + +async function deleteServer(s) { + if (!confirm(`Delete server "${s.name || s.base_url}"?`)) return; + try { + const res = await api(`/api/servers?id=${encodeURIComponent(s.id)}`, { method:"DELETE" }); + if (!res.ok && res.status !== 204) throw new Error(await res.text()); + await loadServers(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else serversStatus && (serversStatus.textContent = "Delete failed: " + e.message); + } +} + +function showServerListView() { + serversListView?.classList.remove("hidden"); + serverConfigSubpage?.classList.add("hidden"); + configuringServerID = ""; +} + +function openManagedServerConfig(id) { + configuringServerID = id || "local"; + const srv = serverByID(configuringServerID) || { name: "Master node" }; + if (cfgServerName) cfgServerName.textContent = srv.name || srv.base_url || configuringServerID; + serversListView?.classList.add("hidden"); + serverConfigSubpage?.classList.remove("hidden"); + loadManagedServerConfig(configuringServerID); +} + +function toggleManagedDnsttFields(on) { + const el = document.getElementById("managedDnsttFields"); + if (!el) return; + el.style.opacity = on ? "1" : ".4"; + el.style.pointerEvents = on ? "" : "none"; +} +function toggleManagedUdpgwFields(on) { + const el = document.getElementById("managedUdpgwFields"); + if (!el) return; + el.style.opacity = on ? "1" : ".4"; + el.style.pointerEvents = on ? "" : "none"; +} + +async function loadManagedServerConfig(id) { + if (!id) return; + const st = document.getElementById("managedConfigStatus"); + if (st) st.textContent = "Loading config…"; + try { + const res = await api(withServerParam("/api/servers/config", id)); + if (!res.ok) throw new Error(await res.text()); + const c = await res.json(); + + document.getElementById("managedCfgListen").value = c.listen || ""; + document.getElementById("managedCfgExtraListen").value = (c.extra_listen || []).join("\n"); + document.getElementById("managedCfgProxyAutoRestart").value = c.proxy_auto_restart_interval || ""; + document.getElementById("managedCfgProxyRestartGrace").value = c.proxy_auto_restart_grace || ""; + + document.getElementById("managedCfgLimitUp").value = c.default_limit_mbps_up || 0; + document.getElementById("managedCfgLimitDown").value = c.default_limit_mbps_down || 0; + document.getElementById("managedCfgQuiet").checked = !!c.quiet; + document.getElementById("managedCfgUserCount").checked = !!c.user_count; + document.getElementById("managedCfgBanner").value = c.banner || ""; + + const hasDnstt = !!c.dnstt; + document.getElementById("managedCfgDnsttEnabled").checked = hasDnstt; + toggleManagedDnsttFields(hasDnstt); + const d = c.dnstt || {}; + document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d); + document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || ""; + document.getElementById("managedCfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled; + document.getElementById("managedCfgDnsttFakeListen").value = d.fake_dns_listen || ""; + document.getElementById("managedCfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan"; + document.getElementById("managedCfgDnsttFakeWorkers").value = d.fake_dns_workers || 0; + document.getElementById("managedCfgDnsttRespWorkers").value = d.dns_response_workers || 0; + document.getElementById("managedCfgDnsttAutoRestart").value = d.auto_restart_interval || ""; + document.getElementById("managedCfgDnsttRestartGrace").value = d.auto_restart_grace || ""; + document.getElementById("managedCfgDnsttMaxSessions").value = d.max_sessions || 0; + document.getElementById("managedCfgDnsttMaxStreams").value = d.max_streams || 0; + document.getElementById("managedCfgDnsttPendingResponses").value = d.pending_responses || 0; + document.getElementById("managedCfgDnsttStreamBuffer").value = d.stream_buffer || 0; + document.getElementById("managedCfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0; + document.getElementById("managedCfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0; + document.getElementById("managedCfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key"; + document.getElementById("managedCfgDnsttNoStats").checked = !!d.disable_stats_log; + document.getElementById("managedCfgDnsttNoConsole").checked = !!d.disable_console_log; + document.getElementById("managedCfgDnsttLogConnections").checked = !!d.log_connections; + + const hasUdpgw = !!c.udpgw; + document.getElementById("managedCfgUdpgwEnabled").checked = hasUdpgw; + toggleManagedUdpgwFields(hasUdpgw); + const u = c.udpgw || {}; + document.getElementById("managedCfgUdpgwListen").value = u.listen || ""; + document.getElementById("managedCfgUdpgwMaxConns").value = u.max_client_conns || 0; + document.getElementById("managedCfgUdpgwIdle").value = u.idle_timeout || ""; + document.getElementById("managedCfgUdpgwMapTTL").value = u.map_ttl || ""; + document.getElementById("managedCfgUdpgwAutoRestart").value = u.auto_restart_interval || ""; + document.getElementById("managedCfgUdpgwRestartGrace").value = u.auto_restart_grace || ""; + document.getElementById("managedCfgUdpgwDebug").checked = !!u.debug; + + managedTlsForwardersState = c.tls_forwarders || []; + renderManagedTLSForwarders(); + + const x = c.xray || {}; + document.getElementById("managedCfgXrayEnabled").checked = !!x.enabled; + document.getElementById("managedCfgXrayMode").value = xrayModeFromConfig(x); + + document.getElementById("managedDnsttPubkeyWrap")?.classList.add("hidden"); + if (st) st.textContent = "Config loaded."; + } catch (e) { + if (e.message === "auth") doAuthError(); + else if (st) st.textContent = "Error: " + e.message; + } +} + +function managedConfigFromForm() { + const extraLines = document.getElementById("managedCfgExtraListen").value + .split("\n").map(s => s.trim()).filter(Boolean); + const dnsttDomains = readDnsttDomains("managedCfgDnsttDomains"); + return { + listen: document.getElementById("managedCfgListen").value.trim(), + extra_listen: extraLines, + proxy_auto_restart_interval: document.getElementById("managedCfgProxyAutoRestart").value.trim(), + proxy_auto_restart_grace: document.getElementById("managedCfgProxyRestartGrace").value.trim(), + host_key_file: "/opt/sshpanel/ssh_host_rsa_key", + admin_dir: "/opt/sshpanel/admin", + default_limit_mbps_up: parseInt(document.getElementById("managedCfgLimitUp").value || "0", 10), + default_limit_mbps_down: parseInt(document.getElementById("managedCfgLimitDown").value || "0", 10), + quiet: document.getElementById("managedCfgQuiet").checked, + user_count: document.getElementById("managedCfgUserCount").checked, + banner: document.getElementById("managedCfgBanner").value, + banner_file: "/opt/sshpanel/banner.txt", + dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? { + domain: dnsttDomains[0] || "", + domains: dnsttDomains, + udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim(), + fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked, + fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(), + fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(), + fake_dns_workers: parseInt(document.getElementById("managedCfgDnsttFakeWorkers").value || "0", 10), + dns_response_workers: parseInt(document.getElementById("managedCfgDnsttRespWorkers").value || "0", 10), + auto_restart_interval: document.getElementById("managedCfgDnsttAutoRestart").value.trim(), + auto_restart_grace: document.getElementById("managedCfgDnsttRestartGrace").value.trim(), + max_sessions: parseInt(document.getElementById("managedCfgDnsttMaxSessions").value || "0", 10), + max_streams: parseInt(document.getElementById("managedCfgDnsttMaxStreams").value || "0", 10), + pending_responses: parseInt(document.getElementById("managedCfgDnsttPendingResponses").value || "0", 10), + stream_buffer: parseInt(document.getElementById("managedCfgDnsttStreamBuffer").value || "0", 10), + udp_read_buffer: parseInt(document.getElementById("managedCfgDnsttUDPReadBuffer").value || "0", 10), + udp_write_buffer: parseInt(document.getElementById("managedCfgDnsttUDPWriteBuffer").value || "0", 10), + privkey_file: document.getElementById("managedCfgDnsttKey").value.trim(), + disable_stats_log: document.getElementById("managedCfgDnsttNoStats").checked, + disable_console_log: document.getElementById("managedCfgDnsttNoConsole").checked, + log_connections: document.getElementById("managedCfgDnsttLogConnections").checked, + } : null, + udpgw: document.getElementById("managedCfgUdpgwEnabled").checked ? { + listen: document.getElementById("managedCfgUdpgwListen").value.trim(), + max_client_conns: parseInt(document.getElementById("managedCfgUdpgwMaxConns").value || "0", 10), + idle_timeout: document.getElementById("managedCfgUdpgwIdle").value.trim(), + map_ttl: document.getElementById("managedCfgUdpgwMapTTL").value.trim(), + auto_restart_interval: document.getElementById("managedCfgUdpgwAutoRestart").value.trim(), + auto_restart_grace: document.getElementById("managedCfgUdpgwRestartGrace").value.trim(), + debug: document.getElementById("managedCfgUdpgwDebug").checked, + } : null, + tls_forwarders: managedTlsForwardersState, + xray: { + enabled: document.getElementById("managedCfgXrayEnabled").checked, + mode: document.getElementById("managedCfgXrayMode").value === "external" ? "external" : "native", + native: document.getElementById("managedCfgXrayMode").value !== "external", + bin_path: "/opt/sshpanel/xray", + config_file: "/opt/sshpanel/xray_config.json", + native_config_file: "/opt/sshpanel/xray_native_config.json", + api_server: "127.0.0.1:10085", + online_window_seconds: 90, + stats_poll_seconds: 15, + }, + }; +} + +async function saveManagedServerConfig() { + if (!configuringServerID) return; + const st = document.getElementById("managedConfigStatus"); + if (st) st.textContent = "Saving config…"; + try { + const cfg = managedConfigFromForm(); + const res = await api(withServerParam("/api/servers/config", configuringServerID), { 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(" | "); + if (st) st.textContent = "Saved live with warnings: " + [...warnings, badText].filter(Boolean).join(" | "); + } else if (st) { + st.textContent = "Saved and applied live."; + } + } catch (e) { + if (e.message === "auth") doAuthError(); + else if (st) st.textContent = "Error: " + e.message; + } +} + +function renderManagedTLSForwarders() { + const list = document.getElementById("managedTlsForwardersList"); + const chip = document.getElementById("managedTlsCountChip"); + if (!list) return; + if (chip) chip.textContent = managedTlsForwardersState.length; + if (!managedTlsForwardersState.length) { + list.innerHTML = '
No TLS forwarders configured.
'; + return; + } + list.innerHTML = ""; + managedTlsForwardersState.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 = `${escapeHTML(fw.listen || "")} + ${escapeHTML(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 = () => { managedTlsForwardersState.splice(i,1); renderManagedTLSForwarders(); }; + row.appendChild(delBtn); + list.appendChild(row); + }); +} + +function toggleManagedAddTLSForm() { + const panel = document.getElementById("managedAddTLSPanel"); + if (!panel) return; + panel.classList.toggle("hidden"); + if (!panel.classList.contains("hidden")) { + document.getElementById("managedTlsAddStatus").textContent = ""; + document.getElementById("managedTlsListenAddr").value = ""; + document.getElementById("managedTlsSSLDomain").value = ""; + document.getElementById("managedTlsCertType").value = "selfsigned"; + onManagedTLSTypeChange("selfsigned"); + } +} + +function onManagedTLSTypeChange(val) { + const setVisible = (id, on, display = "") => { + const el = document.getElementById(id); + if (!el) return; + el.classList.toggle("hidden", !on); + el.style.display = on ? display : "none"; + }; + setVisible("managedTlsSSFields", val === "selfsigned", ""); + setVisible("managedTlsLEFields", val === "letsencrypt", "grid"); + setVisible("managedTlsPasteFields", val === "paste", ""); + setVisible("managedTlsCustomFields", val === "custom", "grid"); +} + +async function addManagedTLSForwarder() { + const st = document.getElementById("managedTlsAddStatus"); + const listen = document.getElementById("managedTlsListenAddr").value.trim(); + const certType = document.getElementById("managedTlsCertType").value; + if (!listen) { st.textContent = "Listen address required."; return; } + let certFile = "", keyFile = ""; + st.textContent = "Processing…"; + if (certType === "selfsigned") { + const domain = document.getElementById("managedTlsSSLDomain").value.trim(); + if (!domain) { st.textContent = "Domain required."; return; } + try { + const res = await api(withServerParam("/api/tls/generate-selfsigned", configuringServerID), { 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("managedTlsLEDomain").value.trim(); + const email = document.getElementById("managedTlsLEEmail").value.trim(); + if (!domain || !email) { st.textContent = "Domain and email required."; return; } + st.textContent = "Running certbot… (may take ~30s)"; + try { + const res = await api(withServerParam("/api/tls/letsencrypt", configuringServerID), { 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("managedTlsPasteName").value.trim(); + const cert = document.getElementById("managedTlsPasteCert").value.trim(); + const key = document.getElementById("managedTlsPasteKey").value.trim(); + if (!name || !cert || !key) { st.textContent = "Name, cert PEM, and key PEM required."; return; } + try { + const res = await api(withServerParam("/api/tls/upload-pem", configuringServerID), { 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("managedTlsCustomCert").value.trim(); + keyFile = document.getElementById("managedTlsCustomKey").value.trim(); + if (!certFile || !keyFile) { st.textContent = "Cert and key paths required."; return; } + } + managedTlsForwardersState.push({ listen, cert_file: certFile, key_file: keyFile }); + renderManagedTLSForwarders(); + document.getElementById("managedAddTLSPanel").classList.add("hidden"); + st.textContent = "Added. Save config to apply."; +} + +async function generateManagedDnsttKey() { + const st = document.getElementById("managedDnsttKeyStatus"); + if (st) st.textContent = "Generating key…"; + try { + const res = await api(withServerParam("/api/dnstt/genkey", configuringServerID), { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + document.getElementById("managedCfgDnsttKey").value = data.privkey_file || "/opt/sshpanel/dnstt.key"; + if (st) st.textContent = "Key generated. Save config to apply."; + await loadManagedDnsttPubkey(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else if (st) st.textContent = "Error: " + e.message; + } +} + +async function loadManagedDnsttPubkey() { + const st = document.getElementById("managedDnsttKeyStatus"); + if (st) st.textContent = "Loading public key…"; + try { + const res = await api(withServerParam("/api/dnstt/pubkey", configuringServerID)); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + const val = data.public_key || data.pubkey || ""; + document.getElementById("managedDnsttPubkeyVal").value = val; + document.getElementById("managedDnsttPubkeyWrap")?.classList.remove("hidden"); + if (st) st.textContent = val ? "Public key loaded." : "No public key returned."; + } catch (e) { + if (e.message === "auth") doAuthError(); + else if (st) st.textContent = "Error: " + e.message; + } +} + + diff --git a/admin/assets/js/07-stats-logs.js b/admin/assets/js/07-stats-logs.js new file mode 100644 index 0000000..34102a8 --- /dev/null +++ b/admin/assets/js/07-stats-logs.js @@ -0,0 +1,276 @@ +// ─── Stats ──────────────────────────────────────────────────────────────────── +document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats); + +async function loadDashboardStats() { + try { + const res = await api("/api/stats"); + if (!res.ok) throw new Error(await res.text()); + const s = await res.json(); + updateDashboardStats(s); + await loadDnsttHealth(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else { + if (dashCpuVal) dashCpuVal.textContent = "erro"; + if (dashRamVal) dashRamVal.textContent = "erro"; + if (dashNetVal) dashNetVal.textContent = "erro"; + } + } +} + +function updateDashboardStats(s) { + if (!s) return; + const cpu = Number(s.cpu_percent ?? 0); + const mem = s.mem_percent == null ? null : Number(s.mem_percent); + if (dashCpuVal) dashCpuVal.textContent = fmtPct(cpu); + if (dashCpuBar) dashCpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%"; + if (dashCpuText) dashCpuText.textContent = cpu >= 85 ? "Carga alta" : cpu >= 60 ? "Carga moderada" : "Carga normal"; + if (dashRamVal) dashRamVal.textContent = mem == null ? "--%" : fmtPct(mem); + if (dashRamBar) dashRamBar.style.width = mem == null ? "0%" : Math.min(100, Math.max(0, mem)) + "%"; + if (dashRamText) { + const used = s.mem_used_bytes, total = s.mem_total_bytes; + dashRamText.textContent = used != null && total != null ? `${fmtBytes(used)} / ${fmtBytes(total)}` : "Memória usada"; + } + const ifaces = Array.isArray(s.interfaces) ? s.interfaces : []; + let rx = 0, tx = 0, rxTotal = 0, txTotal = 0; + ifaces.forEach(it => { + rx += Number(it.rx_mbps || 0); + tx += Number(it.tx_mbps || 0); + rxTotal += Number(it.rx_bytes || 0); + txTotal += Number(it.tx_bytes || 0); + }); + if (dashNetVal) dashNetVal.textContent = `${fmtMbps(rx + tx)} Mb/s`; + if (dashNetText) dashNetText.textContent = `RX ${fmtMbps(rx)} · TX ${fmtMbps(tx)} Mb/s`; + if (dashNetTotal) dashNetTotal.textContent = `Total ${fmtBytes(rxTotal + txTotal)}`; +} + +async function loadDnsttHealth() { + if (!dnsttDashboardCard && !dnsttHealthBody && !dnsttActiveSessions) return; + if (currentRole !== "superadmin") { + dnsttDashboardCard?.classList.add("hidden"); + return; + } + try { + const res = await api("/api/dnstt"); + if (!res.ok) throw new Error(await res.text()); + const d = await res.json(); + const enabled = d.enabled !== false; + if (dnsttDashboardCard) dnsttDashboardCard.classList.toggle("hidden", !enabled); + if (!enabled) return; + + if (dnsttActiveSessions) dnsttActiveSessions.textContent = fmtInt(d.active_sessions); + if (dnsttActiveStreams) dnsttActiveStreams.textContent = fmtInt(d.active_streams); + if (dnsttDNSRx) dnsttDNSRx.textContent = fmtInt(d.dns_rx); + if (dnsttQueueLen) dnsttQueueLen.textContent = fmtInt(d.ch_len); + if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = d.running === false ? "DNSTT stopped" : fmtDnsttTimestamp(d.timestamp); + + const rows = [ + ["Session rejected", d.sess_rejected], + ["Stream rejected", d.stream_rejected], + ["DNS parse errors", d.parse_err], + ["No EDNS", d.no_edns], + ["EDNS limit 512", d.limit512], + ["Local DNS workers", d.fake_dns_workers], + ["Response workers", d.dns_response_workers], + ["Responses queued", d.rec_queued], + ["Responses dropped", d.rec_dropped], + ["Responses sent", d.resp_sent], + ["Response bytes", d.resp_bytes], + ["Empty responses", d.resp_empty], + ["Data responses", d.resp_data], + ["Oversize responses", d.resp_oversize], + ["KCP sessions new", d.kcp_new], + ["KCP sessions ended", d.kcp_end], + ["SMUX streams new", d.smux_new], + ["SMUX streams ended", d.smux_end], + ["Panic recovered", d.panic_recovered], + ]; + if (dnsttHealthBody) { + dnsttHealthBody.innerHTML = ""; + for (let i = 0; i < rows.length; i += 2) { + const a = rows[i]; + const b = rows[i + 1] || ["", ""]; + const tr = document.createElement("tr"); + tr.innerHTML = `${a[0]}${fmtInt(a[1])}${b[0]}${b[0] ? fmtInt(b[1]) : ""}`; + dnsttHealthBody.appendChild(tr); + } + } + const bad = Number(d.sess_rejected || 0) + Number(d.stream_rejected || 0) + Number(d.rec_dropped || 0) + Number(d.panic_recovered || 0); + if (dnsttHealthSummary) { + if (d.running === false) { + dnsttHealthSummary.textContent = "DNSTT is enabled but not running. Check key/domain/listen config or recent logs."; + } else { + dnsttHealthSummary.textContent = bad > 0 + ? `Attention: ${fmtInt(bad)} overload/recovery events in the last DNSTT stats window.` + : "DNSTT health OK: no rejects, drops, or recovered panics in the last stats window."; + } + } + } catch (e) { + if (e.message === "auth") throw e; + dnsttDashboardCard?.classList.add("hidden"); + if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = "Error loading DNSTT stats."; + if (dnsttHealthSummary) dnsttHealthSummary.textContent = e.message || "DNSTT stats unavailable."; + } +} + + +async function loadStats() { + try { + const res = await api("/api/stats"); + if (!res.ok) throw new Error(await res.text()); + const s = await res.json(); + updateDashboardStats(s); + const cpu = Number(s?.cpu_percent ?? 0); + if (cpuVal) cpuVal.textContent = fmtPct(cpu); + if (cpuBar) cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%"; + const mp = s?.mem_percent == null ? null : Number(s.mem_percent); + if (memVal) memVal.textContent = mp == null ? "--%" : fmtPct(mp); + if (memBar) memBar.style.width = mp == null ? "0%" : Math.min(100, Math.max(0, mp)) + "%"; + const mu = s?.mem_used_bytes, mt = s?.mem_total_bytes; + if (memDetail) memDetail.textContent = (mu != null && mt != null) ? `${fmtBytes(mu)} / ${fmtBytes(mt)}` : ""; + const ifaces = Array.isArray(s.interfaces) ? s.interfaces : []; + if (ifaceBody) ifaceBody.innerHTML = ""; + let totRx = 0, totTx = 0; + ifaces.forEach(it => { + totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0); + if (!ifaceBody) return; + 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); + }); + if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; + if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString(); + await loadDnsttHealth(); + } catch (e) { + if (e.message==="auth") doAuthError(); + else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats."; + } +} + +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; + } +} + diff --git a/admin/assets/js/08-server-config.js b/admin/assets/js/08-server-config.js new file mode 100644 index 0000000..3400c97 --- /dev/null +++ b/admin/assets/js/08-server-config.js @@ -0,0 +1,467 @@ +// ─── Server Config ──────────────────────────────────────────────────────────── +document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig); + + +function dnsttDomainsText(d) { + const domains = Array.isArray(d?.domains) && d.domains.length ? d.domains : (d?.domain ? [d.domain] : []); + return domains.join("\n"); +} +function readDnsttDomains(id) { + const el = document.getElementById(id); + if (!el) return []; + const seen = new Set(); + return el.value.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\.$/, "").toLowerCase()).filter(s => { + if (seen.has(s)) return false; + seen.add(s); + return true; + }); +} + +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"; +} + + +const XRAY_NATIVE_TUNING_DEFAULTS = { + safe: { + runtime_gomaxprocs: 0, + mux_max_sessions: 64, + mux_global_sessions: 8192, + mux_udp_idle_ms: 120000, + mux_udp_read_buffer: 131072, + mux_udp_write_buffer: 131072, + xhttp_max_sessions: 4096, + xhttp_buffered_posts: 128, + h2_max_concurrent_streams: 256, + h2_upload_buffer_conn: 1048576, + h2_upload_buffer_stream: 262144, + trace_packets: false, + }, + "2k": { + runtime_gomaxprocs: 0, + mux_max_sessions: 128, + mux_global_sessions: 32768, + mux_udp_idle_ms: 120000, + mux_udp_read_buffer: 262144, + mux_udp_write_buffer: 262144, + xhttp_max_sessions: 16384, + xhttp_buffered_posts: 256, + h2_max_concurrent_streams: 1024, + h2_upload_buffer_conn: 1048576, + h2_upload_buffer_stream: 262144, + trace_packets: false, + }, +}; + +const XRAY_NATIVE_TUNING_FIELDS = { + runtime_gomaxprocs: "cfgXrayRuntimeGomaxprocs", + mux_max_sessions: "cfgXrayMuxMaxSessions", + mux_global_sessions: "cfgXrayMuxGlobalSessions", + mux_udp_idle_ms: "cfgXrayMuxUdpIdleMs", + mux_udp_read_buffer: "cfgXrayMuxUdpRbuf", + mux_udp_write_buffer: "cfgXrayMuxUdpWbuf", + xhttp_max_sessions: "cfgXrayXhttpMaxSessions", + xhttp_buffered_posts: "cfgXrayXhttpBufferedPosts", + h2_max_concurrent_streams: "cfgXrayH2MaxStreams", + h2_upload_buffer_conn: "cfgXrayH2UploadConn", + h2_upload_buffer_stream: "cfgXrayH2UploadStream", +}; + +function setXrayNativeTuningDefaults(profile = "2k") { + const t = XRAY_NATIVE_TUNING_DEFAULTS[profile] || XRAY_NATIVE_TUNING_DEFAULTS.safe; + writeXrayNativeTuning(t); +} + +function writeXrayNativeTuning(t = {}) { + const defaults = XRAY_NATIVE_TUNING_DEFAULTS.safe; + Object.entries(XRAY_NATIVE_TUNING_FIELDS).forEach(([key, id]) => { + const el = document.getElementById(id); + if (el) el.value = Number(t[key] || defaults[key] || 0); + }); + const trace = document.getElementById("cfgXrayTracePackets"); + if (trace) trace.checked = !!t.trace_packets; +} + +function readXrayNativeTuning() { + const out = {}; + Object.entries(XRAY_NATIVE_TUNING_FIELDS).forEach(([key, id]) => { + const el = document.getElementById(id); + out[key] = parseInt(el?.value || "0", 10) || 0; + }); + out.trace_packets = !!document.getElementById("cfgXrayTracePackets")?.checked; + return out; +} + +// Inline onclick handlers in index.html need this exposed on window explicitly. +window.setXrayNativeTuningDefaults = setXrayNativeTuningDefaults; + +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"); + document.getElementById("cfgProxyAutoRestart").value = c.proxy_auto_restart_interval || ""; + document.getElementById("cfgProxyRestartGrace").value = c.proxy_auto_restart_grace || ""; + + // 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("cfgDnsttDomains").value = dnsttDomainsText(d); + document.getElementById("cfgDnsttUDP").value = d.udp_listen || ""; + document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled; + document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || ""; + document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan"; + document.getElementById("cfgDnsttFakeWorkers").value = d.fake_dns_workers || 0; + document.getElementById("cfgDnsttRespWorkers").value = d.dns_response_workers || 0; + document.getElementById("cfgDnsttAutoRestart").value = d.auto_restart_interval || ""; + document.getElementById("cfgDnsttRestartGrace").value = d.auto_restart_grace || ""; + document.getElementById("cfgDnsttMaxSessions").value = d.max_sessions || 0; + document.getElementById("cfgDnsttMaxStreams").value = d.max_streams || 0; + document.getElementById("cfgDnsttPendingResponses").value = d.pending_responses || 0; + document.getElementById("cfgDnsttStreamBuffer").value = d.stream_buffer || 0; + document.getElementById("cfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0; + document.getElementById("cfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0; + 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; + document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections; + + // 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("cfgUdpgwAutoRestart").value = u.auto_restart_interval || ""; + document.getElementById("cfgUdpgwRestartGrace").value = u.auto_restart_grace || ""; + document.getElementById("cfgUdpgwDebug").checked = !!u.debug; + + // TLS forwarders + tlsForwardersState = c.tls_forwarders || []; + renderTLSForwarders(); + + // Xray + const x = c.xray || {}; + document.getElementById("cfgXrayEnabled").checked = !!x.enabled; + document.getElementById("cfgXrayMode").value = xrayModeFromConfig(x); + writeXrayNativeTuning(x.native_tuning || XRAY_NATIVE_TUNING_DEFAULTS.safe); + + 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 dnsttDomains = readDnsttDomains("cfgDnsttDomains"); + + const cfg = { + listen: document.getElementById("cfgListen").value.trim(), + extra_listen: extraLines, + proxy_auto_restart_interval: document.getElementById("cfgProxyAutoRestart").value.trim(), + proxy_auto_restart_grace: document.getElementById("cfgProxyRestartGrace").value.trim(), + 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: dnsttDomains[0] || "", + domains: dnsttDomains, + udp_listen: document.getElementById("cfgDnsttUDP").value.trim(), + fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked, + fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(), + fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(), + fake_dns_workers: parseInt(document.getElementById("cfgDnsttFakeWorkers").value || "0", 10), + dns_response_workers: parseInt(document.getElementById("cfgDnsttRespWorkers").value || "0", 10), + auto_restart_interval: document.getElementById("cfgDnsttAutoRestart").value.trim(), + auto_restart_grace: document.getElementById("cfgDnsttRestartGrace").value.trim(), + max_sessions: parseInt(document.getElementById("cfgDnsttMaxSessions").value || "0", 10), + max_streams: parseInt(document.getElementById("cfgDnsttMaxStreams").value || "0", 10), + pending_responses: parseInt(document.getElementById("cfgDnsttPendingResponses").value || "0", 10), + stream_buffer: parseInt(document.getElementById("cfgDnsttStreamBuffer").value || "0", 10), + udp_read_buffer: parseInt(document.getElementById("cfgDnsttUDPReadBuffer").value || "0", 10), + udp_write_buffer: parseInt(document.getElementById("cfgDnsttUDPWriteBuffer").value || "0", 10), + privkey_file: document.getElementById("cfgDnsttKey").value.trim(), + disable_stats_log: document.getElementById("cfgDnsttNoStats").checked, + disable_console_log: document.getElementById("cfgDnsttNoConsole").checked, + log_connections: document.getElementById("cfgDnsttLogConnections").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(), + auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart").value.trim(), + auto_restart_grace: document.getElementById("cfgUdpgwRestartGrace").value.trim(), + debug: document.getElementById("cfgUdpgwDebug").checked, + } : null, + tls_forwarders: tlsArr, + xray: { + enabled: document.getElementById("cfgXrayEnabled").checked, + mode: document.getElementById("cfgXrayMode").value === "external" ? "external" : "native", + native: document.getElementById("cfgXrayMode").value !== "external", + bin_path: "/opt/sshpanel/xray", + config_file: "/opt/sshpanel/xray_config.json", + native_config_file: "/opt/sshpanel/xray_native_config.json", + api_server: "127.0.0.1:10085", + online_window_seconds: 90, + stats_poll_seconds: 15, + native_tuning: readXrayNativeTuning(), + }, + }; + + 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) { + const setVisible = (id, on, display = "") => { + const el = document.getElementById(id); + if (!el) return; + el.classList.toggle("hidden", !on); + el.style.display = on ? display : "none"; + }; + setVisible("tlsSSFields", val === "selfsigned", ""); + setVisible("tlsLEFields", val === "letsencrypt", "grid"); + setVisible("tlsPasteFields", val === "paste", ""); + setVisible("tlsCustomFields", val === "custom", "grid"); +} + +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; + } +} + diff --git a/admin/assets/js/09-xray-wizard.js b/admin/assets/js/09-xray-wizard.js new file mode 100644 index 0000000..2d03aca --- /dev/null +++ b/admin/assets/js/09-xray-wizard.js @@ -0,0 +1,360 @@ +// ─── 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), + server_id: selectedXrayServer(), + }; + 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({ force: true }); }, 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(); + } +} + +document.getElementById("wzLogLevel")?.addEventListener("change", () => { wzDirty = true; }); + +function cloneJsonSafe(obj) { + return obj && typeof obj === "object" ? JSON.parse(JSON.stringify(obj)) : obj; +} + +function loadWizardFromConfig() { + const serverID = selectedXrayServer(); + const target = selectedXrayServerLabel(); + const st = document.getElementById("wzStatus"); + wzLoadedServerID = null; + wzDirty = false; + if (st) st.textContent = `Loading config from ${target}...`; + api(withServerParam("/api/xray/config", serverID)).then(async res => { + if (!res.ok) throw new Error(await res.text()); + const raw = await res.text(); + const cfg = JSON.parse(raw); + wzLoadedServerID = serverID || "local"; + wzLoadedConfigText = raw; + wzLoadedFullConfig = cloneJsonSafe(cfg); + document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning"; + wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || []; + renderWzInbounds(); + wzDirty = false; + if (st) st.textContent = `Config loaded from ${target}.`; + }).catch(e => { + wzLoadedServerID = null; + wzLoadedConfigText = ""; + wzLoadedFullConfig = null; + wzInbounds = []; + renderWzInbounds(); + if (e.message === "auth") doAuthError(); + else if (st) st.textContent = "Error: " + e.message; + }); +} + +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); wzDirty = true; 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() || "/xhttp", + 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); + wzDirty = true; + renderWzInbounds(); + document.getElementById("wzAddInboundForm").classList.add("hidden"); + document.getElementById("wzPort").value = ""; + document.getElementById("wzTag").value = ""; + document.getElementById("wzListenIP").value = ""; +} + + +function buildConfigFromVisualEditor() { + const selectedID = selectedXrayServer() || "local"; + if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) { + throw new Error("config for this server is not loaded yet"); + } + + let cfg; + try { + cfg = JSON.parse(wzLoadedConfigText); + } catch (_) { + cfg = cloneJsonSafe(wzLoadedFullConfig || {}); + } + if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) { + throw new Error("loaded config is not an object"); + } + + // Preserve the selected server's full JSON exactly as the base. + // The visual tab is intentionally conservative: it only updates fields that + // are visible here, so pressing Save cannot wipe routing/outbounds/policy/etc. + cfg.log = cfg.log && typeof cfg.log === "object" ? cfg.log : {}; + cfg.log.loglevel = document.getElementById("wzLogLevel")?.value || cfg.log.loglevel || "warning"; + + const existingInbounds = Array.isArray(cfg.inbounds) ? cfg.inbounds : []; + const hiddenApiInbounds = existingInbounds.filter(ib => ib && ib.tag === "api"); + const visualInbounds = cloneJsonSafe((wzInbounds || []).filter(ib => ib && ib.tag !== "api")) || []; + cfg.inbounds = [...hiddenApiInbounds, ...visualInbounds]; + + return cfg; +} + +function updateFullConfigFromWizard() { + const cfg = buildConfigFromVisualEditor(); + wzLoadedFullConfig = cloneJsonSafe(cfg); + return cfg; +} + +async function applyWizardConfig() { + const st = document.getElementById("wzStatus"); + const target = selectedXrayServerLabel(); + const selectedID = selectedXrayServer() || "local"; + + if (String(wzLoadedServerID || "") !== String(selectedID) || !wzLoadedConfigText) { + if (st) st.textContent = `Reloading config from ${target} before saving...`; + loadWizardFromConfig(); + return; + } + + let cfg; + try { + cfg = buildConfigFromVisualEditor(); + } catch(e) { + if (st) st.textContent = `Invalid visual config: ${e.message}`; + return; + } + + if (st) st.textContent = `Saving config to ${target}...`; + try { + const body = JSON.stringify(cfg, null, 2); + const res = await api(withServerParam("/api/xray/config", selectedID), { method:"POST", body }); + if (!res.ok) throw new Error(await res.text()); + wzLoadedConfigText = body; + wzLoadedFullConfig = cloneJsonSafe(cfg); + wzLoadedServerID = selectedID; + wzDirty = false; + if (st) st.textContent = `Saved on ${target}. Restarting Xray...`; + await xrayCtrl("restart"); + if (st) st.textContent = `Config saved on ${target} and Xray restarted.`; + setTimeout(() => { loadXrayStatus(); loadInbounds({ force: true }); }, 700); + } catch (e) { + if (e.message==="auth") doAuthError(); + else if (st) st.textContent = "Error: " + e.message; + } +} + diff --git a/admin/assets/js/10-boot.js b/admin/assets/js/10-boot.js new file mode 100644 index 0000000..7e88f43 --- /dev/null +++ b/admin/assets/js/10-boot.js @@ -0,0 +1,27 @@ +// ─── Auth error ─────────────────────────────────────────────────────────────── +function doAuthError() { + sessionToken = ""; + localStorage.removeItem("SESSION_TOKEN"); + clearTimers(); + mainApp.classList.add("hidden"); + loginOverlay.classList.remove("hidden"); + loginErr.textContent = t("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 20bf827..9d2fd95 100644 --- a/admin/index.html +++ b/admin/index.html @@ -1191,6 +1191,7 @@
Native Xray scale tuning
+
@@ -1198,9 +1199,6 @@
-
-
-
@@ -1209,7 +1207,7 @@
-
These values are saved in the panel config and applied live on restart/reload. No systemd Environment lines are needed.
+
These values are saved in the panel config and applied live on restart/reload. Go CPU threads = 0 means all detected CPU cores. No systemd Environment lines are needed.
@@ -1235,6 +1233,18 @@ - + + + + + + + + + + + diff --git a/admin_script.js b/admin_script.js deleted file mode 100644 index e3c6e5d..0000000 --- a/admin_script.js +++ /dev/null @@ -1,1887 +0,0 @@ - -// ─── 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 = []; - -// ─── 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 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"); -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"); -const dnsttDashboardCard = document.getElementById("dnsttDashboardCard"); -const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated"); -const dnsttActiveSessions = document.getElementById("dnsttActiveSessions"); -const dnsttActiveStreams = document.getElementById("dnsttActiveStreams"); -const dnsttDNSRx = document.getElementById("dnsttDNSRx"); -const dnsttQueueLen = document.getElementById("dnsttQueueLen"); -const dnsttHealthBody = document.getElementById("dnsttHealthBody"); -const dnsttHealthSummary = document.getElementById("dnsttHealthSummary"); - -// 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 fmtInt(n) { - const v = Number(n); - return Number.isFinite(v) ? v.toLocaleString() : "--"; -} -function fmtDnsttTimestamp(ts) { - if (!ts) return "Waiting for DNSTT stats…"; - const d = new Date(ts); - if (!Number.isFinite(d.getTime()) || d.getFullYear() < 2020) return "Waiting for DNSTT stats…"; - return "Updated: " + d.toLocaleTimeString(); -} -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.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(".xray-admin-only").forEach(el => { - el.classList.toggle("hidden", currentRole !== "superadmin"); - }); - - resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller"); - dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller"); - - selectTab("dashboard"); - - if (currentRole === "superadmin") { - loadStats(); - statsTimer = setInterval(loadStats, 2000); - } else { - loadMe(); - } - xrayTimer = setInterval(loadXrayStatus, 7000); - - loadUsers(); - loadInbounds(); - usersTimer = setInterval(() => loadUsersSilent(), 3000); -} - -// ─── Me (reseller info) ─────────────────────────────────────────────────────── -async function loadMe() { - try { - const res = await api("/api/auth/me"); - const d = await res.json(); - 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", () => { - 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; - users.forEach(u => { - const on = (u.active_conns || 0) > 0; - if (on) online++; - 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); - }); - userCountChip.textContent = `${users.length} (${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(); - } 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(); - } 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("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 || "--"; - 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(); } -} - -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;"; - hdr.innerHTML = ` -
- ${ib.protocol} - ${ib.tag || "untagged"} - :${ib.port ?? "?"} -
- `; - 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"; - const clients = ib.clients || []; - if (!clients.length) { - tblWrap.innerHTML = '
No clients.
'; - } else { - const tbl = document.createElement("table"); - tbl.innerHTML = `NameUUIDEmailExpiryStatusMaxActions`; - 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.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, 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, 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 tr = document.createElement("tr"); - tr.innerHTML = ` - ${r.username} - ${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"} - `; - 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 loadDnsttHealth() { - if (!dnsttDashboardCard && !dnsttHealthBody && !dnsttActiveSessions) return; - if (currentRole !== "superadmin") { - dnsttDashboardCard?.classList.add("hidden"); - return; - } - try { - const res = await api("/api/dnstt"); - if (!res.ok) throw new Error(await res.text()); - const d = await res.json(); - const enabled = d.enabled !== false; - if (dnsttDashboardCard) dnsttDashboardCard.classList.toggle("hidden", !enabled); - if (!enabled) return; - - if (dnsttActiveSessions) dnsttActiveSessions.textContent = fmtInt(d.active_sessions); - if (dnsttActiveStreams) dnsttActiveStreams.textContent = fmtInt(d.active_streams); - if (dnsttDNSRx) dnsttDNSRx.textContent = fmtInt(d.dns_rx); - if (dnsttQueueLen) dnsttQueueLen.textContent = fmtInt(d.ch_len); - if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = d.running === false ? "DNSTT stopped" : fmtDnsttTimestamp(d.timestamp); - - const rows = [ - ["Session rejected", d.sess_rejected], - ["Stream rejected", d.stream_rejected], - ["DNS parse errors", d.parse_err], - ["No EDNS", d.no_edns], - ["EDNS limit 512", d.limit512], - ["Local DNS workers", d.fake_dns_workers], - ["Response workers", d.dns_response_workers], - ["Responses queued", d.rec_queued], - ["Responses dropped", d.rec_dropped], - ["Responses sent", d.resp_sent], - ["Response bytes", d.resp_bytes], - ["Empty responses", d.resp_empty], - ["Data responses", d.resp_data], - ["Oversize responses", d.resp_oversize], - ["KCP sessions new", d.kcp_new], - ["KCP sessions ended", d.kcp_end], - ["SMUX streams new", d.smux_new], - ["SMUX streams ended", d.smux_end], - ["Panic recovered", d.panic_recovered], - ]; - if (dnsttHealthBody) { - dnsttHealthBody.innerHTML = ""; - for (let i = 0; i < rows.length; i += 2) { - const a = rows[i]; - const b = rows[i + 1] || ["", ""]; - const tr = document.createElement("tr"); - tr.innerHTML = `${a[0]}${fmtInt(a[1])}${b[0]}${b[0] ? fmtInt(b[1]) : ""}`; - dnsttHealthBody.appendChild(tr); - } - } - const bad = Number(d.sess_rejected || 0) + Number(d.stream_rejected || 0) + Number(d.rec_dropped || 0) + Number(d.panic_recovered || 0); - if (dnsttHealthSummary) { - if (d.running === false) { - dnsttHealthSummary.textContent = "DNSTT is enabled but not running. Check key/domain/listen config or recent logs."; - } else { - dnsttHealthSummary.textContent = bad > 0 - ? `Attention: ${fmtInt(bad)} overload/recovery events in the last DNSTT stats window.` - : "DNSTT health OK: no rejects, drops, or recovered panics in the last stats window."; - } - } - } catch (e) { - if (e.message === "auth") throw e; - dnsttDashboardCard?.classList.add("hidden"); - if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = "Error loading DNSTT stats."; - if (dnsttHealthSummary) dnsttHealthSummary.textContent = e.message || "DNSTT stats unavailable."; - } -} - - -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(); - await loadDnsttHealth(); - } 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 dnsttDomainsText(d) { - const domains = Array.isArray(d?.domains) && d.domains.length ? d.domains : (d?.domain ? [d.domain] : []); - return domains.join("\n"); -} -function readDnsttDomains(id) { - const el = document.getElementById(id); - if (!el) return []; - const seen = new Set(); - return el.value.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\.$/, "").toLowerCase()).filter(s => { - if (seen.has(s)) return false; - seen.add(s); - return true; - }); -} - -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"; -} - - -const XRAY_NATIVE_TUNING_DEFAULTS = { - safe: { - mux_max_sessions: 128, - mux_global_sessions: 32768, - mux_udp_idle_ms: 15000, - mux_udp_read_buffer: 262144, - mux_udp_write_buffer: 262144, - xhttp_max_sessions: 16384, - xhttp_buffered_posts: 64, - xhttp_queue_timeout_ms: 250, - xhttp_flush_ms: 5, - xhttp_flush_bytes: 131072, - h2_max_concurrent_streams: 1024, - h2_upload_buffer_conn: 1048576, - h2_upload_buffer_stream: 262144, - trace_packets: false, - }, - "2k": { - mux_max_sessions: 128, - mux_global_sessions: 32768, - mux_udp_idle_ms: 15000, - mux_udp_read_buffer: 262144, - mux_udp_write_buffer: 262144, - xhttp_max_sessions: 16384, - xhttp_buffered_posts: 64, - xhttp_queue_timeout_ms: 250, - xhttp_flush_ms: 5, - xhttp_flush_bytes: 131072, - h2_max_concurrent_streams: 1024, - h2_upload_buffer_conn: 1048576, - h2_upload_buffer_stream: 262144, - trace_packets: false, - }, -}; - -const XRAY_NATIVE_TUNING_FIELDS = { - mux_max_sessions: "cfgXrayMuxMaxSessions", - mux_global_sessions: "cfgXrayMuxGlobalSessions", - mux_udp_idle_ms: "cfgXrayMuxUdpIdleMs", - mux_udp_read_buffer: "cfgXrayMuxUdpRbuf", - mux_udp_write_buffer: "cfgXrayMuxUdpWbuf", - xhttp_max_sessions: "cfgXrayXhttpMaxSessions", - xhttp_buffered_posts: "cfgXrayXhttpBufferedPosts", - xhttp_queue_timeout_ms: "cfgXrayXhttpQueueTimeoutMs", - xhttp_flush_ms: "cfgXrayXhttpFlushMs", - xhttp_flush_bytes: "cfgXrayXhttpFlushBytes", - h2_max_concurrent_streams: "cfgXrayH2MaxStreams", - h2_upload_buffer_conn: "cfgXrayH2UploadConn", - h2_upload_buffer_stream: "cfgXrayH2UploadStream", -}; - -function setXrayNativeTuningDefaults(profile = "2k") { - const t = XRAY_NATIVE_TUNING_DEFAULTS[profile] || XRAY_NATIVE_TUNING_DEFAULTS.safe; - writeXrayNativeTuning(t); -} - -function writeXrayNativeTuning(t = {}) { - const defaults = XRAY_NATIVE_TUNING_DEFAULTS.safe; - Object.entries(XRAY_NATIVE_TUNING_FIELDS).forEach(([key, id]) => { - const el = document.getElementById(id); - if (el) el.value = Number(t[key] || defaults[key] || 0); - }); - const trace = document.getElementById("cfgXrayTracePackets"); - if (trace) trace.checked = !!t.trace_packets; -} - -function readXrayNativeTuning() { - const out = {}; - Object.entries(XRAY_NATIVE_TUNING_FIELDS).forEach(([key, id]) => { - const el = document.getElementById(id); - out[key] = parseInt(el?.value || "0", 10) || 0; - }); - out.trace_packets = !!document.getElementById("cfgXrayTracePackets")?.checked; - return out; -} - -window.setXrayNativeTuningDefaults = setXrayNativeTuningDefaults; - -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"); - if (document.getElementById("cfgProxyAutoRestart")) document.getElementById("cfgProxyAutoRestart").value = c.proxy_auto_restart_interval || ""; - if (document.getElementById("cfgProxyRestartGrace")) document.getElementById("cfgProxyRestartGrace").value = c.proxy_auto_restart_grace || ""; - - // 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("cfgDnsttDomains").value = dnsttDomainsText(d); - document.getElementById("cfgDnsttUDP").value = d.udp_listen || ""; - if (document.getElementById("cfgDnsttFakeEnabled")) document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled; - if (document.getElementById("cfgDnsttFakeListen")) document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || ""; - if (document.getElementById("cfgDnsttFakeDomain")) document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan"; - if (document.getElementById("cfgDnsttFakeWorkers")) document.getElementById("cfgDnsttFakeWorkers").value = d.fake_dns_workers || 0; - if (document.getElementById("cfgDnsttRespWorkers")) document.getElementById("cfgDnsttRespWorkers").value = d.dns_response_workers || 0; - document.getElementById("cfgDnsttAutoRestart").value = d.auto_restart_interval || ""; - document.getElementById("cfgDnsttRestartGrace").value = d.auto_restart_grace || ""; - if (document.getElementById("cfgDnsttMaxSessions")) document.getElementById("cfgDnsttMaxSessions").value = d.max_sessions || 0; - if (document.getElementById("cfgDnsttMaxStreams")) document.getElementById("cfgDnsttMaxStreams").value = d.max_streams || 0; - if (document.getElementById("cfgDnsttPendingResponses")) document.getElementById("cfgDnsttPendingResponses").value = d.pending_responses || 0; - if (document.getElementById("cfgDnsttStreamBuffer")) document.getElementById("cfgDnsttStreamBuffer").value = d.stream_buffer || 0; - if (document.getElementById("cfgDnsttUDPReadBuffer")) document.getElementById("cfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0; - if (document.getElementById("cfgDnsttUDPWriteBuffer")) document.getElementById("cfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0; - 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; - if (document.getElementById("cfgDnsttLogConnections")) document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections; - - // 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 || ""; - if (document.getElementById("cfgUdpgwAutoRestart")) document.getElementById("cfgUdpgwAutoRestart").value = u.auto_restart_interval || ""; - if (document.getElementById("cfgUdpgwRestartGrace")) document.getElementById("cfgUdpgwRestartGrace").value = u.auto_restart_grace || ""; - document.getElementById("cfgUdpgwDebug").checked = !!u.debug; - - // TLS forwarders - tlsForwardersState = c.tls_forwarders || []; - renderTLSForwarders(); - - // Xray - const x = c.xray || {}; - document.getElementById("cfgXrayEnabled").checked = !!x.enabled; - if (document.getElementById("cfgXrayMode")) document.getElementById("cfgXrayMode").value = x.mode || (x.native === false ? "external" : "native"); - writeXrayNativeTuning(x.native_tuning || XRAY_NATIVE_TUNING_DEFAULTS.safe); - - 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 dnsttDomains = readDnsttDomains("cfgDnsttDomains"); - - const cfg = { - listen: document.getElementById("cfgListen").value.trim(), - extra_listen: extraLines, - proxy_auto_restart_interval: document.getElementById("cfgProxyAutoRestart") ? document.getElementById("cfgProxyAutoRestart").value.trim() : "", - proxy_auto_restart_grace: document.getElementById("cfgProxyRestartGrace") ? document.getElementById("cfgProxyRestartGrace").value.trim() : "", - 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: dnsttDomains[0] || "", - domains: dnsttDomains, - udp_listen: document.getElementById("cfgDnsttUDP").value.trim(), - fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled") ? document.getElementById("cfgDnsttFakeEnabled").checked : false, - fake_dns_listen: document.getElementById("cfgDnsttFakeListen") ? document.getElementById("cfgDnsttFakeListen").value.trim() : "", - fake_dns_domain: document.getElementById("cfgDnsttFakeDomain") ? document.getElementById("cfgDnsttFakeDomain").value.trim() : "", - fake_dns_workers: parseInt(document.getElementById("cfgDnsttFakeWorkers") ? document.getElementById("cfgDnsttFakeWorkers").value || "0" : "0", 10), - dns_response_workers: parseInt(document.getElementById("cfgDnsttRespWorkers") ? document.getElementById("cfgDnsttRespWorkers").value || "0" : "0", 10), - auto_restart_interval: document.getElementById("cfgDnsttAutoRestart").value.trim(), - auto_restart_grace: document.getElementById("cfgDnsttRestartGrace").value.trim(), - max_sessions: parseInt(document.getElementById("cfgDnsttMaxSessions") ? document.getElementById("cfgDnsttMaxSessions").value || "0" : "0", 10), - max_streams: parseInt(document.getElementById("cfgDnsttMaxStreams") ? document.getElementById("cfgDnsttMaxStreams").value || "0" : "0", 10), - pending_responses: parseInt(document.getElementById("cfgDnsttPendingResponses") ? document.getElementById("cfgDnsttPendingResponses").value || "0" : "0", 10), - stream_buffer: parseInt(document.getElementById("cfgDnsttStreamBuffer") ? document.getElementById("cfgDnsttStreamBuffer").value || "0" : "0", 10), - udp_read_buffer: parseInt(document.getElementById("cfgDnsttUDPReadBuffer") ? document.getElementById("cfgDnsttUDPReadBuffer").value || "0" : "0", 10), - udp_write_buffer: parseInt(document.getElementById("cfgDnsttUDPWriteBuffer") ? document.getElementById("cfgDnsttUDPWriteBuffer").value || "0" : "0", 10), - privkey_file: document.getElementById("cfgDnsttKey").value.trim(), - disable_stats_log: document.getElementById("cfgDnsttNoStats").checked, - disable_console_log: document.getElementById("cfgDnsttNoConsole").checked, - log_connections: document.getElementById("cfgDnsttLogConnections") ? document.getElementById("cfgDnsttLogConnections").checked : false, - } : 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(), - auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart") ? document.getElementById("cfgUdpgwAutoRestart").value.trim() : "", - auto_restart_grace: document.getElementById("cfgUdpgwRestartGrace") ? document.getElementById("cfgUdpgwRestartGrace").value.trim() : "", - debug: document.getElementById("cfgUdpgwDebug").checked, - } : null, - tls_forwarders: tlsArr, - xray: { - enabled: document.getElementById("cfgXrayEnabled").checked, - mode: document.getElementById("cfgXrayMode")?.value || "native", - native: (document.getElementById("cfgXrayMode")?.value || "native") !== "external", - bin_path: "/opt/sshpanel/xray", - config_file: "/opt/sshpanel/xray_config.json", - native_config_file: "/opt/sshpanel/xray_native_config.json", - native_tuning: readXrayNativeTuning(), - }, - }; - - 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 || []; - 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 cfg = { - log: { loglevel: document.getElementById("wzLogLevel").value }, - inbounds: wzInbounds, - outbounds: [ - { tag:"direct", protocol:"freedom", settings:{} }, - { tag:"blocked", protocol:"blackhole", settings:{} } - ] - }; - 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/go.mod b/go.mod index 204406e..1f4dbf8 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/xtaci/smux v1.5.50 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect www.bamsoftware.com/git/dnstt.git v1.20241021.0 // indirect ) diff --git a/go.sum b/go.sum index abce5e9..2b7ea0d 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/xray_native.go b/xray_native.go index a09213a..6474bd4 100644 --- a/xray_native.go +++ b/xray_native.go @@ -668,7 +668,7 @@ type trafficMeter struct { n int64 } -const trafficFlushThreshold = 64 * 1024 +const trafficFlushThreshold = 1024 * 1024 func (t *trafficMeter) add(n int) { t.n += int64(n) diff --git a/xray_native_mux.go b/xray_native_mux.go index 605e6bb..903cbaf 100644 --- a/xray_native_mux.go +++ b/xray_native_mux.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/binary" "fmt" "io" @@ -47,6 +48,28 @@ type nativeMuxPacket struct { discard bool } +// nativeMuxUplinkItem is one client->backend datagram/segment handed from the +// shared mux read loop to a session's own uplink goroutine. The payload is a +// private copy because the read loop reuses its scratch buffer immediately. +type nativeMuxUplinkItem struct { + payload []byte + host string + port uint16 +} + +// nativeMuxUplinkQueue bounds how many un-written uplink items a single mux +// session may buffer before the shared read loop applies backpressure. This +// isolates a slow/backpressured backend to its own session instead of stalling +// every other session multiplexed on the same client connection. +const nativeMuxUplinkQueue = 64 + +var nativeMuxFramePool = sync.Pool{ + New: func() any { + b := make([]byte, 0, 2+512+2+nativeUDPMaxPacket) + return &b + }, +} + type nativeMuxSession struct { id uint16 network byte @@ -72,7 +95,11 @@ type nativeMuxSession struct { upMeter *trafficMeter downMeter *trafficMeter + uplink chan nativeMuxUplinkItem closed chan struct{} + closeOnce sync.Once + ctx context.Context + cancel context.CancelFunc onClose func(*nativeMuxSession) releaseSlot func() globalID [8]byte @@ -110,6 +137,7 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e writeMu := &sync.Mutex{} sessions := make(map[uint16]*nativeMuxSession) xudpSessions := make(map[[8]byte]*nativeMuxSession) + readScratch := make([]byte, 0, nativeUDPMaxPacket) var mu sync.Mutex removeSession := func(s *nativeMuxSession) { @@ -170,12 +198,12 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e switch meta.status { case nativeMuxStatusKeepAlive: if meta.option&nativeMuxOptionData != 0 { - _, _ = readNativeMuxDataBlock(stream) + _, readScratch, _ = readNativeMuxDataBlockScratch(stream, readScratch) } case nativeMuxStatusEnd: if meta.option&nativeMuxOptionData != 0 { - _, _ = readNativeMuxDataBlock(stream) + _, readScratch, _ = readNativeMuxDataBlockScratch(stream, readScratch) } closeSession(meta.sessionID) @@ -183,7 +211,7 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e if meta.network != nativeMuxNetworkTCP && meta.network != nativeMuxNetworkUDP { xrayLogf("native xray: VLESS mux session %d unsupported network %d", meta.sessionID, meta.network) if meta.option&nativeMuxOptionData != 0 { - _, _ = readNativeMuxDataBlock(stream) + _, readScratch, _ = readNativeMuxDataBlockScratch(stream, readScratch) } continue } @@ -228,7 +256,7 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e } if meta.option&nativeMuxOptionData != 0 { - pkt.payload, err = readNativeMuxDataBlock(stream) + pkt.payload, readScratch, err = readNativeMuxDataBlockScratch(stream, readScratch) if err != nil { xrayLogf("native xray: VLESS mux first packet read failed session=%d: %v", meta.sessionID, err) continue @@ -237,7 +265,10 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e s, target, err := ib.newNativeMuxSession(meta.sessionID, meta.network, targetHost, targetPort, isXUDP, meta.globalID, stream, writeMu, uuid, email, removeSession) if err != nil { - xrayLogf("native xray: VLESS mux dial %s failed: %v", target, err) + xrayLogf("native xray: VLESS mux session %s setup failed: %v", target, err) + writeMu.Lock() + _ = writeNativeMuxEnd(stream, meta.sessionID, true) + writeMu.Unlock() continue } @@ -255,9 +286,15 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e mu.Unlock() xrayTracef("native xray: vless/mux %s user=%s -> %s session=%d xudp=%v", nativeMuxNetworkName(meta.network), email, target, meta.sessionID, isXUDP) - xrayGo(fmt.Sprintf("native xray mux backend session=%d", s.id), func() { s.readBackendLoop() }) + // Dial and pump this session on its own goroutine so a slow-connecting + // target or a backpressured/rate-limited backend never blocks the shared + // read loop and therefore never stalls the other multiplexed sessions. + // The first payload is enqueued (a copy) before run() finishes dialing; + // the session's uplink loop writes it first once the backend is up. + ib2, host2, port2 := ib, targetHost, targetPort + xrayGo(fmt.Sprintf("native xray mux session=%d", s.id), func() { s.run(ib2, host2, port2) }) if len(pkt.payload) > 0 { - s.writeBackend(pkt.payload, pkt.host, pkt.port) + s.enqueueUplink(pkt.payload, pkt.host, pkt.port) } case nativeMuxStatusKeep: @@ -271,7 +308,7 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e continue } pkt := nativeMuxPacket{} - pkt.payload, err = readNativeMuxDataBlock(stream) + pkt.payload, readScratch, err = readNativeMuxDataBlockScratch(stream, readScratch) if err != nil { xrayLogf("native xray: VLESS mux keep packet read failed session=%d: %v", meta.sessionID, err) if s != nil { @@ -295,26 +332,28 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e pkt.port = meta.port } if len(pkt.payload) > 0 { - s.writeBackend(pkt.payload, pkt.host, pkt.port) + s.enqueueUplink(pkt.payload, pkt.host, pkt.port) } default: xrayLogf("native xray: VLESS mux unknown status %d", meta.status) if meta.option&nativeMuxOptionData != 0 { - _, _ = readNativeMuxDataBlock(stream) + _, readScratch, _ = readNativeMuxDataBlockScratch(stream, readScratch) } } } } +// newNativeMuxSession allocates a session and reserves a global slot but does +// NOT dial the backend. Dialing happens later in run() on the session's own +// goroutine, so the shared mux read loop is never blocked by a slow connect. func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host string, port uint16, xudp bool, globalID [8]byte, client io.Writer, writeMu *sync.Mutex, uuid, email string, onClose func(*nativeMuxSession)) (*nativeMuxSession, string, error) { + target := net.JoinHostPort(normalizeNativeTargetHost(host), strconv.Itoa(int(port))) if invalidNativeDestination(host, port) { - target := net.JoinHostPort(normalizeNativeTargetHost(host), strconv.Itoa(int(port))) return nil, target, fmt.Errorf("invalid destination") } releaseSlot, ok := acquireNativeMuxGlobalSlot() if !ok { - target := net.JoinHostPort(normalizeNativeTargetHost(host), strconv.Itoa(int(port))) return nil, target, fmt.Errorf("global mux session limit reached") } s := &nativeMuxSession{ @@ -329,31 +368,114 @@ func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host strin downLimiter: ib.downLimiter(), upMeter: &trafficMeter{uuid: uuid, email: email, uplink: true}, downMeter: &trafficMeter{uuid: uuid, email: email, uplink: false}, + uplink: make(chan nativeMuxUplinkItem, nativeMuxUplinkQueue), closed: make(chan struct{}), onClose: onClose, releaseSlot: releaseSlot, globalID: globalID, } + s.ctx, s.cancel = context.WithCancel(context.Background()) + return s, target, nil +} - if network == nativeMuxNetworkTCP { +// run dials/opens the backend for a mux session, then pumps client->backend +// data from the session's uplink channel. It owns the backend reader goroutine. +// Because this runs off the shared read loop, a slow dial or a congested backend +// only ever affects this one session. +func (s *nativeMuxSession) run(ib *nativeInbound, host string, port uint16) { + defer xrayRecover(fmt.Sprintf("native xray mux run session=%d", s.id)) + + // Abort early if the session was torn down (client sent End, connection + // closed, or a duplicate New replaced it) before we even dialed. + select { + case <-s.closed: + s.failInit(false) + return + default: + } + + if s.network == nativeMuxNetworkTCP { backend, target, err := ib.nativeDialTCP(host, port) if err != nil { - releaseSlot() - return nil, target, err + xrayLogf("native xray: VLESS mux TCP dial %s failed session=%d: %v", target, s.id, err) + s.failInit(true) + return } s.tcp = backend - return s, target, nil + } else { + pc, udpNetwork, udpTarget, target, err := ib.nativeOpenMuxUDP(host, port) + if err != nil { + xrayLogf("native xray: VLESS mux UDP open %s failed session=%d: %v", target, s.id, err) + s.failInit(true) + return + } + s.udp = pc + s.udpNetwork = udpNetwork + s.udpTarget = udpTarget } - pc, udpNetwork, udpTarget, target, err := ib.nativeOpenMuxUDP(host, port) - if err != nil { - releaseSlot() - return nil, target, err + // If the session was closed while dialing, tear the backend down now. + select { + case <-s.closed: + s.closeBackend() + return + default: + } + + xrayGo(fmt.Sprintf("native xray mux backend session=%d", s.id), func() { s.readBackendLoop() }) + s.uplinkLoop() +} + +// failInit reports a session that never came up: optionally notify the client +// with an End(error) frame, remove it from the parent maps, and release the +// slot. It must not be used once a backend reader is running (readBackendLoop's +// deferred cleanup owns that path). +func (s *nativeMuxSession) failInit(notifyClient bool) { + if notifyClient { + s.writeMu.Lock() + _ = writeNativeMuxEnd(s.client, s.id, true) + s.writeMu.Unlock() + } + if s.onClose != nil { + s.onClose(s) + } + s.closeBackend() +} + +// enqueueUplink hands one client->backend datagram/segment to the session's +// uplink goroutine. The payload is copied because the caller (the shared read +// loop) reuses its scratch buffer on the next iteration. A send blocks only when +// this one session's queue is full (per-session backpressure) or once closed. +func (s *nativeMuxSession) enqueueUplink(payload []byte, host string, port uint16) { + if len(payload) == 0 { + return + } + cp := make([]byte, len(payload)) + copy(cp, payload) + select { + case s.uplink <- nativeMuxUplinkItem{payload: cp, host: host, port: port}: + case <-s.closed: + } +} + +// uplinkLoop drains queued client->backend items until the session closes or a +// backend write fails. Rate limiting and the blocking socket write now happen +// here instead of in the shared read loop. +func (s *nativeMuxSession) uplinkLoop() { + // upMeter is owned exclusively by this goroutine (writeBackendItem adds to it + // here), so it is flushed here too. readBackendLoop must not touch upMeter. + defer s.upMeter.flush() + for { + select { + case <-s.closed: + return + case item := <-s.uplink: + if !s.writeBackendItem(item) { + s.closeBackend() + return + } + } } - s.udp = pc - s.udpNetwork = udpNetwork - s.udpTarget = udpTarget - return s, target, nil } func (ib *nativeInbound) nativeOpenMuxUDP(host string, port uint16) (net.PacketConn, string, net.Addr, string, error) { @@ -384,13 +506,19 @@ func (ib *nativeInbound) nativeOpenMuxUDP(host string, port uint16) (net.PacketC return pc, udpNetwork, udpTarget, target, nil } -func (s *nativeMuxSession) writeBackend(payload []byte, overrideHost string, overridePort uint16) { +// writeBackendItem writes one uplink item to the backend. It returns false when +// the session should be torn down (rate wait cancelled or a fatal write error). +// A per-packet UDP sink/override-resolve failure is a soft skip and returns true +// so the session keeps serving other datagrams. +func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool { + payload := item.payload if len(payload) == 0 { - return + return true } - if err := waitNativeRate(s.upLimiter, len(payload)); err != nil { - s.closeBackend() - return + if s.upLimiter != nil { + if err := s.upLimiter.WaitN(s.ctx, len(payload)); err != nil { + return false + } } var n int @@ -399,23 +527,23 @@ func (s *nativeMuxSession) writeBackend(payload []byte, overrideHost string, ove n, err = s.tcp.Write(payload) } else { target := s.udpTarget - if overrideHost != "" && overridePort != 0 { - if isNativeDNSSinkTarget(overrideHost) || invalidNativeDestination(overrideHost, overridePort) { + if item.host != "" && item.port != 0 { + if isNativeDNSSinkTarget(item.host) || invalidNativeDestination(item.host, item.port) { // AdGuard/blocked endpoints must be ignored at the cheapest possible // point. Do not resolve, dial, log loudly, or keep the mux child busy. - xrayTracef("native xray: VLESS mux UDP fast-ignored override sink session=%d target=%s:%d", s.id, overrideHost, overridePort) - return + xrayTracef("native xray: VLESS mux UDP fast-ignored override sink session=%d target=%s:%d", s.id, item.host, item.port) + return true } - if s.lastUDPAddr != nil && s.lastUDPHost == overrideHost && s.lastUDPPort == overridePort { + if s.lastUDPAddr != nil && s.lastUDPHost == item.host && s.lastUDPPort == item.port { target = s.lastUDPAddr - } else if addr, rerr := resolveNativeMuxUDPAddr(s.udpNetwork, overrideHost, overridePort); rerr == nil { + } else if addr, rerr := resolveNativeMuxUDPAddr(s.udpNetwork, item.host, item.port); rerr == nil { target = addr - s.lastUDPHost = overrideHost - s.lastUDPPort = overridePort + s.lastUDPHost = item.host + s.lastUDPPort = item.port s.lastUDPAddr = addr } else { - xrayTracef("native xray: VLESS mux UDP override resolve failed session=%d target=%s:%d: %v", s.id, overrideHost, overridePort, rerr) - return + xrayTracef("native xray: VLESS mux UDP override resolve failed session=%d target=%s:%d: %v", s.id, item.host, item.port, rerr) + return true } } n, err = s.udp.WriteTo(payload, target) @@ -425,18 +553,22 @@ func (s *nativeMuxSession) writeBackend(payload []byte, overrideHost string, ove } if err != nil { xrayLogf("native xray: VLESS mux backend write failed session=%d: %v", s.id, err) - s.closeBackend() + return false } + return true } func (s *nativeMuxSession) readBackendLoop() { defer xrayRecover(fmt.Sprintf("native xray mux backend loop session=%d", s.id)) + sendEnd := true defer func() { + // downMeter is owned by this goroutine; upMeter is flushed by uplinkLoop. s.downMeter.flush() - s.upMeter.flush() - s.writeMu.Lock() - _ = writeNativeMuxEnd(s.client, s.id, false) - s.writeMu.Unlock() + if sendEnd { + s.writeMu.Lock() + _ = writeNativeMuxEnd(s.client, s.id, false) + s.writeMu.Unlock() + } if s.onClose != nil { s.onClose(s) } @@ -447,7 +579,17 @@ func (s *nativeMuxSession) readBackendLoop() { s.readTCPBackendLoop() return } - s.readUDPBackendLoop() + // For UDP/QUIC, an idle backend timeout is only local cleanup. Sending an + // End frame on idle makes some clients close the whole video/QUIC flow after + // a short quiet period. Real errors still return true and notify the client. + sendEnd = s.readUDPBackendLoop() +} + +func (s *nativeMuxSession) waitDownRate(n int) error { + if s.downLimiter == nil || n <= 0 { + return nil + } + return s.downLimiter.WaitN(s.ctx, n) } func (s *nativeMuxSession) readTCPBackendLoop() { @@ -463,7 +605,7 @@ func (s *nativeMuxSession) readTCPBackendLoop() { if n <= 0 { continue } - if err := waitNativeRate(s.downLimiter, n); err != nil { + if err := s.waitDownRate(n); err != nil { return } s.downMeter.add(n) @@ -477,25 +619,25 @@ func (s *nativeMuxSession) readTCPBackendLoop() { } } -func (s *nativeMuxSession) readUDPBackendLoop() { +func (s *nativeMuxSession) readUDPBackendLoop() bool { buf := make([]byte, nativeUDPBufferSize) for { _ = s.udp.SetReadDeadline(time.Now().Add(nativeMuxUDPIdleTimeout())) n, addr, err := s.udp.ReadFrom(buf) if err != nil { if ne, ok := err.(net.Error); ok && ne.Timeout() { - return + return false } if err != io.EOF { xrayLogf("native xray: VLESS mux UDP backend read failed session=%d: %v", s.id, err) } - return + return true } if n <= 0 { continue } - if err := waitNativeRate(s.downLimiter, n); err != nil { - return + if err := s.waitDownRate(n); err != nil { + return true } s.downMeter.add(n) s.writeMu.Lock() @@ -506,17 +648,20 @@ func (s *nativeMuxSession) readUDPBackendLoop() { s.writeMu.Unlock() if werr != nil { xrayLogf("native xray: VLESS mux UDP client write failed session=%d: %v", s.id, werr) - return + return true } } } +// closeBackend tears the session down exactly once. It is safe to call +// concurrently from the read loop, the uplink loop and the backend reader; the +// previous select/default form could double-close s.closed and panic. func (s *nativeMuxSession) closeBackend() { - select { - case <-s.closed: - return - default: + s.closeOnce.Do(func() { close(s.closed) + if s.cancel != nil { + s.cancel() + } if s.releaseSlot != nil { s.releaseSlot() } @@ -526,7 +671,7 @@ func (s *nativeMuxSession) closeBackend() { if s.udp != nil { _ = s.udp.Close() } - } + }) } func nativeMuxNetworkName(network byte) string { @@ -546,7 +691,8 @@ func readNativeMuxMetadata(r io.Reader) (nativeMuxMetadata, error) { if metaLen < 4 || metaLen > 512 { return meta, fmt.Errorf("invalid mux metadata length %d", metaLen) } - b := make([]byte, metaLen) + var stack [512]byte + b := stack[:metaLen] if _, err := io.ReadFull(r, b); err != nil { return meta, err } @@ -639,17 +785,25 @@ func appendNativeMuxAddressPort(dst []byte, host string, port uint16) []byte { } func readNativeMuxDataBlock(r io.Reader) ([]byte, error) { + payload, _, err := readNativeMuxDataBlockScratch(r, nil) + return payload, err +} + +func readNativeMuxDataBlockScratch(r io.Reader, scratch []byte) ([]byte, []byte, error) { var lenBuf [2]byte if _, err := io.ReadFull(r, lenBuf[:]); err != nil { - return nil, err + return nil, scratch, err } n := int(binary.BigEndian.Uint16(lenBuf[:])) if n > nativeUDPMaxPacket { - return nil, fmt.Errorf("mux payload too large: %d", n) + return nil, scratch, fmt.Errorf("mux payload too large: %d", n) } - payload := make([]byte, n) + if cap(scratch) < n { + scratch = make([]byte, n) + } + payload := scratch[:n] _, err := io.ReadFull(r, payload) - return payload, err + return payload, scratch, err } func discardNativeMuxDataBlock(r io.Reader) error { @@ -706,22 +860,24 @@ func writeNativeMuxPacketData(w io.Writer, sessionID uint16, status byte, payloa if len(payload) > nativeUDPMaxPacket { return fmt.Errorf("mux payload too large: %d", len(payload)) } - meta := []byte{byte(sessionID >> 8), byte(sessionID), status, nativeMuxOptionData} + bufp := nativeMuxFramePool.Get().(*[]byte) + frame := (*bufp)[:0] + frame = append(frame, 0, 0) // metadata length placeholder + metaStart := len(frame) + frame = append(frame, byte(sessionID>>8), byte(sessionID), status, nativeMuxOptionData) if includeUDPAddr && udpAddr != nil { if host, port, ok := nativeMuxAddrHostPort(udpAddr); ok { - meta = append(meta, nativeMuxNetworkUDP) - meta = appendNativeMuxAddressPort(meta, host, port) + frame = append(frame, nativeMuxNetworkUDP) + frame = appendNativeMuxAddressPort(frame, host, port) } } - // Keep every logical mux packet in one Write call. The XHTTP response writer - // flushes after each Write, so splitting this into metadata/payload writes - // multiplies flush/syscall work and can pin a CPU core under QUIC traffic. - frame := make([]byte, 0, 2+len(meta)+2+len(payload)) - frame = appendUint16(frame, uint16(len(meta))) - frame = append(frame, meta...) + metaLen := len(frame) - metaStart + binary.BigEndian.PutUint16(frame[:2], uint16(metaLen)) frame = appendUint16(frame, uint16(len(payload))) frame = append(frame, payload...) _, err := w.Write(frame) + *bufp = frame[:0] + nativeMuxFramePool.Put(bufp) return err } @@ -730,8 +886,12 @@ func writeNativeMuxEnd(w io.Writer, sessionID uint16, hasError bool) error { if hasError { opt = nativeMuxOptionError } - meta := []byte{0, 4, byte(sessionID >> 8), byte(sessionID), nativeMuxStatusEnd, opt} - _, err := w.Write(meta) + var frame [6]byte + binary.BigEndian.PutUint16(frame[0:2], 4) + binary.BigEndian.PutUint16(frame[2:4], sessionID) + frame[4] = nativeMuxStatusEnd + frame[5] = opt + _, err := w.Write(frame[:]) return err } diff --git a/xray_native_test.go b/xray_native_test.go index 1878756..35f23f7 100644 --- a/xray_native_test.go +++ b/xray_native_test.go @@ -397,6 +397,107 @@ func TestVLESSOverXHTTPPacketUpGET(t *testing.T) { } } +// TestVLESSOverXHTTPOutOfOrderPacketUp is the regression guard for the burst +// stall that made video/large downloads unusable ("download ~10MB, stall, +// repeat"). Packet-up POSTs arrive out of order (highest sequence first) and each +// POST is fully awaited before the next is sent — exactly what happens when the +// client's concurrent-POST slots fill while a low sequence is still in flight. +// +// The old queue blocked each POST until the tunnel reader consumed that exact +// payload, so a POST carrying seq=2 could never return before seq=0/1 arrived — +// a deadlock that surfaced as periodic stalls. The xray-core-style queue acks +// every POST as soon as it is buffered and reassembles server-side, so this test +// completes quickly and the payload is delivered in order. +func TestVLESSOverXHTTPOutOfOrderPacketUp(t *testing.T) { + echoPort, stopEcho := startEchoServer(t) + defer stopEcho() + + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + t.Fatalf("rand: %v", err) + } + ib := &nativeInbound{ + tag: "test-xhttp-reorder", + protocol: "vless", + transport: "xhttp", + path: normalizeXHTTPPath("/xhttp"), + xhttpMode: "packet-up", + xhttpMaxEachPostBytes: 1_000_000, + xhttpMaxBufferedPosts: 30, + xhttpSessions: make(map[string]*nativeXHTTPSession), + clientsByID: map[[16]byte]*nativeXrayClient{id: {id: id, uuid: "test", email: "test@t"}}, + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("xhttp listen: %v", err) + } + defer ln.Close() + go ib.serveXHTTPListener(ln) + port := ln.Addr().(*net.TCPAddr).Port + + client := &http.Client{Timeout: 4 * time.Second} + session := "session-reorder" + baseURL := "http://" + net.JoinHostPort("127.0.0.1", itoa(port)) + "/xhttp/" + session + + respCh := make(chan *http.Response, 1) + errCh := make(chan error, 1) + go func() { + resp, err := client.Get(baseURL) + if err != nil { + errCh <- err + return + } + respCh <- resp + }() + + var resp *http.Response + select { + case resp = <-respCh: + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("xhttp GET status: %s", resp.Status) + } + case err := <-errCh: + t.Fatalf("xhttp GET: %v", err) + case <-time.After(2 * time.Second): + t.Fatalf("xhttp GET did not open") + } + + // Full uplink stream = VLESS header + body, split into 3 sequenced chunks. + full := append(vlessHeader(id, echoPort), []byte("reordered-payload-body")...) + third := len(full) / 3 + chunks := [][]byte{full[:third], full[third : 2*third], full[2*third:]} + + // Send the POSTs highest-seq-first, awaiting each response before the next. + // On the old block-until-consumed queue, the very first POST (seq 2) would + // hang until the 4s client timeout because seq 0/1 have not arrived yet. + for _, seq := range []int{2, 1, 0} { + start := time.Now() + postResp, err := client.Post(baseURL+"/"+itoa(seq), "application/octet-stream", bytes.NewReader(chunks[seq])) + if err != nil { + t.Fatalf("xhttp POST seq=%d (head-of-line stall?): %v", seq, err) + } + postResp.Body.Close() + if postResp.StatusCode != http.StatusOK { + t.Fatalf("xhttp POST seq=%d status: %s", seq, postResp.Status) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("xhttp POST seq=%d took %v; expected an immediate ack (not blocked on consumption)", seq, elapsed) + } + } + + got := make([]byte, 2+len("reordered-payload-body")) + if _, err := io.ReadFull(resp.Body, got); err != nil { + t.Fatalf("xhttp read response: %v", err) + } + if got[0] != 0 { + t.Fatalf("bad xhttp vless response: %v", got[:2]) + } + if string(got[2:]) != "reordered-payload-body" { + t.Fatalf("xhttp reassembled echo mismatch: got %q", got[2:]) + } +} + func TestXHTTPRejectsBrowserGETWithoutSession(t *testing.T) { var id [16]byte if _, err := rand.Read(id[:]); err != nil { @@ -878,3 +979,61 @@ func TestVLESSMuxTCPDoesNotStall(t *testing.T) { t.Fatalf("mux tcp echo mismatch: got %q want %q", got, want) } } + +// TestVLESSMuxSlowDialDoesNotBlockOtherSessions is the regression guard for the +// head-of-line stall that made the panel proxy "hang with multiple users": a +// single mux session whose target is slow to connect must not freeze the other +// sessions multiplexed on the same client connection. Session 1 targets a +// blackhole address (a connect that hangs until the 10s dial timeout); session 2 +// targets a live echo server and must respond promptly regardless. +func TestVLESSMuxSlowDialDoesNotBlockOtherSessions(t *testing.T) { + tcpPort, stopTCP := startEchoServer(t) + defer stopTCP() + _, port, id, stop := newTestInbound(t, "tcp", "") + defer stop() + + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", itoa(port))) + if err != nil { + t.Fatalf("dial inbound: %v", err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(4 * time.Second)) + + if _, err := conn.Write(vlessMuxHeader(id)); err != nil { + t.Fatalf("write mux header: %v", err) + } + resp := make([]byte, 2) + if _, err := io.ReadFull(conn, resp); err != nil { + t.Fatalf("read mux response header: %v", err) + } + + // Session 1: blackhole target (TEST-NET-1, RFC 5737) — connect will hang for + // the full dial timeout. On the old synchronous read loop this alone blocked + // every subsequent frame for up to 10s. + if _, err := conn.Write(buildMuxTCPFrame(1, "192.0.2.1", 80, []byte("slow"))); err != nil { + t.Fatalf("write slow mux frame: %v", err) + } + // Session 2: live echo server. Must round-trip well within the 4s deadline, + // i.e. long before session 1's 10s dial timeout could ever return. + want := []byte("fast-session") + if _, err := conn.Write(buildMuxTCPFrame(2, "127.0.0.1", tcpPort, want)); err != nil { + t.Fatalf("write fast mux frame: %v", err) + } + + // The blackhole session cannot produce data, so the first frame back must be + // session 2's echo. + meta, err := readNativeMuxMetadata(conn) + if err != nil { + t.Fatalf("read fast session response meta (head-of-line stall?): %v", err) + } + if meta.sessionID != 2 || meta.status != nativeMuxStatusKeep || meta.option&nativeMuxOptionData == 0 { + t.Fatalf("expected session 2 keep-data frame, got: %#v", meta) + } + got, err := readNativeMuxDataBlock(conn) + if err != nil { + t.Fatalf("read fast session payload: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("fast session echo mismatch: got %q want %q", got, want) + } +} diff --git a/xray_native_tuning.go b/xray_native_tuning.go index 2ba898b..71f2943 100644 --- a/xray_native_tuning.go +++ b/xray_native_tuning.go @@ -1,6 +1,7 @@ package main import ( + "runtime" "sync/atomic" "time" ) @@ -9,6 +10,9 @@ import ( // from the admin panel and saved in config.json under xray.native_tuning. // Values are process/runtime settings, not generated Xray JSON settings. type XrayNativeTuning struct { + // RuntimeGOMAXPROCS controls Go CPU parallelism for the in-process native Xray. + // 0 or negative means use all detected CPU cores. + RuntimeGOMAXPROCS int `json:"runtime_gomaxprocs,omitempty"` MuxMaxSessions int `json:"mux_max_sessions,omitempty"` MuxGlobalSessions int `json:"mux_global_sessions,omitempty"` MuxUDPIdleMS int `json:"mux_udp_idle_ms,omitempty"` @@ -16,9 +20,6 @@ type XrayNativeTuning struct { MuxUDPWriteBuffer int `json:"mux_udp_write_buffer,omitempty"` XHTTPMaxSessions int `json:"xhttp_max_sessions,omitempty"` XHTTPBufferedPosts int `json:"xhttp_buffered_posts,omitempty"` - XHTTPQueueTimeoutMS int `json:"xhttp_queue_timeout_ms,omitempty"` - XHTTPFlushMS int `json:"xhttp_flush_ms,omitempty"` - XHTTPFlushBytes int `json:"xhttp_flush_bytes,omitempty"` H2MaxConcurrentStreams int `json:"h2_max_concurrent_streams,omitempty"` H2UploadBufferConn int `json:"h2_upload_buffer_conn,omitempty"` H2UploadBufferStream int `json:"h2_upload_buffer_stream,omitempty"` @@ -26,22 +27,21 @@ type XrayNativeTuning struct { } const ( + defaultNativeRuntimeGOMAXPROCS = 0 defaultNativeMuxMaxSessions = 128 defaultNativeMuxGlobalSessions = 32768 - defaultNativeMuxUDPIdleMS = 15000 + defaultNativeMuxUDPIdleMS = 120000 defaultNativeMuxUDPReadBuffer = 256 * 1024 defaultNativeMuxUDPWriteBuffer = 256 * 1024 defaultNativeXHTTPMaxSessions = 16384 - defaultNativeXHTTPBufferedPosts = 64 - defaultNativeXHTTPQueueTimeoutMS = 250 - defaultNativeXHTTPFlushMS = 5 - defaultNativeXHTTPFlushBytes = 128 * 1024 + defaultNativeXHTTPBufferedPosts = 256 defaultNativeH2MaxConcurrentStreams = 1024 defaultNativeH2UploadBufferConn = 1 * 1024 * 1024 defaultNativeH2UploadBufferStream = 256 * 1024 ) var ( + nativeTuneRuntimeGOMAXPROCS atomic.Int64 nativeTuneMuxMaxSessions atomic.Int64 nativeTuneMuxGlobalSessions atomic.Int64 nativeTuneMuxUDPIdleMS atomic.Int64 @@ -49,9 +49,6 @@ var ( nativeTuneMuxUDPWriteBuffer atomic.Int64 nativeTuneXHTTPMaxSessions atomic.Int64 nativeTuneXHTTPBufferedPosts atomic.Int64 - nativeTuneXHTTPQueueTimeoutMS atomic.Int64 - nativeTuneXHTTPFlushMS atomic.Int64 - nativeTuneXHTTPFlushBytes atomic.Int64 nativeTuneH2MaxConcurrentStreams atomic.Int64 nativeTuneH2UploadBufferConn atomic.Int64 nativeTuneH2UploadBufferStream atomic.Int64 @@ -67,6 +64,9 @@ func normalizeNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning { t = &XrayNativeTuning{} } out := *t + if out.RuntimeGOMAXPROCS < 0 { + out.RuntimeGOMAXPROCS = defaultNativeRuntimeGOMAXPROCS + } if out.MuxMaxSessions <= 0 { out.MuxMaxSessions = defaultNativeMuxMaxSessions } @@ -88,15 +88,6 @@ func normalizeNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning { if out.XHTTPBufferedPosts <= 0 { out.XHTTPBufferedPosts = defaultNativeXHTTPBufferedPosts } - if out.XHTTPQueueTimeoutMS <= 0 { - out.XHTTPQueueTimeoutMS = defaultNativeXHTTPQueueTimeoutMS - } - if out.XHTTPFlushMS <= 0 { - out.XHTTPFlushMS = defaultNativeXHTTPFlushMS - } - if out.XHTTPFlushBytes <= 0 { - out.XHTTPFlushBytes = defaultNativeXHTTPFlushBytes - } if out.H2MaxConcurrentStreams <= 0 { out.H2MaxConcurrentStreams = defaultNativeH2MaxConcurrentStreams } @@ -111,6 +102,15 @@ func normalizeNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning { func applyNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning { out := normalizeNativeXrayTuning(t) + gomax := out.RuntimeGOMAXPROCS + if gomax <= 0 { + gomax = runtime.NumCPU() + } + if gomax < 1 { + gomax = 1 + } + runtime.GOMAXPROCS(gomax) + nativeTuneRuntimeGOMAXPROCS.Store(int64(gomax)) nativeTuneMuxMaxSessions.Store(int64(out.MuxMaxSessions)) nativeTuneMuxGlobalSessions.Store(int64(out.MuxGlobalSessions)) nativeTuneMuxUDPIdleMS.Store(int64(out.MuxUDPIdleMS)) @@ -118,9 +118,6 @@ func applyNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning { nativeTuneMuxUDPWriteBuffer.Store(int64(out.MuxUDPWriteBuffer)) nativeTuneXHTTPMaxSessions.Store(int64(out.XHTTPMaxSessions)) nativeTuneXHTTPBufferedPosts.Store(int64(out.XHTTPBufferedPosts)) - nativeTuneXHTTPQueueTimeoutMS.Store(int64(out.XHTTPQueueTimeoutMS)) - nativeTuneXHTTPFlushMS.Store(int64(out.XHTTPFlushMS)) - nativeTuneXHTTPFlushBytes.Store(int64(out.XHTTPFlushBytes)) nativeTuneH2MaxConcurrentStreams.Store(int64(out.H2MaxConcurrentStreams)) nativeTuneH2UploadBufferConn.Store(int64(out.H2UploadBufferConn)) nativeTuneH2UploadBufferStream.Store(int64(out.H2UploadBufferStream)) @@ -128,6 +125,7 @@ func applyNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning { return out } +func nativeRuntimeGOMAXPROCS() int { return int(nativeTuneRuntimeGOMAXPROCS.Load()) } func nativeMuxMaxSessionLimit() int { return int(nativeTuneMuxMaxSessions.Load()) } func nativeMuxGlobalSessionLimit() int { return int(nativeTuneMuxGlobalSessions.Load()) } func nativeMuxUDPIdleTimeout() time.Duration { @@ -137,13 +135,6 @@ func nativeMuxUDPReadBufferSize() int { return int(nativeTuneMuxUDPReadBuffer. func nativeMuxUDPWriteBufferSize() int { return int(nativeTuneMuxUDPWriteBuffer.Load()) } func nativeXHTTPMaxSessionLimit() int { return int(nativeTuneXHTTPMaxSessions.Load()) } func nativeXHTTPBufferedPostLimit() int { return int(nativeTuneXHTTPBufferedPosts.Load()) } -func nativeXHTTPQueuePushTimeoutDuration() time.Duration { - return time.Duration(nativeTuneXHTTPQueueTimeoutMS.Load()) * time.Millisecond -} -func nativeXHTTPFlushIntervalDuration() time.Duration { - return time.Duration(nativeTuneXHTTPFlushMS.Load()) * time.Millisecond -} -func nativeXHTTPFlushByteLimit() int { return int(nativeTuneXHTTPFlushBytes.Load()) } func nativeH2MaxConcurrentStreams() int { return int(nativeTuneH2MaxConcurrentStreams.Load()) } func nativeH2UploadBufferConn() int { return int(nativeTuneH2UploadBufferConn.Load()) } func nativeH2UploadBufferStream() int { return int(nativeTuneH2UploadBufferStream.Load()) } diff --git a/xray_xhttp.go b/xray_xhttp.go index 3b74014..1d4ebb0 100644 --- a/xray_xhttp.go +++ b/xray_xhttp.go @@ -411,9 +411,10 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n return s } if max := ib.xhttpMaxActiveSessions(); max > 0 && len(ib.xhttpSessions) >= max { - xrayTracef("native xray: xhttp session rejected inbound=%q active=%d limit=%d", ib.tag, len(ib.xhttpSessions), max) - http.Error(w, "xhttp session limit reached", http.StatusTooManyRequests) - return nil + // XHTTP uses many HTTP requests/sessions by design. Returning HTTP 429 + // makes Xray clients tear down active tunnels, which is worse than allowing + // a short soft-limit overflow and relying on stale-session cleanup. + xrayTracef("native xray: xhttp session soft limit exceeded inbound=%q active=%d limit=%d", ib.tag, len(ib.xhttpSessions), max) } s := &nativeXHTTPSession{ id: id, @@ -482,12 +483,11 @@ func (ib *nativeInbound) handleXHTTPStreamUpload(w http.ResponseWriter, r *http. http.Error(w, "xhttp stream-up mode is not allowed", http.StatusBadRequest) return } - if err := sess.queue.pushContext(r.Context(), nativeXHTTPPacket{Reader: r.Body}, nativeXHTTPQueuePushTimeoutDuration()); err != nil { - status := http.StatusConflict - if errors.Is(err, errNativeXHTTPQueueFull) { - status = http.StatusTooManyRequests + if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Reader: r.Body}); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return } - http.Error(w, err.Error(), status) + http.Error(w, err.Error(), http.StatusConflict) return } w.Header().Set("X-Accel-Buffering", "no") @@ -517,13 +517,12 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http. return } xrayTracef("native xray: xhttp packet-up inbound=%q session=%q seq=%d payload=%d remote=%s", ib.tag, sess.id, seq, len(payload), r.RemoteAddr) - if err := sess.queue.pushContext(r.Context(), nativeXHTTPPacket{Payload: payload, Seq: seq}, nativeXHTTPQueuePushTimeoutDuration()); err != nil { - status := http.StatusConflict - if errors.Is(err, errNativeXHTTPQueueFull) { - status = http.StatusTooManyRequests + if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Payload: payload, Seq: seq}); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return } xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err) - http.Error(w, err.Error(), status) + http.Error(w, err.Error(), http.StatusInternalServerError) return } if len(payload) == 0 { @@ -662,7 +661,13 @@ func (ib *nativeInbound) handleXHTTPDownload(w http.ResponseWriter, r *http.Requ sess.touch() defer xrayRecover(fmt.Sprintf("native xray XHTTP download inbound=%q session=%q remote=%s", ib.tag, sessionID, r.RemoteAddr)) xrayTracef("native xray: xhttp stream-down inbound=%q session=%q proto=%s remote=%s", ib.tag, sessionID, r.Proto, r.RemoteAddr) - sess.markConnected() + if !sess.markConnected() { + // Another download is already streaming this session. Reject the duplicate + // GET without touching the live session so the first download keeps flowing. + xrayTracef("native xray: xhttp duplicate download rejected inbound=%q session=%q remote=%s", ib.tag, sessionID, r.RemoteAddr) + xhttpBadRequest(w) + return + } defer ib.deleteXHTTPSession(sessionID, sess) w.Header().Set("X-Accel-Buffering", "no") @@ -740,11 +745,21 @@ func (s *nativeXHTTPSession) touch() { s.mu.Unlock() } -func (s *nativeXHTTPSession) markConnected() { +// markConnected attaches the single download (stream-down) reader to the +// session. It returns false if a download is already attached: XHTTP has exactly +// one downlink per session, and letting a second GET dispatch a second +// VLESS/VMess reader over the same upload queue splits the decoded stream across +// two HTTP responses and corrupts the tunnel (seen as the proxy "stopping +// passing data" after a mobile-network reconnect). +func (s *nativeXHTTPSession) markConnected() bool { s.mu.Lock() + defer s.mu.Unlock() + if s.connected { + return false + } s.connected = true s.lastSeen = time.Now() - s.mu.Unlock() + return true } func (s *nativeXHTTPSession) close() { @@ -808,22 +823,19 @@ func (c *nativeXHTTPConn) SetReadDeadline(t time.Time) error { func (c *nativeXHTTPConn) SetWriteDeadline(time.Time) error { return nil } +// nativeXHTTPResponseWriter mirrors xray-core's httpServerConn.Write: every write +// to the download stream is flushed immediately. XHTTP download is an SSE-style +// stream, and the client only makes progress on bytes that actually reach it, so +// batching writes (behind a timer/threshold) just adds latency and stutter. The +// mutex serializes writes against close. type nativeXHTTPResponseWriter struct { - mu sync.Mutex - w http.ResponseWriter - closed bool - pendingFlush bool - lastFlush time.Time - buffered int - flushTimer *time.Timer - timerActive bool + mu sync.Mutex + w http.ResponseWriter + closed bool } func newNativeXHTTPResponseWriter(w http.ResponseWriter) *nativeXHTTPResponseWriter { - // The handler writes/flushed headers before the proxy stream is dispatched. - // Starting lastFlush at now prevents the first tiny mux packet from forcing an - // immediate extra flush for every user. - return &nativeXHTTPResponseWriter{w: w, lastFlush: time.Now()} + return &nativeXHTTPResponseWriter{w: w} } func (w *nativeXHTTPResponseWriter) Write(p []byte) (int, error) { @@ -833,62 +845,15 @@ func (w *nativeXHTTPResponseWriter) Write(p []byte) (int, error) { return 0, io.ErrClosedPipe } n, err := w.w.Write(p) - if n > 0 { - w.buffered += n - } if err == nil { - w.flushMaybeLocked(false) + flushHTTP(w.w) } return n, err } -func (w *nativeXHTTPResponseWriter) flushMaybeLocked(force bool) { - now := time.Now() - if force || w.buffered >= nativeXHTTPFlushByteLimit() || now.Sub(w.lastFlush) >= nativeXHTTPFlushIntervalDuration() { - flushHTTP(w.w) - w.lastFlush = now - w.buffered = 0 - w.pendingFlush = false - w.timerActive = false - return - } - if w.pendingFlush { - return - } - w.pendingFlush = true - if w.timerActive { - return - } - w.timerActive = true - if w.flushTimer == nil { - w.flushTimer = time.AfterFunc(nativeXHTTPFlushIntervalDuration(), w.fireFlushTimer) - } else { - w.flushTimer.Reset(nativeXHTTPFlushIntervalDuration()) - } -} - -func (w *nativeXHTTPResponseWriter) fireFlushTimer() { - w.mu.Lock() - defer w.mu.Unlock() - w.timerActive = false - if w.closed || !w.pendingFlush { - return - } - flushHTTP(w.w) - w.lastFlush = time.Now() - w.buffered = 0 - w.pendingFlush = false -} - func (w *nativeXHTTPResponseWriter) close() { w.mu.Lock() - if !w.closed { - if w.flushTimer != nil { - w.flushTimer.Stop() - } - w.flushMaybeLocked(true) - w.closed = true - } + w.closed = true w.mu.Unlock() } @@ -898,21 +863,32 @@ type nativeXHTTPPacket struct { Seq uint64 } +// nativeXHTTPUploadQueue is a faithful port of xray-core's splithttp uploadQueue +// (transport/internet/splithttp/upload_queue.go): a bounded channel that feeds a +// sequence-number reorder heap. The critical property — matching upstream Xray — +// is that push() buffers the packet and returns immediately so the HTTP POST is +// acked (200) without waiting for the tunnel reader to consume it. The previous +// implementation blocked each POST until consumption, which throttled the uplink +// to the reassembly rate and periodically deadlocked against the client's +// concurrent-POST limit — the classic "download a burst, stall, repeat" that made +// video/large downloads unusable. type nativeXHTTPUploadQueue struct { pushedPackets chan nativeXHTTPPacket - heap nativeXHTTPHeap - nextSeq uint64 maxPackets int - closed chan struct{} - closeOnce sync.Once - reader io.ReadCloser - deadlineMu sync.Mutex - readDeadline time.Time + + mu sync.Mutex + reader io.ReadCloser + heap nativeXHTTPHeap + nextSeq uint64 + readDeadline time.Time + + closed chan struct{} + closeOnce sync.Once } func newNativeXHTTPUploadQueue(maxPackets int) *nativeXHTTPUploadQueue { if maxPackets <= 0 { - maxPackets = 30 + maxPackets = defaultNativeXHTTPBufferedPosts } return &nativeXHTTPUploadQueue{ pushedPackets: make(chan nativeXHTTPPacket, maxPackets), @@ -921,79 +897,121 @@ func newNativeXHTTPUploadQueue(maxPackets int) *nativeXHTTPUploadQueue { } } -var errNativeXHTTPQueueFull = errors.New("xhttp upload queue full") - -func (q *nativeXHTTPUploadQueue) pushContext(ctx context.Context, p nativeXHTTPPacket, timeout time.Duration) error { - if timeout <= 0 { - timeout = nativeXHTTPQueuePushTimeoutDuration() +// push buffers one uplink packet (or the stream-up reader) and returns as soon as +// it is queued. It only blocks when the bounded buffer is full — the same +// backpressure upstream Xray applies — or until the request/session is cancelled. +func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket) error { + if p.Reader != nil { + // Only one stream-up reader may exist per session. + q.mu.Lock() + if q.reader != nil { + q.mu.Unlock() + return errors.New("xhttp upload reader already exists") + } + q.mu.Unlock() } - t := time.NewTimer(timeout) - defer t.Stop() select { case q.pushedPackets <- p: + select { + case <-q.closed: + return io.ErrClosedPipe + default: + } return nil case <-q.closed: return io.ErrClosedPipe case <-ctx.Done(): return ctx.Err() - case <-t.C: - return errNativeXHTTPQueueFull } } func (q *nativeXHTTPUploadQueue) close() { q.closeOnce.Do(func() { close(q.closed) - if q.reader != nil { - _ = q.reader.Close() + q.mu.Lock() + reader := q.reader + q.mu.Unlock() + if reader != nil { + _ = reader.Close() } }) } func (q *nativeXHTTPUploadQueue) SetReadDeadline(t time.Time) error { - q.deadlineMu.Lock() + q.mu.Lock() q.readDeadline = t - q.deadlineMu.Unlock() + q.mu.Unlock() return nil } -func (q *nativeXHTTPUploadQueue) deadlineChan() <-chan time.Time { - q.deadlineMu.Lock() +// recv blocks for the next buffered packet, honoring the current read deadline +// (used only for the VLESS/VMess handshake timeout; the tunnel body has none). +func (q *nativeXHTTPUploadQueue) recv() (nativeXHTTPPacket, error) { + q.mu.Lock() d := q.readDeadline - q.deadlineMu.Unlock() - if d.IsZero() { - return nil - } - return time.After(time.Until(d)) -} + q.mu.Unlock() -func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) { - if q.reader != nil { - return q.reader.Read(b) - } - if len(q.heap) == 0 { + if d.IsZero() { select { - case <-q.deadlineChan(): - return 0, os.ErrDeadlineExceeded case p := <-q.pushedPackets: - if p.Reader != nil { - q.reader = p.Reader - return q.reader.Read(b) - } - heap.Push(&q.heap, p) + return p, nil case <-q.closed: - return 0, io.EOF + return nativeXHTTPPacket{}, io.EOF } } + + wait := time.Until(d) + if wait <= 0 { + return nativeXHTTPPacket{}, os.ErrDeadlineExceeded + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case p := <-q.pushedPackets: + return p, nil + case <-q.closed: + return nativeXHTTPPacket{}, io.EOF + case <-timer.C: + return nativeXHTTPPacket{}, os.ErrDeadlineExceeded + } +} + +// Read mirrors xray-core uploadQueue.Read: drain in-order payloads from the heap, +// otherwise pull from the channel; misordered packets are buffered until their +// predecessor arrives, and an over-large reassembly heap tears the session down +// so the client retries. +func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) { + if reader := q.loadReader(); reader != nil { + return reader.Read(b) + } + + select { + case <-q.closed: + return 0, io.EOF + default: + } + + if len(q.heap) == 0 { + p, err := q.recv() + if err != nil { + return 0, err + } + if p.Reader != nil { + q.setReader(p.Reader) + return p.Reader.Read(b) + } + heap.Push(&q.heap, p) + } + for len(q.heap) > 0 { packet := heap.Pop(&q.heap).(nativeXHTTPPacket) + if packet.Seq == q.nextSeq { - if len(packet.Payload) == 0 { - q.nextSeq = packet.Seq + 1 - continue - } n := copy(b, packet.Payload) if n < len(packet.Payload) { + // Partial read: push the remainder back with the same sequence so + // the next Read continues it before advancing nextSeq. (This mirrors + // xray-core; a separate side buffer would forget to advance nextSeq.) packet.Payload = packet.Payload[n:] heap.Push(&q.heap, packet) } else { @@ -1001,37 +1019,38 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) { } return n, nil } + if packet.Seq > q.nextSeq { + // Misordered: wait for the missing predecessor. if len(q.heap) > q.maxPackets { - return 0, errors.New("xhttp packet queue is too large") + return 0, errors.New("xhttp upload reassembly buffer too large") } heap.Push(&q.heap, packet) - select { - case <-q.deadlineChan(): - return 0, os.ErrDeadlineExceeded - case p := <-q.pushedPackets: - if p.Reader != nil { - return 0, errors.New("xhttp mixed stream-up and packet-up upload") - } - heap.Push(&q.heap, p) - case <-q.closed: - return 0, io.EOF + p, err := q.recv() + if err != nil { + return 0, err } + if p.Reader != nil { + return 0, errors.New("xhttp mixed stream-up and packet-up upload") + } + heap.Push(&q.heap, p) } + // packet.Seq < nextSeq: stale/duplicate, already popped — drop it. } - select { - case <-q.deadlineChan(): - return 0, os.ErrDeadlineExceeded - case <-q.closed: - return 0, io.EOF - case p := <-q.pushedPackets: - if p.Reader != nil { - q.reader = p.Reader - return q.reader.Read(b) - } - heap.Push(&q.heap, p) - return q.Read(b) - } + + return 0, nil +} + +func (q *nativeXHTTPUploadQueue) loadReader() io.ReadCloser { + q.mu.Lock() + defer q.mu.Unlock() + return q.reader +} + +func (q *nativeXHTTPUploadQueue) setReader(r io.ReadCloser) { + q.mu.Lock() + q.reader = r + q.mu.Unlock() } type nativeXHTTPHeap []nativeXHTTPPacket