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

Добавил возможность создания автоматических сертификатов.
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

View File

@@ -3,6 +3,7 @@ package admin
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"time"
@@ -10,6 +11,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
webserver "vServer/Backend/WebServer"
"vServer/Backend/WebServer/acme"
"vServer/Backend/admin/go/proxy"
"vServer/Backend/admin/go/services"
"vServer/Backend/admin/go/sites"
@@ -61,6 +63,15 @@ func (a *App) Startup(ctx context.Context) {
webserver.Cert_start()
time.Sleep(50 * time.Millisecond)
// Инициализируем ACME менеджер (true = production, false = staging)
if err := acme.Init(true); err != nil {
tools.Logs_file(1, "ACME", "❌ Ошибка инициализации ACME: "+err.Error(), "logs_acme.log", true)
} else {
// Запускаем фоновую проверку сертификатов каждые 24 часа
acme.StartBackgroundRenewal(24 * time.Hour)
}
time.Sleep(50 * time.Millisecond)
// Запускаем серверы
go webserver.StartHTTPS()
time.Sleep(50 * time.Millisecond)
@@ -75,6 +86,18 @@ func (a *App) Startup(ctx context.Context) {
// Запускаем MySQL асинхронно
go webserver.StartMySQLServer(false)
// Автоматическое получение SSL сертификатов для доменов с AutoCreateSSL=true
if config.ConfigData.Soft_Settings.ACME_enabled {
go func() {
time.Sleep(2 * time.Second) // Ждём пока HTTP сервер полностью запустится
results := acme.ObtainAllCertificates()
if len(results) > 0 {
// Перезагружаем сертификаты после получения
webserver.ReloadCertificates()
}
}()
}
// Запускаем мониторинг статусов
go a.monitorServices()
}
@@ -363,4 +386,40 @@ func (a *App) DeleteSite(host string) string {
config.LoadConfig()
return "Site deleted successfully"
}
// ObtainSSLCertificate получает SSL сертификат для домена через Let's Encrypt
func (a *App) ObtainSSLCertificate(domain string) string {
result := acme.ObtainCertificate(domain)
if result.Success {
// Перезагружаем сертификаты после получения
webserver.ReloadCertificates()
return "SSL certificate obtained successfully for " + domain
}
return "Error: " + result.Error
}
// ObtainAllSSLCertificates получает сертификаты для всех доменов с AutoCreateSSL
func (a *App) ObtainAllSSLCertificates() string {
results := acme.ObtainAllCertificates()
successCount := 0
errorCount := 0
for _, r := range results {
if r.Success {
successCount++
} else {
errorCount++
}
}
if len(results) > 0 {
// Перезагружаем сертификаты после получения
webserver.ReloadCertificates()
}
return fmt.Sprintf("Completed: %d success, %d errors", successCount, errorCount)
}

View File

@@ -7,6 +7,7 @@ type ProxyInfo struct {
LocalPort string `json:"local_port"`
ServiceHTTPSuse bool `json:"service_https_use"`
AutoHTTPS bool `json:"auto_https"`
AutoCreateSSL bool `json:"AutoCreateSSL"`
Status string `json:"status"`
}

View File

@@ -7,5 +7,6 @@ type SiteInfo struct {
Status string `json:"status"`
RootFile string `json:"root_file"`
RootFileRouting bool `json:"root_file_routing"`
AutoCreateSSL bool `json:"AutoCreateSSL"`
}

View File

@@ -21,6 +21,7 @@ type Site_www struct {
Status string `json:"status"`
Root_file string `json:"root_file"`
Root_file_routing bool `json:"root_file_routing"`
AutoCreateSSL bool `json:"AutoCreateSSL"`
}
type Soft_Settings struct {
@@ -29,6 +30,7 @@ type Soft_Settings struct {
Mysql_port int `json:"mysql_port"`
Mysql_host string `json:"mysql_host"`
Proxy_enabled bool `json:"proxy_enabled"`
ACME_enabled bool `json:"ACME_enabled"`
}
type Proxy_Service struct {
@@ -38,6 +40,7 @@ type Proxy_Service struct {
LocalPort string `json:"LocalPort"`
ServiceHTTPSuse bool `json:"ServiceHTTPSuse"`
AutoHTTPS bool `json:"AutoHTTPS"`
AutoCreateSSL bool `json:"AutoCreateSSL"`
}
func LoadConfig() {
@@ -57,6 +60,79 @@ func LoadConfig() {
tools.Logs_file(0, "JSON", "config.json успешно прочитан", "logs_config.log", true)
}
// Миграция: добавляем новые поля если их нет
migrateConfig(data)
println()
}
// migrateConfig проверяет и добавляет новые поля в конфиг
func migrateConfig(originalData []byte) {
needsSave := false
// Парсим оригинальный JSON как map для проверки наличия полей
var rawConfig map[string]json.RawMessage
if err := json.Unmarshal(originalData, &rawConfig); err != nil {
return
}
// Проверяем Site_www
if rawSites, ok := rawConfig["Site_www"]; ok {
var sites []map[string]interface{}
if err := json.Unmarshal(rawSites, &sites); err == nil {
for _, site := range sites {
if _, exists := site["AutoCreateSSL"]; !exists {
needsSave = true
break
}
}
}
}
// Проверяем Proxy_Service
if rawProxies, ok := rawConfig["Proxy_Service"]; ok {
var proxies []map[string]interface{}
if err := json.Unmarshal(rawProxies, &proxies); err == nil {
for _, proxy := range proxies {
if _, exists := proxy["AutoCreateSSL"]; !exists {
needsSave = true
break
}
}
}
}
// Проверяем Soft_Settings на наличие ACME_enabled
if rawSettings, ok := rawConfig["Soft_Settings"]; ok {
var settings map[string]interface{}
if err := json.Unmarshal(rawSettings, &settings); err == nil {
if _, exists := settings["ACME_enabled"]; !exists {
needsSave = true
}
}
}
// Если нужно обновить - сохраняем конфиг с новыми полями
if needsSave {
tools.Logs_file(0, "JSON", "🔄 Миграция конфига: добавляем новые поля", "logs_config.log", true)
saveConfig()
}
}
// saveConfig сохраняет текущий конфиг в файл
func saveConfig() {
formattedJSON, err := json.MarshalIndent(ConfigData, "", " ")
if err != nil {
tools.Logs_file(1, "JSON", "Ошибка форматирования конфига: "+err.Error(), "logs_config.log", true)
return
}
err = os.WriteFile(ConfigPath, formattedJSON, 0644)
if err != nil {
tools.Logs_file(1, "JSON", "Ошибка сохранения конфига: "+err.Error(), "logs_config.log", true)
return
}
tools.Logs_file(0, "JSON", "✅ Конфиг обновлён с новыми полями", "logs_config.log", true)
}