// Package certstore writes the combined PEM (cert + chain + key) // HAProxy expects under /etc/edgeguard/tls/.pem and // validates the cert against its private key. // // Layout: // // /etc/edgeguard/tls/.pem — operator-provided or // Let's-Encrypt-issued // /etc/edgeguard/tls/_default.pem — self-signed fallback // (postinst-generated) package certstore import ( "crypto/tls" "crypto/x509" "encoding/pem" "errors" "fmt" "os" "path/filepath" "strings" "time" ) // DefaultDir is the directory HAProxy's `bind ssl crt /etc/edgeguard/tls/` // reads from. Override only in tests. const DefaultDir = "/etc/edgeguard/tls" // CertInfo is the parsed metadata callers want to persist. type CertInfo struct { NotBefore time.Time NotAfter time.Time Issuer string Subject string DNSNames []string } // WriteCombined writes /.pem with the cert chain // followed by the private key — the format HAProxy `crt` consumes. // Validates the cert/key match before writing so a bad upload can't // brick the running HAProxy on next reload. func WriteCombined(dir, domain, certPEM, chainPEM, keyPEM string) (string, error) { if domain == "" { return "", errors.New("domain required") } if certPEM == "" || keyPEM == "" { return "", errors.New("cert and key are required") } // Validate that the key matches the cert. tls.X509KeyPair does // the heavy lifting (PEM parse + private-key/public-key match). if _, err := tls.X509KeyPair([]byte(certPEM+"\n"+chainPEM), []byte(keyPEM)); err != nil { return "", fmt.Errorf("cert/key mismatch: %w", err) } // Compose the file: cert, optional chain, then key. Each section // is normalised to end in a newline so concatenation produces a // well-formed PEM bundle. var sb strings.Builder sb.WriteString(strings.TrimRight(certPEM, "\n")) sb.WriteString("\n") if chain := strings.TrimRight(chainPEM, "\n"); chain != "" { sb.WriteString(chain) sb.WriteString("\n") } sb.WriteString(strings.TrimRight(keyPEM, "\n")) sb.WriteString("\n") if err := os.MkdirAll(dir, 0o750); err != nil { return "", fmt.Errorf("mkdir %s: %w", dir, err) } if !safeDomain(domain) { return "", fmt.Errorf("domain %q contains characters that aren't safe for a filename", domain) } out := filepath.Join(dir, domain+".pem") if err := os.WriteFile(out, []byte(sb.String()), 0o640); err != nil { return "", fmt.Errorf("write %s: %w", out, err) } return out, nil } // Parse pulls Subject/Issuer/NotBefore/NotAfter/SANs out of a PEM // cert block. Useful for handlers that want to surface the metadata // without keeping the raw PEM on the wire. func Parse(certPEM string) (*CertInfo, error) { block, _ := pem.Decode([]byte(certPEM)) if block == nil { return nil, errors.New("no PEM block in certificate input") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, fmt.Errorf("parse certificate: %w", err) } return &CertInfo{ NotBefore: cert.NotBefore, NotAfter: cert.NotAfter, Issuer: cert.Issuer.String(), Subject: cert.Subject.String(), DNSNames: cert.DNSNames, }, nil } // safeDomain rejects names that aren't shaped like a DNS hostname. // We're going to use this as a filename component — anything outside // the conservative DNS charset is refused. func safeDomain(s string) bool { if s == "" || strings.ContainsAny(s, "/\\\x00 \t\n") { return false } for _, r := range s { ok := r == '-' || r == '.' || r == '_' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') if !ok { return false } } return true }