SSL Cert FIX

This commit is contained in:
2026-08-06 00:21:59 -03:00
parent 54981f7348
commit 809e2aeb82
9 changed files with 1326 additions and 5 deletions
+14 -1
View File
@@ -1290,7 +1290,7 @@ Read/write a managed server's `config.json`. Query: `server_id`. Local delegates
### TLS certificates (superadmin only) ### TLS certificates (superadmin only)
All three accept `POST` only and support `server_id` proxying. All endpoints support `server_id` proxying, so a certificate can also be listed/updated on a managed slave node. The three issue/upload endpoints below accept `POST` only.
#### `POST /api/tls/generate-selfsigned` #### `POST /api/tls/generate-selfsigned`
- Body: `domain` (string, required). Writes a self-signed ECDSA (P-256) cert (10-year validity) to `/opt/sshpanel/certs/<domain>/`. - Body: `domain` (string, required). Writes a self-signed ECDSA (P-256) cert (10-year validity) to `/opt/sshpanel/certs/<domain>/`.
@@ -1304,6 +1304,19 @@ All three accept `POST` only and support `server_id` proxying.
- Body: `name` (string, required), `cert` (string, required — PEM), `key` (string, required — PEM). Saves to `/opt/sshpanel/certs/<name>/`. - Body: `name` (string, required), `cert` (string, required — PEM), `key` (string, required — PEM). Saves to `/opt/sshpanel/certs/<name>/`.
- `200`: `{ "cert_file": string, "key_file": string }`. Errors: `400 name, cert, and key required` / `invalid name`; `500`. - `200`: `{ "cert_file": string, "key_file": string }`. Errors: `400 name, cert, and key required` / `invalid name`; `500`.
#### `GET /api/tls/certs`
Lists every certificate this node knows about: the ones stored under `/opt/sshpanel/certs/`, the ones referenced by `tls_forwarders`, and the ones referenced by Xray inbound `tlsSettings` (inbounds that enable TLS without naming a certificate are reported against the first TLS forwarder's material, which is what `buildInboundTLS` falls back to).
- `200`: `{ "certs_dir": string, "certs": [ { "name", "cert_file", "key_file", "managed", "exists", "subject", "issuer", "domains": [string], "not_before", "not_after", "days_left", "expired", "expiring", "self_signed", "chain_length", "key_type", "key_ok", "modified", "error", "used_by": [ { "kind": "tls_forwarder"|"xray_inbound", "ref": string } ] } ] }`.
#### `POST /api/tls/certs/update`
Replaces a certificate's `fullchain.pem` + `privkey.pem`. The panel's **Configuração → TLS → Certificados TLS** card uses this for renewals.
- Body: `fullchain` (string, required — PEM; `cert` accepted as alias), `privkey` (string, required — PEM; `key` accepted as alias), plus **either** `cert_file` (+ optional `key_file`) to replace an existing certificate in place, **or** `name` to create/replace `/opt/sshpanel/certs/<name>/`. Optional `reload` (bool, default `true`) and `force` (bool, default `false`).
- The pair is validated with `tls.X509KeyPair` before anything is written; the previous content is kept as `<file>.bak`; existing file modes are preserved; symlinked targets (certbot layout) are followed so the link structure survives.
- `cert_file` must be inside `/opt/sshpanel/certs/` or already referenced by the running config / Xray config — this endpoint is not an arbitrary file-write primitive.
- Because the paths do not change, no other configuration needs editing. With `reload` on, the TLS forwarders serving the certificate are rebound (established connections are untouched) and Xray is restarted if one of its inbounds uses it.
- `200`: `{ "cert_file": string, "key_file": string, "cert": <same shape as the list entry>, "reloaded": { "tls_forwarders": [string], "xray_inbounds": [string], "xray_restarted": bool }, "warnings": [string] }`. Warnings cover a leaf-only PEM (no intermediates), a not-yet-valid certificate, a domain change versus the previous certificate, and certbot-managed paths.
- Errors: `400` for a missing/mismatched pair, an expired certificate without `force=true`, or a path outside the allowed set; `413` for PEM over 1 MiB; `500` on write failure.
--- ---
### Panel config ### Panel config
+1
View File
@@ -97,6 +97,7 @@ function setWorkspaceSection(workspace, section, options = {}) {
if (!options.silent) { if (!options.silent) {
if (workspace === "xray" && section === "config" && currentRole === "superadmin" && typeof loadWizardFromConfig === "function") loadWizardFromConfig(); if (workspace === "xray" && section === "config" && currentRole === "superadmin" && typeof loadWizardFromConfig === "function") loadWizardFromConfig();
if (workspace === "xray" && section === "logs" && currentRole === "superadmin" && typeof loadXrayLogs === "function") loadXrayLogs(); if (workspace === "xray" && section === "logs" && currentRole === "superadmin" && typeof loadXrayLogs === "function") loadXrayLogs();
if (workspace === "config" && section === "tls" && currentRole === "superadmin" && typeof loadTLSCertificates === "function") loadTLSCertificates();
} }
return true; return true;
} }
+230
View File
@@ -158,6 +158,7 @@ async function loadServerConfig() {
// TLS forwarders // TLS forwarders
tlsForwardersState = c.tls_forwarders || []; tlsForwardersState = c.tls_forwarders || [];
renderTLSForwarders(); renderTLSForwarders();
loadTLSCertificates();
// Xray // Xray
const x = c.xray || {}; const x = c.xray || {};
@@ -288,6 +289,235 @@ function renderTLSForwarders() {
}); });
} }
// ─── 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-----&#10;…" 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-----&#10;…" 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() { function toggleAddTLSForm() {
const panel = document.getElementById("addTLSPanel"); const panel = document.getElementById("addTLSPanel");
panel.classList.toggle("hidden"); panel.classList.toggle("hidden");
+29 -3
View File
@@ -1459,7 +1459,33 @@
</section> </section>
<section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="tls"> <section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="tls">
<div class="workspace-section-heading"><div><span>04 · Segurança</span><h3>Encaminhadores TLS</h3><p>Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.</p></div></div> <div class="workspace-section-heading"><div><span>04 · Segurança</span><h3>Encaminhadores TLS</h3><p>Gerencie os certificados do servidor e crie listeners TLS com certificado automático, colado ou armazenado em arquivo.</p></div></div>
<!-- TLS Certificates -->
<div class="card" style="margin-top:12px">
<div class="card-hdr">
<div class="card-title">Certificados TLS <span class="chip" id="tlsCertsCountChip">0</span></div>
<span class="chip green">live</span>
<button class="btn btn-ghost btn-sm" type="button" onclick="loadTLSCertificates()">Recarregar lista</button>
<button class="btn btn-ghost btn-sm" type="button" onclick="toggleNewCertForm()">+ Novo</button>
</div>
<div class="hint" style="margin-top:2px;">Cole o <code>fullchain.pem</code> e o <code>privkey.pem</code> para renovar um certificado. Os arquivos são substituídos no mesmo caminho (com backup <code>.bak</code>), então nenhuma configuração precisa ser alterada, e os listeners TLS e inbounds Xray que usam o certificado são recarregados na hora.</div>
<div id="tlsCertsList" style="margin-top:8px;"></div>
<div id="newCertPanel" class="hidden" style="border:1px solid var(--border);border-radius:8px;padding:10px;margin-top:8px;">
<div class="field"><label>Nome <span class="hint">(pasta de armazenamento, ex.: meu-dominio)</span></label><input type="text" id="newCertName" placeholder="meu-dominio"/></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;">
<div class="field"><label>fullchain.pem <span class="hint">(certificado + intermediários)</span></label><textarea id="newCertFullchain" rows="6" placeholder="-----BEGIN CERTIFICATE-----&#10;…" 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="newCertPrivkey" rows="6" placeholder="-----BEGIN PRIVATE KEY-----&#10;…" 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="form-actions" style="margin-top:8px;">
<button class="btn btn-sm" type="button" onclick="saveNewCert()">Salvar certificado</button>
<button class="btn btn-ghost btn-sm" type="button" onclick="toggleNewCertForm()">Cancelar</button>
</div>
<div id="newCertStatus" class="hint" style="margin-top:4px;"></div>
</div>
<div id="tlsCertsStatus" class="hint" style="margin-top:6px;"></div>
</div>
<!-- TLS Forwarders --> <!-- TLS Forwarders -->
<div class="card" style="margin-top:12px"> <div class="card" style="margin-top:12px">
<div class="card-hdr"> <div class="card-hdr">
@@ -1577,13 +1603,13 @@
classic scripts sharing one global scope; `defer` preserves execution order, classic scripts sharing one global scope; `defer` preserves execution order,
so behavior is identical to the old single file. Keep this load order. --> so behavior is identical to the old single file. Keep this load order. -->
<script defer src="assets/js/01-core.js?v=20260722xhttpunlimited2"></script> <script defer src="assets/js/01-core.js?v=20260722xhttpunlimited2"></script>
<script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script> <script defer src="assets/js/02-shell.js?v=20260805certupdate1"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260720sshfilters1"></script> <script defer src="assets/js/03-ssh-users.js?v=20260720sshfilters1"></script>
<script defer src="assets/js/04-xray.js?v=20260720sshfilters1"></script> <script defer src="assets/js/04-xray.js?v=20260720sshfilters1"></script>
<script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script> <script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script>
<script defer src="assets/js/06-servers.js?v=20260714pamfix1"></script> <script defer src="assets/js/06-servers.js?v=20260714pamfix1"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260714pamfix1"></script> <script defer src="assets/js/07-stats-logs.js?v=20260714pamfix1"></script>
<script defer src="assets/js/08-server-config.js?v=20260724xrayinboundsr3"></script> <script defer src="assets/js/08-server-config.js?v=20260805certupdate1"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260724xrayinboundsr4"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260724xrayinboundsr4"></script>
<script defer src="assets/js/11-update-status.js?v=20260714pamfix1"></script> <script defer src="assets/js/11-update-status.js?v=20260714pamfix1"></script>
<script defer src="assets/js/12-bot.js?v=20260714pamfix1"></script> <script defer src="assets/js/12-bot.js?v=20260714pamfix1"></script>
+26
View File
@@ -199,6 +199,32 @@ func (p *tlsListenerPool) Has(addr string) bool {
return ok return ok
} }
// Drop closes the listeners for the given addresses so a following Sync rebinds
// them. Used after a certificate is replaced on disk: tls.Listen captures the
// certificate when the listener is created, so the socket has to be recreated
// for new material to be served. Accepted connections are not owned by the pool
// and keep running.
func (p *tlsListenerPool) Drop(addrs []string, reason string) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
for _, addr := range addrs {
entry, ok := p.entries[addr]
if !ok {
continue
}
_ = entry.Close()
delete(p.entries, addr)
if reason != "" {
log.Printf("hotreload: dropped TLS %s (%s)", addr, reason)
} else {
log.Printf("hotreload: dropped TLS %s", addr)
}
}
}
func (p *tlsListenerPool) StopAll(reason string) { func (p *tlsListenerPool) StopAll(reason string) {
if p == nil { if p == nil {
return return
+2
View File
@@ -1736,6 +1736,8 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
mux.Handle("/api/tls/generate-selfsigned", saSession(handleManagedProxyOrLocal(store, handleTLSGenerateSelfSigned))) mux.Handle("/api/tls/generate-selfsigned", saSession(handleManagedProxyOrLocal(store, handleTLSGenerateSelfSigned)))
mux.Handle("/api/tls/letsencrypt", saSession(handleManagedProxyOrLocal(store, handleTLSLetsEncrypt))) mux.Handle("/api/tls/letsencrypt", saSession(handleManagedProxyOrLocal(store, handleTLSLetsEncrypt)))
mux.Handle("/api/tls/upload-pem", saSession(handleManagedProxyOrLocal(store, handleTLSUploadPEM))) mux.Handle("/api/tls/upload-pem", saSession(handleManagedProxyOrLocal(store, handleTLSUploadPEM)))
mux.Handle("/api/tls/certs", saSession(handleManagedProxyOrLocal(store, handleTLSCertList)))
mux.Handle("/api/tls/certs/update", saSession(handleManagedProxyOrLocal(store, handleTLSCertUpdate)))
// Superadmin-only: DNSTT key management // Superadmin-only: DNSTT key management
mux.Handle("/api/dnstt/genkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGenKey))) mux.Handle("/api/dnstt/genkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGenKey)))
+3 -1
View File
@@ -22,7 +22,9 @@ import (
"time" "time"
) )
const tlsCertsDir = "/opt/sshpanel/certs" // tlsCertsDir holds panel-managed certificates. It is a var so tests can point
// it at a temporary directory.
var tlsCertsDir = "/opt/sshpanel/certs"
var ( var (
tlsDNSNamePattern = regexp.MustCompile(`^(?:\*\.)?(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) tlsDNSNamePattern = regexp.MustCompile(`^(?:\*\.)?(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
+715
View File
@@ -0,0 +1,715 @@
package main
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Certificate management for the panel: list the TLS material this node already
// uses and replace it in place (fullchain + privkey) when an operator renews a
// certificate. Replacing in place is what makes renewal painless — every place
// that references the old paths (TLS forwarders, Xray inbounds) keeps working,
// and only the listeners that actually serve the certificate are rebound.
const (
tlsCertFileName = "cert.pem"
tlsKeyFileName = "key.pem"
// Two PEM blobs plus JSON overhead. Certificates are a few KB; RSA chains
// with several intermediates still stay far below this.
maxTLSCertRequestBody = 4 << 20
maxTLSPEMBytes = 1 << 20
// Certificates expiring inside this window are flagged in the panel.
tlsCertExpiryWarnDays = 21
)
// tlsCertUsage records one consumer of a certificate so the panel can show what
// a replacement is going to affect.
type tlsCertUsage struct {
Kind string `json:"kind"` // tls_forwarder | xray_inbound
Ref string `json:"ref"` // listen address or inbound tag
}
type tlsCertInfo struct {
Name string `json:"name"`
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
Managed bool `json:"managed"` // stored under /opt/sshpanel/certs
Exists bool `json:"exists"`
Subject string `json:"subject,omitempty"`
Issuer string `json:"issuer,omitempty"`
Domains []string `json:"domains"`
NotBefore string `json:"not_before,omitempty"`
NotAfter string `json:"not_after,omitempty"`
DaysLeft int `json:"days_left"`
Expired bool `json:"expired"`
Expiring bool `json:"expiring"`
SelfSigned bool `json:"self_signed"`
ChainLen int `json:"chain_length"`
KeyType string `json:"key_type,omitempty"`
KeyOK bool `json:"key_ok"`
Modified string `json:"modified,omitempty"`
Error string `json:"error,omitempty"`
UsedBy []tlsCertUsage `json:"used_by"`
}
type tlsCertRef struct {
certFile string
keyFile string
managed bool
usage []tlsCertUsage
}
type tlsCertRefSet struct {
byCert map[string]*tlsCertRef
order []string
}
func newTLSCertRefSet() *tlsCertRefSet {
return &tlsCertRefSet{byCert: map[string]*tlsCertRef{}}
}
func (s *tlsCertRefSet) add(certFile, keyFile string, usage ...tlsCertUsage) *tlsCertRef {
certFile = strings.TrimSpace(certFile)
if certFile == "" {
return nil
}
certFile = filepath.Clean(certFile)
ref, ok := s.byCert[certFile]
if !ok {
ref = &tlsCertRef{certFile: certFile, managed: isUnderTLSCertsDir(certFile)}
s.byCert[certFile] = ref
s.order = append(s.order, certFile)
}
if ref.keyFile == "" && strings.TrimSpace(keyFile) != "" {
ref.keyFile = filepath.Clean(strings.TrimSpace(keyFile))
}
for _, u := range usage {
if u.Kind == "" {
continue
}
dup := false
for _, have := range ref.usage {
if have == u {
dup = true
break
}
}
if !dup {
ref.usage = append(ref.usage, u)
}
}
return ref
}
func (s *tlsCertRefSet) list() []*tlsCertRef {
out := make([]*tlsCertRef, 0, len(s.order))
for _, key := range s.order {
out = append(out, s.byCert[key])
}
return out
}
func isUnderTLSCertsDir(path string) bool {
rel, err := filepath.Rel(filepath.Clean(tlsCertsDir), filepath.Clean(path))
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// samePathRef compares two file paths, following symlinks when both sides can be
// resolved. /etc/letsencrypt/live/<domain>/fullchain.pem is a symlink, so a
// plain string compare is not enough to match a config reference to a real file.
func samePathRef(a, b string) bool {
a, b = strings.TrimSpace(a), strings.TrimSpace(b)
if a == "" || b == "" {
return false
}
if filepath.Clean(a) == filepath.Clean(b) {
return true
}
ra, errA := filepath.EvalSymlinks(a)
rb, errB := filepath.EvalSymlinks(b)
return errA == nil && errB == nil && ra == rb
}
// collectTLSCertRefs gathers every certificate this node knows about: the ones
// stored in the panel's cert directory plus the ones referenced by the running
// config (TLS forwarders) and the Xray config (inbound tlsSettings).
func collectTLSCertRefs() *tlsCertRefSet {
set := newTLSCertRefSet()
gc := getGlobalCfg()
var fallbackCert, fallbackKey string
if gc != nil {
for _, fwd := range gc.TLSForwarders {
if strings.TrimSpace(fwd.CertFile) == "" {
continue
}
if fallbackCert == "" {
fallbackCert, fallbackKey = fwd.CertFile, fwd.KeyFile
}
listen := strings.TrimSpace(fwd.Listen)
if listen == "" {
listen = "(unbound)"
}
set.add(fwd.CertFile, fwd.KeyFile, tlsCertUsage{Kind: "tls_forwarder", Ref: listen})
}
}
for _, u := range xrayInboundCertUsage(fallbackCert, fallbackKey) {
set.add(u.certFile, u.keyFile, tlsCertUsage{Kind: "xray_inbound", Ref: u.tag})
}
// Panel-managed certificates (self-signed, pasted, or previously updated).
entries, err := os.ReadDir(tlsCertsDir)
if err == nil {
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
certFile := filepath.Join(tlsCertsDir, name, tlsCertFileName)
if _, err := os.Stat(certFile); err != nil {
continue
}
set.add(certFile, filepath.Join(tlsCertsDir, name, tlsKeyFileName))
}
}
return set
}
type xrayCertRef struct {
tag string
certFile string
keyFile string
}
// xrayInboundCertUsage returns the certificate each TLS-enabled Xray inbound
// serves. Inbounds that enable TLS without naming a certificate inherit the
// first TLS forwarder's material (see buildInboundTLS), so they are reported
// against that path — replacing it does affect them.
func xrayInboundCertUsage(fallbackCert, fallbackKey string) []xrayCertRef {
if xrayMgr == nil {
return nil
}
data, err := xrayMgr.GetConfig()
if err != nil || len(data) == 0 {
return nil
}
var cf struct {
Inbounds []struct {
Tag string `json:"tag"`
StreamSettings struct {
Security string `json:"security"`
TLSSettings struct {
Certificates []struct {
CertificateFile string `json:"certificateFile"`
KeyFile string `json:"keyFile"`
} `json:"certificates"`
} `json:"tlsSettings"`
} `json:"streamSettings"`
} `json:"inbounds"`
}
if err := json.Unmarshal(data, &cf); err != nil {
return nil
}
var out []xrayCertRef
for i, in := range cf.Inbounds {
security := strings.ToLower(strings.TrimSpace(in.StreamSettings.Security))
certs := in.StreamSettings.TLSSettings.Certificates
if security != "tls" && len(certs) == 0 {
continue
}
tag := strings.TrimSpace(in.Tag)
if tag == "" {
tag = fmt.Sprintf("inbound-%d", i+1)
}
if len(certs) > 0 && strings.TrimSpace(certs[0].CertificateFile) != "" {
out = append(out, xrayCertRef{tag: tag, certFile: certs[0].CertificateFile, keyFile: certs[0].KeyFile})
continue
}
if security == "tls" && strings.TrimSpace(fallbackCert) != "" {
out = append(out, xrayCertRef{tag: tag + " (herda do TLS forwarder)", certFile: fallbackCert, keyFile: fallbackKey})
}
}
return out
}
func tlsCertDisplayName(certFile string) string {
dir := filepath.Base(filepath.Dir(certFile))
if dir == "" || dir == "." || dir == string(filepath.Separator) {
return filepath.Base(certFile)
}
if dir == "live" || dir == "certs" {
return filepath.Base(certFile)
}
return dir
}
func tlsKeyTypeName(key interface{}) string {
switch k := key.(type) {
case *rsa.PrivateKey:
return fmt.Sprintf("RSA %d", k.N.BitLen())
case *ecdsa.PrivateKey:
return "ECDSA " + k.Curve.Params().Name
case ed25519.PrivateKey:
return "Ed25519"
}
return ""
}
func parsePEMCertChain(data []byte) ([]*x509.Certificate, error) {
var chain []*x509.Certificate
rest := data
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
crt, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
chain = append(chain, crt)
}
if len(chain) == 0 {
return nil, fmt.Errorf("no CERTIFICATE block found")
}
return chain, nil
}
func certDomains(leaf *x509.Certificate) []string {
seen := map[string]bool{}
out := make([]string, 0, len(leaf.DNSNames)+len(leaf.IPAddresses)+1)
for _, d := range leaf.DNSNames {
if d = strings.TrimSpace(d); d != "" && !seen[d] {
seen[d] = true
out = append(out, d)
}
}
for _, ip := range leaf.IPAddresses {
s := ip.String()
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
if len(out) == 0 && strings.TrimSpace(leaf.Subject.CommonName) != "" {
out = append(out, strings.TrimSpace(leaf.Subject.CommonName))
}
return out
}
func describeTLSCert(ref *tlsCertRef) tlsCertInfo {
info := tlsCertInfo{
Name: tlsCertDisplayName(ref.certFile),
CertFile: ref.certFile,
KeyFile: ref.keyFile,
Managed: ref.managed,
Domains: []string{},
UsedBy: ref.usage,
}
if info.UsedBy == nil {
info.UsedBy = []tlsCertUsage{}
}
st, err := os.Stat(ref.certFile)
if err != nil {
info.Error = "arquivo não encontrado"
return info
}
info.Exists = true
info.Modified = st.ModTime().UTC().Format(time.RFC3339)
certPEM, err := os.ReadFile(ref.certFile)
if err != nil {
info.Error = "leitura do certificado: " + err.Error()
return info
}
chain, err := parsePEMCertChain(certPEM)
if err != nil {
info.Error = "certificado inválido: " + err.Error()
return info
}
leaf := chain[0]
info.ChainLen = len(chain)
info.Subject = leaf.Subject.CommonName
info.Issuer = leaf.Issuer.CommonName
if info.Issuer == "" && len(leaf.Issuer.Organization) > 0 {
info.Issuer = leaf.Issuer.Organization[0]
}
info.Domains = certDomains(leaf)
info.NotBefore = leaf.NotBefore.UTC().Format(time.RFC3339)
info.NotAfter = leaf.NotAfter.UTC().Format(time.RFC3339)
info.SelfSigned = string(leaf.RawIssuer) == string(leaf.RawSubject)
now := time.Now()
info.Expired = now.After(leaf.NotAfter)
info.DaysLeft = int(leaf.NotAfter.Sub(now).Hours() / 24)
info.Expiring = !info.Expired && info.DaysLeft <= tlsCertExpiryWarnDays
if ref.keyFile != "" {
keyPEM, err := os.ReadFile(ref.keyFile)
if err != nil {
info.Error = "leitura da chave: " + err.Error()
return info
}
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
info.Error = "a chave privada não corresponde ao certificado"
return info
}
info.KeyOK = true
info.KeyType = tlsKeyTypeName(pair.PrivateKey)
} else {
info.Error = "nenhuma chave privada associada"
}
return info
}
// handleTLSCertList returns every certificate this node uses, with expiry and
// the listeners/inbounds that serve it.
func handleTLSCertList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
refs := collectTLSCertRefs().list()
out := make([]tlsCertInfo, 0, len(refs))
for _, ref := range refs {
out = append(out, describeTLSCert(ref))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"certs_dir": tlsCertsDir,
"certs": out,
})
}
type tlsCertUpdateRequest struct {
// Name creates or replaces a panel-managed certificate under
// /opt/sshpanel/certs/<name>/. Ignored when CertFile is set.
Name string `json:"name"`
// CertFile/KeyFile target an existing certificate in place so every
// reference to those paths keeps working after the renewal.
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
// Fullchain/Privkey hold the PEM text. cert/key are accepted as aliases.
Fullchain string `json:"fullchain"`
Privkey string `json:"privkey"`
Cert string `json:"cert"`
Key string `json:"key"`
Reload *bool `json:"reload"`
Force bool `json:"force"`
}
type tlsCertReloadResult struct {
TLSForwarders []string `json:"tls_forwarders"`
XrayInbounds []string `json:"xray_inbounds"`
XrayRestarted bool `json:"xray_restarted"`
}
func normalizeTLSFilePath(raw string) (string, error) {
p := strings.TrimSpace(raw)
if p == "" {
return "", fmt.Errorf("caminho vazio")
}
if strings.ContainsAny(p, "\x00\r\n") {
return "", fmt.Errorf("caminho inválido")
}
if !filepath.IsAbs(p) {
return "", fmt.Errorf("o caminho precisa ser absoluto")
}
return filepath.Clean(p), nil
}
func normalizePEMText(raw string) string {
s := strings.ReplaceAll(strings.TrimSpace(raw), "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
if s == "" {
return ""
}
return s + "\n"
}
// resolveTLSCertTarget decides which files the new PEM material is written to
// and rejects paths that are neither panel-managed nor already referenced by the
// running configuration. Without that check this endpoint would be an arbitrary
// root file-write primitive.
func resolveTLSCertTarget(req tlsCertUpdateRequest) (certFile, keyFile string, warnings []string, err error) {
if strings.TrimSpace(req.CertFile) != "" {
certFile, err = normalizeTLSFilePath(req.CertFile)
if err != nil {
return "", "", nil, err
}
refs := collectTLSCertRefs()
var known *tlsCertRef
for _, ref := range refs.list() {
if samePathRef(ref.certFile, certFile) {
known = ref
break
}
}
if known == nil && !isUnderTLSCertsDir(certFile) {
return "", "", nil, fmt.Errorf("caminho não gerenciado pelo painel: use um certificado já referenciado na configuração ou informe um nome para armazenar em %s", tlsCertsDir)
}
if strings.TrimSpace(req.KeyFile) != "" {
keyFile, err = normalizeTLSFilePath(req.KeyFile)
if err != nil {
return "", "", nil, err
}
} else if known != nil && known.keyFile != "" {
keyFile = known.keyFile
} else {
keyFile = filepath.Join(filepath.Dir(certFile), tlsKeyFileName)
}
if !isUnderTLSCertsDir(keyFile) {
keyKnown := known != nil && samePathRef(known.keyFile, keyFile)
if !keyKnown && filepath.Dir(keyFile) != filepath.Dir(certFile) {
return "", "", nil, fmt.Errorf("a chave precisa estar na mesma pasta do certificado ou já estar referenciada na configuração")
}
}
return certFile, keyFile, warnings, nil
}
name, nameErr := normalizeTLSStoreName(req.Name)
if nameErr != nil {
return "", "", nil, fmt.Errorf("informe cert_file de um certificado existente ou um nome para armazenar: %v", nameErr)
}
dir := filepath.Join(tlsCertsDir, name)
return filepath.Join(dir, tlsCertFileName), filepath.Join(dir, tlsKeyFileName), warnings, nil
}
// writeTLSMaterial replaces path with data, keeping a .bak copy of the previous
// content and preserving the existing file mode. Symlinked targets (certbot
// layout) are followed so the link structure survives the update.
func writeTLSMaterial(path string, data []byte, defaultMode os.FileMode) (string, []string, error) {
var warnings []string
target := path
if lst, err := os.Lstat(path); err == nil && lst.Mode()&os.ModeSymlink != 0 {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
target = resolved
warnings = append(warnings, fmt.Sprintf("%s é um link para %s; o conteúdo real foi substituído", path, resolved))
}
}
mode := defaultMode
if st, err := os.Stat(target); err == nil {
mode = st.Mode().Perm()
if old, err := os.ReadFile(target); err == nil {
if err := writeFileAtomic(target+".bak", old, mode); err != nil {
warnings = append(warnings, "não foi possível gravar backup de "+filepath.Base(target)+": "+err.Error())
}
}
}
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return target, warnings, err
}
if err := writeFileAtomic(target, data, mode); err != nil {
return target, warnings, err
}
return target, warnings, nil
}
// handleTLSCertUpdate replaces a certificate's fullchain + private key and
// reloads whatever serves it, so a renewal takes effect without touching any
// other configuration.
func handleTLSCertUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxTLSCertRequestBody)
var req tlsCertUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "corpo inválido: "+err.Error(), http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Fullchain) == "" {
req.Fullchain = req.Cert
}
if strings.TrimSpace(req.Privkey) == "" {
req.Privkey = req.Key
}
certPEM := normalizePEMText(req.Fullchain)
keyPEM := normalizePEMText(req.Privkey)
if certPEM == "" || keyPEM == "" {
http.Error(w, "fullchain (certificado) e privkey (chave privada) são obrigatórios", http.StatusBadRequest)
return
}
if len(certPEM) > maxTLSPEMBytes || len(keyPEM) > maxTLSPEMBytes {
http.Error(w, "certificado ou chave muito grandes", http.StatusRequestEntityTooLarge)
return
}
pair, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil || len(pair.Certificate) == 0 {
http.Error(w, "certificado e chave privada inválidos ou não correspondentes", http.StatusBadRequest)
return
}
chain, err := parsePEMCertChain([]byte(certPEM))
if err != nil {
http.Error(w, "certificado inválido: "+err.Error(), http.StatusBadRequest)
return
}
leaf := chain[0]
now := time.Now()
if now.After(leaf.NotAfter) && !req.Force {
http.Error(w, fmt.Sprintf("este certificado expirou em %s; envie force=true para gravar mesmo assim",
leaf.NotAfter.UTC().Format("2006-01-02")), http.StatusBadRequest)
return
}
certFile, keyFile, warnings, err := resolveTLSCertTarget(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(chain) < 2 && !leaf.IsCA && string(leaf.RawIssuer) != string(leaf.RawSubject) {
warnings = append(warnings, "o PEM enviado contém apenas o certificado final; cole o fullchain.pem completo para evitar erros de cadeia em alguns clientes")
}
if now.Before(leaf.NotBefore) {
warnings = append(warnings, "o certificado só é válido a partir de "+leaf.NotBefore.UTC().Format("2006-01-02 15:04")+" UTC")
}
if now.After(leaf.NotAfter) {
warnings = append(warnings, "certificado já expirado — gravado por causa de force=true")
}
// Domain mismatch is usually a wrong paste, but a domain change can be
// intentional, so it is reported rather than blocked.
if oldPEM, err := os.ReadFile(certFile); err == nil {
if oldChain, err := parsePEMCertChain(oldPEM); err == nil {
oldDomains, newDomains := certDomains(oldChain[0]), certDomains(leaf)
if strings.Join(oldDomains, ",") != strings.Join(newDomains, ",") {
warnings = append(warnings, fmt.Sprintf("os domínios mudaram: antes %s, agora %s",
strings.Join(oldDomains, ", "), strings.Join(newDomains, ", ")))
}
}
}
writtenCert, certWarn, err := writeTLSMaterial(certFile, []byte(certPEM), 0o600)
warnings = append(warnings, certWarn...)
if err != nil {
http.Error(w, "gravar certificado: "+err.Error(), http.StatusInternalServerError)
return
}
writtenKey, keyWarn, err := writeTLSMaterial(keyFile, []byte(keyPEM), 0o600)
warnings = append(warnings, keyWarn...)
if err != nil {
http.Error(w, "gravar chave: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("tls: certificate updated cert=%s key=%s cn=%q not_after=%s",
writtenCert, writtenKey, leaf.Subject.CommonName, leaf.NotAfter.UTC().Format(time.RFC3339))
reload := tlsCertReloadResult{TLSForwarders: []string{}, XrayInbounds: []string{}}
if req.Reload == nil || *req.Reload {
var reloadWarn []string
reload, reloadWarn = reloadTLSCertConsumers(certFile, keyFile)
warnings = append(warnings, reloadWarn...)
}
info := describeTLSCert(&tlsCertRef{
certFile: certFile,
keyFile: keyFile,
managed: isUnderTLSCertsDir(certFile),
usage: certUsageFor(certFile, keyFile),
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"cert_file": certFile,
"key_file": keyFile,
"cert": info,
"reloaded": reload,
"warnings": warnings,
})
}
func certUsageFor(certFile, keyFile string) []tlsCertUsage {
for _, ref := range collectTLSCertRefs().list() {
if samePathRef(ref.certFile, certFile) {
return ref.usage
}
}
return nil
}
// reloadTLSCertConsumers rebinds the TLS forwarders that serve the replaced
// certificate and restarts Xray when one of its inbounds uses it. Certificates
// are read once when a listener is created, so nothing short of rebinding picks
// up new material. Established connections are not owned by the listeners and
// keep running.
func reloadTLSCertConsumers(certFile, keyFile string) (tlsCertReloadResult, []string) {
result := tlsCertReloadResult{TLSForwarders: []string{}, XrayInbounds: []string{}}
var warnings []string
gc := getGlobalCfg()
var fallbackCert, fallbackKey string
if gc != nil {
for _, fwd := range gc.TLSForwarders {
if strings.TrimSpace(fwd.CertFile) != "" {
fallbackCert, fallbackKey = fwd.CertFile, fwd.KeyFile
break
}
}
var affected []string
for _, fwd := range gc.TLSForwarders {
if samePathRef(fwd.CertFile, certFile) || samePathRef(fwd.KeyFile, keyFile) {
if listen := strings.TrimSpace(fwd.Listen); listen != "" {
affected = append(affected, listen)
}
}
}
if len(affected) > 0 && tlsPool != nil {
tlsPool.Drop(affected, "certificate updated")
for _, e := range tlsPool.Sync(gc.TLSForwarders) {
warnings = append(warnings, fmt.Sprintf("recarregar TLS forwarder: %v", e))
}
for _, addr := range affected {
if tlsPool.Has(addr) {
result.TLSForwarders = append(result.TLSForwarders, addr)
} else {
warnings = append(warnings, "o TLS forwarder "+addr+" não voltou a escutar; verifique os logs")
}
}
}
}
for _, u := range xrayInboundCertUsage(fallbackCert, fallbackKey) {
if samePathRef(u.certFile, certFile) || samePathRef(u.keyFile, keyFile) {
result.XrayInbounds = append(result.XrayInbounds, u.tag)
}
}
if len(result.XrayInbounds) > 0 && xrayMgr != nil {
st := xrayMgr.Status()
if st.Enabled && st.Running {
if err := xrayMgr.Restart(); err != nil {
warnings = append(warnings, fmt.Sprintf("reiniciar Xray: %v", err))
} else {
result.XrayRestarted = true
}
} else if st.Enabled {
warnings = append(warnings, "o Xray usa este certificado mas não está em execução")
}
}
return result, warnings
}
+306
View File
@@ -0,0 +1,306 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// makeTestCertPair returns PEM cert/key material for the given domain.
func makeTestCertPair(t *testing.T, domain string, notBefore, notAfter time.Time) (certPEM, keyPEM string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("keygen: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: domain},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{domain},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("certgen: %v", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
return certPEM, keyPEM
}
func useTempCertsDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
old := tlsCertsDir
tlsCertsDir = dir
t.Cleanup(func() { tlsCertsDir = old })
oldCfg := getGlobalCfg()
t.Cleanup(func() { setGlobalCfg(oldCfg) })
return dir
}
func postCertUpdate(t *testing.T, body map[string]interface{}) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/tls/certs/update", strings.NewReader(string(raw)))
rec := httptest.NewRecorder()
handleTLSCertUpdate(rec, req)
return rec
}
func TestCertUpdateStoresNamedCertAndReportsExpiry(t *testing.T) {
dir := useTempCertsDir(t)
certPEM, keyPEM := makeTestCertPair(t, "panel.example.com", time.Now().Add(-time.Hour), time.Now().Add(30*24*time.Hour))
rec := postCertUpdate(t, map[string]interface{}{
"name": "panel-example",
"fullchain": certPEM,
"privkey": keyPEM,
"reload": false,
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
Cert tlsCertInfo `json:"cert"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
wantCert := filepath.Join(dir, "panel-example", tlsCertFileName)
if filepath.Clean(resp.CertFile) != wantCert {
t.Fatalf("cert_file = %q, want %q", resp.CertFile, wantCert)
}
if !resp.Cert.KeyOK {
t.Fatalf("expected key to match certificate: %+v", resp.Cert)
}
if resp.Cert.Expired || resp.Cert.DaysLeft < 25 {
t.Fatalf("unexpected expiry data: %+v", resp.Cert)
}
if len(resp.Cert.Domains) != 1 || resp.Cert.Domains[0] != "panel.example.com" {
t.Fatalf("domains = %v", resp.Cert.Domains)
}
data, err := os.ReadFile(wantCert)
if err != nil || !strings.Contains(string(data), "BEGIN CERTIFICATE") {
t.Fatalf("cert not written: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "panel-example", tlsKeyFileName)); err != nil {
t.Fatalf("key not written: %v", err)
}
}
func TestCertUpdateReplacesInPlaceAndKeepsBackup(t *testing.T) {
dir := useTempCertsDir(t)
oldCert, oldKey := makeTestCertPair(t, "old.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
if rec := postCertUpdate(t, map[string]interface{}{
"name": "renew-me", "fullchain": oldCert, "privkey": oldKey, "reload": false,
}); rec.Code != http.StatusOK {
t.Fatalf("seed failed: %s", rec.Body.String())
}
certFile := filepath.Join(dir, "renew-me", tlsCertFileName)
keyFile := filepath.Join(dir, "renew-me", tlsKeyFileName)
newCert, newKey := makeTestCertPair(t, "new.example.com", time.Now().Add(-time.Hour), time.Now().Add(90*24*time.Hour))
rec := postCertUpdate(t, map[string]interface{}{
"cert_file": certFile, "key_file": keyFile,
"fullchain": newCert, "privkey": newKey, "reload": false,
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Cert tlsCertInfo `json:"cert"`
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Cert.Domains[0] != "new.example.com" {
t.Fatalf("cert was not replaced: %+v", resp.Cert)
}
backup, err := os.ReadFile(certFile + ".bak")
if err != nil {
t.Fatalf("no backup written: %v", err)
}
if strings.TrimSpace(string(backup)) != strings.TrimSpace(oldCert) {
t.Fatal("backup does not hold the previous certificate")
}
if _, err := os.Stat(keyFile + ".bak"); err != nil {
t.Fatalf("no key backup: %v", err)
}
joined := strings.Join(resp.Warnings, " | ")
if !strings.Contains(joined, "domínios mudaram") {
t.Fatalf("expected a domain-change warning, got %q", joined)
}
}
func TestCertUpdateRejectsBadInput(t *testing.T) {
dir := useTempCertsDir(t)
certPEM, keyPEM := makeTestCertPair(t, "a.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
_, otherKey := makeTestCertPair(t, "b.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
expiredCert, expiredKey := makeTestCertPair(t, "old.example.com", time.Now().Add(-48*time.Hour), time.Now().Add(-time.Hour))
// Absolute, but neither panel-managed nor referenced by the configuration.
unmanaged := t.TempDir()
cases := []struct {
name string
body map[string]interface{}
want string
}{
{"missing key", map[string]interface{}{"name": "x", "fullchain": certPEM}, "obrigatórios"},
{"mismatched pair", map[string]interface{}{"name": "x", "fullchain": certPEM, "privkey": otherKey}, "não correspondentes"},
{"expired without force", map[string]interface{}{"name": "x", "fullchain": expiredCert, "privkey": expiredKey}, "expirou"},
{"unmanaged path", map[string]interface{}{
"cert_file": filepath.Join(unmanaged, "cert.pem"),
"key_file": filepath.Join(unmanaged, "key.pem"),
"fullchain": certPEM, "privkey": keyPEM,
}, "não gerenciado"},
{"relative path", map[string]interface{}{"cert_file": "certs/cert.pem", "fullchain": certPEM, "privkey": keyPEM}, "absoluto"},
{"bad name", map[string]interface{}{"name": "../escape", "fullchain": certPEM, "privkey": keyPEM}, "nome"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := postCertUpdate(t, tc.body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), tc.want) {
t.Fatalf("body %q does not mention %q", rec.Body.String(), tc.want)
}
})
}
if entries, err := os.ReadDir(dir); err == nil && len(entries) != 0 {
t.Fatalf("rejected requests wrote %d entries to the certs dir", len(entries))
}
}
func TestCertUpdateForceAcceptsExpiredCert(t *testing.T) {
useTempCertsDir(t)
expiredCert, expiredKey := makeTestCertPair(t, "old.example.com", time.Now().Add(-48*time.Hour), time.Now().Add(-time.Hour))
rec := postCertUpdate(t, map[string]interface{}{
"name": "forced", "fullchain": expiredCert, "privkey": expiredKey, "reload": false, "force": true,
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Cert tlsCertInfo `json:"cert"`
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Cert.Expired {
t.Fatal("expected the stored certificate to be reported as expired")
}
if !strings.Contains(strings.Join(resp.Warnings, " | "), "expirado") {
t.Fatalf("expected an expiry warning, got %v", resp.Warnings)
}
}
// A certificate referenced only by the running config (for example a certbot
// path outside the panel directory) must still be updatable in place, because
// that is what makes a renewal invisible to the rest of the configuration.
func TestCertUpdateAllowsPathReferencedByConfig(t *testing.T) {
useTempCertsDir(t)
external := t.TempDir()
certFile := filepath.Join(external, "fullchain.pem")
keyFile := filepath.Join(external, "privkey.pem")
oldCert, oldKey := makeTestCertPair(t, "tunnel.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
if err := os.WriteFile(certFile, []byte(oldCert), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyFile, []byte(oldKey), 0o600); err != nil {
t.Fatal(err)
}
setGlobalCfg(&Config{TLSForwarders: []TLSForwarderConfig{{
Listen: "0.0.0.0:8443", CertFile: certFile, KeyFile: keyFile,
}}})
newCert, newKey := makeTestCertPair(t, "tunnel.example.com", time.Now().Add(-time.Hour), time.Now().Add(60*24*time.Hour))
rec := postCertUpdate(t, map[string]interface{}{
"cert_file": certFile, "key_file": keyFile,
"fullchain": newCert, "privkey": newKey, "reload": false,
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
stored, err := os.ReadFile(certFile)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(stored)) != strings.TrimSpace(newCert) {
t.Fatal("external certificate path was not updated")
}
var resp struct {
Cert tlsCertInfo `json:"cert"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Cert.UsedBy) != 1 || resp.Cert.UsedBy[0].Ref != "0.0.0.0:8443" {
t.Fatalf("expected the TLS forwarder to be reported as consumer, got %+v", resp.Cert.UsedBy)
}
}
func TestTLSCertListReportsConfiguredAndManagedCerts(t *testing.T) {
dir := useTempCertsDir(t)
certPEM, keyPEM := makeTestCertPair(t, "listed.example.com", time.Now().Add(-time.Hour), time.Now().Add(10*24*time.Hour))
if err := os.MkdirAll(filepath.Join(dir, "listed"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "listed", tlsCertFileName), []byte(certPEM), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "listed", tlsKeyFileName), []byte(keyPEM), 0o600); err != nil {
t.Fatal(err)
}
setGlobalCfg(&Config{})
req := httptest.NewRequest(http.MethodGet, "/api/tls/certs", nil)
rec := httptest.NewRecorder()
handleTLSCertList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Certs []tlsCertInfo `json:"certs"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Certs) != 1 {
t.Fatalf("expected 1 cert, got %d (%+v)", len(resp.Certs), resp.Certs)
}
got := resp.Certs[0]
if got.Name != "listed" || !got.Managed || !got.KeyOK || !got.SelfSigned {
t.Fatalf("unexpected cert info: %+v", got)
}
if !got.Expiring || got.Expired {
t.Fatalf("a cert expiring in 10 days should be flagged as expiring: %+v", got)
}
}