Files
sslh-multiplex-lab/internal/providers/letsencrypt/client.go
2026-01-29 00:03:02 +00:00

217 lines
5.5 KiB
Go

package letsencrypt
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
)
type Client struct {
user *User
client *lego.Client
certDir string
email string
dnsProvider DNSProvider
}
type User struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *User) GetEmail() string {
return u.Email
}
func (u *User) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *User) GetPrivateKey() crypto.PrivateKey {
return u.key
}
type DNSProvider interface {
Present(domain, token, keyAuth string) error
CleanUp(domain, token, keyAuth string) error
}
type CertificateInfo struct {
CertificatePath string
PrivateKeyPath string
FullChainPath string
Domain string
ExpiresAt time.Time
}
func NewClient(email, certDir string, dnsProvider DNSProvider) (*Client, error) {
if err := os.MkdirAll(certDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create cert directory: %w", err)
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
user := &User{
Email: email,
key: privateKey,
}
config := lego.NewConfig(user)
config.CADirURL = lego.LEDirectoryProduction
config.Certificate.KeyType = certcrypto.RSA2048
config.HTTPClient.Timeout = 30 * time.Second
client, err := lego.NewClient(config)
if err != nil {
return nil, fmt.Errorf("failed to create lego client: %w", err)
}
provider := &namecheapDNSProvider{dnsProvider: dnsProvider}
client.Challenge.SetDNS01Provider(provider)
accountPath := filepath.Join(certDir, "account.json")
var reg *registration.Resource
if _, err := os.Stat(accountPath); err == nil {
accountData, err := os.ReadFile(accountPath)
if err == nil {
if err := json.Unmarshal(accountData, &reg); err == nil && reg != nil {
user.Registration = reg
}
}
}
if user.Registration == nil {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, fmt.Errorf("failed to register with Let's Encrypt: %w", err)
}
user.Registration = reg
accountData, _ := json.Marshal(reg)
os.WriteFile(accountPath, accountData, 0600)
}
return &Client{
user: user,
client: client,
certDir: certDir,
email: email,
dnsProvider: dnsProvider,
}, nil
}
type namecheapDNSProvider struct {
dnsProvider DNSProvider
}
func (p *namecheapDNSProvider) Present(domain, token, keyAuth string) error {
return p.dnsProvider.Present(domain, token, keyAuth)
}
func (p *namecheapDNSProvider) CleanUp(domain, token, keyAuth string) error {
return p.dnsProvider.CleanUp(domain, token, keyAuth)
}
func (c *Client) ObtainCertificate(domain string, sanDomains []string) (*CertificateInfo, error) {
domains := append([]string{domain}, sanDomains...)
request := certificate.ObtainRequest{
Domains: domains,
Bundle: true,
}
certificates, err := c.client.Certificate.Obtain(request)
if err != nil {
return nil, fmt.Errorf("failed to obtain certificate: %w", err)
}
certPath := filepath.Join(c.certDir, fmt.Sprintf("%s.crt", domain))
keyPath := filepath.Join(c.certDir, fmt.Sprintf("%s.key", domain))
fullChainPath := filepath.Join(c.certDir, fmt.Sprintf("%s-fullchain.crt", domain))
if err := os.WriteFile(certPath, certificates.Certificate, 0644); err != nil {
return nil, fmt.Errorf("failed to write certificate: %w", err)
}
if err := os.WriteFile(keyPath, certificates.PrivateKey, 0600); err != nil {
return nil, fmt.Errorf("failed to write private key: %w", err)
}
fullChain := append(certificates.Certificate, certificates.IssuerCertificate...)
if err := os.WriteFile(fullChainPath, fullChain, 0644); err != nil {
return nil, fmt.Errorf("failed to write full chain: %w", err)
}
block, _ := pem.Decode(certificates.Certificate)
if block == nil {
return nil, fmt.Errorf("failed to decode certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return &CertificateInfo{
CertificatePath: certPath,
PrivateKeyPath: keyPath,
FullChainPath: fullChainPath,
Domain: domain,
ExpiresAt: cert.NotAfter,
}, nil
}
func (c *Client) RevokeCertificate(certPath string) error {
certBytes, err := os.ReadFile(certPath)
if err != nil {
return fmt.Errorf("failed to read certificate: %w", err)
}
reason := uint(4)
return c.client.Certificate.RevokeWithReason(certBytes, &reason)
}
func GetCertificateInfo(certPath string) (*CertificateInfo, error) {
certBytes, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("failed to read certificate: %w", err)
}
block, _ := pem.Decode(certBytes)
if block == nil {
return nil, fmt.Errorf("failed to decode certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
keyPath := certPath[:len(certPath)-4] + ".key"
fullChainPath := certPath[:len(certPath)-4] + "-fullchain.crt"
return &CertificateInfo{
CertificatePath: certPath,
PrivateKeyPath: keyPath,
FullChainPath: fullChainPath,
Domain: cert.Subject.CommonName,
ExpiresAt: cert.NotAfter,
}, nil
}