fix: ACME HTTP-01 via nginx config without HTTPS redirect

This commit is contained in:
orohi
2026-06-17 06:12:54 +03:00
parent f2c05d15ec
commit 0000a8974d
8 changed files with 239 additions and 26 deletions
+6
View File
@@ -16,7 +16,10 @@ type Config struct {
SessionTTLHours int
ACMEEmail string
ACMEStaging bool
ACMEHTTPAddr string
SSLStoragePath string
NginxSitesDir string
NginxReloadCmd string
}
func Load() (*Config, error) {
@@ -65,6 +68,9 @@ func Load() (*Config, error) {
SessionTTLHours: 168,
ACMEEmail: strings.TrimSpace(os.Getenv("ACME_EMAIL")),
ACMEStaging: os.Getenv("ACME_STAGING") == "true" || os.Getenv("ACME_STAGING") == "1",
ACMEHTTPAddr: strings.TrimSpace(os.Getenv("ACME_HTTP_ADDR")),
SSLStoragePath: sslPath,
NginxSitesDir: strings.TrimSpace(os.Getenv("NGINX_SITES_DIR")),
NginxReloadCmd: strings.TrimSpace(os.Getenv("NGINX_RELOAD_CMD")),
}, nil
}
+128
View File
@@ -0,0 +1,128 @@
package nginx
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/panelhosting/panel/internal/ssl"
)
type Manager struct {
sitesDir string
reloadCmd string
sslStorage string
}
func NewManager(sitesDir, reloadCmd, sslStorage string) *Manager {
return &Manager{sitesDir: sitesDir, reloadCmd: reloadCmd, sslStorage: sslStorage}
}
func (m *Manager) Enabled() bool {
return m.sitesDir != ""
}
func (m *Manager) PrepareHTTPChallenge(domain, documentRoot string) error {
if !m.Enabled() {
return nil
}
if err := os.MkdirAll(m.sitesDir, 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(documentRoot, ".well-known", "acme-challenge"), 0o755); err != nil {
return err
}
cfg := fmt.Sprintf(`# PanelHosting — HTTP challenge for %s
server {
listen 80;
listen [::]:80;
server_name %s;
location ^~ /.well-known/acme-challenge/ {
root %s;
default_type "text/plain";
try_files $uri =404;
}
location / {
return 404;
}
}
`, domain, domain, documentRoot)
path := filepath.Join(m.sitesDir, sanitize(domain)+".conf")
if err := os.WriteFile(path, []byte(cfg), 0o644); err != nil {
return err
}
return m.reload()
}
func (m *Manager) ApplyHTTPS(domain, documentRoot string) error {
if !m.Enabled() {
return nil
}
certPath, keyPath := ssl.CertPaths(m.sslStorage, domain)
cfg := fmt.Sprintf(`# PanelHosting — %s
server {
listen 80;
listen [::]:80;
server_name %s;
location ^~ /.well-known/acme-challenge/ {
root %s;
default_type "text/plain";
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name %s;
ssl_certificate %s;
ssl_certificate_key %s;
root %s;
index index.php index.html;
location / {
try_files $uri $uri/ =404;
}
}
`, domain, domain, documentRoot, domain, domain, certPath, keyPath, documentRoot)
path := filepath.Join(m.sitesDir, sanitize(domain)+".conf")
if err := os.WriteFile(path, []byte(cfg), 0o644); err != nil {
return err
}
return m.reload()
}
func (m *Manager) reload() error {
cmd := strings.TrimSpace(m.reloadCmd)
if cmd == "" {
return nil
}
parts := strings.Fields(cmd)
if len(parts) == 0 {
return nil
}
c := exec.Command(parts[0], parts[1:]...)
if out, err := c.CombinedOutput(); err != nil {
return fmt.Errorf("nginx reload: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func sanitize(domain string) string {
return strings.NewReplacer(".", "_", ":", "_").Replace(domain)
}
+4 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/panelhosting/panel/internal/config"
"github.com/panelhosting/panel/internal/handler"
"github.com/panelhosting/panel/internal/middleware"
"github.com/panelhosting/panel/internal/nginx"
"github.com/panelhosting/panel/internal/repository"
"github.com/panelhosting/panel/internal/setup"
"github.com/panelhosting/panel/internal/sitesvc"
@@ -33,8 +34,9 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
sslRepo := repository.NewSSLRepository(pool)
challengeStore := ssl.NewChallengeStore()
issuer := ssl.NewIssuer(cfg.ACMEStaging, cfg.SSLStoragePath, challengeStore)
sslService := sslsvc.NewService(sslRepo, sites, issuer, cfg)
issuer := ssl.NewIssuer(cfg.ACMEStaging, cfg.SSLStoragePath, cfg.ACMEHTTPAddr, challengeStore)
ngx := nginx.NewManager(cfg.NginxSitesDir, cfg.NginxReloadCmd, cfg.SSLStoragePath)
sslService := sslsvc.NewService(sslRepo, sites, issuer, ngx, cfg)
setupSvc := setup.NewService(users, servers, cfg)
authSvc := authsvc.NewService(users, sessions, cfg.SessionTTLHours)
+49 -19
View File
@@ -21,6 +21,7 @@ import (
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/http01"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
)
@@ -33,42 +34,55 @@ type ACMEUser struct {
func (u *ACMEUser) GetEmail() string { return u.Email }
func (u *ACMEUser) GetRegistration() *registration.Resource { return u.Registration }
func (u *ACMEUser) GetPrivateKey() crypto.PrivateKey { return u.key }
func (u *ACMEUser) GetPrivateKey() crypto.PrivateKey { return u.key }
type webrootProvider struct {
webroot string
store *ChallengeStore
type challengeProvider struct {
webroot string
store *ChallengeStore
httpAddr string
httpSrv *http01.ProviderServer
}
func (p *webrootProvider) Present(domain, token, keyAuth string) error {
func (p *challengeProvider) Present(domain, token, keyAuth string) error {
p.store.Set(token, keyAuth)
dir := filepath.Join(p.webroot, ".well-known", "acme-challenge")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, token), []byte(keyAuth), 0o644)
if err := os.WriteFile(filepath.Join(dir, token), []byte(keyAuth), 0o644); err != nil {
return err
}
if p.httpSrv != nil {
return p.httpSrv.Present(domain, token, keyAuth)
}
return nil
}
func (p *webrootProvider) CleanUp(domain, token, keyAuth string) error {
func (p *challengeProvider) CleanUp(domain, token, keyAuth string) error {
p.store.Delete(token)
_ = os.Remove(filepath.Join(p.webroot, ".well-known", "acme-challenge", token))
if p.httpSrv != nil {
return p.httpSrv.CleanUp(domain, token, keyAuth)
}
return nil
}
type Issuer struct {
staging bool
storageDir string
store *ChallengeStore
mu sync.Mutex
clients map[string]*lego.Client
staging bool
storageDir string
acmeHTTPAddr string
store *ChallengeStore
mu sync.Mutex
clients map[string]*lego.Client
}
func NewIssuer(staging bool, storageDir string, store *ChallengeStore) *Issuer {
func NewIssuer(staging bool, storageDir, acmeHTTPAddr string, store *ChallengeStore) *Issuer {
return &Issuer{
staging: staging,
storageDir: storageDir,
store: store,
clients: make(map[string]*lego.Client),
staging: staging,
storageDir: storageDir,
acmeHTTPAddr: acmeHTTPAddr,
store: store,
clients: make(map[string]*lego.Client),
}
}
@@ -83,7 +97,15 @@ func (i *Issuer) Obtain(ctx context.Context, email, domain, webroot string) (*ce
return nil, err
}
provider := &webrootProvider{webroot: webroot, store: i.store}
provider := &challengeProvider{webroot: webroot, store: i.store}
if i.acmeHTTPAddr != "" {
port := "80"
if strings.Contains(i.acmeHTTPAddr, ":") {
port = strings.TrimPrefix(i.acmeHTTPAddr, ":")
}
provider.httpSrv = http01.NewProviderServer("", port)
}
if err := client.Challenge.SetHTTP01Provider(provider); err != nil {
return nil, err
}
@@ -95,7 +117,7 @@ func (i *Issuer) Obtain(ctx context.Context, email, domain, webroot string) (*ce
res, err := client.Certificate.Obtain(request)
if err != nil {
return nil, fmt.Errorf("acme obtain: %w", err)
return nil, fmt.Errorf("acme obtain: %w%s", err, tlsErrorHint(err))
}
if err := i.saveToDisk(domain, res); err != nil {
@@ -105,6 +127,14 @@ func (i *Issuer) Obtain(ctx context.Context, email, domain, webroot string) (*ce
return res, nil
}
func tlsErrorHint(err error) string {
msg := err.Error()
if strings.Contains(msg, "error:tls") || strings.Contains(msg, "remote error: tls") {
return " — домен перенаправляет HTTP→HTTPS; нужен nginx location для /.well-known/acme-challenge/ без редиректа"
}
return ""
}
func (i *Issuer) getClient(email string) (*lego.Client, error) {
i.mu.Lock()
defer i.mu.Unlock()
+24 -3
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/panelhosting/panel/internal/config"
"github.com/panelhosting/panel/internal/nginx"
"github.com/panelhosting/panel/internal/repository"
"github.com/panelhosting/panel/internal/ssl"
)
@@ -15,11 +16,12 @@ type Service struct {
repo *repository.SSLRepository
sites *repository.SiteRepository
issuer *ssl.Issuer
nginx *nginx.Manager
cfg *config.Config
}
func NewService(repo *repository.SSLRepository, sites *repository.SiteRepository, issuer *ssl.Issuer, cfg *config.Config) *Service {
return &Service{repo: repo, sites: sites, issuer: issuer, cfg: cfg}
func NewService(repo *repository.SSLRepository, sites *repository.SiteRepository, issuer *ssl.Issuer, ngx *nginx.Manager, cfg *config.Config) *Service {
return &Service{repo: repo, sites: sites, issuer: issuer, nginx: ngx, cfg: cfg}
}
func (s *Service) IssueAsync(sslID, domainID int64, domain, webroot, email string) {
@@ -45,6 +47,15 @@ func (s *Service) Issue(ctx context.Context, sslID, domainID int64, domain, webr
return s.repo.MarkError(ctx, sslID, "укажите email для Let's Encrypt")
}
if s.nginx.Enabled() {
if err := s.nginx.PrepareHTTPChallenge(domain, webroot); err != nil {
msg := "nginx: " + err.Error()
_ = s.repo.MarkError(ctx, sslID, msg)
return err
}
time.Sleep(2 * time.Second)
}
res, err := s.issuer.Obtain(ctx, email, domain, webroot)
if err != nil {
_ = s.repo.MarkError(ctx, sslID, err.Error())
@@ -61,7 +72,17 @@ func (s *Service) Issue(ctx context.Context, sslID, domainID int64, domain, webr
issuerName = "Let's Encrypt (staging)"
}
return s.repo.MarkActive(ctx, sslID, issuerName, string(res.Certificate), string(res.PrivateKey), string(res.IssuerCertificate), time.Now(), expiresAt)
if err := s.repo.MarkActive(ctx, sslID, issuerName, string(res.Certificate), string(res.PrivateKey), string(res.IssuerCertificate), time.Now(), expiresAt); err != nil {
return err
}
if s.nginx.Enabled() {
if err := s.nginx.ApplyHTTPS(domain, webroot); err != nil {
log.Printf("nginx apply https %s: %v", domain, err)
}
}
return nil
}
func (s *Service) IssueForSite(ctx context.Context, siteID int64, email string) error {