Merge feature/template-placeholders: плейсхолдер {{domain_name}} в шаблонах
Шаблон хранит записи с {{domain_name}}, материализуется под имя зоны при
diff/apply (переиспользуем между доменами); snapshot зоны авто-параметризует
(матчинг по границе DNS-лейбла); подсказка в редакторе.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -0,0 +1,221 @@
|
|||||||
|
# Шаблоны с плейсхолдером {{domain_name}} Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Сделать шаблоны переиспользуемыми между доменами через плейсхолдер `{{domain_name}}`: шаблон хранит записи с плейсхолдером, а при diff/apply он материализуется под имя конкретной зоны. Снимок зоны в шаблон авто-параметризуется.
|
||||||
|
|
||||||
|
**Architecture:** Плейсхолдер `{{domain_name}}` в именах и значениях записей шаблона. Материализация (подстановка имени зоны без завершающей точки) — централизованно в `service.resolve`, поэтому и diff, и apply работают с уже подставленными значениями. Обратная операция (параметризация) применяется при snapshot зоны в шаблон. Новый пакет `internal/tmpl` изолирует обе операции.
|
||||||
|
|
||||||
|
**Tech Stack:** Go (strings), sqlc-модель (правится вручную — sqlc не установлен), React + Vite.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Плейсхолдер: буквально `{{domain_name}}` (константа). Подставляется имя зоны БЕЗ завершающей точки: `strings.TrimSuffix(zoneName, ".")`. Точки в шаблоне — явные (пользователь пишет `_dmarc.{{domain_name}}.`).
|
||||||
|
- Материализация применяется в именах записей И во всех значениях (Values).
|
||||||
|
- Snapshot (`template-from-zone`) авто-параметризует: заменяет вхождения имени зоны на `{{domain_name}}` в именах и значениях. Записи без вхождений (DKIM-ключ, CNAME на внешний хост) остаются как есть.
|
||||||
|
- Материализация — единственная точка истины для diff/apply: НЕ дублировать логику подстановки в провайдере или где-то ещё.
|
||||||
|
- sqlc НЕ установлен: `LoadDomainFull` правится вручную и в `internal/store/queries/domains.sql` (источник), и в `internal/store/db/domains.sql.go` (сгенерированный) — держать синхронно.
|
||||||
|
- Комментарии в Go — на английском; в web — как в окружающих файлах. TDD. НЕ коммитить реальную сборку `internal/web/dist/*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Backend — пакет tmpl (материализация/параметризация) + zoneName в resolve + snapshot
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/tmpl/tmpl.go`, `internal/tmpl/tmpl_test.go`
|
||||||
|
- Modify: `internal/service/service.go` (DomainRef.ZoneName + resolve), `internal/store/loader.go` (LoadDomain возвращает ZoneName), `internal/store/queries/domains.sql` + `internal/store/db/domains.sql.go` (LoadDomainFull += zone_name), `internal/api/handlers.go` (handleTemplateFromZone параметризует)
|
||||||
|
- Test: `internal/service/service_test.go`, `internal/api/tenant_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `tmpl.Placeholder = "{{domain_name}}"`; `tmpl.Materialize(doc dto.TemplateDoc, zoneName string) []model.Record`; `tmpl.Parameterize(recs []model.Record, zoneName string) dto.TemplateDoc`; `service.DomainRef.ZoneName`.
|
||||||
|
- Consumes: `dto.TemplateDoc`, `dto.RecordDTO`, `model.Record`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Тесты пакета tmpl**
|
||||||
|
|
||||||
|
`internal/tmpl/tmpl_test.go`:
|
||||||
|
```go
|
||||||
|
func TestMaterializeReplacesInNameAndValues(t *testing.T) {
|
||||||
|
doc := dto.TemplateDoc{Records: []dto.RecordDTO{
|
||||||
|
{Type: "TXT", Name: "_dmarc.{{domain_name}}.", TTL: 600, Values: []string{"v=DMARC1; p=quarantine"}},
|
||||||
|
{Type: "MX", Name: "{{domain_name}}.", TTL: 600, Values: []string{"0 pmg2-mail.{{domain_name}}."}},
|
||||||
|
{Type: "TXT", Name: "{{domain_name}}.", TTL: 600, Values: []string{"v=spf1 a:mail.{{domain_name}} ~all"}},
|
||||||
|
}}
|
||||||
|
recs := tmpl.Materialize(doc, "reconops.ru.") // trailing dot stripped
|
||||||
|
if recs[0].Name != "_dmarc.reconops.ru." {
|
||||||
|
t.Fatalf("name: %q", recs[0].Name)
|
||||||
|
}
|
||||||
|
if recs[1].Values[0] != "0 pmg2-mail.reconops.ru." {
|
||||||
|
t.Fatalf("mx value: %q", recs[1].Values[0])
|
||||||
|
}
|
||||||
|
if recs[2].Values[0] != "v=spf1 a:mail.reconops.ru ~all" {
|
||||||
|
t.Fatalf("spf value: %q", recs[2].Values[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParameterizeIsInverseForZoneOccurrences(t *testing.T) {
|
||||||
|
recs := []model.Record{
|
||||||
|
{Type: "TXT", Name: "_dmarc.reconops.ru.", TTL: 600, Values: []string{"v=DMARC1"}},
|
||||||
|
{Type: "TXT", Name: "reconops.ru.", TTL: 600, Values: []string{"v=spf1 a:mail.reconops.ru ~all"}},
|
||||||
|
{Type: "CNAME", Name: "mail.reconops.ru.", TTL: 600, Values: []string{"amail.amega.kz."}}, // external host untouched
|
||||||
|
}
|
||||||
|
doc := tmpl.Parameterize(recs, "reconops.ru.")
|
||||||
|
if doc.Records[0].Name != "_dmarc.{{domain_name}}." {
|
||||||
|
t.Fatalf("name: %q", doc.Records[0].Name)
|
||||||
|
}
|
||||||
|
if doc.Records[1].Values[0] != "v=spf1 a:mail.{{domain_name}} ~all" {
|
||||||
|
t.Fatalf("spf: %q", doc.Records[1].Values[0])
|
||||||
|
}
|
||||||
|
if doc.Records[2].Values[0] != "amail.amega.kz." {
|
||||||
|
t.Fatalf("external cname value must be untouched: %q", doc.Records[2].Values[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Run: `go test ./internal/tmpl/...` — Ожидание FAIL (пакета нет).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Пакет tmpl**
|
||||||
|
|
||||||
|
`internal/tmpl/tmpl.go`:
|
||||||
|
```go
|
||||||
|
// Package tmpl renders reusable DNS templates for a concrete zone.
|
||||||
|
//
|
||||||
|
// A template stores records containing the {{domain_name}} placeholder; it is
|
||||||
|
// materialised (placeholder -> zone name, without the trailing dot) just
|
||||||
|
// before diff/apply against a specific domain, and parameterised (zone name
|
||||||
|
// -> placeholder) when snapshotting a live zone into a reusable template.
|
||||||
|
package tmpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Placeholder = "{{domain_name}}"
|
||||||
|
|
||||||
|
func zoneName(z string) string { return strings.TrimSuffix(z, ".") }
|
||||||
|
|
||||||
|
// Materialize renders a template's records for the given zone, substituting
|
||||||
|
// {{domain_name}} with the zone name (без завершающей точки) in each record's
|
||||||
|
// Name and every Value.
|
||||||
|
func Materialize(doc dto.TemplateDoc, zone string) []model.Record {
|
||||||
|
z := zoneName(zone)
|
||||||
|
out := make([]model.Record, 0, len(doc.Records))
|
||||||
|
for _, r := range doc.Records {
|
||||||
|
vals := make([]string, len(r.Values))
|
||||||
|
for i, v := range r.Values {
|
||||||
|
vals[i] = strings.ReplaceAll(v, Placeholder, z)
|
||||||
|
}
|
||||||
|
out = append(out, model.Record{
|
||||||
|
Type: model.RecordType(r.Type),
|
||||||
|
Name: strings.ReplaceAll(r.Name, Placeholder, z),
|
||||||
|
TTL: r.TTL,
|
||||||
|
Values: vals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterize replaces occurrences of the zone name with {{domain_name}} in
|
||||||
|
// record names and values, so a snapshot of a live zone becomes portable.
|
||||||
|
// Records with no zone-name occurrence (DKIM key, external CNAME target) are
|
||||||
|
// left unchanged.
|
||||||
|
func Parameterize(recs []model.Record, zone string) dto.TemplateDoc {
|
||||||
|
z := zoneName(zone)
|
||||||
|
out := dto.TemplateDoc{Records: make([]dto.RecordDTO, 0, len(recs))}
|
||||||
|
for _, r := range recs {
|
||||||
|
vals := make([]string, len(r.Values))
|
||||||
|
for i, v := range r.Values {
|
||||||
|
vals[i] = strings.ReplaceAll(v, z, Placeholder)
|
||||||
|
}
|
||||||
|
out.Records = append(out.Records, dto.RecordDTO{
|
||||||
|
Type: string(r.Type),
|
||||||
|
Name: strings.ReplaceAll(r.Name, z, Placeholder),
|
||||||
|
TTL: r.TTL,
|
||||||
|
Values: vals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Run: `go test ./internal/tmpl/...` — Ожидание PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: DomainRef.ZoneName + LoadDomainFull zone_name**
|
||||||
|
|
||||||
|
`internal/service/service.go` — `DomainRef` += `ZoneName string`.
|
||||||
|
|
||||||
|
`internal/store/queries/domains.sql` — LoadDomainFull:
|
||||||
|
```sql
|
||||||
|
-- name: LoadDomainFull :one
|
||||||
|
SELECT d.zone_id, d.zone_name, a.provider, a.secret_enc, t.doc
|
||||||
|
FROM domains d
|
||||||
|
JOIN provider_accounts a ON a.id = d.provider_account_id
|
||||||
|
LEFT JOIN templates t ON t.id = d.template_id
|
||||||
|
WHERE d.id = $1 AND d.project_id = $2;
|
||||||
|
```
|
||||||
|
`internal/store/db/domains.sql.go` (sqlc НЕ установлен — правь вручную, синхронно): в `loadDomainFull` const добавь `d.zone_name` в SELECT; в `LoadDomainFullRow` добавь `ZoneName string json:"zone_name"` (после ZoneID); в `Scan(...)` добавь `&i.ZoneName` в правильном порядке (сразу после `&i.ZoneID`).
|
||||||
|
|
||||||
|
`internal/store/loader.go` — `LoadDomain` возвращает `ZoneName: row.ZoneName` в DomainRef. (LoadZone можно оставить как есть — snapshot берёт zoneName из GetDomain.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: resolve материализует**
|
||||||
|
|
||||||
|
`internal/service/service.go` resolve — заменить:
|
||||||
|
```go
|
||||||
|
cs := diff.Diff(ref.Template.ToModel(), actual)
|
||||||
|
```
|
||||||
|
на:
|
||||||
|
```go
|
||||||
|
cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual)
|
||||||
|
```
|
||||||
|
(импортируй `internal/tmpl`.) Обнови фейки Loader в тестах — LoadDomain должен возвращать ZoneName; добавь тест-кейс, что resolve/Check материализует плейсхолдер (шаблон с `{{domain_name}}` → diff считается против actual с подставленным именем зоны фейкового провайдера).
|
||||||
|
|
||||||
|
- [ ] **Step 5: snapshot параметризует**
|
||||||
|
|
||||||
|
`internal/api/handlers.go` `handleTemplateFromZone` — после фильтра managed-записей заменить:
|
||||||
|
```go
|
||||||
|
doc := dto.FromModel(managed)
|
||||||
|
```
|
||||||
|
на:
|
||||||
|
```go
|
||||||
|
doc := tmpl.Parameterize(managed, dom.ZoneName)
|
||||||
|
```
|
||||||
|
(импортируй `internal/tmpl`; `dom.ZoneName` уже есть из GetDomain.) Обнови/добавь тест: snapshot зоны reconops.ru → в созданном шаблоне имена/значения содержат `{{domain_name}}` вместо `reconops.ru` (мок ZoneRecords верни записи с именем зоны, проверь что в CreateTemplate ушёл doc с плейсхолдером).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Прогон и коммит**
|
||||||
|
|
||||||
|
Run: `go build ./... && go test ./internal/...`. Ожидание PASS.
|
||||||
|
```bash
|
||||||
|
git add internal/
|
||||||
|
git commit -m "feat(tmpl): {{domain_name}} placeholder — materialize on diff/apply, parameterize on snapshot"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Frontend — подсказка о плейсхолдере в редакторе шаблонов
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/components/RecordEditor.tsx` (или `web/src/pages/TemplatesPage.tsx` — где логичнее подсказка)
|
||||||
|
- Test: соответствующий *.test.tsx
|
||||||
|
|
||||||
|
**Interfaces:** только UI-подсказка; поведение diff уже материализовано бэком, значения приходят подставленными.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Подсказка**
|
||||||
|
|
||||||
|
В редакторе шаблона (RecordEditor или TemplatesPage, рядом с полями записей) добавь короткую подсказку (FieldDescription / текст), например: «Используйте `{{domain_name}}` в имени или значении — при проверке домена подставится имя его зоны (без завершающей точки).» Стиль — как существующие подсказки на страницах (напр. FieldDescription в AccountsPage). Не добавляй валидацию/трансформацию — плейсхолдер это обычный текст.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Тест + прогон**
|
||||||
|
|
||||||
|
Тест: подсказка с текстом про `{{domain_name}}` присутствует в отрендеренном редакторе шаблона. Run: `cd web && npm run test -- --run && npx tsc --noEmit && npm run build`.
|
||||||
|
```bash
|
||||||
|
cd .. && git checkout internal/web/dist/index.html
|
||||||
|
git add web/src/
|
||||||
|
git commit -m "feat(web): hint about {{domain_name}} placeholder in template editor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итоговая проверка
|
||||||
|
|
||||||
|
- `go build ./... && go test ./...` — PASS.
|
||||||
|
- `cd web && npm run test -- --run && npm run build` — PASS.
|
||||||
|
- Ручная: шаблон с `{{domain_name}}` привязать к домену A и B → дифф каждого показывает подстановку имени соответствующей зоны; snapshot зоны создаёт шаблон с `{{domain_name}}` вместо имени зоны; шаблон переиспользуем между доменами.
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/tmpl"
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
@@ -141,17 +142,17 @@ func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) {
|
|||||||
managed = append(managed, rc)
|
managed = append(managed, rc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
doc := dto.FromModel(managed)
|
doc := tmpl.Parameterize(managed, dom.ZoneName)
|
||||||
tmpl, err := a.Store.CreateTemplate(r.Context(), pid, dom.ZoneName+" snapshot", doc)
|
tpl, err := a.Store.CreateTemplate(r.Context(), pid, dom.ZoneName+" snapshot", doc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("api: template-from-zone: create template failed: %v", err)
|
log.Printf("api: template-from-zone: create template failed: %v", err)
|
||||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.Store.SetDomainTemplate(r.Context(), did, pid, &tmpl.ID); err != nil {
|
if _, err := a.Store.SetDomainTemplate(r.Context(), did, pid, &tpl.ID); err != nil {
|
||||||
log.Printf("api: template-from-zone: attach template failed: %v", err)
|
log.Printf("api: template-from-zone: attach template failed: %v", err)
|
||||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, toTemplateResponse(tmpl))
|
writeJSON(w, http.StatusCreated, toTemplateResponse(tpl))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -643,15 +643,17 @@ func TestZoneRecords_ReturnsProviderRecords(t *testing.T) {
|
|||||||
|
|
||||||
// TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches covers the
|
// TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches covers the
|
||||||
// snapshot-to-template flow: NS/SOA are read-only and must be excluded from
|
// snapshot-to-template flow: NS/SOA are read-only and must be excluded from
|
||||||
// the generated template, and the new template must be auto-attached to the
|
// the generated template, the new template must be auto-attached to the
|
||||||
// domain (SetDomainTemplate) so check/apply become immediately available.
|
// domain (SetDomainTemplate) so check/apply become immediately available,
|
||||||
|
// and the zone name must be parameterized to {{domain_name}} in names/values
|
||||||
|
// so the resulting template is reusable across domains (tmpl.Parameterize).
|
||||||
func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) {
|
func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) {
|
||||||
a, ts := newTenantTestAPI()
|
a, ts := newTenantTestAPI()
|
||||||
domID := uuid.New()
|
domID := uuid.New()
|
||||||
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
|
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
|
||||||
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
|
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
|
||||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||||
{Type: model.TXT, Name: "a.example.com.", TTL: 300, Values: []string{"v=spf1 -all"}},
|
{Type: model.TXT, Name: "a.example.com.", TTL: 300, Values: []string{"v=spf1 a:mail.example.com -all"}},
|
||||||
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}},
|
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}},
|
||||||
{Type: model.SOA, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com. admin.example.com. 1 2 3 4 5"}},
|
{Type: model.SOA, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com. admin.example.com. 1 2 3 4 5"}},
|
||||||
}}
|
}}
|
||||||
@@ -674,6 +676,17 @@ func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) {
|
|||||||
if r.Type == "NS" || r.Type == "SOA" {
|
if r.Type == "NS" || r.Type == "SOA" {
|
||||||
t.Fatalf("read-only record type %s leaked into snapshot template", r.Type)
|
t.Fatalf("read-only record type %s leaked into snapshot template", r.Type)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(r.Name, "example.com") {
|
||||||
|
t.Fatalf("expected zone name parameterized to {{domain_name}} in record name, got %+v", r)
|
||||||
|
}
|
||||||
|
for _, v := range r.Values {
|
||||||
|
if strings.Contains(v, "example.com") {
|
||||||
|
t.Fatalf("expected zone name parameterized to {{domain_name}} in record value, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ts.createTemplate.Doc.Records[1].Values[0] != "v=spf1 a:mail.{{domain_name}} -all" {
|
||||||
|
t.Fatalf("expected SPF value parameterized, got %q", ts.createTemplate.Doc.Records[1].Values[0])
|
||||||
}
|
}
|
||||||
// SetDomainTemplate must have been called with the newly created template's id.
|
// SetDomainTemplate must have been called with the newly created template's id.
|
||||||
if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != ts.createTemplate.ID {
|
if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != ts.createTemplate.ID {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
|
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/tmpl"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrProviderUnavailable marks failures that happened while talking to the
|
// ErrProviderUnavailable marks failures that happened while talking to the
|
||||||
@@ -25,6 +26,7 @@ var ErrProviderUnavailable = errors.New("service: provider unavailable")
|
|||||||
// DomainRef is the minimal data the service needs about a domain.
|
// DomainRef is the minimal data the service needs about a domain.
|
||||||
type DomainRef struct {
|
type DomainRef struct {
|
||||||
ZoneID string
|
ZoneID string
|
||||||
|
ZoneName string
|
||||||
Provider string
|
Provider string
|
||||||
SecretEnc string
|
SecretEnc string
|
||||||
Template dto.TemplateDoc
|
Template dto.TemplateDoc
|
||||||
@@ -84,7 +86,7 @@ func (s *DomainService) resolve(ctx context.Context, projectID, domainID uuid.UU
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
|
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
|
||||||
}
|
}
|
||||||
cs := diff.Diff(ref.Template.ToModel(), actual)
|
cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual)
|
||||||
return p, creds, ref, cs, nil
|
return p, creds, ref, cs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func setup(t *testing.T, actual []model.Record, tmpl dto.TemplateDoc) (*DomainSe
|
|||||||
reg.Register(fp)
|
reg.Register(fp)
|
||||||
cipher := testCipher(t)
|
cipher := testCipher(t)
|
||||||
enc, _ := cipher.Encrypt([]byte("secret"))
|
enc, _ := cipher.Encrypt([]byte("secret"))
|
||||||
loader := fakeLoader{ref: DomainRef{ZoneID: "z1", Provider: "selectel", SecretEnc: enc, Template: tmpl}}
|
loader := fakeLoader{ref: DomainRef{ZoneID: "z1", ZoneName: "example.com.", Provider: "selectel", SecretEnc: enc, Template: tmpl}}
|
||||||
return New(loader, nopRecorder{}, reg, cipher), fp
|
return New(loader, nopRecorder{}, reg, cipher), fp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +85,28 @@ func TestCheckProducesDiff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCheckMaterializesDomainNamePlaceholder covers the tmpl.Materialize
|
||||||
|
// wiring in resolve: a template record using {{domain_name}} in its name and
|
||||||
|
// values must be diffed against the zone using the actual zone name (without
|
||||||
|
// trailing dot) substituted in, so a template reused across domains reports
|
||||||
|
// in-sync rather than spurious add/delete pairs.
|
||||||
|
func TestCheckMaterializesDomainNamePlaceholder(t *testing.T) {
|
||||||
|
actual := []model.Record{
|
||||||
|
{Type: model.TXT, Name: "_dmarc.example.com.", TTL: 600, Values: []string{"v=DMARC1; p=quarantine"}},
|
||||||
|
}
|
||||||
|
tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{
|
||||||
|
{Type: "TXT", Name: "_dmarc.{{domain_name}}.", TTL: 600, Values: []string{"v=DMARC1; p=quarantine"}},
|
||||||
|
}}
|
||||||
|
svc, _ := setup(t, actual, tmpl)
|
||||||
|
cs, err := svc.Check(context.Background(), uuid.New(), uuid.New())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cs.Updates()) != 0 || len(cs.Prunes()) != 0 {
|
||||||
|
t.Fatalf("expected placeholder materialized against zone name to be in sync, got updates=%+v prunes=%+v", cs.Updates(), cs.Prunes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestZoneRecordsReadsProviderDirectly covers the no-template zone-viewing
|
// TestZoneRecordsReadsProviderDirectly covers the no-template zone-viewing
|
||||||
// path: ZoneRecords must return the provider's live records with no diff
|
// path: ZoneRecords must return the provider's live records with no diff
|
||||||
// and no template involved (loader's Template field is left zero-valued).
|
// and no template involved (loader's Template field is left zero-valued).
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domai
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadDomainFull = `-- name: LoadDomainFull :one
|
const loadDomainFull = `-- name: LoadDomainFull :one
|
||||||
SELECT d.zone_id, a.provider, a.secret_enc, t.doc
|
SELECT d.zone_id, d.zone_name, a.provider, a.secret_enc, t.doc
|
||||||
FROM domains d
|
FROM domains d
|
||||||
JOIN provider_accounts a ON a.id = d.provider_account_id
|
JOIN provider_accounts a ON a.id = d.provider_account_id
|
||||||
LEFT JOIN templates t ON t.id = d.template_id
|
LEFT JOIN templates t ON t.id = d.template_id
|
||||||
@@ -198,6 +198,7 @@ type LoadDomainFullParams struct {
|
|||||||
|
|
||||||
type LoadDomainFullRow struct {
|
type LoadDomainFullRow struct {
|
||||||
ZoneID string `json:"zone_id"`
|
ZoneID string `json:"zone_id"`
|
||||||
|
ZoneName string `json:"zone_name"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
SecretEnc string `json:"secret_enc"`
|
SecretEnc string `json:"secret_enc"`
|
||||||
Doc *dto.TemplateDoc `json:"doc"`
|
Doc *dto.TemplateDoc `json:"doc"`
|
||||||
@@ -208,6 +209,7 @@ func (q *Queries) LoadDomainFull(ctx context.Context, arg LoadDomainFullParams)
|
|||||||
var i LoadDomainFullRow
|
var i LoadDomainFullRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ZoneID,
|
&i.ZoneID,
|
||||||
|
&i.ZoneName,
|
||||||
&i.Provider,
|
&i.Provider,
|
||||||
&i.SecretEnc,
|
&i.SecretEnc,
|
||||||
&i.Doc,
|
&i.Doc,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func (s *Store) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (
|
|||||||
}
|
}
|
||||||
return service.DomainRef{
|
return service.DomainRef{
|
||||||
ZoneID: row.ZoneID,
|
ZoneID: row.ZoneID,
|
||||||
|
ZoneName: row.ZoneName,
|
||||||
Provider: row.Provider,
|
Provider: row.Provider,
|
||||||
SecretEnc: row.SecretEnc,
|
SecretEnc: row.SecretEnc,
|
||||||
Template: *row.Doc,
|
Template: *row.Doc,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ SELECT * FROM domains WHERE project_id = $1 ORDER BY created_at;
|
|||||||
DELETE FROM domains WHERE id = $1 AND project_id = $2;
|
DELETE FROM domains WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
-- name: LoadDomainFull :one
|
-- name: LoadDomainFull :one
|
||||||
SELECT d.zone_id, a.provider, a.secret_enc, t.doc
|
SELECT d.zone_id, d.zone_name, a.provider, a.secret_enc, t.doc
|
||||||
FROM domains d
|
FROM domains d
|
||||||
JOIN provider_accounts a ON a.id = d.provider_account_id
|
JOIN provider_accounts a ON a.id = d.provider_account_id
|
||||||
LEFT JOIN templates t ON t.id = d.template_id
|
LEFT JOIN templates t ON t.id = d.template_id
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Package tmpl renders reusable DNS templates for a concrete zone.
|
||||||
|
//
|
||||||
|
// A template stores records containing the {{domain_name}} placeholder; it is
|
||||||
|
// materialised (placeholder -> zone name, without the trailing dot) just
|
||||||
|
// before diff/apply against a specific domain, and parameterised (zone name
|
||||||
|
// -> placeholder) when snapshotting a live zone into a reusable template.
|
||||||
|
package tmpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Placeholder = "{{domain_name}}"
|
||||||
|
|
||||||
|
func zoneName(z string) string { return strings.TrimSuffix(z, ".") }
|
||||||
|
|
||||||
|
// replaceZoneBoundary replaces occurrences of the zone name matched by re
|
||||||
|
// with Placeholder, but only where the match sits on a DNS-label boundary:
|
||||||
|
// the zone name must not be a suffix of a longer label. re must be built
|
||||||
|
// from zoneBoundaryPattern for a specific zone name. A boundary is the
|
||||||
|
// start/end of the string, or any character that cannot appear inside a DNS
|
||||||
|
// label (i.e. not a letter, digit, or hyphen — a dot qualifies). This
|
||||||
|
// prevents an external host that merely ends with the zone name as a
|
||||||
|
// substring (e.g. "notreconops.ru." against zone "reconops.ru") from being
|
||||||
|
// falsely parameterized.
|
||||||
|
//
|
||||||
|
// Replacement runs to a fixed point: a single regexp pass is non-overlapping,
|
||||||
|
// so two adjacent zone occurrences separated by a single boundary character
|
||||||
|
// (e.g. "reconops.ru.reconops.ru.") would only have the first one matched,
|
||||||
|
// since that shared separator is consumed as the first match's right
|
||||||
|
// boundary and is unavailable as the second match's left boundary. Placeholder
|
||||||
|
// ends in "}", itself a valid boundary character, so re-running the pass
|
||||||
|
// against the previous pass's output resolves this without double-counting.
|
||||||
|
func replaceZoneBoundary(s string, re *regexp.Regexp) string {
|
||||||
|
for {
|
||||||
|
next := re.ReplaceAllString(s, "${1}"+Placeholder+"${2}")
|
||||||
|
if next == s {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneBoundaryPattern builds the regexp source used to find z on a
|
||||||
|
// DNS-label boundary within a record's Name or Value.
|
||||||
|
func zoneBoundaryPattern(z string) string {
|
||||||
|
return `(^|[^a-zA-Z0-9-])` + regexp.QuoteMeta(z) + `([^a-zA-Z0-9-]|$)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Materialize renders a template's records for the given zone, substituting
|
||||||
|
// {{domain_name}} with the zone name (without the trailing dot) in each
|
||||||
|
// record's Name and every Value.
|
||||||
|
func Materialize(doc dto.TemplateDoc, zone string) []model.Record {
|
||||||
|
z := zoneName(zone)
|
||||||
|
out := make([]model.Record, 0, len(doc.Records))
|
||||||
|
for _, r := range doc.Records {
|
||||||
|
vals := make([]string, len(r.Values))
|
||||||
|
for i, v := range r.Values {
|
||||||
|
vals[i] = strings.ReplaceAll(v, Placeholder, z)
|
||||||
|
}
|
||||||
|
out = append(out, model.Record{
|
||||||
|
Type: model.RecordType(r.Type),
|
||||||
|
Name: strings.ReplaceAll(r.Name, Placeholder, z),
|
||||||
|
TTL: r.TTL,
|
||||||
|
Values: vals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterize replaces occurrences of the zone name with {{domain_name}} in
|
||||||
|
// record names and values, so a snapshot of a live zone becomes portable.
|
||||||
|
// Records with no zone-name occurrence (DKIM key, external CNAME target) are
|
||||||
|
// left unchanged.
|
||||||
|
func Parameterize(recs []model.Record, zone string) dto.TemplateDoc {
|
||||||
|
z := zoneName(zone)
|
||||||
|
re := regexp.MustCompile(zoneBoundaryPattern(z))
|
||||||
|
out := dto.TemplateDoc{Records: make([]dto.RecordDTO, 0, len(recs))}
|
||||||
|
for _, r := range recs {
|
||||||
|
vals := make([]string, len(r.Values))
|
||||||
|
for i, v := range r.Values {
|
||||||
|
vals[i] = replaceZoneBoundary(v, re)
|
||||||
|
}
|
||||||
|
out.Records = append(out.Records, dto.RecordDTO{
|
||||||
|
Type: string(r.Type),
|
||||||
|
Name: replaceZoneBoundary(r.Name, re),
|
||||||
|
TTL: r.TTL,
|
||||||
|
Values: vals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package tmpl_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/tmpl"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMaterializeReplacesInNameAndValues(t *testing.T) {
|
||||||
|
doc := dto.TemplateDoc{Records: []dto.RecordDTO{
|
||||||
|
{Type: "TXT", Name: "_dmarc.{{domain_name}}.", TTL: 600, Values: []string{"v=DMARC1; p=quarantine"}},
|
||||||
|
{Type: "MX", Name: "{{domain_name}}.", TTL: 600, Values: []string{"0 pmg2-mail.{{domain_name}}."}},
|
||||||
|
{Type: "TXT", Name: "{{domain_name}}.", TTL: 600, Values: []string{"v=spf1 a:mail.{{domain_name}} ~all"}},
|
||||||
|
}}
|
||||||
|
recs := tmpl.Materialize(doc, "reconops.ru.") // trailing dot stripped
|
||||||
|
if recs[0].Name != "_dmarc.reconops.ru." {
|
||||||
|
t.Fatalf("name: %q", recs[0].Name)
|
||||||
|
}
|
||||||
|
if recs[1].Values[0] != "0 pmg2-mail.reconops.ru." {
|
||||||
|
t.Fatalf("mx value: %q", recs[1].Values[0])
|
||||||
|
}
|
||||||
|
if recs[2].Values[0] != "v=spf1 a:mail.reconops.ru ~all" {
|
||||||
|
t.Fatalf("spf value: %q", recs[2].Values[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParameterizeIsInverseForZoneOccurrences(t *testing.T) {
|
||||||
|
recs := []model.Record{
|
||||||
|
{Type: "TXT", Name: "_dmarc.reconops.ru.", TTL: 600, Values: []string{"v=DMARC1"}},
|
||||||
|
{Type: "TXT", Name: "reconops.ru.", TTL: 600, Values: []string{"v=spf1 a:mail.reconops.ru ~all"}},
|
||||||
|
{Type: "CNAME", Name: "mail.reconops.ru.", TTL: 600, Values: []string{"amail.amega.kz."}}, // external host untouched
|
||||||
|
}
|
||||||
|
doc := tmpl.Parameterize(recs, "reconops.ru.")
|
||||||
|
if doc.Records[0].Name != "_dmarc.{{domain_name}}." {
|
||||||
|
t.Fatalf("name: %q", doc.Records[0].Name)
|
||||||
|
}
|
||||||
|
if doc.Records[1].Values[0] != "v=spf1 a:mail.{{domain_name}} ~all" {
|
||||||
|
t.Fatalf("spf: %q", doc.Records[1].Values[0])
|
||||||
|
}
|
||||||
|
if doc.Records[2].Values[0] != "amail.amega.kz." {
|
||||||
|
t.Fatalf("external cname value must be untouched: %q", doc.Records[2].Values[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParameterizeDoesNotMatchZoneNameAsSuffixOfLongerLabel(t *testing.T) {
|
||||||
|
recs := []model.Record{
|
||||||
|
{Type: "CNAME", Name: "www.reconops.ru.", TTL: 600, Values: []string{"notreconops.ru."}},
|
||||||
|
}
|
||||||
|
doc := tmpl.Parameterize(recs, "reconops.ru.")
|
||||||
|
if doc.Records[0].Values[0] != "notreconops.ru." {
|
||||||
|
t.Fatalf("external host that merely ends with the zone name must be left untouched: %q", doc.Records[0].Values[0])
|
||||||
|
}
|
||||||
|
if doc.Records[0].Name != "www.{{domain_name}}." {
|
||||||
|
t.Fatalf("name: %q", doc.Records[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParameterizeApexAndSubdomainBoundaries(t *testing.T) {
|
||||||
|
recs := []model.Record{
|
||||||
|
{Type: "A", Name: "reconops.ru.", TTL: 600, Values: []string{"1.2.3.4"}},
|
||||||
|
{Type: "TXT", Name: "_dmarc.reconops.ru.", TTL: 600, Values: []string{"v=DMARC1; p=quarantine"}},
|
||||||
|
{Type: "MX", Name: "reconops.ru.", TTL: 600, Values: []string{"0 pmg2-mail.reconops.ru."}},
|
||||||
|
}
|
||||||
|
doc := tmpl.Parameterize(recs, "reconops.ru.")
|
||||||
|
if doc.Records[0].Name != "{{domain_name}}." {
|
||||||
|
t.Fatalf("apex name: %q", doc.Records[0].Name)
|
||||||
|
}
|
||||||
|
if doc.Records[1].Name != "_dmarc.{{domain_name}}." {
|
||||||
|
t.Fatalf("sub name: %q", doc.Records[1].Name)
|
||||||
|
}
|
||||||
|
if doc.Records[2].Values[0] != "0 pmg2-mail.{{domain_name}}." {
|
||||||
|
t.Fatalf("mx value: %q", doc.Records[2].Values[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParameterizeReplacesAllOccurrencesInSPFRecord(t *testing.T) {
|
||||||
|
recs := []model.Record{
|
||||||
|
{Type: "TXT", Name: "reconops.ru.", TTL: 600, Values: []string{"v=spf1 a:mail.reconops.ru a:pmg2-mail.reconops.ru ~all"}},
|
||||||
|
}
|
||||||
|
doc := tmpl.Parameterize(recs, "reconops.ru.")
|
||||||
|
want := "v=spf1 a:mail.{{domain_name}} a:pmg2-mail.{{domain_name}} ~all"
|
||||||
|
if doc.Records[0].Values[0] != want {
|
||||||
|
t.Fatalf("spf: got %q, want %q", doc.Records[0].Values[0], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParameterizeReplacesAdjacentZoneOccurrencesSharingASingleBoundary(t *testing.T) {
|
||||||
|
// Two zone-name occurrences separated by exactly one boundary character
|
||||||
|
// (the dot). A single non-overlapping regexp pass would only catch the
|
||||||
|
// first occurrence, since the shared "." is consumed as its right
|
||||||
|
// boundary and is then unavailable as the second occurrence's left
|
||||||
|
// boundary. replaceZoneBoundary must still resolve both.
|
||||||
|
recs := []model.Record{
|
||||||
|
{Type: "TXT", Name: "reconops.ru.", TTL: 600, Values: []string{"reconops.ru.reconops.ru."}},
|
||||||
|
}
|
||||||
|
doc := tmpl.Parameterize(recs, "reconops.ru.")
|
||||||
|
want := "{{domain_name}}.{{domain_name}}."
|
||||||
|
if doc.Records[0].Values[0] != want {
|
||||||
|
t.Fatalf("adjacent occurrences: got %q, want %q", doc.Records[0].Values[0], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,12 @@ test("удаление записи вызывает onChange без удалё
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("подсказка про плейсхолдер {{domain_name}} отображается в редакторе", () => {
|
||||||
|
render(<Harness initial={[]} onChange={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/\{\{domain_name\}\}/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
test("mono-шрифт применён к полям name и values", () => {
|
test("mono-шрифт применён к полям name и values", () => {
|
||||||
render(
|
render(
|
||||||
<Harness
|
<Harness
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Plus, Trash2 } from "lucide-react"
|
import { Info, Plus, Trash2 } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { FieldDescription } from "@/components/ui/field"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -42,6 +43,15 @@ export function RecordEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<FieldDescription className="flex items-start gap-2 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5">
|
||||||
|
<Info className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" strokeWidth={1.75} />
|
||||||
|
<span>
|
||||||
|
Используйте <code className="font-dns text-foreground">{"{{domain_name}}"}</code> в имени
|
||||||
|
или значении записи — при проверке домена подставится имя его зоны (без завершающей
|
||||||
|
точки).
|
||||||
|
</span>
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
{value.map((record, index) => (
|
{value.map((record, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
Reference in New Issue
Block a user