From 34ecc5c235a3d91e1a17495601e2c5e409dde9db Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 19 May 2026 14:57:50 +0700 Subject: [PATCH] feat: enhance DoH profile management and resolver policy in modules - 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. --- docs/openapi.yaml | 36 ++++ internal/httpapi/routes.go | 7 + internal/httpapi/routes_crud.go | 27 ++- internal/pipeline/collect_parallel.go | 4 +- internal/pipeline/doh_resolve.go | 131 +++++++++++++++ internal/pipeline/doh_resolve_test.go | 148 +++++++++++++++++ internal/pipeline/module_hash.go | 15 +- internal/pipeline/refresh.go | 11 +- internal/repository/postgres.go | 101 +++++++----- internal/repository/postgres_module_doh.go | 67 ++++++++ internal/store/backend.go | 2 + internal/store/doh_module.go | 110 +++++++++++++ internal/store/doh_module_test.go | 32 ++++ internal/store/memory.go | 4 +- internal/store/memory_crud.go | 22 +-- .../000011_module_doh_resolver.down.sql | 4 + .../000011_module_doh_resolver.up.sql | 21 +++ .../000011_module_doh_resolver.down.sql | 3 + .../sqlite/000011_module_doh_resolver.up.sql | 18 ++ web/src/lib/api/types.ts | 7 + .../routes/modules/[moduleId]/+page.svelte | 154 ++++++++++++++---- 21 files changed, 810 insertions(+), 114 deletions(-) create mode 100644 internal/pipeline/doh_resolve.go create mode 100644 internal/pipeline/doh_resolve_test.go create mode 100644 internal/repository/postgres_module_doh.go create mode 100644 internal/store/doh_module.go create mode 100644 internal/store/doh_module_test.go create mode 100644 migrations/postgres/000011_module_doh_resolver.down.sql create mode 100644 migrations/postgres/000011_module_doh_resolver.up.sql create mode 100644 migrations/sqlite/000011_module_doh_resolver.down.sql create mode 100644 migrations/sqlite/000011_module_doh_resolver.up.sql 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 = { // ---- 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; @@ -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; diff --git a/web/src/routes/modules/[moduleId]/+page.svelte b/web/src/routes/modules/[moduleId]/+page.svelte index f8e9cdb..141b156 100644 --- a/web/src/routes/modules/[moduleId]/+page.svelte +++ b/web/src/routes/modules/[moduleId]/+page.svelte @@ -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(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(`/v1/modules/${moduleId}`, 'PATCH', payload); await loadMod(); @@ -847,8 +898,19 @@

-

DoH профиль

-

{mod.doh_profile_id ? mod.doh_profile_id.slice(0, 8) + '…' : '—'}

+

DoH

+ {#if mod.type === 'DOMAINS'} +

{dohPolicyLabel(mod.doh_resolver_policy)}

+

+ {#if moduleDohProfileIds(mod).length} + {moduleDohProfileIds(mod).map(dohProfileLabel).join('; ')} + {:else} + Системный DNS + {/if} +

+ {:else} +

+ {/if}

Community по умолч.

@@ -1349,37 +1411,63 @@ Сбросить -
- - { + if (v) editForm.doh_resolver_policy = v as DohResolverPolicy; + }} + > + + {dohPolicyLabel(editForm.doh_resolver_policy)} + + + {#each dohPolicyOptions as opt (opt.value)} + {opt.label} + {/each} + + +

+ {dohPolicyOptions.find((o) => o.value === (editForm.doh_resolver_policy ?? 'primary_only'))?.hint} +

+
+
+ +

Порядок выбора = порядок в списке (сверху вниз).

+
{#each dohProfiles as d (d.id)} - {d.url} + + {:else} +

Нет профилей — создайте в справочниках.

{/each} - - - -
+
+ + + {/if}