fix: ACME HTTP-01 via nginx config without HTTPS redirect
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user