Files
2026-08-06 00:21:59 -03:00

307 lines
11 KiB
Go

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)
}
}