feat: enhance DoH profile management and resolver policy in modules
CI / changes (push) Successful in 6s
CI / openapi (push) Successful in 1m2s
CI / go (push) Successful in 27s
CI / docker-web (push) Successful in 1m27s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go (push) Successful in 8m5s
CI / changes (push) Successful in 6s
CI / openapi (push) Successful in 1m2s
CI / go (push) Successful in 27s
CI / docker-web (push) Successful in 1m27s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go (push) Successful in 8m5s
- Added `DohResolverPolicy` schema to OpenAPI documentation, defining policies for domain resolution. - Updated module handling to support multiple DoH profiles via `doh_profile_ids` and introduced `doh_resolver_policy` in the API. - Refactored related functions to accommodate the new DoH profile structure, ensuring backward compatibility with existing `doh_profile_id`. - Enhanced UI components to allow selection and management of DoH profiles and policies in the web interface. - Updated database interactions to handle new fields and ensure proper data normalization.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user