Add routing Rest API support (#428)

Routing API will allow us to list, create, update, and delete routes.
This commit is contained in:
Maycon Santos 2022-08-20 19:11:54 +02:00 committed by GitHub
parent 4b34a6d6df
commit 000ea72aec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 1094 additions and 20 deletions

View File

@ -14,6 +14,8 @@ tags:
description: Interact with and view information about groups. description: Interact with and view information about groups.
- name: Rules - name: Rules
description: Interact with and view information about rules. description: Interact with and view information about rules.
- name: Routes
description: Interact with and view information about routes.
components: components:
schemas: schemas:
User: User:
@ -191,17 +193,13 @@ components:
$ref: '#/components/schemas/PeerMinimum' $ref: '#/components/schemas/PeerMinimum'
required: required:
- peers - peers
GroupPatchOperation: PatchMinimum:
type: object type: object
properties: properties:
op: op:
description: Patch operation type description: Patch operation type
type: string type: string
enum: [ "replace","add","remove" ] enum: [ "replace","add","remove" ]
path:
description: Group field to update in form /<field>
type: string
enum: [ "name","peers" ]
value: value:
description: Values to be applied description: Values to be applied
type: array type: array
@ -209,8 +207,19 @@ components:
type: string type: string
required: required:
- op - op
- path
- value - value
GroupPatchOperation:
allOf:
- $ref: '#/components/schemas/PatchMinimum'
- type: object
properties:
path:
description: Group field to update in form /<field>
type: string
enum: [ "name","peers" ]
required:
- path
RuleMinimum: RuleMinimum:
type: object type: object
properties: properties:
@ -257,25 +266,73 @@ components:
- sources - sources
- destinations - destinations
RulePatchOperation: RulePatchOperation:
allOf:
- $ref: '#/components/schemas/PatchMinimum'
- type: object
properties:
path:
description: Rule field to update in form /<field>
type: string
enum: [ "name","description","disabled","flow","sources","destinations" ]
required:
- path
RouteRequest:
type: object type: object
properties: properties:
op: description:
description: Patch operation type description: Route description
type: string type: string
enum: [ "replace","add","remove" ] enabled:
path: description: Route status
description: Rule field to update in form /<field> type: boolean
peer:
description: Peer Identifier associated with route
type: string type: string
enum: [ "name","description","disabled","flow","sources","destinations" ] prefix:
value: description: Prefix or network range in CIDR format
description: Values to be applied type: string
type: array metric:
items: description: Route metric number. Lowest number has higher priority
type: string type: integer
maximum: 9999
minimum: 1
masquerade:
description: Indicate if peer should masquerade traffic to this route's prefix
type: boolean
required: required:
- op - id
- path - description
- value - 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 /<field>
type: string
enum: [ "prefix","description","enabled","peer","metric","masquerade" ]
required:
- path
responses: responses:
not_found: not_found:
description: Resource not found description: Resource not found
@ -947,3 +1004,174 @@ paths:
"$ref": "#/components/responses/forbidden" "$ref": "#/components/responses/forbidden"
'500': '500':
"$ref": "#/components/responses/internal_error" "$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"

View File

@ -24,6 +24,30 @@ const (
GroupPatchOperationPathPeers GroupPatchOperationPath = "peers" 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. // Defines values for RulePatchOperationOp.
const ( const (
RulePatchOperationOpAdd RulePatchOperationOp = "add" RulePatchOperationOpAdd RulePatchOperationOp = "add"
@ -86,6 +110,18 @@ type GroupPatchOperationOp string
// Group field to update in form /<field> // Group field to update in form /<field>
type GroupPatchOperationPath string 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. // Peer defines model for Peer.
type Peer struct { type Peer struct {
// Provides information of who activated the Peer. User or Setup Key // Provides information of who activated the Peer. User or Setup Key
@ -131,6 +167,72 @@ type PeerMinimum struct {
Name string `json:"name"` 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 /<field>
Path RoutePatchOperationPath `json:"path"`
// Values to be applied
Value []string `json:"value"`
}
// Patch operation type
type RoutePatchOperationOp string
// Route field to update in form /<field>
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. // Rule defines model for Rule.
type Rule struct { type Rule struct {
// Rule friendly description // Rule friendly description
@ -272,6 +374,15 @@ type PutApiPeersIdJSONBody struct {
SshEnabled bool `json:"ssh_enabled"` 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. // PostApiRulesJSONBody defines parameters for PostApiRules.
type PostApiRulesJSONBody struct { type PostApiRulesJSONBody struct {
// Rule friendly description // Rule friendly description
@ -327,6 +438,15 @@ type PutApiGroupsIdJSONRequestBody PutApiGroupsIdJSONBody
// PutApiPeersIdJSONRequestBody defines body for PutApiPeersId for application/json ContentType. // PutApiPeersIdJSONRequestBody defines body for PutApiPeersId for application/json ContentType.
type PutApiPeersIdJSONRequestBody PutApiPeersIdJSONBody 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. // PostApiRulesJSONRequestBody defines body for PostApiRules for application/json ContentType.
type PostApiRulesJSONRequestBody PostApiRulesJSONBody type PostApiRulesJSONRequestBody PostApiRulesJSONBody

View File

@ -33,6 +33,7 @@ func APIHandler(accountManager s.AccountManager, authIssuer string, authAudience
peersHandler := NewPeers(accountManager, authAudience) peersHandler := NewPeers(accountManager, authAudience)
keysHandler := NewSetupKeysHandler(accountManager, authAudience) keysHandler := NewSetupKeysHandler(accountManager, authAudience)
userHandler := NewUserHandler(accountManager, authAudience) userHandler := NewUserHandler(accountManager, authAudience)
routesHandler := NewRoutes(accountManager, authAudience)
apiHandler.HandleFunc("/api/peers", peersHandler.GetPeers).Methods("GET", "OPTIONS") apiHandler.HandleFunc("/api/peers", peersHandler.GetPeers).Methods("GET", "OPTIONS")
apiHandler.HandleFunc("/api/peers/{id}", peersHandler.HandlePeer). 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.GetGroupHandler).Methods("GET", "OPTIONS")
apiHandler.HandleFunc("/api/groups/{id}", groupsHandler.DeleteGroupHandler).Methods("DELETE", "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 return apiHandler, nil
} }

View File

@ -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,
}
}

View File

@ -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)
})
}
}