diff --git a/docs/docs.go b/docs/docs.go index b2309b5..11e04dd 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1378,6 +1378,209 @@ const docTemplate = `{ } } }, + "/api/v1/tools/netem/reset": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Resets (removes) netem impairments from a specific interface of a containerlab node. Requires superuser privileges.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tools - Netem" + ], + "summary": "Reset link impairments (netem)", + "parameters": [ + { + "description": "Netem Reset Parameters", + "name": "netem_reset_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.NetemResetRequest" + } + } + ], + "responses": { + "200": { + "description": "Impairments reset successfully", + "schema": { + "$ref": "#/definitions/models.GenericSuccessResponse" + } + }, + "400": { + "description": "Invalid input parameters", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized (JWT)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (User is not a superuser)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Container or interface not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/v1/tools/netem/set": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Sets netem impairments (delay, jitter, loss, rate limiting, corruption) on a specific interface of a containerlab node. Requires superuser privileges.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tools - Netem" + ], + "summary": "Set link impairments (netem)", + "parameters": [ + { + "description": "Netem Set Parameters", + "name": "netem_set_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.NetemSetRequest" + } + } + ], + "responses": { + "200": { + "description": "Impairments set successfully", + "schema": { + "$ref": "#/definitions/models.GenericSuccessResponse" + } + }, + "400": { + "description": "Invalid input parameters", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized (JWT)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (User is not a superuser)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Container or interface not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/v1/tools/netem/show": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Lists netem impairments for a given containerlab node. Requires superuser privileges.", + "produces": [ + "application/json" + ], + "tags": [ + "Tools - Netem" + ], + "summary": "Show link impairments (netem)", + "parameters": [ + { + "type": "string", + "example": "clab-my-lab-srl1", + "description": "Container/node name", + "name": "containerName", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Netem impairments", + "schema": { + "$ref": "#/definitions/models.NetemShowResponse" + } + }, + "400": { + "description": "Invalid input parameters", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized (JWT)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (User is not a superuser)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Container not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/api/v1/tools/veth": { "post": { "security": [ @@ -2763,6 +2966,107 @@ const docTemplate = `{ } } }, + "models.NetemInterfaceInfo": { + "type": "object", + "properties": { + "corruption": { + "description": "Percentage (might be missing in older clab versions)", + "type": "number" + }, + "delay": { + "description": "Duration string or empty", + "type": "string" + }, + "interface": { + "description": "Interface name", + "type": "string" + }, + "jitter": { + "description": "Duration string or empty", + "type": "string" + }, + "packet_loss": { + "description": "Percentage", + "type": "number" + }, + "rate": { + "description": "Kbit/s", + "type": "integer" + } + } + }, + "models.NetemResetRequest": { + "type": "object", + "required": [ + "containerName", + "interface" + ], + "properties": { + "containerName": { + "description": "Container/node name to reset impairments on (e.g., \"clab-my-lab-srl1\").", + "type": "string", + "example": "clab-my-lab-srl1" + }, + "interface": { + "description": "Interface name or interface alias to reset impairments on (e.g., \"eth1\" or \"mgmt0\").", + "type": "string", + "example": "eth1" + } + } + }, + "models.NetemSetRequest": { + "type": "object", + "required": [ + "containerName", + "interface" + ], + "properties": { + "containerName": { + "description": "Container/node name to apply impairments on (e.g., \"clab-my-lab-srl1\").", + "type": "string", + "example": "clab-my-lab-srl1" + }, + "corruption": { + "description": "Percentage (0.0 to 100.0)", + "type": "number", + "example": 0.1 + }, + "delay": { + "description": "Duration string (e.g., \"100ms\", \"1s\")", + "type": "string", + "example": "50ms" + }, + "interface": { + "description": "Interface name or interface alias to apply impairments on (e.g., \"eth1\" or \"mgmt0\").", + "type": "string", + "example": "eth1" + }, + "jitter": { + "description": "Duration string, requires Delay", + "type": "string", + "example": "5ms" + }, + "loss": { + "description": "Percentage (0.0 to 100.0)", + "type": "number", + "example": 10.5 + }, + "rate": { + "description": "Kbit/s (non-negative integer)", + "type": "integer", + "example": 1000 + } + } + }, + "models.NetemShowResponse": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/models.NetemInterfaceInfo" + } + } + }, "models.NodeInterfaceInfo": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index c0ed49e..cd20625 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1374,6 +1374,209 @@ } } }, + "/api/v1/tools/netem/reset": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Resets (removes) netem impairments from a specific interface of a containerlab node. Requires superuser privileges.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tools - Netem" + ], + "summary": "Reset link impairments (netem)", + "parameters": [ + { + "description": "Netem Reset Parameters", + "name": "netem_reset_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.NetemResetRequest" + } + } + ], + "responses": { + "200": { + "description": "Impairments reset successfully", + "schema": { + "$ref": "#/definitions/models.GenericSuccessResponse" + } + }, + "400": { + "description": "Invalid input parameters", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized (JWT)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (User is not a superuser)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Container or interface not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/v1/tools/netem/set": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Sets netem impairments (delay, jitter, loss, rate limiting, corruption) on a specific interface of a containerlab node. Requires superuser privileges.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tools - Netem" + ], + "summary": "Set link impairments (netem)", + "parameters": [ + { + "description": "Netem Set Parameters", + "name": "netem_set_request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.NetemSetRequest" + } + } + ], + "responses": { + "200": { + "description": "Impairments set successfully", + "schema": { + "$ref": "#/definitions/models.GenericSuccessResponse" + } + }, + "400": { + "description": "Invalid input parameters", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized (JWT)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (User is not a superuser)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Container or interface not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/v1/tools/netem/show": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Lists netem impairments for a given containerlab node. Requires superuser privileges.", + "produces": [ + "application/json" + ], + "tags": [ + "Tools - Netem" + ], + "summary": "Show link impairments (netem)", + "parameters": [ + { + "type": "string", + "example": "clab-my-lab-srl1", + "description": "Container/node name", + "name": "containerName", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Netem impairments", + "schema": { + "$ref": "#/definitions/models.NetemShowResponse" + } + }, + "400": { + "description": "Invalid input parameters", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized (JWT)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (User is not a superuser)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Container not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/api/v1/tools/veth": { "post": { "security": [ @@ -2652,10 +2855,10 @@ "username" ], "properties": { - "username": { + "password": { "type": "string" }, - "password": { + "username": { "type": "string" } } @@ -2759,6 +2962,107 @@ } } }, + "models.NetemInterfaceInfo": { + "type": "object", + "properties": { + "corruption": { + "description": "Percentage (might be missing in older clab versions)", + "type": "number" + }, + "delay": { + "description": "Duration string or empty", + "type": "string" + }, + "interface": { + "description": "Interface name", + "type": "string" + }, + "jitter": { + "description": "Duration string or empty", + "type": "string" + }, + "packet_loss": { + "description": "Percentage", + "type": "number" + }, + "rate": { + "description": "Kbit/s", + "type": "integer" + } + } + }, + "models.NetemResetRequest": { + "type": "object", + "required": [ + "containerName", + "interface" + ], + "properties": { + "containerName": { + "description": "Container/node name to reset impairments on (e.g., \"clab-my-lab-srl1\").", + "type": "string", + "example": "clab-my-lab-srl1" + }, + "interface": { + "description": "Interface name or interface alias to reset impairments on (e.g., \"eth1\" or \"mgmt0\").", + "type": "string", + "example": "eth1" + } + } + }, + "models.NetemSetRequest": { + "type": "object", + "required": [ + "containerName", + "interface" + ], + "properties": { + "containerName": { + "description": "Container/node name to apply impairments on (e.g., \"clab-my-lab-srl1\").", + "type": "string", + "example": "clab-my-lab-srl1" + }, + "corruption": { + "description": "Percentage (0.0 to 100.0)", + "type": "number", + "example": 0.1 + }, + "delay": { + "description": "Duration string (e.g., \"100ms\", \"1s\")", + "type": "string", + "example": "50ms" + }, + "interface": { + "description": "Interface name or interface alias to apply impairments on (e.g., \"eth1\" or \"mgmt0\").", + "type": "string", + "example": "eth1" + }, + "jitter": { + "description": "Duration string, requires Delay", + "type": "string", + "example": "5ms" + }, + "loss": { + "description": "Percentage (0.0 to 100.0)", + "type": "number", + "example": 10.5 + }, + "rate": { + "description": "Kbit/s (non-negative integer)", + "type": "integer", + "example": 1000 + } + } + }, + "models.NetemShowResponse": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/models.NetemInterfaceInfo" + } + } + }, "models.NodeInterfaceInfo": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 06aa5ba..8ccbe32 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -433,10 +433,10 @@ definitions: type: object models.LoginRequest: properties: - username: - type: string password: type: string + username: + type: string required: - password - username @@ -501,6 +501,83 @@ definitions: - $ref: '#/definitions/models.ServerInfo' description: Basic server information type: object + models.NetemInterfaceInfo: + properties: + corruption: + description: Percentage (might be missing in older clab versions) + type: number + delay: + description: Duration string or empty + type: string + interface: + description: Interface name + type: string + jitter: + description: Duration string or empty + type: string + packet_loss: + description: Percentage + type: number + rate: + description: Kbit/s + type: integer + type: object + models.NetemResetRequest: + properties: + containerName: + description: Container/node name to reset impairments on (e.g., "clab-my-lab-srl1"). + example: clab-my-lab-srl1 + type: string + interface: + description: Interface name or interface alias to reset impairments on (e.g., + "eth1" or "mgmt0"). + example: eth1 + type: string + required: + - containerName + - interface + type: object + models.NetemSetRequest: + properties: + containerName: + description: Container/node name to apply impairments on (e.g., "clab-my-lab-srl1"). + example: clab-my-lab-srl1 + type: string + corruption: + description: Percentage (0.0 to 100.0) + example: 0.1 + type: number + delay: + description: Duration string (e.g., "100ms", "1s") + example: 50ms + type: string + interface: + description: Interface name or interface alias to apply impairments on (e.g., + "eth1" or "mgmt0"). + example: eth1 + type: string + jitter: + description: Duration string, requires Delay + example: 5ms + type: string + loss: + description: Percentage (0.0 to 100.0) + example: 10.5 + type: number + rate: + description: Kbit/s (non-negative integer) + example: 1000 + type: integer + required: + - containerName + - interface + type: object + models.NetemShowResponse: + additionalProperties: + items: + $ref: '#/definitions/models.NetemInterfaceInfo' + type: array + type: object models.NodeInterfaceInfo: properties: interfaces: @@ -1672,6 +1749,139 @@ paths: summary: Disable TX checksum offload tags: - Tools + /api/v1/tools/netem/reset: + post: + consumes: + - application/json + description: Resets (removes) netem impairments from a specific interface of + a containerlab node. Requires superuser privileges. + parameters: + - description: Netem Reset Parameters + in: body + name: netem_reset_request + required: true + schema: + $ref: '#/definitions/models.NetemResetRequest' + produces: + - application/json + responses: + "200": + description: Impairments reset successfully + schema: + $ref: '#/definitions/models.GenericSuccessResponse' + "400": + description: Invalid input parameters + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized (JWT) + schema: + $ref: '#/definitions/models.ErrorResponse' + "403": + description: Forbidden (User is not a superuser) + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: Container or interface not found + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Reset link impairments (netem) + tags: + - Tools - Netem + /api/v1/tools/netem/set: + post: + consumes: + - application/json + description: Sets netem impairments (delay, jitter, loss, rate limiting, corruption) + on a specific interface of a containerlab node. Requires superuser privileges. + parameters: + - description: Netem Set Parameters + in: body + name: netem_set_request + required: true + schema: + $ref: '#/definitions/models.NetemSetRequest' + produces: + - application/json + responses: + "200": + description: Impairments set successfully + schema: + $ref: '#/definitions/models.GenericSuccessResponse' + "400": + description: Invalid input parameters + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized (JWT) + schema: + $ref: '#/definitions/models.ErrorResponse' + "403": + description: Forbidden (User is not a superuser) + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: Container or interface not found + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Set link impairments (netem) + tags: + - Tools - Netem + /api/v1/tools/netem/show: + get: + description: Lists netem impairments for a given containerlab node. Requires + superuser privileges. + parameters: + - description: Container/node name + example: clab-my-lab-srl1 + in: query + name: containerName + required: true + type: string + produces: + - application/json + responses: + "200": + description: Netem impairments + schema: + $ref: '#/definitions/models.NetemShowResponse' + "400": + description: Invalid input parameters + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized (JWT) + schema: + $ref: '#/definitions/models.ErrorResponse' + "403": + description: Forbidden (User is not a superuser) + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: Container not found + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Show link impairments (netem) + tags: + - Tools - Netem /api/v1/tools/veth: post: consumes: diff --git a/internal/api/routes.go b/internal/api/routes.go index 19b8d36..5d1838d 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -121,6 +121,14 @@ func SetupRoutes(router *gin.Engine) { vxlan.DELETE("", DeleteVxlanHandler) // DELETE /api/v1/tools/vxlan } + // Netem Tools (Superuser Only) + netem := tools.Group("/netem") + { + netem.POST("/set", SetNetemHandler) // POST /api/v1/tools/netem/set + netem.GET("/show", ShowNetemHandler) // GET /api/v1/tools/netem/show + netem.POST("/reset", ResetNetemHandler) // POST /api/v1/tools/netem/reset + } + } // Version Info Routes diff --git a/internal/api/tools_handlers.go b/internal/api/tools_handlers.go index 2199896..3f13b11 100644 --- a/internal/api/tools_handlers.go +++ b/internal/api/tools_handlers.go @@ -3,6 +3,7 @@ package api import ( "context" + "errors" "fmt" "net/http" "os" @@ -624,3 +625,263 @@ func DeleteVxlanHandler(c *gin.Context) { Message: fmt.Sprintf("Successfully deleted VxLAN interface(s): %s", strings.Join(deleted, ", ")), }) } + +// --- Netem Handlers --- + +// @Summary Set link impairments (netem) +// @Description Sets netem impairments (delay, jitter, loss, rate limiting, corruption) on a specific interface of a containerlab node. Requires superuser privileges. +// @Tags Tools - Netem +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param netem_set_request body models.NetemSetRequest true "Netem Set Parameters" +// @Success 200 {object} models.GenericSuccessResponse "Impairments set successfully" +// @Failure 400 {object} models.ErrorResponse "Invalid input parameters" +// @Failure 401 {object} models.ErrorResponse "Unauthorized (JWT)" +// @Failure 403 {object} models.ErrorResponse "Forbidden (User is not a superuser)" +// @Failure 404 {object} models.ErrorResponse "Container or interface not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/tools/netem/set [post] +func SetNetemHandler(c *gin.Context) { + username := c.GetString("username") + + // --- Authorization: Superuser Only --- + if !requireSuperuser(c, username, "use netem set") { + return + } + + var req models.NetemSetRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Warnf("SetNetem failed for superuser '%s': Invalid request body: %v", username, err) + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request body: " + err.Error()}) + return + } + + if !isValidContainerName(req.ContainerName) { + log.Warnf("SetNetem failed for superuser '%s': Invalid container name format '%s'", username, req.ContainerName) + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid container name format."}) + return + } + + iface := strings.TrimSpace(req.Interface) + if iface == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Interface cannot be empty."}) + return + } + // Interface aliases can include spaces/slashes, so keep validation minimal but bounded. + if len(iface) > 128 { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Interface value is too long."}) + return + } + + var delay, jitter time.Duration + var err error + if strings.TrimSpace(req.Delay) != "" { + delay, err = time.ParseDuration(req.Delay) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid delay duration: " + err.Error()}) + return + } + } + if strings.TrimSpace(req.Jitter) != "" { + jitter, err = time.ParseDuration(req.Jitter) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid jitter duration: " + err.Error()}) + return + } + } + if jitter != 0 && delay == 0 { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Jitter cannot be set without setting delay."}) + return + } + + if req.Loss < 0 || req.Loss > 100 { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Loss must be in the range between 0 and 100."}) + return + } + if req.Corruption < 0 || req.Corruption > 100 { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Corruption must be in the range between 0 and 100."}) + return + } + + _, ownerErr := verifyContainerOwnership(c, username, req.ContainerName) + if ownerErr != nil { + return + } + + svc := GetClabService() + log.Infof("Superuser '%s' setting netem impairments: container=%s interface=%s", username, req.ContainerName, iface) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + err = svc.SetNetem(ctx, clab.NetemSetOptions{ + ContainerName: req.ContainerName, + Interface: iface, + Delay: delay, + Jitter: jitter, + Loss: req.Loss, + Rate: uint64(req.Rate), + Corruption: req.Corruption, + }) + if err != nil { + if errors.Is(err, clab.ErrNetemInterfaceNotFound) { + c.JSON(http.StatusNotFound, models.ErrorResponse{Error: fmt.Sprintf("Interface '%s' not found in container '%s'.", iface, req.ContainerName)}) + return + } + + log.Errorf("SetNetem failed for superuser '%s' (container '%s', interface '%s'): %v", username, req.ContainerName, iface, err) + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Error: fmt.Sprintf("Failed to set netem impairments for container '%s' interface '%s': %s", req.ContainerName, iface, err.Error()), + }) + return + } + + c.JSON(http.StatusOK, models.GenericSuccessResponse{ + Message: fmt.Sprintf("Netem impairments set successfully for container '%s' interface '%s'", req.ContainerName, iface), + }) +} + +// @Summary Show link impairments (netem) +// @Description Lists netem impairments for a given containerlab node. Requires superuser privileges. +// @Tags Tools - Netem +// @Security BearerAuth +// @Produce json +// @Param containerName query string true "Container/node name" example(clab-my-lab-srl1) +// @Success 200 {object} models.NetemShowResponse "Netem impairments" +// @Failure 400 {object} models.ErrorResponse "Invalid input parameters" +// @Failure 401 {object} models.ErrorResponse "Unauthorized (JWT)" +// @Failure 403 {object} models.ErrorResponse "Forbidden (User is not a superuser)" +// @Failure 404 {object} models.ErrorResponse "Container not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/tools/netem/show [get] +func ShowNetemHandler(c *gin.Context) { + username := c.GetString("username") + + // --- Authorization: Superuser Only --- + if !requireSuperuser(c, username, "use netem show") { + return + } + + containerName := c.Query("containerName") + if !isValidContainerName(containerName) { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid container name format."}) + return + } + + _, ownerErr := verifyContainerOwnership(c, username, containerName) + if ownerErr != nil { + return + } + + svc := GetClabService() + log.Infof("Superuser '%s' showing netem impairments: container=%s", username, containerName) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + impairments, err := svc.ShowNetem(ctx, containerName) + if err != nil { + log.Errorf("ShowNetem failed for superuser '%s' (container '%s'): %v", username, containerName, err) + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Error: fmt.Sprintf("Failed to show netem impairments for container '%s': %s", containerName, err.Error()), + }) + return + } + + infos := make([]models.NetemInterfaceInfo, 0, len(impairments)) + for _, imp := range impairments { + rate := uint(0) + if imp.Rate > 0 { + rate = uint(imp.Rate) + } + + infos = append(infos, models.NetemInterfaceInfo{ + Interface: imp.Interface, + Delay: imp.Delay, + Jitter: imp.Jitter, + PacketLoss: imp.PacketLoss, + Rate: rate, + Corruption: imp.Corruption, + }) + } + + c.JSON(http.StatusOK, models.NetemShowResponse{ + containerName: infos, + }) +} + +// @Summary Reset link impairments (netem) +// @Description Resets (removes) netem impairments from a specific interface of a containerlab node. Requires superuser privileges. +// @Tags Tools - Netem +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param netem_reset_request body models.NetemResetRequest true "Netem Reset Parameters" +// @Success 200 {object} models.GenericSuccessResponse "Impairments reset successfully" +// @Failure 400 {object} models.ErrorResponse "Invalid input parameters" +// @Failure 401 {object} models.ErrorResponse "Unauthorized (JWT)" +// @Failure 403 {object} models.ErrorResponse "Forbidden (User is not a superuser)" +// @Failure 404 {object} models.ErrorResponse "Container or interface not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/tools/netem/reset [post] +func ResetNetemHandler(c *gin.Context) { + username := c.GetString("username") + + // --- Authorization: Superuser Only --- + if !requireSuperuser(c, username, "use netem reset") { + return + } + + var req models.NetemResetRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Warnf("ResetNetem failed for superuser '%s': Invalid request body: %v", username, err) + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request body: " + err.Error()}) + return + } + + if !isValidContainerName(req.ContainerName) { + log.Warnf("ResetNetem failed for superuser '%s': Invalid container name format '%s'", username, req.ContainerName) + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid container name format."}) + return + } + + iface := strings.TrimSpace(req.Interface) + if iface == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Interface cannot be empty."}) + return + } + if len(iface) > 128 { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Interface value is too long."}) + return + } + + _, ownerErr := verifyContainerOwnership(c, username, req.ContainerName) + if ownerErr != nil { + return + } + + svc := GetClabService() + log.Infof("Superuser '%s' resetting netem impairments: container=%s interface=%s", username, req.ContainerName, iface) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + err := svc.ResetNetem(ctx, req.ContainerName, iface) + if err != nil { + if errors.Is(err, clab.ErrNetemInterfaceNotFound) { + c.JSON(http.StatusNotFound, models.ErrorResponse{Error: fmt.Sprintf("Interface '%s' not found in container '%s'.", iface, req.ContainerName)}) + return + } + + log.Errorf("ResetNetem failed for superuser '%s' (container '%s', interface '%s'): %v", username, req.ContainerName, iface, err) + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Error: fmt.Sprintf("Failed to reset netem impairments for container '%s' interface '%s': %s", req.ContainerName, iface, err.Error()), + }) + return + } + + c.JSON(http.StatusOK, models.GenericSuccessResponse{ + Message: fmt.Sprintf("Netem impairments reset successfully for container '%s' interface '%s'", req.ContainerName, iface), + }) +} diff --git a/internal/clab/service.go b/internal/clab/service.go index dd4e55c..17a2f78 100644 --- a/internal/clab/service.go +++ b/internal/clab/service.go @@ -3,9 +3,12 @@ package clab import ( "context" + "errors" "fmt" + "math" "net" "os" + "os/exec" "os/user" "path/filepath" "strconv" @@ -14,11 +17,13 @@ import ( "github.com/charmbracelet/log" "github.com/containernetworking/plugins/pkg/ns" + gotc "github.com/florianl/go-tc" clabcert "github.com/srl-labs/containerlab/cert" clabcore "github.com/srl-labs/containerlab/core" clabexec "github.com/srl-labs/containerlab/exec" clabgit "github.com/srl-labs/containerlab/git" clablinks "github.com/srl-labs/containerlab/links" + clabnetem "github.com/srl-labs/containerlab/netem" clabnodes "github.com/srl-labs/containerlab/nodes" clabnodesstate "github.com/srl-labs/containerlab/nodes/state" clabruntime "github.com/srl-labs/containerlab/runtime" @@ -35,6 +40,8 @@ const ( gracefulDestroyTimeout = 2 * time.Minute ) +var ErrNetemInterfaceNotFound = errors.New("netem interface not found") + // Service provides an interface to containerlab operations using the library directly. type Service struct{} @@ -956,6 +963,283 @@ func (s *Service) DeleteVxlan(ctx context.Context, prefix string) ([]string, err return deleted, nil } +// --- Netem --- + +// NetemSetOptions contains options for setting link impairments using netem. +type NetemSetOptions struct { + ContainerName string + Interface string + Delay time.Duration + Jitter time.Duration + Loss float64 + Rate uint64 // kbit/s + Corruption float64 +} + +// SetNetem applies netem impairments to a specific interface in a container's network namespace. +func (s *Service) SetNetem(ctx context.Context, opts NetemSetOptions) error { + ctx, cancel := s.ensureTimeout(ctx) + defer cancel() + + // Best-effort: ensure netem kernel module is loaded (helps on some distros). + if err := exec.CommandContext(ctx, "modprobe", "sch_netem").Run(); err != nil { + log.Warn("failed to load sch_netem kernel module", "err", err) + } + + rt, err := s.getContainerRuntime() + if err != nil { + return err + } + + nsPath, err := rt.GetNSPath(ctx, opts.ContainerName) + if err != nil { + return fmt.Errorf("failed to get namespace path for container %s: %w", opts.ContainerName, err) + } + + nodeNS, err := ns.GetNS(nsPath) + if err != nil { + return fmt.Errorf("failed to open network namespace %s: %w", nsPath, err) + } + defer nodeNS.Close() + + tcnl, err := clabnetem.NewTC(int(nodeNS.Fd())) + if err != nil { + return fmt.Errorf("failed to open rtnetlink socket: %w", err) + } + defer func() { + if closeErr := tcnl.Close(); closeErr != nil { + log.Warn("failed to close rtnetlink socket", "err", closeErr) + } + }() + + err = nodeNS.Do(func(_ ns.NetNS) error { + netemIfLink, err := netlink.LinkByName(clablinks.SanitizeInterfaceName(opts.Interface)) + if err != nil { + var lnf netlink.LinkNotFoundError + if errors.As(err, &lnf) { + return fmt.Errorf("%w: %s", ErrNetemInterfaceNotFound, opts.Interface) + } + return err + } + + netemIfName := netemIfLink.Attrs().Name + iface, err := net.InterfaceByName(netemIfName) + if err != nil { + return fmt.Errorf("%w: %s", ErrNetemInterfaceNotFound, netemIfName) + } + + _, err = clabnetem.SetImpairments( + tcnl, + opts.ContainerName, + iface, + opts.Delay, + opts.Jitter, + opts.Loss, + opts.Rate, + opts.Corruption, + ) + return err + }) + if err != nil { + return err + } + + log.Info("netem impairments set", + "container", opts.ContainerName, + "interface", opts.Interface, + "delay", opts.Delay.String(), + "jitter", opts.Jitter.String(), + "loss", opts.Loss, + "rate_kbit", opts.Rate, + "corruption", opts.Corruption, + ) + + return nil +} + +// ResetNetem removes netem impairments from a specific interface in a container's network namespace. +func (s *Service) ResetNetem(ctx context.Context, containerName, iface string) error { + ctx, cancel := s.ensureTimeout(ctx) + defer cancel() + + rt, err := s.getContainerRuntime() + if err != nil { + return err + } + + nsPath, err := rt.GetNSPath(ctx, containerName) + if err != nil { + return fmt.Errorf("failed to get namespace path for container %s: %w", containerName, err) + } + + nodeNS, err := ns.GetNS(nsPath) + if err != nil { + return fmt.Errorf("failed to open network namespace %s: %w", nsPath, err) + } + defer nodeNS.Close() + + tcnl, err := clabnetem.NewTC(int(nodeNS.Fd())) + if err != nil { + return fmt.Errorf("failed to open rtnetlink socket: %w", err) + } + defer func() { + if closeErr := tcnl.Close(); closeErr != nil { + log.Warn("failed to close rtnetlink socket", "err", closeErr) + } + }() + + return nodeNS.Do(func(_ ns.NetNS) error { + netemIfLink, err := netlink.LinkByName(clablinks.SanitizeInterfaceName(iface)) + if err != nil { + var lnf netlink.LinkNotFoundError + if errors.As(err, &lnf) { + return fmt.Errorf("%w: %s", ErrNetemInterfaceNotFound, iface) + } + return err + } + + netemIfIface, err := net.InterfaceByName(netemIfLink.Attrs().Name) + if err != nil { + return fmt.Errorf("%w: %s", ErrNetemInterfaceNotFound, netemIfLink.Attrs().Name) + } + + return clabnetem.DeleteImpairments(tcnl, netemIfIface) + }) +} + +// ShowNetem returns the current netem impairments for interfaces that have netem qdisc set. +func (s *Service) ShowNetem(ctx context.Context, containerName string) ([]clabtypes.ImpairmentData, error) { + ctx, cancel := s.ensureTimeout(ctx) + defer cancel() + + rt, err := s.getContainerRuntime() + if err != nil { + return nil, err + } + + nsPath, err := rt.GetNSPath(ctx, containerName) + if err != nil { + return nil, fmt.Errorf("failed to get namespace path for container %s: %w", containerName, err) + } + + nodeNS, err := ns.GetNS(nsPath) + if err != nil { + return nil, fmt.Errorf("failed to open network namespace %s: %w", nsPath, err) + } + defer nodeNS.Close() + + tcnl, err := clabnetem.NewTC(int(nodeNS.Fd())) + if err != nil { + return nil, fmt.Errorf("failed to open rtnetlink socket: %w", err) + } + defer func() { + if closeErr := tcnl.Close(); closeErr != nil { + log.Warn("failed to close rtnetlink socket", "err", closeErr) + } + }() + + var impairments []clabtypes.ImpairmentData + err = nodeNS.Do(func(_ ns.NetNS) error { + qdiscs, err := clabnetem.Impairments(tcnl) + if err != nil { + return err + } + + for idx := range qdiscs { + if qdiscs[idx].Attribute.Kind != "netem" { + continue // skip clsact or other qdisc types + } + impairments = append(impairments, qdiscToImpairmentData(&qdiscs[idx])) + } + + return nil + }) + if err != nil { + return nil, err + } + + return impairments, nil +} + +func (s *Service) getContainerRuntime() (clabruntime.ContainerRuntime, error) { + clabOpts := []clabcore.ClabOption{ + clabcore.WithTimeout(defaultTimeout), + clabcore.WithRuntime(config.AppConfig.ClabRuntime, &clabruntime.RuntimeConfig{Timeout: defaultTimeout}), + } + + clab, err := clabcore.NewContainerLab(clabOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create containerlab instance: %w", err) + } + + // Prefer explicitly configured runtime if present. + if rt, ok := clab.Runtimes[config.AppConfig.ClabRuntime]; ok && rt != nil { + return rt, nil + } + + // Fall back to first configured runtime. + for _, rt := range clab.Runtimes { + if rt != nil { + return rt, nil + } + } + + return nil, fmt.Errorf("no container runtime configured") +} + +const msPerSec = 1000 + +func qdiscToImpairmentData(qdisc *gotc.Object) clabtypes.ImpairmentData { + var ifDisplayName string + + link, err := netlink.LinkByIndex(int(qdisc.Ifindex)) + if err != nil || link == nil { + ifDisplayName = fmt.Sprintf("ifindex:%d", qdisc.Ifindex) + } else { + ifDisplayName = link.Attrs().Name + if link.Attrs().Alias != "" { + ifDisplayName += fmt.Sprintf(" (%s)", link.Attrs().Alias) + } + } + + // Return empty values when netem is not set. + if qdisc.Netem == nil { + return clabtypes.ImpairmentData{ + Interface: ifDisplayName, + } + } + + data := clabtypes.ImpairmentData{ + Interface: ifDisplayName, + } + + if qdisc.Netem.Latency64 != nil && *qdisc.Netem.Latency64 != 0 { + data.Delay = (time.Duration(*qdisc.Netem.Latency64) * time.Nanosecond).String() + } + + if qdisc.Netem.Jitter64 != nil && *qdisc.Netem.Jitter64 != 0 { + data.Jitter = (time.Duration(*qdisc.Netem.Jitter64) * time.Nanosecond).String() + } + + if qdisc.Netem.Rate != nil && int(qdisc.Netem.Rate.Rate) != 0 { + data.Rate = int(uint64(qdisc.Netem.Rate.Rate) * 8 / msPerSec) + } + + if qdisc.Netem.Corrupt != nil && qdisc.Netem.Corrupt.Probability != 0 { + // round to 2 decimal places + data.Corruption = math.Round((float64(qdisc.Netem.Corrupt.Probability)/ + float64(math.MaxUint32)*100)*100) / 100 //nolint: mnd + } + + if qdisc.Netem.Qopt.Loss != 0 { + // round to 2 decimal places + data.PacketLoss = math.Round( + (float64(qdisc.Netem.Qopt.Loss)/float64(math.MaxUint32)*100)*100) / 100 //nolint: mnd + } + + return data +} + // GenerateTopologyOptions contains options for generating a topology. type GenerateTopologyOptions struct { Name string diff --git a/internal/models/models.go b/internal/models/models.go index 0a27e44..2fbe988 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -286,6 +286,12 @@ type VxlanCreateRequest struct { // Use pointers to distinguish between unset and zero values if necessary, // but clab defaults usually handle zero values correctly (meaning "unset"). type NetemSetRequest struct { + // Container/node name to apply impairments on (e.g., "clab-my-lab-srl1"). + ContainerName string `json:"containerName" binding:"required" example:"clab-my-lab-srl1"` + + // Interface name or interface alias to apply impairments on (e.g., "eth1" or "mgmt0"). + Interface string `json:"interface" binding:"required" example:"eth1"` + Delay string `json:"delay,omitempty" example:"50ms"` // Duration string (e.g., "100ms", "1s") Jitter string `json:"jitter,omitempty" example:"5ms"` // Duration string, requires Delay Loss float64 `json:"loss,omitempty" example:"10.5"` // Percentage (0.0 to 100.0) @@ -293,6 +299,15 @@ type NetemSetRequest struct { Corruption float64 `json:"corruption,omitempty" example:"0.1"` // Percentage (0.0 to 100.0) } +// NetemResetRequest represents the parameters for resetting network emulation on an interface. +type NetemResetRequest struct { + // Container/node name to reset impairments on (e.g., "clab-my-lab-srl1"). + ContainerName string `json:"containerName" binding:"required" example:"clab-my-lab-srl1"` + + // Interface name or interface alias to reset impairments on (e.g., "eth1" or "mgmt0"). + Interface string `json:"interface" binding:"required" example:"eth1"` +} + // NetemInterfaceInfo holds the netem details for a single interface from `clab tools netem show --format json` type NetemInterfaceInfo struct { Interface string `json:"interface"` // Interface name diff --git a/tests_go/netem_suite_test.go b/tests_go/netem_suite_test.go new file mode 100644 index 0000000..78b30e3 --- /dev/null +++ b/tests_go/netem_suite_test.go @@ -0,0 +1,178 @@ +// tests_go/netem_suite_test.go +package tests_go + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +// NetemSuite tests the netem tools endpoints for setting/showing/resetting link impairments. +type NetemSuite struct { + BaseSuite + + apiUserToken string + apiUserHeaders http.Header + superuserToken string + superuserHeaders http.Header +} + +// TestNetemSuite runs the NetemSuite. +func TestNetemSuite(t *testing.T) { + suite.Run(t, new(NetemSuite)) +} + +func (s *NetemSuite) SetupSuite() { + s.BaseSuite.SetupSuite() + + s.apiUserToken = s.login(s.cfg.APIUserUser, s.cfg.APIUserPass) + s.apiUserHeaders = s.getAuthHeaders(s.apiUserToken) + s.Require().NotEmpty(s.apiUserToken) + + s.superuserToken = s.login(s.cfg.SuperuserUser, s.cfg.SuperuserPass) + s.superuserHeaders = s.getAuthHeaders(s.superuserToken) + s.Require().NotEmpty(s.superuserToken) +} + +func (s *NetemSuite) TestNetemForbiddenForAPIUser() { + labName, userHeaders := s.setupEphemeralLab() + defer s.cleanupLab(labName, true) + + inspectURL := fmt.Sprintf("%s/api/v1/labs/%s", s.cfg.APIURL, labName) + bodyBytes, statusCode, err := s.doRequest("GET", inspectURL, userHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, statusCode, "Expected status 200 inspecting lab '%s'. Body: %s", labName, string(bodyBytes)) + + var containers []struct { + Name string `json:"name"` + } + s.Require().NoError(json.Unmarshal(bodyBytes, &containers)) + s.Require().NotEmpty(containers) + + containerName := containers[0].Name + s.Require().NotEmpty(containerName) + + setURL := fmt.Sprintf("%s/api/v1/tools/netem/set", s.cfg.APIURL) + payload := map[string]interface{}{ + "containerName": containerName, + "interface": "eth1", + "delay": "10ms", + } + reqBody := s.mustMarshal(payload) + + respBody, respStatus, reqErr := s.doRequest("POST", setURL, s.apiUserHeaders, bytes.NewBuffer(reqBody), s.cfg.RequestTimeout) + s.Require().NoError(reqErr) + s.Require().Equal(http.StatusForbidden, respStatus, "Expected 403 when non-superuser calls netem set. Body: %s", string(respBody)) +} + +func (s *NetemSuite) TestNetemSetShowReset() { + labName, userHeaders := s.setupEphemeralLab() + defer s.cleanupLab(labName, true) + + inspectURL := fmt.Sprintf("%s/api/v1/labs/%s", s.cfg.APIURL, labName) + bodyBytes, statusCode, err := s.doRequest("GET", inspectURL, userHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, statusCode, "Expected status 200 inspecting lab '%s'. Body: %s", labName, string(bodyBytes)) + + var containers []struct { + Name string `json:"name"` + } + s.Require().NoError(json.Unmarshal(bodyBytes, &containers)) + s.Require().NotEmpty(containers) + + containerName := containers[0].Name + s.Require().NotEmpty(containerName) + + iface := "eth1" + + // --- Set impairments --- + setURL := fmt.Sprintf("%s/api/v1/tools/netem/set", s.cfg.APIURL) + setPayload := map[string]interface{}{ + "containerName": containerName, + "interface": iface, + "delay": "100ms", + "jitter": "2ms", + "loss": 10, + "rate": 1000, + "corruption": 2, + } + setReqBody := s.mustMarshal(setPayload) + + setRespBody, setStatus, setErr := s.doRequest("POST", setURL, s.superuserHeaders, bytes.NewBuffer(setReqBody), s.cfg.RequestTimeout) + s.Require().NoError(setErr) + s.Require().Equal(http.StatusOK, setStatus, "Expected 200 when setting netem. Body: %s", string(setRespBody)) + + time.Sleep(500 * time.Millisecond) + + // --- Show impairments --- + showURL := fmt.Sprintf("%s/api/v1/tools/netem/show?containerName=%s", s.cfg.APIURL, containerName) + showBody, showStatus, showErr := s.doRequest("GET", showURL, s.superuserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(showErr) + s.Require().Equal(http.StatusOK, showStatus, "Expected 200 when showing netem. Body: %s", string(showBody)) + + type netemIfInfo struct { + Interface string `json:"interface"` + Delay string `json:"delay"` + Jitter string `json:"jitter"` + PacketLoss float64 `json:"packet_loss"` + Rate int `json:"rate"` + Corruption float64 `json:"corruption"` + } + var showResp map[string][]netemIfInfo + s.Require().NoError(json.Unmarshal(showBody, &showResp), "Failed to unmarshal netem show response. Body: %s", string(showBody)) + + ifs, ok := showResp[containerName] + s.Require().True(ok, "Expected show response to include key '%s'. Body: %s", containerName, string(showBody)) + s.Require().NotEmpty(ifs, "Expected at least one netem entry after set. Body: %s", string(showBody)) + + var found *netemIfInfo + for i := range ifs { + // Allow "eth1 (alias)" display format. + if strings.HasPrefix(ifs[i].Interface, iface) { + found = &ifs[i] + break + } + } + s.Require().NotNil(found, "Expected to find netem entry for interface '%s'. Body: %s", iface, string(showBody)) + + s.Require().Equal("100ms", found.Delay) + s.Require().Equal("2ms", found.Jitter) + s.Require().Equal(1000, found.Rate) + s.Require().InDelta(10.0, found.PacketLoss, 0.05) + s.Require().InDelta(2.0, found.Corruption, 0.05) + + // --- Reset impairments --- + resetURL := fmt.Sprintf("%s/api/v1/tools/netem/reset", s.cfg.APIURL) + resetPayload := map[string]interface{}{ + "containerName": containerName, + "interface": iface, + } + resetReqBody := s.mustMarshal(resetPayload) + + resetRespBody, resetStatus, resetErr := s.doRequest("POST", resetURL, s.superuserHeaders, bytes.NewBuffer(resetReqBody), s.cfg.RequestTimeout) + s.Require().NoError(resetErr) + s.Require().Equal(http.StatusOK, resetStatus, "Expected 200 when resetting netem. Body: %s", string(resetRespBody)) + + time.Sleep(500 * time.Millisecond) + + // --- Show again, ensure interface entry is gone --- + showBody2, showStatus2, showErr2 := s.doRequest("GET", showURL, s.superuserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(showErr2) + s.Require().Equal(http.StatusOK, showStatus2, "Expected 200 when showing netem after reset. Body: %s", string(showBody2)) + + var showResp2 map[string][]netemIfInfo + s.Require().NoError(json.Unmarshal(showBody2, &showResp2), "Failed to unmarshal netem show response (after reset). Body: %s", string(showBody2)) + + ifs2, ok2 := showResp2[containerName] + s.Require().True(ok2, "Expected show response (after reset) to include key '%s'. Body: %s", containerName, string(showBody2)) + + for i := range ifs2 { + s.Require().False(strings.HasPrefix(ifs2[i].Interface, iface), "Expected netem entry for '%s' to be removed after reset. Body: %s", iface, string(showBody2)) + } +}