SSL Cert FIX
This commit is contained in:
@@ -1290,7 +1290,7 @@ Read/write a managed server's `config.json`. Query: `server_id`. Local delegates
|
||||
|
||||
### 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`
|
||||
- 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>/`.
|
||||
- `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
|
||||
|
||||
@@ -97,6 +97,7 @@ function setWorkspaceSection(workspace, section, options = {}) {
|
||||
if (!options.silent) {
|
||||
if (workspace === "xray" && section === "config" && currentRole === "superadmin" && typeof loadWizardFromConfig === "function") loadWizardFromConfig();
|
||||
if (workspace === "xray" && section === "logs" && currentRole === "superadmin" && typeof loadXrayLogs === "function") loadXrayLogs();
|
||||
if (workspace === "config" && section === "tls" && currentRole === "superadmin" && typeof loadTLSCertificates === "function") loadTLSCertificates();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ async function loadServerConfig() {
|
||||
// TLS forwarders
|
||||
tlsForwardersState = c.tls_forwarders || [];
|
||||
renderTLSForwarders();
|
||||
loadTLSCertificates();
|
||||
|
||||
// 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----- …" 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");
|
||||
|
||||
+29
-3
@@ -1459,7 +1459,33 @@
|
||||
</section>
|
||||
|
||||
<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----- …" 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----- …" 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 -->
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card-hdr">
|
||||
@@ -1577,13 +1603,13 @@
|
||||
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=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/04-xray.js?v=20260720sshfilters1"></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/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/11-update-status.js?v=20260714pamfix1"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260714pamfix1"></script>
|
||||
|
||||
@@ -199,6 +199,32 @@ func (p *tlsListenerPool) Has(addr string) bool {
|
||||
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) {
|
||||
if p == nil {
|
||||
return
|
||||
|
||||
@@ -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/letsencrypt", saSession(handleManagedProxyOrLocal(store, handleTLSLetsEncrypt)))
|
||||
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
|
||||
mux.Handle("/api/dnstt/genkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGenKey)))
|
||||
|
||||
+3
-1
@@ -22,7 +22,9 @@ import (
|
||||
"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 (
|
||||
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])?$`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user