diff --git a/internal/handler/health.go b/internal/handler/health.go index f68164c..c33ea4f 100644 --- a/internal/handler/health.go +++ b/internal/handler/health.go @@ -74,6 +74,10 @@ const baseCSS = ` .badge.ssl-pending { background: #713f12; color: #fde68a; } .badge.ssl-active { background: #14532d; color: #bbf7d0; } .badge.ssl-error { background: #7f1d1d; color: #fecaca; } + .ssl-err-text { margin-top: .35rem; font-size: .75rem; color: #fca5a5; line-height: 1.3; max-width: 220px; } + .ssl-cell { min-width: 120px; } + input.email-sm { padding: .4rem .5rem; margin: 0 0 .35rem; font-size: .85rem; } + .actions-cell { min-width: 140px; } ` const setupPageHTML = ` @@ -125,7 +129,8 @@ func dashboardPageHTML(username string) string {
- +
+

Сайты

@@ -133,31 +138,39 @@ func dashboardPageHTML(username string) string {
`, baseCSS, username) } diff --git a/internal/handler/site.go b/internal/handler/site.go index d107223..b6deff6 100644 --- a/internal/handler/site.go +++ b/internal/handler/site.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" + "github.com/panelhosting/panel/internal/auth" "github.com/panelhosting/panel/internal/middleware" "github.com/panelhosting/panel/internal/sitesvc" ) @@ -22,10 +23,15 @@ type createSiteRequest struct { Name string `json:"name"` Domain string `json:"domain"` PHPVersion string `json:"php_version"` + SSLEmail string `json:"ssl_email"` ServerID int64 `json:"server_id,omitempty"` IssueSSL *bool `json:"issue_ssl"` } +type reissueSSLRequest struct { + SSLEmail string `json:"ssl_email"` +} + func (h *SiteHandler) List(w http.ResponseWriter, r *http.Request) { user, ok := middleware.UserFromContext(r.Context()) if !ok { @@ -63,6 +69,7 @@ func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) { Name: req.Name, Domain: req.Domain, PHPVersion: req.PHPVersion, + SSLEmail: req.SSLEmail, ServerID: req.ServerID, OwnerID: user.ID, IsAdmin: middleware.IsAdmin(user), @@ -72,7 +79,9 @@ func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) { switch { case errors.Is(err, sitesvc.ErrInvalidSiteName), errors.Is(err, sitesvc.ErrInvalidDomain), - errors.Is(err, sitesvc.ErrInvalidPHPVersion): + errors.Is(err, sitesvc.ErrInvalidPHPVersion), + errors.Is(err, sitesvc.ErrInvalidSSLEmail), + errors.Is(err, sitesvc.ErrSSLEmailRequired): writeError(w, http.StatusBadRequest, err.Error()) default: writeError(w, http.StatusInternalServerError, "failed to create site: "+err.Error()) @@ -105,6 +114,15 @@ func (h *SiteHandler) ReissueSSL(w http.ResponseWriter, r *http.Request) { return } - h.svc.ReissueSSLAsync(siteID) + var req reissueSSLRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.SSLEmail != "" { + if err := auth.ValidateEmail(req.SSLEmail); err != nil { + writeError(w, http.StatusBadRequest, "invalid ssl email") + return + } + } + + h.svc.ReissueSSLAsync(siteID, req.SSLEmail) writeJSON(w, http.StatusOK, map[string]string{"message": "ssl issuance started"}) } diff --git a/internal/repository/site.go b/internal/repository/site.go index f8ca528..d3701f4 100644 --- a/internal/repository/site.go +++ b/internal/repository/site.go @@ -26,9 +26,10 @@ type SiteWithDomain struct { SSLID int64 `json:"ssl_id,omitempty"` SSLStatus string `json:"ssl_status,omitempty"` SSLError string `json:"ssl_error,omitempty"` + SSLEmail string `json:"ssl_email,omitempty"` } -func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, name, documentRoot, phpVersion, domain string, issueSSL bool) (*SiteWithDomain, error) { +func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, name, documentRoot, phpVersion, domain, sslEmail string, issueSSL bool) (*SiteWithDomain, error) { tx, err := r.pool.Begin(ctx) if err != nil { return nil, err @@ -36,8 +37,8 @@ func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, na defer tx.Rollback(ctx) const siteQ = ` - INSERT INTO sites (server_id, owner_id, name, document_root, php_version, status) - VALUES ($1, $2, $3, $4, $5, 'active') + INSERT INTO sites (server_id, owner_id, name, document_root, php_version, ssl_email, status) + VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), 'active') RETURNING id, uuid, server_id, owner_id, name, document_root, php_version, status, settings, disk_quota_mb, created_at, updated_at ` @@ -46,7 +47,7 @@ func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, na if phpVersion != "" { phpVer = &phpVersion } - err = tx.QueryRow(ctx, siteQ, serverID, ownerID, name, documentRoot, phpVer).Scan( + err = tx.QueryRow(ctx, siteQ, serverID, ownerID, name, documentRoot, phpVer, sslEmail).Scan( &s.ID, &s.UUID, &s.ServerID, &s.OwnerID, &s.Name, &s.DocumentRoot, &s.PHPVersion, &s.Status, &s.Settings, &s.DiskQuotaMB, &s.CreatedAt, &s.UpdatedAt, ) @@ -64,7 +65,7 @@ func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, na return nil, fmt.Errorf("create domain: %w", err) } - result := &SiteWithDomain{Site: s, PrimaryDomain: domain, DomainID: domainID} + result := &SiteWithDomain{Site: s, PrimaryDomain: domain, DomainID: domainID, SSLEmail: sslEmail} if issueSSL { const sslQ = ` @@ -110,7 +111,8 @@ const siteListQuery = ` COALESCE(d.domain::text, ''), COALESCE(d.id, 0), COALESCE(ssl.status::text, 'none'), - COALESCE(ssl.error_message, '') + COALESCE(ssl.error_message, ''), + COALESCE(s.ssl_email::text, '') FROM sites s LEFT JOIN domains d ON d.site_id = s.id AND d.is_primary = true LEFT JOIN LATERAL ( @@ -127,7 +129,7 @@ func scanSiteList(rows pgx.Rows) ([]SiteWithDomain, error) { &item.ID, &item.UUID, &item.ServerID, &item.OwnerID, &item.Name, &item.DocumentRoot, &item.PHPVersion, &item.Status, &item.Settings, &item.DiskQuotaMB, &item.CreatedAt, &item.UpdatedAt, &item.PrimaryDomain, - &item.DomainID, &item.SSLStatus, &item.SSLError, + &item.DomainID, &item.SSLStatus, &item.SSLError, &item.SSLEmail, ) if err != nil { return nil, err @@ -137,6 +139,23 @@ func scanSiteList(rows pgx.Rows) ([]SiteWithDomain, error) { return list, rows.Err() } +func (r *SiteRepository) UpdateSSLEmail(ctx context.Context, siteID int64, email string) error { + _, err := r.pool.Exec(ctx, `UPDATE sites SET ssl_email = $2, updated_at = now() WHERE id = $1`, siteID, email) + return err +} + +func (r *SiteRepository) GetSSLEmail(ctx context.Context, siteID int64) (string, error) { + var email *string + err := r.pool.QueryRow(ctx, `SELECT ssl_email::text FROM sites WHERE id = $1`, siteID).Scan(&email) + if err != nil { + return "", err + } + if email == nil { + return "", nil + } + return *email, nil +} + type PHPVersionRepository struct { pool *pgxpool.Pool } diff --git a/internal/server/server.go b/internal/server/server.go index 1c85106..cabf26c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -33,15 +33,15 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) { sslRepo := repository.NewSSLRepository(pool) challengeStore := ssl.NewChallengeStore() - issuer := ssl.NewIssuer(cfg.ACMEEmail, cfg.ACMEStaging, cfg.SSLStoragePath, challengeStore) - sslService := sslsvc.NewService(sslRepo, issuer, cfg) + issuer := ssl.NewIssuer(cfg.ACMEStaging, cfg.SSLStoragePath, challengeStore) + sslService := sslsvc.NewService(sslRepo, sites, issuer, cfg) setupSvc := setup.NewService(users, servers, cfg) authSvc := authsvc.NewService(users, sessions, cfg.SessionTTLHours) siteSvc := sitesvc.NewService(sites, servers, phpVers, sslService) if cfg.ACMEEmail == "" { - log.Println("ssl: ACME_EMAIL not set, let's encrypt issuance disabled") + log.Println("ssl: ACME_EMAIL not set globally, use per-site email for Let's Encrypt") } setupHandler := handler.NewSetupHandler(setupSvc) diff --git a/internal/sitesvc/service.go b/internal/sitesvc/service.go index b502b87..c59f58b 100644 --- a/internal/sitesvc/service.go +++ b/internal/sitesvc/service.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" + "github.com/panelhosting/panel/internal/auth" "github.com/panelhosting/panel/internal/models" "github.com/panelhosting/panel/internal/repository" ) @@ -15,6 +16,8 @@ var ( ErrInvalidSiteName = errors.New("site name must be 3-64 chars: lowercase letters, digits, hyphen") ErrInvalidDomain = errors.New("invalid domain") ErrInvalidPHPVersion = errors.New("unsupported php version") + ErrInvalidSSLEmail = errors.New("invalid ssl email") + ErrSSLEmailRequired = errors.New("ssl email is required when issuing certificate") ) var ( @@ -23,8 +26,8 @@ var ( ) type SSLIssuer interface { - IssueAsync(sslID, domainID int64, domain, webroot string) - IssueForSite(ctx context.Context, siteID int64) error + IssueAsync(sslID, domainID int64, domain, webroot, email string) + IssueForSite(ctx context.Context, siteID int64, email string) error } type Service struct { @@ -42,6 +45,7 @@ type CreateInput struct { Name string Domain string PHPVersion string + SSLEmail string ServerID int64 OwnerID int64 IsAdmin bool @@ -60,6 +64,7 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (*repository.SiteW name := strings.ToLower(strings.TrimSpace(in.Name)) domain := strings.ToLower(strings.TrimSpace(in.Domain)) phpVersion := strings.TrimSpace(in.PHPVersion) + sslEmail := strings.TrimSpace(in.SSLEmail) if !siteNameRegex.MatchString(name) { return nil, ErrInvalidSiteName @@ -70,6 +75,14 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (*repository.SiteW if phpVersion == "" { return nil, ErrInvalidPHPVersion } + if in.IssueSSL { + if sslEmail == "" { + return nil, ErrSSLEmailRequired + } + if err := auth.ValidateEmail(sslEmail); err != nil { + return nil, ErrInvalidSSLEmail + } + } active, err := s.phpVers.IsActive(ctx, phpVersion) if err != nil { @@ -93,23 +106,23 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (*repository.SiteW } documentRoot := fmt.Sprintf("/var/www/%s/public", name) - site, err := s.sites.Create(ctx, serverID, in.OwnerID, name, documentRoot, phpVersion, domain, in.IssueSSL) + site, err := s.sites.Create(ctx, serverID, in.OwnerID, name, documentRoot, phpVersion, domain, sslEmail, in.IssueSSL) if err != nil { return nil, err } if in.IssueSSL && s.ssl != nil && site.SSLID > 0 { - s.ssl.IssueAsync(site.SSLID, site.DomainID, domain, documentRoot) + s.ssl.IssueAsync(site.SSLID, site.DomainID, domain, documentRoot, sslEmail) } return site, nil } -func (s *Service) ReissueSSLAsync(siteID int64) { +func (s *Service) ReissueSSLAsync(siteID int64, email string) { if s.ssl == nil { return } go func() { - _ = s.ssl.IssueForSite(context.Background(), siteID) + _ = s.ssl.IssueForSite(context.Background(), siteID, email) }() } diff --git a/internal/ssl/issuer.go b/internal/ssl/issuer.go index aa94566..eefb3d1 100644 --- a/internal/ssl/issuer.go +++ b/internal/ssl/issuer.go @@ -6,7 +6,9 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/sha256" "crypto/x509" + "encoding/hex" "encoding/pem" "errors" "fmt" @@ -31,7 +33,7 @@ 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 @@ -54,29 +56,29 @@ func (p *webrootProvider) CleanUp(domain, token, keyAuth string) error { } type Issuer struct { - email string - staging bool - storageDir string - store *ChallengeStore - mu sync.Mutex - client *lego.Client + staging bool + storageDir string + store *ChallengeStore + mu sync.Mutex + clients map[string]*lego.Client } -func NewIssuer(email string, staging bool, storageDir string, store *ChallengeStore) *Issuer { +func NewIssuer(staging bool, storageDir string, store *ChallengeStore) *Issuer { return &Issuer{ - email: email, staging: staging, storageDir: storageDir, store: store, + clients: make(map[string]*lego.Client), } } -func (i *Issuer) Obtain(ctx context.Context, domain, webroot string) (*certificate.Resource, error) { - if i.email == "" { - return nil, errors.New("ACME_EMAIL is not configured") +func (i *Issuer) Obtain(ctx context.Context, email, domain, webroot string) (*certificate.Resource, error) { + email = strings.TrimSpace(strings.ToLower(email)) + if email == "" { + return nil, errors.New("ssl email is not configured") } - client, err := i.getClient() + client, err := i.getClient(email) if err != nil { return nil, err } @@ -103,15 +105,15 @@ func (i *Issuer) Obtain(ctx context.Context, domain, webroot string) (*certifica return res, nil } -func (i *Issuer) getClient() (*lego.Client, error) { +func (i *Issuer) getClient(email string) (*lego.Client, error) { i.mu.Lock() defer i.mu.Unlock() - if i.client != nil { - return i.client, nil + if client, ok := i.clients[email]; ok { + return client, nil } - user, err := i.loadOrCreateUser() + user, err := i.loadOrCreateUser(email) if err != nil { return nil, err } @@ -140,18 +142,21 @@ func (i *Issuer) getClient() (*lego.Client, error) { } } - i.client = client + i.clients[email] = client return client, nil } -func (i *Issuer) accountPath() string { - return filepath.Join(i.storageDir, "acme", "account.pem") +func (i *Issuer) accountPath(email string) string { + sum := sha256.Sum256([]byte(strings.ToLower(email))) + id := hex.EncodeToString(sum[:8]) + return filepath.Join(i.storageDir, "acme", id, "account.pem") } -func (i *Issuer) loadOrCreateUser() (*ACMEUser, error) { - user := &ACMEUser{Email: i.email} +func (i *Issuer) loadOrCreateUser(email string) (*ACMEUser, error) { + user := &ACMEUser{Email: email} + path := i.accountPath(email) - if data, err := os.ReadFile(i.accountPath()); err == nil { + if data, err := os.ReadFile(path); err == nil { block, _ := pem.Decode(data) if block != nil { key, err := x509.ParseECPrivateKey(block.Bytes) @@ -168,7 +173,7 @@ func (i *Issuer) loadOrCreateUser() (*ACMEUser, error) { } user.key = key - if err := os.MkdirAll(filepath.Dir(i.accountPath()), 0o700); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, err } if err := i.saveAccount(user); err != nil { @@ -183,7 +188,7 @@ func (i *Issuer) saveAccount(user *ACMEUser) error { return err } pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) - return os.WriteFile(i.accountPath(), pemBytes, 0o600) + return os.WriteFile(i.accountPath(user.Email), pemBytes, 0o600) } func (i *Issuer) saveToDisk(domain string, res *certificate.Resource) error { diff --git a/internal/sslsvc/service.go b/internal/sslsvc/service.go index be729b7..a89a1e0 100644 --- a/internal/sslsvc/service.go +++ b/internal/sslsvc/service.go @@ -3,6 +3,7 @@ package sslsvc import ( "context" "log" + "strings" "time" "github.com/panelhosting/panel/internal/config" @@ -12,29 +13,39 @@ import ( type Service struct { repo *repository.SSLRepository + sites *repository.SiteRepository issuer *ssl.Issuer cfg *config.Config } -func NewService(repo *repository.SSLRepository, issuer *ssl.Issuer, cfg *config.Config) *Service { - return &Service{repo: repo, issuer: issuer, cfg: cfg} +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 (s *Service) IssueAsync(sslID, domainID int64, domain, webroot string) { +func (s *Service) IssueAsync(sslID, domainID int64, domain, webroot, email string) { go func() { ctx := context.Background() - if err := s.Issue(ctx, sslID, domainID, domain, webroot); err != nil { + if err := s.Issue(ctx, sslID, domainID, domain, webroot, email); err != nil { log.Printf("ssl issue %s: %v", domain, err) } }() } -func (s *Service) Issue(ctx context.Context, sslID, domainID int64, domain, webroot string) error { - if s.cfg.ACMEEmail == "" { - return s.repo.MarkError(ctx, sslID, "ACME_EMAIL not configured") +func (s *Service) resolveEmail(email string) string { + email = strings.TrimSpace(strings.ToLower(email)) + if email != "" { + return email + } + return strings.TrimSpace(strings.ToLower(s.cfg.ACMEEmail)) +} + +func (s *Service) Issue(ctx context.Context, sslID, domainID int64, domain, webroot, email string) error { + email = s.resolveEmail(email) + if email == "" { + return s.repo.MarkError(ctx, sslID, "укажите email для Let's Encrypt") } - res, err := s.issuer.Obtain(ctx, domain, webroot) + res, err := s.issuer.Obtain(ctx, email, domain, webroot) if err != nil { _ = s.repo.MarkError(ctx, sslID, err.Error()) return err @@ -53,7 +64,13 @@ func (s *Service) Issue(ctx context.Context, sslID, domainID int64, domain, webr return s.repo.MarkActive(ctx, sslID, issuerName, string(res.Certificate), string(res.PrivateKey), string(res.IssuerCertificate), time.Now(), expiresAt) } -func (s *Service) IssueForSite(ctx context.Context, siteID int64) error { +func (s *Service) IssueForSite(ctx context.Context, siteID int64, email string) error { + if email != "" { + if err := s.sites.UpdateSSLEmail(ctx, siteID, email); err != nil { + return err + } + } + domainID, err := s.repo.GetDomainIDBySite(ctx, siteID) if err != nil { return err @@ -64,6 +81,11 @@ func (s *Service) IssueForSite(ctx context.Context, siteID int64) error { return err } + siteEmail, err := s.sites.GetSSLEmail(ctx, siteID) + if err != nil { + return err + } + cert, err := s.repo.GetByDomainID(ctx, domainID) if err != nil { return err @@ -80,7 +102,7 @@ func (s *Service) IssueForSite(ctx context.Context, siteID int64) error { } go func() { - if err := s.Issue(context.Background(), sslID, domainID, domain, webroot); err != nil { + if err := s.Issue(context.Background(), sslID, domainID, domain, webroot, siteEmail); err != nil { log.Printf("ssl reissue %s: %v", domain, err) } }() diff --git a/migrations/000004_site_ssl_email.down.sql b/migrations/000004_site_ssl_email.down.sql new file mode 100644 index 0000000..3b116e5 --- /dev/null +++ b/migrations/000004_site_ssl_email.down.sql @@ -0,0 +1 @@ +ALTER TABLE sites DROP COLUMN IF EXISTS ssl_email; diff --git a/migrations/000004_site_ssl_email.up.sql b/migrations/000004_site_ssl_email.up.sql new file mode 100644 index 0000000..d70d1ff --- /dev/null +++ b/migrations/000004_site_ssl_email.up.sql @@ -0,0 +1 @@ +ALTER TABLE sites ADD COLUMN IF NOT EXISTS ssl_email CITEXT;