Skip to content

TLS-сертификаты

Для получения сертификата 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 вообще.


Панель (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 дней → обновить

Два варианта:

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.com
de1.domain-b.com ← разные домены → разные DNS-провайдеры
us1.domain-c.com

Плюс: блокировка одного домена не затрагивает остальные. Минус: сложнее управлять.

Панель должна поддерживать оба варианта.


dns_providers — API-ключи DNS провайдеров

Section titled “dns_providers — API-ключи DNS провайдеров”
id uuid PRIMARY KEY
name varchar -- "Cloudflare main", "Namecheap backup"
provider enum(cloudflare, namecheap, digitalocean, route53, ...)
zone varchar -- "your-domain.com"
credentials text -- JSON, зашифрован AES-256-GCM мастер-ключом из ENV
is_active bool DEFAULT true
created_at timestamptz

Пример credentials (до шифрования):

// Cloudflare
{ "api_token": "..." }
// Namecheap
{ "api_user": "...", "api_key": "...", "username": "..." }

certificates — выданные сертификаты

Section titled “certificates — выданные сертификаты”
id uuid PRIMARY KEY
domain 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 null
issued_at timestamptz null
expires_at timestamptz null -- обычно +90 дней от issued_at
last_renewed_at timestamptz null
created_at timestamptz
-- Вместо хранения cert/key напрямую
certificate_id uuid null REFERENCES certificates(id)
-- cert/key берутся из certificates при применении конфига на ноду

Использует github.com/go-acme/lego — поддерживает 90+ DNS-провайдеров из коробки.

internal/certmanager/manager.go
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
}
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":
// ... и так далее
}
}

internal/certmanager/renewal_cron.go
// Запускается каждые 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
}
}
// agent: обработка UpdateCertificate
func (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()
}

Приватные ключи сертификатов и DNS credentials шифруются AES-256-GCM перед записью в БД:

internal/crypto/encrypt.go
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.


Добавление DNS-провайдера

Section titled “Добавление DNS-провайдера”
Настройки → DNS провайдеры → [+ Добавить]
Провайдер: [ Cloudflare ▾ ]
Зона: [ your-domain.com ]
API Token: [ ••••••••••••••• ]
[ Проверить ] → создаёт и удаляет тестовую TXT-запись
[ Сохранить ]
Сертификаты → [+ Выдать]
Домен: [ *.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│
└──────────────────────────┴──────────┴───────────────┴────────┘