diff --git a/docs/evobgp-api-sketches.md b/docs/evobgp-api-sketches.md index 702fc77..08711af 100644 --- a/docs/evobgp-api-sketches.md +++ b/docs/evobgp-api-sketches.md @@ -59,7 +59,7 @@ | Метод | Путь | Описание | |-------|------|----------| -| `GET` | `/v1/modules/{module_id}/as-entries` | Список ASN/префиксов. | +| `GET` | `/v1/modules/{module_id}/as-entries` | Список записей: ASN + community. | | `POST` | `/v1/modules/{module_id}/as-entries` | Добавить. | | `PATCH` | `/v1/modules/{module_id}/as-entries/{entry_id}` | Обновить. | | `DELETE` | `/v1/modules/{module_id}/as-entries/{entry_id}` | Удалить. | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 67be770..fc9fc18 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -433,25 +433,38 @@ components: type: object required: - id + - asn properties: id: $ref: "#/components/schemas/ResourceId" asn: - type: ["integer", "null"] - prefix: - type: ["string", "null"] - description: CIDR или префикс в зависимости от модели. + type: integer + minimum: 1 + maximum: 4294967295 community_id: type: ["string", "null"] additionalProperties: true AsEntryCreate: type: object + required: + - asn properties: asn: type: integer - prefix: - type: string + minimum: 1 + maximum: 4294967295 + community_id: + type: ["string", "null"] + additionalProperties: true + + AsEntryPatch: + type: object + properties: + asn: + type: integer + minimum: 1 + maximum: 4294967295 community_id: type: ["string", "null"] additionalProperties: true @@ -600,7 +613,9 @@ components: PrefixSnapshotItem: type: object - description: Элемент материализованного снимка префиксов (детали - по реализации). + description: > + Элемент материализованного снимка. Поле prefix обычно содержит CIDR; + для модулей AS_PREFIXES допускается ключ вида as:<номер_asn> (не CIDR). additionalProperties: true Job: @@ -1166,7 +1181,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AsEntryCreate" + $ref: "#/components/schemas/AsEntryPatch" responses: "200": description: Успешно. diff --git a/docs/overview.md b/docs/overview.md index ad5ae2e..ff4a217 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -8,7 +8,7 @@ EvoBGP — это control plane для описания источников п | Тип | Назначение | |-----|------------| -| `AS_PREFIXES` | ASN и связанные префиксы | +| `AS_PREFIXES` | Номера AS и привязка к BGP community (без статического CIDR в записи) | | `CDN_CIDRS` | CIDR из внешних CDN-источников (URL, виды источников) | | `DOMAINS` | FQDN с привязкой к BGP community; опционально DoH-профили | | `IP_RANGES` | Статические CIDR + `community_id` (данные в БД, без внешнего ingest по URL) | diff --git a/internal/birdfmt/filter.go b/internal/birdfmt/filter.go index 1681950..6b0553b 100644 --- a/internal/birdfmt/filter.go +++ b/internal/birdfmt/filter.go @@ -7,9 +7,27 @@ import ( "strings" ) +const maxBGPASN = 4294967295 + +func filterUniqueASNs(pathASNs []int64) []int64 { + seen := make(map[int64]struct{}) + for _, a := range pathASNs { + if a >= 1 && a <= maxBGPASN { + seen[a] = struct{}{} + } + } + out := make([]int64, 0, len(seen)) + for a := range seen { + out = append(out, a) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + // RenderExportFilterIPv4 renders a BIRD 2 filter that accepts IPv4 routes whose prefix -// is in prefixes (exact CIDR match via set membership), and rejects others. -func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix) (string, error) { +// is in prefixes (exact CIDR match via set membership), and/or routes whose AS_PATH +// contains any of pathASNs (BIRD pattern [= * ASN =]), then rejects others. +func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix, pathASNs []int64) (string, error) { if strings.TrimSpace(filterName) == "" { return "", fmt.Errorf("birdfmt: filter name is required") } @@ -27,24 +45,32 @@ func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix) (string, } sort.Strings(keys) + asns := filterUniqueASNs(pathASNs) + var b strings.Builder b.WriteString("filter ") b.WriteString(strings.TrimSpace(filterName)) b.WriteString(" {\n") - if len(keys) == 0 { - b.WriteString(" reject;\n") - } else { + if len(keys) > 0 { b.WriteString(" if net ~ [ ") b.WriteString(strings.Join(keys, ", ")) b.WriteString(" ] then accept;\n") + } + for _, asn := range asns { + fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn) + } + if len(keys) == 0 && len(asns) == 0 { + b.WriteString(" reject;\n") + } else { b.WriteString(" reject;\n") } b.WriteString("}\n") return b.String(), nil } -// RenderExportFilterIPv6 renders a BIRD 2 filter for IPv6 prefixes (CIDR set, then reject). -func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix) (string, error) { +// RenderExportFilterIPv6 renders a BIRD 2 filter for IPv6 prefixes (CIDR set) and/or +// AS_PATH matches, then reject. +func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix, pathASNs []int64) (string, error) { if strings.TrimSpace(filterName) == "" { return "", fmt.Errorf("birdfmt: filter name is required") } @@ -62,16 +88,23 @@ func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix) (string, } sort.Strings(keys) + asns := filterUniqueASNs(pathASNs) + var b strings.Builder b.WriteString("filter ") b.WriteString(strings.TrimSpace(filterName)) b.WriteString(" {\n") - if len(keys) == 0 { - b.WriteString(" reject;\n") - } else { + if len(keys) > 0 { b.WriteString(" if net ~ [ ") b.WriteString(strings.Join(keys, ", ")) b.WriteString(" ] then accept;\n") + } + for _, asn := range asns { + fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn) + } + if len(keys) == 0 && len(asns) == 0 { + b.WriteString(" reject;\n") + } else { b.WriteString(" reject;\n") } b.WriteString("}\n") diff --git a/internal/birdfmt/filter_test.go b/internal/birdfmt/filter_test.go index 6b326da..ef0863c 100644 --- a/internal/birdfmt/filter_test.go +++ b/internal/birdfmt/filter_test.go @@ -7,7 +7,7 @@ import ( ) func TestRenderExportFilterIPv4_Empty(t *testing.T) { - got, err := RenderExportFilterIPv4("evobgp_x", nil) + got, err := RenderExportFilterIPv4("evobgp_x", nil, nil) if err != nil { t.Fatal(err) } @@ -21,7 +21,7 @@ func TestRenderExportFilterIPv4_Empty(t *testing.T) { func TestRenderExportFilterIPv4_SkipsNonV4(t *testing.T) { v6 := netip.MustParsePrefix("2001:db8::/32") - got, err := RenderExportFilterIPv4("f", []netip.Prefix{v6}) + got, err := RenderExportFilterIPv4("f", []netip.Prefix{v6}, nil) if err != nil { t.Fatal(err) } @@ -32,7 +32,7 @@ func TestRenderExportFilterIPv4_SkipsNonV4(t *testing.T) { func TestRenderExportFilterIPv6(t *testing.T) { p := netip.MustParsePrefix("2001:db8::/32") - got, err := RenderExportFilterIPv6("evobgp_export_v6", []netip.Prefix{p, p}) + got, err := RenderExportFilterIPv6("evobgp_export_v6", []netip.Prefix{p, p}, nil) if err != nil { t.Fatal(err) } @@ -45,3 +45,13 @@ func TestRenderExportFilterIPv6(t *testing.T) { t.Fatalf("mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want) } } + +func TestRenderExportFilterIPv4_ASPathOnly(t *testing.T) { + got, err := RenderExportFilterIPv4("f", nil, []int64{65001, 65002}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "bgp_path ~ [= * 65001 =]") || !strings.Contains(got, "bgp_path ~ [= * 65002 =]") { + t.Fatal(got) + } +} diff --git a/internal/birdfmt/standard_layout_test.go b/internal/birdfmt/standard_layout_test.go index 8e18971..4e9c5c4 100644 --- a/internal/birdfmt/standard_layout_test.go +++ b/internal/birdfmt/standard_layout_test.go @@ -11,13 +11,13 @@ import ( func TestStandardLayout_GeneratorMatchesFixtures(t *testing.T) { p4 := netip.MustParsePrefix("203.0.113.0/24") - f4, err := RenderExportFilterIPv4("evobgp_export_v4", []netip.Prefix{p4}) + f4, err := RenderExportFilterIPv4("evobgp_export_v4", []netip.Prefix{p4}, nil) if err != nil { t.Fatal(err) } assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf", f4) - f6, err := RenderExportFilterIPv6("evobgp_export_v6", nil) + f6, err := RenderExportFilterIPv6("evobgp_export_v6", nil, nil) if err != nil { t.Fatal(err) } diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index db3cd47..9fe8a17 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -231,17 +231,7 @@ func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) { } func asEntryJSON(x *store.ASEntry) map[string]any { - m := map[string]any{"id": x.ID} - if x.ASN != nil { - m["asn"] = *x.ASN - } else { - m["asn"] = nil - } - if x.Prefix != nil { - m["prefix"] = *x.Prefix - } else { - m["prefix"] = nil - } + m := map[string]any{"id": x.ID, "asn": x.ASN} if x.CommunityID != nil { m["community_id"] = *x.CommunityID } else { diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index f8129ae..00f7c52 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -8,6 +8,7 @@ import ( "net/http" "net/netip" "sort" + "strconv" "strings" "evobgp/internal/birdfmt" @@ -16,6 +17,11 @@ import ( "github.com/google/uuid" ) +// MaterializedASPrefixKey returns the revision snapshot key for an AS-only entry (not a CIDR). +func MaterializedASPrefixKey(asn int64) string { + return fmt.Sprintf("as:%d", asn) +} + // RefreshModule runs ingest (where applicable) and creates a new rendered revision for the module. func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) (revisionID string, err error) { if hc == nil { @@ -50,7 +56,7 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan return "", err } for _, e := range list { - if e.Prefix == nil || strings.TrimSpace(*e.Prefix) == "" { + if !store.ValidASN(e.ASN) { continue } comm := e.CommunityID @@ -58,7 +64,7 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan c := *mod.DefaultCommunityID comm = &c } - rows = append(rows, store.PrefixRow{Prefix: strings.TrimSpace(*e.Prefix), CommunityID: comm, Source: "as_entry"}) + rows = append(rows, store.PrefixRow{Prefix: MaterializedASPrefixKey(e.ASN), CommunityID: comm, Source: "as_entry"}) } case "CDN_CIDRS": sources, err := st.ListCDNSources(tenantID, moduleID) @@ -175,8 +181,18 @@ func hashMaterialization(moduleID string, rows []store.PrefixRow) string { func buildPreviewFragments(revisionID string, rows []store.PrefixRow) (map[string]string, error) { var v4, v6 []netip.Prefix + var pathASNs []int64 for _, pr := range rows { - pfx, err := netip.ParsePrefix(strings.TrimSpace(pr.Prefix)) + p := strings.TrimSpace(pr.Prefix) + if strings.HasPrefix(p, "as:") { + n, err := strconv.ParseInt(strings.TrimPrefix(p, "as:"), 10, 64) + if err != nil || !store.ValidASN(n) { + continue + } + pathASNs = append(pathASNs, n) + continue + } + pfx, err := netip.ParsePrefix(p) if err != nil { continue } @@ -186,11 +202,11 @@ func buildPreviewFragments(revisionID string, rows []store.PrefixRow) (map[strin v6 = append(v6, pfx.Masked()) } } - f4, err := birdfmt.RenderExportFilterIPv4("evobgp_export_v4", v4) + f4, err := birdfmt.RenderExportFilterIPv4("evobgp_export_v4", v4, pathASNs) if err != nil { return nil, err } - f6, err := birdfmt.RenderExportFilterIPv6("evobgp_export_v6", v6) + f6, err := birdfmt.RenderExportFilterIPv6("evobgp_export_v6", v6, pathASNs) if err != nil { return nil, err } diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go index 119bf5d..06fa8bd 100644 --- a/internal/repository/postgres.go +++ b/internal/repository/postgres.go @@ -830,7 +830,7 @@ func (p *Postgres) CreateRenderRevision(revisionID, tenantID, moduleID string, p } _, err = tx.Exec(ctx, ` INSERT INTO revision_materialized_prefix (revision_id, prefix, community_id, source) - VALUES ($1::uuid, $2::cidr, $3::uuid, $4)`, + VALUES ($1::uuid, $2, $3::uuid, $4)`, strings.TrimSpace(revisionID), strings.TrimSpace(pr.Prefix), comm, src) if err != nil { return err diff --git a/internal/repository/postgres_entities.go b/internal/repository/postgres_entities.go index e9df19a..cab303f 100644 --- a/internal/repository/postgres_entities.go +++ b/internal/repository/postgres_entities.go @@ -164,7 +164,7 @@ func (p *Postgres) ListASEntries(tenantID, moduleID string) ([]*store.ASEntry, e } ctx := context.Background() rows, err := p.pool.Query(ctx, ` - SELECT id::text, asn, prefix::text, community_id::text FROM module_as_entry WHERE module_id=$1`, moduleID) + SELECT id::text, asn, community_id::text FROM module_as_entry WHERE module_id=$1`, moduleID) if err != nil { return nil, err } @@ -173,13 +173,10 @@ func (p *Postgres) ListASEntries(tenantID, moduleID string) ([]*store.ASEntry, e for rows.Next() { var e store.ASEntry e.ModuleID = moduleID - var asn *int64 - var pref, comm *string - if err := rows.Scan(&e.ID, &asn, &pref, &comm); err != nil { + var comm *string + if err := rows.Scan(&e.ID, &e.ASN, &comm); err != nil { continue } - e.ASN = asn - e.Prefix = pref e.CommunityID = strOrNil(comm) out = append(out, &e) } @@ -194,19 +191,15 @@ func (p *Postgres) CreateASEntry(tenantID, moduleID string, in *store.ASEntry) ( if mod.Type != "AS_PREFIXES" { return nil, store.ErrInvalidInput } - if in == nil || (in.ASN == nil && (in.Prefix == nil || strings.TrimSpace(*in.Prefix) == "")) { + if in == nil || !store.ValidASN(in.ASN) { return nil, store.ErrInvalidInput } ctx := context.Background() id := uuid.NewString() - var pref any - if in.Prefix != nil && strings.TrimSpace(*in.Prefix) != "" { - pref = strings.TrimSpace(*in.Prefix) - } _, err = p.pool.Exec(ctx, ` - INSERT INTO module_as_entry (id, module_id, asn, prefix, community_id) - VALUES ($1,$2,$3,$4::cidr, NULLIF($5::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`, - id, moduleID, in.ASN, pref, uuidOrNilPtr(in.CommunityID)) + INSERT INTO module_as_entry (id, module_id, asn, community_id) + VALUES ($1,$2,$3,NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`, + id, moduleID, in.ASN, uuidOrNilPtr(in.CommunityID)) if err != nil { return nil, err } @@ -216,16 +209,13 @@ func (p *Postgres) CreateASEntry(tenantID, moduleID string, in *store.ASEntry) ( func (p *Postgres) getASEntry(ctx context.Context, moduleID, id string) (*store.ASEntry, error) { var e store.ASEntry e.ModuleID = moduleID - var asn *int64 - var pref, comm *string + var comm *string err := p.pool.QueryRow(ctx, ` - SELECT id::text, asn, prefix::text, community_id::text FROM module_as_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan( - &e.ID, &asn, &pref, &comm) + SELECT id::text, asn, community_id::text FROM module_as_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan( + &e.ID, &e.ASN, &comm) if err != nil { return nil, err } - e.ASN = asn - e.Prefix = pref e.CommunityID = strOrNil(comm) return &e, nil } @@ -242,15 +232,7 @@ func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *stor return nil, err } if patch.ASN != nil { - cur.ASN = patch.ASN - } - if patch.Prefix != nil { - p := strings.TrimSpace(*patch.Prefix) - if p == "" { - cur.Prefix = nil - } else { - cur.Prefix = &p - } + cur.ASN = *patch.ASN } if patch.CommunityID != nil { v := strings.TrimSpace(*patch.CommunityID) @@ -260,15 +242,14 @@ func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *stor cur.CommunityID = &v } } - ctx := context.Background() - var pref any - if cur.Prefix != nil { - pref = *cur.Prefix + if !store.ValidASN(cur.ASN) { + return nil, store.ErrInvalidInput } + ctx := context.Background() _, err = p.pool.Exec(ctx, ` - UPDATE module_as_entry SET asn=$3, prefix=$4::cidr, community_id=NULLIF($5::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now() + UPDATE module_as_entry SET asn=$3, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now() WHERE id=$1 AND module_id=$2`, - entryID, moduleID, cur.ASN, pref, uuidOrNilPtr(cur.CommunityID)) + entryID, moduleID, cur.ASN, uuidOrNilPtr(cur.CommunityID)) if err != nil { return nil, err } diff --git a/internal/store/backend.go b/internal/store/backend.go index cea9069..0bdecad 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -109,19 +109,22 @@ type CDNSourcePatch struct { } type ASEntry struct { - ID string - ModuleID string - ASN *int64 - Prefix *string - CommunityID *string + ID string + ModuleID string + ASN int64 + CommunityID *string } type ASEntryPatch struct { ASN *int64 - Prefix *string CommunityID *string } +// ValidASN reports whether n is a usable BGP ASN (1..4294967295). +func ValidASN(n int64) bool { + return n >= 1 && n <= 4294967295 +} + type DomainEntry struct { ID string ModuleID string diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go index 2ad5ee9..780904b 100644 --- a/internal/store/memory_crud.go +++ b/internal/store/memory_crud.go @@ -217,7 +217,7 @@ func (m *Memory) ListASEntries(tenantID, moduleID string) ([]*ASEntry, error) { } func (m *Memory) CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry, error) { - if in == nil || (in.ASN == nil && (in.Prefix == nil || strings.TrimSpace(*in.Prefix) == "")) { + if in == nil || !ValidASN(in.ASN) { return nil, ErrInvalidInput } m.mu.Lock() @@ -230,7 +230,7 @@ func (m *Memory) CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry return nil, ErrInvalidInput } id := uuid.NewString() - e := &ASEntry{ID: id, ModuleID: moduleID, ASN: in.ASN, Prefix: in.Prefix, CommunityID: in.CommunityID} + e := &ASEntry{ID: id, ModuleID: moduleID, ASN: in.ASN, CommunityID: in.CommunityID} m.asEntries[id] = e return e, nil } @@ -249,15 +249,7 @@ func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntr return nil, ErrNotFound } if patch.ASN != nil { - e.ASN = patch.ASN - } - if patch.Prefix != nil { - p := strings.TrimSpace(*patch.Prefix) - if p == "" { - e.Prefix = nil - } else { - e.Prefix = &p - } + e.ASN = *patch.ASN } if patch.CommunityID != nil { v := strings.TrimSpace(*patch.CommunityID) @@ -267,6 +259,9 @@ func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntr e.CommunityID = &v } } + if !ValidASN(e.ASN) { + return nil, ErrInvalidInput + } return e, nil } diff --git a/migrations/postgres/000003_as_entry_asn_only.down.sql b/migrations/postgres/000003_as_entry_asn_only.down.sql new file mode 100644 index 0000000..a6fa7c3 --- /dev/null +++ b/migrations/postgres/000003_as_entry_asn_only.down.sql @@ -0,0 +1,9 @@ +DELETE FROM revision_materialized_prefix WHERE prefix LIKE 'as:%'; + +ALTER TABLE revision_materialized_prefix + ALTER COLUMN prefix TYPE CIDR USING prefix::cidr; + +ALTER TABLE module_as_entry ADD COLUMN prefix CIDR; +ALTER TABLE module_as_entry ALTER COLUMN asn DROP NOT NULL; +ALTER TABLE module_as_entry + ADD CONSTRAINT module_as_entry_asn_or_prefix_chk CHECK (asn IS NOT NULL OR prefix IS NOT NULL); diff --git a/migrations/postgres/000003_as_entry_asn_only.up.sql b/migrations/postgres/000003_as_entry_asn_only.up.sql new file mode 100644 index 0000000..4313a99 --- /dev/null +++ b/migrations/postgres/000003_as_entry_asn_only.up.sql @@ -0,0 +1,9 @@ +-- AS-запись: только ASN + community; снимок ревизии может хранить ключи вида as:. +ALTER TABLE revision_materialized_prefix + ALTER COLUMN prefix TYPE TEXT USING prefix::text; + +DELETE FROM module_as_entry WHERE asn IS NULL; + +ALTER TABLE module_as_entry DROP CONSTRAINT module_as_entry_asn_or_prefix_chk; +ALTER TABLE module_as_entry DROP COLUMN prefix; +ALTER TABLE module_as_entry ALTER COLUMN asn SET NOT NULL; diff --git a/migrations/sqlite/000003_as_entry_asn_only.down.sql b/migrations/sqlite/000003_as_entry_asn_only.down.sql new file mode 100644 index 0000000..882def3 --- /dev/null +++ b/migrations/sqlite/000003_as_entry_asn_only.down.sql @@ -0,0 +1,18 @@ +CREATE TABLE module_as_entry_old ( + id TEXT PRIMARY KEY NOT NULL, + module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE, + asn INTEGER, + prefix TEXT, + community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (asn IS NOT NULL OR prefix IS NOT NULL) +); + +INSERT INTO module_as_entry_old (id, module_id, asn, prefix, community_id, created_at, updated_at) +SELECT id, module_id, asn, NULL, community_id, created_at, updated_at FROM module_as_entry; + +DROP TABLE module_as_entry; +ALTER TABLE module_as_entry_old RENAME TO module_as_entry; + +CREATE INDEX idx_module_as_entry_module ON module_as_entry (module_id); diff --git a/migrations/sqlite/000003_as_entry_asn_only.up.sql b/migrations/sqlite/000003_as_entry_asn_only.up.sql new file mode 100644 index 0000000..9a5ebeb --- /dev/null +++ b/migrations/sqlite/000003_as_entry_asn_only.up.sql @@ -0,0 +1,20 @@ +-- AS-запись: только ASN + community (revision_materialized_prefix.prefix уже TEXT). + +DELETE FROM module_as_entry WHERE asn IS NULL; + +CREATE TABLE module_as_entry_new ( + id TEXT PRIMARY KEY NOT NULL, + module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE, + asn INTEGER NOT NULL, + community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +INSERT INTO module_as_entry_new (id, module_id, asn, community_id, created_at, updated_at) +SELECT id, module_id, asn, community_id, created_at, updated_at FROM module_as_entry; + +DROP TABLE module_as_entry; +ALTER TABLE module_as_entry_new RENAME TO module_as_entry; + +CREATE INDEX idx_module_as_entry_module ON module_as_entry (module_id); diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index eb91cf8..4d9fa15 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -36,13 +36,15 @@ export type ModulePatch = Partial>; // ---- AS Entries ---- export type AsEntry = { id: string; - asn: number | null; - prefix: string | null; + asn: number; community_id: string | null; }; export type AsEntryCreate = { + asn: number; + community_id?: string | null; +}; +export type AsEntryPatch = { asn?: number; - prefix?: string; community_id?: string | null; }; export type AsEntriesResponse = Page; @@ -158,6 +160,7 @@ export type RevisionRow = { export type RevisionsResponse = Page; export type RevisionPrefix = { + /** Обычно CIDR; для AS-модуля в снимке ревизии — строка вида `as:<номер_asn>`. */ prefix: string; community_id?: string | null; }; diff --git a/web/src/routes/modules/+page.svelte b/web/src/routes/modules/+page.svelte index 2ea48db..e8e2167 100644 --- a/web/src/routes/modules/+page.svelte +++ b/web/src/routes/modules/+page.svelte @@ -49,7 +49,7 @@ }); const moduleTypes = [ - { value: 'AS_PREFIXES', label: 'AS Prefixes' }, + { value: 'AS_PREFIXES', label: 'AS (номера)' }, { value: 'CDN_CIDRS', label: 'CDN CIDRs' }, { value: 'DOMAINS', label: 'Домены' }, { value: 'IP_RANGES', label: 'IP Ranges' } diff --git a/web/src/routes/modules/[moduleId]/+page.svelte b/web/src/routes/modules/[moduleId]/+page.svelte index e8a70e1..5dd34b6 100644 --- a/web/src/routes/modules/[moduleId]/+page.svelte +++ b/web/src/routes/modules/[moduleId]/+page.svelte @@ -9,6 +9,7 @@ ModulePatch, AsEntry, AsEntryCreate, + AsEntryPatch, AsEntriesResponse, CdnSource, CdnSourceCreate, @@ -82,7 +83,7 @@ let asEntries = $state([]); let asDialog = $state(false); let asEdit = $state(null); - let asForm = $state({ asn: undefined, prefix: undefined, community_id: null }); + let asForm = $state({ asn: 0, community_id: null }); let asSaving = $state(false); let asDeleteTarget = $state(null); @@ -223,22 +224,28 @@ // --- AS Entries --- function openAsCreate() { asEdit = null; - asForm = { asn: undefined, prefix: undefined, community_id: null }; + asForm = { asn: 0, community_id: null }; asDialog = true; } function openAsEdit(entry: AsEntry) { asEdit = entry; - asForm = { asn: entry.asn ?? undefined, prefix: entry.prefix ?? undefined, community_id: entry.community_id }; + asForm = { asn: entry.asn, community_id: entry.community_id }; asDialog = true; } async function saveAs() { + const asn = Number(asForm.asn); + if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) { + toast.error('Укажите корректный ASN (1–4294967295)'); + return; + } asSaving = true; try { + const body: AsEntryCreate | AsEntryPatch = { asn, community_id: asForm.community_id }; if (asEdit) { - await apiMutate(`/v1/modules/${moduleId}/as-entries/${asEdit.id}`, 'PATCH', asForm); + await apiMutate(`/v1/modules/${moduleId}/as-entries/${asEdit.id}`, 'PATCH', body); toast.success('Запись обновлена'); } else { - await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', asForm); + await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate); toast.success('Запись добавлена'); } asDialog = false; @@ -468,7 +475,7 @@
AS-записи - ASN и/или префиксы для анонса + Номер AS и привязка к BGP community
@@ -477,7 +484,6 @@ ASN - Префикс Community @@ -485,8 +491,7 @@ {#each asEntries as entry (entry.id)} - {entry.asn ?? '—'} - {entry.prefix ?? '—'} + {entry.asn} {communityName(entry.community_id)}
@@ -497,7 +502,7 @@ {:else} - Нет записей + Нет записей {/each} @@ -717,16 +722,12 @@ {asEdit ? 'Редактировать запись' : 'Новая AS-запись'} - ASN и/или префикс для анонса через BGP. + Номер автономной системы и community для политики анонса.
- -
-
- - +
@@ -754,7 +755,7 @@ Удалить запись? - ASN: {asDeleteTarget?.asn ?? '—'}, Префикс: {asDeleteTarget?.prefix ?? '—'} + ASN: {asDeleteTarget?.asn ?? '—'} (asDeleteTarget = null)}>Отмена