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-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,'&').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 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE sites ADD COLUMN IF NOT EXISTS ssl_email CITEXT;
|
||||||
Reference in New Issue
Block a user