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}/prefixes", s.handleListCommPrefixes) m.HandleFunc("GET /communities/{id}", s.handleGetComm) 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) s.registerPeerDiscoveryRoutes(m) 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 case "22P02": writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid id format") 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 := pipeline.UpstreamHTTPDo(r.Context(), s.cdnHTTP, 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 req speakerCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json") return } metaStr, err := metaJSONRawToString(req.MetaJSON) if err != nil { writeProblem(w, http.StatusBadRequest, "Bad Request", "meta_json must be a JSON object or string") return } role := strings.TrimSpace(req.Role) if role == "" { role = "replica" } body := store.Speaker{ Role: role, Endpoint: strings.TrimSpace(req.Endpoint), MetaJSON: metaStr, } 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 } if strings.ToLower(strings.TrimSpace(x.Role)) != "master" { createdKey, kerr := s.store.CreateAPIKey(a.TenantID, &store.APIKeyCreate{ Name: "speaker:" + x.ID, Role: "node", }) if kerr != nil { _ = s.store.DeleteSpeaker(a.TenantID, x.ID) writeStoreErr(w, kerr) return } if err := s.keyResolver.Reload(s.store); err != nil { writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys") return } s.recordCRUDAudit(r, a, "bgp.api_key.create", "Created API key "+createdKey.Name, createdKey.ID, map[string]any{"api_key_id": createdKey.ID, "role": createdKey.Role, "speaker_id": x.ID}) resp["node_token"] = createdKey.Token s.attachReplicaInstall(resp, x, createdKey.Token, req, r) } 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 } if peerDiscoverySettingsChanged(body) { s.enqueuePeerReconcile(a.TenantID, "peer_discovery_settings") } 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 peerDiscoverySettingsChanged(body map[string]any) bool { for _, k := range []string{ "peer_discovery_enabled", "peer_discovery_ranges_v4", "peer_discovery_ranges_v6", "peer_discovery_require_external", } { if _, ok := body[k]; ok { return true } } return false } 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 }