diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 47b1e72..a5823c0 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -295,6 +295,18 @@ components:
- DOMAINS
- IP_RANGES
+ DohResolverPolicy:
+ type: string
+ description: |
+ Политика резолва доменов через DoH при нескольких профилях.
+ `primary_only` — только первый профиль; `failover` — по порядку до первого успешного;
+ `union` — объединение A/AAAA со всех профилей.
+ enum:
+ - primary_only
+ - failover
+ - union
+ default: primary_only
+
JobStatus:
type: string
description: Статус задачи; перечень может расширяться.
@@ -332,6 +344,14 @@ components:
type: integer
doh_profile_id:
type: ["string", "null"]
+ description: Первый DoH-профиль (legacy); предпочтительно `doh_profile_ids`.
+ doh_profile_ids:
+ type: array
+ items:
+ $ref: "#/components/schemas/ResourceId"
+ description: Упорядоченный список DoH-профилей для модулей `DOMAINS`.
+ doh_resolver_policy:
+ $ref: "#/components/schemas/DohResolverPolicy"
refresh_interval_sec:
type: ["integer", "null"]
minimum: 0
@@ -363,6 +383,14 @@ components:
default: 0
doh_profile_id:
type: ["string", "null"]
+ description: Первый DoH-профиль (legacy); предпочтительно `doh_profile_ids`.
+ doh_profile_ids:
+ type: array
+ items:
+ $ref: "#/components/schemas/ResourceId"
+ description: Упорядоченный список DoH-профилей для модулей `DOMAINS`.
+ doh_resolver_policy:
+ $ref: "#/components/schemas/DohResolverPolicy"
refresh_interval_sec:
type: ["integer", "null"]
last_refreshed_at:
@@ -385,6 +413,14 @@ components:
type: integer
doh_profile_id:
type: ["string", "null"]
+ description: Первый DoH-профиль (legacy); предпочтительно `doh_profile_ids`.
+ doh_profile_ids:
+ type: array
+ items:
+ $ref: "#/components/schemas/ResourceId"
+ description: Упорядоченный список DoH-профилей для модулей `DOMAINS`.
+ doh_resolver_policy:
+ $ref: "#/components/schemas/DohResolverPolicy"
refresh_interval_sec:
type: ["integer", "null"]
cron_expr:
diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go
index 2310a77..eb79e76 100644
--- a/internal/httpapi/routes.go
+++ b/internal/httpapi/routes.go
@@ -122,6 +122,13 @@ func moduleJSON(mod *store.Module) map[string]any {
} else {
m["default_community_id"] = nil
}
+ ids := mod.EffectiveDohProfileIDs()
+ if len(ids) > 0 {
+ m["doh_profile_ids"] = ids
+ } else {
+ m["doh_profile_ids"] = []string{}
+ }
+ m["doh_resolver_policy"] = store.NormalizeDohResolverPolicy(mod.DohResolverPolicy)
if mod.DohProfileID != nil {
m["doh_profile_id"] = *mod.DohProfileID
} else {
diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go
index af0c093..9b992e5 100644
--- a/internal/httpapi/routes_crud.go
+++ b/internal/httpapi/routes_crud.go
@@ -75,14 +75,16 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
- Type string `json:"type"`
- Name string `json:"name"`
- Enabled bool `json:"enabled"`
- Priority int `json:"priority"`
- RefreshIntervalSec int `json:"refresh_interval_sec"`
- CronExpr string `json:"cron_expr"`
- DefaultCommunityID *string `json:"default_community_id"`
- DohProfileID *string `json:"doh_profile_id"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ Priority int `json:"priority"`
+ RefreshIntervalSec int `json:"refresh_interval_sec"`
+ CronExpr string `json:"cron_expr"`
+ DefaultCommunityID *string `json:"default_community_id"`
+ DohProfileID *string `json:"doh_profile_id"`
+ DohProfileIDs []string `json:"doh_profile_ids"`
+ DohResolverPolicy string `json:"doh_resolver_policy"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
@@ -92,6 +94,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
+ DohProfileIDs: body.DohProfileIDs, DohResolverPolicy: body.DohResolverPolicy,
})
if err != nil {
writeStoreErr(w, err)
@@ -129,6 +132,14 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
empty := ""
body.DohProfileID = &empty
}
+ if v, ok := raw["doh_profile_ids"]; ok && string(v) == "null" {
+ empty := []string{}
+ body.DohProfileIDs = &empty
+ }
+ if v, ok := raw["doh_resolver_policy"]; ok && string(v) == "null" {
+ p := store.DohPolicyPrimaryOnly
+ body.DohResolverPolicy = &p
+ }
if v, ok := raw["cron_expr"]; ok && string(v) == "null" {
empty := ""
body.CronExpr = &empty
diff --git a/internal/pipeline/collect_parallel.go b/internal/pipeline/collect_parallel.go
index 5379e44..3e1cbc5 100644
--- a/internal/pipeline/collect_parallel.go
+++ b/internal/pipeline/collect_parallel.go
@@ -175,7 +175,7 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
return out, nil
}
-func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profile *store.DohProfile, entries []*store.DomainEntry) ([]store.PrefixRow, error) {
+func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profiles []*store.DohProfile, policy string, entries []*store.DomainEntry) ([]store.PrefixRow, error) {
var validDom []*store.DomainEntry
for _, e := range entries {
if e != nil {
@@ -202,7 +202,7 @@ func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Mo
c := *mod.DefaultCommunityID
comm = &c
}
- addrs, err := resolveDomainIPs(ctx, hc, profile, entry.FQDN)
+ addrs, err := resolveDomainIPsWithPolicy(ctx, hc, profiles, policy, entry.FQDN)
if err != nil {
results[idx] = domResult{err: fmt.Errorf("resolve domain %q: %w", entry.FQDN, err)}
return
diff --git a/internal/pipeline/doh_resolve.go b/internal/pipeline/doh_resolve.go
new file mode 100644
index 0000000..19e751c
--- /dev/null
+++ b/internal/pipeline/doh_resolve.go
@@ -0,0 +1,131 @@
+package pipeline
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/netip"
+ "strings"
+ "time"
+
+ "evobgp/internal/store"
+
+ "github.com/miekg/dns"
+)
+
+func loadModuleDohProfiles(st store.Backend, tenantID string, mod *store.Module) ([]*store.DohProfile, string, error) {
+ if mod == nil {
+ return nil, store.DohPolicyPrimaryOnly, nil
+ }
+ policy := store.NormalizeDohResolverPolicy(mod.DohResolverPolicy)
+ var profiles []*store.DohProfile
+ for _, id := range mod.EffectiveDohProfileIDs() {
+ prof, err := st.GetDohProfile(tenantID, id)
+ if err != nil {
+ return nil, "", fmt.Errorf("get doh profile %s: %w", id, err)
+ }
+ profiles = append(profiles, prof)
+ }
+ return profiles, policy, nil
+}
+
+func resolveDomainIPsWithPolicy(ctx context.Context, hc *http.Client, profiles []*store.DohProfile, policy, fqdn string) ([]netip.Addr, error) {
+ policy = store.NormalizeDohResolverPolicy(policy)
+ if len(profiles) == 0 {
+ return resolveDomainIPs(ctx, hc, nil, fqdn)
+ }
+ if len(profiles) == 1 {
+ return resolveDomainIPs(ctx, hc, profiles[0], fqdn)
+ }
+
+ switch policy {
+ case store.DohPolicyUnion:
+ return resolveDomainIPsUnion(ctx, hc, profiles, fqdn)
+ case store.DohPolicyFailover:
+ return resolveDomainIPsFailover(ctx, hc, profiles, fqdn)
+ default:
+ return resolveDomainIPs(ctx, hc, profiles[0], fqdn)
+ }
+}
+
+func resolveDomainIPsUnion(ctx context.Context, hc *http.Client, profiles []*store.DohProfile, fqdn string) ([]netip.Addr, error) {
+ var merged []netip.Addr
+ var errs []error
+ for _, prof := range profiles {
+ if prof == nil {
+ continue
+ }
+ ips, err := resolveDomainIPsNoSystemFallback(ctx, hc, prof, fqdn)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("%s: %w", strings.TrimSpace(prof.URL), err))
+ continue
+ }
+ merged = append(merged, ips...)
+ }
+ merged = uniqAddrs(merged)
+ if len(merged) > 0 {
+ return merged, nil
+ }
+ if len(errs) > 0 {
+ return nil, fmt.Errorf("doh union failed: %v", errs)
+ }
+ return nil, nil
+}
+
+func resolveDomainIPsFailover(ctx context.Context, hc *http.Client, profiles []*store.DohProfile, fqdn string) ([]netip.Addr, error) {
+ var lastErr error
+ for _, prof := range profiles {
+ if prof == nil {
+ continue
+ }
+ ips, err := resolveDomainIPsNoSystemFallback(ctx, hc, prof, fqdn)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ if len(ips) > 0 {
+ return ips, nil
+ }
+ }
+ if lastErr != nil {
+ return nil, lastErr
+ }
+ return resolveDomainIPs(ctx, hc, nil, fqdn)
+}
+
+// resolveDomainIPsNoSystemFallback queries one DoH profile without falling back to OS resolver.
+func resolveDomainIPsNoSystemFallback(ctx context.Context, hc *http.Client, profile *store.DohProfile, fqdn string) ([]netip.Addr, error) {
+ host := strings.TrimSpace(strings.TrimSuffix(fqdn, "."))
+ if host == "" {
+ return nil, nil
+ }
+ if profile == nil || strings.TrimSpace(profile.URL) == "" {
+ return nil, fmt.Errorf("empty doh profile")
+ }
+
+ timeout := dohProfileTimeout(profile)
+ dctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ baseURL := strings.TrimSpace(profile.URL)
+ v4, err4 := resolveDomainWithDOHMessage(dctx, hc, baseURL, host, dns.TypeA)
+ v6, err6 := resolveDomainWithDOHMessage(dctx, hc, baseURL, host, dns.TypeAAAA)
+ if err4 != nil {
+ v4, err4 = resolveDomainWithDOHJSON(dctx, hc, baseURL, host, "A")
+ }
+ if err6 != nil {
+ v6, err6 = resolveDomainWithDOHJSON(dctx, hc, baseURL, host, "AAAA")
+ }
+ if err4 != nil && err6 != nil {
+ return nil, fmt.Errorf("doh failed for A and AAAA: %v; %v", err4, err6)
+ }
+ return uniqAddrs(append(v4, v6...)), nil
+}
+
+func dohProfileTimeout(profile *store.DohProfile) time.Duration {
+ timeout := 10 * time.Second
+ if profile != nil && profile.TimeoutMs != nil && *profile.TimeoutMs > 0 {
+ timeout = time.Duration(*profile.TimeoutMs) * time.Millisecond
+ }
+ return timeout
+}
diff --git a/internal/pipeline/doh_resolve_test.go b/internal/pipeline/doh_resolve_test.go
new file mode 100644
index 0000000..e88e5b4
--- /dev/null
+++ b/internal/pipeline/doh_resolve_test.go
@@ -0,0 +1,148 @@
+package pipeline
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "evobgp/internal/store"
+)
+
+func TestResolveDomainIPsWithPolicy_Union(t *testing.T) {
+ srvRU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.1"}]}`))
+ }))
+ defer srvRU.Close()
+ srvEU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"203.0.113.1"}]}`))
+ }))
+ defer srvEU.Close()
+
+ profiles := []*store.DohProfile{
+ {URL: srvRU.URL},
+ {URL: srvEU.URL},
+ }
+ ips, err := resolveDomainIPsWithPolicy(context.Background(), srvRU.Client(), profiles, store.DohPolicyUnion, "example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ips) != 2 {
+ t.Fatalf("want 2 ips, got %v", ips)
+ }
+ seen := map[string]bool{ips[0].String(): true, ips[1].String(): true}
+ if !seen["198.51.100.1"] || !seen["203.0.113.1"] {
+ t.Fatalf("unexpected ips: %v", ips)
+ }
+}
+
+func TestResolveDomainIPsWithPolicy_Failover(t *testing.T) {
+ var calls int
+ srvBad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ http.Error(w, "fail", http.StatusBadGateway)
+ }))
+ defer srvBad.Close()
+ srvOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ _, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.5"}]}`))
+ }))
+ defer srvOK.Close()
+
+ profiles := []*store.DohProfile{
+ {URL: srvBad.URL},
+ {URL: srvOK.URL},
+ }
+ ips, err := resolveDomainIPsWithPolicy(context.Background(), srvBad.Client(), profiles, store.DohPolicyFailover, "example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ips) != 1 || ips[0].String() != "198.51.100.5" {
+ t.Fatalf("unexpected ips: %v", ips)
+ }
+ if calls < 2 {
+ t.Fatalf("want at least 2 resolver calls, got %d", calls)
+ }
+}
+
+func TestResolveDomainIPsWithPolicy_PrimaryOnly(t *testing.T) {
+ var secondCalled bool
+ srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.9"}]}`))
+ }))
+ defer srv1.Close()
+ srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ secondCalled = true
+ _, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"203.0.113.9"}]}`))
+ }))
+ defer srv2.Close()
+
+ profiles := []*store.DohProfile{
+ {URL: srv1.URL},
+ {URL: srv2.URL},
+ }
+ ips, err := resolveDomainIPsWithPolicy(context.Background(), srv1.Client(), profiles, store.DohPolicyPrimaryOnly, "example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ips) != 1 || ips[0].String() != "198.51.100.9" {
+ t.Fatalf("unexpected ips: %v", ips)
+ }
+ if secondCalled {
+ t.Fatal("secondary resolver must not be queried in primary_only mode")
+ }
+}
+
+func TestCollectModulePrefixRows_DohUnion(t *testing.T) {
+ m := store.NewMemory()
+ m.SeedDemo()
+ tenant, _, _, _, _ := m.DemoIDs()
+
+ mod, err := m.CreateModule(tenant, &store.Module{
+ Type: "DOMAINS",
+ Name: "domains-union",
+ Enabled: true,
+ DohResolverPolicy: store.DohPolicyUnion,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ srvRU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.2"}]}`))
+ }))
+ defer srvRU.Close()
+ srvEU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"203.0.113.2"}]}`))
+ }))
+ defer srvEU.Close()
+
+ ru, err := m.CreateDohProfile(tenant, &store.DohProfile{Name: "ru", URL: srvRU.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ eu, err := m.CreateDohProfile(tenant, &store.DohProfile{Name: "eu", URL: srvEU.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := m.UpdateModule(tenant, mod.ID, &store.ModulePatch{
+ DohProfileIDs: &[]string{ru.ID, eu.ID},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ mod, err = m.GetModule(tenant, mod.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := m.CreateDomainEntry(tenant, mod.ID, &store.DomainEntry{FQDN: "svc.example.com"}); err != nil {
+ t.Fatal(err)
+ }
+
+ rows, err := collectModulePrefixRows(context.Background(), m, srvRU.Client(), tenant, mod, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 2 {
+ t.Fatalf("want 2 prefix rows, got %+v", rows)
+ }
+}
diff --git a/internal/pipeline/module_hash.go b/internal/pipeline/module_hash.go
index 2e3e633..4e0a24d 100644
--- a/internal/pipeline/module_hash.go
+++ b/internal/pipeline/module_hash.go
@@ -21,14 +21,13 @@ func moduleIngestInputHash(st store.Backend, tenantID string, mod *store.Module)
if mod.DefaultCommunityID != nil {
_, _ = fmt.Fprintf(h, "default_community=%s\n", strings.TrimSpace(*mod.DefaultCommunityID))
}
- if mod.DohProfileID != nil {
- _, _ = fmt.Fprintf(h, "doh_profile=%s\n", strings.TrimSpace(*mod.DohProfileID))
- if pid := strings.TrimSpace(*mod.DohProfileID); pid != "" {
- if prof, err := st.GetDohProfile(tenantID, pid); err == nil && prof != nil {
- _, _ = fmt.Fprintf(h, "doh_url=%s\n", strings.TrimSpace(prof.URL))
- if prof.TimeoutMs != nil {
- _, _ = fmt.Fprintf(h, "doh_timeout=%d\n", *prof.TimeoutMs)
- }
+ _, _ = fmt.Fprintf(h, "doh_policy=%s\n", store.NormalizeDohResolverPolicy(mod.DohResolverPolicy))
+ for _, pid := range mod.EffectiveDohProfileIDs() {
+ _, _ = fmt.Fprintf(h, "doh_profile=%s\n", pid)
+ if prof, err := st.GetDohProfile(tenantID, pid); err == nil && prof != nil {
+ _, _ = fmt.Fprintf(h, "doh_url=%s\n", strings.TrimSpace(prof.URL))
+ if prof.TimeoutMs != nil {
+ _, _ = fmt.Fprintf(h, "doh_timeout=%d\n", *prof.TimeoutMs)
}
}
}
diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go
index 6d63602..7635a32 100644
--- a/internal/pipeline/refresh.go
+++ b/internal/pipeline/refresh.go
@@ -171,14 +171,11 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
if err != nil {
return nil, err
}
- var profile *store.DohProfile
- if mod.DohProfileID != nil && strings.TrimSpace(*mod.DohProfileID) != "" {
- profile, err = st.GetDohProfile(tenantID, strings.TrimSpace(*mod.DohProfileID))
- if err != nil {
- return nil, fmt.Errorf("get doh profile: %w", err)
- }
+ profiles, policy, err := loadModuleDohProfiles(st, tenantID, mod)
+ if err != nil {
+ return nil, err
}
- return collectDomainPrefixRows(ctx, hc, mod, profile, entries)
+ return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries)
default:
return nil, fmt.Errorf("unknown module type %q", mod.Type)
}
diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go
index 919581f..f4386e4 100644
--- a/internal/repository/postgres.go
+++ b/internal/repository/postgres.go
@@ -113,7 +113,8 @@ func (p *Postgres) PeerSessionCountsByState() map[string]int {
func (p *Postgres) ListModules(tenantID string) []*store.Module {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
- SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
+ SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
+ refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
if err != nil {
return nil
@@ -126,9 +127,10 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
var doh, dc, cron *string
var refresh *int32
var last *time.Time
- if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &cron, &dc, &last); err != nil {
+ if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
continue
}
+ m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
if refresh != nil {
m.RefreshIntervalSec = int(*refresh)
}
@@ -145,6 +147,9 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
t := last.UTC()
m.LastRefreshedAt = &t
}
+ if err := p.fillModuleDohFields(ctx, &m); err != nil {
+ continue
+ }
out = append(out, &m)
}
return out
@@ -158,9 +163,10 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
var refresh *int32
var last *time.Time
err := p.pool.QueryRow(ctx, `
- SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
+ SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
+ refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
- &m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &cron, &dc, &last)
+ &m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
@@ -183,6 +189,10 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
t := last.UTC()
m.LastRefreshedAt = &t
}
+ m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
+ if err := p.fillModuleDohFields(ctx, &m); err != nil {
+ return nil, err
+ }
return &m, nil
}
@@ -190,6 +200,7 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
if in == nil {
return nil, store.ErrInvalidInput
}
+ store.NormalizeModuleDoh(in)
ctx := context.Background()
id := uuid.NewString()
var doh, dc any
@@ -211,13 +222,17 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
if in.LastRefreshedAt != nil {
lastArg = in.LastRefreshedAt.UTC()
}
+ policy := store.NormalizeDohResolverPolicy(in.DohResolverPolicy)
_, err := p.pool.Exec(ctx, `
- INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
- id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, ri, cronArg, dc, lastArg)
+ INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
+ id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg)
if err != nil {
return nil, err
}
+ if err := p.setModuleDohProfiles(ctx, id, in.DohProfileIDs); err != nil {
+ return nil, err
+ }
return p.GetModule(tenantID, id)
}
@@ -230,77 +245,68 @@ func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePa
if err != nil {
return nil, err
}
- name := base.Name
- en := base.Enabled
- pr := base.Priority
- ri := base.RefreshIntervalSec
- cron := base.CronExpr
- var dc, doh *string
- dc = base.DefaultCommunityID
- doh = base.DohProfileID
- last := base.LastRefreshedAt
+ work := *base
if patch.Name != nil {
- name = strings.TrimSpace(*patch.Name)
+ work.Name = strings.TrimSpace(*patch.Name)
}
if patch.Enabled != nil {
- en = *patch.Enabled
+ work.Enabled = *patch.Enabled
}
if patch.Priority != nil {
- pr = *patch.Priority
+ work.Priority = *patch.Priority
}
if patch.RefreshIntervalSec != nil {
- ri = *patch.RefreshIntervalSec
+ work.RefreshIntervalSec = *patch.RefreshIntervalSec
}
if patch.CronExpr != nil {
- cron = *patch.CronExpr
+ work.CronExpr = *patch.CronExpr
}
if patch.DefaultCommunityID != nil {
v := strings.TrimSpace(*patch.DefaultCommunityID)
if v == "" {
- dc = nil
+ work.DefaultCommunityID = nil
} else {
- dc = &v
- }
- }
- if patch.DohProfileID != nil {
- v := strings.TrimSpace(*patch.DohProfileID)
- if v == "" {
- doh = nil
- } else {
- doh = &v
+ work.DefaultCommunityID = &v
}
}
+ store.ApplyModuleDohPatch(&work, patch)
if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC()
- last = &t
+ work.LastRefreshedAt = &t
}
var dcArg, dohArg any
- if dc != nil {
- dcArg = *dc
+ if work.DefaultCommunityID != nil {
+ dcArg = *work.DefaultCommunityID
}
- if doh != nil {
- dohArg = *doh
+ if work.DohProfileID != nil {
+ dohArg = *work.DohProfileID
}
var riArg any
- if ri != 0 {
- riArg = ri
+ if work.RefreshIntervalSec != 0 {
+ riArg = work.RefreshIntervalSec
}
var cronArg any
- if strings.TrimSpace(cron) != "" {
- cronArg = strings.TrimSpace(cron)
+ if strings.TrimSpace(work.CronExpr) != "" {
+ cronArg = strings.TrimSpace(work.CronExpr)
}
var lastArg any
- if last != nil {
- lastArg = last.UTC()
+ if work.LastRefreshedAt != nil {
+ lastArg = work.LastRefreshedAt.UTC()
}
+ policy := store.NormalizeDohResolverPolicy(work.DohResolverPolicy)
_, err = p.pool.Exec(ctx, `
UPDATE module SET name=$3, enabled=$4, priority=$5, refresh_interval_sec=$6, cron_expr=$7,
- default_community_id=$8, doh_profile_id=$9, last_refreshed_at=$10, updated_at=now()
+ default_community_id=$8, doh_profile_id=$9, doh_resolver_policy=$10, last_refreshed_at=$11, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL`,
- moduleID, tenantID, name, en, pr, riArg, cronArg, dcArg, dohArg, lastArg)
+ moduleID, tenantID, work.Name, work.Enabled, work.Priority, riArg, cronArg, dcArg, dohArg, policy, lastArg)
if err != nil {
return nil, err
}
+ if patch.DohProfileIDs != nil || patch.DohProfileID != nil {
+ if err := p.setModuleDohProfiles(ctx, moduleID, work.DohProfileIDs); err != nil {
+ return nil, err
+ }
+ }
return p.GetModule(tenantID, moduleID)
}
@@ -1041,6 +1047,13 @@ func (p *Postgres) UpdateDohProfile(tenantID, id string, patch *store.DohProfile
func (p *Postgres) DeleteDohProfile(tenantID, id string) error {
ctx := context.Background()
+ inUse, err := p.moduleDohProfileInUse(ctx, id)
+ if err != nil {
+ return err
+ }
+ if inUse {
+ return store.ErrInvalidInput
+ }
var n int
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM module WHERE doh_profile_id=$1::uuid AND deleted_at IS NULL`, id).Scan(&n)
if n > 0 {
diff --git a/internal/repository/postgres_module_doh.go b/internal/repository/postgres_module_doh.go
new file mode 100644
index 0000000..3b2b98b
--- /dev/null
+++ b/internal/repository/postgres_module_doh.go
@@ -0,0 +1,67 @@
+package repository
+
+import (
+ "context"
+
+ "evobgp/internal/store"
+)
+
+func (p *Postgres) fillModuleDohFields(ctx context.Context, m *store.Module) error {
+ if m == nil {
+ return nil
+ }
+ rows, err := p.pool.Query(ctx, `
+ SELECT doh_profile_id::text
+ FROM module_doh_profile
+ WHERE module_id = $1
+ ORDER BY sort_order, doh_profile_id`, m.ID)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return err
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ return err
+ }
+ m.DohProfileIDs = store.NormalizeDohProfileIDList(ids)
+ m.SyncLegacyDohProfileID()
+ return nil
+}
+
+func (p *Postgres) setModuleDohProfiles(ctx context.Context, moduleID string, ids []string) error {
+ ids = store.NormalizeDohProfileIDList(ids)
+ if _, err := p.pool.Exec(ctx, `DELETE FROM module_doh_profile WHERE module_id = $1`, moduleID); err != nil {
+ return err
+ }
+ for i, id := range ids {
+ if _, err := p.pool.Exec(ctx, `
+ INSERT INTO module_doh_profile (module_id, doh_profile_id, sort_order)
+ VALUES ($1, $2, $3)`, moduleID, id, i); err != nil {
+ return err
+ }
+ }
+ var dohArg any
+ if len(ids) > 0 {
+ dohArg = ids[0]
+ }
+ _, err := p.pool.Exec(ctx, `UPDATE module SET doh_profile_id = $2, updated_at = now() WHERE id = $1`, moduleID, dohArg)
+ return err
+}
+
+func (p *Postgres) moduleDohProfileInUse(ctx context.Context, dohProfileID string) (bool, error) {
+ var n int
+ if err := p.pool.QueryRow(ctx, `
+ SELECT COUNT(*) FROM module_doh_profile mdp
+ JOIN module m ON m.id = mdp.module_id
+ WHERE mdp.doh_profile_id = $1::uuid AND m.deleted_at IS NULL`, dohProfileID).Scan(&n); err != nil {
+ return false, err
+ }
+ return n > 0, nil
+}
diff --git a/internal/store/backend.go b/internal/store/backend.go
index e25d45c..14c8b26 100644
--- a/internal/store/backend.go
+++ b/internal/store/backend.go
@@ -114,6 +114,8 @@ type ModulePatch struct {
CronExpr *string `json:"cron_expr,omitempty"`
DefaultCommunityID *string `json:"default_community_id,omitempty"`
DohProfileID *string `json:"doh_profile_id,omitempty"`
+ DohProfileIDs *[]string `json:"doh_profile_ids,omitempty"`
+ DohResolverPolicy *string `json:"doh_resolver_policy,omitempty"`
LastRefreshedAt *time.Time `json:"-"`
}
diff --git a/internal/store/doh_module.go b/internal/store/doh_module.go
new file mode 100644
index 0000000..c8f019e
--- /dev/null
+++ b/internal/store/doh_module.go
@@ -0,0 +1,110 @@
+package store
+
+import "strings"
+
+const (
+ DohPolicyPrimaryOnly = "primary_only"
+ DohPolicyFailover = "failover"
+ DohPolicyUnion = "union"
+)
+
+// NormalizeDohResolverPolicy returns a supported resolver policy name.
+func NormalizeDohResolverPolicy(policy string) string {
+ switch strings.TrimSpace(policy) {
+ case DohPolicyFailover, DohPolicyUnion:
+ return strings.TrimSpace(policy)
+ default:
+ return DohPolicyPrimaryOnly
+ }
+}
+
+// NormalizeDohProfileIDList deduplicates profile ids preserving order.
+func NormalizeDohProfileIDList(ids []string) []string {
+ if len(ids) == 0 {
+ return nil
+ }
+ seen := make(map[string]struct{}, len(ids))
+ out := make([]string, 0, len(ids))
+ for _, id := range ids {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+// EffectiveDohProfileIDs returns ordered DoH profile ids for a module.
+func (m *Module) EffectiveDohProfileIDs() []string {
+ if m == nil {
+ return nil
+ }
+ if ids := NormalizeDohProfileIDList(m.DohProfileIDs); len(ids) > 0 {
+ return ids
+ }
+ if m.DohProfileID != nil {
+ if id := strings.TrimSpace(*m.DohProfileID); id != "" {
+ return []string{id}
+ }
+ }
+ return nil
+}
+
+// SyncLegacyDohProfileID keeps deprecated doh_profile_id aligned with the first profile.
+func (m *Module) SyncLegacyDohProfileID() {
+ if m == nil {
+ return
+ }
+ ids := NormalizeDohProfileIDList(m.DohProfileIDs)
+ if len(ids) == 0 {
+ m.DohProfileID = nil
+ return
+ }
+ first := ids[0]
+ m.DohProfileID = &first
+}
+
+// NormalizeModuleDoh fills doh_profile_ids, policy and legacy id from module input.
+func NormalizeModuleDoh(m *Module) {
+ if m == nil {
+ return
+ }
+ ids := NormalizeDohProfileIDList(m.DohProfileIDs)
+ if len(ids) == 0 && m.DohProfileID != nil {
+ if id := strings.TrimSpace(*m.DohProfileID); id != "" {
+ ids = []string{id}
+ }
+ }
+ m.DohProfileIDs = ids
+ m.DohResolverPolicy = NormalizeDohResolverPolicy(m.DohResolverPolicy)
+ m.SyncLegacyDohProfileID()
+}
+
+// ApplyModuleDohPatch merges DoH-related fields from patch into mod.
+func ApplyModuleDohPatch(mod *Module, patch *ModulePatch) {
+ if mod == nil || patch == nil {
+ return
+ }
+ if patch.DohProfileIDs != nil {
+ mod.DohProfileIDs = NormalizeDohProfileIDList(*patch.DohProfileIDs)
+ } else if patch.DohProfileID != nil {
+ v := strings.TrimSpace(*patch.DohProfileID)
+ if v == "" {
+ mod.DohProfileIDs = nil
+ } else {
+ mod.DohProfileIDs = []string{v}
+ }
+ }
+ if patch.DohResolverPolicy != nil {
+ mod.DohResolverPolicy = NormalizeDohResolverPolicy(*patch.DohResolverPolicy)
+ }
+ mod.SyncLegacyDohProfileID()
+}
diff --git a/internal/store/doh_module_test.go b/internal/store/doh_module_test.go
new file mode 100644
index 0000000..daa4bcb
--- /dev/null
+++ b/internal/store/doh_module_test.go
@@ -0,0 +1,32 @@
+package store
+
+import "testing"
+
+func TestNormalizeDohProfileIDList(t *testing.T) {
+ got := NormalizeDohProfileIDList([]string{" a ", "b", "a", "", "b"})
+ if len(got) != 2 || got[0] != "a" || got[1] != "b" {
+ t.Fatalf("unexpected: %v", got)
+ }
+}
+
+func TestApplyModuleDohPatch(t *testing.T) {
+ mod := &Module{DohProfileIDs: []string{"one"}, DohResolverPolicy: DohPolicyPrimaryOnly}
+ ids := []string{"ru", "eu"}
+ policy := DohPolicyUnion
+ ApplyModuleDohPatch(mod, &ModulePatch{DohProfileIDs: &ids, DohResolverPolicy: &policy})
+ if len(mod.DohProfileIDs) != 2 || mod.DohResolverPolicy != DohPolicyUnion {
+ t.Fatalf("unexpected module doh fields: %+v", mod)
+ }
+ if mod.DohProfileID == nil || *mod.DohProfileID != "ru" {
+ t.Fatalf("legacy id not synced: %+v", mod.DohProfileID)
+ }
+}
+
+func TestEffectiveDohProfileIDs_LegacyField(t *testing.T) {
+ id := "legacy-id"
+ mod := &Module{DohProfileID: &id}
+ got := mod.EffectiveDohProfileIDs()
+ if len(got) != 1 || got[0] != "legacy-id" {
+ t.Fatalf("unexpected: %v", got)
+ }
+}
diff --git a/internal/store/memory.go b/internal/store/memory.go
index 1f22519..94b41ba 100644
--- a/internal/store/memory.go
+++ b/internal/store/memory.go
@@ -72,7 +72,9 @@ type Module struct {
CronExpr string // optional cron for scheduler (display / future use)
Priority int
DefaultCommunityID *string
- DohProfileID *string
+ DohProfileID *string // deprecated: first id in DohProfileIDs
+ DohProfileIDs []string
+ DohResolverPolicy string
LastRefreshedAt *time.Time
DeletedAt *time.Time
}
diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go
index bc8ceee..cff51c5 100644
--- a/internal/store/memory_crud.go
+++ b/internal/store/memory_crud.go
@@ -31,9 +31,11 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
RefreshIntervalSec: in.RefreshIntervalSec,
CronExpr: in.CronExpr,
DefaultCommunityID: in.DefaultCommunityID,
- DohProfileID: in.DohProfileID,
+ DohProfileIDs: append([]string(nil), in.DohProfileIDs...),
+ DohResolverPolicy: in.DohResolverPolicy,
LastRefreshedAt: in.LastRefreshedAt,
}
+ NormalizeModuleDoh(mod)
m.modules[id] = mod
return mod, nil
}
@@ -71,14 +73,7 @@ func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*M
mod.DefaultCommunityID = &v
}
}
- if patch.DohProfileID != nil {
- v := strings.TrimSpace(*patch.DohProfileID)
- if v == "" {
- mod.DohProfileID = nil
- } else {
- mod.DohProfileID = &v
- }
- }
+ ApplyModuleDohPatch(mod, patch)
if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC()
mod.LastRefreshedAt = &t
@@ -555,8 +550,13 @@ func (m *Memory) DeleteDohProfile(tenantID, id string) error {
return ErrNotFound
}
for _, mod := range m.modules {
- if mod.DohProfileID != nil && *mod.DohProfileID == id {
- return ErrInvalidInput
+ if mod == nil || mod.DeletedAt != nil {
+ continue
+ }
+ for _, pid := range mod.EffectiveDohProfileIDs() {
+ if pid == id {
+ return ErrInvalidInput
+ }
}
}
delete(m.dohProfiles, id)
diff --git a/migrations/postgres/000011_module_doh_resolver.down.sql b/migrations/postgres/000011_module_doh_resolver.down.sql
new file mode 100644
index 0000000..a21fce5
--- /dev/null
+++ b/migrations/postgres/000011_module_doh_resolver.down.sql
@@ -0,0 +1,4 @@
+DROP TABLE IF EXISTS module_doh_profile;
+
+ALTER TABLE module DROP CONSTRAINT IF EXISTS module_doh_resolver_policy_chk;
+ALTER TABLE module DROP COLUMN IF EXISTS doh_resolver_policy;
diff --git a/migrations/postgres/000011_module_doh_resolver.up.sql b/migrations/postgres/000011_module_doh_resolver.up.sql
new file mode 100644
index 0000000..382d22c
--- /dev/null
+++ b/migrations/postgres/000011_module_doh_resolver.up.sql
@@ -0,0 +1,21 @@
+-- Module DoH resolver policy and ordered profile list.
+ALTER TABLE module
+ ADD COLUMN doh_resolver_policy TEXT NOT NULL DEFAULT 'primary_only'
+ CONSTRAINT module_doh_resolver_policy_chk CHECK (
+ doh_resolver_policy IN ('primary_only', 'failover', 'union')
+ );
+
+CREATE TABLE module_doh_profile (
+ module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE,
+ doh_profile_id UUID NOT NULL REFERENCES doh_profile (id) ON DELETE CASCADE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (module_id, doh_profile_id)
+);
+
+CREATE INDEX idx_module_doh_profile_doh ON module_doh_profile (doh_profile_id);
+
+INSERT INTO module_doh_profile (module_id, doh_profile_id, sort_order)
+SELECT id, doh_profile_id, 0
+FROM module
+WHERE doh_profile_id IS NOT NULL
+ AND deleted_at IS NULL;
diff --git a/migrations/sqlite/000011_module_doh_resolver.down.sql b/migrations/sqlite/000011_module_doh_resolver.down.sql
new file mode 100644
index 0000000..52b2eb2
--- /dev/null
+++ b/migrations/sqlite/000011_module_doh_resolver.down.sql
@@ -0,0 +1,3 @@
+DROP TABLE IF EXISTS module_doh_profile;
+
+ALTER TABLE module DROP COLUMN doh_resolver_policy;
diff --git a/migrations/sqlite/000011_module_doh_resolver.up.sql b/migrations/sqlite/000011_module_doh_resolver.up.sql
new file mode 100644
index 0000000..034f4cf
--- /dev/null
+++ b/migrations/sqlite/000011_module_doh_resolver.up.sql
@@ -0,0 +1,18 @@
+ALTER TABLE module
+ ADD COLUMN doh_resolver_policy TEXT NOT NULL DEFAULT 'primary_only'
+ CHECK (doh_resolver_policy IN ('primary_only', 'failover', 'union'));
+
+CREATE TABLE module_doh_profile (
+ module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE,
+ doh_profile_id TEXT NOT NULL REFERENCES doh_profile (id) ON DELETE CASCADE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (module_id, doh_profile_id)
+);
+
+CREATE INDEX idx_module_doh_profile_doh ON module_doh_profile (doh_profile_id);
+
+INSERT INTO module_doh_profile (module_id, doh_profile_id, sort_order)
+SELECT id, doh_profile_id, 0
+FROM module
+WHERE doh_profile_id IS NOT NULL
+ AND deleted_at IS NULL;
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index 0860e8e..e70c3f3 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -8,6 +8,8 @@ export type Page
{mod.doh_profile_id ? mod.doh_profile_id.slice(0, 8) + '…' : '—'}
+{dohPolicyLabel(mod.doh_resolver_policy)}
++ {#if moduleDohProfileIds(mod).length} + {moduleDohProfileIds(mod).map(dohProfileLabel).join('; ')} + {:else} + Системный DNS + {/if} +
+ {:else} +—
+ {/if}