feat: Let's Encrypt SSL on site creation
This commit is contained in:
@@ -3,16 +3,20 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
HTTPPort string
|
||||
DefaultServerName string
|
||||
DefaultServerHost string
|
||||
DefaultServerIP string
|
||||
SessionCookieName string
|
||||
SessionTTLHours int
|
||||
DatabaseURL string
|
||||
HTTPPort string
|
||||
DefaultServerName string
|
||||
DefaultServerHost string
|
||||
DefaultServerIP string
|
||||
SessionCookieName string
|
||||
SessionTTLHours int
|
||||
ACMEEmail string
|
||||
ACMEStaging bool
|
||||
SSLStoragePath string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -46,6 +50,11 @@ func Load() (*Config, error) {
|
||||
cookieName = "panel_session"
|
||||
}
|
||||
|
||||
sslPath := os.Getenv("SSL_STORAGE_PATH")
|
||||
if sslPath == "" {
|
||||
sslPath = "/etc/ssl/panel"
|
||||
}
|
||||
|
||||
return &Config{
|
||||
DatabaseURL: url,
|
||||
HTTPPort: port,
|
||||
@@ -53,6 +62,9 @@ func Load() (*Config, error) {
|
||||
DefaultServerHost: serverHost,
|
||||
DefaultServerIP: serverIP,
|
||||
SessionCookieName: cookieName,
|
||||
SessionTTLHours: 168, // 7 days
|
||||
SessionTTLHours: 168,
|
||||
ACMEEmail: strings.TrimSpace(os.Getenv("ACME_EMAIL")),
|
||||
ACMEStaging: os.Getenv("ACME_STAGING") == "true" || os.Getenv("ACME_STAGING") == "1",
|
||||
SSLStoragePath: sslPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/panelhosting/panel/internal/ssl"
|
||||
)
|
||||
|
||||
type ACMEHandler struct {
|
||||
store *ssl.ChallengeStore
|
||||
}
|
||||
|
||||
func NewACMEHandler(store *ssl.ChallengeStore) *ACMEHandler {
|
||||
return &ACMEHandler{store: store}
|
||||
}
|
||||
|
||||
func (h *ACMEHandler) Challenge(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
if token == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
keyAuth, ok := h.store.Get(token)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte(keyAuth))
|
||||
}
|
||||
@@ -68,6 +68,11 @@ const baseCSS = `
|
||||
.badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; background: #14532d; color: #bbf7d0; }
|
||||
.center-card { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; }
|
||||
.center-card .card { width: 100%; max-width: 420px; }
|
||||
label.check { display: flex; align-items: center; gap: .5rem; margin-bottom: 1rem; font-size: .9rem; color: #cbd5e1; }
|
||||
label.check input { width: auto; margin: 0; }
|
||||
.badge.ssl-pending { background: #713f12; color: #fde68a; }
|
||||
.badge.ssl-active { background: #14532d; color: #bbf7d0; }
|
||||
.badge.ssl-error { background: #7f1d1d; color: #fecaca; }
|
||||
`
|
||||
|
||||
const setupPageHTML = `<!DOCTYPE html>
|
||||
@@ -119,17 +124,20 @@ 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>
|
||||
<button type="submit" id="createBtn">Создать сайт</button></form>
|
||||
<div id="formMsg" class="msg"></div></div>
|
||||
<div class="card"><h2>Сайты</h2>
|
||||
<table><thead><tr><th>Имя</th><th>Домен</th><th>PHP</th><th>Статус</th><th>Путь</th></tr></thead>
|
||||
<table><thead><tr><th>Имя</th><th>Домен</th><th>PHP</th><th>SSL</th><th>Статус</th><th>Путь</th><th></th></tr></thead>
|
||||
<tbody id="sites"></tbody></table></div>
|
||||
</div>
|
||||
<script>
|
||||
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');
|
||||
tr.innerHTML='<td>'+site.name+'</td><td>'+site.primary_domain+'</td><td>'+(site.php_version||'-')+'</td><td><span class="badge">'+site.status+'</span></td><td><code>'+site.document_root+'</code></td>';
|
||||
(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');
|
||||
let actions='';if(ssl==='error'||ssl==='pending'){actions='<button class="secondary" data-id="'+site.id+'">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(actions){tr.querySelector('button').onclick=async()=>{await fetch('/api/v1/sites/'+site.id+'/ssl/issue',{method:'POST'});setTimeout(loadSites,2000);};}
|
||||
tb.appendChild(tr);});}
|
||||
document.getElementById('logout').onclick=async()=>{await fetch('/api/v1/auth/logout',{method:'POST'});location.reload();};
|
||||
document.getElementById('siteForm').onsubmit=async e=>{
|
||||
@@ -138,7 +146,7 @@ const btn=document.getElementById('createBtn'),msg=document.getElementById('form
|
||||
const siteName=document.getElementById('name'),siteDomain=document.getElementById('domain'),phpSelect=document.getElementById('php_version');
|
||||
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})});
|
||||
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 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='Сайт создан';
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/panelhosting/panel/internal/middleware"
|
||||
"github.com/panelhosting/panel/internal/sitesvc"
|
||||
@@ -22,6 +23,7 @@ type createSiteRequest struct {
|
||||
Domain string `json:"domain"`
|
||||
PHPVersion string `json:"php_version"`
|
||||
ServerID int64 `json:"server_id,omitempty"`
|
||||
IssueSSL *bool `json:"issue_ssl"`
|
||||
}
|
||||
|
||||
func (h *SiteHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -52,6 +54,11 @@ func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
issueSSL := true
|
||||
if req.IssueSSL != nil {
|
||||
issueSSL = *req.IssueSSL
|
||||
}
|
||||
|
||||
site, err := h.svc.Create(r.Context(), sitesvc.CreateInput{
|
||||
Name: req.Name,
|
||||
Domain: req.Domain,
|
||||
@@ -59,6 +66,7 @@ func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
ServerID: req.ServerID,
|
||||
OwnerID: user.ID,
|
||||
IsAdmin: middleware.IsAdmin(user),
|
||||
IssueSSL: issueSSL,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
@@ -83,3 +91,20 @@ func (h *SiteHandler) PHPVersions(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"versions": versions})
|
||||
}
|
||||
|
||||
func (h *SiteHandler) ReissueSSL(w http.ResponseWriter, r *http.Request) {
|
||||
_, ok := middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
siteID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid site id")
|
||||
return
|
||||
}
|
||||
|
||||
h.svc.ReissueSSLAsync(siteID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "ssl issuance started"})
|
||||
}
|
||||
|
||||
@@ -22,9 +22,13 @@ func NewSiteRepository(pool *pgxpool.Pool) *SiteRepository {
|
||||
type SiteWithDomain struct {
|
||||
models.Site
|
||||
PrimaryDomain string `json:"primary_domain"`
|
||||
DomainID int64 `json:"domain_id,omitempty"`
|
||||
SSLID int64 `json:"ssl_id,omitempty"`
|
||||
SSLStatus string `json:"ssl_status,omitempty"`
|
||||
SSLError string `json:"ssl_error,omitempty"`
|
||||
}
|
||||
|
||||
func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, name, documentRoot, phpVersion, domain string) (*SiteWithDomain, error) {
|
||||
func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, name, documentRoot, phpVersion, domain string, issueSSL bool) (*SiteWithDomain, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,17 +56,33 @@ func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, na
|
||||
|
||||
const domainQ = `
|
||||
INSERT INTO domains (site_id, domain, is_primary, ssl_enabled)
|
||||
VALUES ($1, $2, true, true)
|
||||
VALUES ($1, $2, true, $3)
|
||||
RETURNING id
|
||||
`
|
||||
if _, err = tx.Exec(ctx, domainQ, s.ID, domain); err != nil {
|
||||
var domainID int64
|
||||
if err = tx.QueryRow(ctx, domainQ, s.ID, domain, issueSSL).Scan(&domainID); err != nil {
|
||||
return nil, fmt.Errorf("create domain: %w", err)
|
||||
}
|
||||
|
||||
result := &SiteWithDomain{Site: s, PrimaryDomain: domain, DomainID: domainID}
|
||||
|
||||
if issueSSL {
|
||||
const sslQ = `
|
||||
INSERT INTO ssl_certificates (domain_id, type, status, auto_renew)
|
||||
VALUES ($1, 'letsencrypt', 'pending', true)
|
||||
RETURNING id
|
||||
`
|
||||
if err = tx.QueryRow(ctx, sslQ, domainID).Scan(&result.SSLID); err != nil {
|
||||
return nil, fmt.Errorf("create ssl cert: %w", err)
|
||||
}
|
||||
result.SSLStatus = "pending"
|
||||
}
|
||||
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SiteWithDomain{Site: s, PrimaryDomain: domain}, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *SiteRepository) ListForUser(ctx context.Context, userID int64, isAdmin bool) ([]SiteWithDomain, error) {
|
||||
@@ -87,9 +107,16 @@ func (r *SiteRepository) ListForUser(ctx context.Context, userID int64, isAdmin
|
||||
const siteListQuery = `
|
||||
SELECT s.id, s.uuid, s.server_id, s.owner_id, s.name, s.document_root, s.php_version, s.status,
|
||||
s.settings, s.disk_quota_mb, s.created_at, s.updated_at,
|
||||
COALESCE(d.domain::text, '')
|
||||
COALESCE(d.domain::text, ''),
|
||||
COALESCE(d.id, 0),
|
||||
COALESCE(ssl.status::text, 'none'),
|
||||
COALESCE(ssl.error_message, '')
|
||||
FROM sites s
|
||||
LEFT JOIN domains d ON d.site_id = s.id AND d.is_primary = true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT status, error_message FROM ssl_certificates
|
||||
WHERE domain_id = d.id ORDER BY id DESC LIMIT 1
|
||||
) ssl ON true
|
||||
`
|
||||
|
||||
func scanSiteList(rows pgx.Rows) ([]SiteWithDomain, error) {
|
||||
@@ -100,6 +127,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,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type SSLCertificate struct {
|
||||
ID int64 `json:"id"`
|
||||
DomainID int64 `json:"domain_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Issuer *string `json:"issuer,omitempty"`
|
||||
ErrorMessage *string `json:"error_message,omitempty"`
|
||||
IssuedAt *time.Time `json:"issued_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
}
|
||||
|
||||
type SSLRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewSSLRepository(pool *pgxpool.Pool) *SSLRepository {
|
||||
return &SSLRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *SSLRepository) CreatePending(ctx context.Context, domainID int64) (int64, error) {
|
||||
const q = `
|
||||
INSERT INTO ssl_certificates (domain_id, type, status, auto_renew)
|
||||
VALUES ($1, 'letsencrypt', 'pending', true)
|
||||
RETURNING id
|
||||
`
|
||||
var id int64
|
||||
err := r.pool.QueryRow(ctx, q, domainID).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create ssl cert: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *SSLRepository) MarkActive(ctx context.Context, id int64, issuer, certPEM, keyPEM, chainPEM string, issuedAt, expiresAt time.Time) error {
|
||||
const q = `
|
||||
UPDATE ssl_certificates
|
||||
SET status = 'active', issuer = $2, cert_pem = $3, key_pem = $4, chain_pem = $5,
|
||||
issued_at = $6, expires_at = $7, error_message = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := r.pool.Exec(ctx, q, id, issuer, certPEM, keyPEM, chainPEM, issuedAt, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SSLRepository) MarkError(ctx context.Context, id int64, msg string) error {
|
||||
const q = `
|
||||
UPDATE ssl_certificates
|
||||
SET status = 'error', error_message = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := r.pool.Exec(ctx, q, id, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SSLRepository) GetByDomainID(ctx context.Context, domainID int64) (*SSLCertificate, error) {
|
||||
const q = `
|
||||
SELECT id, domain_id, type::text, status::text, issuer, error_message, issued_at, expires_at, auto_renew
|
||||
FROM ssl_certificates WHERE domain_id = $1 ORDER BY id DESC LIMIT 1
|
||||
`
|
||||
var c SSLCertificate
|
||||
err := r.pool.QueryRow(ctx, q, domainID).Scan(
|
||||
&c.ID, &c.DomainID, &c.Type, &c.Status, &c.Issuer, &c.ErrorMessage,
|
||||
&c.IssuedAt, &c.ExpiresAt, &c.AutoRenew,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (r *SSLRepository) GetDomainInfo(ctx context.Context, domainID int64) (domain string, webroot string, err error) {
|
||||
const q = `
|
||||
SELECT d.domain::text, s.document_root
|
||||
FROM domains d
|
||||
JOIN sites s ON s.id = d.site_id
|
||||
WHERE d.id = $1
|
||||
`
|
||||
err = r.pool.QueryRow(ctx, q, domainID).Scan(&domain, &webroot)
|
||||
return
|
||||
}
|
||||
|
||||
func (r *SSLRepository) GetSiteOwner(ctx context.Context, siteID int64) (int64, error) {
|
||||
var ownerID int64
|
||||
err := r.pool.QueryRow(ctx, `SELECT owner_id FROM sites WHERE id = $1`, siteID).Scan(&ownerID)
|
||||
return ownerID, err
|
||||
}
|
||||
|
||||
func (r *SSLRepository) GetDomainIDBySite(ctx context.Context, siteID int64) (int64, error) {
|
||||
var id int64
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id FROM domains WHERE site_id = $1 AND is_primary = true LIMIT 1
|
||||
`, siteID).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/panelhosting/panel/internal/repository"
|
||||
"github.com/panelhosting/panel/internal/setup"
|
||||
"github.com/panelhosting/panel/internal/sitesvc"
|
||||
"github.com/panelhosting/panel/internal/ssl"
|
||||
"github.com/panelhosting/panel/internal/sslsvc"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -28,14 +30,24 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
|
||||
sessions := repository.NewSessionRepository(pool)
|
||||
sites := repository.NewSiteRepository(pool)
|
||||
phpVers := repository.NewPHPVersionRepository(pool)
|
||||
sslRepo := repository.NewSSLRepository(pool)
|
||||
|
||||
challengeStore := ssl.NewChallengeStore()
|
||||
issuer := ssl.NewIssuer(cfg.ACMEEmail, cfg.ACMEStaging, cfg.SSLStoragePath, challengeStore)
|
||||
sslService := sslsvc.NewService(sslRepo, issuer, cfg)
|
||||
|
||||
setupSvc := setup.NewService(users, servers, cfg)
|
||||
authSvc := authsvc.NewService(users, sessions, cfg.SessionTTLHours)
|
||||
siteSvc := sitesvc.NewService(sites, servers, phpVers)
|
||||
siteSvc := sitesvc.NewService(sites, servers, phpVers, sslService)
|
||||
|
||||
if cfg.ACMEEmail == "" {
|
||||
log.Println("ssl: ACME_EMAIL not set, let's encrypt issuance disabled")
|
||||
}
|
||||
|
||||
setupHandler := handler.NewSetupHandler(setupSvc)
|
||||
authHandler := handler.NewAuthHandler(authSvc, cfg)
|
||||
siteHandler := handler.NewSiteHandler(siteSvc)
|
||||
acmeHandler := handler.NewACMEHandler(challengeStore)
|
||||
authMW := middleware.NewAuth(authSvc, cfg)
|
||||
|
||||
setupRequired, err := setupSvc.Status(context.Background())
|
||||
@@ -52,6 +64,7 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", handler.Health)
|
||||
mux.HandleFunc("GET /.well-known/acme-challenge/{token}", acmeHandler.Challenge)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/setup/status", setupHandler.Status)
|
||||
mux.HandleFunc("POST /api/v1/setup/register", setupHandler.Register)
|
||||
@@ -63,6 +76,7 @@ func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
|
||||
mux.HandleFunc("GET /api/v1/php-versions", siteHandler.PHPVersions)
|
||||
mux.HandleFunc("GET /api/v1/sites", authMW.Require(siteHandler.List))
|
||||
mux.HandleFunc("POST /api/v1/sites", authMW.Require(siteHandler.Create))
|
||||
mux.HandleFunc("POST /api/v1/sites/{id}/ssl/issue", authMW.Require(siteHandler.ReissueSSL))
|
||||
|
||||
mux.HandleFunc("GET /{$}", authMW.Optional(handler.IndexPage(setupSvc)))
|
||||
|
||||
|
||||
@@ -22,14 +22,20 @@ var (
|
||||
domainRegex = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
sites *repository.SiteRepository
|
||||
servers *repository.ServerRepository
|
||||
phpVers *repository.PHPVersionRepository
|
||||
type SSLIssuer interface {
|
||||
IssueAsync(sslID, domainID int64, domain, webroot string)
|
||||
IssueForSite(ctx context.Context, siteID int64) error
|
||||
}
|
||||
|
||||
func NewService(sites *repository.SiteRepository, servers *repository.ServerRepository, php *repository.PHPVersionRepository) *Service {
|
||||
return &Service{sites: sites, servers: servers, phpVers: php}
|
||||
type Service struct {
|
||||
sites *repository.SiteRepository
|
||||
servers *repository.ServerRepository
|
||||
phpVers *repository.PHPVersionRepository
|
||||
ssl SSLIssuer
|
||||
}
|
||||
|
||||
func NewService(sites *repository.SiteRepository, servers *repository.ServerRepository, php *repository.PHPVersionRepository, ssl SSLIssuer) *Service {
|
||||
return &Service{sites: sites, servers: servers, phpVers: php, ssl: ssl}
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
@@ -39,6 +45,7 @@ type CreateInput struct {
|
||||
ServerID int64
|
||||
OwnerID int64
|
||||
IsAdmin bool
|
||||
IssueSSL bool
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, user *models.User, isAdmin bool) ([]repository.SiteWithDomain, error) {
|
||||
@@ -86,5 +93,23 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (*repository.SiteW
|
||||
}
|
||||
|
||||
documentRoot := fmt.Sprintf("/var/www/%s/public", name)
|
||||
return s.sites.Create(ctx, serverID, in.OwnerID, name, documentRoot, phpVersion, domain)
|
||||
site, err := s.sites.Create(ctx, serverID, in.OwnerID, name, documentRoot, phpVersion, domain, 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)
|
||||
}
|
||||
|
||||
return site, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReissueSSLAsync(siteID int64) {
|
||||
if s.ssl == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
_ = s.ssl.IssueForSite(context.Background(), siteID)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package ssl
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ChallengeStore struct {
|
||||
mu sync.RWMutex
|
||||
tokens map[string]string
|
||||
}
|
||||
|
||||
func NewChallengeStore() *ChallengeStore {
|
||||
return &ChallengeStore{tokens: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (s *ChallengeStore) Set(token, keyAuth string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.tokens[token] = keyAuth
|
||||
}
|
||||
|
||||
func (s *ChallengeStore) Get(token string) (string, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
v, ok := s.tokens[token]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (s *ChallengeStore) Delete(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.tokens, token)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package ssl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
)
|
||||
|
||||
type ACMEUser struct {
|
||||
Email string
|
||||
Registration *registration.Resource
|
||||
key *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
type webrootProvider struct {
|
||||
webroot string
|
||||
store *ChallengeStore
|
||||
}
|
||||
|
||||
func (p *webrootProvider) Present(domain, token, keyAuth string) error {
|
||||
p.store.Set(token, keyAuth)
|
||||
dir := filepath.Join(p.webroot, ".well-known", "acme-challenge")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, token), []byte(keyAuth), 0o644)
|
||||
}
|
||||
|
||||
func (p *webrootProvider) CleanUp(domain, token, keyAuth string) error {
|
||||
p.store.Delete(token)
|
||||
_ = os.Remove(filepath.Join(p.webroot, ".well-known", "acme-challenge", token))
|
||||
return nil
|
||||
}
|
||||
|
||||
type Issuer struct {
|
||||
email string
|
||||
staging bool
|
||||
storageDir string
|
||||
store *ChallengeStore
|
||||
mu sync.Mutex
|
||||
client *lego.Client
|
||||
}
|
||||
|
||||
func NewIssuer(email string, staging bool, storageDir string, store *ChallengeStore) *Issuer {
|
||||
return &Issuer{
|
||||
email: email,
|
||||
staging: staging,
|
||||
storageDir: storageDir,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
client, err := i.getClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := &webrootProvider{webroot: webroot, store: i.store}
|
||||
if err := client.Challenge.SetHTTP01Provider(provider); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request := certificate.ObtainRequest{
|
||||
Domains: []string{domain},
|
||||
Bundle: true,
|
||||
}
|
||||
|
||||
res, err := client.Certificate.Obtain(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acme obtain: %w", err)
|
||||
}
|
||||
|
||||
if err := i.saveToDisk(domain, res); err != nil {
|
||||
log.Printf("ssl: save to disk: %v", err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (i *Issuer) getClient() (*lego.Client, error) {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
if i.client != nil {
|
||||
return i.client, nil
|
||||
}
|
||||
|
||||
user, err := i.loadOrCreateUser()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := lego.NewConfig(user)
|
||||
config.Certificate.KeyType = certcrypto.RSA2048
|
||||
if i.staging {
|
||||
config.CADirURL = lego.LEDirectoryStaging
|
||||
} else {
|
||||
config.CADirURL = lego.LEDirectoryProduction
|
||||
}
|
||||
|
||||
client, err := lego.NewClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user.Registration == nil {
|
||||
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acme register: %w", err)
|
||||
}
|
||||
user.Registration = reg
|
||||
if err := i.saveAccount(user); err != nil {
|
||||
log.Printf("ssl: save acme account: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
i.client = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (i *Issuer) accountPath() string {
|
||||
return filepath.Join(i.storageDir, "acme", "account.pem")
|
||||
}
|
||||
|
||||
func (i *Issuer) loadOrCreateUser() (*ACMEUser, error) {
|
||||
user := &ACMEUser{Email: i.email}
|
||||
|
||||
if data, err := os.ReadFile(i.accountPath()); err == nil {
|
||||
block, _ := pem.Decode(data)
|
||||
if block != nil {
|
||||
key, err := x509.ParseECPrivateKey(block.Bytes)
|
||||
if err == nil {
|
||||
user.key = key
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.key = key
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(i.accountPath()), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := i.saveAccount(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (i *Issuer) saveAccount(user *ACMEUser) error {
|
||||
der, err := x509.MarshalECPrivateKey(user.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
|
||||
return os.WriteFile(i.accountPath(), pemBytes, 0o600)
|
||||
}
|
||||
|
||||
func (i *Issuer) saveToDisk(domain string, res *certificate.Resource) error {
|
||||
dir := filepath.Join(i.storageDir, "live", sanitizeDomain(domain))
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "fullchain.pem"), res.Certificate, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "privkey.pem"), res.PrivateKey, 0o600)
|
||||
}
|
||||
|
||||
func (i *Issuer) Store() *ChallengeStore {
|
||||
return i.store
|
||||
}
|
||||
|
||||
func sanitizeDomain(domain string) string {
|
||||
return strings.ReplaceAll(domain, "..", "")
|
||||
}
|
||||
|
||||
func CertPaths(storageDir, domain string) (certPath, keyPath string) {
|
||||
dir := filepath.Join(storageDir, "live", sanitizeDomain(domain))
|
||||
return filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem")
|
||||
}
|
||||
|
||||
func ParseCertExpiry(certPEM []byte) (time.Time, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return time.Time{}, errors.New("invalid certificate pem")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return cert.NotAfter, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package sslsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/panelhosting/panel/internal/config"
|
||||
"github.com/panelhosting/panel/internal/repository"
|
||||
"github.com/panelhosting/panel/internal/ssl"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *repository.SSLRepository
|
||||
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 (s *Service) IssueAsync(sslID, domainID int64, domain, webroot string) {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
if err := s.Issue(ctx, sslID, domainID, domain, webroot); 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")
|
||||
}
|
||||
|
||||
res, err := s.issuer.Obtain(ctx, domain, webroot)
|
||||
if err != nil {
|
||||
_ = s.repo.MarkError(ctx, sslID, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
expiresAt, err := ssl.ParseCertExpiry(res.Certificate)
|
||||
if err != nil {
|
||||
expiresAt = time.Now().Add(90 * 24 * time.Hour)
|
||||
}
|
||||
|
||||
issuerName := "Let's Encrypt"
|
||||
if s.cfg.ACMEStaging {
|
||||
issuerName = "Let's Encrypt (staging)"
|
||||
}
|
||||
|
||||
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 {
|
||||
domainID, err := s.repo.GetDomainIDBySite(ctx, siteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
domain, webroot, err := s.repo.GetDomainInfo(ctx, domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cert, err := s.repo.GetByDomainID(ctx, domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sslID int64
|
||||
if cert == nil {
|
||||
sslID, err = s.repo.CreatePending(ctx, domainID)
|
||||
} else {
|
||||
sslID = cert.ID
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := s.Issue(context.Background(), sslID, domainID, domain, webroot); err != nil {
|
||||
log.Printf("ssl reissue %s: %v", domain, err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user