diff --git a/.env.example b/.env.example index 6e4ce4e..4657992 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,8 @@ DEFAULT_SERVER_IP=127.0.0.1 ACME_EMAIL=admin@example.com ACME_STAGING=false 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= diff --git a/docker-compose.yml b/docker-compose.yml index 1d57978..3349fe7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,15 +49,19 @@ services: ACME_EMAIL: ${ACME_EMAIL:-} ACME_STAGING: ${ACME_STAGING:-false} 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: - "${HTTP_PORT:-8000}:8000" + - "80:80" volumes: - /var/www:/var/www - - panel_ssl:/etc/ssl/panel + - /etc/ssl/panel:/etc/ssl/panel + - /etc/nginx/panel:/etc/nginx/panel depends_on: postgres: condition: service_healthy volumes: postgres_data: - panel_ssl: diff --git a/internal/config/config.go b/internal/config/config.go index 6ae78f0..2e73a2f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/nginx/config.go b/internal/nginx/config.go new file mode 100644 index 0000000..c9052ff --- /dev/null +++ b/internal/nginx/config.go @@ -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) +} diff --git a/internal/server/server.go b/internal/server/server.go index cabf26c..e76ef48 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/ssl/issuer.go b/internal/ssl/issuer.go index eefb3d1..d30b343 100644 --- a/internal/ssl/issuer.go +++ b/internal/ssl/issuer.go @@ -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() diff --git a/internal/sslsvc/service.go b/internal/sslsvc/service.go index a89a1e0..a3ea899 100644 --- a/internal/sslsvc/service.go +++ b/internal/sslsvc/service.go @@ -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 { diff --git a/scripts/setup-nginx.sh b/scripts/setup-nginx.sh new file mode 100644 index 0000000..d4a200a --- /dev/null +++ b/scripts/setup-nginx.sh @@ -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/"