693 lines
32 KiB
JavaScript
693 lines
32 KiB
JavaScript
// ─── 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";
|
|
}
|
|
|
|
|
|
// Only operator-safe knobs remain. Global transport/XHTTP count admission is
|
|
// fixed to unlimited; byte backpressure and protocol buffers stay internal so a
|
|
// panel value cannot turn normal VPN traffic into HTTP overload responses.
|
|
const XRAY_NATIVE_TUNING_DEFAULTS = {
|
|
safe: {
|
|
runtime_gomaxprocs: 0,
|
|
mux_global_sessions: 32768,
|
|
max_concurrent_connections: -1,
|
|
max_concurrent_xhttp_requests: -1,
|
|
xhttp_max_sessions: -1,
|
|
trace_packets: false,
|
|
},
|
|
"high": {
|
|
runtime_gomaxprocs: 0,
|
|
mux_global_sessions: 65536,
|
|
max_concurrent_connections: -1,
|
|
max_concurrent_xhttp_requests: -1,
|
|
xhttp_max_sessions: -1,
|
|
trace_packets: false,
|
|
},
|
|
};
|
|
|
|
const XRAY_NATIVE_TUNING_FIELDS = {
|
|
runtime_gomaxprocs: "cfgXrayRuntimeGomaxprocs",
|
|
mux_global_sessions: "cfgXrayMuxGlobalSessions",
|
|
};
|
|
|
|
function setXrayNativeTuningDefaults(profile = "high") {
|
|
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;
|
|
});
|
|
// These legacy JSON keys are intentionally fixed at unlimited. Keeping them
|
|
// in saved configs makes upgrades/downgrades explicit without exposing web
|
|
// request ceilings that do not belong on a VPN transport.
|
|
out.max_concurrent_connections = -1;
|
|
out.max_concurrent_xhttp_requests = -1;
|
|
out.xhttp_max_sessions = -1;
|
|
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("cfgMaxTotalConns").value = c.max_total_connections || 0;
|
|
document.getElementById("cfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s";
|
|
document.getElementById("cfgQuiet").checked = !!c.quiet;
|
|
document.getElementById("cfgUserCount").checked = !!c.user_count;
|
|
document.getElementById("cfgPamAuth").checked = !!c.pam_auth_enabled;
|
|
|
|
// 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 || "0.0.0.0:5300";
|
|
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("cfgUdpgwMaxClients").value = u.max_clients || 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();
|
|
loadTLSCertificates();
|
|
|
|
// 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),
|
|
max_total_connections: parseInt(document.getElementById("cfgMaxTotalConns").value || "0", 10),
|
|
ssh_idle_timeout: document.getElementById("cfgSSHIdleTimeout").value.trim() || "0s",
|
|
quiet: document.getElementById("cfgQuiet").checked,
|
|
user_count: document.getElementById("cfgUserCount").checked,
|
|
pam_auth_enabled: document.getElementById("cfgPamAuth").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() || "0.0.0.0:5300",
|
|
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),
|
|
max_clients: parseInt(document.getElementById("cfgUdpgwMaxClients").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 = '<div class="hint" style="padding:4px 0;">No TLS forwarders configured.</div>';
|
|
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 = `<span style="flex:1;font-family:monospace;">${escapeHTML(fw.listen || "")}</span>
|
|
<span class="hint">${escapeHTML(fw.cert_file ? fw.cert_file.split("/").pop() : "no cert")}</span>`;
|
|
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);
|
|
});
|
|
}
|
|
|
|
// ─── TLS Certificates (renew fullchain + privkey) ─────────────────────────────
|
|
let tlsCertsState = [];
|
|
|
|
async function loadTLSCertificates() {
|
|
const st = document.getElementById("tlsCertsStatus");
|
|
const list = document.getElementById("tlsCertsList");
|
|
if (!list) return;
|
|
if (st) st.textContent = "Carregando certificados…";
|
|
try {
|
|
const res = await api("/api/tls/certs");
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
tlsCertsState = data.certs || [];
|
|
renderTLSCertificates();
|
|
if (st) st.textContent = tlsCertsState.length
|
|
? `${tlsCertsState.length} certificado(s). Pasta do painel: ${data.certs_dir || "/opt/sshpanel/certs"}`
|
|
: "Nenhum certificado encontrado.";
|
|
} catch (e) {
|
|
if (e.message === "auth") doAuthError();
|
|
else if (st) st.textContent = "Erro: " + e.message;
|
|
}
|
|
}
|
|
|
|
function certExpiryChip(c) {
|
|
if (!c.exists) return '<span class="chip red">arquivo ausente</span>';
|
|
if (c.error) return `<span class="chip red">${escapeHTML(c.error)}</span>`;
|
|
if (c.expired) return '<span class="chip red">expirado</span>';
|
|
if (c.expiring) return `<span class="chip warn">expira em ${c.days_left} dia(s)</span>`;
|
|
return `<span class="chip green">válido por ${c.days_left} dia(s)</span>`;
|
|
}
|
|
|
|
function renderTLSCertificates() {
|
|
const list = document.getElementById("tlsCertsList");
|
|
const chip = document.getElementById("tlsCertsCountChip");
|
|
if (!list) return;
|
|
if (chip) chip.textContent = tlsCertsState.length;
|
|
if (!tlsCertsState.length) {
|
|
list.innerHTML = '<div class="hint" style="padding:4px 0;">Nenhum certificado encontrado neste servidor.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = "";
|
|
tlsCertsState.forEach((c, i) => {
|
|
const row = document.createElement("div");
|
|
row.style = "padding:8px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
|
|
|
|
const usedBy = (c.used_by || []).map(u => {
|
|
const label = u.kind === "tls_forwarder" ? "TLS " + u.ref : "Xray " + u.ref;
|
|
return `<span class="chip">${escapeHTML(label)}</span>`;
|
|
}).join(" ") || '<span class="hint">não referenciado na configuração</span>';
|
|
|
|
const head = document.createElement("div");
|
|
head.style = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;";
|
|
head.innerHTML = `<strong style="font-size:.78rem;">${escapeHTML(c.name || "cert")}</strong>
|
|
${certExpiryChip(c)}
|
|
${c.self_signed ? '<span class="chip warn">autoassinado</span>' : ""}
|
|
${c.managed ? '<span class="chip">painel</span>' : ""}
|
|
<span style="flex:1"></span>`;
|
|
|
|
const btn = document.createElement("button");
|
|
btn.className = "btn btn-ghost btn-sm";
|
|
btn.type = "button";
|
|
btn.textContent = "Atualizar certificado";
|
|
btn.onclick = () => toggleCertRenewForm(i);
|
|
head.appendChild(btn);
|
|
row.appendChild(head);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "hint";
|
|
meta.style = "margin-top:3px;font-family:monospace;word-break:break-all;";
|
|
const domains = (c.domains || []).join(", ") || "sem SAN";
|
|
meta.innerHTML = `${escapeHTML(domains)}<br/>${escapeHTML(c.cert_file || "")}<br/>${escapeHTML(c.key_file || "sem chave")}`;
|
|
row.appendChild(meta);
|
|
|
|
const extra = document.createElement("div");
|
|
extra.className = "hint";
|
|
extra.style = "margin-top:3px;";
|
|
const bits = [];
|
|
if (c.issuer) bits.push("emissor: " + c.issuer);
|
|
if (c.key_type) bits.push("chave: " + c.key_type);
|
|
if (c.chain_length) bits.push("cadeia: " + c.chain_length + " cert(s)");
|
|
if (c.not_after) bits.push("expira: " + c.not_after.replace("T", " ").replace("Z", " UTC"));
|
|
extra.textContent = bits.join(" · ");
|
|
row.appendChild(extra);
|
|
|
|
const usage = document.createElement("div");
|
|
usage.style = "margin-top:5px;display:flex;gap:4px;flex-wrap:wrap;align-items:center;";
|
|
usage.innerHTML = `<span class="hint">em uso por:</span> ${usedBy}`;
|
|
row.appendChild(usage);
|
|
|
|
const panel = document.createElement("div");
|
|
panel.id = "certRenewPanel-" + i;
|
|
panel.className = "hidden";
|
|
panel.style = "border:1px solid var(--border);border-radius:8px;padding:10px;margin-top:8px;";
|
|
panel.innerHTML = `
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
|
<div class="field"><label>fullchain.pem <span class="hint">(certificado + intermediários)</span></label>
|
|
<textarea id="certRenewFullchain-${i}" rows="6" placeholder="-----BEGIN CERTIFICATE----- …" style="font-family:monospace;font-size:.7rem;width:100%;box-sizing:border-box;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:inherit;padding:4px;"></textarea></div>
|
|
<div class="field"><label>privkey.pem <span class="hint">(chave privada)</span></label>
|
|
<textarea id="certRenewPrivkey-${i}" rows="6" placeholder="-----BEGIN PRIVATE KEY----- …" style="font-family:monospace;font-size:.7rem;width:100%;box-sizing:border-box;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:inherit;padding:4px;"></textarea></div>
|
|
</div>
|
|
<div class="hint" style="margin-top:6px;">Grava em <code>${escapeHTML(c.cert_file || "")}</code> e <code>${escapeHTML(c.key_file || "")}</code>. O conteúdo anterior fica salvo como <code>.bak</code>.</div>
|
|
<div class="form-actions" style="margin-top:8px;">
|
|
<button class="btn btn-sm" type="button" onclick="submitCertRenew(${i})">Salvar e recarregar</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" onclick="toggleCertRenewForm(${i})">Cancelar</button>
|
|
</div>
|
|
<div id="certRenewStatus-${i}" class="hint" style="margin-top:4px;"></div>`;
|
|
row.appendChild(panel);
|
|
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function toggleCertRenewForm(i) {
|
|
const panel = document.getElementById("certRenewPanel-" + i);
|
|
if (!panel) return;
|
|
panel.classList.toggle("hidden");
|
|
if (!panel.classList.contains("hidden")) {
|
|
document.getElementById("certRenewStatus-" + i).textContent = "";
|
|
document.getElementById("certRenewFullchain-" + i).focus();
|
|
}
|
|
}
|
|
|
|
function reportCertUpdate(statusEl, data) {
|
|
const r = data?.reloaded || {};
|
|
const applied = [];
|
|
if ((r.tls_forwarders || []).length) applied.push("TLS " + r.tls_forwarders.join(", "));
|
|
if (r.xray_restarted) applied.push("Xray reiniciado (" + (r.xray_inbounds || []).join(", ") + ")");
|
|
const warnings = data?.warnings || [];
|
|
const cert = data?.cert || {};
|
|
const parts = ["Certificado gravado."];
|
|
if (cert.not_after) parts.push("Válido até " + cert.not_after.replace("T", " ").replace("Z", " UTC") + ".");
|
|
if (applied.length) parts.push("Recarregado: " + applied.join(" | ") + ".");
|
|
else parts.push("Nenhum listener em uso precisou recarregar.");
|
|
if (warnings.length) parts.push("Avisos: " + warnings.join(" | "));
|
|
statusEl.textContent = parts.join(" ");
|
|
showPanelToast(
|
|
warnings.length ? "Certificado atualizado com avisos." : "Certificado atualizado e aplicado.",
|
|
warnings.length ? "warning" : "success",
|
|
);
|
|
}
|
|
|
|
async function submitCertRenew(i) {
|
|
const c = tlsCertsState[i];
|
|
const st = document.getElementById("certRenewStatus-" + i);
|
|
if (!c || !st) return;
|
|
const fullchain = document.getElementById("certRenewFullchain-" + i).value.trim();
|
|
const privkey = document.getElementById("certRenewPrivkey-" + i).value.trim();
|
|
if (!fullchain || !privkey) { st.textContent = "Cole o fullchain.pem e o privkey.pem."; return; }
|
|
|
|
const usedBy = (c.used_by || []).length;
|
|
const ok = await panelConfirm({
|
|
title: "Atualizar certificado",
|
|
message: `Substituir o certificado de ${c.name || c.cert_file}?`,
|
|
detail: usedBy
|
|
? "Os listeners TLS que usam este certificado serão reabertos e o Xray será reiniciado se algum inbound usar o certificado. Conexões já estabelecidas não são encerradas."
|
|
: "Os arquivos serão substituídos (backup .bak).",
|
|
confirmLabel: "Atualizar",
|
|
});
|
|
if (!ok) return;
|
|
|
|
st.textContent = "Gravando e recarregando…";
|
|
await postCertUpdate({ cert_file: c.cert_file, key_file: c.key_file, fullchain, privkey }, st, () => {
|
|
document.getElementById("certRenewPanel-" + i)?.classList.add("hidden");
|
|
loadTLSCertificates();
|
|
});
|
|
}
|
|
|
|
async function postCertUpdate(payload, st, onDone) {
|
|
try {
|
|
let res = await api("/api/tls/certs/update", { method: "POST", body: JSON.stringify(payload) });
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
if (res.status === 400 && text.includes("force=true")) {
|
|
const force = await panelConfirm({
|
|
title: "Certificado expirado",
|
|
message: text.split(";")[0],
|
|
detail: "Gravar mesmo assim? Clientes não conseguirão validar um certificado expirado.",
|
|
confirmLabel: "Gravar mesmo assim",
|
|
danger: true,
|
|
});
|
|
if (!force) { st.textContent = "Cancelado."; return; }
|
|
res = await api("/api/tls/certs/update", { method: "POST", body: JSON.stringify({ ...payload, force: true }) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
} else {
|
|
throw new Error(text);
|
|
}
|
|
}
|
|
const data = await res.json();
|
|
reportCertUpdate(st, data);
|
|
onDone?.();
|
|
} catch (e) {
|
|
if (e.message === "auth") doAuthError();
|
|
else st.textContent = "Erro: " + e.message;
|
|
}
|
|
}
|
|
|
|
function toggleNewCertForm() {
|
|
const panel = document.getElementById("newCertPanel");
|
|
if (!panel) return;
|
|
panel.classList.toggle("hidden");
|
|
if (!panel.classList.contains("hidden")) {
|
|
document.getElementById("newCertStatus").textContent = "";
|
|
document.getElementById("newCertName").value = "";
|
|
document.getElementById("newCertFullchain").value = "";
|
|
document.getElementById("newCertPrivkey").value = "";
|
|
}
|
|
}
|
|
|
|
async function saveNewCert() {
|
|
const st = document.getElementById("newCertStatus");
|
|
const name = document.getElementById("newCertName").value.trim();
|
|
const fullchain = document.getElementById("newCertFullchain").value.trim();
|
|
const privkey = document.getElementById("newCertPrivkey").value.trim();
|
|
if (!name || !fullchain || !privkey) { st.textContent = "Nome, fullchain.pem e privkey.pem são obrigatórios."; return; }
|
|
st.textContent = "Gravando…";
|
|
await postCertUpdate({ name, fullchain, privkey }, st, () => {
|
|
document.getElementById("newCertFullchain").value = "";
|
|
document.getElementById("newCertPrivkey").value = "";
|
|
loadTLSCertificates();
|
|
});
|
|
}
|
|
|
|
// Inline onclick handlers in index.html need these exposed explicitly.
|
|
window.loadTLSCertificates = loadTLSCertificates;
|
|
window.toggleCertRenewForm = toggleCertRenewForm;
|
|
window.submitCertRenew = submitCertRenew;
|
|
window.toggleNewCertForm = toggleNewCertForm;
|
|
window.saveNewCert = saveNewCert;
|
|
|
|
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(withServerParam("/api/tls/upload-pem", selectedXrayServer()), { 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(withServerParam("/api/tls/generate-selfsigned", selectedXrayServer()), { 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;
|
|
}
|
|
}
|