feat: per-site SSL email and error display
This commit is contained in:
@@ -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 = `<!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="domain" required placeholder="example.com"></div>
|
||||
</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>
|
||||
<div id="formMsg" class="msg"></div></div>
|
||||
<div class="card"><h2>Сайты</h2>
|
||||
@@ -133,31 +138,39 @@ func dashboardPageHTML(username string) string {
|
||||
<tbody id="sites"></tbody></table></div>
|
||||
</div>
|
||||
<script>
|
||||
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');}
|
||||
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='';
|
||||
(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 needSSL=ssl!=='active';
|
||||
const actions=needSSL?'<button class="secondary btn-ssl" type="button">Выпустить SSL</button>':'';
|
||||
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>';
|
||||
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 errHtml=site.ssl_error?'<div class="ssl-err-text">'+esc(site.ssl_error)+'</div>':'';
|
||||
const emailVal=esc(site.ssl_email||'');
|
||||
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);});}
|
||||
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('siteForm').onsubmit=async e=>{
|
||||
e.preventDefault();
|
||||
const btn=document.getElementById('createBtn'),msg=document.getElementById('formMsg');
|
||||
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='';
|
||||
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(()=>({}));
|
||||
if(!res.ok){msg.className='msg error';msg.textContent=d.error||'Ошибка';btn.disabled=false;return;}
|
||||
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();
|
||||
}catch(err){msg.className='msg error';msg.textContent='Сетевая ошибка';}
|
||||
btn.disabled=false;
|
||||
};
|
||||
loadPHP();loadSites();
|
||||
setInterval(()=>{if(document.querySelector('.badge.ssl-pending'))loadSites();},5000);
|
||||
</script></body></html>`, baseCSS, username)
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
|
||||
+30
-25
@@ -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 {
|
||||
|
||||
+32
-10
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
Reference in New Issue
Block a user