diff --git a/.env.example b/.env.example index 080c9c5..db0fde5 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # .env.example API_PORT=8080 +API_SERVER_HOST=localhost JWT_SECRET=a_very_secret_key_change_me_please # CHANGE THIS! Use a strong, random secret JWT_EXPIRATION_MINUTES=60m # API_USER_GROUP=clab_api # The API group name, if not defined, the user needs to be in clab_admins group @@ -8,9 +9,13 @@ CLAB_RUNTIME=docker # The runtime to use for the labs LOG_LEVEL=debug # Gin Settings -GIN_MODE=debug # Options: debug, release, test +GIN_MODE=release # Options: debug, release, test TRUSTED_PROXIES= # Options: nil (trust none), comma-separated IPs, or empty (trust all) +# SSH proxy port range (Default: 2222-2322) +#SSH_BASE_PORT=2222 +#SSH_MAX_PORT=2322 + # --- TLS Configuration --- #TLS_ENABLE=true #TLS_CERT_FILE=./certs/localhost+2.pem # Path relative to where you run the app diff --git a/README.md b/README.md index 3ebc76f..f31f3fd 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ This project provides a standalone RESTful API server written in Go to interact * **Lab Management:** Deploy, destroy, redeploy, inspect, and list labs * **Node Operations:** Execute commands and save configurations +* **SSH Access:** Connect to lab nodes via SSH through the API server * **Topology Tools:** Generate and deploy CLOS topologies * **Network Tools:** Manage network emulation, virtual Ethernet pairs, VxLAN tunnels -* **Certification Tools:** Certificate management, user authentication via Linux PAM and JWT +* **Certification Tools:** Certificate management * **User Context:** Track ownership and manage files within user home directories * **Configuration:** Configurable via environment variables and `.env` files * **Documentation:** Embedded Swagger UI for API exploration @@ -95,6 +96,7 @@ All options can be set via **environment variables**, the shipped **`/etc/clab-a ```dotenv # Containerlab API Server configuration (excerpt) API_PORT=8080 +API_SERVER_HOST=localhost LOG_LEVEL=info # --- Authentication --- @@ -110,13 +112,19 @@ CLAB_RUNTIME=docker GIN_MODE=release TRUSTED_PROXIES= +# --- SSH (otional) --- +#SSH proxy port range (Default: 2222-2322) +#SSH_BASE_PORT=2222 +#SSH_MAX_PORT=2322 + # --- TLS (optional) --- #TLS_ENABLE=true #TLS_CERT_FILE=/etc/clab-api-server/certs/server.pem #TLS_KEY_FILE=/etc/clab-api-server/certs/server-key.pem ``` -> **Note:** Settings defined as environment variables always take precedence over the file. +> [!NOTE] +> Settings defined as environment variables always take precedence over the file. --- @@ -132,8 +140,10 @@ sudo /usr/local/bin/clab-api-server -env-file /etc/clab-api-server.env * **Server user** – defined in the systemd unit (default: the user that executed the install script). Needs rights to run **clab** and access the container runtime (e.g. be in the `docker` group). * **Authenticated Linux user** – validated via PAM, must be member of `API_USER_GROUP` (default `clab_api`) or `SUPERUSER_GROUP` (`clab_admins`). -* **Command execution** – all **clab** commands run as the *server* user, *not* the authenticated user. +* **Command execution** – all **clab** commands *and* SSH proxies run as the *server* user, *not* the authenticated user. * **Ownership** – Lab ownership is inferred from clab container labels; file operations attempt to store artifacts under the authenticated user’s home. +* **SSH sessions** – The SSH manager allocates local ports (default **2222‑2322**) and forwards traffic to container port 22. Sessions expire automatically (default **1 h**, max **24 h**) and can be listed or terminated via the API. +* **Security controls** – PAM for credential validation, JWT for session management, input validation & path sanitisation, optional TLS with client‑cert auth, execution timeouts. See the full *Privilege Model and Security* section further below for details. @@ -222,6 +232,7 @@ A user must either * **Input validation & path sanitisation** against directory traversal * **TLS** support with optional client‑cert auth * **Execution timeouts** for clab commands +* **SSH session limits** and automatic expiration > [!IMPORTANT] > Granting the server user write access to other users’ home directories has security implications. Review your threat model carefully before production deployments. diff --git a/cmd/server/main.go b/cmd/server/main.go index 425e294..f165525 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,50 +2,50 @@ package main import ( - "flag" // Import flag package + "context" + "errors" + "flag" "fmt" "net/http" "os" "os/exec" + "os/signal" "strings" + "syscall" + "time" "github.com/charmbracelet/log" "github.com/gin-gonic/gin" - "github.com/spf13/viper" // Import viper to check for file not found error type + "github.com/spf13/viper" - // Adjust these import paths if your module path is different - _ "github.com/srl-labs/clab-api-server/docs" // swagger docs + _ "github.com/srl-labs/clab-api-server/docs" "github.com/srl-labs/clab-api-server/internal/api" "github.com/srl-labs/clab-api-server/internal/config" ) -// --- Version Info (Set via LDFLAGS during build) --- -// Example build command: -// go build -ldflags="-X main.version=1.2.3 -X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" ./cmd/server +// --- Version Info --- var ( - version = "development" // Default value - commit = "none" // Default value - date = "unknown" // Default value + version = "development" + commit = "none" + date = "unknown" ) +// --- Swagger annotations --- // @title Containerlab API -// @version 1.0 // This swagger version is separate from the application version +// @version 1.0 // @description This is an API server to interact with Containerlab for authenticated Linux users. Runs clab commands as the API server's user. Requires PAM for authentication. // @termsOfService http://swagger.io/terms/ - // @contact.name API Support // @contact.url https://swagger.io/support/ // @contact.email support@swagger.io - // @license.name Apache 2.0 // @license.url http://www.apache.org/licenses/LICENSE-2.0.html - // @schemes http https - // @securityDefinitions.apikey BearerAuth // @in header // @name Authorization // @description Type "Bearer" followed by a space and JWT token. Example: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + func main() { // --- Define and Parse Command Line Flags --- var showVersion bool @@ -66,21 +66,15 @@ func main() { } // --- Load configuration First --- - // Use a basic logger initially, as the configured one isn't ready yet. basicLogger := log.New(os.Stderr) basicLogger.Infof("Attempting to load configuration from '%s' and environment variables...", envFile) - err := config.LoadConfig(envFile) if err != nil { - // Check if the error was specifically 'file not found' for the default path if _, ok := err.(viper.ConfigFileNotFoundError); ok && envFile == defaultEnvFile { basicLogger.Infof("Default config file '%s' not found. Using environment variables and defaults.", defaultEnvFile) - // Reload config forcing viper to ignore the file and use only env/defaults - // This might not be strictly necessary if viper already fell back correctly, but makes it explicit. - viper.Reset() // Reset viper state - config.LoadConfig("") // Call again with empty path, forcing reliance on env/defaults + viper.Reset() + config.LoadConfig("") // Rely on env/defaults } else { - // A specific file was requested and not found, or another error occurred. basicLogger.Fatalf("Failed to load configuration: %v", err) } } else { @@ -90,38 +84,17 @@ func main() { // --- Initialize Logger Based on Config --- log.SetOutput(os.Stderr) log.SetTimeFormat("2006-01-02 15:04:05") - - // Set log level from config switch strings.ToLower(config.AppConfig.LogLevel) { case "debug": log.SetLevel(log.DebugLevel) case "info": log.SetLevel(log.InfoLevel) - case "warn": - log.SetLevel(log.WarnLevel) - case "error": - log.SetLevel(log.ErrorLevel) - case "fatal": - log.SetLevel(log.FatalLevel) default: - log.Warnf("Invalid LOG_LEVEL '%s' specified in config, defaulting to 'info'", config.AppConfig.LogLevel) - log.SetLevel(log.InfoLevel) // Default to info if invalid value + log.Warnf("Invalid LOG_LEVEL '%s', defaulting to 'info'", config.AppConfig.LogLevel) + log.SetLevel(log.InfoLevel) } - - log.Infof("clab-api-server version %s starting...", version) // Log version on startup + log.Infof("clab-api-server version %s starting...", version) log.Infof("Configuration processed. Log level set to '%s'.", config.AppConfig.LogLevel) - - // --- Log Loaded Configuration Details (using the configured logger) --- - log.Debugf("Using configuration file: %s (or defaults/env if not found)", envFile) - log.Debugf("API Port: %s", config.AppConfig.APIPort) - log.Debugf("JWT Secret Loaded: %t", config.AppConfig.JWTSecret != "" && config.AppConfig.JWTSecret != "default_secret_change_me") - log.Debugf("JWT Expiration: %s", config.AppConfig.JWTExpirationMinutes) - log.Infof("Containerlab Runtime: %s", config.AppConfig.ClabRuntime) - log.Debugf("TLS Enabled: %t", config.AppConfig.TLSEnable) - if config.AppConfig.TLSEnable { - log.Debugf("TLS Cert File: %s", config.AppConfig.TLSCertFile) - log.Debugf("TLS Key File: %s", config.AppConfig.TLSKeyFile) - } if config.AppConfig.JWTSecret == "default_secret_change_me" { log.Warn("Using default JWT secret. Change JWT_SECRET environment variable or .env file for production!") } @@ -132,44 +105,41 @@ func main() { } log.Info("'clab' command found in PATH.") + // --- Initialize SSH Manager --- + log.Info("Initializing SSH Session Manager...") + api.InitSSHManager() // Initialize before setting up routes + log.Infof("SSH port range configured: %d - %d", config.AppConfig.SSHBasePort, config.AppConfig.SSHMaxPort) + // --- Initialize Gin router --- if strings.ToLower(config.AppConfig.GinMode) == "release" { gin.SetMode(gin.ReleaseMode) - } else if strings.ToLower(config.AppConfig.GinMode) == "test" { - gin.SetMode(gin.TestMode) } else { gin.SetMode(gin.DebugMode) // Default to debug } - log.Infof("Gin running in '%s' mode", gin.Mode()) // Use gin.Mode() to get actual mode - - router := gin.Default() + log.Infof("Gin running in '%s' mode", gin.Mode()) + router := gin.Default() // Use Default for logging and recovery middleware // Configure trusted proxies if config.AppConfig.TrustedProxies == "nil" { - // Explicitly disable proxy trust log.Info("Proxy trust disabled (TRUSTED_PROXIES=nil)") - _ = router.SetTrustedProxies(nil) // Error ignored as per Gin docs for nil + _ = router.SetTrustedProxies(nil) } else if config.AppConfig.TrustedProxies != "" { - // Set specific trusted proxies proxyList := strings.Split(config.AppConfig.TrustedProxies, ",") - // Trim any whitespace for i, proxy := range proxyList { proxyList[i] = strings.TrimSpace(proxy) } log.Infof("Setting trusted proxies: %v", proxyList) - err := router.SetTrustedProxies(proxyList) - if err != nil { - log.Warnf("Error setting trusted proxies: %v. Using default.", err) // Log error if setting fails + if err := router.SetTrustedProxies(proxyList); err != nil { + log.Warnf("Error setting trusted proxies: %v. Using default.", err) } } else { - // Default behavior (trust all) - just log a warning - log.Warn("All proxies are trusted (default). Set TRUSTED_PROXIES=nil to disable proxy trust or provide a comma-separated list of trusted proxy IPs.") + log.Warn("All proxies are trusted (default). Set TRUSTED_PROXIES=nil or provide a list.") } // Setup API routes api.SetupRoutes(router) - // Root handler + // Root handler (remains the same) router.GET("/", func(c *gin.Context) { protocol := "http" if config.AppConfig.TLSEnable { @@ -182,7 +152,7 @@ func main() { baseURL := fmt.Sprintf("%s://%s", protocol, host) c.JSON(http.StatusOK, gin.H{ - "message": fmt.Sprintf("Containerlab API Server (Version: %s) is running (%s).", version, protocol), // Include version + "message": fmt.Sprintf("Containerlab API Server (Version: %s) is running (%s).", version, protocol), "documentation": fmt.Sprintf("%s/swagger/index.html", baseURL), "login_endpoint": fmt.Sprintf("POST %s/login", baseURL), "api_base_path": fmt.Sprintf("%s/api/v1", baseURL), @@ -196,34 +166,68 @@ func main() { }) }) - // --- Start the server --- + // --- Prepare Server Configuration --- listenAddr := fmt.Sprintf(":%s", config.AppConfig.APIPort) serverBaseURL := fmt.Sprintf("http://localhost:%s", config.AppConfig.APIPort) if config.AppConfig.TLSEnable { serverBaseURL = fmt.Sprintf("https://localhost:%s", config.AppConfig.APIPort) } - if config.AppConfig.TLSEnable { - // Start HTTPS server - log.Infof("Starting HTTPS server, accessible locally at %s (and potentially other IPs)", serverBaseURL) - if config.AppConfig.TLSCertFile == "" || config.AppConfig.TLSKeyFile == "" { - log.Fatalf("TLS is enabled but TLS_CERT_FILE or TLS_KEY_FILE is not set in config.") - } - if _, err := os.Stat(config.AppConfig.TLSCertFile); os.IsNotExist(err) { - log.Fatalf("TLS cert file not found: %s", config.AppConfig.TLSCertFile) - } - if _, err := os.Stat(config.AppConfig.TLSKeyFile); os.IsNotExist(err) { - log.Fatalf("TLS key file not found: %s", config.AppConfig.TLSKeyFile) - } + srv := &http.Server{ + Addr: listenAddr, + Handler: router, + } - if err := router.RunTLS(listenAddr, config.AppConfig.TLSCertFile, config.AppConfig.TLSKeyFile); err != nil { - log.Fatalf("Failed to start HTTPS server: %v", err) - } - } else { - // Start HTTP server - log.Infof("Starting HTTP server, accessible locally at %s (and potentially other IPs)", serverBaseURL) - if err := router.Run(listenAddr); err != nil { - log.Fatalf("Failed to start HTTP server: %v", err) + // --- Start Server Goroutine --- + go func() { + protocol := "HTTP" + if config.AppConfig.TLSEnable { + protocol = "HTTPS" + log.Infof("Starting %s server, accessible locally at %s (and potentially other IPs)", protocol, serverBaseURL) + // Check TLS files before starting + if config.AppConfig.TLSCertFile == "" || config.AppConfig.TLSKeyFile == "" { + log.Fatalf("TLS is enabled but TLS_CERT_FILE or TLS_KEY_FILE is not set.") + } + if _, err := os.Stat(config.AppConfig.TLSCertFile); os.IsNotExist(err) { + log.Fatalf("TLS cert file not found: %s", config.AppConfig.TLSCertFile) + } + if _, err := os.Stat(config.AppConfig.TLSKeyFile); os.IsNotExist(err) { + log.Fatalf("TLS key file not found: %s", config.AppConfig.TLSKeyFile) + } + // Start HTTPS server + if err := srv.ListenAndServeTLS(config.AppConfig.TLSCertFile, config.AppConfig.TLSKeyFile); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("Failed to start %s server: %v", protocol, err) + } + } else { + // Start HTTP server + log.Infof("Starting %s server, accessible locally at %s (and potentially other IPs)", protocol, serverBaseURL) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("Failed to start %s server: %v", protocol, err) + } } + log.Info("Server listener stopped.") // Will log when ListenAndServe returns + }() + + // --- Graceful Shutdown Handling --- + quit := make(chan os.Signal, 1) + // Notify about common termination signals + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + // Block until a signal is received + sig := <-quit + log.Infof("Received signal: %s. Shutting down server...", sig) + + // Create a context with a timeout for the shutdown + // Give outstanding requests a deadline to finish + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // Adjust timeout as needed + defer cancel() + + // Shutdown SSH Manager (can run concurrently with server shutdown) + go api.ShutdownSSHManager() // No need to wait for this specifically unless it's critical + + // Attempt graceful shutdown of the HTTP server + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) } + + log.Info("Server exiting gracefully.") } diff --git a/docs/docs.go b/docs/docs.go index ac9665f..f4f51f6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -923,6 +923,88 @@ const docTemplate = `{ } } }, + "/api/v1/labs/{labName}/nodes/{nodeName}/ssh": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates temporary SSH access to a specific lab node, returning connection details", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SSH Access" + ], + "summary": "Request SSH Access to Lab Node", + "parameters": [ + { + "type": "string", + "description": "Lab name", + "name": "labName", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Full container name of the node (e.g., clab-my-lab-srl1)", + "name": "nodeName", + "in": "path", + "required": true + }, + { + "description": "SSH access parameters", + "name": "sshRequest", + "in": "body", + "schema": { + "$ref": "#/definitions/models.SSHAccessRequest" + } + } + ], + "responses": { + "200": { + "description": "SSH connection details", + "schema": { + "$ref": "#/definitions/models.SSHAccessResponse" + } + }, + "400": { + "description": "Invalid request parameters", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (not owner of the lab)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Lab or node not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/api/v1/labs/{labName}/save": { "post": { "security": [ @@ -987,6 +1069,118 @@ const docTemplate = `{ } } }, + "/api/v1/ssh/sessions": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Lists active SSH sessions. For regular users, shows only their sessions. Superusers can see all sessions by using the 'all' query parameter.", + "produces": [ + "application/json" + ], + "tags": [ + "SSH Access" + ], + "summary": "List SSH Sessions", + "parameters": [ + { + "type": "boolean", + "description": "If true and user is superuser, shows sessions for all users (default: false)", + "name": "all", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of active SSH sessions", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.SSHSessionInfo" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (non-superuser attempting to list all sessions)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/v1/ssh/sessions/{port}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Terminates a specific SSH session by port", + "produces": [ + "application/json" + ], + "tags": [ + "SSH Access" + ], + "summary": "Terminate SSH Session", + "parameters": [ + { + "type": "integer", + "description": "SSH session port to terminate", + "name": "port", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Session terminated successfully", + "schema": { + "$ref": "#/definitions/models.GenericSuccessResponse" + } + }, + "400": { + "description": "Invalid port parameter", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (not owner of the session)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Session not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/api/v1/tools/certs/ca": { "post": { "security": [ @@ -2080,6 +2274,73 @@ const docTemplate = `{ } } }, + "models.SSHAccessRequest": { + "type": "object", + "properties": { + "duration": { + "description": "How long the access should be valid for (e.g., \"1h\", \"30m\")", + "type": "string" + }, + "sshUsername": { + "description": "Optional override for container's SSH user", + "type": "string" + } + } + }, + "models.SSHAccessResponse": { + "type": "object", + "properties": { + "command": { + "description": "Example SSH command", + "type": "string" + }, + "expiration": { + "description": "When this access expires", + "type": "string" + }, + "host": { + "description": "API server's hostname or IP", + "type": "string" + }, + "port": { + "description": "Allocated port on API server", + "type": "integer" + }, + "username": { + "description": "Username to use for SSH", + "type": "string" + } + } + }, + "models.SSHSessionInfo": { + "type": "object", + "properties": { + "created": { + "description": "When this access was created", + "type": "string" + }, + "expiration": { + "description": "When this access expires", + "type": "string" + }, + "labName": { + "description": "Lab name", + "type": "string" + }, + "nodeName": { + "description": "Node name", + "type": "string" + }, + "port": { + "description": "Allocated port on API server", + "type": "integer" + }, + "username": { + "description": "SSH username", + "type": "string" + } + } + }, "models.SaveConfigResponse": { "type": "object", "properties": { @@ -2189,7 +2450,7 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "1.0 // This swagger version is separate from the application version", + Version: "1.0", Host: "", BasePath: "", Schemes: []string{"http", "https"}, diff --git a/docs/swagger.json b/docs/swagger.json index 12ace02..b27a23a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -17,7 +17,7 @@ "name": "Apache 2.0", "url": "http://www.apache.org/licenses/LICENSE-2.0.html" }, - "version": "1.0 // This swagger version is separate from the application version" + "version": "1.0" }, "paths": { "/api/v1/generate": { @@ -919,6 +919,88 @@ } } }, + "/api/v1/labs/{labName}/nodes/{nodeName}/ssh": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates temporary SSH access to a specific lab node, returning connection details", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SSH Access" + ], + "summary": "Request SSH Access to Lab Node", + "parameters": [ + { + "type": "string", + "description": "Lab name", + "name": "labName", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Full container name of the node (e.g., clab-my-lab-srl1)", + "name": "nodeName", + "in": "path", + "required": true + }, + { + "description": "SSH access parameters", + "name": "sshRequest", + "in": "body", + "schema": { + "$ref": "#/definitions/models.SSHAccessRequest" + } + } + ], + "responses": { + "200": { + "description": "SSH connection details", + "schema": { + "$ref": "#/definitions/models.SSHAccessResponse" + } + }, + "400": { + "description": "Invalid request parameters", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (not owner of the lab)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Lab or node not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/api/v1/labs/{labName}/save": { "post": { "security": [ @@ -983,6 +1065,118 @@ } } }, + "/api/v1/ssh/sessions": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Lists active SSH sessions. For regular users, shows only their sessions. Superusers can see all sessions by using the 'all' query parameter.", + "produces": [ + "application/json" + ], + "tags": [ + "SSH Access" + ], + "summary": "List SSH Sessions", + "parameters": [ + { + "type": "boolean", + "description": "If true and user is superuser, shows sessions for all users (default: false)", + "name": "all", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of active SSH sessions", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.SSHSessionInfo" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (non-superuser attempting to list all sessions)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/v1/ssh/sessions/{port}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Terminates a specific SSH session by port", + "produces": [ + "application/json" + ], + "tags": [ + "SSH Access" + ], + "summary": "Terminate SSH Session", + "parameters": [ + { + "type": "integer", + "description": "SSH session port to terminate", + "name": "port", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Session terminated successfully", + "schema": { + "$ref": "#/definitions/models.GenericSuccessResponse" + } + }, + "400": { + "description": "Invalid port parameter", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "Forbidden (not owner of the session)", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Session not found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/api/v1/tools/certs/ca": { "post": { "security": [ @@ -2076,6 +2270,73 @@ } } }, + "models.SSHAccessRequest": { + "type": "object", + "properties": { + "duration": { + "description": "How long the access should be valid for (e.g., \"1h\", \"30m\")", + "type": "string" + }, + "sshUsername": { + "description": "Optional override for container's SSH user", + "type": "string" + } + } + }, + "models.SSHAccessResponse": { + "type": "object", + "properties": { + "command": { + "description": "Example SSH command", + "type": "string" + }, + "expiration": { + "description": "When this access expires", + "type": "string" + }, + "host": { + "description": "API server's hostname or IP", + "type": "string" + }, + "port": { + "description": "Allocated port on API server", + "type": "integer" + }, + "username": { + "description": "Username to use for SSH", + "type": "string" + } + } + }, + "models.SSHSessionInfo": { + "type": "object", + "properties": { + "created": { + "description": "When this access was created", + "type": "string" + }, + "expiration": { + "description": "When this access expires", + "type": "string" + }, + "labName": { + "description": "Lab name", + "type": "string" + }, + "nodeName": { + "description": "Node name", + "type": "string" + }, + "port": { + "description": "Allocated port on API server", + "type": "integer" + }, + "username": { + "description": "SSH username", + "type": "string" + } + } + }, "models.SaveConfigResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 00a932a..b147a26 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -471,6 +471,54 @@ definitions: description: Corresponds to --skip-post-deploy flag type: boolean type: object + models.SSHAccessRequest: + properties: + duration: + description: How long the access should be valid for (e.g., "1h", "30m") + type: string + sshUsername: + description: Optional override for container's SSH user + type: string + type: object + models.SSHAccessResponse: + properties: + command: + description: Example SSH command + type: string + expiration: + description: When this access expires + type: string + host: + description: API server's hostname or IP + type: string + port: + description: Allocated port on API server + type: integer + username: + description: Username to use for SSH + type: string + type: object + models.SSHSessionInfo: + properties: + created: + description: When this access was created + type: string + expiration: + description: When this access expires + type: string + labName: + description: Lab name + type: string + nodeName: + description: Node name + type: string + port: + description: Allocated port on API server + type: integer + username: + description: SSH username + type: string + type: object models.SaveConfigResponse: properties: message: @@ -567,7 +615,7 @@ info: url: http://www.apache.org/licenses/LICENSE-2.0.html termsOfService: http://swagger.io/terms/ title: Containerlab API - version: 1.0 // This swagger version is separate from the application version + version: "1.0" paths: /api/v1/generate: post: @@ -1110,6 +1158,60 @@ paths: summary: Show Network Emulation tags: - Tools - Netem + /api/v1/labs/{labName}/nodes/{nodeName}/ssh: + post: + consumes: + - application/json + description: Creates temporary SSH access to a specific lab node, returning + connection details + parameters: + - description: Lab name + in: path + name: labName + required: true + type: string + - description: Full container name of the node (e.g., clab-my-lab-srl1) + in: path + name: nodeName + required: true + type: string + - description: SSH access parameters + in: body + name: sshRequest + schema: + $ref: '#/definitions/models.SSHAccessRequest' + produces: + - application/json + responses: + "200": + description: SSH connection details + schema: + $ref: '#/definitions/models.SSHAccessResponse' + "400": + description: Invalid request parameters + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.ErrorResponse' + "403": + description: Forbidden (not owner of the lab) + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: Lab or node not found + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Request SSH Access to Lab Node + tags: + - SSH Access /api/v1/labs/{labName}/save: post: description: Saves the running configuration for nodes in a specific lab. Checks @@ -1233,6 +1335,79 @@ paths: summary: Deploy Lab from Archive tags: - Labs + /api/v1/ssh/sessions: + get: + description: Lists active SSH sessions. For regular users, shows only their + sessions. Superusers can see all sessions by using the 'all' query parameter. + parameters: + - description: 'If true and user is superuser, shows sessions for all users + (default: false)' + in: query + name: all + type: boolean + produces: + - application/json + responses: + "200": + description: List of active SSH sessions + schema: + items: + $ref: '#/definitions/models.SSHSessionInfo' + type: array + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.ErrorResponse' + "403": + description: Forbidden (non-superuser attempting to list all sessions) + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: List SSH Sessions + tags: + - SSH Access + /api/v1/ssh/sessions/{port}: + delete: + description: Terminates a specific SSH session by port + parameters: + - description: SSH session port to terminate + in: path + name: port + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Session terminated successfully + schema: + $ref: '#/definitions/models.GenericSuccessResponse' + "400": + description: Invalid port parameter + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.ErrorResponse' + "403": + description: Forbidden (not owner of the session) + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: Session not found + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Terminate SSH Session + tags: + - SSH Access /api/v1/tools/certs/ca: post: consumes: diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 4570896..2ad5f54 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -5,7 +5,7 @@ import ( "archive/tar" "archive/zip" "compress/gzip" - "context" // Import context + "context" "encoding/json" "fmt" "io" @@ -136,7 +136,6 @@ func getUserCertBasePath(username string) (string, error) { return "", fmt.Errorf("could not retrieve user details for '%s'", username) } - // --- NEW PATH: Use user's home directory --- // Store certs under ~/.clab/certs (consistent with potential user clab usage) basePath := filepath.Join(usr.HomeDir, ".clab", "certs") diff --git a/internal/api/lab_handlers.go b/internal/api/lab_handlers.go index fcaee4a..0c889d5 100644 --- a/internal/api/lab_handlers.go +++ b/internal/api/lab_handlers.go @@ -120,19 +120,9 @@ func DeployLabHandler(c *gin.Context) { topoPathForClab = req.TopologySourceUrl // If URL is used, clab determines the name unless overridden. - // We need the override name *now* for the existence check. if labNameOverride != "" { effectiveLabName = labNameOverride } else { - // We cannot reliably determine the name clab will use from the URL beforehand. - // This check might need refinement or we accept that URL deploys without override - // might bypass the pre-check slightly differently. For now, assume override is needed for pre-check with URL. - // OR: We could try a dry-run or inspect after a potential partial fetch? Complex. - // Let's proceed assuming if no override, we cannot pre-check name collision effectively for URL source. - // Clab itself might still fail if the lab exists. - // For the purpose of the requirement "Prevent deployment if lab already exists", - // this path (URL source without override) doesn't fully meet it without more complex logic. - // Let's log a warning and proceed, relying on clab's potential failure later. log.Warnf("DeployLab user '%s': Deploying from URL without labNameOverride. Pre-deployment existence check skipped. Clab will handle potential conflicts.", username) effectiveLabName = "" // Placeholder } @@ -204,7 +194,7 @@ func DeployLabHandler(c *gin.Context) { // Lab does not exist -> Proceed log.Infof("DeployLab user '%s': Lab '%s' does not exist. Proceeding with deployment.", username, effectiveLabName) } - } // End pre-deployment check + } // --- Prepare Base Arguments --- args := []string{"deploy", "--owner", username, "--format", "json"} @@ -1140,7 +1130,6 @@ func ListLabsHandler(c *gin.Context) { } log.Infof("ListLabs user '%s': Found %d labs containing containers owned by the user.", username, len(finalResult)) } - // --- End Filtering --- c.JSON(http.StatusOK, finalResult) // Return the potentially filtered map } @@ -1217,7 +1206,6 @@ func SaveLabConfigHandler(c *gin.Context) { log.Infof("SaveLabConfig user '%s': clab save for lab '%s' executed successfully.", username, labName) - // --- Use the new response model --- c.JSON(http.StatusOK, models.SaveConfigResponse{ Message: fmt.Sprintf("Configuration save command executed successfully for lab '%s'.", labName), Output: stderr, // Include the captured stderr content diff --git a/internal/api/middleware.go b/internal/api/middleware.go index d612ded..f16daee 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/srl-labs/clab-api-server/internal/auth" // Adjust import path + "github.com/srl-labs/clab-api-server/internal/auth" "github.com/srl-labs/clab-api-server/internal/models" ) diff --git a/internal/api/routes.go b/internal/api/routes.go index fa3498b..ad312fb 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -6,10 +6,10 @@ import ( swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" - // Adjust import path if your module path is different _ "github.com/srl-labs/clab-api-server/docs" ) +// SetupRoutes defines all the API endpoints and applies middleware. func SetupRoutes(router *gin.Engine) { // --- Public Routes --- @@ -17,11 +17,12 @@ func SetupRoutes(router *gin.Engine) { router.POST("/login", LoginHandler) // Swagger documentation route + // URL needs to match the basePath in your main swagger annotations router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("/swagger/doc.json"))) // --- Authenticated Routes (/api/v1) --- apiV1 := router.Group("/api/v1") - apiV1.Use(AuthMiddleware()) // Apply JWT authentication middleware + apiV1.Use(AuthMiddleware()) // Apply JWT authentication middleware to all /api/v1 routes { // Lab management routes labs := apiV1.Group("/labs") @@ -29,10 +30,10 @@ func SetupRoutes(router *gin.Engine) { // Deploy new lab (JSON/URL method) labs.POST("", DeployLabHandler) // POST /api/v1/labs - // Deploy new lab (Archive method) - NEW + // Deploy new lab (Archive method) labs.POST("/archive", DeployLabArchiveHandler) // POST /api/v1/labs/archive - // List labs for user + // List labs for user (or all if superuser) labs.GET("", ListLabsHandler) // GET /api/v1/labs // Actions on a specific lab by name @@ -56,12 +57,16 @@ func SetupRoutes(router *gin.Engine) { // Execute Command in Lab labSpecific.POST("/exec", ExecCommandHandler) // POST /api/v1/labs/{labName}/exec - // Netem Routes (nested under node) + // Node Specific Routes (nested under lab) nodeSpecific := labSpecific.Group("/nodes/:nodeName") { + // Request SSH Access to a specific node + nodeSpecific.POST("/ssh", RequestSSHAccessHandler) // POST /api/v1/labs/{labName}/nodes/{nodeName}/ssh + // Show netem for all interfaces on node nodeSpecific.GET("/netem", ShowNetemHandler) // GET /api/v1/labs/{labName}/nodes/{nodeName}/netem + // Interface Specific Routes (nested under node) interfaceSpecific := nodeSpecific.Group("/interfaces/:interfaceName") { // Set netem for specific interface @@ -73,10 +78,20 @@ func SetupRoutes(router *gin.Engine) { } } - // Topology Generation Route --- + // SSH Session Management Routes (Global) + ssh := apiV1.Group("/ssh") + { + // List active SSH sessions for the user (or all if superuser) + ssh.GET("/sessions", ListSSHSessionsHandler) // GET /api/v1/ssh/sessions + + // Terminate a specific SSH session by port + ssh.DELETE("/sessions/:port", TerminateSSHSessionHandler) // DELETE /api/v1/ssh/sessions/{port} + } + + // Topology Generation Route apiV1.POST("/generate", GenerateTopologyHandler) // POST /api/v1/generate - // Tools Routes (Top Level, mostly Superuser) + // Tools Routes (Mostly Superuser) tools := apiV1.Group("/tools") { // Disable TX Offload (Superuser Only) @@ -87,20 +102,26 @@ func SetupRoutes(router *gin.Engine) { { certs.POST("/ca", CreateCAHandler) // POST /api/v1/tools/certs/ca certs.POST("/sign", SignCertHandler) // POST /api/v1/tools/certs/sign - } + } // End /certs group + // vEth Tools (Superuser Only) tools.POST("/veth", CreateVethHandler) // POST /api/v1/tools/veth // VxLAN Tools (Superuser Only) - tools.POST("/vxlan", CreateVxlanHandler) // POST /api/v1/tools/vxlan - tools.DELETE("/vxlan", DeleteVxlanHandler) // DELETE + vxlan := tools.Group("/vxlan") + { + vxlan.POST("", CreateVxlanHandler) // POST /api/v1/tools/vxlan + vxlan.DELETE("", DeleteVxlanHandler) // DELETE /api/v1/tools/vxlan + } + } - // --- NEW: Version Info Routes --- + // Version Info Routes version := apiV1.Group("/version") { version.GET("", GetVersionHandler) // GET /api/v1/version version.GET("/check", CheckVersionHandler) // GET /api/v1/version/check } + } } diff --git a/internal/api/ssh_handlers.go b/internal/api/ssh_handlers.go new file mode 100644 index 0000000..ceb6efb --- /dev/null +++ b/internal/api/ssh_handlers.go @@ -0,0 +1,295 @@ +// internal/api/ssh_handlers.go +package api + +import ( + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/gin-gonic/gin" + + "github.com/srl-labs/clab-api-server/internal/config" + "github.com/srl-labs/clab-api-server/internal/models" + "github.com/srl-labs/clab-api-server/internal/ssh" +) + +// Global SSH manager instance +var sshManager *ssh.SSHManager + +// InitSSHManager initializes the SSH manager +func InitSSHManager() { + sshManager = ssh.NewSSHManager( + config.AppConfig.SSHBasePort, + config.AppConfig.SSHMaxPort, + ssh.DefaultSSHCleanupTick, + ssh.DefaultSSHSessionTimeout, + ) +} + +// ShutdownSSHManager gracefully shuts down the SSH manager +func ShutdownSSHManager() { + if sshManager != nil { + sshManager.Shutdown() + } +} + +// @Summary Request SSH Access to Lab Node +// @Description Creates temporary SSH access to a specific lab node, returning connection details +// @Tags SSH Access +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param labName path string true "Lab name" example="my-lab" +// @Param nodeName path string true "Full container name of the node (e.g., clab-my-lab-srl1)" example="clab-my-lab-srl1" +// @Param sshRequest body models.SSHAccessRequest false "SSH access parameters" +// @Success 200 {object} models.SSHAccessResponse "SSH connection details" +// @Failure 400 {object} models.ErrorResponse "Invalid request parameters" +// @Failure 401 {object} models.ErrorResponse "Unauthorized" +// @Failure 403 {object} models.ErrorResponse "Forbidden (not owner of the lab)" +// @Failure 404 {object} models.ErrorResponse "Lab or node not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/labs/{labName}/nodes/{nodeName}/ssh [post] +func RequestSSHAccessHandler(c *gin.Context) { + username := c.GetString("username") + labName := c.Param("labName") + containerName := c.Param("nodeName") + + // Validate inputs + if !isValidLabName(labName) { + log.Warnf("SSH Access failed for user '%s': Invalid lab name '%s'", username, labName) + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid lab name format."}) + return + } + + if !isValidContainerName(containerName) { // Changed from isValidNodeName to isValidContainerName + log.Warnf("SSH Access failed for user '%s': Invalid container name '%s'", username, containerName) + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid container name format."}) + return + } + + // Parse request + var req models.SSHAccessRequest + if err := c.ShouldBindJSON(&req); err != nil { + // Empty request is fine, we'll use defaults + if err != io.EOF { + log.Warnf("SSH Access failed for user '%s': Invalid request body: %v", username, err) + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request format: " + err.Error()}) + return + } + } + + // Set defaults and validate request parameters + sshUsername := req.SSHUsername + if sshUsername == "" { + sshUsername = "admin" // Most network devices use admin or root + } + + duration := ssh.DefaultSSHSessionTimeout + if req.Duration != "" { + var err error + duration, err = time.ParseDuration(req.Duration) + if err != nil { + log.Warnf("SSH Access failed for user '%s': Invalid duration '%s'", username, req.Duration) + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid duration format. Use values like '1h', '30m'."}) + return + } + + if duration > ssh.MaxSSHSessionDuration { + duration = ssh.MaxSSHSessionDuration + } + } + + // Verify lab ownership + _, err := verifyLabOwnership(c, username, labName) + if err != nil { + // verifyLabOwnership already sent response + return + } + + // Get container details - now using the full container name directly + containerInfo, err := verifyContainerOwnership(c, username, containerName) + if err != nil { + // verifyContainerOwnership already sent response + return + } + + // Extract the node name from the container name for session tracking + // Expected format: clab-- + nodeName := containerName + prefix := "clab-" + labName + "-" + if strings.HasPrefix(containerName, prefix) { + nodeName = strings.TrimPrefix(containerName, prefix) + } + + // Extract container IP for SSH access + containerIP := containerInfo.IPv4Address + if containerIP == "" { + log.Warnf("SSH Access failed for user '%s': Container '%s' has no IPv4 address", username, containerName) + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Container has no IPv4 address for SSH access."}) + return + } + + // Extract IP without CIDR notation if present + if strings.Contains(containerIP, "/") { + containerIP = strings.Split(containerIP, "/")[0] + } + + // Standard SSH port is 22, but some containers might use different ports + // This could be made configurable per node type + containerPort := 22 + + // Create SSH session + session, err := sshManager.CreateSession( + username, + labName, + nodeName, // Using the extracted nodeName for session data + sshUsername, + containerIP, + containerPort, + duration, + ) + + if err != nil { + log.Errorf("SSH Access failed for user '%s': Failed to create SSH session: %v", username, err) + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to create SSH access: " + err.Error()}) + return + } + + // Get API server host + apiServerHost := getAPIServerHost(c.Request) + + // Build response + response := models.SSHAccessResponse{ + Port: session.Port, + Host: apiServerHost, + Username: sshUsername, + Expiration: session.Expiration, + Command: fmt.Sprintf("ssh -p %d %s@%s", session.Port, sshUsername, apiServerHost), + } + + log.Infof("SSH access granted for user '%s' to lab '%s', node '%s' on port %d until %s", + username, labName, nodeName, session.Port, session.Expiration.Format(time.RFC3339)) + + c.JSON(http.StatusOK, response) +} + +// @Summary List SSH Sessions +// @Description Lists active SSH sessions. For regular users, shows only their sessions. Superusers can see all sessions by using the 'all' query parameter. +// @Tags SSH Access +// @Security BearerAuth +// @Produce json +// @Param all query boolean false "If true and user is superuser, shows sessions for all users (default: false)" example="true" +// @Success 200 {array} models.SSHSessionInfo "List of active SSH sessions" +// @Failure 401 {object} models.ErrorResponse "Unauthorized" +// @Failure 403 {object} models.ErrorResponse "Forbidden (non-superuser attempting to list all sessions)" +// @Router /api/v1/ssh/sessions [get] +func ListSSHSessionsHandler(c *gin.Context) { + username := c.GetString("username") + userIsSuperuser := isSuperuser(username) + + // Parse the 'all' query parameter (defaults to false) + showAllSessions := c.Query("all") == "true" + + // Only allow superusers to see all sessions + if showAllSessions && !userIsSuperuser { + log.Warnf("User '%s' attempted to list all SSH sessions without superuser privileges", username) + c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "Superuser privileges required to list all SSH sessions"}) + return + } + + // When calling ListSessions: + // - For regular users: always false (see only their sessions) + // - For superusers: depends on 'all' parameter (true = all sessions, false = only their sessions) + sessions := sshManager.ListSessions(username, showAllSessions && userIsSuperuser) + + c.JSON(http.StatusOK, sessions) +} + +// @Summary Terminate SSH Session +// @Description Terminates a specific SSH session by port +// @Tags SSH Access +// @Security BearerAuth +// @Produce json +// @Param port path int true "SSH session port to terminate" example="2222" +// @Success 200 {object} models.GenericSuccessResponse "Session terminated successfully" +// @Failure 400 {object} models.ErrorResponse "Invalid port parameter" +// @Failure 401 {object} models.ErrorResponse "Unauthorized" +// @Failure 403 {object} models.ErrorResponse "Forbidden (not owner of the session)" +// @Failure 404 {object} models.ErrorResponse "Session not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/ssh/sessions/{port} [delete] +func TerminateSSHSessionHandler(c *gin.Context) { + username := c.GetString("username") + + port, err := strconv.Atoi(c.Param("port")) + if err != nil { + log.Warnf("Terminate SSH session failed for user '%s': Invalid port '%s'", username, c.Param("port")) + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid port parameter."}) + return + } + + // Get session + session, exists := sshManager.GetSession(port) + if !exists { + log.Warnf("Terminate SSH session failed for user '%s': Session on port %d not found", username, port) + c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "SSH session not found."}) + return + } + + // Check ownership + if session.ApiUsername != username && !isSuperuser(username) { + log.Warnf("Terminate SSH session failed for user '%s': Attempted to terminate session owned by '%s'", + username, session.ApiUsername) + c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "You don't have permission to terminate this SSH session."}) + return + } + + // Terminate session + err = sshManager.TerminateSession(port) + if err != nil { + log.Errorf("Terminate SSH session failed for user '%s', port %d: %v", username, port, err) + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to terminate SSH session: " + err.Error()}) + return + } + + log.Infof("SSH session on port %d terminated by user '%s'", port, username) + c.JSON(http.StatusOK, models.GenericSuccessResponse{Message: "SSH session terminated successfully."}) +} + +// Helper function to get API server host, respecting config, proxies and headers +func getAPIServerHost(r *http.Request) string { + // First check if API_SERVER_HOST is explicitly configured + if config.AppConfig.APIServerHost != "" { + return config.AppConfig.APIServerHost + } + + // Try X-Forwarded-Host header first (common with proxies) + forwardedHost := r.Header.Get("X-Forwarded-Host") + if forwardedHost != "" { + return forwardedHost + } + + // Fall back to Host header + host := r.Host + + // If Host includes a port and we're using HTTP/HTTPS standard ports, remove it + if strings.Contains(host, ":") { + // Remove port if it's a standard port + hostParts := strings.Split(host, ":") + if len(hostParts) == 2 { + if r.TLS != nil && hostParts[1] == "443" { + // HTTPS on standard port + return hostParts[0] + } else if r.TLS == nil && hostParts[1] == "80" { + // HTTP on standard port + return hostParts[0] + } + } + } + + return host +} diff --git a/internal/api/tools_handlers.go b/internal/api/tools_handlers.go index d2b5fa4..b2d1f55 100644 --- a/internal/api/tools_handlers.go +++ b/internal/api/tools_handlers.go @@ -6,9 +6,9 @@ import ( "fmt" "net/http" "os" - "os/user" // <-- Ensure os/user is imported + "os/user" "path/filepath" - "strconv" // <-- Ensure strconv is imported + "strconv" "strings" "github.com/charmbracelet/log" @@ -16,8 +16,6 @@ import ( "github.com/srl-labs/clab-api-server/internal/clab" "github.com/srl-labs/clab-api-server/internal/models" - // "github.com/srl-labs/clab-api-server/internal/auth" // Already imported via helpers - // "github.com/srl-labs/clab-api-server/internal/config" // Already imported via helpers ) // --- TX Offload Handler --- @@ -59,8 +57,6 @@ func DisableTxOffloadHandler(c *gin.Context) { return } - // Optional: Verify container exists (using the less efficient inspect --all method for now) - // If performance is critical, direct runtime calls might be better. _, err := verifyContainerOwnership(c, username, req.ContainerName) // We check ownership even for superuser to ensure container exists if err != nil { // verifyContainerOwnership already sent the response (404 or 500) @@ -141,8 +137,6 @@ func CreateCAHandler(c *gin.Context) { return } - // Other fields use clab defaults if empty - // --- Path Handling & Ownership Setup --- basePath, err := getUserCertBasePath(username) // Get ~/.clab/certs path if err != nil { diff --git a/internal/api/topology_handlers.go b/internal/api/topology_handlers.go index 6dd0d57..99ada8c 100644 --- a/internal/api/topology_handlers.go +++ b/internal/api/topology_handlers.go @@ -6,7 +6,7 @@ import ( "fmt" "net/http" "os" - "os/user" // Import os/user + "os/user" "path/filepath" "strconv" "strings" @@ -61,7 +61,6 @@ func GenerateTopologyHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "'images' field is required."}) return } - // Add more validation for tier contents, image/license formats if needed log.Debugf("GenerateTopology user '%s': Generating topology '%s' (deploy=%t)", username, req.Name, req.Deploy) @@ -98,7 +97,6 @@ func GenerateTopologyHandler(c *gin.Context) { nodeStr += ":" + tier.Type } } else if tier.Type != "" { - // If kind is empty but type is not, clab might need kind explicitly. Assume default. defaultKind := req.DefaultKind if defaultKind == "" { defaultKind = "srl" // clab's default @@ -121,8 +119,6 @@ func GenerateTopologyHandler(c *gin.Context) { if len(req.Licenses) > 0 { var licArgs []string for kind, lic := range req.Licenses { - // Security: Ensure license path is somewhat sane? Difficult without knowing server layout. - // Rely on clab's own handling for now. Basic sanitization might be good. cleanLic, licErr := clab.SanitizePath(lic) // Apply basic sanitization if licErr != nil { log.Warnf("GenerateTopology failed for user '%s': Invalid license path '%s': %v", username, lic, licErr) @@ -148,8 +144,6 @@ func GenerateTopologyHandler(c *gin.Context) { if req.IPv6Subnet != "" { args = append(args, "--ipv6-subnet", req.IPv6Subnet) } - // MaxWorkers is handled during deploy step if needed - // Deploy and OutputFile flags are handled specially below // --- Determine Output/Action and Target File Path --- var targetFilePath string // Path used by clab generate --file and clab deploy -t @@ -199,8 +193,6 @@ func GenerateTopologyHandler(c *gin.Context) { if err != nil { // Log error but continue, maybe file write will succeed anyway if API user has perms log.Warnf("GenerateTopology user '%s': Failed to set ownership on lab directory '%s': %v. Continuing...", username, targetDir, err) - // c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: fmt.Sprintf("Failed to set ownership on lab directory: %s.", err.Error())}) - // return } log.Infof("GenerateTopology user '%s': Ensured directory '%s' exists and attempted ownership set.", username, targetDir) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 34a7844..c8f69e0 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -5,7 +5,7 @@ import ( "time" "github.com/golang-jwt/jwt/v5" - "github.com/srl-labs/clab-api-server/internal/config" // Adjust import path + "github.com/srl-labs/clab-api-server/internal/config" ) type Claims struct { diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index 27c2b98..47727ba 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -8,7 +8,7 @@ import ( "github.com/charmbracelet/log" "github.com/msteinert/pam" - "github.com/srl-labs/clab-api-server/internal/config" // Ensure config is imported + "github.com/srl-labs/clab-api-server/internal/config" ) // Define the primary required group name as a constant @@ -111,10 +111,6 @@ func ValidateCredentials(username, password string) (bool, error) { err = t.AcctMgmt(0) if err != nil { log.Warnf("PAM account management check failed for user '%s' (but login allowed as Authenticate and Group Check passed): %v", username, err) - // Decide if this should prevent login. For now, treat it as a warning - // and allow login if Authenticate and Group Check succeeded. - // You might return false here for stricter checks: - // return false, fmt.Errorf("PAM account validation failed: %w", err) } // 5. Success: Authenticated AND in one of the required login groups diff --git a/internal/clab/executor.go b/internal/clab/executor.go index d071753..a00cad1 100644 --- a/internal/clab/executor.go +++ b/internal/clab/executor.go @@ -19,7 +19,6 @@ const defaultTimeout = 5 * time.Minute // Timeout for clab commands // RunClabCommand executes a clab command directly as the user running the API server. func RunClabCommand(ctx context.Context, username string, args ...string) (stdout string, stderr string, err error) { - // Add timeout to context if not already present if _, hasDeadline := ctx.Deadline(); !hasDeadline { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, defaultTimeout) diff --git a/internal/config/config.go b/internal/config/config.go index 4995dec..e16f9b3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,9 @@ type Config struct { TLSKeyFile string `mapstructure:"TLS_KEY_FILE"` GinMode string `mapstructure:"GIN_MODE"` TrustedProxies string `mapstructure:"TRUSTED_PROXIES"` + APIServerHost string `mapstructure:"API_SERVER_HOST"` // Host/IP/FQDN used for SSH access commands + SSHBasePort int `mapstructure:"SSH_BASE_PORT"` // Base port for SSH proxy allocation + SSHMaxPort int `mapstructure:"SSH_MAX_PORT"` // Maximum port for SSH proxy allocation } var AppConfig Config @@ -44,6 +47,9 @@ func LoadConfig(envFilePath string) error { viper.SetDefault("TLS_KEY_FILE", "") viper.SetDefault("GIN_MODE", "debug") viper.SetDefault("TRUSTED_PROXIES", "") + viper.SetDefault("API_SERVER_HOST", "") + viper.SetDefault("SSH_BASE_PORT", 2222) // Default base port for SSH proxy + viper.SetDefault("SSH_MAX_PORT", 2322) // Default max port for SSH proxy (allows 100 sessions) err := viper.ReadInConfig() diff --git a/internal/models/ssh_models.go b/internal/models/ssh_models.go new file mode 100644 index 0000000..6a65686 --- /dev/null +++ b/internal/models/ssh_models.go @@ -0,0 +1,29 @@ +// internal/models/ssh_models.go +package models + +import "time" + +// SSHAccessRequest represents the payload for requesting SSH access to a node +type SSHAccessRequest struct { + SSHUsername string `json:"sshUsername,omitempty"` // Optional override for container's SSH user + Duration string `json:"duration,omitempty"` // How long the access should be valid for (e.g., "1h", "30m") +} + +// SSHAccessResponse represents the response with SSH connection details +type SSHAccessResponse struct { + Port int `json:"port"` // Allocated port on API server + Host string `json:"host"` // API server's hostname or IP + Username string `json:"username"` // Username to use for SSH + Expiration time.Time `json:"expiration"` // When this access expires + Command string `json:"command"` // Example SSH command +} + +// SSHSessionInfo represents information about an active SSH session +type SSHSessionInfo struct { + Port int `json:"port"` // Allocated port on API server + LabName string `json:"labName"` // Lab name + NodeName string `json:"nodeName"` // Node name + Username string `json:"username"` // SSH username + Expiration time.Time `json:"expiration"` // When this access expires + Created time.Time `json:"created"` // When this access was created +} diff --git a/internal/ssh/manager.go b/internal/ssh/manager.go new file mode 100644 index 0000000..178e021 --- /dev/null +++ b/internal/ssh/manager.go @@ -0,0 +1,383 @@ +// internal/ssh/manager.go +package ssh + +import ( + "context" + "fmt" + "io" + "net" + "os/exec" + "sync" + "time" + + "github.com/charmbracelet/log" + "github.com/srl-labs/clab-api-server/internal/models" +) + +// Configuration constants for SSH proxy service +const ( + DefaultSSHBasePort = 2222 // Starting port for SSH proxy allocation + DefaultSSHMaxPort = 2322 // Maximum port (allows 100 concurrent sessions) + DefaultSSHCleanupTick = time.Minute // Cleanup interval for expired sessions + DefaultSSHSessionTimeout = time.Hour // Default session duration if not specified + MaxSSHSessionDuration = 24 * time.Hour // Maximum allowed session duration +) + +// SSHSession represents an active SSH proxy session +type SSHSession struct { + Port int // Allocated port on API server + LabName string // Name of the lab + NodeName string // Name of the node within the lab + Username string // SSH username to use + ApiUsername string // API user who created this session + Expiration time.Time // When this session expires + Created time.Time // When this session was created + cmd *exec.Cmd // Proxy process + cmdCancel func() // Function to cancel the proxy process +} + +// SSHManager handles SSH session creation, management, and cleanup +type SSHManager struct { + mu sync.Mutex + sessions map[int]*SSHSession + basePort int + maxPort int + cleanupTick time.Duration + defaultDuration time.Duration + shutdownCh chan struct{} +} + +// NewSSHManager creates a new SSH manager with the given configuration +func NewSSHManager(basePort, maxPort int, cleanupTick, defaultDuration time.Duration) *SSHManager { + if basePort <= 0 { + basePort = DefaultSSHBasePort + } + if maxPort <= basePort { + maxPort = basePort + 100 // Ensure at least some ports are available + } + if cleanupTick <= 0 { + cleanupTick = DefaultSSHCleanupTick + } + if defaultDuration <= 0 { + defaultDuration = DefaultSSHSessionTimeout + } + + m := &SSHManager{ + sessions: make(map[int]*SSHSession), + basePort: basePort, + maxPort: maxPort, + cleanupTick: cleanupTick, + defaultDuration: defaultDuration, + shutdownCh: make(chan struct{}), + } + + // Start background cleanup + go m.cleanupRoutine() + + log.Info("SSH Manager initialized", + "basePort", basePort, + "maxPort", maxPort, + "cleanupInterval", cleanupTick.String(), + "defaultDuration", defaultDuration.String()) + + return m +} + +// CreateSession creates a new SSH proxy session for the specified lab node +func (m *SSHManager) CreateSession(apiUsername, labName, nodeName, sshUsername string, + containerIP string, containerPort int, duration time.Duration) (*SSHSession, error) { + + if duration > MaxSSHSessionDuration { + duration = MaxSSHSessionDuration + } + + m.mu.Lock() + defer m.mu.Unlock() + + // Allocate port + port, err := m.allocatePort() + if err != nil { + return nil, fmt.Errorf("failed to allocate port: %w", err) + } + + // Create session + now := time.Now() + session := &SSHSession{ + Port: port, + LabName: labName, + NodeName: nodeName, + Username: sshUsername, + ApiUsername: apiUsername, + Created: now, + Expiration: now.Add(duration), + } + + // Start proxy + err = m.startProxy(session, containerIP, containerPort) + if err != nil { + // Free the port if proxy fails to start + m.sessions[port] = nil + return nil, fmt.Errorf("failed to start SSH proxy: %w", err) + } + + // Store session + m.sessions[port] = session + + log.Info("SSH session created", + "user", apiUsername, + "lab", labName, + "node", nodeName, + "port", port, + "expiration", session.Expiration.Format(time.RFC3339)) + + return session, nil +} + +// GetSession retrieves a session by port +func (m *SSHManager) GetSession(port int) (*SSHSession, bool) { + m.mu.Lock() + defer m.mu.Unlock() + + session, exists := m.sessions[port] + if !exists || session == nil { + return nil, false + } + return session, true +} + +// ListSessions returns all sessions for the specified user +// If isSuperuser is true, all sessions are returned +func (m *SSHManager) ListSessions(username string, isSuperuser bool) []models.SSHSessionInfo { + m.mu.Lock() + defer m.mu.Unlock() + + // Initialize as an empty slice, NOT a nil slice + result := make([]models.SSHSessionInfo, 0) // <--- CHANGE THIS LINE + + // Add logging to see map content (optional, for debugging) + log.Debugf("Listing SSH sessions. Current map size: %d", len(m.sessions)) + + for port, session := range m.sessions { + if session == nil { + log.Debugf("Skipping nil session entry for port %d", port) + continue + } + + // Add logging for filtering logic (optional, for debugging) + log.Debugf("Checking session on port %d: ApiUsername=%s, targetUsername=%s, isSuperuser=%t", + port, session.ApiUsername, username, isSuperuser) + + if isSuperuser || session.ApiUsername == username { + result = append(result, models.SSHSessionInfo{ + Port: session.Port, + LabName: session.LabName, + NodeName: session.NodeName, + Username: session.Username, + Expiration: session.Expiration, + Created: session.Created, + }) + log.Debugf("Added session on port %d to results.", port) + } + } + + // Add logging to see the final result before returning (optional, for debugging) + log.Debugf("Returning %d SSH sessions.", len(result)) + + return result +} + +// TerminateSession terminates a specific SSH session +func (m *SSHManager) TerminateSession(port int) error { + m.mu.Lock() + defer m.mu.Unlock() + + session, exists := m.sessions[port] + if !exists || session == nil { + return fmt.Errorf("session not found") + } + + // Terminate proxy + if session.cmdCancel != nil { + session.cmdCancel() + } + + // Remove session + delete(m.sessions, port) + + log.Info("SSH session terminated", "port", port, "lab", session.LabName, "node", session.NodeName) + return nil +} + +// Shutdown terminates all sessions and stops the manager +func (m *SSHManager) Shutdown() { + close(m.shutdownCh) + + // Terminate all sessions + m.mu.Lock() + defer m.mu.Unlock() + + for port, session := range m.sessions { + if session != nil && session.cmdCancel != nil { + session.cmdCancel() + } + delete(m.sessions, port) + } + + log.Info("SSH Manager shutdown complete") +} + +// allocatePort finds and allocates an available port +func (m *SSHManager) allocatePort() (int, error) { + // Try each port in the range + for port := m.basePort; port <= m.maxPort; port++ { + // Skip if port is already in use by another session + if _, exists := m.sessions[port]; exists { + continue + } + + // Test if port is available on the system + addr := fmt.Sprintf(":%d", port) + ln, err := net.Listen("tcp", addr) + if err != nil { + // Port is in use by another process + continue + } + ln.Close() + + // Reserve this port by creating a nil entry + m.sessions[port] = nil + return port, nil + } + + return 0, fmt.Errorf("no available ports in range %d-%d", m.basePort, m.maxPort) +} + +// startProxy starts a proxy process for the given session +func (m *SSHManager) startProxy(session *SSHSession, containerIP string, containerPort int) error { + // We'll use a TCP proxy in Go instead of relying on external tools like socat + go func() { + localAddr := fmt.Sprintf("0.0.0.0:%d", session.Port) + remoteAddr := fmt.Sprintf("%s:%d", containerIP, containerPort) + + log.Debug("Starting TCP proxy", + "localAddr", localAddr, + "remoteAddr", remoteAddr, + "session", session.Port) + + listener, err := net.Listen("tcp", localAddr) + if err != nil { + log.Error("Failed to listen on proxy port", + "port", session.Port, + "error", err) + return + } + defer listener.Close() + + // Create a context with cancel function for termination + ctx, cancel := context.WithCancel(context.Background()) + session.cmdCancel = cancel + + // Run the proxy until canceled or expired + go func() { + select { + case <-ctx.Done(): + listener.Close() + case <-time.After(session.Expiration.Sub(time.Now())): + // Session expired + listener.Close() + m.TerminateSession(session.Port) + } + }() + + for { + client, err := listener.Accept() + if err != nil { + // Check if this is due to listener being closed + if ne, ok := err.(net.Error); ok && ne.Temporary() { + continue + } + return + } + + go handleConnection(client, remoteAddr) + } + }() + + return nil +} + +// handleConnection manages a single proxied connection +func handleConnection(client net.Conn, remoteAddr string) { + defer client.Close() + + remote, err := net.Dial("tcp", remoteAddr) + if err != nil { + log.Error("Failed to connect to remote address", + "remoteAddr", remoteAddr, + "error", err) + return + } + defer remote.Close() + + // Copy bidirectionally + errCh := make(chan error, 2) + + // client -> remote + go func() { + _, err := io.Copy(remote, client) + errCh <- err + }() + + // remote -> client + go func() { + _, err := io.Copy(client, remote) + errCh <- err + }() + + // Wait for either direction to finish + <-errCh +} + +// cleanupRoutine periodically removes expired sessions +func (m *SSHManager) cleanupRoutine() { + ticker := time.NewTicker(m.cleanupTick) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + m.cleanupExpiredSessions() + case <-m.shutdownCh: + return + } + } +} + +// cleanupExpiredSessions removes all expired sessions +func (m *SSHManager) cleanupExpiredSessions() { + m.mu.Lock() + defer m.mu.Unlock() + + now := time.Now() + for port, session := range m.sessions { + if session == nil { + // This is a reserved port without a session, clean it up + delete(m.sessions, port) + continue + } + + if now.After(session.Expiration) { + // Terminate the proxy + if session.cmdCancel != nil { + session.cmdCancel() + } + + // Remove the session + delete(m.sessions, port) + log.Info("Expired SSH session cleaned up", + "port", port, + "lab", session.LabName, + "node", session.NodeName) + } + } +}