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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS module_doh_profile;
|
||||
|
||||
ALTER TABLE module DROP COLUMN doh_resolver_policy;
|
||||
@@ -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;
|
||||
@@ -8,6 +8,8 @@ export type Page<T> = {
|
||||
// ---- Modules ----
|
||||
export type ModuleType = 'AS_PREFIXES' | 'CDN_CIDRS' | 'DOMAINS' | 'IP_RANGES';
|
||||
|
||||
export type DohResolverPolicy = 'primary_only' | 'failover' | 'union';
|
||||
|
||||
export type ModuleRow = {
|
||||
id: string;
|
||||
type: ModuleType;
|
||||
@@ -17,7 +19,10 @@ export type ModuleRow = {
|
||||
refresh_interval_sec: number | null;
|
||||
cron_expr: string | null;
|
||||
default_community_id: string | null;
|
||||
/** @deprecated use doh_profile_ids */
|
||||
doh_profile_id: string | null;
|
||||
doh_profile_ids: string[];
|
||||
doh_resolver_policy: DohResolverPolicy;
|
||||
last_refreshed_at: string | null;
|
||||
};
|
||||
export type ModulesResponse = Page<ModuleRow>;
|
||||
@@ -36,6 +41,8 @@ export type ModuleCreate = {
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
doh_profile_id?: string | null;
|
||||
doh_profile_ids?: string[];
|
||||
doh_resolver_policy?: DohResolverPolicy;
|
||||
refresh_interval_sec?: number | null;
|
||||
cron_expr?: string | null;
|
||||
default_community_id?: string | null;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type {
|
||||
ModuleRow,
|
||||
ModulePatch,
|
||||
DohResolverPolicy,
|
||||
AsEntry,
|
||||
AsEntryCreate,
|
||||
AsEntryPatch,
|
||||
@@ -57,6 +58,7 @@
|
||||
SelectTrigger,
|
||||
} from '$lib/components/ui/select/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -82,6 +84,53 @@
|
||||
import Network from '@lucide/svelte/icons/network';
|
||||
import { moduleTypeRu } from '$lib/ui-labels.js';
|
||||
|
||||
const dohPolicyOptions: { value: DohResolverPolicy; label: string; hint: string }[] = [
|
||||
{
|
||||
value: 'primary_only',
|
||||
label: 'Только первый',
|
||||
hint: 'Используется первый выбранный DoH-профиль.'
|
||||
},
|
||||
{
|
||||
value: 'failover',
|
||||
label: 'Резервирование',
|
||||
hint: 'Профили по порядку до первого успешного ответа.'
|
||||
},
|
||||
{
|
||||
value: 'union',
|
||||
label: 'Объединение',
|
||||
hint: 'Все A/AAAA со всех профилей (geo-split DNS).'
|
||||
}
|
||||
];
|
||||
|
||||
function dohPolicyLabel(policy: DohResolverPolicy | null | undefined): string {
|
||||
return dohPolicyOptions.find((o) => o.value === policy)?.label ?? 'Только первый';
|
||||
}
|
||||
|
||||
function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
|
||||
if (!modRow) return [];
|
||||
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids;
|
||||
return modRow.doh_profile_id ? [modRow.doh_profile_id] : [];
|
||||
}
|
||||
|
||||
function dohProfileLabel(id: string): string {
|
||||
const p = dohProfiles.find((d) => d.id === id);
|
||||
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : id.slice(0, 8) + '…';
|
||||
}
|
||||
|
||||
function toggleEditDohProfile(id: string, checked: boolean) {
|
||||
let ids = [...(editForm.doh_profile_ids ?? [])];
|
||||
if (checked) {
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
} else {
|
||||
ids = ids.filter((x) => x !== id);
|
||||
}
|
||||
editForm = { ...editForm, doh_profile_ids: ids };
|
||||
}
|
||||
|
||||
function isEditDohProfileSelected(id: string): boolean {
|
||||
return (editForm.doh_profile_ids ?? []).includes(id);
|
||||
}
|
||||
|
||||
const moduleId = $derived(page.params.moduleId);
|
||||
|
||||
let mod = $state<ModuleRow | null>(null);
|
||||
@@ -264,7 +313,8 @@
|
||||
refresh_interval_sec: mod.refresh_interval_sec,
|
||||
cron_expr: mod.cron_expr,
|
||||
default_community_id: mod.default_community_id,
|
||||
doh_profile_id: mod.doh_profile_id
|
||||
doh_profile_ids: moduleDohProfileIds(mod),
|
||||
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
|
||||
};
|
||||
editDialog = true;
|
||||
}
|
||||
@@ -283,7 +333,8 @@
|
||||
cron_expr: cron ? cron : null,
|
||||
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
|
||||
default_community_id: fromNullableSelect(nullableSelectValue(editForm.default_community_id)),
|
||||
doh_profile_id: fromNullableSelect(nullableSelectValue(editForm.doh_profile_id))
|
||||
doh_profile_ids: editForm.doh_profile_ids ?? [],
|
||||
doh_resolver_policy: editForm.doh_resolver_policy ?? 'primary_only'
|
||||
};
|
||||
await apiMutate<ModuleRow>(`/v1/modules/${moduleId}`, 'PATCH', payload);
|
||||
await loadMod();
|
||||
@@ -847,8 +898,19 @@
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="bg-chart-4/10 p-3">
|
||||
<p class="text-muted-foreground flex items-center gap-1.5 text-xs"><Network class="size-3.5" />DoH профиль</p>
|
||||
<p class="font-semibold text-sm">{mod.doh_profile_id ? mod.doh_profile_id.slice(0, 8) + '…' : '—'}</p>
|
||||
<p class="text-muted-foreground flex items-center gap-1.5 text-xs"><Network class="size-3.5" />DoH</p>
|
||||
{#if mod.type === 'DOMAINS'}
|
||||
<p class="font-semibold text-sm">{dohPolicyLabel(mod.doh_resolver_policy)}</p>
|
||||
<p class="text-muted-foreground text-xs break-all">
|
||||
{#if moduleDohProfileIds(mod).length}
|
||||
{moduleDohProfileIds(mod).map(dohProfileLabel).join('; ')}
|
||||
{:else}
|
||||
Системный DNS
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="font-semibold text-sm">—</p>
|
||||
{/if}
|
||||
</Card>
|
||||
<Card class="bg-chart-2/10 p-3">
|
||||
<p class="text-muted-foreground flex items-center gap-1.5 text-xs"><ShieldCheck class="size-3.5" />Community по умолч.</p>
|
||||
@@ -1349,37 +1411,63 @@
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="e-doh">DoH профиль</Label>
|
||||
<Select
|
||||
type="single"
|
||||
value={nullableSelectValue(editForm.doh_profile_id)}
|
||||
onValueChange={(v) => {
|
||||
editForm.doh_profile_id = fromNullableSelect(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="e-doh" class="w-full">
|
||||
{editForm.doh_profile_id ? (dohProfiles.find(d => d.id === editForm.doh_profile_id)?.url ?? editForm.doh_profile_id) : 'Не выбрано'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||
{#if mod?.type === 'DOMAINS'}
|
||||
<div class="space-y-1.5">
|
||||
<Label for="e-doh-policy">Политика DoH</Label>
|
||||
<Select
|
||||
type="single"
|
||||
value={editForm.doh_resolver_policy ?? 'primary_only'}
|
||||
onValueChange={(v) => {
|
||||
if (v) editForm.doh_resolver_policy = v as DohResolverPolicy;
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="e-doh-policy" class="w-full">
|
||||
{dohPolicyLabel(editForm.doh_resolver_policy)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each dohPolicyOptions as opt (opt.value)}
|
||||
<SelectItem value={opt.value}>{opt.label}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{dohPolicyOptions.find((o) => o.value === (editForm.doh_resolver_policy ?? 'primary_only'))?.hint}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>DoH-профили</Label>
|
||||
<p class="text-muted-foreground text-xs">Порядок выбора = порядок в списке (сверху вниз).</p>
|
||||
<div class="max-h-40 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||
{#each dohProfiles as d (d.id)}
|
||||
<SelectItem value={d.id}>{d.url}</SelectItem>
|
||||
<label class="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={isEditDohProfileSelected(d.id)}
|
||||
onCheckedChange={(v) => toggleEditDohProfile(d.id, v === true)}
|
||||
/>
|
||||
<span class="min-w-0 break-all">
|
||||
<span class="font-medium">{d.name?.trim() ? d.name : d.url}</span>
|
||||
{#if d.name?.trim()}
|
||||
<span class="text-muted-foreground block font-mono text-xs">{d.url}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет профилей — создайте в справочниках.</p>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2"
|
||||
onclick={() => {
|
||||
editForm.doh_profile_id = null;
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2"
|
||||
onclick={() => {
|
||||
editForm = { ...editForm, doh_profile_ids: [] };
|
||||
}}
|
||||
>
|
||||
Сбросить профили
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user