fix: ACME HTTP-01 via nginx config without HTTPS redirect
This commit is contained in:
@@ -21,3 +21,8 @@ DEFAULT_SERVER_IP=127.0.0.1
|
|||||||
ACME_EMAIL=admin@example.com
|
ACME_EMAIL=admin@example.com
|
||||||
ACME_STAGING=false
|
ACME_STAGING=false
|
||||||
SSL_STORAGE_PATH=/etc/ssl/panel
|
SSL_STORAGE_PATH=/etc/ssl/panel
|
||||||
|
NGINX_SITES_DIR=/etc/nginx/panel
|
||||||
|
# На хосте: nginx -s reload (или systemctl reload nginx)
|
||||||
|
NGINX_RELOAD_CMD=
|
||||||
|
# Если nginx не используется — встроенный HTTP на :80 (нужен проброс порта 80)
|
||||||
|
ACME_HTTP_ADDR=
|
||||||
|
|||||||
+6
-2
@@ -49,15 +49,19 @@ services:
|
|||||||
ACME_EMAIL: ${ACME_EMAIL:-}
|
ACME_EMAIL: ${ACME_EMAIL:-}
|
||||||
ACME_STAGING: ${ACME_STAGING:-false}
|
ACME_STAGING: ${ACME_STAGING:-false}
|
||||||
SSL_STORAGE_PATH: /etc/ssl/panel
|
SSL_STORAGE_PATH: /etc/ssl/panel
|
||||||
|
NGINX_SITES_DIR: /etc/nginx/panel
|
||||||
|
NGINX_RELOAD_CMD: ${NGINX_RELOAD_CMD:-}
|
||||||
|
ACME_HTTP_ADDR: ${ACME_HTTP_ADDR:-}
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-8000}:8000"
|
- "${HTTP_PORT:-8000}:8000"
|
||||||
|
- "80:80"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/www:/var/www
|
- /var/www:/var/www
|
||||||
- panel_ssl:/etc/ssl/panel
|
- /etc/ssl/panel:/etc/ssl/panel
|
||||||
|
- /etc/nginx/panel:/etc/nginx/panel
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
panel_ssl:
|
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ type Config struct {
|
|||||||
SessionTTLHours int
|
SessionTTLHours int
|
||||||
ACMEEmail string
|
ACMEEmail string
|
||||||
ACMEStaging bool
|
ACMEStaging bool
|
||||||
|
ACMEHTTPAddr string
|
||||||
SSLStoragePath string
|
SSLStoragePath string
|
||||||
|
NginxSitesDir string
|
||||||
|
NginxReloadCmd string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -65,6 +68,9 @@ func Load() (*Config, error) {
|
|||||||
SessionTTLHours: 168,
|
SessionTTLHours: 168,
|
||||||
ACMEEmail: strings.TrimSpace(os.Getenv("ACME_EMAIL")),
|
ACMEEmail: strings.TrimSpace(os.Getenv("ACME_EMAIL")),
|
||||||
ACMEStaging: os.Getenv("ACME_STAGING") == "true" || os.Getenv("ACME_STAGING") == "1",
|
ACMEStaging: os.Getenv("ACME_STAGING") == "true" || os.Getenv("ACME_STAGING") == "1",
|
||||||
|
ACMEHTTPAddr: strings.TrimSpace(os.Getenv("ACME_HTTP_ADDR")),
|
||||||
SSLStoragePath: sslPath,
|
SSLStoragePath: sslPath,
|
||||||
|
NginxSitesDir: strings.TrimSpace(os.Getenv("NGINX_SITES_DIR")),
|
||||||
|
NginxReloadCmd: strings.TrimSpace(os.Getenv("NGINX_RELOAD_CMD")),
|
||||||
}, nil
|
}, 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/config"
|
||||||
"github.com/panelhosting/panel/internal/handler"
|
"github.com/panelhosting/panel/internal/handler"
|
||||||
"github.com/panelhosting/panel/internal/middleware"
|
"github.com/panelhosting/panel/internal/middleware"
|
||||||
|
"github.com/panelhosting/panel/internal/nginx"
|
||||||
"github.com/panelhosting/panel/internal/repository"
|
"github.com/panelhosting/panel/internal/repository"
|
||||||
"github.com/panelhosting/panel/internal/setup"
|
"github.com/panelhosting/panel/internal/setup"
|
||||||
"github.com/panelhosting/panel/internal/sitesvc"
|
"github.com/panelhosting/panel/internal/sitesvc"
|
||||||
@@ -33,8 +34,9 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
|
|||||||
sslRepo := repository.NewSSLRepository(pool)
|
sslRepo := repository.NewSSLRepository(pool)
|
||||||
|
|
||||||
challengeStore := ssl.NewChallengeStore()
|
challengeStore := ssl.NewChallengeStore()
|
||||||
issuer := ssl.NewIssuer(cfg.ACMEStaging, cfg.SSLStoragePath, challengeStore)
|
issuer := ssl.NewIssuer(cfg.ACMEStaging, cfg.SSLStoragePath, cfg.ACMEHTTPAddr, challengeStore)
|
||||||
sslService := sslsvc.NewService(sslRepo, sites, issuer, cfg)
|
ngx := nginx.NewManager(cfg.NginxSitesDir, cfg.NginxReloadCmd, cfg.SSLStoragePath)
|
||||||
|
sslService := sslsvc.NewService(sslRepo, sites, issuer, ngx, cfg)
|
||||||
|
|
||||||
setupSvc := setup.NewService(users, servers, cfg)
|
setupSvc := setup.NewService(users, servers, cfg)
|
||||||
authSvc := authsvc.NewService(users, sessions, cfg.SessionTTLHours)
|
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/certcrypto"
|
||||||
"github.com/go-acme/lego/v4/certificate"
|
"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/lego"
|
||||||
"github.com/go-acme/lego/v4/registration"
|
"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) GetEmail() string { return u.Email }
|
||||||
func (u *ACMEUser) GetRegistration() *registration.Resource { return u.Registration }
|
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 {
|
type challengeProvider struct {
|
||||||
webroot string
|
webroot string
|
||||||
store *ChallengeStore
|
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)
|
p.store.Set(token, keyAuth)
|
||||||
dir := filepath.Join(p.webroot, ".well-known", "acme-challenge")
|
dir := filepath.Join(p.webroot, ".well-known", "acme-challenge")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
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)
|
p.store.Delete(token)
|
||||||
_ = os.Remove(filepath.Join(p.webroot, ".well-known", "acme-challenge", 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Issuer struct {
|
type Issuer struct {
|
||||||
staging bool
|
staging bool
|
||||||
storageDir string
|
storageDir string
|
||||||
store *ChallengeStore
|
acmeHTTPAddr string
|
||||||
mu sync.Mutex
|
store *ChallengeStore
|
||||||
clients map[string]*lego.Client
|
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{
|
return &Issuer{
|
||||||
staging: staging,
|
staging: staging,
|
||||||
storageDir: storageDir,
|
storageDir: storageDir,
|
||||||
store: store,
|
acmeHTTPAddr: acmeHTTPAddr,
|
||||||
clients: make(map[string]*lego.Client),
|
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
|
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 {
|
if err := client.Challenge.SetHTTP01Provider(provider); err != nil {
|
||||||
return nil, err
|
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)
|
res, err := client.Certificate.Obtain(request)
|
||||||
if err != nil {
|
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 {
|
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
|
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) {
|
func (i *Issuer) getClient(email string) (*lego.Client, error) {
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
defer i.mu.Unlock()
|
defer i.mu.Unlock()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/panelhosting/panel/internal/config"
|
"github.com/panelhosting/panel/internal/config"
|
||||||
|
"github.com/panelhosting/panel/internal/nginx"
|
||||||
"github.com/panelhosting/panel/internal/repository"
|
"github.com/panelhosting/panel/internal/repository"
|
||||||
"github.com/panelhosting/panel/internal/ssl"
|
"github.com/panelhosting/panel/internal/ssl"
|
||||||
)
|
)
|
||||||
@@ -15,11 +16,12 @@ type Service struct {
|
|||||||
repo *repository.SSLRepository
|
repo *repository.SSLRepository
|
||||||
sites *repository.SiteRepository
|
sites *repository.SiteRepository
|
||||||
issuer *ssl.Issuer
|
issuer *ssl.Issuer
|
||||||
|
nginx *nginx.Manager
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(repo *repository.SSLRepository, sites *repository.SiteRepository, issuer *ssl.Issuer, cfg *config.Config) *Service {
|
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, cfg: cfg}
|
return &Service{repo: repo, sites: sites, issuer: issuer, nginx: ngx, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) IssueAsync(sslID, domainID int64, domain, webroot, email string) {
|
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")
|
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)
|
res, err := s.issuer.Obtain(ctx, email, domain, webroot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.repo.MarkError(ctx, sslID, err.Error())
|
_ = 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)"
|
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 {
|
func (s *Service) IssueForSite(ctx context.Context, siteID int64, email string) error {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONF="/etc/nginx/nginx.conf"
|
||||||
|
INCLUDE="include /etc/nginx/panel/*.conf;"
|
||||||
|
|
||||||
|
mkdir -p /etc/nginx/panel
|
||||||
|
|
||||||
|
if [ -f "$CONF" ] && ! grep -qF "$INCLUDE" "$CONF"; then
|
||||||
|
echo "Добавляю include в $CONF"
|
||||||
|
echo "" >> "$CONF"
|
||||||
|
echo "# PanelHosting" >> "$CONF"
|
||||||
|
echo "$INCLUDE" >> "$CONF"
|
||||||
|
fi
|
||||||
|
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
echo "Nginx готов. Конфиги панели: /etc/nginx/panel/"
|
||||||
Reference in New Issue
Block a user