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
|
- DOMAINS
|
||||||
- IP_RANGES
|
- IP_RANGES
|
||||||
|
|
||||||
|
DohResolverPolicy:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Политика резолва доменов через DoH при нескольких профилях.
|
||||||
|
`primary_only` — только первый профиль; `failover` — по порядку до первого успешного;
|
||||||
|
`union` — объединение A/AAAA со всех профилей.
|
||||||
|
enum:
|
||||||
|
- primary_only
|
||||||
|
- failover
|
||||||
|
- union
|
||||||
|
default: primary_only
|
||||||
|
|
||||||
JobStatus:
|
JobStatus:
|
||||||
type: string
|
type: string
|
||||||
description: Статус задачи; перечень может расширяться.
|
description: Статус задачи; перечень может расширяться.
|
||||||
@@ -332,6 +344,14 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
doh_profile_id:
|
doh_profile_id:
|
||||||
type: ["string", "null"]
|
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:
|
refresh_interval_sec:
|
||||||
type: ["integer", "null"]
|
type: ["integer", "null"]
|
||||||
minimum: 0
|
minimum: 0
|
||||||
@@ -363,6 +383,14 @@ components:
|
|||||||
default: 0
|
default: 0
|
||||||
doh_profile_id:
|
doh_profile_id:
|
||||||
type: ["string", "null"]
|
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:
|
refresh_interval_sec:
|
||||||
type: ["integer", "null"]
|
type: ["integer", "null"]
|
||||||
last_refreshed_at:
|
last_refreshed_at:
|
||||||
@@ -385,6 +413,14 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
doh_profile_id:
|
doh_profile_id:
|
||||||
type: ["string", "null"]
|
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:
|
refresh_interval_sec:
|
||||||
type: ["integer", "null"]
|
type: ["integer", "null"]
|
||||||
cron_expr:
|
cron_expr:
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ func moduleJSON(mod *store.Module) map[string]any {
|
|||||||
} else {
|
} else {
|
||||||
m["default_community_id"] = nil
|
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 {
|
if mod.DohProfileID != nil {
|
||||||
m["doh_profile_id"] = *mod.DohProfileID
|
m["doh_profile_id"] = *mod.DohProfileID
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -75,14 +75,16 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
RefreshIntervalSec int `json:"refresh_interval_sec"`
|
RefreshIntervalSec int `json:"refresh_interval_sec"`
|
||||||
CronExpr string `json:"cron_expr"`
|
CronExpr string `json:"cron_expr"`
|
||||||
DefaultCommunityID *string `json:"default_community_id"`
|
DefaultCommunityID *string `json:"default_community_id"`
|
||||||
DohProfileID *string `json:"doh_profile_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 {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
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,
|
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
|
||||||
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
|
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
|
||||||
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
|
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
|
||||||
|
DohProfileIDs: body.DohProfileIDs, DohResolverPolicy: body.DohResolverPolicy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
@@ -129,6 +132,14 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
empty := ""
|
empty := ""
|
||||||
body.DohProfileID = &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" {
|
if v, ok := raw["cron_expr"]; ok && string(v) == "null" {
|
||||||
empty := ""
|
empty := ""
|
||||||
body.CronExpr = &empty
|
body.CronExpr = &empty
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
|
|||||||
return out, nil
|
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
|
var validDom []*store.DomainEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e != nil {
|
if e != nil {
|
||||||
@@ -202,7 +202,7 @@ func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Mo
|
|||||||
c := *mod.DefaultCommunityID
|
c := *mod.DefaultCommunityID
|
||||||
comm = &c
|
comm = &c
|
||||||
}
|
}
|
||||||
addrs, err := resolveDomainIPs(ctx, hc, profile, entry.FQDN)
|
addrs, err := resolveDomainIPsWithPolicy(ctx, hc, profiles, policy, entry.FQDN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results[idx] = domResult{err: fmt.Errorf("resolve domain %q: %w", entry.FQDN, err)}
|
results[idx] = domResult{err: fmt.Errorf("resolve domain %q: %w", entry.FQDN, err)}
|
||||||
return
|
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 {
|
if mod.DefaultCommunityID != nil {
|
||||||
_, _ = fmt.Fprintf(h, "default_community=%s\n", strings.TrimSpace(*mod.DefaultCommunityID))
|
_, _ = fmt.Fprintf(h, "default_community=%s\n", strings.TrimSpace(*mod.DefaultCommunityID))
|
||||||
}
|
}
|
||||||
if mod.DohProfileID != nil {
|
_, _ = fmt.Fprintf(h, "doh_policy=%s\n", store.NormalizeDohResolverPolicy(mod.DohResolverPolicy))
|
||||||
_, _ = fmt.Fprintf(h, "doh_profile=%s\n", strings.TrimSpace(*mod.DohProfileID))
|
for _, pid := range mod.EffectiveDohProfileIDs() {
|
||||||
if pid := strings.TrimSpace(*mod.DohProfileID); pid != "" {
|
_, _ = fmt.Fprintf(h, "doh_profile=%s\n", pid)
|
||||||
if prof, err := st.GetDohProfile(tenantID, pid); err == nil && prof != nil {
|
if prof, err := st.GetDohProfile(tenantID, pid); err == nil && prof != nil {
|
||||||
_, _ = fmt.Fprintf(h, "doh_url=%s\n", strings.TrimSpace(prof.URL))
|
_, _ = fmt.Fprintf(h, "doh_url=%s\n", strings.TrimSpace(prof.URL))
|
||||||
if prof.TimeoutMs != nil {
|
if prof.TimeoutMs != nil {
|
||||||
_, _ = fmt.Fprintf(h, "doh_timeout=%d\n", *prof.TimeoutMs)
|
_, _ = 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var profile *store.DohProfile
|
profiles, policy, err := loadModuleDohProfiles(st, tenantID, mod)
|
||||||
if mod.DohProfileID != nil && strings.TrimSpace(*mod.DohProfileID) != "" {
|
if err != nil {
|
||||||
profile, err = st.GetDohProfile(tenantID, strings.TrimSpace(*mod.DohProfileID))
|
return nil, err
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get doh profile: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return collectDomainPrefixRows(ctx, hc, mod, profile, entries)
|
return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown module type %q", mod.Type)
|
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 {
|
func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rows, err := p.pool.Query(ctx, `
|
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)
|
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -126,9 +127,10 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
|||||||
var doh, dc, cron *string
|
var doh, dc, cron *string
|
||||||
var refresh *int32
|
var refresh *int32
|
||||||
var last *time.Time
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||||
if refresh != nil {
|
if refresh != nil {
|
||||||
m.RefreshIntervalSec = int(*refresh)
|
m.RefreshIntervalSec = int(*refresh)
|
||||||
}
|
}
|
||||||
@@ -145,6 +147,9 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
|||||||
t := last.UTC()
|
t := last.UTC()
|
||||||
m.LastRefreshedAt = &t
|
m.LastRefreshedAt = &t
|
||||||
}
|
}
|
||||||
|
if err := p.fillModuleDohFields(ctx, &m); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, &m)
|
out = append(out, &m)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -158,9 +163,10 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
|||||||
var refresh *int32
|
var refresh *int32
|
||||||
var last *time.Time
|
var last *time.Time
|
||||||
err := p.pool.QueryRow(ctx, `
|
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(
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, store.ErrNotFound
|
return nil, store.ErrNotFound
|
||||||
@@ -183,6 +189,10 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
|||||||
t := last.UTC()
|
t := last.UTC()
|
||||||
m.LastRefreshedAt = &t
|
m.LastRefreshedAt = &t
|
||||||
}
|
}
|
||||||
|
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||||
|
if err := p.fillModuleDohFields(ctx, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +200,7 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
|
|||||||
if in == nil {
|
if in == nil {
|
||||||
return nil, store.ErrInvalidInput
|
return nil, store.ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
store.NormalizeModuleDoh(in)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
var doh, dc any
|
var doh, dc any
|
||||||
@@ -211,13 +222,17 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
|
|||||||
if in.LastRefreshedAt != nil {
|
if in.LastRefreshedAt != nil {
|
||||||
lastArg = in.LastRefreshedAt.UTC()
|
lastArg = in.LastRefreshedAt.UTC()
|
||||||
}
|
}
|
||||||
|
policy := store.NormalizeDohResolverPolicy(in.DohResolverPolicy)
|
||||||
_, err := p.pool.Exec(ctx, `
|
_, 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)
|
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)`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||||
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, ri, cronArg, dc, lastArg)
|
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := p.setModuleDohProfiles(ctx, id, in.DohProfileIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return p.GetModule(tenantID, id)
|
return p.GetModule(tenantID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,77 +245,68 @@ func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
name := base.Name
|
work := *base
|
||||||
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
|
|
||||||
if patch.Name != nil {
|
if patch.Name != nil {
|
||||||
name = strings.TrimSpace(*patch.Name)
|
work.Name = strings.TrimSpace(*patch.Name)
|
||||||
}
|
}
|
||||||
if patch.Enabled != nil {
|
if patch.Enabled != nil {
|
||||||
en = *patch.Enabled
|
work.Enabled = *patch.Enabled
|
||||||
}
|
}
|
||||||
if patch.Priority != nil {
|
if patch.Priority != nil {
|
||||||
pr = *patch.Priority
|
work.Priority = *patch.Priority
|
||||||
}
|
}
|
||||||
if patch.RefreshIntervalSec != nil {
|
if patch.RefreshIntervalSec != nil {
|
||||||
ri = *patch.RefreshIntervalSec
|
work.RefreshIntervalSec = *patch.RefreshIntervalSec
|
||||||
}
|
}
|
||||||
if patch.CronExpr != nil {
|
if patch.CronExpr != nil {
|
||||||
cron = *patch.CronExpr
|
work.CronExpr = *patch.CronExpr
|
||||||
}
|
}
|
||||||
if patch.DefaultCommunityID != nil {
|
if patch.DefaultCommunityID != nil {
|
||||||
v := strings.TrimSpace(*patch.DefaultCommunityID)
|
v := strings.TrimSpace(*patch.DefaultCommunityID)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
dc = nil
|
work.DefaultCommunityID = nil
|
||||||
} else {
|
} else {
|
||||||
dc = &v
|
work.DefaultCommunityID = &v
|
||||||
}
|
|
||||||
}
|
|
||||||
if patch.DohProfileID != nil {
|
|
||||||
v := strings.TrimSpace(*patch.DohProfileID)
|
|
||||||
if v == "" {
|
|
||||||
doh = nil
|
|
||||||
} else {
|
|
||||||
doh = &v
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
store.ApplyModuleDohPatch(&work, patch)
|
||||||
if patch.LastRefreshedAt != nil {
|
if patch.LastRefreshedAt != nil {
|
||||||
t := patch.LastRefreshedAt.UTC()
|
t := patch.LastRefreshedAt.UTC()
|
||||||
last = &t
|
work.LastRefreshedAt = &t
|
||||||
}
|
}
|
||||||
var dcArg, dohArg any
|
var dcArg, dohArg any
|
||||||
if dc != nil {
|
if work.DefaultCommunityID != nil {
|
||||||
dcArg = *dc
|
dcArg = *work.DefaultCommunityID
|
||||||
}
|
}
|
||||||
if doh != nil {
|
if work.DohProfileID != nil {
|
||||||
dohArg = *doh
|
dohArg = *work.DohProfileID
|
||||||
}
|
}
|
||||||
var riArg any
|
var riArg any
|
||||||
if ri != 0 {
|
if work.RefreshIntervalSec != 0 {
|
||||||
riArg = ri
|
riArg = work.RefreshIntervalSec
|
||||||
}
|
}
|
||||||
var cronArg any
|
var cronArg any
|
||||||
if strings.TrimSpace(cron) != "" {
|
if strings.TrimSpace(work.CronExpr) != "" {
|
||||||
cronArg = strings.TrimSpace(cron)
|
cronArg = strings.TrimSpace(work.CronExpr)
|
||||||
}
|
}
|
||||||
var lastArg any
|
var lastArg any
|
||||||
if last != nil {
|
if work.LastRefreshedAt != nil {
|
||||||
lastArg = last.UTC()
|
lastArg = work.LastRefreshedAt.UTC()
|
||||||
}
|
}
|
||||||
|
policy := store.NormalizeDohResolverPolicy(work.DohResolverPolicy)
|
||||||
_, err = p.pool.Exec(ctx, `
|
_, err = p.pool.Exec(ctx, `
|
||||||
UPDATE module SET name=$3, enabled=$4, priority=$5, refresh_interval_sec=$6, cron_expr=$7,
|
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`,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
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 {
|
func (p *Postgres) DeleteDohProfile(tenantID, id string) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
inUse, err := p.moduleDohProfileInUse(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if inUse {
|
||||||
|
return store.ErrInvalidInput
|
||||||
|
}
|
||||||
var n int
|
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)
|
_ = 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 {
|
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"`
|
CronExpr *string `json:"cron_expr,omitempty"`
|
||||||
DefaultCommunityID *string `json:"default_community_id,omitempty"`
|
DefaultCommunityID *string `json:"default_community_id,omitempty"`
|
||||||
DohProfileID *string `json:"doh_profile_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:"-"`
|
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)
|
CronExpr string // optional cron for scheduler (display / future use)
|
||||||
Priority int
|
Priority int
|
||||||
DefaultCommunityID *string
|
DefaultCommunityID *string
|
||||||
DohProfileID *string
|
DohProfileID *string // deprecated: first id in DohProfileIDs
|
||||||
|
DohProfileIDs []string
|
||||||
|
DohResolverPolicy string
|
||||||
LastRefreshedAt *time.Time
|
LastRefreshedAt *time.Time
|
||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
|
|||||||
RefreshIntervalSec: in.RefreshIntervalSec,
|
RefreshIntervalSec: in.RefreshIntervalSec,
|
||||||
CronExpr: in.CronExpr,
|
CronExpr: in.CronExpr,
|
||||||
DefaultCommunityID: in.DefaultCommunityID,
|
DefaultCommunityID: in.DefaultCommunityID,
|
||||||
DohProfileID: in.DohProfileID,
|
DohProfileIDs: append([]string(nil), in.DohProfileIDs...),
|
||||||
|
DohResolverPolicy: in.DohResolverPolicy,
|
||||||
LastRefreshedAt: in.LastRefreshedAt,
|
LastRefreshedAt: in.LastRefreshedAt,
|
||||||
}
|
}
|
||||||
|
NormalizeModuleDoh(mod)
|
||||||
m.modules[id] = mod
|
m.modules[id] = mod
|
||||||
return mod, nil
|
return mod, nil
|
||||||
}
|
}
|
||||||
@@ -71,14 +73,7 @@ func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*M
|
|||||||
mod.DefaultCommunityID = &v
|
mod.DefaultCommunityID = &v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if patch.DohProfileID != nil {
|
ApplyModuleDohPatch(mod, patch)
|
||||||
v := strings.TrimSpace(*patch.DohProfileID)
|
|
||||||
if v == "" {
|
|
||||||
mod.DohProfileID = nil
|
|
||||||
} else {
|
|
||||||
mod.DohProfileID = &v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if patch.LastRefreshedAt != nil {
|
if patch.LastRefreshedAt != nil {
|
||||||
t := patch.LastRefreshedAt.UTC()
|
t := patch.LastRefreshedAt.UTC()
|
||||||
mod.LastRefreshedAt = &t
|
mod.LastRefreshedAt = &t
|
||||||
@@ -555,8 +550,13 @@ func (m *Memory) DeleteDohProfile(tenantID, id string) error {
|
|||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
for _, mod := range m.modules {
|
for _, mod := range m.modules {
|
||||||
if mod.DohProfileID != nil && *mod.DohProfileID == id {
|
if mod == nil || mod.DeletedAt != nil {
|
||||||
return ErrInvalidInput
|
continue
|
||||||
|
}
|
||||||
|
for _, pid := range mod.EffectiveDohProfileIDs() {
|
||||||
|
if pid == id {
|
||||||
|
return ErrInvalidInput
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete(m.dohProfiles, id)
|
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 ----
|
// ---- Modules ----
|
||||||
export type ModuleType = 'AS_PREFIXES' | 'CDN_CIDRS' | 'DOMAINS' | 'IP_RANGES';
|
export type ModuleType = 'AS_PREFIXES' | 'CDN_CIDRS' | 'DOMAINS' | 'IP_RANGES';
|
||||||
|
|
||||||
|
export type DohResolverPolicy = 'primary_only' | 'failover' | 'union';
|
||||||
|
|
||||||
export type ModuleRow = {
|
export type ModuleRow = {
|
||||||
id: string;
|
id: string;
|
||||||
type: ModuleType;
|
type: ModuleType;
|
||||||
@@ -17,7 +19,10 @@ export type ModuleRow = {
|
|||||||
refresh_interval_sec: number | null;
|
refresh_interval_sec: number | null;
|
||||||
cron_expr: string | null;
|
cron_expr: string | null;
|
||||||
default_community_id: string | null;
|
default_community_id: string | null;
|
||||||
|
/** @deprecated use doh_profile_ids */
|
||||||
doh_profile_id: string | null;
|
doh_profile_id: string | null;
|
||||||
|
doh_profile_ids: string[];
|
||||||
|
doh_resolver_policy: DohResolverPolicy;
|
||||||
last_refreshed_at: string | null;
|
last_refreshed_at: string | null;
|
||||||
};
|
};
|
||||||
export type ModulesResponse = Page<ModuleRow>;
|
export type ModulesResponse = Page<ModuleRow>;
|
||||||
@@ -36,6 +41,8 @@ export type ModuleCreate = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
doh_profile_id?: string | null;
|
doh_profile_id?: string | null;
|
||||||
|
doh_profile_ids?: string[];
|
||||||
|
doh_resolver_policy?: DohResolverPolicy;
|
||||||
refresh_interval_sec?: number | null;
|
refresh_interval_sec?: number | null;
|
||||||
cron_expr?: string | null;
|
cron_expr?: string | null;
|
||||||
default_community_id?: string | null;
|
default_community_id?: string | null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ModuleRow,
|
ModuleRow,
|
||||||
ModulePatch,
|
ModulePatch,
|
||||||
|
DohResolverPolicy,
|
||||||
AsEntry,
|
AsEntry,
|
||||||
AsEntryCreate,
|
AsEntryCreate,
|
||||||
AsEntryPatch,
|
AsEntryPatch,
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from '$lib/components/ui/select/index.js';
|
} from '$lib/components/ui/select/index.js';
|
||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -82,6 +84,53 @@
|
|||||||
import Network from '@lucide/svelte/icons/network';
|
import Network from '@lucide/svelte/icons/network';
|
||||||
import { moduleTypeRu } from '$lib/ui-labels.js';
|
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);
|
const moduleId = $derived(page.params.moduleId);
|
||||||
|
|
||||||
let mod = $state<ModuleRow | null>(null);
|
let mod = $state<ModuleRow | null>(null);
|
||||||
@@ -264,7 +313,8 @@
|
|||||||
refresh_interval_sec: mod.refresh_interval_sec,
|
refresh_interval_sec: mod.refresh_interval_sec,
|
||||||
cron_expr: mod.cron_expr,
|
cron_expr: mod.cron_expr,
|
||||||
default_community_id: mod.default_community_id,
|
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;
|
editDialog = true;
|
||||||
}
|
}
|
||||||
@@ -283,7 +333,8 @@
|
|||||||
cron_expr: cron ? cron : null,
|
cron_expr: cron ? cron : null,
|
||||||
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
|
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
|
||||||
default_community_id: fromNullableSelect(nullableSelectValue(editForm.default_community_id)),
|
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 apiMutate<ModuleRow>(`/v1/modules/${moduleId}`, 'PATCH', payload);
|
||||||
await loadMod();
|
await loadMod();
|
||||||
@@ -847,8 +898,19 @@
|
|||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="bg-chart-4/10 p-3">
|
<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="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>
|
{#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>
|
||||||
<Card class="bg-chart-2/10 p-3">
|
<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>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
{#if mod?.type === 'DOMAINS'}
|
||||||
<Label for="e-doh">DoH профиль</Label>
|
<div class="space-y-1.5">
|
||||||
<Select
|
<Label for="e-doh-policy">Политика DoH</Label>
|
||||||
type="single"
|
<Select
|
||||||
value={nullableSelectValue(editForm.doh_profile_id)}
|
type="single"
|
||||||
onValueChange={(v) => {
|
value={editForm.doh_resolver_policy ?? 'primary_only'}
|
||||||
editForm.doh_profile_id = fromNullableSelect(v);
|
onValueChange={(v) => {
|
||||||
}}
|
if (v) editForm.doh_resolver_policy = v as DohResolverPolicy;
|
||||||
>
|
}}
|
||||||
<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 id="e-doh-policy" class="w-full">
|
||||||
</SelectTrigger>
|
{dohPolicyLabel(editForm.doh_resolver_policy)}
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
<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)}
|
{#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}
|
{/each}
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
class="h-7 px-2"
|
||||||
class="h-7 px-2"
|
onclick={() => {
|
||||||
onclick={() => {
|
editForm = { ...editForm, doh_profile_ids: [] };
|
||||||
editForm.doh_profile_id = null;
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Сбросить профили
|
||||||
Сбросить
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3"
|
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