TLS-сертификаты
Почему DNS-01 challenge
Section titled “Почему DNS-01 challenge”Для получения сертификата Let’s Encrypt нужно пройти ACME challenge. Есть три варианта:
| Challenge | Что нужно | Проблема |
|---|---|---|
| HTTP-01 | Порт 80 на ноде | Xray занимает 443, нужно открывать 80, не работает для wildcard |
| TLS-ALPN-01 | Порт 443 | Конфликтует с Xray |
| DNS-01 | Доступ к DNS API | Не нужен открытый порт, работает для wildcard, ноды не трогаем |
Выбираем DNS-01. Панель держит API-ключ DNS-провайдера, сама получает и продлевает сертификаты для всех нод. Ноды не знают про ACME вообще.
Архитектура
Section titled “Архитектура”Панель (CertManager) │ ├── ACME (Let's Encrypt / ZeroSSL) │ └── DNS-01 challenge │ └── DNS Provider API (Cloudflare / Namecheap / ...) │ └── создаёт TXT-запись _acme-challenge.nl1.domain.com │ ├── Сертификат получен → зашифровать → сохранить в БД │ └── gRPC → Агент на ноде └── обновляет cert/key файлы → перезапускает Xray
Крон (каждые 12 часов): └── Найти сертификаты с expires_at < now() + 30 дней → обновитьСтруктура доменов нод
Section titled “Структура доменов нод”Два варианта:
Wildcard (рекомендуется на старте)
*.your-domain.com → один сертификат для всех нодnl1.your-domain.com, de1.your-domain.com, us1.your-domain.comПлюс: один cert, одно продление, меньше API-вызовов к Let’s Encrypt. Минус: все ноды на одном домене — если домен блокируют, все ноды падают.
Per-node домены (лучше для резистентности)
nl1.domain-a.comde1.domain-b.com ← разные домены → разные DNS-провайдерыus1.domain-c.comПлюс: блокировка одного домена не затрагивает остальные. Минус: сложнее управлять.
Панель должна поддерживать оба варианта.
dns_providers — API-ключи DNS провайдеров
Section titled “dns_providers — API-ключи DNS провайдеров”id uuid PRIMARY KEYname varchar -- "Cloudflare main", "Namecheap backup"provider enum(cloudflare, namecheap, digitalocean, route53, ...)zone varchar -- "your-domain.com"credentials text -- JSON, зашифрован AES-256-GCM мастер-ключом из ENVis_active bool DEFAULT truecreated_at timestamptzПример credentials (до шифрования):
// Cloudflare{ "api_token": "..." }
// Namecheap{ "api_user": "...", "api_key": "...", "username": "..." }certificates — выданные сертификаты
Section titled “certificates — выданные сертификаты”id uuid PRIMARY KEYdomain varchar UNIQUE -- "*.your-domain.com" или "nl1.your-domain.com"dns_provider_id uuid REFERENCES dns_providers(id)ca enum(letsencrypt, zerossl, buypass) DEFAULT 'letsencrypt'
-- Сам сертификат (зашифрован)cert_pem text -- fullchain.pem, зашифрованkey_pem text -- privkey.pem, зашифрован
-- ACME аккаунт (для продления)acme_account_key text -- зашифрованacme_account_url text
-- Статусstatus enum(pending, active, renewing, error) DEFAULT 'pending'error_msg text nullissued_at timestamptz nullexpires_at timestamptz null -- обычно +90 дней от issued_atlast_renewed_at timestamptz nullcreated_at timestamptzИзменения в node_server_configs
Section titled “Изменения в node_server_configs”-- Вместо хранения cert/key напрямуюcertificate_id uuid null REFERENCES certificates(id)-- cert/key берутся из certificates при применении конфига на нодуCertManager (Go)
Section titled “CertManager (Go)”Использует github.com/go-acme/lego — поддерживает 90+ DNS-провайдеров из коробки.
type CertManager struct { db *gorm.DB keyring *crypto.Keyring // DEK+KEK, см. crypto.md nodeClients map[string]*xray.AgentClient}
// Выдать новый сертификатfunc (m *CertManager) Issue(ctx context.Context, domain string, providerID string) error { provider := m.loadProvider(providerID) dnsProvider := m.buildLegoProvider(provider) // Cloudflare, Namecheap и тд
// ACME клиент config := lego.NewConfig(m.acmeUser()) config.CADirURL = lego.LEDirectoryProduction client, _ := lego.NewClient(config) client.Challenge.SetDNS01Provider(dnsProvider, dns01.AddDNSTimeout(2*time.Minute), // ждём пока TXT запись распространится )
// Получаем сертификат request := certificate.ObtainRequest{ Domains: []string{domain}, Bundle: true, } cert, err := client.Certificate.Obtain(request) if err != nil { return err }
// Сохраняем в БД (зашифровано) m.saveCert(domain, cert) return nil}
// Продлить сертификатfunc (m *CertManager) Renew(ctx context.Context, certID string) error { existing := m.loadCert(certID) // lego умеет продлевать по сохранённому ресурсу renewed, err := client.Certificate.Renew(*existing.Resource, true, false, "") if err != nil { return err } m.updateCert(certID, renewed) m.pushToNodes(certID) // обновляем на всех нодах где используется return nil}DNS провайдеры через lego
Section titled “DNS провайдеры через lego”func (m *CertManager) buildLegoProvider(p *DNSProvider) challenge.Provider { creds := m.decrypt(p.Credentials)
switch p.Provider { case "cloudflare": cfg := cloudflare.NewDefaultConfig() cfg.AuthToken = creds["api_token"] return cloudflare.NewDNSProviderConfig(cfg)
case "namecheap": cfg := namecheap.NewDefaultConfig() cfg.APIUser = creds["api_user"] cfg.APIKey = creds["api_key"] return namecheap.NewDNSProviderConfig(cfg)
case "route53": // ... и так далее }}Крон: автопродление
Section titled “Крон: автопродление”// Запускается каждые 12 часовfunc (m *CertManager) RenewalCron(ctx context.Context) { ticker := time.NewTicker(12 * time.Hour) for range ticker.C { m.checkAndRenew(ctx) }}
func (m *CertManager) checkAndRenew(ctx context.Context) { // Найти сертификаты которые истекают через 30 дней var certs []Certificate m.db.Where("expires_at < ? AND status = 'active'", time.Now().Add(30*24*time.Hour)).Find(&certs)
for _, cert := range certs { m.db.Model(&cert).Update("status", "renewing")
if err := m.Renew(ctx, cert.ID); err != nil { m.db.Model(&cert).Updates(map[string]any{ "status": "error", "error_msg": err.Error(), }) m.alertAdmin(cert, err) // алерт в Telegram/email } }}Применение сертификата на ноду
Section titled “Применение сертификата на ноду”После выдачи/продления — пушим на все ноды где используется этот сертификат:
func (m *CertManager) pushToNodes(certID string) { // Найти все node_server_configs с этим сертификатом var bindings []NodeServerConfig m.db.Where("certificate_id = ? AND status = 'applied'", certID).Find(&bindings)
cert := m.loadAndDecryptCert(certID)
for _, binding := range bindings { agent := m.nodeClients[binding.NodeID]
// Отправляем cert/key на агент agent.UpdateCertificate(ctx, &UpdateCertRequest{ InboundTag: binding.Tag, CertPEM: cert.CertPEM, KeyPEM: cert.KeyPEM, }) // Агент записывает файлы на диск и перезапускает Xray }}На стороне агента
Section titled “На стороне агента”// agent: обработка UpdateCertificatefunc (a *Agent) UpdateCertificate(ctx context.Context, req *UpdateCertRequest) error { certPath := fmt.Sprintf("/etc/astral/certs/%s.crt", req.InboundTag) keyPath := fmt.Sprintf("/etc/astral/certs/%s.key", req.InboundTag)
os.WriteFile(certPath, []byte(req.CertPEM), 0600) os.WriteFile(keyPath, []byte(req.KeyPEM), 0600)
// Xray не умеет hot-reload сертификатов → graceful restart // Downtime ~1-2 секунды, раз в 60 дней — приемлемо return a.restartXray()}Безопасность хранения
Section titled “Безопасность хранения”Приватные ключи сертификатов и DNS credentials шифруются AES-256-GCM перед записью в БД:
func Encrypt(masterKey []byte, plaintext []byte) (string, error) { block, _ := aes.NewCipher(masterKey) gcm, _ := cipher.NewGCM(block) nonce := make([]byte, gcm.NonceSize()) io.ReadFull(rand.Reader, nonce) ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) return base64.StdEncoding.EncodeToString(ciphertext), nil}MASTER_ENCRYPTION_KEY — 32-байтный ключ, хранится только в ENV. Никогда не в БД, не в коде, не в git.
Флоу в админке
Section titled “Флоу в админке”Добавление DNS-провайдера
Section titled “Добавление DNS-провайдера”Настройки → DNS провайдеры → [+ Добавить] Провайдер: [ Cloudflare ▾ ] Зона: [ your-domain.com ] API Token: [ ••••••••••••••• ] [ Проверить ] → создаёт и удаляет тестовую TXT-запись [ Сохранить ]Выдача сертификата
Section titled “Выдача сертификата”Сертификаты → [+ Выдать] Домен: [ *.your-domain.com ] DNS провайдер: [ Cloudflare main ▾ ] CA: [ Let's Encrypt ▾ ] [ Выдать ] → статус: pending → activeПривязка к конфигу ноды
Section titled “Привязка к конфигу ноды”Нода: NL-1 → Конфиги → VLESS XHTTP TLS → Редактировать Сертификат: [ *.your-domain.com (истекает через 87 дней) ▾ ] [ Применить ]Статус сертификатов в админке
Section titled “Статус сертификатов в админке”┌──────────────────────────┬──────────┬───────────────┬────────┐│ Домен │ CA │ Истекает │ Статус │├──────────────────────────┼──────────┼───────────────┼────────┤│ *.your-domain.com │ LE │ через 87 дней │ ✓ ││ nl1.backup-domain.com │ ZeroSSL │ через 12 дней │ ⚠ скоро││ de1.domain.com │ LE │ истёк 2д назад│ ✗ error│└──────────────────────────┴──────────┴───────────────┴────────┘