Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ---
Expand All @@ -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.

---

Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
172 changes: 88 additions & 84 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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!")
}
Expand All @@ -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 {
Expand All @@ -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),
Expand All @@ -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.")
}
Loading