Files
DragonCoreSSH-NewWEB/tls_certs_api.go
T
2026-08-06 00:21:59 -03:00

716 lines
22 KiB
Go

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
}