feat: enhance EvoBGP with new command-line options for the evobgp-agent, including a watch command for periodic configuration updates. Update go.mod with additional dependencies and improve Docker Compose setup for new services, including NATS and various worker components.
CI / changes (push) Successful in 5s
CI / go (push) Successful in 1m40s
CI / openapi (push) Has been skipped
CI / bird2 (push) Successful in 17s

This commit is contained in:
Denozordec
2026-04-05 17:03:21 +07:00
parent bf52b21150
commit 6a55f72ab3
35 changed files with 4609 additions and 193 deletions
+18 -1
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
@@ -61,6 +62,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /speakers/{speaker_id}/revisions/latest", s.handleNodeLatestRevision)
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll)
s.registerCRUDRoutes(m)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -68,7 +70,20 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "checks": map[string]string{"memory_store": "ok"}})
checks := map[string]string{"store": "ok", "jobs": "memory"}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if s.pgPool != nil {
if err := s.pgPool.Ping(ctx); err != nil {
checks["postgres"] = err.Error()
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
return
}
checks["postgres"] = "ok"
} else {
checks["store_backend"] = "memory"
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "checks": checks})
}
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
@@ -107,6 +122,8 @@ func peerJSON(p *store.BGPPeer) map[string]any {
"id": p.ID,
"name": p.Name,
"neighbor": p.Neighbor,
"remote_asn": p.RemoteASN,
"enabled": p.Enabled,
"session_state": p.SessionState,
}
if p.SpeakerID != nil {
+793
View File
@@ -0,0 +1,793 @@
package httpapi
import (
"encoding/json"
"net/http"
"strconv"
"evobgp/internal/store"
)
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("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 /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("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("GET /revisions/{revision_id}/prefixes", s.handleRevisionPrefixes)
m.HandleFunc("GET /settings", s.handleGetSettings)
m.HandleFunc("PATCH /settings", s.handlePatchSettings)
}
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
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"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mod, err := s.store.CreateModule(a.TenantID, &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,
})
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, moduleJSON(mod))
}
func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.ModulePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mod, err := s.store.UpdateModule(a.TenantID, r.PathValue("module_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, moduleJSON(mod))
}
func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.SoftDeleteModule(a.TenantID, r.PathValue("module_id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func writeStoreErr(w http.ResponseWriter, err error) {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", err.Error())
return
}
if err == store.ErrInvalidInput {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
}
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
list, err := s.store.ListCDNSources(a.TenantID, r.PathValue("module_id"))
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, cdnSourceJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nil, "has_more": false})
}
func cdnSourceJSON(x *store.CDNSource) map[string]any {
m := map[string]any{"id": x.ID, "source_kind": x.SourceKind, "url": x.URL, "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
}
return m
}
func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.CDNSource
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.CreateCDNSource(a.TenantID, r.PathValue("module_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
}
func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.CDNSourcePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdateCDNSource(a.TenantID, r.PathValue("module_id"), r.PathValue("source_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
}
func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeleteCDNSource(a.TenantID, r.PathValue("module_id"), r.PathValue("source_id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
list, err := s.store.ListASEntries(a.TenantID, r.PathValue("module_id"))
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, asEntryJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
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
}
if x.CommunityID != nil {
m["community_id"] = *x.CommunityID
} else {
m["community_id"] = nil
}
return m
}
func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.ASEntry
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.CreateASEntry(a.TenantID, r.PathValue("module_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, asEntryJSON(x))
}
func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.ASEntryPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdateASEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, asEntryJSON(x))
}
func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeleteASEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
list, err := s.store.ListDomainEntries(a.TenantID, r.PathValue("module_id"))
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, domainEntryJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
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.requireAtLeast(w, a, "editor") {
return
}
var body store.DomainEntry
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.CreateDomainEntry(a.TenantID, r.PathValue("module_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
}
func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.DomainEntryPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdateDomainEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, domainEntryJSON(x))
}
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeleteDomainEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
list, err := s.store.ListIPRangeEntries(a.TenantID, r.PathValue("module_id"))
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, ipRangeJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
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.requireAtLeast(w, a, "editor") {
return
}
var body store.IPRangeEntry
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.CreateIPRangeEntry(a.TenantID, r.PathValue("module_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, ipRangeJSON(x))
}
func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.IPRangePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdateIPRangeEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, ipRangeJSON(x))
}
func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeleteIPRangeEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
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.requireAtLeast(w, a, "viewer") {
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.requireAtLeast(w, a, "editor") {
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
}
writeJSON(w, http.StatusCreated, dohJSON(x))
}
func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
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
}
writeJSON(w, http.StatusOK, dohJSON(x))
}
func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
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, "name": x.Name, "kind": x.Kind, "value_json": v}
}
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
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) handlePostComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
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
}
writeJSON(w, http.StatusCreated, commJSON(x))
}
func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
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
}
writeJSON(w, http.StatusOK, commJSON(x))
}
func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
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
x, err := s.store.CreatePeer(a.TenantID, &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, peerJSON(x))
}
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
x, err := s.store.GetPeer(a.TenantID, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
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.requireAtLeast(w, a, "editor") {
return
}
var body store.PeerPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdatePeer(a.TenantID, r.PathValue("id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, peerJSON(x))
}
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeletePeer(a.TenantID, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.Speaker
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.CreateSpeaker(a.TenantID, &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, speakerJSON(x))
}
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
x, err := s.store.GetSpeaker(a.TenantID, r.PathValue("speaker_id"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, speakerJSON(x))
}
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
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
}
writeJSON(w, http.StatusOK, speakerJSON(x))
}
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
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.requireAtLeast(w, a, "viewer") {
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.requireAtLeast(w, a, "operator") {
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 err := s.store.PatchGlobalSettings(a.TenantID, body); err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+48 -14
View File
@@ -1,21 +1,28 @@
package httpapi
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
"time"
"evobgp/internal/db"
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/repository"
"evobgp/internal/store"
"github.com/jackc/pgx/v5/pgxpool"
)
// Server implements EvoBGP control-plane HTTP API (subset focused on jobs, deploy, node bundle).
// Server implements EvoBGP control-plane HTTP API.
type Server struct {
store *store.Memory
store store.Backend
pgPool *pgxpool.Pool
jobs *jobs.Registry
bundlePriv ed25519.PrivateKey
apiKeys []apiKeyRecord
@@ -26,24 +33,43 @@ type Server struct {
// Options configures the API server.
type Options struct {
// APIKeys is comma-separated "token|tenantUUID|role" (role: viewer, editor, operator, node).
APIKeys string
// InsecureDev with SeedDemo allows Bearer "dev" as operator for the demo tenant (local only).
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
DatabaseURL string
InsecureDev bool
SeedDemo bool
// BundleSeedHex is 64 hex chars (32 bytes) for deterministic Ed25519 bundle signing; if empty, random.
BundleSeedHex string
// CORSAllowedOrigins is comma-separated list of allowed browser Origins (e.g. http://localhost:4173).
CORSAllowedOrigins string
}
// New constructs Server and wiring for async jobs.
func New(opts Options) (*Server, error) {
mem := store.NewMemory()
if opts.SeedDemo {
mem.SeedDemo()
var backend store.Backend
var pool *pgxpool.Pool
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if u := strings.TrimSpace(opts.DatabaseURL); u != "" {
p, err := db.OpenPostgresPool(ctx, u)
if err != nil {
return nil, err
}
pool = p
pgbe, err := repository.NewPostgres(ctx, p, opts.SeedDemo)
if err != nil {
pool.Close()
return nil, err
}
backend = pgbe
} else {
mem := store.NewMemory()
if opts.SeedDemo {
mem.SeedDemo()
}
backend = mem
}
wk := &jobs.Worker{Store: mem}
wk := &jobs.Worker{Store: backend}
reg := jobs.NewRegistry(wk.Process)
var priv ed25519.PrivateKey
@@ -61,18 +87,26 @@ func New(opts Options) (*Server, error) {
}
s := &Server{
store: mem,
store: backend,
pgPool: pool,
jobs: reg,
bundlePriv: priv,
apiKeys: parseAPIKeysSpec(opts.APIKeys),
insecureDev: opts.InsecureDev && opts.SeedDemo,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
}
observability.RegisterStoreMetrics(mem)
observability.RegisterStoreBackend(backend)
s.mux = http.NewServeMux()
s.registerRoutes()
return s, nil
}
// Store exposes the in-memory store (for operators / tests).
func (s *Server) Store() *store.Memory { return s.store }
// Close releases database resources.
func (s *Server) Close() {
if s.pgPool != nil {
s.pgPool.Close()
}
}
// Store exposes the backing store (for operators / tests).
func (s *Server) Store() store.Backend { return s.store }
+10
View File
@@ -26,6 +26,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator")
@@ -35,6 +36,15 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
base := ts.URL
t.Run("prometheus metrics", func(t *testing.T) {
// HTTPMiddleware increments the counter after the handler returns, so the scrape
// of /metrics does not include that same request; warm with a public route first.
warm, _ := http.NewRequest(http.MethodGet, base+"/v1/health", nil)
warmResp, err := client.Do(warm)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, warmResp.Body)
_ = warmResp.Body.Close()
req, _ := http.NewRequest(http.MethodGet, base+"/metrics", nil)
resp, err := client.Do(req)
if err != nil {