Skip to content

Instantly share code, notes, and snippets.

@ponfertato
Last active September 11, 2025 09:55
Show Gist options
  • Select an option

  • Save ponfertato/11792d3c13098806b2df1dcae28db605 to your computer and use it in GitHub Desktop.

Select an option

Save ponfertato/11792d3c13098806b2df1dcae28db605 to your computer and use it in GitHub Desktop.

Beget ACME DNS Hook для Traefik

Цель: Автоматическая выдача и обновление Let’s Encrypt сертификатов через DNS-01 challenge для доменов, размещённых на Beget, в связке с Traefik.

Суть внедрения

Этот контейнер — HTTP-хук для Traefik, который:

  1. Принимает запросы от Traefik на установку/удаление TXT-записей для ACME DNS-01 challenge.
  2. Вызывает API Beget для изменения DNS-записей.
  3. Работает строго с тем FQDN, который передаёт Traefik — без модификаций, без добавления _acme-challenge..
  4. Позволяет использовать CNAME-цепочки — например, _acme-challenge.home.example.ru_acme-challenge.connect.example.ru.

Как это работает

  1. Traefik запрашивает сертификат для home.example.ru.
  2. Let’s Encrypt требует разместить TXT-запись на _acme-challenge.home.example.ru.
  3. Traefik вызывает /present в этом контейнере с:
    { "fqdn": "_acme-challenge.home.example.ru", "value": "токен" }
  4. Контейнер вызывает API Beget и устанавливает TXT-запись именно на _acme-challenge.home.example.ru.
  5. Let’s Encrypt проверяет запись → выдаёт сертификат.
  6. Traefik вызывает /cleanup → контейнер удаляет TXT-запись.

Структура контейнера

Dockerfile

  • Стадия 1 (build): Сборка Go-бинарника в golang:alpine.
  • Стадия 2 (runtime): Запуск в alpine с ca-certificates.
  • HEALTHCHECK: Проверяет /healthz каждые 30 сек.
  • EXPOSE 8080: Порт для Traefik.

main.go

  • Принимает fqdn как есть — не добавляет префиксы.
  • Работает только с changeRecords API Beget — без чтения, без проверок.
  • Цветные логи в стиле Traefik — с таймзоной из TZ.
  • Минималистичный и надёжный — делает ровно то, что нужно.

docker-compose.yaml

  • Сеть beget — для связи с Traefik.
  • Зависимость Traefik от beget:service_healthy — не стартует, пока хук не готов.
  • Подключение через HTTPREQ_ENDPOINT=http://beget:8080.

stack.env

  • BEGET_LOGIN, BEGET_PASSWORD — учётные данные API Beget.
  • HTTPREQ_ENDPOINT=http://beget:8080 — адрес хука для Traefik.
  • TZ=Europe/Moscow — таймзона для логов.

Требования к DNS (Beget)

Для работы с CNAME:

home 300 IN CNAME connect.example.ru.
_acme-challenge.home 300 IN CNAME _acme-challenge.connect.example.ru.
connect 300 IN A 194.58.183.34

→ TXT-запись будет физически создана на _acme-challenge.connect.example.ru, но Let’s Encrypt найдёт её через CNAME.

Запуск

  1. Укажи BEGET_LOGIN и BEGET_PASSWORD в stack.env.
  2. Создай CNAME-записи в DNS Beget.
  3. Запусти: docker-compose up -d

Это — чистый, рабочий, production-ready хук. Никакой воды. Только суть.

FROM golang:1.25.1-alpine AS build
WORKDIR /app
ENV CGO_ENABLED=0
COPY . .
RUN go mod tidy
RUN go build -o /beget-hook
FROM alpine:3.22.1
RUN apk add --no-cache ca-certificates
COPY --from=build /beget-hook /usr/local/bin/beget-hook
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/beget-hook"]
go 1.25.1
module github.com/potatoenergy/beget
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const DefaultPort = "8080"
var (
BegetLogin = os.Getenv("BEGET_LOGIN")
BegetPassword = os.Getenv("BEGET_PASSWORD")
HTTPTimeout = 30 * time.Second
HTTPClient = &http.Client{Timeout: HTTPTimeout}
Logger = log.New(os.Stdout, "", 0)
)
const (
Reset = "\033[0m"
Red = "\033[31m"
Green = "\033[32m"
Cyan = "\033[36m"
)
var location = func() *time.Location {
if tz := os.Getenv("TZ"); tz != "" {
if loc, err := time.LoadLocation(tz); err == nil {
return loc
}
}
return time.UTC
}()
type DNSRequest struct {
FQDN string `json:"fqdn"`
Value string `json:"value"`
}
func main() {
if BegetLogin == "" || BegetPassword == "" {
logFatalf("BEGET_LOGIN and BEGET_PASSWORD required")
}
http.HandleFunc("/healthz", handleHealthz)
http.HandleFunc("/present", handlePresent)
http.HandleFunc("/cleanup", handleCleanup)
logInfof("Server started on port=%s", DefaultPort)
if err := http.ListenAndServe(":"+DefaultPort, nil); err != nil {
logFatalf("Server failed error=%v", err)
}
}
func handleHealthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func handlePresent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req DNSRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logWarnf("Invalid JSON body")
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.FQDN == "" || req.Value == "" {
logWarnf("Missing required fields fqdn=%s value=%s", req.FQDN, req.Value)
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
target := strings.TrimSuffix(req.FQDN, ".")
logInfof("Setting TXT record target=%s value=%s", target, req.Value)
if err := setTXTRecord(r.Context(), target, req.Value); err != nil {
logErrorf("Failed to set TXT record error=%s target=%s", err.Error(), target)
http.Error(w, "Set failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func handleCleanup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req DNSRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logWarnf("Invalid JSON body")
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.FQDN == "" {
logWarnf("Missing required field fqdn=%s", req.FQDN)
http.Error(w, "Missing required field", http.StatusBadRequest)
return
}
target := strings.TrimSuffix(req.FQDN, ".")
logInfof("Clearing TXT record target=%s", target)
if err := clearTXTRecord(r.Context(), target); err != nil {
logErrorf("Failed to clear TXT record error=%s target=%s", err.Error(), target)
http.Error(w, "Clear failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func setTXTRecord(ctx context.Context, domain, value string) error {
params := map[string]interface{}{
"fqdn": domain,
"records": map[string]interface{}{
"TXT": []interface{}{map[string]interface{}{"value": value, "priority": 0}},
},
}
_, err := callBegetAPI(ctx, "changeRecords", params)
return err
}
func clearTXTRecord(ctx context.Context, domain string) error {
params := map[string]interface{}{
"fqdn": domain,
"records": map[string]interface{}{
"TXT": []interface{}{},
},
}
_, err := callBegetAPI(ctx, "changeRecords", params)
return err
}
func callBegetAPI(ctx context.Context, method string, params interface{}) (interface{}, error) {
jsonData, _ := json.Marshal(params)
values := url.Values{}
values.Set("login", BegetLogin)
values.Set("passwd", BegetPassword)
values.Set("input_format", "json")
values.Set("output_format", "json")
values.Set("input_data", string(jsonData))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.beget.com/api/dns/"+method+"?"+values.Encode(), nil)
req.Header.Set("User-Agent", "Beget-DNS-ACME-Hook/1.0")
resp, err := HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var response interface{}
json.NewDecoder(resp.Body).Decode(&response)
if m, ok := response.(map[string]interface{}); ok {
if s, ok := m["status"].(string); ok && s == "error" {
return nil, fmt.Errorf("API error")
}
}
return response, nil
}
func nowFormatted() string {
return time.Now().In(location).Format("2006-01-02T15:04:05-07:00")
}
func logInfof(format string, args ...interface{}) {
msg := colorizeKeys(fmt.Sprintf(format, args...))
Logger.Printf("%s %s%s%s %s", nowFormatted(), Green, "INF", Reset, msg)
}
func logWarnf(format string, args ...interface{}) {
msg := colorizeKeys(fmt.Sprintf(format, args...))
Logger.Printf("%s %s%s%s %s", nowFormatted(), "\033[33m", "WRN", Reset, msg)
}
func logErrorf(format string, args ...interface{}) {
msg := colorizeKeys(fmt.Sprintf(format, args...))
Logger.Printf("%s %s%s%s %s", nowFormatted(), Red, "ERR", Reset, msg)
}
func logFatalf(format string, args ...interface{}) {
msg := colorizeKeys(fmt.Sprintf(format, args...))
Logger.Printf("%s %s%s%s %s", nowFormatted(), Red, "FATAL", Reset, msg)
os.Exit(1)
}
func colorizeKeys(msg string) string {
parts := strings.Fields(msg)
var result []string
for _, part := range parts {
if i := strings.Index(part, "="); i > 0 {
key := part[:i+1]
val := part[i+1:]
result = append(result, Cyan+key+Reset+val)
} else {
result = append(result, part)
}
}
return strings.Join(result, " ")
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment