diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml index d5bf580e5..aa6159c20 100644 --- a/management/server/http/api/openapi.yml +++ b/management/server/http/api/openapi.yml @@ -14,6 +14,8 @@ tags: description: Interact with and view information about groups. - name: Rules description: Interact with and view information about rules. + - name: Routes + description: Interact with and view information about routes. components: schemas: User: @@ -191,17 +193,13 @@ components: $ref: '#/components/schemas/PeerMinimum' required: - peers - GroupPatchOperation: + PatchMinimum: type: object properties: op: description: Patch operation type type: string enum: [ "replace","add","remove" ] - path: - description: Group field to update in form / - type: string - enum: [ "name","peers" ] value: description: Values to be applied type: array @@ -209,8 +207,19 @@ components: type: string required: - op - - path - value + GroupPatchOperation: + allOf: + - $ref: '#/components/schemas/PatchMinimum' + - type: object + properties: + path: + description: Group field to update in form / + type: string + enum: [ "name","peers" ] + required: + - path + RuleMinimum: type: object properties: @@ -257,25 +266,73 @@ components: - sources - destinations RulePatchOperation: + allOf: + - $ref: '#/components/schemas/PatchMinimum' + - type: object + properties: + path: + description: Rule field to update in form / + type: string + enum: [ "name","description","disabled","flow","sources","destinations" ] + required: + - path + RouteRequest: type: object properties: - op: - description: Patch operation type + description: + description: Route description type: string - enum: [ "replace","add","remove" ] - path: - description: Rule field to update in form / + enabled: + description: Route status + type: boolean + peer: + description: Peer Identifier associated with route type: string - enum: [ "name","description","disabled","flow","sources","destinations" ] - value: - description: Values to be applied - type: array - items: - type: string + prefix: + description: Prefix or network range in CIDR format + type: string + metric: + description: Route metric number. Lowest number has higher priority + type: integer + maximum: 9999 + minimum: 1 + masquerade: + description: Indicate if peer should masquerade traffic to this route's prefix + type: boolean required: - - op - - path - - value + - id + - description + - enabled + - peer + - prefix + - metric + - masquerade + Route: + allOf: + - type: object + properties: + id: + description: Route Id + type: string + prefix_type: + description: Prefix type indicating if it is IPv4 or IPv6 + type: string + required: + - id + - prefix_type + - $ref: '#/components/schemas/RouteRequest' + RoutePatchOperation: + allOf: + - $ref: '#/components/schemas/PatchMinimum' + - type: object + properties: + path: + description: Route field to update in form / + type: string + enum: [ "prefix","description","enabled","peer","metric","masquerade" ] + required: + - path + responses: not_found: description: Resource not found @@ -945,5 +1002,176 @@ paths: "$ref": "#/components/responses/requires_authentication" '403': "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + + /api/routes: + get: + summary: Returns a list of all routes + tags: [ Routes ] + security: + - BearerAuth: [ ] + responses: + '200': + description: A JSON Array of Routes + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Route' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Creates a Route + tags: [ Routes ] + security: + - BearerAuth: [ ] + requestBody: + description: New Routes request + content: + 'application/json': + schema: + $ref: '#/components/schemas/RouteRequest' + responses: + '200': + description: A Route Object + content: + application/json: + schema: + $ref: '#/components/schemas/Route' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + + /api/routes/{id}: + get: + summary: Get information about a Routes + tags: [ Routes ] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Route ID + responses: + '200': + description: A Route object + content: + application/json: + schema: + $ref: '#/components/schemas/Route' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update/Replace a Route + tags: [ Routes ] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Route ID + requestBody: + description: Update Route request + content: + application/json: + schema: + $ref: '#/components/schemas/RouteRequest' + responses: + '200': + description: A Route object + content: + application/json: + schema: + $ref: '#/components/schemas/Route' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + patch: + summary: Update information about a Route + tags: [ Routes ] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Route ID + requestBody: + description: Update Route request using a list of json patch objects + content: + 'application/json': + schema: + type: array + items: + $ref: '#/components/schemas/RoutePatchOperation' + responses: + '200': + description: A Route object + content: + application/json: + schema: + $ref: '#/components/schemas/Route' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete a Route + tags: [ Routes ] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Route ID + responses: + '200': + description: Delete status code + content: { } + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" \ No newline at end of file diff --git a/management/server/http/api/types.gen.go b/management/server/http/api/types.gen.go index 8c37a484b..e175ce9fe 100644 --- a/management/server/http/api/types.gen.go +++ b/management/server/http/api/types.gen.go @@ -24,6 +24,30 @@ const ( GroupPatchOperationPathPeers GroupPatchOperationPath = "peers" ) +// Defines values for PatchMinimumOp. +const ( + PatchMinimumOpAdd PatchMinimumOp = "add" + PatchMinimumOpRemove PatchMinimumOp = "remove" + PatchMinimumOpReplace PatchMinimumOp = "replace" +) + +// Defines values for RoutePatchOperationOp. +const ( + RoutePatchOperationOpAdd RoutePatchOperationOp = "add" + RoutePatchOperationOpRemove RoutePatchOperationOp = "remove" + RoutePatchOperationOpReplace RoutePatchOperationOp = "replace" +) + +// Defines values for RoutePatchOperationPath. +const ( + RoutePatchOperationPathDescription RoutePatchOperationPath = "description" + RoutePatchOperationPathEnabled RoutePatchOperationPath = "enabled" + RoutePatchOperationPathMasquerade RoutePatchOperationPath = "masquerade" + RoutePatchOperationPathMetric RoutePatchOperationPath = "metric" + RoutePatchOperationPathPeer RoutePatchOperationPath = "peer" + RoutePatchOperationPathPrefix RoutePatchOperationPath = "prefix" +) + // Defines values for RulePatchOperationOp. const ( RulePatchOperationOpAdd RulePatchOperationOp = "add" @@ -86,6 +110,18 @@ type GroupPatchOperationOp string // Group field to update in form / type GroupPatchOperationPath string +// PatchMinimum defines model for PatchMinimum. +type PatchMinimum struct { + // Patch operation type + Op PatchMinimumOp `json:"op"` + + // Values to be applied + Value []string `json:"value"` +} + +// Patch operation type +type PatchMinimumOp string + // Peer defines model for Peer. type Peer struct { // Provides information of who activated the Peer. User or Setup Key @@ -131,6 +167,72 @@ type PeerMinimum struct { Name string `json:"name"` } +// Route defines model for Route. +type Route struct { + // Route description + Description string `json:"description"` + + // Route status + Enabled bool `json:"enabled"` + + // Route Id + Id string `json:"id"` + + // Indicate if peer should masquerade traffic to this route's prefix + Masquerade bool `json:"masquerade"` + + // Route metric number. Lowest number has higher priority + Metric int `json:"metric"` + + // Peer Identifier associated with route + Peer string `json:"peer"` + + // Prefix or network range in CIDR format + Prefix string `json:"prefix"` + + // Prefix type indicating if it is IPv4 or IPv6 + PrefixType string `json:"prefix_type"` +} + +// RoutePatchOperation defines model for RoutePatchOperation. +type RoutePatchOperation struct { + // Patch operation type + Op RoutePatchOperationOp `json:"op"` + + // Route field to update in form / + Path RoutePatchOperationPath `json:"path"` + + // Values to be applied + Value []string `json:"value"` +} + +// Patch operation type +type RoutePatchOperationOp string + +// Route field to update in form / +type RoutePatchOperationPath string + +// RouteRequest defines model for RouteRequest. +type RouteRequest struct { + // Route description + Description string `json:"description"` + + // Route status + Enabled bool `json:"enabled"` + + // Indicate if peer should masquerade traffic to this route's prefix + Masquerade bool `json:"masquerade"` + + // Route metric number. Lowest number has higher priority + Metric int `json:"metric"` + + // Peer Identifier associated with route + Peer string `json:"peer"` + + // Prefix or network range in CIDR format + Prefix string `json:"prefix"` +} + // Rule defines model for Rule. type Rule struct { // Rule friendly description @@ -272,6 +374,15 @@ type PutApiPeersIdJSONBody struct { SshEnabled bool `json:"ssh_enabled"` } +// PostApiRoutesJSONBody defines parameters for PostApiRoutes. +type PostApiRoutesJSONBody = RouteRequest + +// PatchApiRoutesIdJSONBody defines parameters for PatchApiRoutesId. +type PatchApiRoutesIdJSONBody = []RoutePatchOperation + +// PutApiRoutesIdJSONBody defines parameters for PutApiRoutesId. +type PutApiRoutesIdJSONBody = RouteRequest + // PostApiRulesJSONBody defines parameters for PostApiRules. type PostApiRulesJSONBody struct { // Rule friendly description @@ -327,6 +438,15 @@ type PutApiGroupsIdJSONRequestBody PutApiGroupsIdJSONBody // PutApiPeersIdJSONRequestBody defines body for PutApiPeersId for application/json ContentType. type PutApiPeersIdJSONRequestBody PutApiPeersIdJSONBody +// PostApiRoutesJSONRequestBody defines body for PostApiRoutes for application/json ContentType. +type PostApiRoutesJSONRequestBody = PostApiRoutesJSONBody + +// PatchApiRoutesIdJSONRequestBody defines body for PatchApiRoutesId for application/json ContentType. +type PatchApiRoutesIdJSONRequestBody = PatchApiRoutesIdJSONBody + +// PutApiRoutesIdJSONRequestBody defines body for PutApiRoutesId for application/json ContentType. +type PutApiRoutesIdJSONRequestBody = PutApiRoutesIdJSONBody + // PostApiRulesJSONRequestBody defines body for PostApiRules for application/json ContentType. type PostApiRulesJSONRequestBody PostApiRulesJSONBody diff --git a/management/server/http/handler.go b/management/server/http/handler.go index db5123483..9ed3fbb19 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -33,6 +33,7 @@ func APIHandler(accountManager s.AccountManager, authIssuer string, authAudience peersHandler := NewPeers(accountManager, authAudience) keysHandler := NewSetupKeysHandler(accountManager, authAudience) userHandler := NewUserHandler(accountManager, authAudience) + routesHandler := NewRoutes(accountManager, authAudience) apiHandler.HandleFunc("/api/peers", peersHandler.GetPeers).Methods("GET", "OPTIONS") apiHandler.HandleFunc("/api/peers/{id}", peersHandler.HandlePeer). @@ -59,6 +60,13 @@ func APIHandler(accountManager s.AccountManager, authIssuer string, authAudience apiHandler.HandleFunc("/api/groups/{id}", groupsHandler.GetGroupHandler).Methods("GET", "OPTIONS") apiHandler.HandleFunc("/api/groups/{id}", groupsHandler.DeleteGroupHandler).Methods("DELETE", "OPTIONS") + apiHandler.HandleFunc("/api/routes", routesHandler.GetAllRoutesHandler).Methods("GET", "OPTIONS") + apiHandler.HandleFunc("/api/routes", routesHandler.CreateRouteHandler).Methods("POST", "OPTIONS") + apiHandler.HandleFunc("/api/routes/{id}", routesHandler.UpdateRouteHandler).Methods("PUT", "OPTIONS") + apiHandler.HandleFunc("/api/routes/{id}", routesHandler.PatchRouteHandler).Methods("PATCH", "OPTIONS") + apiHandler.HandleFunc("/api/routes/{id}", routesHandler.GetRouteHandler).Methods("GET", "OPTIONS") + apiHandler.HandleFunc("/api/routes/{id}", routesHandler.DeleteRouteHandler).Methods("DELETE", "OPTIONS") + return apiHandler, nil } diff --git a/management/server/http/routes.go b/management/server/http/routes.go new file mode 100644 index 000000000..b9a945bec --- /dev/null +++ b/management/server/http/routes.go @@ -0,0 +1,380 @@ +package http + +import ( + "encoding/json" + "fmt" + "github.com/gorilla/mux" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/jwtclaims" + "github.com/netbirdio/netbird/route" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "net/http" +) + +// Routes is the routes handler of the account +type Routes struct { + jwtExtractor jwtclaims.ClaimsExtractor + accountManager server.AccountManager + authAudience string +} + +// NewRoutes returns a new instance of Routes handler +func NewRoutes(accountManager server.AccountManager, authAudience string) *Routes { + return &Routes{ + accountManager: accountManager, + authAudience: authAudience, + jwtExtractor: *jwtclaims.NewClaimsExtractor(nil), + } +} + +// GetAllRoutesHandler returns the list of routes for the account +func (h *Routes) GetAllRoutesHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + log.Error(err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + routes, err := h.accountManager.ListRoutes(account.Id) + if err != nil { + log.Error(err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + apiRoutes := make([]*api.Route, 0) + for _, r := range routes { + apiRoutes = append(apiRoutes, toRouteResponse(account, r)) + } + + writeJSONObject(w, apiRoutes) +} + +// CreateRouteHandler handles route creation request +func (h *Routes) CreateRouteHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + var req api.PostApiRoutesJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + peerKey := req.Peer + if req.Peer != "" { + peer, err := h.accountManager.GetPeerByIP(account.Id, req.Peer) + if err != nil { + log.Error(err) + http.Redirect(w, r, "/", http.StatusUnprocessableEntity) + return + } + peerKey = peer.Key + } + + _, newPrefix, err := route.ParsePrefix(req.Prefix) + if err != nil { + http.Error(w, fmt.Sprintf("couldn't parse update prefix %s", req.Prefix), http.StatusBadRequest) + return + } + + newRoute, err := h.accountManager.CreateRoute(account.Id, newPrefix.String(), peerKey, req.Description, req.Masquerade, req.Metric, req.Enabled) + if err != nil { + log.Error(err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + resp := toRouteResponse(account, newRoute) + + writeJSONObject(w, &resp) +} + +// UpdateRouteHandler handles update to a route identified by a given ID +func (h *Routes) UpdateRouteHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + vars := mux.Vars(r) + routeID := vars["id"] + if len(routeID) == 0 { + http.Error(w, "invalid route Id", http.StatusBadRequest) + return + } + + _, err = h.accountManager.GetRoute(account.Id, routeID) + if err != nil { + http.Error(w, fmt.Sprintf("couldn't find route for ID %s", routeID), http.StatusNotFound) + return + } + + var req api.PutApiRoutesIdJSONBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + prefixType, newPrefix, err := route.ParsePrefix(req.Prefix) + if err != nil { + http.Error(w, fmt.Sprintf("couldn't parse update prefix %s for route ID %s", req.Prefix, routeID), http.StatusBadRequest) + return + } + + peerKey := req.Peer + if req.Peer != "" { + peer, err := h.accountManager.GetPeerByIP(account.Id, req.Peer) + if err != nil { + log.Error(err) + http.Redirect(w, r, "/", http.StatusUnprocessableEntity) + return + } + peerKey = peer.Key + } + + newRoute := &route.Route{ + ID: routeID, + Prefix: newPrefix, + PrefixType: prefixType, + Masquerade: req.Masquerade, + Peer: peerKey, + Metric: req.Metric, + Description: req.Description, + Enabled: req.Enabled, + } + + err = h.accountManager.SaveRoute(account.Id, newRoute) + if err != nil { + log.Errorf("failed updating route \"%s\" under account %s %v", routeID, account.Id, err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + resp := toRouteResponse(account, newRoute) + + writeJSONObject(w, &resp) +} + +// PatchRouteHandler handles patch updates to a route identified by a given ID +func (h *Routes) PatchRouteHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + vars := mux.Vars(r) + routeID := vars["id"] + if len(routeID) == 0 { + http.Error(w, "invalid route ID", http.StatusBadRequest) + return + } + + _, err = h.accountManager.GetRoute(account.Id, routeID) + if err != nil { + log.Error(err) + http.Error(w, fmt.Sprintf("couldn't find route ID %s", routeID), http.StatusNotFound) + return + } + + var req api.PatchApiRoutesIdJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if len(req) == 0 { + http.Error(w, "no patch instruction received", http.StatusBadRequest) + return + } + + var operations []server.RouteUpdateOperation + + for _, patch := range req { + switch patch.Path { + case api.RoutePatchOperationPathPrefix: + if patch.Op != api.RoutePatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Prefix field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + operations = append(operations, server.RouteUpdateOperation{ + Type: server.UpdateRoutePrefix, + Values: patch.Value, + }) + case api.RoutePatchOperationPathDescription: + if patch.Op != api.RoutePatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Description field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + operations = append(operations, server.RouteUpdateOperation{ + Type: server.UpdateRouteDescription, + Values: patch.Value, + }) + case api.RoutePatchOperationPathPeer: + if patch.Op != api.RoutePatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Peer field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + if len(patch.Value) > 1 { + http.Error(w, fmt.Sprintf("Value field only accepts 1 value, got %d", len(patch.Value)), + http.StatusBadRequest) + return + } + peerValue := patch.Value + if patch.Value[0] != "" { + peer, err := h.accountManager.GetPeerByIP(account.Id, patch.Value[0]) + if err != nil { + log.Error(err) + http.Redirect(w, r, "/", http.StatusUnprocessableEntity) + return + } + peerValue = []string{peer.Key} + } + operations = append(operations, server.RouteUpdateOperation{ + Type: server.UpdateRoutePeer, + Values: peerValue, + }) + case api.RoutePatchOperationPathMetric: + if patch.Op != api.RoutePatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Metric field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + operations = append(operations, server.RouteUpdateOperation{ + Type: server.UpdateRouteMetric, + Values: patch.Value, + }) + case api.RoutePatchOperationPathMasquerade: + if patch.Op != api.RoutePatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Masquerade field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + operations = append(operations, server.RouteUpdateOperation{ + Type: server.UpdateRouteMasquerade, + Values: patch.Value, + }) + case api.RoutePatchOperationPathEnabled: + if patch.Op != api.RoutePatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Enabled field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + operations = append(operations, server.RouteUpdateOperation{ + Type: server.UpdateRouteEnabled, + Values: patch.Value, + }) + default: + http.Error(w, "invalid patch path", http.StatusBadRequest) + return + } + } + + route, err := h.accountManager.UpdateRoute(account.Id, routeID, operations) + + if err != nil { + errStatus, ok := status.FromError(err) + if ok && errStatus.Code() == codes.Internal { + http.Error(w, errStatus.String(), http.StatusInternalServerError) + return + } + + if ok && errStatus.Code() == codes.NotFound { + http.Error(w, errStatus.String(), http.StatusNotFound) + return + } + + if ok && errStatus.Code() == codes.InvalidArgument { + http.Error(w, errStatus.String(), http.StatusBadRequest) + return + } + + log.Errorf("failed updating route %s under account %s %v", routeID, account.Id, err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + resp := toRouteResponse(account, route) + + writeJSONObject(w, &resp) +} + +// DeleteRouteHandler handles route deletion request +func (h *Routes) DeleteRouteHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + routeID := mux.Vars(r)["id"] + if len(routeID) == 0 { + http.Error(w, "invalid route ID", http.StatusBadRequest) + return + } + + err = h.accountManager.DeleteRoute(account.Id, routeID) + if err != nil { + log.Errorf("failed delete route %s under account %s %v", routeID, account.Id, err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + writeJSONObject(w, "") +} + +// GetRouteHandler handles a route Get request identified by ID +func (h *Routes) GetRouteHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + routeID := mux.Vars(r)["id"] + if len(routeID) == 0 { + http.Error(w, "invalid route ID", http.StatusBadRequest) + return + } + + foundRoute, err := h.accountManager.GetRoute(account.Id, routeID) + if err != nil { + http.Error(w, "route not found", http.StatusNotFound) + return + } + + writeJSONObject(w, toRouteResponse(account, foundRoute)) +} + +func toRouteResponse(account *server.Account, serverRoute *route.Route) *api.Route { + var peerIP string + if serverRoute.Peer != "" { + peer, found := account.Peers[serverRoute.Peer] + if !found { + panic("peer ID not found") + } + peerIP = peer.IP.String() + } + + return &api.Route{ + Id: serverRoute.ID, + Description: serverRoute.Description, + Enabled: serverRoute.Enabled, + Peer: peerIP, + Prefix: serverRoute.Prefix.String(), + PrefixType: serverRoute.PrefixType.String(), + Masquerade: serverRoute.Masquerade, + Metric: serverRoute.Metric, + } +} diff --git a/management/server/http/routes_test.go b/management/server/http/routes_test.go new file mode 100644 index 000000000..f52b7a100 --- /dev/null +++ b/management/server/http/routes_test.go @@ -0,0 +1,338 @@ +package http + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/route" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "io" + "net/http" + "net/http/httptest" + "net/netip" + "strconv" + "testing" + + "github.com/gorilla/mux" + "github.com/magiconair/properties/assert" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/jwtclaims" + "github.com/netbirdio/netbird/management/server/mock_server" +) + +const ( + existingRouteID = "existingRouteID" + notFoundRouteID = "notFoundRouteID" + existingPeerID = "100.64.0.100" + notFoundPeerID = "100.64.0.200" + existingPeerKey = "existingPeerKey" + testAccountID = "test_id" +) + +var baseExistingRoute = &route.Route{ + ID: existingRouteID, + Description: "base route", + Prefix: netip.MustParsePrefix("192.168.0.0/24"), + PrefixType: route.IPv4Prefix, + Metric: 9999, + Masquerade: false, + Enabled: true, +} + +var testingAccount = &server.Account{ + Id: testAccountID, + Domain: "hotmail.com", + Peers: map[string]*server.Peer{ + existingPeerKey: { + Key: existingPeerID, + IP: netip.MustParseAddr(existingPeerID).AsSlice(), + }, + }, +} + +func initRoutesTestData() *Routes { + return &Routes{ + accountManager: &mock_server.MockAccountManager{ + GetRouteFunc: func(_, routeID string) (*route.Route, error) { + if routeID == existingRouteID { + return baseExistingRoute, nil + } + return nil, status.Errorf(codes.NotFound, "route with ID %s not found", routeID) + }, + CreateRouteFunc: func(accountID string, prefix, peer, description string, masquerade bool, metric int, enabled bool) (*route.Route, error) { + prefixType, p, _ := route.ParsePrefix(prefix) + return &route.Route{ + ID: existingRouteID, + Peer: peer, + Prefix: p, + PrefixType: prefixType, + Description: description, + Masquerade: masquerade, + Enabled: enabled, + }, nil + }, + SaveRouteFunc: func(_ string, _ *route.Route) error { + return nil + }, + DeleteRouteFunc: func(_ string, _ string) error { + return nil + }, + GetPeerByIPFunc: func(_ string, peerIP string) (*server.Peer, error) { + if peerIP != existingPeerID { + return nil, status.Errorf(codes.NotFound, "Peer with ID %s not found", peerIP) + } + return &server.Peer{ + Key: existingPeerKey, + IP: netip.MustParseAddr(existingPeerID).AsSlice(), + }, nil + }, + UpdateRouteFunc: func(_ string, routeID string, operations []server.RouteUpdateOperation) (*route.Route, error) { + routeToUpdate := baseExistingRoute + if routeID != routeToUpdate.ID { + return nil, status.Errorf(codes.NotFound, "route %s no longer exists", routeID) + } + for _, operation := range operations { + switch operation.Type { + case server.UpdateRoutePrefix: + routeToUpdate.PrefixType, routeToUpdate.Prefix, _ = route.ParsePrefix(operation.Values[0]) + case server.UpdateRouteDescription: + routeToUpdate.Description = operation.Values[0] + case server.UpdateRoutePeer: + routeToUpdate.Peer = operation.Values[0] + case server.UpdateRouteMetric: + routeToUpdate.Metric, _ = strconv.Atoi(operation.Values[0]) + case server.UpdateRouteMasquerade: + routeToUpdate.Masquerade, _ = strconv.ParseBool(operation.Values[0]) + case server.UpdateRouteEnabled: + routeToUpdate.Enabled, _ = strconv.ParseBool(operation.Values[0]) + default: + return nil, fmt.Errorf("no operation") + } + } + return routeToUpdate, nil + }, + GetAccountWithAuthorizationClaimsFunc: func(_ jwtclaims.AuthorizationClaims) (*server.Account, error) { + return testingAccount, nil + }, + }, + authAudience: "", + jwtExtractor: jwtclaims.ClaimsExtractor{ + ExtractClaimsFromRequestContext: func(r *http.Request, authAudiance string) jwtclaims.AuthorizationClaims { + return jwtclaims.AuthorizationClaims{ + UserId: "test_user", + Domain: "hotmail.com", + AccountId: testAccountID, + } + }, + }, + } +} + +func TestRoutesHandlers(t *testing.T) { + tt := []struct { + name string + expectedStatus int + expectedBody bool + expectedRoute *api.Route + requestType string + requestPath string + requestBody io.Reader + }{ + { + name: "Get Existing Route", + requestType: http.MethodGet, + requestPath: "/api/routes/" + existingRouteID, + expectedStatus: http.StatusOK, + expectedBody: true, + expectedRoute: toRouteResponse(testingAccount, baseExistingRoute), + }, + { + name: "Get Not Existing Route", + requestType: http.MethodGet, + requestPath: "/api/rules/" + notFoundRouteID, + expectedStatus: http.StatusNotFound, + }, + { + name: "Delete Existing Route", + requestType: http.MethodDelete, + requestPath: "/api/routes/" + existingRouteID, + expectedStatus: http.StatusOK, + expectedBody: false, + }, + { + name: "Delete Not Existing Route", + requestType: http.MethodDelete, + requestPath: "/api/rules/" + notFoundRouteID, + expectedStatus: http.StatusNotFound, + }, + { + name: "POST OK", + requestType: http.MethodPost, + requestPath: "/api/routes", + requestBody: bytes.NewBuffer( + []byte(fmt.Sprintf("{\"Description\":\"Post\",\"Prefix\":\"192.168.0.0/16\",\"Peer\":\"%s\"}", existingPeerID))), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedRoute: &api.Route{ + Id: existingRouteID, + Description: "Post", + Prefix: "192.168.0.0/16", + Peer: existingPeerID, + PrefixType: route.IPv4PrefixString, + Masquerade: false, + Enabled: false, + }, + }, + { + name: "POST Not Found Peer", + requestType: http.MethodPost, + requestPath: "/api/routes", + requestBody: bytes.NewBufferString(fmt.Sprintf("{\"Description\":\"Post\",\"Prefix\":\"192.168.0.0/16\",\"Peer\":\"%s\"}", notFoundPeerID)), + expectedStatus: http.StatusUnprocessableEntity, + expectedBody: false, + }, + { + name: "POST Invalid Prefix", + requestType: http.MethodPost, + requestPath: "/api/routes", + requestBody: bytes.NewBufferString(fmt.Sprintf("{\"Description\":\"Post\",\"Prefix\":\"192.168.0.0/34\",\"Peer\":\"%s\"}", existingPeerID)), + expectedStatus: http.StatusBadRequest, + expectedBody: false, + }, + { + name: "PUT OK", + requestType: http.MethodPut, + requestPath: "/api/routes/" + existingRouteID, + requestBody: bytes.NewBufferString(fmt.Sprintf("{\"Description\":\"Post\",\"Prefix\":\"192.168.0.0/16\",\"Peer\":\"%s\"}", existingPeerID)), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedRoute: &api.Route{ + Id: existingRouteID, + Description: "Post", + Prefix: "192.168.0.0/16", + Peer: existingPeerID, + PrefixType: route.IPv4PrefixString, + Masquerade: false, + Enabled: false, + }, + }, + { + name: "PUT Not Found Route", + requestType: http.MethodPut, + requestPath: "/api/routes/" + notFoundRouteID, + requestBody: bytes.NewBufferString(fmt.Sprintf("{\"Description\":\"Post\",\"Prefix\":\"192.168.0.0/16\",\"Peer\":\"%s\"}", existingPeerID)), + expectedStatus: http.StatusNotFound, + expectedBody: false, + }, + { + name: "PUT Not Found Peer", + requestType: http.MethodPut, + requestPath: "/api/routes/" + existingRouteID, + requestBody: bytes.NewBufferString(fmt.Sprintf("{\"Description\":\"Post\",\"Prefix\":\"192.168.0.0/16\",\"Peer\":\"%s\"}", notFoundPeerID)), + expectedStatus: http.StatusUnprocessableEntity, + expectedBody: false, + }, + { + name: "PUT Invalid Prefix", + requestType: http.MethodPut, + requestPath: "/api/routes/" + existingRouteID, + requestBody: bytes.NewBufferString(fmt.Sprintf("{\"Description\":\"Post\",\"Prefix\":\"192.168.0.0/34\",\"Peer\":\"%s\"}", existingPeerID)), + expectedStatus: http.StatusBadRequest, + expectedBody: false, + }, + { + name: "PATCH Description OK", + requestType: http.MethodPatch, + requestPath: "/api/routes/" + existingRouteID, + requestBody: bytes.NewBufferString("[{\"op\":\"replace\",\"path\":\"description\",\"value\":[\"NewDesc\"]}]"), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedRoute: &api.Route{ + Id: existingRouteID, + Description: "NewDesc", + Prefix: baseExistingRoute.Prefix.String(), + PrefixType: route.IPv4PrefixString, + Masquerade: baseExistingRoute.Masquerade, + Enabled: baseExistingRoute.Enabled, + Metric: baseExistingRoute.Metric, + }, + }, + { + name: "PATCH Peer OK", + requestType: http.MethodPatch, + requestPath: "/api/routes/" + existingRouteID, + requestBody: bytes.NewBufferString(fmt.Sprintf("[{\"op\":\"replace\",\"path\":\"peer\",\"value\":[\"%s\"]}]", existingPeerID)), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedRoute: &api.Route{ + Id: existingRouteID, + Description: "NewDesc", + Prefix: baseExistingRoute.Prefix.String(), + PrefixType: route.IPv4PrefixString, + Peer: existingPeerID, + Masquerade: baseExistingRoute.Masquerade, + Enabled: baseExistingRoute.Enabled, + Metric: baseExistingRoute.Metric, + }, + }, + { + name: "PATCH Not Found Peer", + requestType: http.MethodPatch, + requestPath: "/api/routes/" + existingRouteID, + requestBody: bytes.NewBufferString(fmt.Sprintf("[{\"op\":\"replace\",\"path\":\"peer\",\"value\":[\"%s\"]}]", notFoundPeerID)), + expectedStatus: http.StatusUnprocessableEntity, + expectedBody: false, + }, + { + name: "PATCH Not Found Route", + requestType: http.MethodPatch, + requestPath: "/api/routes/" + notFoundRouteID, + requestBody: bytes.NewBufferString("[{\"op\":\"replace\",\"path\":\"prefix\",\"value\":[\"192.168.0.0/34\"]}]"), + expectedStatus: http.StatusNotFound, + expectedBody: false, + }, + } + + p := initRoutesTestData() + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) + + router := mux.NewRouter() + router.HandleFunc("/api/routes/{id}", p.GetRouteHandler).Methods("GET") + router.HandleFunc("/api/routes/{id}", p.DeleteRouteHandler).Methods("DELETE") + router.HandleFunc("/api/routes", p.CreateRouteHandler).Methods("POST") + router.HandleFunc("/api/routes/{id}", p.UpdateRouteHandler).Methods("PUT") + router.HandleFunc("/api/routes/{id}", p.PatchRouteHandler).Methods("PATCH") + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + content, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("I don't know what I expected; %v", err) + } + + if status := recorder.Code; status != tc.expectedStatus { + t.Errorf("handler returned wrong status code: got %v want %v, content: %s", + status, tc.expectedStatus, string(content)) + return + } + + if !tc.expectedBody { + return + } + + got := &api.Route{} + if err = json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, got, tc.expectedRoute) + }) + } +}