Introduced a new GET endpoint `/v1/communities/{id}/prefixes` to retrieve unique prefixes associated with a community, including pagination support via cursor and limit parameters. Updated OpenAPI documentation to reflect this addition. Implemented backend logic in both PostgreSQL and in-memory storage to handle the new functionality, ensuring proper authorization checks and response formatting.
1304 lines
42 KiB
Go
1304 lines
42 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"evobgp/internal/importer"
|
|
"evobgp/internal/pipeline"
|
|
"evobgp/internal/runtimelogs"
|
|
"evobgp/internal/store"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
|
m.HandleFunc("POST /modules", s.handlePostModule)
|
|
m.HandleFunc("PATCH /modules/{module_id}", s.handlePatchModule)
|
|
m.HandleFunc("DELETE /modules/{module_id}", s.handleDeleteModule)
|
|
|
|
m.HandleFunc("GET /modules/{module_id}/cdn-sources", s.handleListCDNSources)
|
|
m.HandleFunc("POST /modules/{module_id}/cdn-sources", s.handlePostCDNSource)
|
|
m.HandleFunc("POST /modules/{module_id}/cdn-sources/preview", s.handlePreviewCDNSource)
|
|
m.HandleFunc("PATCH /modules/{module_id}/cdn-sources/{source_id}", s.handlePatchCDNSource)
|
|
m.HandleFunc("DELETE /modules/{module_id}/cdn-sources/{source_id}", s.handleDeleteCDNSource)
|
|
|
|
m.HandleFunc("GET /modules/{module_id}/as-entries", s.handleListAS)
|
|
m.HandleFunc("POST /modules/{module_id}/as-entries", s.handlePostAS)
|
|
m.HandleFunc("PATCH /modules/{module_id}/as-entries/{entry_id}", s.handlePatchAS)
|
|
m.HandleFunc("DELETE /modules/{module_id}/as-entries/{entry_id}", s.handleDeleteAS)
|
|
|
|
m.HandleFunc("GET /modules/{module_id}/domain-entries", s.handleListDomain)
|
|
m.HandleFunc("POST /modules/{module_id}/domain-entries", s.handlePostDomain)
|
|
m.HandleFunc("PATCH /modules/{module_id}/domain-entries/{entry_id}", s.handlePatchDomain)
|
|
m.HandleFunc("DELETE /modules/{module_id}/domain-entries/{entry_id}", s.handleDeleteDomain)
|
|
|
|
m.HandleFunc("GET /modules/{module_id}/ip-range-entries", s.handleListIPRange)
|
|
m.HandleFunc("POST /modules/{module_id}/ip-range-entries", s.handlePostIPRange)
|
|
m.HandleFunc("PATCH /modules/{module_id}/ip-range-entries/{entry_id}", s.handlePatchIPRange)
|
|
m.HandleFunc("DELETE /modules/{module_id}/ip-range-entries/{entry_id}", s.handleDeleteIPRange)
|
|
m.HandleFunc("GET /modules/{module_id}/entries.csv", s.handleExportModuleEntriesCSV)
|
|
m.HandleFunc("POST /modules/{module_id}/entries.csv", s.handleImportModuleEntriesCSV)
|
|
|
|
m.HandleFunc("GET /doh-profiles", s.handleListDoh)
|
|
m.HandleFunc("POST /doh-profiles", s.handlePostDoh)
|
|
m.HandleFunc("GET /doh-profiles/{id}", s.handleGetDoh)
|
|
m.HandleFunc("PATCH /doh-profiles/{id}", s.handlePatchDoh)
|
|
m.HandleFunc("DELETE /doh-profiles/{id}", s.handleDeleteDoh)
|
|
|
|
m.HandleFunc("GET /communities", s.handleListComm)
|
|
m.HandleFunc("POST /communities", s.handlePostComm)
|
|
m.HandleFunc("GET /communities/{id}", s.handleGetComm)
|
|
m.HandleFunc("GET /communities/{id}/prefixes", s.handleListCommPrefixes)
|
|
m.HandleFunc("PATCH /communities/{id}", s.handlePatchComm)
|
|
m.HandleFunc("DELETE /communities/{id}", s.handleDeleteComm)
|
|
|
|
m.HandleFunc("POST /peers", s.handlePostPeer)
|
|
m.HandleFunc("GET /peers/{id}", s.handleGetPeer)
|
|
m.HandleFunc("PATCH /peers/{id}", s.handlePatchPeer)
|
|
m.HandleFunc("DELETE /peers/{id}", s.handleDeletePeer)
|
|
|
|
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
|
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
|
|
m.HandleFunc("PATCH /speakers/{speaker_id}", s.handlePatchSpeaker)
|
|
m.HandleFunc("DELETE /speakers/{speaker_id}", s.handleDeleteSpeaker)
|
|
|
|
m.HandleFunc("GET /revisions/{revision_id}/prefixes", s.handleRevisionPrefixes)
|
|
|
|
m.HandleFunc("GET /settings", s.handleGetSettings)
|
|
m.HandleFunc("PATCH /settings", s.handlePatchSettings)
|
|
|
|
s.registerAPIKeyRoutes(m)
|
|
}
|
|
|
|
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
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"`
|
|
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")
|
|
return
|
|
}
|
|
newModule := &store.Module{
|
|
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 a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
|
|
newModule.CreatedByUserID = a.UserID
|
|
}
|
|
mod, err := s.store.CreateModule(a.TenantID, newModule)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.module.create", "Created module "+mod.Name, mod.ID, map[string]any{"module_id": mod.ID, "type": mod.Type, "name": mod.Name})
|
|
writeJSON(w, http.StatusCreated, moduleJSON(mod))
|
|
}
|
|
|
|
func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
rawBody, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid body")
|
|
return
|
|
}
|
|
var body store.ModulePatch
|
|
if err := json.Unmarshal(rawBody, &body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
// NOTE:
|
|
// In Go, unmarshalling JSON `null` into pointer fields results in nil,
|
|
// which is indistinguishable from "field omitted". For PATCH we need to
|
|
// distinguish these cases so clients can explicitly clear nullable fields.
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(rawBody, &raw); err == nil {
|
|
if v, ok := raw["default_community_id"]; ok && string(v) == "null" {
|
|
empty := ""
|
|
body.DefaultCommunityID = &empty
|
|
}
|
|
if v, ok := raw["doh_profile_id"]; ok && string(v) == "null" {
|
|
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
|
|
}
|
|
if v, ok := raw["refresh_interval_sec"]; ok && string(v) == "null" {
|
|
zero := 0
|
|
body.RefreshIntervalSec = &zero
|
|
}
|
|
}
|
|
moduleID := r.PathValue("module_id")
|
|
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
|
|
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
|
return
|
|
}
|
|
}
|
|
mod, err := s.store.UpdateModule(a.TenantID, moduleID, &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.module.update", "Updated module "+mod.Name, mod.ID, map[string]any{"module_id": mod.ID, "name": mod.Name})
|
|
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
|
}
|
|
|
|
func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
moduleID := r.PathValue("module_id")
|
|
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
|
|
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
|
return
|
|
}
|
|
}
|
|
if err := s.store.SoftDeleteModule(a.TenantID, moduleID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.module.delete", "Deleted module", moduleID, map[string]any{"module_id": moduleID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func writeStoreErr(w http.ResponseWriter, err error) {
|
|
if err != nil {
|
|
log.Printf("httpapi: store: %v", err)
|
|
}
|
|
if err == store.ErrNotFound || err == store.ErrTenantScope {
|
|
writeProblem(w, http.StatusNotFound, "Not Found", notFoundDetail)
|
|
return
|
|
}
|
|
if err == store.ErrInvalidInput {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
|
return
|
|
}
|
|
if writePostgresStoreErr(w, err) {
|
|
return
|
|
}
|
|
writeInternalError(w, "store", err)
|
|
}
|
|
|
|
func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
if !errors.As(err, &pgErr) {
|
|
return false
|
|
}
|
|
switch pgErr.Code {
|
|
case "42P01":
|
|
writeProblem(w, http.StatusServiceUnavailable, "Service Unavailable",
|
|
"database schema outdated; restart API after deploy or apply migration 000027_firewall")
|
|
return true
|
|
case "23505":
|
|
writeProblem(w, http.StatusConflict, "Conflict", "resource already exists")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
|
return
|
|
}
|
|
list, err := s.store.ListCDNSources(a.TenantID, r.PathValue("module_id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writePaginatedListJSON(w, r, list, cdnSourceJSON)
|
|
}
|
|
|
|
func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
|
m := map[string]any{"id": x.ID, "source_kind": x.SourceKind, "url": x.URL, "prefix_path": x.PrefixPath, "etag": x.Etag}
|
|
if x.RefreshIntervalSec != nil {
|
|
m["refresh_interval_sec"] = *x.RefreshIntervalSec
|
|
} else {
|
|
m["refresh_interval_sec"] = nil
|
|
}
|
|
if x.CommunityID != nil {
|
|
m["community_id"] = *x.CommunityID
|
|
} else {
|
|
m["community_id"] = nil
|
|
}
|
|
if x.LastRefreshedAt != nil {
|
|
m["last_refreshed_at"] = x.LastRefreshedAt.UTC().Format(time.RFC3339Nano)
|
|
} else {
|
|
m["last_refreshed_at"] = nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
var body struct {
|
|
URL string `json:"url"`
|
|
SourceKind string `json:"source_kind"`
|
|
PrefixPath string `json:"prefix_path"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
u := strings.TrimSpace(body.URL)
|
|
if u == "" {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "url is required")
|
|
return
|
|
}
|
|
if _, err := pipeline.ValidateCDNURL(u); err != nil {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
|
return
|
|
}
|
|
if err := pipeline.ResolveCDNURLHost(r.Context(), u); err != nil {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
|
return
|
|
}
|
|
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
if mod.Type != "CDN_CIDRS" {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "module type must be CDN_CIDRS")
|
|
return
|
|
}
|
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u, nil)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid url")
|
|
return
|
|
}
|
|
resp, err := s.cdnHTTP.Do(req)
|
|
if err != nil {
|
|
writeBadGateway(w, "cdn preview fetch", err)
|
|
return
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
|
|
return
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
if err != nil {
|
|
writeBadGateway(w, "cdn preview read body", err)
|
|
return
|
|
}
|
|
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
|
if err != nil {
|
|
log.Printf("httpapi: cdn preview extract: %v", err)
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", cdnExtractDetail)
|
|
return
|
|
}
|
|
items := make([]string, 0, len(pfxs))
|
|
const previewLimit = 100
|
|
for i, p := range pfxs {
|
|
if i >= previewLimit {
|
|
break
|
|
}
|
|
items = append(items, p.String())
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
"total": len(pfxs),
|
|
"truncated": len(pfxs) > previewLimit,
|
|
"source_url": u,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
var body store.CDNSource
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
if body.URL != "" {
|
|
if _, err := pipeline.ValidateCDNURL(body.URL); err != nil {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
|
return
|
|
}
|
|
if err := pipeline.ResolveCDNURLHost(r.Context(), body.URL); err != nil {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
|
return
|
|
}
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
x, err := s.store.CreateCDNSource(a.TenantID, mid, &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.cdn_source.create", "Created CDN source", x.ID, map[string]any{"module_id": mid, "source_id": x.ID, "url": x.URL})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_create")
|
|
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
|
|
}
|
|
|
|
func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
var body store.CDNSourcePatch
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
if body.URL != nil && strings.TrimSpace(*body.URL) != "" {
|
|
if _, err := pipeline.ValidateCDNURL(*body.URL); err != nil {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
|
return
|
|
}
|
|
if err := pipeline.ResolveCDNURLHost(r.Context(), *body.URL); err != nil {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
|
|
return
|
|
}
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
x, err := s.store.UpdateCDNSource(a.TenantID, mid, r.PathValue("source_id"), &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.cdn_source.update", "Updated CDN source", x.ID, map[string]any{"module_id": mid, "source_id": x.ID})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_patch")
|
|
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
|
|
}
|
|
|
|
func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
sourceID := r.PathValue("source_id")
|
|
if err := s.store.DeleteCDNSource(a.TenantID, mid, sourceID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.cdn_source.delete", "Deleted CDN source", sourceID, map[string]any{"module_id": mid, "source_id": sourceID})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_delete")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
|
return
|
|
}
|
|
list, err := s.store.ListASEntries(a.TenantID, r.PathValue("module_id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writePaginatedListJSON(w, r, list, asEntryJSON)
|
|
}
|
|
|
|
func asEntryJSON(x *store.ASEntry) map[string]any {
|
|
m := map[string]any{"id": x.ID, "asn": x.ASN}
|
|
if x.CommunityID != nil {
|
|
m["community_id"] = *x.CommunityID
|
|
} else {
|
|
m["community_id"] = nil
|
|
}
|
|
if strings.TrimSpace(x.ASNName) != "" {
|
|
m["asn_name"] = strings.TrimSpace(x.ASNName)
|
|
} else {
|
|
m["asn_name"] = nil
|
|
}
|
|
if x.PrefixCount != nil {
|
|
m["prefix_count"] = *x.PrefixCount
|
|
} else {
|
|
m["prefix_count"] = nil
|
|
}
|
|
if x.ASNResolvedAt != nil {
|
|
m["asn_resolved_at"] = x.ASNResolvedAt.UTC().Format(time.RFC3339Nano)
|
|
} else {
|
|
m["asn_resolved_at"] = nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
var body store.ASEntry
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
x, err := s.store.CreateASEntry(a.TenantID, mid, &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.as_entry.create", "Created AS entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "asn": x.ASN})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_create")
|
|
writeJSON(w, http.StatusCreated, asEntryJSON(x))
|
|
}
|
|
|
|
func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
var body store.ASEntryPatch
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
x, err := s.store.UpdateASEntry(a.TenantID, mid, r.PathValue("entry_id"), &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.as_entry.update", "Updated AS entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "asn": x.ASN})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_patch")
|
|
writeJSON(w, http.StatusOK, asEntryJSON(x))
|
|
}
|
|
|
|
func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
entryID := r.PathValue("entry_id")
|
|
if err := s.store.DeleteASEntry(a.TenantID, mid, entryID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.as_entry.delete", "Deleted AS entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_delete")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
|
return
|
|
}
|
|
list, err := s.store.ListDomainEntries(a.TenantID, r.PathValue("module_id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writePaginatedListJSON(w, r, list, domainEntryJSON)
|
|
}
|
|
|
|
func domainEntryJSON(x *store.DomainEntry) map[string]any {
|
|
m := map[string]any{"id": x.ID, "fqdn": x.FQDN}
|
|
if x.CommunityID != nil {
|
|
m["community_id"] = *x.CommunityID
|
|
} else {
|
|
m["community_id"] = nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
var body store.DomainEntry
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
x, err := s.store.CreateDomainEntry(a.TenantID, mid, &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.domain_entry.create", "Created domain entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "fqdn": x.FQDN})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_create")
|
|
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
|
|
}
|
|
|
|
func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
var body store.DomainEntryPatch
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
x, err := s.store.UpdateDomainEntry(a.TenantID, mid, r.PathValue("entry_id"), &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.domain_entry.update", "Updated domain entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "fqdn": x.FQDN})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_patch")
|
|
writeJSON(w, http.StatusOK, domainEntryJSON(x))
|
|
}
|
|
|
|
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
entryID := r.PathValue("entry_id")
|
|
if err := s.store.DeleteDomainEntry(a.TenantID, mid, entryID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.domain_entry.delete", "Deleted domain entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_delete")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
|
return
|
|
}
|
|
list, err := s.store.ListIPRangeEntries(a.TenantID, r.PathValue("module_id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writePaginatedListJSON(w, r, list, ipRangeJSON)
|
|
}
|
|
|
|
func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
|
|
m := map[string]any{"id": x.ID, "prefix": x.Prefix}
|
|
if x.CommunityID != nil {
|
|
m["community_id"] = *x.CommunityID
|
|
} else {
|
|
m["community_id"] = nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
var body store.IPRangeEntry
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
x, err := s.store.CreateIPRangeEntry(a.TenantID, mid, &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.ip_range.create", "Created IP range entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "prefix": x.Prefix})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_create")
|
|
writeJSON(w, http.StatusCreated, ipRangeJSON(x))
|
|
}
|
|
|
|
func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
var body store.IPRangePatch
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
x, err := s.store.UpdateIPRangeEntry(a.TenantID, mid, r.PathValue("entry_id"), &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.ip_range.update", "Updated IP range entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "prefix": x.Prefix})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_patch")
|
|
writeJSON(w, http.StatusOK, ipRangeJSON(x))
|
|
}
|
|
|
|
func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
mid := r.PathValue("module_id")
|
|
entryID := r.PathValue("entry_id")
|
|
if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, entryID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.ip_range.delete", "Deleted IP range entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_delete")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
|
return
|
|
}
|
|
moduleID := r.PathValue("module_id")
|
|
mod, err := s.store.GetModule(a.TenantID, moduleID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
communities, err := s.store.ListCommunities(a.TenantID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
communityByID := make(map[string]string, len(communities))
|
|
for _, c := range communities {
|
|
communityByID[c.ID] = strings.TrimSpace(c.Community)
|
|
}
|
|
|
|
records := make([][]string, 0, 64)
|
|
|
|
switch mod.Type {
|
|
case "AS_PREFIXES":
|
|
records = append(records, []string{"asn", "community"})
|
|
list, err := s.store.ListASEntries(a.TenantID, moduleID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
for _, x := range list {
|
|
community := ""
|
|
if x.CommunityID != nil {
|
|
community = communityByID[*x.CommunityID]
|
|
}
|
|
records = append(records, []string{strconv.FormatInt(x.ASN, 10), community})
|
|
}
|
|
case "DOMAINS":
|
|
records = append(records, []string{"domain", "community"})
|
|
list, err := s.store.ListDomainEntries(a.TenantID, moduleID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
for _, x := range list {
|
|
community := ""
|
|
if x.CommunityID != nil {
|
|
community = communityByID[*x.CommunityID]
|
|
}
|
|
records = append(records, []string{x.FQDN, community})
|
|
}
|
|
case "IP_RANGES":
|
|
records = append(records, []string{"ipRange", "community"})
|
|
list, err := s.store.ListIPRangeEntries(a.TenantID, moduleID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
for _, x := range list {
|
|
community := ""
|
|
if x.CommunityID != nil {
|
|
community = communityByID[*x.CommunityID]
|
|
}
|
|
records = append(records, []string{x.Prefix, community})
|
|
}
|
|
default:
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="module-entries.csv"`)
|
|
w.WriteHeader(http.StatusOK)
|
|
cw := csv.NewWriter(w)
|
|
for _, rec := range records {
|
|
if err := cw.Write(rec); err != nil {
|
|
return
|
|
}
|
|
}
|
|
cw.Flush()
|
|
}
|
|
|
|
func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
|
return
|
|
}
|
|
moduleID := r.PathValue("module_id")
|
|
res, err := importer.ImportModuleEntriesCSV(s.store, a.TenantID, moduleID, r.Body)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrInvalidInput) {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
|
|
return
|
|
}
|
|
if strings.Contains(err.Error(), "importer: invalid csv") {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
|
|
return
|
|
}
|
|
if strings.Contains(err.Error(), "importer: line") {
|
|
log.Printf("httpapi: csv import: %v", err)
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", csvInvalidRowDetail)
|
|
return
|
|
}
|
|
if strings.Contains(err.Error(), "importer: csv import/export") {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
|
return
|
|
}
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
if res.Imported > 0 {
|
|
switch res.ModuleType {
|
|
case "AS_PREFIXES":
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "as_entry_import_csv")
|
|
case "DOMAINS":
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "domain_entry_import_csv")
|
|
case "IP_RANGES":
|
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "ip_range_import_csv")
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"imported": res.Imported,
|
|
"module_type": res.ModuleType,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleListDoh(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
|
return
|
|
}
|
|
list, err := s.store.ListDohProfiles(a.TenantID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(list))
|
|
for _, x := range list {
|
|
items = append(items, dohJSON(x))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
func dohJSON(x *store.DohProfile) map[string]any {
|
|
m := map[string]any{"id": x.ID, "name": x.Name, "url": x.URL}
|
|
if x.TimeoutMs != nil {
|
|
m["timeout_ms"] = *x.TimeoutMs
|
|
} else {
|
|
m["timeout_ms"] = nil
|
|
}
|
|
if x.SecretRef != nil {
|
|
m["vault_secret_ref"] = *x.SecretRef
|
|
} else {
|
|
m["vault_secret_ref"] = nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
|
return
|
|
}
|
|
x, err := s.store.GetDohProfile(a.TenantID, r.PathValue("id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, dohJSON(x))
|
|
}
|
|
|
|
func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
|
return
|
|
}
|
|
var body store.DohProfile
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
x, err := s.store.CreateDohProfile(a.TenantID, &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.doh_profile.create", "Created DoH profile "+x.Name, x.ID, map[string]any{"profile_id": x.ID, "name": x.Name})
|
|
writeJSON(w, http.StatusCreated, dohJSON(x))
|
|
}
|
|
|
|
func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
|
return
|
|
}
|
|
var body store.DohProfilePatch
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
x, err := s.store.UpdateDohProfile(a.TenantID, r.PathValue("id"), &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.doh_profile.update", "Updated DoH profile "+x.Name, x.ID, map[string]any{"profile_id": x.ID, "name": x.Name})
|
|
writeJSON(w, http.StatusOK, dohJSON(x))
|
|
}
|
|
|
|
func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
|
return
|
|
}
|
|
profileID := r.PathValue("id")
|
|
if err := s.store.DeleteDohProfile(a.TenantID, profileID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.doh_profile.delete", "Deleted DoH profile", profileID, map[string]any{"profile_id": profileID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleListComm(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
|
return
|
|
}
|
|
list, err := s.store.ListCommunities(a.TenantID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(list))
|
|
for _, x := range list {
|
|
items = append(items, commJSON(x))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
func commJSON(x *store.Community) map[string]any {
|
|
var v any
|
|
if err := json.Unmarshal([]byte(x.ValueJSON), &v); err != nil {
|
|
v = x.ValueJSON
|
|
}
|
|
return map[string]any{"id": x.ID, "community": x.Community, "title": x.Title, "value_json": v}
|
|
}
|
|
|
|
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
|
return
|
|
}
|
|
x, err := s.store.GetCommunity(a.TenantID, r.PathValue("id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, commJSON(x))
|
|
}
|
|
|
|
func (s *Server) handleListCommPrefixes(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit == 0 {
|
|
limit = 500
|
|
}
|
|
cursor := r.URL.Query().Get("cursor")
|
|
rows, next, more, err := s.store.ListCommunityPrefixes(a.TenantID, r.PathValue("id"), cursor, limit)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(rows))
|
|
prefixes := make([]string, 0, len(rows))
|
|
for _, pr := range rows {
|
|
m := map[string]any{"prefix": pr.Prefix}
|
|
if pr.Source != "" {
|
|
m["source"] = pr.Source
|
|
}
|
|
items = append(items, m)
|
|
prefixes = append(prefixes, pr.Prefix)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
"prefixes": prefixes,
|
|
"next_cursor": strPtrOrNull(next),
|
|
"has_more": more,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
|
return
|
|
}
|
|
var body store.Community
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
x, err := s.store.CreateCommunity(a.TenantID, &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.community.create", "Created community "+x.Community, x.ID, map[string]any{"community_id": x.ID, "community": x.Community})
|
|
writeJSON(w, http.StatusCreated, commJSON(x))
|
|
}
|
|
|
|
func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
|
return
|
|
}
|
|
var body store.CommunityPatch
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
x, err := s.store.UpdateCommunity(a.TenantID, r.PathValue("id"), &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.community.update", "Updated community "+x.Community, x.ID, map[string]any{"community_id": x.ID, "community": x.Community})
|
|
writeJSON(w, http.StatusOK, commJSON(x))
|
|
}
|
|
|
|
func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
|
return
|
|
}
|
|
commID := r.PathValue("id")
|
|
if err := s.store.DeleteCommunity(a.TenantID, commID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.community.delete", "Deleted community", commID, map[string]any{"community_id": commID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
|
return
|
|
}
|
|
var body store.BGPPeer
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
body.TenantID = a.TenantID
|
|
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
|
|
body.CreatedByUserID = a.UserID
|
|
}
|
|
x, err := s.store.CreatePeer(a.TenantID, &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.peer.create", "Created BGP peer "+x.Name, x.ID, map[string]any{"peer_id": x.ID, "neighbor": x.Neighbor})
|
|
s.enqueuePeerReconcile(a.TenantID, "peer_create")
|
|
writeJSON(w, http.StatusCreated, peerJSON(x))
|
|
}
|
|
|
|
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
|
|
return
|
|
}
|
|
x, err := s.store.GetPeer(a.TenantID, r.PathValue("id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, x.CreatedByUserID) {
|
|
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, peerJSON(x))
|
|
}
|
|
|
|
func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
|
return
|
|
}
|
|
var body store.PeerPatch
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
peerID := r.PathValue("id")
|
|
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
|
|
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
|
|
return
|
|
}
|
|
}
|
|
x, err := s.store.UpdatePeer(a.TenantID, peerID, &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.peer.update", "Updated BGP peer "+x.Name, x.ID, map[string]any{"peer_id": x.ID, "neighbor": x.Neighbor})
|
|
s.enqueuePeerReconcile(a.TenantID, "peer_patch")
|
|
writeJSON(w, http.StatusOK, peerJSON(x))
|
|
}
|
|
|
|
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
|
return
|
|
}
|
|
peerID := r.PathValue("id")
|
|
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
|
|
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
|
|
return
|
|
}
|
|
}
|
|
if err := s.store.DeletePeer(a.TenantID, peerID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.peer.delete", "Deleted BGP peer", peerID, map[string]any{"peer_id": peerID})
|
|
s.enqueuePeerReconcile(a.TenantID, "peer_delete")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
|
return
|
|
}
|
|
var body store.Speaker
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
if err := normalizeSpeakerCreate(&body); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
x, err := s.store.CreateSpeaker(a.TenantID, &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.speaker.create", "Created speaker "+x.ID, x.ID, map[string]any{"speaker_id": x.ID, "role": x.Role})
|
|
resp := speakerJSONFromStore(s.store, x)
|
|
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
|
|
resp["agent_secret"] = meta.AgentSecret
|
|
}
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
|
|
return
|
|
}
|
|
x, err := s.store.GetSpeaker(a.TenantID, r.PathValue("speaker_id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
|
}
|
|
|
|
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
|
return
|
|
}
|
|
var body store.SpeakerPatch
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
x, err := s.store.UpdateSpeaker(a.TenantID, r.PathValue("speaker_id"), &body)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.speaker.update", "Updated speaker "+x.ID, x.ID, map[string]any{"speaker_id": x.ID, "role": x.Role})
|
|
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
|
}
|
|
|
|
func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
|
return
|
|
}
|
|
speakerID := r.PathValue("speaker_id")
|
|
if err := s.store.DeleteSpeaker(a.TenantID, speakerID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.speaker.delete", "Deleted speaker", speakerID, map[string]any{"speaker_id": speakerID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
cursor := r.URL.Query().Get("cursor")
|
|
rows, next, more := s.store.ListRevisionPrefixes(a.TenantID, r.PathValue("revision_id"), cursor, limit)
|
|
items := make([]map[string]any, 0, len(rows))
|
|
for _, pr := range rows {
|
|
m := map[string]any{"prefix": pr.Prefix, "source": pr.Source}
|
|
if pr.CommunityID != nil {
|
|
m["community_id"] = *pr.CommunityID
|
|
} else {
|
|
m["community_id"] = nil
|
|
}
|
|
items = append(items, m)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": strPtrOrNull(next), "has_more": more})
|
|
}
|
|
|
|
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
|
|
return
|
|
}
|
|
m, err := s.store.ListGlobalSettings(a.TenantID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, m)
|
|
}
|
|
|
|
func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
|
|
return
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
|
return
|
|
}
|
|
if raw, ok := body["revision_retention_minutes"]; ok && raw != nil {
|
|
v, ok := parseRevisionRetentionMinutes(raw)
|
|
if !ok {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_retention_minutes must be an integer in range 15..43200")
|
|
return
|
|
}
|
|
body["revision_retention_minutes"] = v
|
|
}
|
|
if !runtimelogs.ValidateRuntimeLogsSettingsPatch(body) {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid runtime_logs_* settings")
|
|
return
|
|
}
|
|
if err := s.store.PatchGlobalSettings(a.TenantID, body); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
s.recordCRUDAudit(r, a, "bgp.settings.update", "Updated tenant settings", a.TenantID, map[string]any{"keys": settingsAuditKeys(body)})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func settingsAuditKeys(body map[string]any) []string {
|
|
if len(body) == 0 {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, len(body))
|
|
for k := range body {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func parseRevisionRetentionMinutes(v any) (int, bool) {
|
|
const minMinutes = 15
|
|
const maxMinutes = 30 * 24 * 60
|
|
|
|
var out int
|
|
switch x := v.(type) {
|
|
case float64:
|
|
if x != float64(int(x)) {
|
|
return 0, false
|
|
}
|
|
out = int(x)
|
|
case int:
|
|
out = x
|
|
case int64:
|
|
out = int(x)
|
|
case string:
|
|
n, err := strconv.Atoi(strings.TrimSpace(x))
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
out = n
|
|
default:
|
|
return 0, false
|
|
}
|
|
if out < minMinutes || out > maxMinutes {
|
|
return 0, false
|
|
}
|
|
return out, true
|
|
}
|