Xhttp and panel update
This commit is contained in:
@@ -11,6 +11,8 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine
|
||||
- Banco de dados PostgreSQL
|
||||
- Integração com Xray-core/V2Ray
|
||||
- Configurador visual para VLESS e VMess
|
||||
- Editor visual completo de inbounds (criar, editar, duplicar e remover)
|
||||
- Endpoint XHTTP compartilhado no modo nativo: VLESS **ou** VMess em `/` e SSH em `/ssh`, usando o mesmo domínio/porta/TLS
|
||||
- Contas de revendedor (reseller) com cota de usuários e escopo próprio
|
||||
- Gerenciamento multi-servidor (master/slave) direto pelo painel
|
||||
- API HTTP completa para bots/automações (ver **HTTP API Reference**)
|
||||
@@ -48,6 +50,15 @@ gRPC
|
||||
|
||||
Observação: Reality deve ser usado apenas em protocolos compatíveis. No configurador visual, VMess não usa Reality.
|
||||
|
||||
### Endpoint XHTTP compartilhado
|
||||
|
||||
Na aba **Xray → Configuração Xray → Visual**, o cartão **Shared XHTTP endpoint** cria dois inbounds na mesma porta:
|
||||
|
||||
- VLESS **ou** VMess em `/` (selecione um protocolo raiz);
|
||||
- SSH sobre XHTTP em `/ssh`, autenticado pelas contas SSH normais do painel.
|
||||
|
||||
O listener HTTP interno escolhe o caminho mais específico, portanto `/ssh` é encaminhado ao servidor SSH antes do inbound raiz. Todos os inbounds na mesma porta devem usar a mesma opção TLS e o mesmo certificado. Esse recurso exige o modo **Internal native emulator**; o binário Xray externo não reconhece o protocolo `ssh` específico do DragonCore.
|
||||
|
||||
### Requisitos
|
||||
|
||||
- Servidor Linux com `systemd`
|
||||
@@ -522,6 +533,8 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post
|
||||
- PostgreSQL database
|
||||
- Xray-core/V2Ray integration
|
||||
- Visual configurator for VLESS and VMess
|
||||
- Full visual inbound editor (create, edit, duplicate, and remove)
|
||||
- Native shared XHTTP endpoint: VLESS **or** VMess on `/` and SSH on `/ssh`, using the same domain/port/TLS
|
||||
- Reseller accounts with a user quota and self-scoped access
|
||||
- Multi-server (master/slave) management directly from the panel
|
||||
- Full HTTP API for bots/automations (see **HTTP API Reference**)
|
||||
@@ -559,6 +572,15 @@ gRPC
|
||||
|
||||
Note: Reality should only be used with compatible protocols. In the visual configurator, VMess does not use Reality.
|
||||
|
||||
### Shared XHTTP endpoint
|
||||
|
||||
In **Xray → Xray Config → Visual**, the **Shared XHTTP endpoint** card creates two inbounds on the same port:
|
||||
|
||||
- VLESS **or** VMess on `/` (select one root protocol);
|
||||
- SSH over XHTTP on `/ssh`, authenticated by the panel's regular SSH accounts.
|
||||
|
||||
The internal HTTP listener selects the most specific path, so `/ssh` reaches the SSH server before the root inbound. Every inbound sharing a port must use the same TLS setting and certificate. This feature requires **Internal native emulator** mode; the external Xray binary does not recognize DragonCore's custom `ssh` protocol.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Linux server with `systemd`
|
||||
|
||||
@@ -650,6 +650,37 @@ select:disabled {
|
||||
.update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.btn-xs{padding:5px 8px!important;font-size:.67rem!important;border-radius:9px!important;}
|
||||
|
||||
/* --- Shared visual language for every workspace tab --- */
|
||||
.page-hero{
|
||||
--hero-accent:34,211,238;
|
||||
position:relative;display:flex;align-items:flex-end;justify-content:space-between;gap:22px;
|
||||
min-height:150px;margin-bottom:18px;padding:25px 27px;overflow:hidden;
|
||||
border:1px solid rgba(var(--hero-accent),.22);border-radius:28px;
|
||||
background:
|
||||
radial-gradient(circle at 88% 8%,rgba(var(--hero-accent),.24),transparent 33%),
|
||||
radial-gradient(circle at 8% 115%,rgba(139,92,246,.12),transparent 40%),
|
||||
linear-gradient(135deg,rgba(17,22,35,.98),rgba(8,11,18,.98));
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.32),inset 0 1px 0 rgba(255,255,255,.04);
|
||||
}
|
||||
.page-hero::after{content:"";position:absolute;right:-65px;top:-100px;width:250px;height:250px;border:1px solid rgba(255,255,255,.055);border-radius:50%;box-shadow:0 0 0 34px rgba(255,255,255,.017),0 0 0 68px rgba(255,255,255,.011);pointer-events:none;}
|
||||
.page-hero[data-tone="green"]{--hero-accent:49,214,123}.page-hero[data-tone="purple"]{--hero-accent:139,92,246}.page-hero[data-tone="amber"]{--hero-accent:255,200,87}.page-hero[data-tone="blue"]{--hero-accent:80,145,255}
|
||||
.page-hero-copy,.page-hero-pills,.page-hero-mark{position:relative;z-index:1}.page-hero-copy{max-width:700px}.page-kicker{display:block;color:rgb(var(--hero-accent));font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase}.page-hero h2{margin-top:7px;font-size:2rem;line-height:1.05;letter-spacing:-.045em}.page-hero p{max-width:650px;margin-top:9px;color:var(--muted);font-size:.82rem;line-height:1.55}.page-hero-pills{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap;max-width:42%}.page-hero-pills span{padding:7px 10px;border:1px solid rgba(var(--hero-accent),.18);border-radius:999px;background:rgba(var(--hero-accent),.075);color:var(--text-2);font-size:.69rem;font-weight:850;letter-spacing:.04em}.page-hero-mark{display:grid;place-items:center;width:64px;height:64px;border:1px solid rgba(var(--hero-accent),.25);border-radius:22px;background:rgba(var(--hero-accent),.10);color:rgb(var(--hero-accent));font-size:1.05rem;font-weight:950;box-shadow:0 18px 44px rgba(0,0,0,.24)}
|
||||
|
||||
/* Bring legacy screens up to the Bot workspace's information density. */
|
||||
.tab-pane:not(#tab-bot)>.grid2,.tab-pane:not(#tab-bot)>#serversListView>.grid2{gap:16px}.tab-pane:not(#tab-bot) .card-hdr{padding-bottom:12px;border-bottom:1px solid rgba(148,163,184,.09)}.tab-pane:not(#tab-bot) .card-title{font-size:.96rem}.tab-pane:not(#tab-bot) .statusbar{margin-top:13px;padding-top:11px;border-top:1px solid rgba(148,163,184,.08)}
|
||||
|
||||
/* Xray visual configuration studio */
|
||||
.shared-endpoint-card{--hero-accent:139,92,246;position:relative;margin-bottom:18px;padding:20px;overflow:hidden;border:1px solid rgba(139,92,246,.22);border-radius:22px;background:radial-gradient(circle at 96% 0,rgba(139,92,246,.17),transparent 32%),rgba(8,12,20,.82)}
|
||||
.shared-endpoint-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}.shared-endpoint-head h3,.visual-editor-heading h3{margin-top:5px;font-size:1.12rem;letter-spacing:-.02em}.shared-endpoint-head p{margin-top:5px;color:var(--muted);font-size:.75rem;line-height:1.45}.shared-endpoint-head code,.shared-route-preview code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:#c8bbff}
|
||||
.shared-route-preview{display:grid;grid-template-columns:1fr 48px 1fr;align-items:center;gap:8px;margin:17px 0;padding:10px;border:1px solid rgba(148,163,184,.11);border-radius:17px;background:rgba(255,255,255,.025)}.shared-route-preview span{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 12px;border:1px solid rgba(139,92,246,.16);border-radius:13px;background:rgba(139,92,246,.07)}.shared-route-preview strong{font-size:.78rem}.shared-route-preview code{font-size:.77rem;font-weight:900}.shared-route-preview i{height:1px;background:linear-gradient(90deg,rgba(139,92,246,.2),rgba(34,211,238,.7),rgba(139,92,246,.2));position:relative}.shared-route-preview i::after{content:"";position:absolute;right:0;top:-3px;width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent)}
|
||||
.shared-endpoint-grid{grid-template-columns:repeat(3,minmax(0,1fr));}.shared-endpoint-actions{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:15px;padding-top:14px;border-top:1px solid rgba(148,163,184,.1)}.shared-endpoint-actions .hint{max-width:650px}
|
||||
.visual-config-toolbar{display:grid;grid-template-columns:180px minmax(0,1fr) auto;align-items:end;gap:14px;margin-bottom:13px;padding:13px 15px;border:1px solid rgba(148,163,184,.1);border-radius:18px;background:rgba(255,255,255,.025)}.visual-config-toolbar-copy{display:flex;flex-direction:column;gap:4px;padding-bottom:4px}.visual-config-toolbar-copy strong{font-size:.84rem}.visual-config-toolbar-copy span{color:var(--muted);font-size:.71rem}
|
||||
.visual-inbound-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:14px}.visual-inbound-card{position:relative;display:flex;flex-direction:column;gap:13px;min-width:0;padding:15px;border:1px solid rgba(148,163,184,.11);border-radius:18px;background:rgba(255,255,255,.027);transition:.15s ease}.visual-inbound-card:hover{border-color:rgba(139,92,246,.3);background:rgba(139,92,246,.045);transform:translateY(-1px)}.visual-inbound-card-head,.visual-inbound-meta,.visual-inbound-actions{display:flex;align-items:center;gap:8px}.visual-inbound-card-head{justify-content:space-between}.visual-inbound-name{min-width:0}.visual-inbound-name strong{display:block;overflow:hidden;text-overflow:ellipsis;color:var(--text);font-size:.84rem;white-space:nowrap}.visual-inbound-name small{display:block;margin-top:4px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.67rem}.visual-inbound-meta{flex-wrap:wrap}.visual-inbound-meta span{padding:4px 7px;border-radius:8px;background:rgba(148,163,184,.07);color:var(--muted);font-size:.67rem}.visual-inbound-actions{justify-content:flex-end;margin-top:auto;padding-top:11px;border-top:1px solid rgba(148,163,184,.08)}
|
||||
.visual-inbound-editor{margin:14px 0;padding:18px;border:1px solid rgba(34,211,238,.2);border-radius:22px;background:radial-gradient(circle at 100% 0,rgba(34,211,238,.09),transparent 28%),rgba(6,10,16,.86)}.visual-editor-heading{--hero-accent:34,211,238;display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:15px;padding-bottom:13px;border-bottom:1px solid rgba(148,163,184,.1)}.visual-save-bar{position:sticky;bottom:14px;z-index:8;display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:16px;padding:13px 15px;border:1px solid rgba(139,92,246,.2);border-radius:19px;background:rgba(8,12,20,.9);box-shadow:0 18px 48px rgba(0,0,0,.35);backdrop-filter:blur(16px)}
|
||||
|
||||
@media(max-width:1100px){.shared-endpoint-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.visual-inbound-list{grid-template-columns:1fr}}
|
||||
@media(max-width:760px){.page-hero{min-height:0;padding:20px;border-radius:22px;align-items:flex-start;flex-direction:column}.page-hero h2{font-size:1.55rem}.page-hero-pills{max-width:none;justify-content:flex-start}.page-hero-mark{width:50px;height:50px;border-radius:17px}.shared-endpoint-card{padding:15px}.shared-endpoint-head,.shared-endpoint-actions,.visual-save-bar{align-items:flex-start;flex-direction:column}.shared-endpoint-actions .btn,.visual-save-bar .btn{width:100%}.shared-route-preview{grid-template-columns:1fr}.shared-route-preview i{width:1px;height:22px;justify-self:center}.shared-route-preview i::after{right:-3px;top:auto;bottom:0}.shared-endpoint-grid{grid-template-columns:1fr!important}.visual-config-toolbar{grid-template-columns:1fr;align-items:stretch}.visual-config-toolbar .btn{width:100%}}
|
||||
|
||||
/* --- Bot sales workspace --- */
|
||||
#tab-bot{--bot-accent:#7c5cff;--bot-line:rgba(160,174,192,.14);}
|
||||
.bot-hero{
|
||||
|
||||
@@ -15,6 +15,7 @@ let wzLoadedFullConfig = null;
|
||||
let wzLoadedConfigText = "";
|
||||
let wzLoadedServerID = null;
|
||||
let wzDirty = false;
|
||||
let wzEditingIndex = -1;
|
||||
let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null };
|
||||
let currentTab = "dashboard";
|
||||
let inboundsRefreshInFlight = false;
|
||||
@@ -114,6 +115,22 @@ Object.assign(I18N_ALIASES, {
|
||||
"Atualizações do painel":"Panel Updates","Verificando…":"Checking…","Abrir Git":"Open Git","Verificar agora":"Check now","Versão instalada":"Installed version","Última versão no Git":"Latest Git version","Última verificação":"Last checked",
|
||||
"Comparando a versão instalada com o repositório Git.":"Comparing the installed version with the Git repository.","Para atualizar:":"To update:","Copiar comando":"Copy command","Atualizado":"Up to date","Atualização disponível":"Update available","Alterações locais":"Local changes","Desconhecido":"Unknown"
|
||||
});
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Command center":"Command center","Overview command copy":"Accounts, connections, and infrastructure at a glance.","Access workspace":"Access workspace","SSH accounts":"SSH accounts","SSH accounts copy":"Create, limit, and monitor SSH and SlowDNS access securely.",
|
||||
"Proxy studio":"Proxy studio","Xray visual":"Visual Xray","Xray visual copy":"Manage clients, inbounds, and a shared XHTTP endpoint without editing JSON.","Partner operations":"Partner operations","Partner operations copy":"Control partner quotas, expiration, and access in one place.",
|
||||
"Fleet control":"Fleet control","Fleet control copy":"Add nodes, test credentials, and configure remote infrastructure.","Live fleet":"Live fleet","Server status copy":"Health, load, and active sessions for every managed node.","Observability":"Observability","Monitoring copy":"Server resources, interfaces, and capacity in real time.",
|
||||
"Traffic intelligence":"Traffic intelligence","Network traffic":"Network traffic","Network traffic copy":"Daily and monthly history for understanding infrastructure usage.","Diagnostics":"Diagnostics","System logs copy":"Investigate the panel, DNSTT, and Xray in a focused view.","System studio":"System studio","Settings copy":"Network, SSH, tunnels, and TLS organized visually and applied live.",
|
||||
"One domain and port":"One domain and port","Shared endpoint copy":"The selected protocol uses /; SSH uses /ssh. Available in native Xray mode.","Protocol on /":"Protocol on /","Shared port":"Shared port","Listen IP":"Listen IP","HTTP host":"HTTP host","optional":"optional","XHTTP mode":"XHTTP mode","Security":"Security","No TLS":"No TLS","Certificate file":"Certificate file","Key file":"Key file","Create / update endpoint":"Create / update endpoint",
|
||||
"Configured inbounds":"Configured inbounds","Visual inbound help":"Edit any card visually or use JSON for advanced fields.","New inbound":"New inbound","Visual editor":"Visual editor","Add inbound":"Add inbound","Save changes":"Save changes","Duplicate":"Duplicate","Remove":"Remove","Save config and restart":"Save config and restart"
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Command center":"Central de comando","Overview command copy":"Contas, conexões e infraestrutura em uma leitura rápida.","Access workspace":"Área de acessos","SSH accounts":"Contas SSH","SSH accounts copy":"Crie, limite e acompanhe acessos SSH e SlowDNS com segurança.",
|
||||
"Proxy studio":"Estúdio de proxy","Xray visual":"Xray visual","Xray visual copy":"Gerencie clientes, inbounds e um endpoint XHTTP compartilhado sem editar JSON.","Partner operations":"Operação de parceiros","Partner operations copy":"Controle cotas, validade e acesso dos parceiros em um só lugar.",
|
||||
"Fleet control":"Controle da frota","Fleet control copy":"Adicione nós, teste credenciais e configure a infraestrutura remota.","Live fleet":"Frota ao vivo","Server status copy":"Saúde, carga e sessões ativas de cada nó gerenciado.","Observability":"Observabilidade","Monitoring copy":"Recursos, interfaces e capacidade do servidor em tempo real.",
|
||||
"Traffic intelligence":"Inteligência de tráfego","Network traffic":"Tráfego de rede","Network traffic copy":"Histórico diário e mensal para entender o consumo da infraestrutura.","Diagnostics":"Diagnóstico","System logs copy":"Investigue painel, DNSTT e Xray com uma visualização focada.","System studio":"Estúdio do sistema","Settings copy":"Rede, SSH, túneis e TLS organizados em blocos visuais e aplicados ao vivo.",
|
||||
"One domain and port":"Um domínio e uma porta","Shared endpoint copy":"O protocolo selecionado usa /; SSH usa /ssh. Disponível no modo Xray nativo.","Protocol on /":"Protocolo em /","Shared port":"Porta compartilhada","Listen IP":"IP de listen","HTTP host":"Host HTTP","optional":"opcional","XHTTP mode":"Modo XHTTP","Security":"Segurança","No TLS":"Sem TLS","Certificate file":"Arquivo do certificado","Key file":"Arquivo da chave","Create / update endpoint":"Criar / atualizar endpoint",
|
||||
"Configured inbounds":"Inbounds configurados","Visual inbound help":"Edite qualquer cartão visualmente ou use JSON para campos avançados.","New inbound":"Novo inbound","Visual editor":"Editor visual","Add inbound":"Adicionar inbound","Save changes":"Salvar alterações","Duplicate":"Duplicar","Remove":"Remover","Save config and restart":"Salvar configuração e reiniciar"
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -98,9 +98,11 @@ async function xrayCtrl(action) {
|
||||
xStatus.textContent = currentLang === "pt-BR" ? "Xray OK." : "Xray "+action+" OK.";
|
||||
setTimeout(loadXrayStatus, 700);
|
||||
setTimeout(() => loadInbounds({ force: true }), 1200);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else xStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,9 @@ function loadWizardFromConfig() {
|
||||
wzLoadedFullConfig = cloneJsonSafe(cfg);
|
||||
document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning";
|
||||
wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
|
||||
wzEditingIndex = -1;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Config loaded from ${target}.`;
|
||||
}).catch(e => {
|
||||
@@ -90,7 +92,9 @@ function loadWizardFromConfig() {
|
||||
wzLoadedConfigText = "";
|
||||
wzLoadedFullConfig = null;
|
||||
wzInbounds = [];
|
||||
wzEditingIndex = -1;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
});
|
||||
@@ -99,49 +103,396 @@ function loadWizardFromConfig() {
|
||||
function renderWzInbounds() {
|
||||
const list = document.getElementById("wzInboundsList");
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
if (!wzInbounds.length) {
|
||||
list.innerHTML = '<div class="hint" style="padding:4px 0;">No inbounds. Click + Add to create one.</div>';
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "hint visual-empty-state";
|
||||
empty.textContent = "Nenhum inbound configurado. Crie um endpoint compartilhado ou adicione um inbound.";
|
||||
list.appendChild(empty);
|
||||
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 row = document.createElement("article");
|
||||
row.className = "visual-inbound-card";
|
||||
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 = `<span class="chip">${escapeHTML(ib.protocol || "")}</span>
|
||||
<span style="font-family:monospace;">${escapeHTML((ib.tag||"untagged")+portStr)}</span>
|
||||
<span class="hint" style="flex:1;">${escapeHTML((ib.listen||"0.0.0.0")+(net?" · "+net:"")+modeLabel+secLabel)}</span>`;
|
||||
const transportSettings = ss.xhttpSettings || ss.splithttpSettings || ss.wsSettings || ss.httpupgradeSettings || ss.httpSettings || ss.grpcSettings || {};
|
||||
const head = document.createElement("div");
|
||||
head.className = "visual-inbound-card-head";
|
||||
const name = document.createElement("div");
|
||||
name.className = "visual-inbound-name";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = ib.tag || "untagged";
|
||||
const address = document.createElement("small");
|
||||
address.textContent = `${ib.listen || "0.0.0.0"}${portStr}`;
|
||||
name.append(title, address);
|
||||
const protocol = document.createElement("span");
|
||||
protocol.className = "chip";
|
||||
protocol.textContent = String(ib.protocol || "unknown").toUpperCase();
|
||||
head.append(name, protocol);
|
||||
row.appendChild(head);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "visual-inbound-meta";
|
||||
[net || "default", transportSettings.path || transportSettings.serviceName || "no path", sec || "no TLS"].forEach(value => {
|
||||
const item = document.createElement("span");
|
||||
item.textContent = value;
|
||||
meta.appendChild(item);
|
||||
});
|
||||
row.appendChild(meta);
|
||||
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);
|
||||
meta.appendChild(badge);
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "visual-inbound-actions";
|
||||
const duplicateBtn = document.createElement("button");
|
||||
duplicateBtn.className = "btn btn-ghost btn-sm";
|
||||
duplicateBtn.type = "button";
|
||||
duplicateBtn.textContent = "Duplicar";
|
||||
duplicateBtn.onclick = () => duplicateWzInbound(i);
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "btn btn-ghost btn-sm";
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Editar";
|
||||
editBtn.onclick = () => editWzInbound(i);
|
||||
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);
|
||||
delBtn.type = "button";
|
||||
delBtn.textContent = "Remover";
|
||||
delBtn.onclick = () => {
|
||||
if (!confirm(`Remover o inbound ${ib.tag || "untagged"}?`)) return;
|
||||
wzInbounds.splice(i,1);
|
||||
if (wzEditingIndex === i) wzCancelInbound();
|
||||
else if (wzEditingIndex > i) wzEditingIndex--;
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
};
|
||||
actions.append(duplicateBtn, editBtn, delBtn);
|
||||
row.appendChild(actions);
|
||||
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);
|
||||
if (!form.classList.contains("hidden") && wzEditingIndex < 0) return wzCancelInbound();
|
||||
resetWzInboundForm();
|
||||
form.classList.remove("hidden");
|
||||
form.scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
}
|
||||
|
||||
function setWzValue(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = value ?? "";
|
||||
}
|
||||
|
||||
function resetWzInboundForm() {
|
||||
wzEditingIndex = -1;
|
||||
setWzValue("wzProtocol", "vless");
|
||||
setWzValue("wzPort", "10086");
|
||||
setWzValue("wzListenIP", "0.0.0.0");
|
||||
setWzValue("wzTag", "vless-in");
|
||||
setWzValue("wzNetwork", "tcp");
|
||||
setWzValue("wzWSPath", "/ws");
|
||||
setWzValue("wzXHTTPPath", "/xhttp");
|
||||
setWzValue("wzXHTTPHost", "");
|
||||
setWzValue("wzXHTTPMode", "auto");
|
||||
setWzValue("wzHUPath", "/upgrade");
|
||||
setWzValue("wzHUHost", "");
|
||||
setWzValue("wzH2Path", "/h2");
|
||||
setWzValue("wzH2Host", "");
|
||||
setWzValue("wzGRPCService", "grpc-service");
|
||||
document.getElementById("wzGRPCMulti").checked = false;
|
||||
setWzValue("wzTLS", "none");
|
||||
["wzTLSCert", "wzTLSKey", "wzTLSCertPath", "wzTLSKeyPath", "wzRealityDest", "wzRealitySNI", "wzRealityPriv", "wzRealityShortID", "wzTrojanPass", "wzSSPass"].forEach(id => setWzValue(id, ""));
|
||||
setWzValue("wzSSMethod", "chacha20-ietf-poly1305");
|
||||
document.getElementById("wzInboundFormTitle").textContent = "Novo inbound";
|
||||
document.getElementById("wzSaveInboundBtn").textContent = "Adicionar inbound";
|
||||
document.getElementById("wzEditingBadge").classList.add("hidden");
|
||||
onWzProtoChange("vless");
|
||||
onWzNetworkChange("tcp");
|
||||
onWzTLSChange("none");
|
||||
}
|
||||
|
||||
function wzCancelInbound() {
|
||||
wzEditingIndex = -1;
|
||||
document.getElementById("wzAddInboundForm")?.classList.add("hidden");
|
||||
document.getElementById("wzEditingBadge")?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function editWzInbound(index) {
|
||||
const ib = wzInbounds[index];
|
||||
if (!ib) return;
|
||||
resetWzInboundForm();
|
||||
wzEditingIndex = index;
|
||||
const proto = String(ib.protocol || "vless").toLowerCase();
|
||||
setWzValue("wzProtocol", proto);
|
||||
onWzProtoChange(proto);
|
||||
setWzValue("wzPort", ib.port ?? "");
|
||||
setWzValue("wzListenIP", ib.listen || (proto === "socks" ? "127.0.0.1" : "0.0.0.0"));
|
||||
setWzValue("wzTag", ib.tag || `${proto}-in`);
|
||||
|
||||
const ss = ib.streamSettings || {};
|
||||
const network = proto === "ssh" ? "xhttp" : (ss.network || "tcp");
|
||||
setWzValue("wzNetwork", network);
|
||||
onWzNetworkChange(network);
|
||||
const xh = ss.xhttpSettings || ss.splithttpSettings || {};
|
||||
setWzValue("wzWSPath", ss.wsSettings?.path || "/ws");
|
||||
setWzValue("wzXHTTPPath", xh.path || "/xhttp");
|
||||
setWzValue("wzXHTTPHost", xh.host || "");
|
||||
setWzValue("wzXHTTPMode", xh.mode || "auto");
|
||||
setWzValue("wzHUPath", ss.httpupgradeSettings?.path || "/upgrade");
|
||||
setWzValue("wzHUHost", ss.httpupgradeSettings?.host || "");
|
||||
setWzValue("wzH2Path", ss.httpSettings?.path || "/h2");
|
||||
setWzValue("wzH2Host", Array.isArray(ss.httpSettings?.host) ? (ss.httpSettings.host[0] || "") : (ss.httpSettings?.host || ""));
|
||||
setWzValue("wzGRPCService", ss.grpcSettings?.serviceName || "grpc-service");
|
||||
document.getElementById("wzGRPCMulti").checked = !!ss.grpcSettings?.multiMode;
|
||||
|
||||
const security = ss.security === "tls" ? "tls" : (ss.security === "reality" ? "reality" : "none");
|
||||
setWzValue("wzTLS", security);
|
||||
onWzTLSChange(security);
|
||||
const cert = ss.tlsSettings?.certificates?.[0] || {};
|
||||
setWzValue("wzTLSCert", cert.certificateFile || "");
|
||||
setWzValue("wzTLSKey", cert.keyFile || "");
|
||||
setWzValue("wzTLSCertPath", cert.certificateFile || "");
|
||||
setWzValue("wzTLSKeyPath", cert.keyFile || "");
|
||||
setWzValue("wzRealityDest", ss.realitySettings?.dest || "");
|
||||
setWzValue("wzRealitySNI", ss.realitySettings?.serverNames?.[0] || "");
|
||||
setWzValue("wzRealityPriv", ss.realitySettings?.privateKey || "");
|
||||
setWzValue("wzRealityShortID", ss.realitySettings?.shortIds?.[0] || "");
|
||||
setWzValue("wzTrojanPass", ib.settings?.clients?.[0]?.password || "");
|
||||
setWzValue("wzSSPass", ib.settings?.password || "");
|
||||
setWzValue("wzSSMethod", ib.settings?.method || "chacha20-ietf-poly1305");
|
||||
|
||||
document.getElementById("wzInboundFormTitle").textContent = `Editar ${ib.tag || "inbound"}`;
|
||||
document.getElementById("wzSaveInboundBtn").textContent = "Salvar alterações";
|
||||
document.getElementById("wzEditingBadge").classList.remove("hidden");
|
||||
const form = document.getElementById("wzAddInboundForm");
|
||||
form.classList.remove("hidden");
|
||||
form.scrollIntoView({ behavior:"smooth", block:"start" });
|
||||
}
|
||||
|
||||
function duplicateWzInbound(index) {
|
||||
const source = wzInbounds[index];
|
||||
if (!source) return;
|
||||
const copy = cloneJsonSafe(source);
|
||||
const tags = new Set(wzInbounds.map(ib => ib?.tag));
|
||||
let n = 2;
|
||||
let tag = `${source.tag || source.protocol || "inbound"}-copy`;
|
||||
while (tags.has(tag)) tag = `${source.tag || source.protocol || "inbound"}-copy-${n++}`;
|
||||
copy.tag = tag;
|
||||
const port = Number(copy.port || 0);
|
||||
if (port > 0 && port < 65535) copy.port = port + 1;
|
||||
wzInbounds.push(copy);
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
editWzInbound(wzInbounds.length - 1);
|
||||
}
|
||||
|
||||
function normalizeVisualPath(value) {
|
||||
let path = String(value || "/").trim().split("?", 1)[0];
|
||||
if (!path.startsWith("/")) path = `/${path}`;
|
||||
path = path.replace(/\/+$/, "") || "/";
|
||||
return path;
|
||||
}
|
||||
|
||||
function visualXHTTPSettings(ib) {
|
||||
const ss = ib?.streamSettings || {};
|
||||
if (!["xhttp", "splithttp"].includes(String(ss.network || "").toLowerCase())) return null;
|
||||
return ss.xhttpSettings || ss.splithttpSettings || {};
|
||||
}
|
||||
|
||||
function validateVisualInbounds(inbounds) {
|
||||
const tags = new Set();
|
||||
const binds = new Map();
|
||||
const nativeMode = (document.getElementById("xCoreMode")?.value || "native") === "native";
|
||||
for (const ib of inbounds || []) {
|
||||
const tag = String(ib?.tag || "").trim();
|
||||
if (!tag) throw new Error("every inbound needs a tag");
|
||||
if (tags.has(tag)) throw new Error(`duplicate inbound tag: ${tag}`);
|
||||
tags.add(tag);
|
||||
const port = Number(ib?.port || 0);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid port on ${tag}`);
|
||||
const bind = `${String(ib?.listen || "0.0.0.0").trim()}:${port}`;
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
if (!binds.has(bind)) binds.set(bind, []);
|
||||
binds.get(bind).push({ ib, xh });
|
||||
if (String(ib?.protocol || "").toLowerCase() === "ssh") {
|
||||
if (!xh) throw new Error(`SSH inbound ${tag} requires XHTTP`);
|
||||
if (!nativeMode) throw new Error(`SSH inbound ${tag} requires native Xray mode`);
|
||||
}
|
||||
}
|
||||
for (const [bind, rows] of binds) {
|
||||
if (rows.length < 2) continue;
|
||||
if (!nativeMode || rows.some(row => !row.xh)) throw new Error(`multiple inbounds cannot share ${bind} unless all use native XHTTP`);
|
||||
const paths = new Set();
|
||||
const firstSecurity = String(rows[0].ib.streamSettings?.security || "none");
|
||||
const firstCert = rows[0].ib.streamSettings?.tlsSettings?.certificates?.[0] || {};
|
||||
for (const row of rows) {
|
||||
const path = normalizeVisualPath(row.xh.path);
|
||||
if (paths.has(path)) throw new Error(`duplicate XHTTP path ${path} on ${bind}`);
|
||||
paths.add(path);
|
||||
const security = String(row.ib.streamSettings?.security || "none");
|
||||
const cert = row.ib.streamSettings?.tlsSettings?.certificates?.[0] || {};
|
||||
if (security !== firstSecurity) throw new Error(`shared XHTTP inbounds on ${bind} must use the same TLS setting`);
|
||||
if (security === "tls" && (cert.certificateFile !== firstCert.certificateFile || cert.keyFile !== firstCert.keyFile)) {
|
||||
throw new Error(`shared XHTTP inbounds on ${bind} must use the same certificate`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findSharedEndpointPair() {
|
||||
const roots = wzInbounds.filter(ib => {
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
return !!xh && ["vless", "vmess"].includes(String(ib?.protocol || "").toLowerCase()) && normalizeVisualPath(xh.path) === "/";
|
||||
});
|
||||
for (const proxy of roots) {
|
||||
const ssh = wzInbounds.find(ib => String(ib?.protocol || "").toLowerCase() === "ssh" &&
|
||||
String(ib.listen || "0.0.0.0") === String(proxy.listen || "0.0.0.0") && String(ib.port) === String(proxy.port) &&
|
||||
normalizeVisualPath(visualXHTTPSettings(ib)?.path) === "/ssh");
|
||||
if (ssh) return { proxy, ssh };
|
||||
}
|
||||
const proxy = wzInbounds.find(ib => ib?.tag === "shared-proxy-xhttp") || null;
|
||||
const ssh = wzInbounds.find(ib => ib?.tag === "shared-ssh-xhttp") || null;
|
||||
return proxy && ssh ? { proxy, ssh } : null;
|
||||
}
|
||||
|
||||
function loadSharedEndpointForm() {
|
||||
const status = document.getElementById("sharedXHTTPStatus");
|
||||
if (!status) return;
|
||||
const pair = findSharedEndpointPair();
|
||||
if (!pair) {
|
||||
status.textContent = wzLoadedConfigText ? "Nenhum endpoint compartilhado detectado. Preencha os campos para criar um." : "Carregue a configuração para detectar um endpoint existente.";
|
||||
return;
|
||||
}
|
||||
const xh = visualXHTTPSettings(pair.proxy) || {};
|
||||
const ss = pair.proxy.streamSettings || {};
|
||||
const cert = ss.tlsSettings?.certificates?.[0] || {};
|
||||
setWzValue("sharedXHTTPProtocol", pair.proxy.protocol || "vless");
|
||||
setWzValue("sharedXHTTPPort", pair.proxy.port || 443);
|
||||
setWzValue("sharedXHTTPListen", pair.proxy.listen || "0.0.0.0");
|
||||
setWzValue("sharedXHTTPHost", xh.host || "");
|
||||
setWzValue("sharedXHTTPMode", xh.mode || "auto");
|
||||
setWzValue("sharedXHTTPSecurity", ss.security === "tls" ? "tls" : "none");
|
||||
setWzValue("sharedXHTTPCert", cert.certificateFile || "");
|
||||
setWzValue("sharedXHTTPKey", cert.keyFile || "");
|
||||
updateSharedEndpointControls();
|
||||
status.textContent = `Endpoint detectado em ${pair.proxy.listen || "0.0.0.0"}:${pair.proxy.port} — ${String(pair.proxy.protocol).toUpperCase()} / e SSH /ssh.`;
|
||||
}
|
||||
|
||||
function updateSharedEndpointControls() {
|
||||
const protocol = document.getElementById("sharedXHTTPProtocol")?.value || "vless";
|
||||
const security = document.getElementById("sharedXHTTPSecurity")?.value || "none";
|
||||
document.getElementById("sharedProxyRouteLabel").textContent = protocol.toUpperCase();
|
||||
document.querySelectorAll(".shared-tls-field").forEach(el => el.classList.toggle("hidden", security !== "tls"));
|
||||
}
|
||||
|
||||
function applySharedXHTTPEndpoint() {
|
||||
const status = document.getElementById("sharedXHTTPStatus");
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
|
||||
status.textContent = "Carregue a configuração do servidor selecionado antes de editar.";
|
||||
return;
|
||||
}
|
||||
if ((document.getElementById("xCoreMode")?.value || "native") !== "native") {
|
||||
status.textContent = "O endpoint compartilhado requer o modo Xray nativo.";
|
||||
return;
|
||||
}
|
||||
const protocol = document.getElementById("sharedXHTTPProtocol").value;
|
||||
const port = Number(document.getElementById("sharedXHTTPPort").value || 0);
|
||||
const listen = document.getElementById("sharedXHTTPListen").value.trim() || "0.0.0.0";
|
||||
const host = document.getElementById("sharedXHTTPHost").value.trim();
|
||||
const mode = document.getElementById("sharedXHTTPMode").value || "auto";
|
||||
const security = document.getElementById("sharedXHTTPSecurity").value;
|
||||
const cert = document.getElementById("sharedXHTTPCert").value.trim();
|
||||
const key = document.getElementById("sharedXHTTPKey").value.trim();
|
||||
if (!["vless", "vmess"].includes(protocol) || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
status.textContent = "Escolha VLESS/VMess e uma porta válida.";
|
||||
return;
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f]/.test(`${listen}${host}${cert}${key}`)) {
|
||||
status.textContent = "Os campos contêm caracteres de controle inválidos.";
|
||||
return;
|
||||
}
|
||||
if (security === "tls" && (!cert || !key)) {
|
||||
status.textContent = "Informe os arquivos do certificado e da chave para usar TLS.";
|
||||
return;
|
||||
}
|
||||
|
||||
const pair = findSharedEndpointPair();
|
||||
const sameEndpoint = ib => String(ib?.listen || "0.0.0.0") === listen && String(ib?.port) === String(port);
|
||||
const existingProxy = pair?.proxy || wzInbounds.find(ib => ib?.tag === "shared-proxy-xhttp") || wzInbounds.find(ib => {
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
return sameEndpoint(ib) && !!xh && ["vless", "vmess"].includes(String(ib?.protocol || "").toLowerCase()) && normalizeVisualPath(xh.path) === "/";
|
||||
}) || null;
|
||||
const existingSSH = pair?.ssh || wzInbounds.find(ib => ib?.tag === "shared-ssh-xhttp") || wzInbounds.find(ib => {
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
return sameEndpoint(ib) && !!xh && String(ib?.protocol || "").toLowerCase() === "ssh" && normalizeVisualPath(xh.path) === "/ssh";
|
||||
}) || null;
|
||||
const removeSet = new Set([existingProxy, existingSSH].filter(Boolean));
|
||||
const others = wzInbounds.filter(ib => !removeSet.has(ib));
|
||||
const blocking = others.find(ib => String(ib.listen || "0.0.0.0") === listen && String(ib.port) === String(port) && !visualXHTTPSettings(ib));
|
||||
if (blocking) {
|
||||
status.textContent = `A porta já é usada pelo inbound não-XHTTP ${blocking.tag || "sem tag"}. Escolha outra porta.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const buildSharedStream = (existing, path) => {
|
||||
const stream = cloneJsonSafe(existing?.streamSettings || {});
|
||||
stream.network = "xhttp";
|
||||
stream.xhttpSettings = Object.assign({}, stream.xhttpSettings || stream.splithttpSettings || {}, { path, mode });
|
||||
delete stream.splithttpSettings;
|
||||
if (host) stream.xhttpSettings.host = host;
|
||||
else delete stream.xhttpSettings.host;
|
||||
if (security === "tls") {
|
||||
stream.security = "tls";
|
||||
stream.tlsSettings = Object.assign({}, stream.tlsSettings || {}, { certificates:[{ certificateFile:cert, keyFile:key }] });
|
||||
} else {
|
||||
delete stream.security;
|
||||
delete stream.tlsSettings;
|
||||
}
|
||||
delete stream.realitySettings;
|
||||
return stream;
|
||||
};
|
||||
const previousClients = Array.isArray(existingProxy?.settings?.clients) ? cloneJsonSafe(existingProxy.settings.clients) : [];
|
||||
const proxyInbound = cloneJsonSafe(existingProxy || {});
|
||||
proxyInbound.tag = existingProxy?.tag || "shared-proxy-xhttp";
|
||||
proxyInbound.listen = listen;
|
||||
proxyInbound.port = port;
|
||||
proxyInbound.protocol = protocol;
|
||||
proxyInbound.settings = existingProxy?.protocol === protocol ? cloneJsonSafe(existingProxy.settings || {}) : {};
|
||||
proxyInbound.settings.clients = previousClients;
|
||||
if (protocol === "vless") proxyInbound.settings.decryption = "none";
|
||||
else delete proxyInbound.settings.decryption;
|
||||
proxyInbound.streamSettings = buildSharedStream(existingProxy, "/");
|
||||
const sshInbound = cloneJsonSafe(existingSSH || {});
|
||||
sshInbound.tag = existingSSH?.tag || "shared-ssh-xhttp";
|
||||
sshInbound.listen = listen;
|
||||
sshInbound.port = port;
|
||||
sshInbound.protocol = "ssh";
|
||||
sshInbound.settings = {};
|
||||
sshInbound.streamSettings = buildSharedStream(existingSSH, "/ssh");
|
||||
wzInbounds = [...others, proxyInbound, sshInbound];
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
status.textContent = "Endpoint atualizado no rascunho. Clique em Salvar configuração e reiniciar para aplicar.";
|
||||
}
|
||||
|
||||
document.getElementById("sharedXHTTPProtocol")?.addEventListener("change", updateSharedEndpointControls);
|
||||
document.getElementById("sharedXHTTPSecurity")?.addEventListener("change", updateSharedEndpointControls);
|
||||
document.getElementById("sharedXHTTPApplyBtn")?.addEventListener("click", applySharedXHTTPEndpoint);
|
||||
updateSharedEndpointControls();
|
||||
|
||||
function onWzProtoChange(val) {
|
||||
const isSSH = val === "ssh";
|
||||
// SSH tunnels reuse the VLESS/VMess transport block to expose the XHTTP
|
||||
@@ -227,78 +578,115 @@ function wzSaveInbound() {
|
||||
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: {} };
|
||||
const st = document.getElementById("wzStatus");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) { st.textContent = "Informe uma porta válida."; return; }
|
||||
if (/[\u0000-\u001f\u007f]/.test(`${listen}${tag}`)) { st.textContent = "Listen ou tag contém caracteres inválidos."; return; }
|
||||
if (wzInbounds.some((item, index) => index !== wzEditingIndex && item?.tag === tag)) { st.textContent = `A tag ${tag} já está em uso.`; return; }
|
||||
|
||||
const original = wzEditingIndex >= 0 ? cloneJsonSafe(wzInbounds[wzEditingIndex]) : null;
|
||||
const ib = original || {};
|
||||
const previousClients = Array.isArray(original?.settings?.clients) ? cloneJsonSafe(original.settings.clients) : [];
|
||||
ib.tag = tag;
|
||||
ib.port = port;
|
||||
ib.listen = listen;
|
||||
ib.protocol = proto;
|
||||
ib.settings = {};
|
||||
if (proto === "vless" || proto === "vmess") {
|
||||
ib.settings = proto === "vless" ? { clients: [], decryption: "none" } : { clients: [] };
|
||||
ib.settings = original?.protocol === proto && original.settings ? cloneJsonSafe(original.settings) : {};
|
||||
ib.settings.clients = previousClients;
|
||||
if (proto === "vless") ib.settings.decryption = "none";
|
||||
else delete ib.settings.decryption;
|
||||
const net = document.getElementById("wzNetwork").value;
|
||||
const tlsVal = document.getElementById("wzTLS").value;
|
||||
ib.streamSettings = { network: net };
|
||||
const previousStream = original?.protocol === proto && original?.streamSettings?.network === net ? cloneJsonSafe(original.streamSettings) : {};
|
||||
ib.streamSettings = previousStream || {};
|
||||
ib.streamSettings.network = net;
|
||||
["wsSettings", "xhttpSettings", "splithttpSettings", "httpupgradeSettings", "httpSettings", "grpcSettings"].forEach(key => {
|
||||
if (!((net === "ws" && key === "wsSettings") || (net === "xhttp" && (key === "xhttpSettings" || key === "splithttpSettings")) || (net === "httpupgrade" && key === "httpupgradeSettings") || (net === "h2" && key === "httpSettings") || (net === "grpc" && key === "grpcSettings"))) delete ib.streamSettings[key];
|
||||
});
|
||||
// Transport-specific settings
|
||||
switch (net) {
|
||||
case "ws":
|
||||
ib.streamSettings.wsSettings = { path: document.getElementById("wzWSPath").value.trim() || "/" };
|
||||
ib.streamSettings.wsSettings = Object.assign({}, ib.streamSettings.wsSettings || {}, { path: document.getElementById("wzWSPath").value.trim() || "/" });
|
||||
break;
|
||||
case "xhttp":
|
||||
ib.streamSettings.xhttpSettings = {
|
||||
ib.streamSettings.xhttpSettings = Object.assign({}, ib.streamSettings.xhttpSettings || ib.streamSettings.splithttpSettings || {}, {
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
|
||||
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
||||
mode: document.getElementById("wzXHTTPMode").value,
|
||||
};
|
||||
});
|
||||
delete ib.streamSettings.splithttpSettings;
|
||||
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
|
||||
break;
|
||||
case "httpupgrade":
|
||||
ib.streamSettings.httpupgradeSettings = {
|
||||
ib.streamSettings.httpupgradeSettings = Object.assign({}, 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 = {
|
||||
ib.streamSettings.httpSettings = Object.assign({}, ib.streamSettings.httpSettings || {}, {
|
||||
path: document.getElementById("wzH2Path").value.trim() || "/",
|
||||
host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean),
|
||||
};
|
||||
});
|
||||
break;
|
||||
case "grpc":
|
||||
ib.streamSettings.grpcSettings = {
|
||||
ib.streamSettings.grpcSettings = Object.assign({}, ib.streamSettings.grpcSettings || {}, {
|
||||
serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc",
|
||||
multiMode: document.getElementById("wzGRPCMulti").checked,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
// TLS / Reality
|
||||
if (tlsVal === "tls") {
|
||||
const certificateFile = document.getElementById("wzTLSCert").value.trim();
|
||||
const keyFile = document.getElementById("wzTLSKey").value.trim();
|
||||
if (!certificateFile || !keyFile) { st.textContent = "TLS exige os arquivos de certificado e chave."; return; }
|
||||
ib.streamSettings.security = "tls";
|
||||
ib.streamSettings.tlsSettings = {
|
||||
certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }],
|
||||
};
|
||||
ib.streamSettings.tlsSettings = Object.assign({}, ib.streamSettings.tlsSettings || {}, {
|
||||
certificates: [{ certificateFile, keyFile }],
|
||||
});
|
||||
delete ib.streamSettings.realitySettings;
|
||||
} else if (tlsVal === "reality" && proto === "vless") {
|
||||
ib.streamSettings.security = "reality";
|
||||
ib.streamSettings.realitySettings = {
|
||||
ib.streamSettings.realitySettings = Object.assign({}, 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),
|
||||
};
|
||||
});
|
||||
delete ib.streamSettings.tlsSettings;
|
||||
} else {
|
||||
delete ib.streamSettings.security;
|
||||
delete ib.streamSettings.tlsSettings;
|
||||
delete ib.streamSettings.realitySettings;
|
||||
}
|
||||
} else if (proto === "ssh") {
|
||||
// SSH tunnel over XHTTP: no proxy clients — the decoded stream is handed to
|
||||
// the SSH server, so authentication is an ordinary SSH account.
|
||||
ib.settings = {};
|
||||
ib.streamSettings = { network: "xhttp" };
|
||||
ib.streamSettings.xhttpSettings = {
|
||||
ib.streamSettings = original?.protocol === "ssh" ? (cloneJsonSafe(original.streamSettings) || {}) : {};
|
||||
ib.streamSettings.network = "xhttp";
|
||||
ib.streamSettings.xhttpSettings = Object.assign({}, ib.streamSettings.xhttpSettings || ib.streamSettings.splithttpSettings || {}, {
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
|
||||
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
||||
mode: document.getElementById("wzXHTTPMode").value,
|
||||
};
|
||||
});
|
||||
delete ib.streamSettings.splithttpSettings;
|
||||
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
|
||||
const tlsVal = document.getElementById("wzTLS").value;
|
||||
if (tlsVal === "tls") {
|
||||
const certificateFile = document.getElementById("wzTLSCert").value.trim();
|
||||
const keyFile = document.getElementById("wzTLSKey").value.trim();
|
||||
if (!certificateFile || !keyFile) { st.textContent = "TLS exige os arquivos de certificado e chave."; return; }
|
||||
ib.streamSettings.security = "tls";
|
||||
ib.streamSettings.tlsSettings = {
|
||||
certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }],
|
||||
};
|
||||
ib.streamSettings.tlsSettings = Object.assign({}, ib.streamSettings.tlsSettings || {}, {
|
||||
certificates: [{ certificateFile, keyFile }],
|
||||
});
|
||||
} else {
|
||||
delete ib.streamSettings.security;
|
||||
delete ib.streamSettings.tlsSettings;
|
||||
}
|
||||
} else if (proto === "trojan") {
|
||||
ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] };
|
||||
@@ -309,13 +697,13 @@ function wzSaveInbound() {
|
||||
ib.settings = { auth: "noauth", udp: true };
|
||||
ib.streamSettings = { network: "tcp" };
|
||||
}
|
||||
wzInbounds.push(ib);
|
||||
if (wzEditingIndex >= 0) wzInbounds[wzEditingIndex] = ib;
|
||||
else wzInbounds.push(ib);
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
document.getElementById("wzAddInboundForm").classList.add("hidden");
|
||||
document.getElementById("wzPort").value = "";
|
||||
document.getElementById("wzTag").value = "";
|
||||
document.getElementById("wzListenIP").value = "";
|
||||
loadSharedEndpointForm();
|
||||
st.textContent = original ? `Inbound ${tag} atualizado no rascunho.` : `Inbound ${tag} adicionado ao rascunho.`;
|
||||
wzCancelInbound();
|
||||
}
|
||||
|
||||
|
||||
@@ -344,6 +732,7 @@ function buildConfigFromVisualEditor() {
|
||||
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")) || [];
|
||||
validateVisualInbounds(visualInbounds);
|
||||
cfg.inbounds = [...hiddenApiInbounds, ...visualInbounds];
|
||||
|
||||
return cfg;
|
||||
@@ -384,8 +773,10 @@ async function applyWizardConfig() {
|
||||
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.`;
|
||||
const restarted = await xrayCtrl("restart");
|
||||
if (st) st.textContent = restarted
|
||||
? `Config saved on ${target} and Xray restarted.`
|
||||
: `Config saved on ${target}, but Xray could not restart. Check Xray logs before editing again.`;
|
||||
setTimeout(() => { loadXrayStatus(); loadInbounds({ force: true }); }, 700);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
|
||||
+66
-25
@@ -16,7 +16,7 @@
|
||||
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260713secure1"/>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260713xhttp2"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -87,6 +87,10 @@
|
||||
|
||||
<!-- ═══════════ Dashboard Tab ═══════════ -->
|
||||
<div class="tab-pane active" id="tab-dashboard">
|
||||
<section class="page-hero" data-tone="cyan">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Command center</span><h2>Visão geral</h2><p>Contas, conexões e infraestrutura em uma leitura rápida.</p></div>
|
||||
<div class="page-hero-mark" aria-hidden="true">DC</div>
|
||||
</section>
|
||||
<div class="dash-grid">
|
||||
<div class="dash-card accent-blue">
|
||||
<div class="dash-card-main">
|
||||
@@ -209,6 +213,10 @@
|
||||
|
||||
<!-- ═══════════ SSH Users Tab ═══════════ -->
|
||||
<div class="tab-pane" id="tab-ssh">
|
||||
<section class="page-hero" data-tone="green">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Access workspace</span><h2>Contas SSH</h2><p>Crie, limite e acompanhe acessos SSH e SlowDNS com segurança.</p></div>
|
||||
<div class="page-hero-pills"><span>SSH</span><span>TOTP</span><span>SlowDNS</span></div>
|
||||
</section>
|
||||
|
||||
<!-- Reseller info card (visible to resellers only) -->
|
||||
<div id="resellerInfoCard" class="card hidden" style="margin-bottom:12px;">
|
||||
@@ -304,6 +312,10 @@
|
||||
|
||||
<!-- ═══════════ Xray Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-xray">
|
||||
<section class="page-hero" data-tone="purple">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Proxy studio</span><h2>Xray visual</h2><p>Gerencie clientes, inbounds e um endpoint XHTTP compartilhado sem editar JSON.</p></div>
|
||||
<div class="page-hero-pills"><span>VLESS</span><span>VMess</span><span>XHTTP</span><span>SSH</span></div>
|
||||
</section>
|
||||
<!-- Edit Client Panel (hidden by default) -->
|
||||
<div id="editXrayClientPanel" class="card hidden" style="margin-bottom:12px;border-color:rgba(245,158,11,.4);">
|
||||
<div class="card-hdr">
|
||||
@@ -393,7 +405,30 @@
|
||||
</div>
|
||||
<!-- Wizard pane -->
|
||||
<div id="xrayWizardPane">
|
||||
<div class="form-grid" style="margin-bottom:8px;">
|
||||
<section class="shared-endpoint-card">
|
||||
<div class="shared-endpoint-head">
|
||||
<div><span class="page-kicker">Shared XHTTP endpoint</span><h3>Um domínio e uma porta</h3><p>O protocolo selecionado usa <code>/</code>; SSH usa <code>/ssh</code>. Disponível no modo Xray nativo.</p></div>
|
||||
<span class="chip green">path routing</span>
|
||||
</div>
|
||||
<div class="shared-route-preview" aria-label="Shared endpoint route preview">
|
||||
<span><strong id="sharedProxyRouteLabel">VLESS</strong><code>/</code></span>
|
||||
<i></i>
|
||||
<span><strong>SSH</strong><code>/ssh</code></span>
|
||||
</div>
|
||||
<div class="form-grid shared-endpoint-grid">
|
||||
<div class="field"><label>Protocolo em /</label><select id="sharedXHTTPProtocol"><option value="vless">VLESS</option><option value="vmess">VMess</option></select></div>
|
||||
<div class="field"><label>Porta compartilhada</label><input id="sharedXHTTPPort" type="number" min="1" max="65535" value="443"/></div>
|
||||
<div class="field"><label>IP de listen</label><input id="sharedXHTTPListen" value="0.0.0.0" placeholder="0.0.0.0"/></div>
|
||||
<div class="field"><label>Host HTTP <span class="hint">opcional</span></label><input id="sharedXHTTPHost" placeholder="vpn.seudominio.com"/></div>
|
||||
<div class="field"><label>Modo XHTTP</label><select id="sharedXHTTPMode"><option value="auto">auto</option><option value="packet-up">packet-up</option><option value="stream-up">stream-up</option><option value="stream-down">stream-down</option><option value="stream-one">stream-one</option></select></div>
|
||||
<div class="field"><label>Segurança</label><select id="sharedXHTTPSecurity"><option value="none">Sem TLS</option><option value="tls">TLS</option></select></div>
|
||||
<div class="field shared-tls-field hidden"><label>Arquivo do certificado</label><input id="sharedXHTTPCert" placeholder="/opt/sshpanel/certs/domain/cert.pem"/></div>
|
||||
<div class="field shared-tls-field hidden"><label>Arquivo da chave</label><input id="sharedXHTTPKey" placeholder="/opt/sshpanel/certs/domain/key.pem"/></div>
|
||||
</div>
|
||||
<div class="shared-endpoint-actions"><span id="sharedXHTTPStatus" class="hint">Carregue a configuração para detectar um endpoint existente.</span><button class="btn" id="sharedXHTTPApplyBtn" type="button">Criar / atualizar endpoint</button></div>
|
||||
</section>
|
||||
|
||||
<div class="visual-config-toolbar">
|
||||
<div class="field">
|
||||
<label>Log Level</label>
|
||||
<select id="wzLogLevel">
|
||||
@@ -404,14 +439,13 @@
|
||||
<option value="debug">debug</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="visual-config-toolbar-copy"><strong>Inbounds configurados</strong><span>Edite qualquer cartão visualmente ou use JSON para campos avançados.</span></div>
|
||||
<button class="btn btn-ghost btn-sm" type="button" onclick="wzToggleAddInbound()">+ Novo inbound</button>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
||||
<span style="font-size:.8rem;font-weight:600;">Inbounds</span>
|
||||
<button class="btn btn-ghost btn-sm" type="button" onclick="wzToggleAddInbound()">+ Add</button>
|
||||
</div>
|
||||
<div id="wzInboundsList" style="margin-bottom:8px;"></div>
|
||||
<div id="wzInboundsList" class="visual-inbound-list"></div>
|
||||
<!-- Add inbound form -->
|
||||
<div id="wzAddInboundForm" class="hidden" style="border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;">
|
||||
<div id="wzAddInboundForm" class="visual-inbound-editor hidden">
|
||||
<div class="visual-editor-heading"><div><span class="page-kicker">Visual editor</span><h3 id="wzInboundFormTitle">Novo inbound</h3></div><span id="wzEditingBadge" class="chip hidden">editing</span></div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>Protocol</label>
|
||||
@@ -537,13 +571,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:8px;">
|
||||
<button class="btn btn-sm" type="button" onclick="wzSaveInbound()">Add Inbound</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" onclick="document.getElementById('wzAddInboundForm').classList.add('hidden')">Cancel</button>
|
||||
<button class="btn btn-sm" id="wzSaveInboundBtn" type="button" onclick="wzSaveInbound()">Adicionar inbound</button>
|
||||
<button class="btn btn-ghost btn-sm" id="wzCancelInboundBtn" type="button" onclick="wzCancelInbound()">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;">
|
||||
<button class="btn btn-sm" type="button" onclick="applyWizardConfig()">Save Config</button>
|
||||
<span class="hint" id="wzStatus" style="margin-left:8px;"></span>
|
||||
<div class="visual-save-bar">
|
||||
<span class="hint" id="wzStatus"></span>
|
||||
<button class="btn" type="button" onclick="applyWizardConfig()">Salvar configuração e reiniciar</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- JSON pane (hidden by default) -->
|
||||
@@ -569,6 +603,7 @@
|
||||
|
||||
<!-- ═══════════ Resellers Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-resellers">
|
||||
<section class="page-hero" data-tone="amber"><div class="page-hero-copy"><span class="page-kicker">Partner operations</span><h2>Revendedores</h2><p>Controle cotas, validade e acesso dos parceiros em um só lugar.</p></div><div class="page-hero-pills"><span>Cotas</span><span>Expiração</span><span>Auditoria</span></div></section>
|
||||
<div class="grid2">
|
||||
<!-- Resellers list -->
|
||||
<div class="card">
|
||||
@@ -614,6 +649,7 @@
|
||||
|
||||
<!-- ═══════════ Servers Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-servers">
|
||||
<section class="page-hero" data-tone="blue"><div class="page-hero-copy"><span class="page-kicker">Fleet control</span><h2>Servidores</h2><p>Adicione nós, teste credenciais e configure a infraestrutura remota.</p></div><div class="page-hero-pills"><span>Master</span><span>Nodes</span><span>Encrypted</span></div></section>
|
||||
<div id="serversListView">
|
||||
<div class="grid2 servers-grid">
|
||||
<div class="card">
|
||||
@@ -841,6 +877,7 @@
|
||||
|
||||
<!-- ═══════════ Servers Status Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-servers-status">
|
||||
<section class="page-hero" data-tone="green"><div class="page-hero-copy"><span class="page-kicker">Live fleet</span><h2>Status dos servidores</h2><p>Saúde, carga e sessões ativas de cada nó gerenciado.</p></div><div class="page-hero-pills"><span>CPU</span><span>RAM</span><span>Uptime</span></div></section>
|
||||
<div class="card servers-status-toolbar">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Servers Status <span class="chip" id="serversStatusCountChip">0</span></div>
|
||||
@@ -857,6 +894,7 @@
|
||||
|
||||
<!-- ═══════════ Stats Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-stats">
|
||||
<section class="page-hero" data-tone="cyan"><div class="page-hero-copy"><span class="page-kicker">Observability</span><h2>Monitoramento</h2><p>Recursos, interfaces e capacidade do servidor em tempo real.</p></div><div class="page-hero-pills"><span>2s live</span><span>Network</span><span>Load</span></div></section>
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<div class="card-hdr"><div class="card-title">Server Load</div><span class="hint" id="statsUpdated">--</span></div>
|
||||
@@ -891,6 +929,7 @@
|
||||
|
||||
<!-- ═══════════ VnStat Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-vnstat">
|
||||
<section class="page-hero" data-tone="purple"><div class="page-hero-copy"><span class="page-kicker">Traffic intelligence</span><h2>Tráfego de rede</h2><p>Histórico diário e mensal para entender o consumo da infraestrutura.</p></div><div class="page-hero-pills"><span>RX</span><span>TX</span><span>History</span></div></section>
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">VnStat Usage <span class="chip">daily / monthly</span></div>
|
||||
@@ -931,6 +970,7 @@
|
||||
|
||||
<!-- ═══════════ Logs Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-logs">
|
||||
<section class="page-hero" data-tone="amber"><div class="page-hero-copy"><span class="page-kicker">Diagnostics</span><h2>Logs do sistema</h2><p>Investigue painel, DNSTT e Xray com uma visualização focada.</p></div><div class="page-hero-pills"><span>Panel</span><span>DNSTT</span><span>Xray</span></div></section>
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">System Logs</div>
|
||||
@@ -1085,6 +1125,7 @@
|
||||
|
||||
<!-- ═══════════ Server Config Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-server">
|
||||
<section class="page-hero" data-tone="blue"><div class="page-hero-copy"><span class="page-kicker">System studio</span><h2>Configurações</h2><p>Rede, SSH, túneis e TLS organizados em blocos visuais e aplicados ao vivo.</p></div><div class="page-hero-pills"><span>Network</span><span>Tunnels</span><span>TLS</span></div></section>
|
||||
|
||||
<div class="grid2">
|
||||
<!-- ── Left column ── -->
|
||||
@@ -1396,17 +1437,17 @@
|
||||
<!-- app.js was split into ordered modules for maintainability. They are plain
|
||||
classic scripts sharing one global scope; `defer` preserves execution order,
|
||||
so behavior is identical to the old single file. Keep this load order. -->
|
||||
<script defer src="assets/js/01-core.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/11-update-status.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/01-core.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/11-update-status.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260713xhttp2"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260713xhttp2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-6
@@ -1103,7 +1103,7 @@ func (m *XrayManager) readConfigLocked() ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.WriteFile(configFile, pretty, 0o600)
|
||||
_ = writeFileAtomic(configFile, pretty, 0o600)
|
||||
return pretty, nil
|
||||
}
|
||||
}
|
||||
@@ -1115,7 +1115,7 @@ func (m *XrayManager) readConfigLocked() ([]byte, error) {
|
||||
return nil, err
|
||||
} else if changed {
|
||||
data = forced
|
||||
_ = os.WriteFile(configFile, forced, 0o600)
|
||||
_ = writeFileAtomic(configFile, forced, 0o600)
|
||||
xrayLogf("xray: forced debug log output in file config")
|
||||
}
|
||||
return data, nil
|
||||
@@ -1141,7 +1141,7 @@ func (m *XrayManager) writeConfigLocked(data []byte) error {
|
||||
return fmt.Errorf("save Xray config to database: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(configFile, pretty, 0o600); err != nil {
|
||||
if err := writeFileAtomic(configFile, pretty, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
m.importConfigClientsLocked(pretty, "saved xray config")
|
||||
@@ -1226,7 +1226,7 @@ func (m *XrayManager) bootstrapConfigStoreLocked() error {
|
||||
return err
|
||||
}
|
||||
m.importConfigClientsLocked(pretty, "database xray config")
|
||||
return os.WriteFile(configFile, pretty, 0o600)
|
||||
return writeFileAtomic(configFile, pretty, 0o600)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configFile)
|
||||
@@ -1252,7 +1252,7 @@ func (m *XrayManager) bootstrapConfigStoreLocked() error {
|
||||
if err := statsStore.UpsertXrayConfig(ctx, key, pretty); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(configFile, pretty, 0o600)
|
||||
return writeFileAtomic(configFile, pretty, 0o600)
|
||||
}
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
@@ -1319,7 +1319,7 @@ func (m *XrayManager) syncConfigFileFromStoreLocked() error {
|
||||
return err
|
||||
}
|
||||
m.importConfigClientsLocked(pretty, "database xray config")
|
||||
return os.WriteFile(configFile, pretty, 0o600)
|
||||
return writeFileAtomic(configFile, pretty, 0o600)
|
||||
}
|
||||
|
||||
// GetConfig reads the current Xray JSON config. With PostgreSQL enabled, the
|
||||
@@ -1341,6 +1341,11 @@ func (m *XrayManager) SetConfig(data []byte) error {
|
||||
if changed {
|
||||
xrayLogf("xray: added/repaired Stats API support and forced debug logs while saving config")
|
||||
}
|
||||
if m.cfg != nil && m.cfg.UseNative() {
|
||||
if err := validateNativeInboundBindings(patched); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return m.writeConfigLocked(patched)
|
||||
}
|
||||
|
||||
|
||||
+125
-14
@@ -110,6 +110,18 @@ type nativeXrayServer struct {
|
||||
|
||||
var nativeXray = &nativeXrayServer{}
|
||||
|
||||
// nativeXHTTPListener groups all XHTTP inbounds that bind the same address.
|
||||
// XHTTP is ordinary HTTP at the transport layer, so routing by the configured
|
||||
// path lets VLESS/VMess and the DragonCore SSH tunnel safely share one TLS port.
|
||||
// The most specific path wins: for example /ssh/ is checked before /.
|
||||
type nativeXHTTPListener struct {
|
||||
addr string
|
||||
inbounds []*nativeInbound
|
||||
tlsConfig *tls.Config
|
||||
security string
|
||||
headerSize int
|
||||
}
|
||||
|
||||
// nativeRunning reports whether the in-process Xray listeners are up.
|
||||
func (s *nativeXrayServer) nativeRunning() bool {
|
||||
s.mu.Lock()
|
||||
@@ -140,7 +152,18 @@ func (s *nativeXrayServer) start(configFile string) error {
|
||||
|
||||
var opened []net.Listener
|
||||
active := make(map[string]*nativeInbound, len(inbounds))
|
||||
xhttpGroups := make(map[string][]*nativeInbound)
|
||||
var xhttpOrder []string
|
||||
for _, ib := range inbounds {
|
||||
if ib.isXHTTP() {
|
||||
addr := net.JoinHostPort(ib.listen, strconv.Itoa(ib.port))
|
||||
if _, exists := xhttpGroups[addr]; !exists {
|
||||
xhttpOrder = append(xhttpOrder, addr)
|
||||
}
|
||||
xhttpGroups[addr] = append(xhttpGroups[addr], ib)
|
||||
active[ib.tag] = ib
|
||||
continue
|
||||
}
|
||||
addr := net.JoinHostPort(ib.listen, strconv.Itoa(ib.port))
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
@@ -151,25 +174,40 @@ func (s *nativeXrayServer) start(configFile string) error {
|
||||
return fmt.Errorf("native xray: listen %s (inbound %q): %w", addr, ib.tag, err)
|
||||
}
|
||||
|
||||
serveLn := ln
|
||||
if ib.isXHTTP() {
|
||||
// HTTP/XHTTP needs a real http.Server because one logical XHTTP
|
||||
// session can span several HTTP requests/connections. TLS is therefore
|
||||
// wrapped at listener level instead of inside serve().
|
||||
if ib.security == "tls" {
|
||||
serveLn = tls.NewListener(ln, ib.tlsConfig)
|
||||
}
|
||||
opened = append(opened, serveLn)
|
||||
xrayGo(fmt.Sprintf("native xray xhttp listener %s", addr), func() { ib.serveXHTTPListener(serveLn) })
|
||||
} else {
|
||||
opened = append(opened, serveLn)
|
||||
xrayGo(fmt.Sprintf("native xray accept loop %s", addr), func() { ib.acceptLoop(serveLn) })
|
||||
}
|
||||
opened = append(opened, ln)
|
||||
xrayGo(fmt.Sprintf("native xray accept loop %s", addr), func() { ib.acceptLoop(ln) })
|
||||
active[ib.tag] = ib
|
||||
xrayLogf("native xray: serving %s/%s on %s (inbound %q, security=%s, %d clients)",
|
||||
ib.protocol, ib.transport, addr, ib.tag, orNone(ib.security), ib.clientCount())
|
||||
}
|
||||
|
||||
for _, addr := range xhttpOrder {
|
||||
group, err := newNativeXHTTPListener(addr, xhttpGroups[addr])
|
||||
if err != nil {
|
||||
for _, l := range opened {
|
||||
_ = l.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
for _, l := range opened {
|
||||
_ = l.Close()
|
||||
}
|
||||
return fmt.Errorf("native xray: listen %s (shared XHTTP): %w", addr, err)
|
||||
}
|
||||
serveLn := net.Listener(ln)
|
||||
if group.security == "tls" {
|
||||
serveLn = tls.NewListener(ln, group.tlsConfig)
|
||||
}
|
||||
opened = append(opened, serveLn)
|
||||
xrayGo(fmt.Sprintf("native xray shared xhttp listener %s", addr), func() { group.serve(serveLn) })
|
||||
for _, ib := range group.inbounds {
|
||||
xrayLogf("native xray: serving %s/%s on %s%s (inbound %q, security=%s, %d clients)",
|
||||
ib.protocol, ib.transport, addr, ib.path, ib.tag, orNone(ib.security), ib.clientCount())
|
||||
}
|
||||
}
|
||||
|
||||
s.listeners = opened
|
||||
s.inboundsByTag = active
|
||||
s.running = true
|
||||
@@ -941,6 +979,79 @@ type nativeInboundJSON struct {
|
||||
} `json:"streamSettings"`
|
||||
}
|
||||
|
||||
// validateNativeInboundBindings checks the listener topology before a visual or
|
||||
// raw-JSON update replaces the running native config. It intentionally mirrors
|
||||
// the startup rules so an invalid shared-port edit is rejected before the last
|
||||
// working file is overwritten.
|
||||
func validateNativeInboundBindings(data []byte) error {
|
||||
var cf nativeXrayConfigFile
|
||||
if err := json.Unmarshal(data, &cf); err != nil {
|
||||
return fmt.Errorf("native xray: parse config: %w", err)
|
||||
}
|
||||
type binding struct {
|
||||
tag string
|
||||
xhttp bool
|
||||
path string
|
||||
security string
|
||||
certFile string
|
||||
keyFile string
|
||||
}
|
||||
groups := make(map[string][]binding)
|
||||
for _, in := range cf.Inbounds {
|
||||
proto := strings.ToLower(strings.TrimSpace(in.Protocol))
|
||||
if proto != "ssh" && !xrayClientProtos[proto] {
|
||||
continue
|
||||
}
|
||||
port, ok := parseSinglePort(in.Port)
|
||||
if !ok {
|
||||
return fmt.Errorf("native xray: inbound %q has an unsupported port", in.Tag)
|
||||
}
|
||||
network := strings.ToLower(firstNonEmpty(in.StreamSettings.Network, "tcp"))
|
||||
isXHTTP := network == "xhttp" || network == "splithttp"
|
||||
if proto == "ssh" && !isXHTTP {
|
||||
return fmt.Errorf("native xray: inbound %q protocol ssh requires XHTTP", in.Tag)
|
||||
}
|
||||
item := binding{tag: in.Tag, xhttp: isXHTTP, security: strings.ToLower(strings.TrimSpace(in.StreamSettings.Security))}
|
||||
if item.security == "none" {
|
||||
item.security = ""
|
||||
}
|
||||
if isXHTTP {
|
||||
xh := mergeNativeXHTTPSettings(in.StreamSettings.XHTTPSettings, in.StreamSettings.SplitHTTPSettings)
|
||||
item.path = normalizeXHTTPPath(firstNonEmpty(xh.Path, "/xhttp"))
|
||||
}
|
||||
if len(in.StreamSettings.TLSSettings.Certificates) > 0 {
|
||||
item.certFile = strings.TrimSpace(in.StreamSettings.TLSSettings.Certificates[0].CertificateFile)
|
||||
item.keyFile = strings.TrimSpace(in.StreamSettings.TLSSettings.Certificates[0].KeyFile)
|
||||
}
|
||||
host := normalizeNativeListenHost(firstNonEmpty(in.Listen, "0.0.0.0"))
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
groups[addr] = append(groups[addr], item)
|
||||
}
|
||||
for addr, items := range groups {
|
||||
if len(items) < 2 {
|
||||
continue
|
||||
}
|
||||
paths := make(map[string]string, len(items))
|
||||
first := items[0]
|
||||
for _, item := range items {
|
||||
if !item.xhttp {
|
||||
return fmt.Errorf("native xray: multiple inbounds on %s require XHTTP path routing", addr)
|
||||
}
|
||||
if previous, exists := paths[item.path]; exists {
|
||||
return fmt.Errorf("native xray: XHTTP inbounds %q and %q use the same path %s on %s", previous, item.tag, item.path, addr)
|
||||
}
|
||||
paths[item.path] = item.tag
|
||||
if item.security != first.security {
|
||||
return fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS setting", addr)
|
||||
}
|
||||
if item.security == "tls" && (item.certFile != first.certFile || item.keyFile != first.keyFile) {
|
||||
return fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS certificate", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseNativeInbounds reads the Xray config file and returns one nativeInbound
|
||||
// per servable client-bearing inbound. Unsupported inbounds (api dokodemo-door,
|
||||
// freedom, etc.) are silently skipped.
|
||||
|
||||
@@ -10,12 +10,62 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSharedXHTTPListenerRoutesMostSpecificPath(t *testing.T) {
|
||||
root := &nativeInbound{tag: "shared-proxy-xhttp", protocol: "vless", transport: "xhttp", path: "/", xhttpHost: "proxy.example", xhttpSessions: make(map[string]*nativeXHTTPSession)}
|
||||
ssh := &nativeInbound{tag: "shared-ssh-xhttp", protocol: "ssh", transport: "xhttp", path: "/ssh", xhttpHost: "ssh.example", xhttpSessions: make(map[string]*nativeXHTTPSession)}
|
||||
group, err := newNativeXHTTPListener("127.0.0.1:443", []*nativeInbound{root, ssh})
|
||||
if err != nil {
|
||||
t.Fatalf("new shared XHTTP listener: %v", err)
|
||||
}
|
||||
|
||||
sshReq := httptest.NewRequest(http.MethodOptions, "http://ssh.example/ssh/session", nil)
|
||||
sshRec := httptest.NewRecorder()
|
||||
group.ServeHTTP(sshRec, sshReq)
|
||||
if sshRec.Code != http.StatusOK {
|
||||
t.Fatalf("/ssh routed to root instead of SSH inbound: status=%d", sshRec.Code)
|
||||
}
|
||||
|
||||
rootReq := httptest.NewRequest(http.MethodOptions, "http://proxy.example/session", nil)
|
||||
rootRec := httptest.NewRecorder()
|
||||
group.ServeHTTP(rootRec, rootReq)
|
||||
if rootRec.Code != http.StatusOK {
|
||||
t.Fatalf("/ routed incorrectly: status=%d", rootRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedXHTTPListenerRejectsDuplicatePath(t *testing.T) {
|
||||
a := &nativeInbound{tag: "a", transport: "xhttp", path: "/same"}
|
||||
b := &nativeInbound{tag: "b", transport: "xhttp", path: "/same/"}
|
||||
if _, err := newNativeXHTTPListener("127.0.0.1:443", []*nativeInbound{a, b}); err == nil {
|
||||
t.Fatal("expected duplicate normalized XHTTP path to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNativeSharedXHTTPConfig(t *testing.T) {
|
||||
valid := []byte(`{"inbounds":[
|
||||
{"tag":"proxy","listen":"0.0.0.0","port":443,"protocol":"vless","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/"}}},
|
||||
{"tag":"ssh","listen":"0.0.0.0","port":443,"protocol":"ssh","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/ssh"}}}
|
||||
]}`)
|
||||
if err := validateNativeInboundBindings(valid); err != nil {
|
||||
t.Fatalf("valid shared endpoint rejected: %v", err)
|
||||
}
|
||||
|
||||
duplicate := []byte(`{"inbounds":[
|
||||
{"tag":"a","listen":"0.0.0.0","port":443,"protocol":"vless","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/same"}}},
|
||||
{"tag":"b","listen":"0.0.0.0","port":443,"protocol":"ssh","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/same/"}}}
|
||||
]}`)
|
||||
if err := validateNativeInboundBindings(duplicate); err == nil {
|
||||
t.Fatal("duplicate normalized XHTTP path was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// startEchoServer starts a TCP server that echoes everything back and returns
|
||||
// its port and a cleanup func.
|
||||
func startEchoServer(t *testing.T) (int, func()) {
|
||||
|
||||
+91
-12
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -97,34 +98,112 @@ func mergeNativeXHTTPSettings(primary, fallback nativeXHTTPSettingsJSON) nativeX
|
||||
return out
|
||||
}
|
||||
|
||||
func newNativeXHTTPListener(addr string, inbounds []*nativeInbound) (*nativeXHTTPListener, error) {
|
||||
if len(inbounds) == 0 {
|
||||
return nil, fmt.Errorf("native xray: shared XHTTP listener %s has no inbounds", addr)
|
||||
}
|
||||
group := &nativeXHTTPListener{addr: addr, inbounds: append([]*nativeInbound(nil), inbounds...)}
|
||||
paths := make(map[string]string, len(inbounds))
|
||||
for i, ib := range group.inbounds {
|
||||
if ib == nil || !ib.isXHTTP() {
|
||||
return nil, fmt.Errorf("native xray: shared XHTTP listener %s contains a non-XHTTP inbound", addr)
|
||||
}
|
||||
ib.path = normalizeXHTTPPath(ib.path)
|
||||
if previous, exists := paths[ib.path]; exists {
|
||||
return nil, fmt.Errorf("native xray: XHTTP inbounds %q and %q use the same path %s on %s", previous, ib.tag, ib.path, addr)
|
||||
}
|
||||
paths[ib.path] = ib.tag
|
||||
if size := ib.xhttpServerMaxHeaderBytes(); size > group.headerSize {
|
||||
group.headerSize = size
|
||||
}
|
||||
if i == 0 {
|
||||
group.security = ib.security
|
||||
group.tlsConfig = ib.tlsConfig
|
||||
continue
|
||||
}
|
||||
if ib.security != group.security {
|
||||
return nil, fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS setting", addr)
|
||||
}
|
||||
if group.security == "tls" && !sameNativeTLSCertificate(group.tlsConfig, ib.tlsConfig) {
|
||||
return nil, fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS certificate", addr)
|
||||
}
|
||||
}
|
||||
if group.security == "tls" && group.tlsConfig == nil {
|
||||
return nil, fmt.Errorf("native xray: shared XHTTP listener %s has no TLS configuration", addr)
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func sameNativeTLSCertificate(a, b *tls.Config) bool {
|
||||
if a == nil || b == nil || len(a.Certificates) == 0 || len(b.Certificates) == 0 {
|
||||
return a == b
|
||||
}
|
||||
ac := a.Certificates[0].Certificate
|
||||
bc := b.Certificates[0].Certificate
|
||||
if len(ac) == 0 || len(bc) == 0 || len(ac[0]) != len(bc[0]) {
|
||||
return false
|
||||
}
|
||||
return string(ac[0]) == string(bc[0])
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray XHTTP listener inbound=%q addr=%s", ib.tag, ln.Addr()))
|
||||
group, err := newNativeXHTTPListener(ln.Addr().String(), []*nativeInbound{ib})
|
||||
if err != nil {
|
||||
xrayLogf("native xray: XHTTP listener %q rejected: %v", ib.tag, err)
|
||||
return
|
||||
}
|
||||
group.serve(ln)
|
||||
}
|
||||
|
||||
func (g *nativeXHTTPListener) serve(ln net.Listener) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray shared XHTTP listener addr=%s", ln.Addr()))
|
||||
h2s := &http2.Server{}
|
||||
handler := http.Handler(ib)
|
||||
handler := http.Handler(g)
|
||||
// Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP
|
||||
// listeners, and negotiates h2/http1 through ALPN on TLS listeners. Without
|
||||
// h2c, some clients/CDNs can reach the port but the request never reaches the
|
||||
// XHTTP handler, which makes the proxy look dead with no useful target logs.
|
||||
if ib.security != "tls" {
|
||||
handler = h2c.NewHandler(ib, h2s)
|
||||
if g.security != "tls" {
|
||||
handler = h2c.NewHandler(g, h2s)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 4 * time.Second,
|
||||
MaxHeaderBytes: ib.xhttpServerMaxHeaderBytes(),
|
||||
MaxHeaderBytes: g.headerSize,
|
||||
}
|
||||
if ib.security == "tls" && ib.tlsConfig != nil {
|
||||
srv.TLSConfig = ib.tlsConfig
|
||||
if g.security == "tls" && g.tlsConfig != nil {
|
||||
srv.TLSConfig = g.tlsConfig
|
||||
_ = http2.ConfigureServer(srv, h2s)
|
||||
}
|
||||
// Backstop reaper for connected sessions whose client vanished without the
|
||||
// request context ever firing. Lives for the lifetime of this listener.
|
||||
// Each path has its own session namespace and idle sweeper.
|
||||
stopSweep := make(chan struct{})
|
||||
defer close(stopSweep)
|
||||
xrayGo(fmt.Sprintf("native xray XHTTP idle sweeper inbound=%q", ib.tag), func() { ib.sweepXHTTPSessions(stopSweep) })
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) && !isListenerClosed(err) {
|
||||
xrayLogf("native xray: XHTTP server for inbound %q stopped: %v", ib.tag, err)
|
||||
for _, ib := range g.inbounds {
|
||||
ib := ib
|
||||
xrayGo(fmt.Sprintf("native xray XHTTP idle sweeper inbound=%q", ib.tag), func() { ib.sweepXHTTPSessions(stopSweep) })
|
||||
}
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) && !isListenerClosed(err) {
|
||||
xrayLogf("native xray: shared XHTTP server on %s stopped: %v", g.addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP picks the longest configured path. This makes a root XHTTP inbound
|
||||
// coexist with more specific services such as /ssh without allowing the root
|
||||
// handler to steal the SSH session path.
|
||||
func (g *nativeXHTTPListener) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var selected *nativeInbound
|
||||
selectedLen := -1
|
||||
for _, ib := range g.inbounds {
|
||||
if _, ok := ib.matchXHTTPPath(r.URL.Path); ok && len(ib.path) > selectedLen {
|
||||
selected = ib
|
||||
selectedLen = len(ib.path)
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
selected.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// sweepXHTTPSessions periodically evicts connected XHTTP sessions that have seen
|
||||
|
||||
Reference in New Issue
Block a user