diff --git a/README.md b/README.md index ff49ce4..72cd693 100644 --- a/README.md +++ b/README.md @@ -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//`. @@ -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//`. - `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//`. 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 `.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": , "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 diff --git a/admin/assets/js/02-shell.js b/admin/assets/js/02-shell.js index 63a3051..1dff0c8 100644 --- a/admin/assets/js/02-shell.js +++ b/admin/assets/js/02-shell.js @@ -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; } diff --git a/admin/assets/js/08-server-config.js b/admin/assets/js/08-server-config.js index 8aa6255..296c127 100644 --- a/admin/assets/js/08-server-config.js +++ b/admin/assets/js/08-server-config.js @@ -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 'arquivo ausente'; + if (c.error) return `${escapeHTML(c.error)}`; + if (c.expired) return 'expirado'; + if (c.expiring) return `expira em ${c.days_left} dia(s)`; + return `válido por ${c.days_left} dia(s)`; +} + +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 = '
Nenhum certificado encontrado neste servidor.
'; + 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 `${escapeHTML(label)}`; + }).join(" ") || 'não referenciado na configuração'; + + const head = document.createElement("div"); + head.style = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;"; + head.innerHTML = `${escapeHTML(c.name || "cert")} + ${certExpiryChip(c)} + ${c.self_signed ? 'autoassinado' : ""} + ${c.managed ? 'painel' : ""} + `; + + 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)}
${escapeHTML(c.cert_file || "")}
${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 = `em uso por: ${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 = ` +
+
+
+
+
+
+
Grava em ${escapeHTML(c.cert_file || "")} e ${escapeHTML(c.key_file || "")}. O conteúdo anterior fica salvo como .bak.
+
+ + +
+
`; + 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"); diff --git a/admin/index.html b/admin/index.html index e0a84fd..318207c 100644 --- a/admin/index.html +++ b/admin/index.html @@ -1459,7 +1459,33 @@
-
04 · Segurança

Encaminhadores TLS

Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.

+
04 · Segurança

Encaminhadores TLS

Gerencie os certificados do servidor e crie listeners TLS com certificado automático, colado ou armazenado em arquivo.

+ + +
+
+
Certificados TLS 0
+ live + + +
+
Cole o fullchain.pem e o privkey.pem para renovar um certificado. Os arquivos são substituídos no mesmo caminho (com backup .bak), então nenhuma configuração precisa ser alterada, e os listeners TLS e inbounds Xray que usam o certificado são recarregados na hora.
+
+ +
+
+
@@ -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. --> - + - + diff --git a/hotreload.go b/hotreload.go index 171754a..6d080df 100644 --- a/hotreload.go +++ b/hotreload.go @@ -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 diff --git a/main.go b/main.go index 231aab2..5629790 100644 --- a/main.go +++ b/main.go @@ -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))) diff --git a/tls_api.go b/tls_api.go index b663371..c1a58d9 100644 --- a/tls_api.go +++ b/tls_api.go @@ -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])?$`) diff --git a/tls_certs_api.go b/tls_certs_api.go new file mode 100644 index 0000000..4796f7f --- /dev/null +++ b/tls_certs_api.go @@ -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//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//. 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 +} diff --git a/tls_certs_api_test.go b/tls_certs_api_test.go new file mode 100644 index 0000000..0fff02c --- /dev/null +++ b/tls_certs_api_test.go @@ -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) + } +}