feat: per-site SSL email and error display

This commit is contained in:
orohi
2026-06-17 06:05:34 +03:00
parent 02201fb4e3
commit f2c05d15ec
9 changed files with 151 additions and 59 deletions
+19 -6
View File
@@ -74,6 +74,10 @@ const baseCSS = `
.badge.ssl-pending { background: #713f12; color: #fde68a; } .badge.ssl-pending { background: #713f12; color: #fde68a; }
.badge.ssl-active { background: #14532d; color: #bbf7d0; } .badge.ssl-active { background: #14532d; color: #bbf7d0; }
.badge.ssl-error { background: #7f1d1d; color: #fecaca; } .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 = `<!DOCTYPE html> const setupPageHTML = `<!DOCTYPE html>
@@ -125,7 +129,8 @@ func dashboardPageHTML(username string) string {
<div><label>Имя сайта</label><input id="name" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="mysite"></div> <div><label>Имя сайта</label><input id="name" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="mysite"></div>
<div><label>Домен</label><input id="domain" required placeholder="example.com"></div> <div><label>Домен</label><input id="domain" required placeholder="example.com"></div>
</div><label>PHP версия</label><select id="php_version" required></select> </div><label>PHP версия</label><select id="php_version" required></select>
<label class="check"><input type="checkbox" id="issue_ssl" checked> Выпустить SSL (Let's Encrypt)</label> <div id="sslBlock"><label>Email для SSL</label><input id="ssl_email" type="email" placeholder="admin@example.com">
<label class="check"><input type="checkbox" id="issue_ssl" checked> Выпустить SSL (Let's Encrypt)</label></div>
<button type="submit" id="createBtn">Создать сайт</button></form> <button type="submit" id="createBtn">Создать сайт</button></form>
<div id="formMsg" class="msg"></div></div> <div id="formMsg" class="msg"></div></div>
<div class="card"><h2>Сайты</h2> <div class="card"><h2>Сайты</h2>
@@ -133,31 +138,39 @@ func dashboardPageHTML(username string) string {
<tbody id="sites"></tbody></table></div> <tbody id="sites"></tbody></table></div>
</div> </div>
<script> <script>
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');}
async function loadPHP(){const fallback=['8.1','8.2','8.3','8.4'];let versions=fallback;try{const r=await fetch('/api/v1/php-versions');const d=await r.json();if(r.ok&&d.versions&&d.versions.length)versions=d.versions;}catch(e){}const s=document.getElementById('php_version');s.innerHTML='';versions.forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent='PHP '+v;s.appendChild(o);});if(versions.length)s.value=versions[versions.length-1];} async function loadPHP(){const fallback=['8.1','8.2','8.3','8.4'];let versions=fallback;try{const r=await fetch('/api/v1/php-versions');const d=await r.json();if(r.ok&&d.versions&&d.versions.length)versions=d.versions;}catch(e){}const s=document.getElementById('php_version');s.innerHTML='';versions.forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent='PHP '+v;s.appendChild(o);});if(versions.length)s.value=versions[versions.length-1];}
async function loadSites(){const r=await fetch('/api/v1/sites');const d=await r.json();const tb=document.getElementById('sites');tb.innerHTML=''; async function loadSites(){const r=await fetch('/api/v1/sites');const d=await r.json();const tb=document.getElementById('sites');tb.innerHTML='';
(d.sites||[]).forEach(site=>{const tr=document.createElement('tr');const ssl=site.ssl_status||'none'; (d.sites||[]).forEach(site=>{const tr=document.createElement('tr');const ssl=site.ssl_status||'none';
const sslCls=ssl==='active'?'ssl-active':(ssl==='error'?'ssl-error':(ssl==='pending'?'ssl-pending':'ssl-none')); const sslCls=ssl==='active'?'ssl-active':(ssl==='error'?'ssl-error':(ssl==='pending'?'ssl-pending':'ssl-none'));
const needSSL=ssl!=='active'; const needSSL=ssl!=='active';
const actions=needSSL?'<button class="secondary btn-ssl" type="button">Выпустить SSL</button>':''; const errHtml=site.ssl_error?'<div class="ssl-err-text">'+esc(site.ssl_error)+'</div>':'';
tr.innerHTML='<td>'+site.name+'</td><td>'+site.primary_domain+'</td><td>'+(site.php_version||'-')+'</td><td><span class="badge '+sslCls+'">'+ssl+'</span></td><td><span class="badge">'+site.status+'</span></td><td><code>'+site.document_root+'</code></td><td>'+actions+'</td>'; const emailVal=esc(site.ssl_email||'');
if(needSSL){const btn=tr.querySelector('.btn-ssl');btn.onclick=async()=>{btn.disabled=true;btn.textContent='Выпускается...';const res=await fetch('/api/v1/sites/'+site.id+'/ssl/issue',{method:'POST'});if(!res.ok){const err=await res.json().catch(()=>({}));btn.textContent=err.error||'Ошибка';btn.disabled=false;return;}setTimeout(loadSites,1500);};} const actions=needSSL?'<input type="email" class="email-sm ssl-email" placeholder="email@..." value="'+emailVal+'"><button class="secondary btn-ssl" type="button">Выпустить SSL</button>':'';
tr.innerHTML='<td>'+esc(site.name)+'</td><td>'+esc(site.primary_domain)+'</td><td>'+(site.php_version||'-')+'</td><td class="ssl-cell"><span class="badge '+sslCls+'">'+ssl+'</span>'+errHtml+'</td><td><span class="badge">'+site.status+'</span></td><td><code>'+esc(site.document_root)+'</code></td><td class="actions-cell">'+actions+'</td>';
if(needSSL){const btn=tr.querySelector('.btn-ssl');const emailIn=tr.querySelector('.ssl-email');btn.onclick=async()=>{const em=emailIn.value.trim();if(!em){alert('Укажите email для SSL');return;}btn.disabled=true;btn.textContent='Выпускается...';const res=await fetch('/api/v1/sites/'+site.id+'/ssl/issue',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssl_email:em})});if(!res.ok){const err=await res.json().catch(()=>({}));alert(err.error||'Ошибка');btn.textContent='Выпустить SSL';btn.disabled=false;return;}setTimeout(loadSites,1500);};}
tb.appendChild(tr);});} tb.appendChild(tr);});}
document.getElementById('issue_ssl').onchange=()=>{document.getElementById('ssl_email').required=document.getElementById('issue_ssl').checked;};
document.getElementById('ssl_email').required=true;
document.getElementById('logout').onclick=async()=>{await fetch('/api/v1/auth/logout',{method:'POST'});location.reload();}; document.getElementById('logout').onclick=async()=>{await fetch('/api/v1/auth/logout',{method:'POST'});location.reload();};
document.getElementById('siteForm').onsubmit=async e=>{ document.getElementById('siteForm').onsubmit=async e=>{
e.preventDefault(); e.preventDefault();
const btn=document.getElementById('createBtn'),msg=document.getElementById('formMsg'); const btn=document.getElementById('createBtn'),msg=document.getElementById('formMsg');
const siteName=document.getElementById('name'),siteDomain=document.getElementById('domain'),phpSelect=document.getElementById('php_version'); const siteName=document.getElementById('name'),siteDomain=document.getElementById('domain'),phpSelect=document.getElementById('php_version');
const issueSSL=document.getElementById('issue_ssl').checked,sslEmail=document.getElementById('ssl_email').value.trim();
if(issueSSL&&!sslEmail){msg.className='msg error';msg.textContent='Укажите email для SSL';return;}
btn.disabled=true;msg.className='msg';msg.textContent=''; btn.disabled=true;msg.className='msg';msg.textContent='';
try{ try{
const res=await fetch('/api/v1/sites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:siteName.value.trim().toLowerCase(),domain:siteDomain.value.trim().toLowerCase(),php_version:phpSelect.value,issue_ssl:document.getElementById('issue_ssl').checked})}); const res=await fetch('/api/v1/sites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:siteName.value.trim().toLowerCase(),domain:siteDomain.value.trim().toLowerCase(),php_version:phpSelect.value,issue_ssl:issueSSL,ssl_email:sslEmail})});
const d=await res.json().catch(()=>({})); const d=await res.json().catch(()=>({}));
if(!res.ok){msg.className='msg error';msg.textContent=d.error||'Ошибка';btn.disabled=false;return;} if(!res.ok){msg.className='msg error';msg.textContent=d.error||'Ошибка';btn.disabled=false;return;}
msg.className='msg ok';msg.textContent='Сайт создан'; msg.className='msg ok';msg.textContent='Сайт создан';
document.getElementById('siteForm').reset(); document.getElementById('siteForm').reset();document.getElementById('issue_ssl').checked=true;document.getElementById('ssl_email').required=true;
await loadPHP();loadSites(); await loadPHP();loadSites();
}catch(err){msg.className='msg error';msg.textContent='Сетевая ошибка';} }catch(err){msg.className='msg error';msg.textContent='Сетевая ошибка';}
btn.disabled=false; btn.disabled=false;
}; };
loadPHP();loadSites(); loadPHP();loadSites();
setInterval(()=>{if(document.querySelector('.badge.ssl-pending'))loadSites();},5000);
</script></body></html>`, baseCSS, username) </script></body></html>`, baseCSS, username)
} }
+20 -2
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/panelhosting/panel/internal/auth"
"github.com/panelhosting/panel/internal/middleware" "github.com/panelhosting/panel/internal/middleware"
"github.com/panelhosting/panel/internal/sitesvc" "github.com/panelhosting/panel/internal/sitesvc"
) )
@@ -22,10 +23,15 @@ type createSiteRequest struct {
Name string `json:"name"` Name string `json:"name"`
Domain string `json:"domain"` Domain string `json:"domain"`
PHPVersion string `json:"php_version"` PHPVersion string `json:"php_version"`
SSLEmail string `json:"ssl_email"`
ServerID int64 `json:"server_id,omitempty"` ServerID int64 `json:"server_id,omitempty"`
IssueSSL *bool `json:"issue_ssl"` IssueSSL *bool `json:"issue_ssl"`
} }
type reissueSSLRequest struct {
SSLEmail string `json:"ssl_email"`
}
func (h *SiteHandler) List(w http.ResponseWriter, r *http.Request) { func (h *SiteHandler) List(w http.ResponseWriter, r *http.Request) {
user, ok := middleware.UserFromContext(r.Context()) user, ok := middleware.UserFromContext(r.Context())
if !ok { if !ok {
@@ -63,6 +69,7 @@ func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) {
Name: req.Name, Name: req.Name,
Domain: req.Domain, Domain: req.Domain,
PHPVersion: req.PHPVersion, PHPVersion: req.PHPVersion,
SSLEmail: req.SSLEmail,
ServerID: req.ServerID, ServerID: req.ServerID,
OwnerID: user.ID, OwnerID: user.ID,
IsAdmin: middleware.IsAdmin(user), IsAdmin: middleware.IsAdmin(user),
@@ -72,7 +79,9 @@ func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) {
switch { switch {
case errors.Is(err, sitesvc.ErrInvalidSiteName), case errors.Is(err, sitesvc.ErrInvalidSiteName),
errors.Is(err, sitesvc.ErrInvalidDomain), 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()) writeError(w, http.StatusBadRequest, err.Error())
default: default:
writeError(w, http.StatusInternalServerError, "failed to create site: "+err.Error()) 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 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"}) writeJSON(w, http.StatusOK, map[string]string{"message": "ssl issuance started"})
} }
+26 -7
View File
@@ -26,9 +26,10 @@ type SiteWithDomain struct {
SSLID int64 `json:"ssl_id,omitempty"` SSLID int64 `json:"ssl_id,omitempty"`
SSLStatus string `json:"ssl_status,omitempty"` SSLStatus string `json:"ssl_status,omitempty"`
SSLError string `json:"ssl_error,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) tx, err := r.pool.Begin(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -36,8 +37,8 @@ func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, na
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
const siteQ = ` const siteQ = `
INSERT INTO sites (server_id, owner_id, name, document_root, php_version, status) INSERT INTO sites (server_id, owner_id, name, document_root, php_version, ssl_email, status)
VALUES ($1, $2, $3, $4, $5, 'active') VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), 'active')
RETURNING id, uuid, server_id, owner_id, name, document_root, php_version, status, RETURNING id, uuid, server_id, owner_id, name, document_root, php_version, status,
settings, disk_quota_mb, created_at, updated_at 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 != "" { if phpVersion != "" {
phpVer = &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.ID, &s.UUID, &s.ServerID, &s.OwnerID, &s.Name, &s.DocumentRoot, &s.PHPVersion, &s.Status,
&s.Settings, &s.DiskQuotaMB, &s.CreatedAt, &s.UpdatedAt, &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) 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 { if issueSSL {
const sslQ = ` const sslQ = `
@@ -110,7 +111,8 @@ const siteListQuery = `
COALESCE(d.domain::text, ''), COALESCE(d.domain::text, ''),
COALESCE(d.id, 0), COALESCE(d.id, 0),
COALESCE(ssl.status::text, 'none'), COALESCE(ssl.status::text, 'none'),
COALESCE(ssl.error_message, '') COALESCE(ssl.error_message, ''),
COALESCE(s.ssl_email::text, '')
FROM sites s FROM sites s
LEFT JOIN domains d ON d.site_id = s.id AND d.is_primary = true LEFT JOIN domains d ON d.site_id = s.id AND d.is_primary = true
LEFT JOIN LATERAL ( 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.ID, &item.UUID, &item.ServerID, &item.OwnerID, &item.Name, &item.DocumentRoot,
&item.PHPVersion, &item.Status, &item.Settings, &item.DiskQuotaMB, &item.PHPVersion, &item.Status, &item.Settings, &item.DiskQuotaMB,
&item.CreatedAt, &item.UpdatedAt, &item.PrimaryDomain, &item.CreatedAt, &item.UpdatedAt, &item.PrimaryDomain,
&item.DomainID, &item.SSLStatus, &item.SSLError, &item.DomainID, &item.SSLStatus, &item.SSLError, &item.SSLEmail,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -137,6 +139,23 @@ func scanSiteList(rows pgx.Rows) ([]SiteWithDomain, error) {
return list, rows.Err() 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 { type PHPVersionRepository struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
+3 -3
View File
@@ -33,15 +33,15 @@ 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.ACMEEmail, cfg.ACMEStaging, cfg.SSLStoragePath, challengeStore) issuer := ssl.NewIssuer(cfg.ACMEStaging, cfg.SSLStoragePath, challengeStore)
sslService := sslsvc.NewService(sslRepo, issuer, cfg) sslService := sslsvc.NewService(sslRepo, sites, issuer, 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)
siteSvc := sitesvc.NewService(sites, servers, phpVers, sslService) siteSvc := sitesvc.NewService(sites, servers, phpVers, sslService)
if cfg.ACMEEmail == "" { 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) setupHandler := handler.NewSetupHandler(setupSvc)
+19 -6
View File
@@ -7,6 +7,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/panelhosting/panel/internal/auth"
"github.com/panelhosting/panel/internal/models" "github.com/panelhosting/panel/internal/models"
"github.com/panelhosting/panel/internal/repository" "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") ErrInvalidSiteName = errors.New("site name must be 3-64 chars: lowercase letters, digits, hyphen")
ErrInvalidDomain = errors.New("invalid domain") ErrInvalidDomain = errors.New("invalid domain")
ErrInvalidPHPVersion = errors.New("unsupported php version") ErrInvalidPHPVersion = errors.New("unsupported php version")
ErrInvalidSSLEmail = errors.New("invalid ssl email")
ErrSSLEmailRequired = errors.New("ssl email is required when issuing certificate")
) )
var ( var (
@@ -23,8 +26,8 @@ var (
) )
type SSLIssuer interface { type SSLIssuer interface {
IssueAsync(sslID, domainID int64, domain, webroot string) IssueAsync(sslID, domainID int64, domain, webroot, email string)
IssueForSite(ctx context.Context, siteID int64) error IssueForSite(ctx context.Context, siteID int64, email string) error
} }
type Service struct { type Service struct {
@@ -42,6 +45,7 @@ type CreateInput struct {
Name string Name string
Domain string Domain string
PHPVersion string PHPVersion string
SSLEmail string
ServerID int64 ServerID int64
OwnerID int64 OwnerID int64
IsAdmin bool IsAdmin bool
@@ -60,6 +64,7 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (*repository.SiteW
name := strings.ToLower(strings.TrimSpace(in.Name)) name := strings.ToLower(strings.TrimSpace(in.Name))
domain := strings.ToLower(strings.TrimSpace(in.Domain)) domain := strings.ToLower(strings.TrimSpace(in.Domain))
phpVersion := strings.TrimSpace(in.PHPVersion) phpVersion := strings.TrimSpace(in.PHPVersion)
sslEmail := strings.TrimSpace(in.SSLEmail)
if !siteNameRegex.MatchString(name) { if !siteNameRegex.MatchString(name) {
return nil, ErrInvalidSiteName return nil, ErrInvalidSiteName
@@ -70,6 +75,14 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (*repository.SiteW
if phpVersion == "" { if phpVersion == "" {
return nil, ErrInvalidPHPVersion 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) active, err := s.phpVers.IsActive(ctx, phpVersion)
if err != nil { 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) 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 { if err != nil {
return nil, err return nil, err
} }
if in.IssueSSL && s.ssl != nil && site.SSLID > 0 { 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 return site, nil
} }
func (s *Service) ReissueSSLAsync(siteID int64) { func (s *Service) ReissueSSLAsync(siteID int64, email string) {
if s.ssl == nil { if s.ssl == nil {
return return
} }
go func() { go func() {
_ = s.ssl.IssueForSite(context.Background(), siteID) _ = s.ssl.IssueForSite(context.Background(), siteID, email)
}() }()
} }
+25 -20
View File
@@ -6,7 +6,9 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/hex"
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
@@ -54,29 +56,29 @@ func (p *webrootProvider) CleanUp(domain, token, keyAuth string) error {
} }
type Issuer struct { type Issuer struct {
email string
staging bool staging bool
storageDir string storageDir string
store *ChallengeStore store *ChallengeStore
mu sync.Mutex mu sync.Mutex
client *lego.Client 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{ return &Issuer{
email: email,
staging: staging, staging: staging,
storageDir: storageDir, storageDir: storageDir,
store: store, store: store,
clients: make(map[string]*lego.Client),
} }
} }
func (i *Issuer) Obtain(ctx context.Context, domain, webroot string) (*certificate.Resource, error) { func (i *Issuer) Obtain(ctx context.Context, email, domain, webroot string) (*certificate.Resource, error) {
if i.email == "" { email = strings.TrimSpace(strings.ToLower(email))
return nil, errors.New("ACME_EMAIL is not configured") if email == "" {
return nil, errors.New("ssl email is not configured")
} }
client, err := i.getClient() client, err := i.getClient(email)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -103,15 +105,15 @@ func (i *Issuer) Obtain(ctx context.Context, domain, webroot string) (*certifica
return res, nil return res, nil
} }
func (i *Issuer) getClient() (*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()
if i.client != nil { if client, ok := i.clients[email]; ok {
return i.client, nil return client, nil
} }
user, err := i.loadOrCreateUser() user, err := i.loadOrCreateUser(email)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -140,18 +142,21 @@ func (i *Issuer) getClient() (*lego.Client, error) {
} }
} }
i.client = client i.clients[email] = client
return client, nil return client, nil
} }
func (i *Issuer) accountPath() string { func (i *Issuer) accountPath(email string) string {
return filepath.Join(i.storageDir, "acme", "account.pem") 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) { func (i *Issuer) loadOrCreateUser(email string) (*ACMEUser, error) {
user := &ACMEUser{Email: i.email} 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) block, _ := pem.Decode(data)
if block != nil { if block != nil {
key, err := x509.ParseECPrivateKey(block.Bytes) key, err := x509.ParseECPrivateKey(block.Bytes)
@@ -168,7 +173,7 @@ func (i *Issuer) loadOrCreateUser() (*ACMEUser, error) {
} }
user.key = key 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 return nil, err
} }
if err := i.saveAccount(user); err != nil { if err := i.saveAccount(user); err != nil {
@@ -183,7 +188,7 @@ func (i *Issuer) saveAccount(user *ACMEUser) error {
return err return err
} }
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) 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 { func (i *Issuer) saveToDisk(domain string, res *certificate.Resource) error {
+32 -10
View File
@@ -3,6 +3,7 @@ package sslsvc
import ( import (
"context" "context"
"log" "log"
"strings"
"time" "time"
"github.com/panelhosting/panel/internal/config" "github.com/panelhosting/panel/internal/config"
@@ -12,29 +13,39 @@ import (
type Service struct { type Service struct {
repo *repository.SSLRepository repo *repository.SSLRepository
sites *repository.SiteRepository
issuer *ssl.Issuer issuer *ssl.Issuer
cfg *config.Config cfg *config.Config
} }
func NewService(repo *repository.SSLRepository, issuer *ssl.Issuer, cfg *config.Config) *Service { func NewService(repo *repository.SSLRepository, sites *repository.SiteRepository, issuer *ssl.Issuer, cfg *config.Config) *Service {
return &Service{repo: repo, issuer: issuer, cfg: cfg} 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() { go func() {
ctx := context.Background() 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) log.Printf("ssl issue %s: %v", domain, err)
} }
}() }()
} }
func (s *Service) Issue(ctx context.Context, sslID, domainID int64, domain, webroot string) error { func (s *Service) resolveEmail(email string) string {
if s.cfg.ACMEEmail == "" { email = strings.TrimSpace(strings.ToLower(email))
return s.repo.MarkError(ctx, sslID, "ACME_EMAIL not configured") 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 { if err != nil {
_ = s.repo.MarkError(ctx, sslID, err.Error()) _ = s.repo.MarkError(ctx, sslID, err.Error())
return err 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) 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) domainID, err := s.repo.GetDomainIDBySite(ctx, siteID)
if err != nil { if err != nil {
return err return err
@@ -64,6 +81,11 @@ func (s *Service) IssueForSite(ctx context.Context, siteID int64) error {
return err return err
} }
siteEmail, err := s.sites.GetSSLEmail(ctx, siteID)
if err != nil {
return err
}
cert, err := s.repo.GetByDomainID(ctx, domainID) cert, err := s.repo.GetByDomainID(ctx, domainID)
if err != nil { if err != nil {
return err return err
@@ -80,7 +102,7 @@ func (s *Service) IssueForSite(ctx context.Context, siteID int64) error {
} }
go func() { 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) log.Printf("ssl reissue %s: %v", domain, err)
} }
}() }()
@@ -0,0 +1 @@
ALTER TABLE sites DROP COLUMN IF EXISTS ssl_email;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE sites ADD COLUMN IF NOT EXISTS ssl_email CITEXT;