Автоматическое создание сертификатов

Добавил возможность создания автоматических сертификатов.
This commit is contained in:
2026-01-17 10:36:00 +07:00
parent 7169304212
commit 9a788800b5
11 changed files with 779 additions and 13 deletions

View File

@@ -0,0 +1,490 @@
package acme
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"vServer/Backend/config"
tools "vServer/Backend/tools"
"golang.org/x/crypto/acme"
)
var (
// DefaultManager глобальный менеджер ACME
DefaultManager *Manager
// Let's Encrypt URLs
LetsEncryptProduction = "https://acme-v02.api.letsencrypt.org/directory"
LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
)
// Init инициализирует ACME менеджер
func Init(production bool) error {
certDir := "WebServer/cert"
acmeDir := filepath.Join(certDir, ".acme")
// Создаём директорию для ACME данных
if err := os.MkdirAll(acmeDir, 0700); err != nil {
return fmt.Errorf("не удалось создать директорию ACME: %w", err)
}
DefaultManager = &Manager{
challenges: make(map[string]*ChallengeData),
certDir: certDir,
acmeDir: acmeDir,
isProduction: production,
}
// Загружаем или создаём account key
if err := DefaultManager.loadOrCreateAccountKey(); err != nil {
return fmt.Errorf("ошибка account key: %w", err)
}
mode := "STAGING"
if production {
mode = "PRODUCTION"
}
tools.Logs_file(0, "ACME", "✅ ACME менеджер инициализирован ("+mode+")", "logs_acme.log", true)
return nil
}
// CollectDomainsForSSL собирает все домены для получения SSL
func CollectDomainsForSSL() []string {
domains := make(map[string]bool)
// Из Site_www
for _, site := range config.ConfigData.Site_www {
if site.AutoCreateSSL && site.Status == "active" {
if isValidDomain(site.Host) {
domains[site.Host] = true
}
for _, alias := range site.Alias {
if isValidDomain(alias) && !strings.Contains(alias, "*") {
domains[alias] = true
}
}
}
}
// Из Proxy_Service
for _, proxy := range config.ConfigData.Proxy_Service {
if proxy.AutoCreateSSL && proxy.Enable {
if isValidDomain(proxy.ExternalDomain) {
domains[proxy.ExternalDomain] = true
}
}
}
// Конвертируем map в slice
result := make([]string, 0, len(domains))
for domain := range domains {
result = append(result, domain)
}
return result
}
// ObtainCertificate получает сертификат для домена (метод для кнопки в админке)
func ObtainCertificate(domain string) ObtainResult {
if DefaultManager == nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "ACME менеджер не инициализирован",
}
}
return DefaultManager.obtainCertificate(domain)
}
// ObtainAllCertificates получает сертификаты для всех доменов с AutoCreateSSL
func ObtainAllCertificates() []ObtainResult {
domains := CollectDomainsForSSL()
results := make([]ObtainResult, 0, len(domains))
for _, domain := range domains {
// Проверяем нужно ли получать сертификат
if !needsCertificate(domain) {
continue
}
result := ObtainCertificate(domain)
results = append(results, result)
// Пауза между запросами чтобы не превысить лимиты
time.Sleep(time.Second * 2)
}
return results
}
// CheckAndRenewCertificates проверяет и обновляет истекающие сертификаты
func CheckAndRenewCertificates() []ObtainResult {
domains := CollectDomainsForSSL()
results := make([]ObtainResult, 0)
for _, domain := range domains {
daysLeft := getCertDaysLeft(domain)
// Обновляем если до истечения менее 30 дней
if daysLeft >= 0 && daysLeft < 30 {
tools.Logs_file(0, "ACME", "🔄 Обновление сертификата для "+domain+" (осталось "+fmt.Sprintf("%d", daysLeft)+" дней)", "logs_acme.log", true)
result := ObtainCertificate(domain)
results = append(results, result)
time.Sleep(time.Second * 2)
}
}
return results
}
// StartBackgroundRenewal запускает фоновую проверку сертификатов
func StartBackgroundRenewal(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
tools.Logs_file(0, "ACME", "🔍 Проверка сертификатов...", "logs_acme.log", false)
results := CheckAndRenewCertificates()
for _, r := range results {
if r.Success {
tools.Logs_file(0, "ACME", "✅ Обновлён: "+r.Domain, "logs_acme.log", true)
} else {
tools.Logs_file(1, "ACME", "❌ Ошибка обновления "+r.Domain+": "+r.Error, "logs_acme.log", true)
}
}
// Очистка старых challenges
if DefaultManager != nil {
DefaultManager.cleanupOldChallenges()
}
}
}()
}
// obtainCertificate внутренний метод получения сертификата
func (m *Manager) obtainCertificate(domain string) ObtainResult {
tools.Logs_file(0, "ACME", "🔐 Получение сертификата для: "+domain, "logs_acme.log", true)
// Определяем ACME сервер
acmeURL := LetsEncryptStaging
if m.isProduction {
acmeURL = LetsEncryptProduction
}
// Создаём ACME клиент
client := &acme.Client{
Key: m.accountKey,
DirectoryURL: acmeURL,
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
// Регистрируем аккаунт (если ещё не зарегистрирован)
_, err := client.Register(ctx, &acme.Account{}, acme.AcceptTOS)
if err != nil && err != acme.ErrAccountAlreadyExists {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка регистрации: " + err.Error(),
}
}
// Создаём заказ на сертификат
order, err := client.AuthorizeOrder(ctx, acme.DomainIDs(domain))
if err != nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка создания заказа: " + err.Error(),
}
}
// Обрабатываем авторизации
for _, authURL := range order.AuthzURLs {
auth, err := client.GetAuthorization(ctx, authURL)
if err != nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка получения авторизации: " + err.Error(),
}
}
if auth.Status == acme.StatusValid {
continue
}
// Ищем HTTP-01 challenge
var challenge *acme.Challenge
for _, c := range auth.Challenges {
if c.Type == "http-01" {
challenge = c
break
}
}
if challenge == nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "HTTP-01 challenge не найден",
}
}
// Получаем KeyAuth для challenge
keyAuth, err := client.HTTP01ChallengeResponse(challenge.Token)
if err != nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка генерации ответа: " + err.Error(),
}
}
// Добавляем challenge для обработки HTTP запросов
m.addChallenge(challenge.Token, keyAuth, domain)
defer m.removeChallenge(challenge.Token)
// Уведомляем ACME сервер что готовы к проверке
if _, err := client.Accept(ctx, challenge); err != nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка принятия challenge: " + err.Error(),
}
}
// Ждём завершения авторизации
if _, err := client.WaitAuthorization(ctx, authURL); err != nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка авторизации: " + err.Error(),
}
}
}
// Генерируем приватный ключ для сертификата
certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка генерации ключа: " + err.Error(),
}
}
// Создаём CSR
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
DNSNames: []string{domain},
}, certKey)
if err != nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка создания CSR: " + err.Error(),
}
}
// Ждём готовности заказа и финализируем
order, err = client.WaitOrder(ctx, order.URI)
if err != nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка ожидания заказа: " + err.Error(),
}
}
// Получаем сертификат
der, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
if err != nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка получения сертификата: " + err.Error(),
}
}
// Сохраняем сертификат и ключ
if err := m.saveCertificate(domain, der, certKey); err != nil {
return ObtainResult{
Success: false,
Domain: domain,
Error: "Ошибка сохранения: " + err.Error(),
}
}
tools.Logs_file(0, "ACME", "✅ Сертификат получен для: "+domain, "logs_acme.log", true)
return ObtainResult{
Success: true,
Domain: domain,
Message: "Сертификат успешно получен",
}
}
// saveCertificate сохраняет сертификат и ключ в файлы
func (m *Manager) saveCertificate(domain string, certDER [][]byte, key *ecdsa.PrivateKey) error {
certDir := filepath.Join(m.certDir, domain)
// Создаём директорию
if err := os.MkdirAll(certDir, 0700); err != nil {
return err
}
// Сохраняем сертификат (certificate.crt)
certPath := filepath.Join(certDir, "certificate.crt")
certFile, err := os.Create(certPath)
if err != nil {
return err
}
defer certFile.Close()
for _, der := range certDER {
pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: der})
}
// Сохраняем приватный ключ (private.key)
keyPath := filepath.Join(certDir, "private.key")
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return err
}
keyFile, err := os.Create(keyPath)
if err != nil {
return err
}
defer keyFile.Close()
pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return nil
}
// loadOrCreateAccountKey загружает или создаёт ключ аккаунта
func (m *Manager) loadOrCreateAccountKey() error {
keyPath := filepath.Join(m.acmeDir, "account.key")
// Пробуем загрузить существующий ключ
if data, err := os.ReadFile(keyPath); err == nil {
block, _ := pem.Decode(data)
if block != nil {
key, err := x509.ParseECPrivateKey(block.Bytes)
if err == nil {
m.accountKey = key
tools.Logs_file(0, "ACME", "🔑 Account key загружен", "logs_acme.log", false)
return nil
}
}
}
// Создаём новый ключ
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
// Сохраняем ключ
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return err
}
keyFile, err := os.Create(keyPath)
if err != nil {
return err
}
defer keyFile.Close()
pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
m.accountKey = key
tools.Logs_file(0, "ACME", "🔑 Новый account key создан", "logs_acme.log", true)
return nil
}
// Вспомогательные функции
func isValidDomain(domain string) bool {
// Исключаем localhost, IP адреса и локальные домены
if domain == "" || domain == "localhost" {
return false
}
// Исключаем IP адреса
if net.ParseIP(domain) != nil {
return false
}
// Исключаем локальные домены
if strings.HasSuffix(domain, ".local") || strings.HasSuffix(domain, ".localhost") {
return false
}
// Исключаем wildcard
if strings.Contains(domain, "*") {
return false
}
// Базовая проверка формата домена
domainRegex := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*(\.[a-zA-Z0-9][a-zA-Z0-9-]*)+$`)
return domainRegex.MatchString(domain)
}
func needsCertificate(domain string) bool {
certPath := filepath.Join("WebServer/cert", domain, "certificate.crt")
// Если сертификата нет - нужен
if _, err := os.Stat(certPath); os.IsNotExist(err) {
return true
}
// Если сертификат истекает скоро - нужно обновить
daysLeft := getCertDaysLeft(domain)
return daysLeft >= 0 && daysLeft < 30
}
func getCertDaysLeft(domain string) int {
certPath := filepath.Join("WebServer/cert", domain, "certificate.crt")
data, err := os.ReadFile(certPath)
if err != nil {
return -1
}
block, _ := pem.Decode(data)
if block == nil {
return -1
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return -1
}
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
return daysLeft
}
func getCurrentTimestamp() int64 {
return time.Now().Unix()
}

View File

@@ -0,0 +1,84 @@
package acme
import (
"net/http"
"strings"
tools "vServer/Backend/tools"
)
// HandleChallenge обрабатывает HTTP-01 ACME challenge
// Путь: /.well-known/acme-challenge/{token}
func (m *Manager) HandleChallenge(w http.ResponseWriter, r *http.Request) bool {
path := r.URL.Path
// Проверяем что это ACME challenge
if !strings.HasPrefix(path, "/.well-known/acme-challenge/") {
return false
}
// Извлекаем token из пути
token := strings.TrimPrefix(path, "/.well-known/acme-challenge/")
if token == "" {
http.Error(w, "Token not found", http.StatusNotFound)
return true
}
// Ищем challenge по token
m.mu.RLock()
challenge, exists := m.challenges[token]
m.mu.RUnlock()
if !exists {
tools.Logs_file(1, "ACME", "⚠️ Challenge не найден для token: "+token, "logs_acme.log", false)
http.Error(w, "Challenge not found", http.StatusNotFound)
return true
}
// Отдаём KeyAuth для подтверждения владения доменом
tools.Logs_file(0, "ACME", "✅ Challenge ответ для домена: "+challenge.Domain, "logs_acme.log", true)
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(challenge.KeyAuth))
return true
}
// addChallenge добавляет challenge в хранилище
func (m *Manager) addChallenge(token, keyAuth, domain string) {
m.mu.Lock()
defer m.mu.Unlock()
m.challenges[token] = &ChallengeData{
Token: token,
KeyAuth: keyAuth,
Domain: domain,
Created: getCurrentTimestamp(),
}
tools.Logs_file(0, "ACME", "📝 Challenge добавлен для: "+domain, "logs_acme.log", false)
}
// removeChallenge удаляет challenge из хранилища
func (m *Manager) removeChallenge(token string) {
m.mu.Lock()
defer m.mu.Unlock()
if challenge, exists := m.challenges[token]; exists {
tools.Logs_file(0, "ACME", "🗑️ Challenge удалён для: "+challenge.Domain, "logs_acme.log", false)
delete(m.challenges, token)
}
}
// cleanupOldChallenges удаляет старые challenges (старше 10 минут)
func (m *Manager) cleanupOldChallenges() {
m.mu.Lock()
defer m.mu.Unlock()
now := getCurrentTimestamp()
maxAge := int64(600) // 10 минут
for token, challenge := range m.challenges {
if now-challenge.Created > maxAge {
delete(m.challenges, token)
}
}
}

View File

@@ -0,0 +1,42 @@
package acme
import (
"crypto/ecdsa"
"sync"
)
// ChallengeData хранит данные для HTTP-01 challenge
type ChallengeData struct {
Token string
KeyAuth string
Domain string
Created int64
}
// Manager управляет ACME сертификатами
type Manager struct {
mu sync.RWMutex
accountKey *ecdsa.PrivateKey
challenges map[string]*ChallengeData // token -> challenge data
certDir string
acmeDir string
isProduction bool
}
// CertInfo информация о сертификате
type CertInfo struct {
Domain string `json:"domain"`
Issuer string `json:"issuer"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
DaysLeft int `json:"days_left"`
AutoCreated bool `json:"auto_created"`
}
// ObtainResult результат получения сертификата
type ObtainResult struct {
Success bool `json:"success"`
Domain string `json:"domain"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}

View File

@@ -7,6 +7,7 @@ import (
"sync"
"vServer/Backend/config"
tools "vServer/Backend/tools"
"vServer/Backend/WebServer/acme"
)
var (
@@ -199,6 +200,13 @@ func isSiteActive(host string) bool {
// Обработчик запросов
func handler(w http.ResponseWriter, r *http.Request) {
// ACME HTTP-01 Challenge (для Let's Encrypt)
if strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") {
if acme.DefaultManager != nil && acme.DefaultManager.HandleChallenge(w, r) {
return
}
}
host := Alias_Run(r) // Получаем хост из запроса
https_check := !(r.TLS == nil) // Проверяем, по HTTPS ли запрос
root_url := r.URL.Path == "/" // Проверяем, является ли запрос корневым URL