feat: Let's Encrypt SSL on site creation

This commit is contained in:
orohi
2026-06-17 05:57:01 +03:00
parent 9b7eb99d20
commit 00d6789897
18 changed files with 703 additions and 51 deletions
+20 -8
View File
@@ -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
}
+32
View File
@@ -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))
}
+12 -4
View File
@@ -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='Сайт создан';
+25
View File
@@ -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"})
}
+33 -5
View File
@@ -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
+110
View File
@@ -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
}
+15 -1
View File
@@ -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)))
+32 -7
View File
@@ -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)
}()
}
+33
View File
@@ -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)
}
+223
View File
@@ -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
}
+88
View File
@@ -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
}