diff --git a/.vscode/launch.json b/.vscode/launch.json index 12a8525..5422f27 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,6 +14,7 @@ "GO_ENV": "development", "LLAMACTL_CONFIG_PATH": "${workspaceFolder}/llamactl.dev.yaml" }, + "console": "integratedTerminal", } ] } \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 0eeca45..579b924 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "llamactl/pkg/config" "llamactl/pkg/database" @@ -11,6 +12,7 @@ import ( "os" "os/signal" "syscall" + "time" ) // version is set at build time using -ldflags "-X main.version=1.0.0" @@ -48,7 +50,7 @@ func main() { cfg.CommitHash = commitHash cfg.BuildTime = buildTime - // Create the data directory if it doesn't exist + // Create data directory if it doesn't exist if cfg.Instances.AutoCreateDirs { // Create the main data directory if err := os.MkdirAll(cfg.DataDir, 0755); err != nil { @@ -91,7 +93,7 @@ func main() { instanceManager := manager.New(&cfg, db) // Create a new handler with the instance manager - handler := server.NewHandler(instanceManager, cfg) + handler := server.NewHandler(instanceManager, cfg, db) // Setup the router with the handler r := server.SetupRouter(handler) @@ -116,14 +118,23 @@ func main() { <-stop fmt.Println("Shutting down server...") - if err := server.Close(); err != nil { + // Create shutdown context with timeout + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + // Shutdown HTTP server gracefully + if err := server.Shutdown(shutdownCtx); err != nil { log.Printf("Error shutting down server: %v\n", err) } else { fmt.Println("Server shut down gracefully.") } - // Wait for all instances to stop + // Stop all instances and cleanup instanceManager.Shutdown() + if err := db.Close(); err != nil { + log.Printf("Error closing database: %v\n", err) + } + fmt.Println("Exiting llamactl.") } diff --git a/cmd/server/migrate_json.go b/cmd/server/migrate_json.go index 7ee6a2b..b35a40c 100644 --- a/cmd/server/migrate_json.go +++ b/cmd/server/migrate_json.go @@ -13,7 +13,8 @@ import ( // migrateFromJSON migrates instances from JSON files to SQLite database // This is a one-time migration that runs on first startup with existing JSON files. -func migrateFromJSON(cfg *config.AppConfig, db database.DB) error { +// Migrated files are moved to a migrated subdirectory to avoid re-importing. +func migrateFromJSON(cfg *config.AppConfig, db database.InstanceStore) error { instancesDir := cfg.Instances.InstancesDir if instancesDir == "" { return nil // No instances directory configured @@ -24,16 +25,6 @@ func migrateFromJSON(cfg *config.AppConfig, db database.DB) error { return nil // No instances directory, nothing to migrate } - // Check if database is empty (no instances) - existing, err := db.LoadAll() - if err != nil { - return fmt.Errorf("failed to check existing instances: %w", err) - } - - if len(existing) > 0 { - return nil // Database already has instances, skip migration - } - // Find all JSON files files, err := filepath.Glob(filepath.Join(instancesDir, "*.json")) if err != nil { @@ -46,6 +37,12 @@ func migrateFromJSON(cfg *config.AppConfig, db database.DB) error { log.Printf("Migrating %d instances from JSON to SQLite...", len(files)) + // Create migrated directory + migratedDir := filepath.Join(instancesDir, "migrated") + if err := os.MkdirAll(migratedDir, 0755); err != nil { + return fmt.Errorf("failed to create migrated directory: %w", err) + } + // Migrate each JSON file var migrated int for _, file := range files { @@ -53,30 +50,24 @@ func migrateFromJSON(cfg *config.AppConfig, db database.DB) error { log.Printf("Failed to migrate %s: %v", file, err) continue } + + // Move the file to the migrated directory + destPath := filepath.Join(migratedDir, filepath.Base(file)) + if err := os.Rename(file, destPath); err != nil { + log.Printf("Warning: Failed to move %s to migrated directory: %v", file, err) + // Don't fail the migration if we can't move the file + } + migrated++ } log.Printf("Successfully migrated %d/%d instances to SQLite", migrated, len(files)) - // Archive old JSON files - if migrated > 0 { - archiveDir := filepath.Join(instancesDir, "json_archive") - if err := os.MkdirAll(archiveDir, 0755); err == nil { - for _, file := range files { - newPath := filepath.Join(archiveDir, filepath.Base(file)) - if err := os.Rename(file, newPath); err != nil { - log.Printf("Failed to archive %s: %v", file, err) - } - } - log.Printf("Archived old JSON files to %s", archiveDir) - } - } - return nil } // migrateJSONFile migrates a single JSON file to the database -func migrateJSONFile(filename string, db database.DB) error { +func migrateJSONFile(filename string, db database.InstanceStore) error { data, err := os.ReadFile(filename) if err != nil { return fmt.Errorf("failed to read file: %w", err) diff --git a/docs/docs.go b/docs/docs.go index 8d6a8f1..4a9bce6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -19,6 +19,235 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/v1/auth/keys": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all API keys for the system user (excludes key hash and plain-text key)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "List all API keys", + "responses": { + "200": { + "description": "List of API keys", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/server.KeyResponse" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "description": "Creates a new API key with the specified permissions and returns the plain-text key (only shown once)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Create a new API key", + "parameters": [ + { + "description": "API key configuration", + "name": "key", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.CreateKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created API key with plain-text key", + "schema": { + "$ref": "#/definitions/server.CreateKeyResponse" + } + }, + "400": { + "description": "Invalid request body or validation error", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/keys/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns details for a specific API key by ID (excludes key hash and plain-text key)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Get details of a specific API key", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "API key details", + "schema": { + "$ref": "#/definitions/server.KeyResponse" + } + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes an API key by ID", + "tags": [ + "Keys" + ], + "summary": "Delete an API key", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "API key deleted successfully" + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/keys/{id}/permissions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the instance-level permissions for a specific API key (includes instance names)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Get API key permissions", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "List of key permissions", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/server.KeyPermissionResponse" + } + } + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/backends/llama-cpp/devices": { "get": { "security": [ @@ -1503,6 +1732,17 @@ const docTemplate = `{ } }, "definitions": { + "auth.PermissionMode": { + "type": "string", + "enum": [ + "allow_all", + "per_instance" + ], + "x-enum-varnames": [ + "PermissionModeAllowAll", + "PermissionModePerInstance" + ] + }, "config.AppConfig": { "type": "object", "properties": { @@ -1518,6 +1758,13 @@ const docTemplate = `{ "commit_hash": { "type": "string" }, + "data_dir": { + "description": "Directory where all llamactl data will be stored (database, instances, logs, etc.)", + "type": "string" + }, + "database": { + "$ref": "#/definitions/config.DatabaseConfig" + }, "instances": { "$ref": "#/definitions/config.InstancesConfig" }, @@ -1608,6 +1855,26 @@ const docTemplate = `{ } } }, + "config.DatabaseConfig": { + "type": "object", + "properties": { + "connection_max_lifetime": { + "type": "string", + "example": "1h" + }, + "max_idle_connections": { + "type": "integer" + }, + "max_open_connections": { + "description": "Connection settings", + "type": "integer" + }, + "path": { + "description": "Database file path (relative to the top-level data_dir or absolute)", + "type": "string" + } + } + }, "config.DockerSettings": { "type": "object", "properties": { @@ -1639,11 +1906,7 @@ const docTemplate = `{ "type": "boolean" }, "configs_dir": { - "description": "Instance config directory override", - "type": "string" - }, - "data_dir": { - "description": "Directory where all llamactl data will be stored (instances.json, logs, etc.)", + "description": "Instance config directory override (relative to data_dir if not absolute)", "type": "string" }, "default_auto_restart": { @@ -1667,7 +1930,7 @@ const docTemplate = `{ "type": "boolean" }, "logs_dir": { - "description": "Logs directory override", + "description": "Logs directory override (relative to data_dir if not absolute)", "type": "string" }, "max_instances": { @@ -1748,7 +2011,10 @@ const docTemplate = `{ "type": "object", "properties": { "created": { - "description": "Unix timestamp when the instance was created", + "description": "Unix timestamp when instance was created", + "type": "integer" + }, + "id": { "type": "integer" }, "name": { @@ -1794,6 +2060,125 @@ const docTemplate = `{ } } }, + "server.CreateKeyRequest": { + "type": "object", + "properties": { + "expiresAt": { + "type": "integer", + "format": "int64" + }, + "instancePermissions": { + "type": "array", + "items": { + "$ref": "#/definitions/server.InstancePermission" + } + }, + "name": { + "type": "string" + }, + "permissionMode": { + "$ref": "#/definitions/auth.PermissionMode" + } + } + }, + "server.CreateKeyResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "last_used_at": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permission_mode": { + "$ref": "#/definitions/auth.PermissionMode" + }, + "updated_at": { + "type": "integer" + }, + "user_id": { + "type": "string" + } + } + }, + "server.InstancePermission": { + "type": "object", + "properties": { + "can_infer": { + "type": "boolean" + }, + "can_view_logs": { + "type": "boolean" + }, + "instance_id": { + "type": "integer" + } + } + }, + "server.KeyPermissionResponse": { + "type": "object", + "properties": { + "can_infer": { + "type": "boolean" + }, + "can_view_logs": { + "type": "boolean" + }, + "instance_id": { + "type": "integer" + }, + "instance_name": { + "type": "string" + } + } + }, + "server.KeyResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permission_mode": { + "$ref": "#/definitions/auth.PermissionMode" + }, + "updated_at": { + "type": "integer" + }, + "user_id": { + "type": "string" + } + } + }, "server.NodeResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index f79a008..25cf87d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -12,6 +12,235 @@ }, "basePath": "/api/v1", "paths": { + "/api/v1/auth/keys": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all API keys for the system user (excludes key hash and plain-text key)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "List all API keys", + "responses": { + "200": { + "description": "List of API keys", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/server.KeyResponse" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "description": "Creates a new API key with the specified permissions and returns the plain-text key (only shown once)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Create a new API key", + "parameters": [ + { + "description": "API key configuration", + "name": "key", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.CreateKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created API key with plain-text key", + "schema": { + "$ref": "#/definitions/server.CreateKeyResponse" + } + }, + "400": { + "description": "Invalid request body or validation error", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/keys/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns details for a specific API key by ID (excludes key hash and plain-text key)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Get details of a specific API key", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "API key details", + "schema": { + "$ref": "#/definitions/server.KeyResponse" + } + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes an API key by ID", + "tags": [ + "Keys" + ], + "summary": "Delete an API key", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "API key deleted successfully" + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/keys/{id}/permissions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the instance-level permissions for a specific API key (includes instance names)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Get API key permissions", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "List of key permissions", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/server.KeyPermissionResponse" + } + } + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/backends/llama-cpp/devices": { "get": { "security": [ @@ -1496,6 +1725,17 @@ } }, "definitions": { + "auth.PermissionMode": { + "type": "string", + "enum": [ + "allow_all", + "per_instance" + ], + "x-enum-varnames": [ + "PermissionModeAllowAll", + "PermissionModePerInstance" + ] + }, "config.AppConfig": { "type": "object", "properties": { @@ -1511,6 +1751,13 @@ "commit_hash": { "type": "string" }, + "data_dir": { + "description": "Directory where all llamactl data will be stored (database, instances, logs, etc.)", + "type": "string" + }, + "database": { + "$ref": "#/definitions/config.DatabaseConfig" + }, "instances": { "$ref": "#/definitions/config.InstancesConfig" }, @@ -1601,6 +1848,26 @@ } } }, + "config.DatabaseConfig": { + "type": "object", + "properties": { + "connection_max_lifetime": { + "type": "string", + "example": "1h" + }, + "max_idle_connections": { + "type": "integer" + }, + "max_open_connections": { + "description": "Connection settings", + "type": "integer" + }, + "path": { + "description": "Database file path (relative to the top-level data_dir or absolute)", + "type": "string" + } + } + }, "config.DockerSettings": { "type": "object", "properties": { @@ -1632,11 +1899,7 @@ "type": "boolean" }, "configs_dir": { - "description": "Instance config directory override", - "type": "string" - }, - "data_dir": { - "description": "Directory where all llamactl data will be stored (instances.json, logs, etc.)", + "description": "Instance config directory override (relative to data_dir if not absolute)", "type": "string" }, "default_auto_restart": { @@ -1660,7 +1923,7 @@ "type": "boolean" }, "logs_dir": { - "description": "Logs directory override", + "description": "Logs directory override (relative to data_dir if not absolute)", "type": "string" }, "max_instances": { @@ -1741,7 +2004,10 @@ "type": "object", "properties": { "created": { - "description": "Unix timestamp when the instance was created", + "description": "Unix timestamp when instance was created", + "type": "integer" + }, + "id": { "type": "integer" }, "name": { @@ -1787,6 +2053,125 @@ } } }, + "server.CreateKeyRequest": { + "type": "object", + "properties": { + "expiresAt": { + "type": "integer", + "format": "int64" + }, + "instancePermissions": { + "type": "array", + "items": { + "$ref": "#/definitions/server.InstancePermission" + } + }, + "name": { + "type": "string" + }, + "permissionMode": { + "$ref": "#/definitions/auth.PermissionMode" + } + } + }, + "server.CreateKeyResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "last_used_at": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permission_mode": { + "$ref": "#/definitions/auth.PermissionMode" + }, + "updated_at": { + "type": "integer" + }, + "user_id": { + "type": "string" + } + } + }, + "server.InstancePermission": { + "type": "object", + "properties": { + "can_infer": { + "type": "boolean" + }, + "can_view_logs": { + "type": "boolean" + }, + "instance_id": { + "type": "integer" + } + } + }, + "server.KeyPermissionResponse": { + "type": "object", + "properties": { + "can_infer": { + "type": "boolean" + }, + "can_view_logs": { + "type": "boolean" + }, + "instance_id": { + "type": "integer" + }, + "instance_name": { + "type": "string" + } + } + }, + "server.KeyResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permission_mode": { + "$ref": "#/definitions/auth.PermissionMode" + }, + "updated_at": { + "type": "integer" + }, + "user_id": { + "type": "string" + } + } + }, "server.NodeResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2888ce1..8143bc3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,13 @@ basePath: /api/v1 definitions: + auth.PermissionMode: + enum: + - allow_all + - per_instance + type: string + x-enum-varnames: + - PermissionModeAllowAll + - PermissionModePerInstance config.AppConfig: properties: auth: @@ -10,6 +18,12 @@ definitions: type: string commit_hash: type: string + data_dir: + description: Directory where all llamactl data will be stored (database, instances, + logs, etc.) + type: string + database: + $ref: '#/definitions/config.DatabaseConfig' instances: $ref: '#/definitions/config.InstancesConfig' local_node: @@ -70,6 +84,20 @@ definitions: type: string type: object type: object + config.DatabaseConfig: + properties: + connection_max_lifetime: + example: 1h + type: string + max_idle_connections: + type: integer + max_open_connections: + description: Connection settings + type: integer + path: + description: Database file path (relative to the top-level data_dir or absolute) + type: string + type: object config.DockerSettings: properties: args: @@ -91,11 +119,8 @@ definitions: description: Automatically create the data directory if it doesn't exist type: boolean configs_dir: - description: Instance config directory override - type: string - data_dir: - description: Directory where all llamactl data will be stored (instances.json, - logs, etc.) + description: Instance config directory override (relative to data_dir if not + absolute) type: string default_auto_restart: description: Default auto-restart setting for new instances @@ -113,7 +138,7 @@ definitions: description: Enable LRU eviction for instance logs type: boolean logs_dir: - description: Logs directory override + description: Logs directory override (relative to data_dir if not absolute) type: string max_instances: description: Maximum number of instances that can be created @@ -171,7 +196,9 @@ definitions: instance.Instance: properties: created: - description: Unix timestamp when the instance was created + description: Unix timestamp when instance was created + type: integer + id: type: integer name: type: string @@ -203,6 +230,84 @@ definitions: description: seconds type: integer type: object + server.CreateKeyRequest: + properties: + expiresAt: + format: int64 + type: integer + instancePermissions: + items: + $ref: '#/definitions/server.InstancePermission' + type: array + name: + type: string + permissionMode: + $ref: '#/definitions/auth.PermissionMode' + type: object + server.CreateKeyResponse: + properties: + created_at: + type: integer + enabled: + type: boolean + expires_at: + type: integer + id: + type: integer + key: + type: string + last_used_at: + type: integer + name: + type: string + permission_mode: + $ref: '#/definitions/auth.PermissionMode' + updated_at: + type: integer + user_id: + type: string + type: object + server.InstancePermission: + properties: + can_infer: + type: boolean + can_view_logs: + type: boolean + instance_id: + type: integer + type: object + server.KeyPermissionResponse: + properties: + can_infer: + type: boolean + can_view_logs: + type: boolean + instance_id: + type: integer + instance_name: + type: string + type: object + server.KeyResponse: + properties: + created_at: + type: integer + enabled: + type: boolean + expires_at: + type: integer + id: + type: integer + last_used_at: + type: integer + name: + type: string + permission_mode: + $ref: '#/definitions/auth.PermissionMode' + updated_at: + type: integer + user_id: + type: string + type: object server.NodeResponse: properties: address: @@ -242,6 +347,156 @@ info: title: llamactl API version: "1.0" paths: + /api/v1/auth/keys: + get: + description: Returns a list of all API keys for the system user (excludes key + hash and plain-text key) + produces: + - application/json + responses: + "200": + description: List of API keys + schema: + items: + $ref: '#/definitions/server.KeyResponse' + type: array + "500": + description: Internal Server Error + schema: + type: string + security: + - ApiKeyAuth: [] + summary: List all API keys + tags: + - Keys + post: + consumes: + - application/json + description: Creates a new API key with the specified permissions and returns + the plain-text key (only shown once) + parameters: + - description: API key configuration + in: body + name: key + required: true + schema: + $ref: '#/definitions/server.CreateKeyRequest' + produces: + - application/json + responses: + "201": + description: Created API key with plain-text key + schema: + $ref: '#/definitions/server.CreateKeyResponse' + "400": + description: Invalid request body or validation error + schema: + type: string + "500": + description: Internal Server Error + schema: + type: string + summary: Create a new API key + tags: + - Keys + /api/v1/auth/keys/{id}: + delete: + description: Deletes an API key by ID + parameters: + - description: Key ID + in: path + name: id + required: true + type: integer + responses: + "204": + description: API key deleted successfully + "400": + description: Invalid key ID + schema: + type: string + "404": + description: API key not found + schema: + type: string + "500": + description: Internal Server Error + schema: + type: string + security: + - ApiKeyAuth: [] + summary: Delete an API key + tags: + - Keys + get: + description: Returns details for a specific API key by ID (excludes key hash + and plain-text key) + parameters: + - description: Key ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: API key details + schema: + $ref: '#/definitions/server.KeyResponse' + "400": + description: Invalid key ID + schema: + type: string + "404": + description: API key not found + schema: + type: string + "500": + description: Internal Server Error + schema: + type: string + security: + - ApiKeyAuth: [] + summary: Get details of a specific API key + tags: + - Keys + /api/v1/auth/keys/{id}/permissions: + get: + description: Returns the instance-level permissions for a specific API key (includes + instance names) + parameters: + - description: Key ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: List of key permissions + schema: + items: + $ref: '#/definitions/server.KeyPermissionResponse' + type: array + "400": + description: Invalid key ID + schema: + type: string + "404": + description: API key not found + schema: + type: string + "500": + description: Internal Server Error + schema: + type: string + security: + - ApiKeyAuth: [] + summary: Get API key permissions + tags: + - Keys /api/v1/backends/llama-cpp/devices: get: description: Returns a list of available devices for the llama server diff --git a/go.mod b/go.mod index 73b77f8..de69ecf 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,11 @@ go 1.24.5 require ( github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.2 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/mattn/go-sqlite3 v1.14.24 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.5 + golang.org/x/crypto v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -16,16 +19,12 @@ require ( github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect - github.com/golang-migrate/migrate/v4 v4.19.1 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/swaggo/files v1.0.1 // indirect - go.uber.org/atomic v1.7.0 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/tools v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 8924797..7431b29 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= @@ -16,35 +14,26 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= -github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= -github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= @@ -54,27 +43,21 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4 github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM= github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -83,6 +66,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -93,8 +78,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/auth/hash.go b/pkg/auth/hash.go new file mode 100644 index 0000000..776b851 --- /dev/null +++ b/pkg/auth/hash.go @@ -0,0 +1,73 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + // Argon2 parameters + time uint32 = 1 + memory uint32 = 64 * 1024 // 64 MB + threads uint8 = 4 + keyLen uint32 = 32 + saltLen uint32 = 16 +) + +// HashKey hashes an API key using Argon2id +func HashKey(plainTextKey string) (string, error) { + // Generate random salt + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("failed to generate salt: %w", err) + } + + // Derive key using Argon2id + hash := argon2.IDKey([]byte(plainTextKey), salt, time, memory, threads, keyLen) + + // Format: $argon2id$v=19$m=65536,t=1,p=4$$ + saltB64 := base64.RawStdEncoding.EncodeToString(salt) + hashB64 := base64.RawStdEncoding.EncodeToString(hash) + + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", memory, time, threads, saltB64, hashB64), nil +} + +// VerifyKey verifies a plain-text key against an Argon2id hash +func VerifyKey(plainTextKey, hash string) bool { + // Parse the hash format + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + + // Extract parameters + var version, time, memory, threads int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != 19 { + return false + } + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false + } + + // Decode salt and hash + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + + expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + + // Compute hash of the provided key + computedHash := argon2.IDKey([]byte(plainTextKey), salt, uint32(time), uint32(memory), uint8(threads), uint32(len(expectedHash))) + + // Compare hashes using constant-time comparison + return subtle.ConstantTimeCompare(computedHash, expectedHash) == 1 +} diff --git a/pkg/auth/key.go b/pkg/auth/key.go new file mode 100644 index 0000000..3771a23 --- /dev/null +++ b/pkg/auth/key.go @@ -0,0 +1,46 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +type PermissionMode string + +const ( + PermissionModeAllowAll PermissionMode = "allow_all" + PermissionModePerInstance PermissionMode = "per_instance" +) + +type APIKey struct { + ID int + KeyHash string + Name string + UserID string + PermissionMode PermissionMode + ExpiresAt *int64 + CreatedAt int64 + UpdatedAt int64 + LastUsedAt *int64 +} + +type KeyPermission struct { + KeyID int + InstanceID int +} + +// GenerateKey generates a cryptographically secure API key with the given prefix +func GenerateKey(prefix string) (string, error) { + // Generate 32 random bytes + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Convert to hex (64 characters) + hexStr := hex.EncodeToString(bytes) + + return fmt.Sprintf("%s-%s", prefix, hexStr), nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 1c53c17..0f49f58 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -85,7 +85,7 @@ type DatabaseConfig struct { // Connection settings MaxOpenConnections int `yaml:"max_open_connections" json:"max_open_connections"` MaxIdleConnections int `yaml:"max_idle_connections" json:"max_idle_connections"` - ConnMaxLifetime time.Duration `yaml:"connection_max_lifetime" json:"connection_max_lifetime"` + ConnMaxLifetime time.Duration `yaml:"connection_max_lifetime" json:"connection_max_lifetime" swaggertype:"string" example:"1h"` } // InstancesConfig contains instance management configuration @@ -93,7 +93,6 @@ type InstancesConfig struct { // Port range for instances (e.g., 8000,9000) PortRange [2]int `yaml:"port_range" json:"port_range"` - // Instance config directory override (relative to data_dir if not absolute) InstancesDir string `yaml:"configs_dir" json:"configs_dir"` @@ -248,9 +247,18 @@ func LoadConfig(configPath string) (AppConfig, error) { // 3. Override with environment variables loadEnvVars(&cfg) + // Log warning if deprecated inference keys are present + if len(cfg.Auth.InferenceKeys) > 0 { + log.Println("⚠️ Config-based inference keys are no longer supported and will be ignored.") + log.Println(" Please create inference keys in web UI or via management API.") + } + // Set default directories if not specified if cfg.Instances.InstancesDir == "" { cfg.Instances.InstancesDir = filepath.Join(cfg.DataDir, "instances") + } else { + // Log deprecation warning if using custom instances dir + log.Println("⚠️ Instances directory is deprecated and will be removed in future versions. Instances are persisted in the database.") } if cfg.Instances.LogsDir == "" { cfg.Instances.LogsDir = filepath.Join(cfg.DataDir, "logs") diff --git a/pkg/database/apikeys.go b/pkg/database/apikeys.go new file mode 100644 index 0000000..8748ad5 --- /dev/null +++ b/pkg/database/apikeys.go @@ -0,0 +1,211 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "llamactl/pkg/auth" + "time" +) + +// CreateKey inserts a new API key with permissions (transactional) +func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions []auth.KeyPermission) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Insert the API key + query := ` + INSERT INTO api_keys (key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ` + + var expiresAt sql.NullInt64 + if key.ExpiresAt != nil { + expiresAt = sql.NullInt64{Int64: *key.ExpiresAt, Valid: true} + } + + result, err := tx.ExecContext(ctx, query, + key.KeyHash, key.Name, key.UserID, key.PermissionMode, + expiresAt, key.CreatedAt, key.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("failed to insert API key: %w", err) + } + + keyID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert ID: %w", err) + } + key.ID = int(keyID) + + // Insert permissions if per-instance mode + if key.PermissionMode == auth.PermissionModePerInstance { + for _, perm := range permissions { + query := ` + INSERT INTO key_permissions (key_id, instance_id) + VALUES (?, ?) + ` + _, err := tx.ExecContext(ctx, query, key.ID, perm.InstanceID) + if err != nil { + return fmt.Errorf("failed to insert permission for instance %d: %w", perm.InstanceID, err) + } + } + } + + return tx.Commit() +} + +// GetKeyByID retrieves an API key by ID +func (db *sqliteDB) GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error) { + query := ` + SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at + FROM api_keys + WHERE id = ? + ` + + var key auth.APIKey + var expiresAt sql.NullInt64 + var lastUsedAt sql.NullInt64 + + err := db.QueryRowContext(ctx, query, id).Scan( + &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, + &expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("API key not found") + } + return nil, fmt.Errorf("failed to query API key: %w", err) + } + + if expiresAt.Valid { + key.ExpiresAt = &expiresAt.Int64 + } + if lastUsedAt.Valid { + key.LastUsedAt = &lastUsedAt.Int64 + } + + return &key, nil +} + +// GetUserKeys retrieves all API keys for a user +func (db *sqliteDB) GetUserKeys(ctx context.Context, userID string) ([]*auth.APIKey, error) { + query := ` + SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at + FROM api_keys + WHERE user_id = ? + ORDER BY created_at DESC + ` + + rows, err := db.QueryContext(ctx, query, userID) + if err != nil { + return nil, fmt.Errorf("failed to query API keys: %w", err) + } + defer rows.Close() + + var keys []*auth.APIKey + for rows.Next() { + var key auth.APIKey + var expiresAt sql.NullInt64 + var lastUsedAt sql.NullInt64 + + err := rows.Scan( + &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, + &expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan API key: %w", err) + } + + if expiresAt.Valid { + key.ExpiresAt = &expiresAt.Int64 + } + if lastUsedAt.Valid { + key.LastUsedAt = &lastUsedAt.Int64 + } + + keys = append(keys, &key) + } + + return keys, nil +} + +// GetActiveKeys retrieves all non-expired API keys +func (db *sqliteDB) GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error) { + query := ` + SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at + FROM api_keys + WHERE expires_at IS NULL OR expires_at > ? + ORDER BY created_at DESC + ` + + now := time.Now().Unix() + rows, err := db.QueryContext(ctx, query, now) + if err != nil { + return nil, fmt.Errorf("failed to query active API keys: %w", err) + } + defer rows.Close() + + var keys []*auth.APIKey + for rows.Next() { + var key auth.APIKey + var expiresAt sql.NullInt64 + var lastUsedAt sql.NullInt64 + + err := rows.Scan( + &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, + &expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan API key: %w", err) + } + + if expiresAt.Valid { + key.ExpiresAt = &expiresAt.Int64 + } + if lastUsedAt.Valid { + key.LastUsedAt = &lastUsedAt.Int64 + } + + keys = append(keys, &key) + } + + return keys, nil +} + +// DeleteKey removes an API key (cascades to permissions) +func (db *sqliteDB) DeleteKey(ctx context.Context, id int) error { + query := `DELETE FROM api_keys WHERE id = ?` + + result, err := db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("failed to delete API key: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("API key not found") + } + + return nil +} + +// TouchKey updates the last_used_at timestamp +func (db *sqliteDB) TouchKey(ctx context.Context, id int) error { + query := `UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?` + + now := time.Now().Unix() + _, err := db.ExecContext(ctx, query, now, now, id) + if err != nil { + return fmt.Errorf("failed to update last used timestamp: %w", err) + } + + return nil +} diff --git a/pkg/database/database.go b/pkg/database/database.go index 957fac7..b5ed946 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -1,8 +1,10 @@ package database import ( + "context" "database/sql" "fmt" + "llamactl/pkg/auth" "llamactl/pkg/instance" "log" "path/filepath" @@ -11,14 +13,26 @@ import ( _ "github.com/mattn/go-sqlite3" ) -// DB defines the interface for instance persistence operations -type DB interface { +// InstanceStore defines interface for instance persistence operations +type InstanceStore interface { Save(inst *instance.Instance) error Delete(name string) error LoadAll() ([]*instance.Instance, error) Close() error } +// AuthStore defines the interface for authentication operations +type AuthStore interface { + CreateKey(ctx context.Context, key *auth.APIKey, permissions []auth.KeyPermission) error + GetUserKeys(ctx context.Context, userID string) ([]*auth.APIKey, error) + GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error) + GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error) + DeleteKey(ctx context.Context, id int) error + TouchKey(ctx context.Context, id int) error + GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPermission, error) + HasPermission(ctx context.Context, keyID, instanceID int) (bool, error) +} + // Config contains database configuration settings type Config struct { // Database file path (relative to data_dir or absolute) @@ -30,13 +44,13 @@ type Config struct { ConnMaxLifetime time.Duration } -// sqliteDB wraps the database connection with configuration +// sqliteDB wraps database connection with configuration type sqliteDB struct { *sql.DB config *Config } -// Open creates a new database connection with the provided configuration +// Open creates a new database connection with provided configuration func Open(config *Config) (*sqliteDB, error) { if config == nil { return nil, fmt.Errorf("database config cannot be nil") @@ -46,10 +60,10 @@ func Open(config *Config) (*sqliteDB, error) { return nil, fmt.Errorf("database path cannot be empty") } - // Ensure the database directory exists + // Ensure that database directory exists dbDir := filepath.Dir(config.Path) if dbDir != "." && dbDir != "/" { - // Directory will be created by the manager if auto_create_dirs is enabled + // Directory will be created by manager if auto_create_dirs is enabled log.Printf("Database will be created at: %s", config.Path) } @@ -89,16 +103,22 @@ func Open(config *Config) (*sqliteDB, error) { }, nil } -// Close closes the database connection +// Close closes database connection func (db *sqliteDB) Close() error { if db.DB != nil { log.Println("Closing database connection") + + // Checkpoint WAL to merge changes back to main database file + if _, err := db.DB.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { + log.Printf("Warning: Failed to checkpoint WAL: %v", err) + } + return db.DB.Close() } return nil } -// HealthCheck verifies the database is accessible +// HealthCheck verifies that database is accessible func (db *sqliteDB) HealthCheck() error { if db.DB == nil { return fmt.Errorf("database connection is nil") diff --git a/pkg/database/instances.go b/pkg/database/instances.go index 1dbdb32..3108e82 100644 --- a/pkg/database/instances.go +++ b/pkg/database/instances.go @@ -45,7 +45,7 @@ func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error { ) VALUES (?, ?, ?, ?, ?, ?) ` - _, err = db.DB.ExecContext(ctx, query, + result, err := db.DB.ExecContext(ctx, query, row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID, ) @@ -53,6 +53,14 @@ func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error { return fmt.Errorf("failed to insert instance: %w", err) } + // Get the auto-generated ID and set it on the instance + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert ID: %w", err) + } + + inst.ID = int(id) + return nil } @@ -263,6 +271,7 @@ func (db *sqliteDB) rowToInstance(row *instanceRow) (*instance.Instance, error) // Build complete instance JSON with all fields instanceJSON, err := json.Marshal(map[string]any{ + "id": row.ID, "name": row.Name, "created": row.CreatedAt, "status": row.Status, diff --git a/pkg/database/migrations/001_initial_schema.down.sql b/pkg/database/migrations/001_initial_schema.down.sql index 08b26e0..633814b 100644 --- a/pkg/database/migrations/001_initial_schema.down.sql +++ b/pkg/database/migrations/001_initial_schema.down.sql @@ -1,7 +1,11 @@ --- Drop indexes first -DROP INDEX IF EXISTS idx_instances_backend_type; +-- Drop API key related indexes and tables first +DROP INDEX IF EXISTS idx_key_permissions_instance_id; +DROP INDEX IF EXISTS idx_api_keys_expires_at; +DROP INDEX IF EXISTS idx_api_keys_user_id; +DROP TABLE IF EXISTS key_permissions; +DROP TABLE IF EXISTS api_keys; + +-- Drop instance related indexes and tables DROP INDEX IF EXISTS idx_instances_status; DROP INDEX IF EXISTS idx_instances_name; - --- Drop tables DROP TABLE IF EXISTS instances; diff --git a/pkg/database/migrations/001_initial_schema.up.sql b/pkg/database/migrations/001_initial_schema.up.sql index 89eac83..299463e 100644 --- a/pkg/database/migrations/001_initial_schema.up.sql +++ b/pkg/database/migrations/001_initial_schema.up.sql @@ -25,3 +25,36 @@ CREATE TABLE IF NOT EXISTS instances ( -- ----------------------------------------------------------------------------- CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_name ON instances(name); CREATE INDEX IF NOT EXISTS idx_instances_status ON instances(status); + +-- ----------------------------------------------------------------------------- +-- API Keys Table: Database-backed inference API keys +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_hash TEXT NOT NULL, + name TEXT NOT NULL, + user_id TEXT NOT NULL, + permission_mode TEXT NOT NULL CHECK(permission_mode IN ('allow_all', 'per_instance')) DEFAULT 'per_instance', + expires_at INTEGER NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_used_at INTEGER NULL +); + +-- ----------------------------------------------------------------------------- +-- Key Permissions Table: Per-instance permissions for API keys +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS key_permissions ( + key_id INTEGER NOT NULL, + instance_id INTEGER NOT NULL, + PRIMARY KEY (key_id, instance_id), + FOREIGN KEY (key_id) REFERENCES api_keys (id) ON DELETE CASCADE, + FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE +); + +-- ----------------------------------------------------------------------------- +-- Indexes for API keys and permissions +-- ----------------------------------------------------------------------------- +CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at); +CREATE INDEX IF NOT EXISTS idx_key_permissions_instance_id ON key_permissions(instance_id); diff --git a/pkg/database/permissions.go b/pkg/database/permissions.go new file mode 100644 index 0000000..a8602e1 --- /dev/null +++ b/pkg/database/permissions.go @@ -0,0 +1,57 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "llamactl/pkg/auth" +) + +// GetPermissions retrieves all permissions for a key +func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPermission, error) { + query := ` + SELECT key_id, instance_id + FROM key_permissions + WHERE key_id = ? + ORDER BY instance_id + ` + + rows, err := db.QueryContext(ctx, query, keyID) + if err != nil { + return nil, fmt.Errorf("failed to query key permissions: %w", err) + } + defer rows.Close() + + var permissions []auth.KeyPermission + for rows.Next() { + var perm auth.KeyPermission + err := rows.Scan(&perm.KeyID, &perm.InstanceID) + if err != nil { + return nil, fmt.Errorf("failed to scan key permission: %w", err) + } + permissions = append(permissions, perm) + } + + return permissions, nil +} + +// HasPermission checks if key has inference permission for instance +func (db *sqliteDB) HasPermission(ctx context.Context, keyID, instanceID int) (bool, error) { + query := ` + SELECT 1 + FROM key_permissions + WHERE key_id = ? AND instance_id = ? + ` + + var exists int + err := db.QueryRowContext(ctx, query, keyID, instanceID).Scan(&exists) + if err != nil { + if err == sql.ErrNoRows { + // No permission record found, deny access + return false, nil + } + return false, fmt.Errorf("failed to check key permission: %w", err) + } + + return true, nil +} diff --git a/pkg/instance/instance.go b/pkg/instance/instance.go index 376cc0c..465cd5e 100644 --- a/pkg/instance/instance.go +++ b/pkg/instance/instance.go @@ -9,10 +9,11 @@ import ( "time" ) -// Instance represents a running instance of the llama server +// Instance represents a running instance of llama server type Instance struct { + ID int `json:"id"` Name string `json:"name"` - Created int64 `json:"created,omitempty"` // Unix timestamp when the instance was created + Created int64 `json:"created,omitempty"` // Unix timestamp when instance was created // Global configuration globalInstanceSettings *config.InstancesConfig @@ -48,6 +49,7 @@ func New(name string, globalConfig *config.AppConfig, opts *Options, onStatusCha options := newOptions(opts) instance := &Instance{ + ID: 0, // Will be set by database Name: name, options: options, globalInstanceSettings: globalInstanceSettings, @@ -279,11 +281,13 @@ func (i *Instance) buildEnvironment() map[string]string { // MarshalJSON implements json.Marshaler for Instance func (i *Instance) MarshalJSON() ([]byte, error) { return json.Marshal(&struct { + ID int `json:"id"` Name string `json:"name"` Status *status `json:"status"` Created int64 `json:"created,omitempty"` Options *options `json:"options,omitempty"` }{ + ID: i.ID, Name: i.Name, Status: i.status, Created: i.Created, @@ -295,6 +299,7 @@ func (i *Instance) MarshalJSON() ([]byte, error) { func (i *Instance) UnmarshalJSON(data []byte) error { // Explicitly deserialize to match MarshalJSON format aux := &struct { + ID int `json:"id"` Name string `json:"name"` Status *status `json:"status"` Created int64 `json:"created,omitempty"` @@ -306,6 +311,7 @@ func (i *Instance) UnmarshalJSON(data []byte) error { } // Set the fields + i.ID = aux.ID i.Name = aux.Name i.Created = aux.Created i.status = aux.Status diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 5aca037..47e554b 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -31,7 +31,7 @@ type instanceManager struct { // Components (each with own synchronization) registry *instanceRegistry ports *portAllocator - db database.DB + db database.InstanceStore remote *remoteManager lifecycle *lifecycleManager @@ -44,7 +44,7 @@ type instanceManager struct { } // New creates a new instance of InstanceManager with dependency injection. -func New(globalConfig *config.AppConfig, db database.DB) InstanceManager { +func New(globalConfig *config.AppConfig, db database.InstanceStore) InstanceManager { if globalConfig.Instances.TimeoutCheckInterval <= 0 { globalConfig.Instances.TimeoutCheckInterval = 5 // Default to 5 minutes if not set @@ -114,11 +114,6 @@ func (im *instanceManager) Shutdown() { } wg.Wait() fmt.Println("All instances stopped.") - - // 4. Close database connection - if err := im.db.Close(); err != nil { - log.Printf("Error closing database: %v\n", err) - } }) } @@ -181,6 +176,7 @@ func (im *instanceManager) loadInstance(persistedInst *instance.Instance) error inst := instance.New(name, im.globalConfig, options, statusCallback) // Restore persisted fields that NewInstance doesn't set + inst.ID = persistedInst.ID inst.Created = persistedInst.Created inst.SetStatus(persistedInst.GetStatus()) diff --git a/pkg/manager/operations.go b/pkg/manager/operations.go index 7256d7f..2cfbebf 100644 --- a/pkg/manager/operations.go +++ b/pkg/manager/operations.go @@ -37,7 +37,6 @@ func (im *instanceManager) ListInstances() ([]*instance.Instance, error) { if node := im.getNodeForInstance(inst); node != nil { remoteInst, err := im.remote.getInstance(ctx, node, inst.Name) if err != nil { - // Log error but continue with stale data // Don't fail the entire list operation due to one remote failure continue } diff --git a/pkg/server/handlers.go b/pkg/server/handlers.go index 78b83c5..3e232ee 100644 --- a/pkg/server/handlers.go +++ b/pkg/server/handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "llamactl/pkg/config" + "llamactl/pkg/database" "llamactl/pkg/instance" "llamactl/pkg/manager" "llamactl/pkg/validation" @@ -52,20 +53,25 @@ type Handler struct { InstanceManager manager.InstanceManager cfg config.AppConfig httpClient *http.Client + authStore database.AuthStore + authMiddleware *APIAuthMiddleware } // NewHandler creates a new Handler instance with the provided instance manager and configuration -func NewHandler(im manager.InstanceManager, cfg config.AppConfig) *Handler { - return &Handler{ +func NewHandler(im manager.InstanceManager, cfg config.AppConfig, authStore database.AuthStore) *Handler { + handler := &Handler{ InstanceManager: im, cfg: cfg, httpClient: &http.Client{ Timeout: 30 * time.Second, }, + authStore: authStore, } + handler.authMiddleware = NewAPIAuthMiddleware(cfg.Auth, authStore) + return handler } -// getInstance retrieves an instance by name from the request query parameters +// getInstance retrieves an instance by name from request query parameters func (h *Handler) getInstance(r *http.Request) (*instance.Instance, error) { name := chi.URLParam(r, "name") validatedName, err := validation.ValidateInstanceName(name) @@ -81,7 +87,7 @@ func (h *Handler) getInstance(r *http.Request) (*instance.Instance, error) { return inst, nil } -// ensureInstanceRunning ensures the instance is running by starting it if on-demand start is enabled +// ensureInstanceRunning ensures that an instance is running by starting it if on-demand start is enabled // It handles LRU eviction when the maximum number of running instances is reached func (h *Handler) ensureInstanceRunning(inst *instance.Instance) error { options := inst.GetOptions() diff --git a/pkg/server/handlers_auth.go b/pkg/server/handlers_auth.go new file mode 100644 index 0000000..e2a4bf5 --- /dev/null +++ b/pkg/server/handlers_auth.go @@ -0,0 +1,354 @@ +package server + +import ( + "encoding/json" + "fmt" + "llamactl/pkg/auth" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" +) + +// CreateKeyRequest represents the request body for creating a new API key. +type CreateKeyRequest struct { + Name string `json:"name"` + PermissionMode auth.PermissionMode `json:"permission_mode"` + ExpiresAt *int64 `json:"expires_at,omitempty"` + InstanceIDs []int `json:"instance_ids,omitempty"` +} + +// CreateKeyResponse represents the response returned when creating a new API key. +type CreateKeyResponse struct { + ID int `json:"id"` + Name string `json:"name"` + UserID string `json:"user_id"` + PermissionMode auth.PermissionMode `json:"permission_mode"` + ExpiresAt *int64 `json:"expires_at"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + LastUsedAt *int64 `json:"last_used_at"` + Key string `json:"key"` +} + +// KeyResponse represents an API key in responses for list and get operations. +type KeyResponse struct { + ID int `json:"id"` + Name string `json:"name"` + UserID string `json:"user_id"` + PermissionMode auth.PermissionMode `json:"permission_mode"` + ExpiresAt *int64 `json:"expires_at"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + LastUsedAt *int64 `json:"last_used_at"` +} + +// KeyPermissionResponse represents the permissions for an API key on a specific instance. +type KeyPermissionResponse struct { + InstanceID int `json:"instance_id"` + InstanceName string `json:"instance_name"` +} + +// CreateKey godoc +// @Summary Create a new API key +// @Description Creates a new API key with the specified permissions and returns the plain-text key (only shown once) +// @Tags Keys +// @Accept json +// @Produce json +// @Param key body CreateKeyRequest true "API key configuration" +// @Success 201 {object} CreateKeyResponse "Created API key with plain-text key" +// @Failure 400 {string} string "Invalid request body or validation error" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/auth/keys [post] +func (h *Handler) CreateKey() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req CreateKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", "Invalid JSON in request body") + return + } + + // Validate request + if req.Name == "" { + writeError(w, http.StatusBadRequest, "invalid_name", "Name is required") + return + } + if len(req.Name) > 100 { + writeError(w, http.StatusBadRequest, "invalid_name", "Name must be 100 characters or less") + return + } + if req.PermissionMode != auth.PermissionModeAllowAll && req.PermissionMode != auth.PermissionModePerInstance { + writeError(w, http.StatusBadRequest, "invalid_permission_mode", "Permission mode must be 'allow_all' or 'per_instance'") + return + } + if req.PermissionMode == auth.PermissionModePerInstance && len(req.InstanceIDs) == 0 { + writeError(w, http.StatusBadRequest, "missing_permissions", "Instance IDs required when permission mode is 'per_instance'") + return + } + if req.ExpiresAt != nil && *req.ExpiresAt <= time.Now().Unix() { + writeError(w, http.StatusBadRequest, "invalid_expires_at", "Expiration time must be in future") + return + } + + // Validate instance IDs exist + if req.PermissionMode == auth.PermissionModePerInstance { + instances, err := h.InstanceManager.ListInstances() + if err != nil { + writeError(w, http.StatusInternalServerError, "fetch_instances_failed", fmt.Sprintf("Failed to fetch instances: %v", err)) + return + } + instanceIDMap := make(map[int]bool) + for _, inst := range instances { + instanceIDMap[inst.ID] = true + } + + for _, instanceID := range req.InstanceIDs { + if !instanceIDMap[instanceID] { + writeError(w, http.StatusBadRequest, "invalid_instance_id", fmt.Sprintf("Instance ID %d does not exist", instanceID)) + return + } + } + } + + // Generate plain-text key + plainTextKey, err := auth.GenerateKey("llamactl") + if err != nil { + writeError(w, http.StatusInternalServerError, "key_generation_failed", "Failed to generate API key") + return + } + + // Hash key + keyHash, err := auth.HashKey(plainTextKey) + if err != nil { + writeError(w, http.StatusInternalServerError, "key_hashing_failed", "Failed to hash API key") + return + } + + // Create APIKey struct + now := time.Now().Unix() + apiKey := &auth.APIKey{ + KeyHash: keyHash, + Name: req.Name, + UserID: "system", + PermissionMode: req.PermissionMode, + ExpiresAt: req.ExpiresAt, + CreatedAt: now, + UpdatedAt: now, + } + + // Convert InstanceIDs to KeyPermissions + var keyPermissions []auth.KeyPermission + for _, instanceID := range req.InstanceIDs { + keyPermissions = append(keyPermissions, auth.KeyPermission{ + KeyID: 0, // Will be set by database after key creation + InstanceID: instanceID, + }) + } + + // Create in database + err = h.authStore.CreateKey(r.Context(), apiKey, keyPermissions) + if err != nil { + writeError(w, http.StatusInternalServerError, "creation_failed", fmt.Sprintf("Failed to create API key: %v", err)) + return + } + + // Return response with plain-text key (only shown once) + response := CreateKeyResponse{ + ID: apiKey.ID, + Name: apiKey.Name, + UserID: apiKey.UserID, + PermissionMode: apiKey.PermissionMode, + ExpiresAt: apiKey.ExpiresAt, + CreatedAt: apiKey.CreatedAt, + UpdatedAt: apiKey.UpdatedAt, + LastUsedAt: apiKey.LastUsedAt, + Key: plainTextKey, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) + } +} + +// ListKeys godoc +// @Summary List all API keys +// @Description Returns a list of all API keys for the system user (excludes key hash and plain-text key) +// @Tags Keys +// @Security ApiKeyAuth +// @Produce json +// @Success 200 {array} KeyResponse "List of API keys" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/auth/keys [get] +func (h *Handler) ListKeys() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + keys, err := h.authStore.GetUserKeys(r.Context(), "system") + if err != nil { + writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API keys: %v", err)) + return + } + + // Remove key_hash from all keys + response := make([]KeyResponse, 0, len(keys)) + for _, key := range keys { + response = append(response, KeyResponse{ + ID: key.ID, + Name: key.Name, + UserID: key.UserID, + PermissionMode: key.PermissionMode, + ExpiresAt: key.ExpiresAt, + CreatedAt: key.CreatedAt, + UpdatedAt: key.UpdatedAt, + LastUsedAt: key.LastUsedAt, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + } +} + +// GetKey godoc +// @Summary Get details of a specific API key +// @Description Returns details for a specific API key by ID (excludes key hash and plain-text key) +// @Tags Keys +// @Security ApiKeyAuth +// @Produce json +// @Param id path int true "Key ID" +// @Success 200 {object} KeyResponse "API key details" +// @Failure 400 {string} string "Invalid key ID" +// @Failure 404 {string} string "API key not found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/auth/keys/{id} [get] +func (h *Handler) GetKey() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID") + return + } + + key, err := h.authStore.GetKeyByID(r.Context(), id) + if err != nil { + if err.Error() == "API key not found" { + writeError(w, http.StatusNotFound, "not_found", "API key not found") + return + } + writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API key: %v", err)) + return + } + + // Remove key_hash from response + response := KeyResponse{ + ID: key.ID, + Name: key.Name, + UserID: key.UserID, + PermissionMode: key.PermissionMode, + ExpiresAt: key.ExpiresAt, + CreatedAt: key.CreatedAt, + UpdatedAt: key.UpdatedAt, + LastUsedAt: key.LastUsedAt, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + } +} + +// DeleteKey godoc +// @Summary Delete an API key +// @Description Deletes an API key by ID +// @Tags Keys +// @Security ApiKeyAuth +// @Param id path int true "Key ID" +// @Success 204 "API key deleted successfully" +// @Failure 400 {string} string "Invalid key ID" +// @Failure 404 {string} string "API key not found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/auth/keys/{id} [delete] +func (h *Handler) DeleteKey() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID") + return + } + + err = h.authStore.DeleteKey(r.Context(), id) + if err != nil { + if err.Error() == "API key not found" { + writeError(w, http.StatusNotFound, "not_found", "API key not found") + return + } + writeError(w, http.StatusInternalServerError, "deletion_failed", fmt.Sprintf("Failed to delete API key: %v", err)) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +// GetKeyPermissions godoc +// @Summary Get API key permissions +// @Description Returns the instance-level permissions for a specific API key (includes instance names) +// @Tags Keys +// @Security ApiKeyAuth +// @Produce json +// @Param id path int true "Key ID" +// @Success 200 {array} KeyPermissionResponse "List of key permissions" +// @Failure 400 {string} string "Invalid key ID" +// @Failure 404 {string} string "API key not found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/auth/keys/{id}/permissions [get] +func (h *Handler) GetKeyPermissions() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID") + return + } + + // Verify key exists + _, err = h.authStore.GetKeyByID(r.Context(), id) + if err != nil { + if err.Error() == "API key not found" { + writeError(w, http.StatusNotFound, "not_found", "API key not found") + return + } + writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API key: %v", err)) + return + } + + permissions, err := h.authStore.GetPermissions(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch permissions: %v", err)) + return + } + + // Get instance names for the permissions + instances, err := h.InstanceManager.ListInstances() + if err != nil { + writeError(w, http.StatusInternalServerError, "fetch_instances_failed", fmt.Sprintf("Failed to fetch instances: %v", err)) + return + } + instanceNameMap := make(map[int]string) + for _, inst := range instances { + instanceNameMap[inst.ID] = inst.Name + } + + response := make([]KeyPermissionResponse, 0, len(permissions)) + for _, perm := range permissions { + response = append(response, KeyPermissionResponse{ + InstanceID: perm.InstanceID, + InstanceName: instanceNameMap[perm.InstanceID], + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + } +} diff --git a/pkg/server/handlers_backends.go b/pkg/server/handlers_backends.go index 1e249f9..065b24e 100644 --- a/pkg/server/handlers_backends.go +++ b/pkg/server/handlers_backends.go @@ -109,6 +109,12 @@ func (h *Handler) LlamaCppProxy() http.HandlerFunc { return } + // Check instance permissions + if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil { + writeError(w, http.StatusForbidden, "permission_denied", err.Error()) + return + } + // Check if instance is shutting down before autostart logic if inst.GetStatus() == instance.ShuttingDown { writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down") diff --git a/pkg/server/handlers_instances.go b/pkg/server/handlers_instances.go index 43bed3e..e155fd1 100644 --- a/pkg/server/handlers_instances.go +++ b/pkg/server/handlers_instances.go @@ -327,6 +327,12 @@ func (h *Handler) InstanceProxy() http.HandlerFunc { return } + // Check instance permissions + if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil { + writeError(w, http.StatusForbidden, "permission_denied", err.Error()) + return + } + if !inst.IsRunning() { writeError(w, http.StatusServiceUnavailable, "instance_not_running", "Instance is not running") return diff --git a/pkg/server/handlers_openai.go b/pkg/server/handlers_openai.go index 81aa9e7..a7ad635 100644 --- a/pkg/server/handlers_openai.go +++ b/pkg/server/handlers_openai.go @@ -107,6 +107,12 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc { return } + // Check instance permissions + if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil { + writeError(w, http.StatusForbidden, "permission_denied", err.Error()) + return + } + // Check if instance is shutting down before autostart logic if inst.GetStatus() == instance.ShuttingDown { writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down") diff --git a/pkg/server/middleware.go b/pkg/server/middleware.go index 0654be6..3e0ae91 100644 --- a/pkg/server/middleware.go +++ b/pkg/server/middleware.go @@ -1,107 +1,76 @@ package server import ( - "crypto/rand" + "context" "crypto/subtle" - "encoding/hex" "fmt" + "llamactl/pkg/auth" "llamactl/pkg/config" + "llamactl/pkg/database" "log" "net/http" "os" "strings" + "time" ) -type KeyType int +// contextKey is a custom type for context keys to avoid collisions +type contextKey string const ( - KeyTypeInference KeyType = iota - KeyTypeManagement + apiKeyContextKey contextKey = "apiKey" ) type APIAuthMiddleware struct { + authStore database.AuthStore requireInferenceAuth bool - inferenceKeys map[string]bool requireManagementAuth bool - managementKeys map[string]bool + managementKeys map[string]bool // Config-based management keys } // NewAPIAuthMiddleware creates a new APIAuthMiddleware with the given configuration -func NewAPIAuthMiddleware(authCfg config.AuthConfig) *APIAuthMiddleware { +func NewAPIAuthMiddleware(authCfg config.AuthConfig, authStore database.AuthStore) *APIAuthMiddleware { + // Load management keys from config into managementKeys map + managementKeys := make(map[string]bool) + for _, key := range authCfg.ManagementKeys { + managementKeys[key] = true + } + // Handle legacy auto-generation for management keys if none provided and auth is required var generated bool = false - - inferenceAPIKeys := make(map[string]bool) - managementAPIKeys := make(map[string]bool) - const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if authCfg.RequireManagementAuth && len(authCfg.ManagementKeys) == 0 { - key := generateAPIKey(KeyTypeManagement) - managementAPIKeys[key] = true + key, err := auth.GenerateKey("llamactl-mgmt") + if err != nil { + log.Printf("Warning: Failed to generate management key: %v", err) + // Fallback to PID-based key for safety + key = fmt.Sprintf("sk-management-fallback-%d", os.Getpid()) + } + managementKeys[key] = true generated = true fmt.Printf("%s\n⚠️ MANAGEMENT AUTHENTICATION REQUIRED\n%s\n", banner, banner) fmt.Printf("🔑 Generated Management API Key:\n\n %s\n\n", key) } - for _, key := range authCfg.ManagementKeys { - managementAPIKeys[key] = true - } - - if authCfg.RequireInferenceAuth && len(authCfg.InferenceKeys) == 0 { - key := generateAPIKey(KeyTypeInference) - inferenceAPIKeys[key] = true - generated = true - fmt.Printf("%s\n⚠️ INFERENCE AUTHENTICATION REQUIRED\n%s\n", banner, banner) - fmt.Printf("🔑 Generated Inference API Key:\n\n %s\n\n", key) - } - for _, key := range authCfg.InferenceKeys { - inferenceAPIKeys[key] = true - } if generated { fmt.Printf("%s\n⚠️ IMPORTANT\n%s\n", banner, banner) - fmt.Println("• These keys are auto-generated and will change on restart") + fmt.Println("• This key is auto-generated and will change on restart") fmt.Println("• For production, add explicit keys to your configuration") - fmt.Println("• Copy these keys before they disappear from the terminal") + fmt.Println("• Copy this key before it disappears from the terminal") fmt.Println(banner) } return &APIAuthMiddleware{ + authStore: authStore, requireInferenceAuth: authCfg.RequireInferenceAuth, - inferenceKeys: inferenceAPIKeys, requireManagementAuth: authCfg.RequireManagementAuth, - managementKeys: managementAPIKeys, + managementKeys: managementKeys, } } -// generateAPIKey creates a cryptographically secure API key -func generateAPIKey(keyType KeyType) string { - // Generate 32 random bytes (256 bits) - randomBytes := make([]byte, 32) - - var prefix string - - switch keyType { - case KeyTypeInference: - prefix = "sk-inference" - case KeyTypeManagement: - prefix = "sk-management" - default: - prefix = "sk-unknown" - } - - if _, err := rand.Read(randomBytes); err != nil { - log.Printf("Warning: Failed to generate secure random key, using fallback") - // Fallback to a less secure method if crypto/rand fails - return fmt.Sprintf("%s-fallback-%d", prefix, os.Getpid()) - } - - // Convert to hex and add prefix - return fmt.Sprintf("%s-%s", prefix, hex.EncodeToString(randomBytes)) -} - -// AuthMiddleware returns a middleware that checks API keys for the given key type -func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) http.Handler { +// InferenceAuthMiddleware returns middleware for inference endpoints +func (a *APIAuthMiddleware) InferenceAuthMiddleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { @@ -109,24 +78,74 @@ func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) h return } + // Extract API key from request apiKey := a.extractAPIKey(r) if apiKey == "" { a.unauthorized(w, "Missing API key") return } - var isValid bool - switch keyType { - case KeyTypeInference: - // Management keys also work for OpenAI endpoints (higher privilege) - isValid = a.isValidKey(apiKey, KeyTypeInference) || a.isValidKey(apiKey, KeyTypeManagement) - case KeyTypeManagement: - isValid = a.isValidKey(apiKey, KeyTypeManagement) - default: - isValid = false + // Try database authentication first + var foundKey *auth.APIKey + if a.requireInferenceAuth && a.authStore != nil { + activeKeys, err := a.authStore.GetActiveKeys(r.Context()) + if err != nil { + log.Printf("Failed to get active inference keys: %v", err) + // Continue to management key fallback + } else { + for _, key := range activeKeys { + if auth.VerifyKey(apiKey, key.KeyHash) { + foundKey = key + // Async update last_used_at + go func(keyID int) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := a.authStore.TouchKey(ctx, keyID); err != nil { + log.Printf("Failed to update last used timestamp for key %d: %v", keyID, err) + } + }(key.ID) + break + } + } + } } - if !isValid { + // If no database key found, try management key authentication (config-based) + if foundKey == nil { + if !a.isValidManagementKey(apiKey) { + a.unauthorized(w, "Invalid API key") + return + } + // Management key was used, continue without adding APIKey to context + } else { + // Add APIKey to context for permission checking + ctx := context.WithValue(r.Context(), apiKeyContextKey, foundKey) + r = r.WithContext(ctx) + } + + next.ServeHTTP(w, r) + }) + } +} + +// ManagementAuthMiddleware returns middleware for management endpoints +func (a *APIAuthMiddleware) ManagementAuthMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + next.ServeHTTP(w, r) + return + } + + // Extract API key from request + apiKey := a.extractAPIKey(r) + if apiKey == "" { + a.unauthorized(w, "Missing API key") + return + } + + // Check if key exists in managementKeys map using constant-time comparison + if !a.isValidManagementKey(apiKey) { a.unauthorized(w, "Invalid API key") return } @@ -136,6 +155,33 @@ func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) h } } +// CheckInstancePermission checks if the authenticated key has permission for the instance +func (a *APIAuthMiddleware) CheckInstancePermission(ctx context.Context, instanceID int) error { + // Extract APIKey from context + apiKey, ok := ctx.Value(apiKeyContextKey).(*auth.APIKey) + if !ok { + // APIKey is nil, management key was used, allow all + return nil + } + + // If permission_mode == "allow_all", allow all + if apiKey.PermissionMode == auth.PermissionModeAllowAll { + return nil + } + + // Check per-instance permissions + canInfer, err := a.authStore.HasPermission(ctx, apiKey.ID, instanceID) + if err != nil { + return fmt.Errorf("failed to check permission: %w", err) + } + + if !canInfer { + return fmt.Errorf("permission denied: key does not have access to this instance") + } + + return nil +} + // extractAPIKey extracts the API key from the request func (a *APIAuthMiddleware) extractAPIKey(r *http.Request) string { // Check Authorization header: "Bearer sk-..." @@ -158,20 +204,9 @@ func (a *APIAuthMiddleware) extractAPIKey(r *http.Request) string { return "" } -// isValidKey checks if the provided API key is valid for the given key type -func (a *APIAuthMiddleware) isValidKey(providedKey string, keyType KeyType) bool { - var validKeys map[string]bool - - switch keyType { - case KeyTypeInference: - validKeys = a.inferenceKeys - case KeyTypeManagement: - validKeys = a.managementKeys - default: - return false - } - - for validKey := range validKeys { +// isValidManagementKey checks if the provided API key is a valid management key +func (a *APIAuthMiddleware) isValidManagementKey(providedKey string) bool { + for validKey := range a.managementKeys { if len(providedKey) == len(validKey) && subtle.ConstantTimeCompare([]byte(providedKey), []byte(validKey)) == 1 { return true @@ -187,3 +222,11 @@ func (a *APIAuthMiddleware) unauthorized(w http.ResponseWriter, message string) response := fmt.Sprintf(`{"error": {"message": "%s", "type": "authentication_error"}}`, message) w.Write([]byte(response)) } + +// forbidden sends a forbidden response +func (a *APIAuthMiddleware) forbidden(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + response := fmt.Sprintf(`{"error": {"message": "%s", "type": "permission_denied"}}`, message) + w.Write([]byte(response)) +} diff --git a/pkg/server/middleware_test.go b/pkg/server/middleware_test.go index 8a1e7fc..6a552e4 100644 --- a/pkg/server/middleware_test.go +++ b/pkg/server/middleware_test.go @@ -9,107 +9,44 @@ import ( "testing" ) -func TestAuthMiddleware(t *testing.T) { +func TestInferenceAuthMiddleware(t *testing.T) { tests := []struct { name string - keyType server.KeyType - inferenceKeys []string managementKeys []string requestKey string method string expectedStatus int }{ - // Valid key tests { - name: "valid inference key for inference", - keyType: server.KeyTypeInference, - inferenceKeys: []string{"sk-inference-valid123"}, - requestKey: "sk-inference-valid123", - method: "GET", - expectedStatus: http.StatusOK, - }, - { - name: "valid management key for inference", // Management keys work for inference - keyType: server.KeyTypeInference, + name: "valid management key for inference", managementKeys: []string{"sk-management-admin123"}, requestKey: "sk-management-admin123", method: "GET", expectedStatus: http.StatusOK, }, { - name: "valid management key for management", - keyType: server.KeyTypeManagement, - managementKeys: []string{"sk-management-admin123"}, - requestKey: "sk-management-admin123", - method: "GET", - expectedStatus: http.StatusOK, - }, - - // Invalid key tests - { - name: "inference key for management should fail", - keyType: server.KeyTypeManagement, - inferenceKeys: []string{"sk-inference-user123"}, - requestKey: "sk-inference-user123", - method: "GET", - expectedStatus: http.StatusUnauthorized, - }, - { - name: "invalid inference key", - keyType: server.KeyTypeInference, - inferenceKeys: []string{"sk-inference-valid123"}, - requestKey: "sk-inference-invalid", - method: "GET", - expectedStatus: http.StatusUnauthorized, - }, - { - name: "missing inference key", - keyType: server.KeyTypeInference, - inferenceKeys: []string{"sk-inference-valid123"}, - requestKey: "", - method: "GET", - expectedStatus: http.StatusUnauthorized, - }, - { - name: "invalid management key", - keyType: server.KeyTypeManagement, + name: "invalid key", managementKeys: []string{"sk-management-valid123"}, requestKey: "sk-management-invalid", method: "GET", expectedStatus: http.StatusUnauthorized, }, { - name: "missing management key", - keyType: server.KeyTypeManagement, + name: "missing key", managementKeys: []string{"sk-management-valid123"}, requestKey: "", method: "GET", expectedStatus: http.StatusUnauthorized, }, - - // OPTIONS requests should always pass { - name: "OPTIONS request bypasses inference auth", - keyType: server.KeyTypeInference, - inferenceKeys: []string{"sk-inference-valid123"}, - requestKey: "", - method: "OPTIONS", - expectedStatus: http.StatusOK, - }, - { - name: "OPTIONS request bypasses management auth", - keyType: server.KeyTypeManagement, + name: "OPTIONS request bypasses auth", managementKeys: []string{"sk-management-valid123"}, requestKey: "", method: "OPTIONS", expectedStatus: http.StatusOK, }, - - // Cross-key-type validation { name: "management key works for inference endpoint", - keyType: server.KeyTypeInference, - inferenceKeys: []string{}, managementKeys: []string{"sk-management-admin"}, requestKey: "sk-management-admin", method: "POST", @@ -120,10 +57,10 @@ func TestAuthMiddleware(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := config.AuthConfig{ - InferenceKeys: tt.inferenceKeys, - ManagementKeys: tt.managementKeys, + RequireInferenceAuth: true, + ManagementKeys: tt.managementKeys, } - middleware := server.NewAPIAuthMiddleware(cfg) + middleware := server.NewAPIAuthMiddleware(cfg, nil) // Create test request req := httptest.NewRequest(tt.method, "/test", nil) @@ -131,24 +68,17 @@ func TestAuthMiddleware(t *testing.T) { req.Header.Set("Authorization", "Bearer "+tt.requestKey) } - // Create test handler using the appropriate middleware - var handler http.Handler - if tt.keyType == server.KeyTypeInference { - handler = middleware.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - } else { - handler = middleware.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - } + // Create test handler + handler := middleware.InferenceAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) // Execute request recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) if recorder.Code != tt.expectedStatus { - t.Errorf("AuthMiddleware() status = %v, expected %v", recorder.Code, tt.expectedStatus) + t.Errorf("InferenceAuthMiddleware() status = %v, expected %v", recorder.Code, tt.expectedStatus) } // Check that unauthorized responses have proper format @@ -167,178 +97,171 @@ func TestAuthMiddleware(t *testing.T) { } } -func TestGenerateAPIKey(t *testing.T) { +func TestManagementAuthMiddleware(t *testing.T) { tests := []struct { - name string - keyType server.KeyType - }{ - {"inference key generation", server.KeyTypeInference}, - {"management key generation", server.KeyTypeManagement}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Test auto-generation by creating config that will trigger it - var config config.AuthConfig - if tt.keyType == server.KeyTypeInference { - config.RequireInferenceAuth = true - config.InferenceKeys = []string{} // Empty to trigger generation - } else { - config.RequireManagementAuth = true - config.ManagementKeys = []string{} // Empty to trigger generation - } - - // Create middleware - this should trigger key generation - middleware := server.NewAPIAuthMiddleware(config) - - // Test that auth is required (meaning a key was generated) - req := httptest.NewRequest("GET", "/", nil) - recorder := httptest.NewRecorder() - - var handler http.Handler - if tt.keyType == server.KeyTypeInference { - handler = middleware.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - } else { - handler = middleware.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - } - - handler.ServeHTTP(recorder, req) - - // Should be unauthorized without a key (proving that a key was generated and auth is working) - if recorder.Code != http.StatusUnauthorized { - t.Errorf("Expected unauthorized without key, got status %v", recorder.Code) - } - - // Test uniqueness by creating another middleware instance - middleware2 := server.NewAPIAuthMiddleware(config) - - req2 := httptest.NewRequest("GET", "/", nil) - recorder2 := httptest.NewRecorder() - - if tt.keyType == server.KeyTypeInference { - handler2 := middleware2.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - handler2.ServeHTTP(recorder2, req2) - } else { - handler2 := middleware2.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - handler2.ServeHTTP(recorder2, req2) - } - - // Both should require auth (proving keys were generated for both instances) - if recorder2.Code != http.StatusUnauthorized { - t.Errorf("Expected unauthorized for second middleware without key, got status %v", recorder2.Code) - } - }) - } -} - -func TestAutoGeneration(t *testing.T) { - tests := []struct { - name string - requireInference bool - requireManagement bool - providedInference []string - providedManagement []string - shouldGenerateInf bool // Whether inference key should be generated - shouldGenerateMgmt bool // Whether management key should be generated + name string + managementKeys []string + requestKey string + method string + expectedStatus int }{ { - name: "inference auth required, keys provided - no generation", - requireInference: true, - requireManagement: false, - providedInference: []string{"sk-inference-provided"}, - providedManagement: []string{}, - shouldGenerateInf: false, - shouldGenerateMgmt: false, + name: "valid management key", + managementKeys: []string{"sk-management-admin123"}, + requestKey: "sk-management-admin123", + method: "GET", + expectedStatus: http.StatusOK, }, { - name: "inference auth required, no keys - should auto-generate", - requireInference: true, - requireManagement: false, - providedInference: []string{}, - providedManagement: []string{}, - shouldGenerateInf: true, - shouldGenerateMgmt: false, + name: "invalid management key", + managementKeys: []string{"sk-management-valid123"}, + requestKey: "sk-management-invalid", + method: "GET", + expectedStatus: http.StatusUnauthorized, }, { - name: "management auth required, keys provided - no generation", - requireInference: false, - requireManagement: true, - providedInference: []string{}, - providedManagement: []string{"sk-management-provided"}, - shouldGenerateInf: false, - shouldGenerateMgmt: false, + name: "missing management key", + managementKeys: []string{"sk-management-valid123"}, + requestKey: "", + method: "GET", + expectedStatus: http.StatusUnauthorized, }, { - name: "management auth required, no keys - should auto-generate", - requireInference: false, - requireManagement: true, - providedInference: []string{}, - providedManagement: []string{}, - shouldGenerateInf: false, - shouldGenerateMgmt: true, - }, - { - name: "both required, both provided - no generation", - requireInference: true, - requireManagement: true, - providedInference: []string{"sk-inference-provided"}, - providedManagement: []string{"sk-management-provided"}, - shouldGenerateInf: false, - shouldGenerateMgmt: false, - }, - { - name: "both required, none provided - should auto-generate both", - requireInference: true, - requireManagement: true, - providedInference: []string{}, - providedManagement: []string{}, - shouldGenerateInf: true, - shouldGenerateMgmt: true, + name: "OPTIONS request bypasses management auth", + managementKeys: []string{"sk-management-valid123"}, + requestKey: "", + method: "OPTIONS", + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.AuthConfig{ + RequireManagementAuth: true, + ManagementKeys: tt.managementKeys, + } + middleware := server.NewAPIAuthMiddleware(cfg, nil) + + // Create test request + req := httptest.NewRequest(tt.method, "/test", nil) + if tt.requestKey != "" { + req.Header.Set("Authorization", "Bearer "+tt.requestKey) + } + + // Create test handler + handler := middleware.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // Execute request + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + if recorder.Code != tt.expectedStatus { + t.Errorf("ManagementAuthMiddleware() status = %v, expected %v", recorder.Code, tt.expectedStatus) + } + + // Check that unauthorized responses have proper format + if recorder.Code == http.StatusUnauthorized { + contentType := recorder.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Unauthorized response Content-Type = %v, expected application/json", contentType) + } + + body := recorder.Body.String() + if !strings.Contains(body, `"type": "authentication_error"`) { + t.Errorf("Unauthorized response missing proper error type: %v", body) + } + } + }) + } +} + +func TestManagementKeyAutoGeneration(t *testing.T) { + // Test auto-generation for management keys + config := config.AuthConfig{ + RequireManagementAuth: true, + ManagementKeys: []string{}, // Empty to trigger generation + } + + // Create middleware - this should trigger key generation + middleware := server.NewAPIAuthMiddleware(config, nil) + + // Test that auth is required (meaning a key was generated) + req := httptest.NewRequest("GET", "/", nil) + recorder := httptest.NewRecorder() + + handler := middleware.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(recorder, req) + + // Should be unauthorized without a key (proving that a key was generated and auth is working) + if recorder.Code != http.StatusUnauthorized { + t.Errorf("Expected unauthorized without key, got status %v", recorder.Code) + } + + // Test uniqueness by creating another middleware instance + middleware2 := server.NewAPIAuthMiddleware(config, nil) + + req2 := httptest.NewRequest("GET", "/", nil) + recorder2 := httptest.NewRecorder() + + handler2 := middleware2.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + handler2.ServeHTTP(recorder2, req2) + + // Both should require auth (proving keys were generated for both instances) + if recorder2.Code != http.StatusUnauthorized { + t.Errorf("Expected unauthorized for second middleware without key, got status %v", recorder2.Code) + } +} + +func TestAutoGenerationScenarios(t *testing.T) { + tests := []struct { + name string + requireManagement bool + providedManagement []string + shouldGenerate bool + }{ + { + name: "management auth required, keys provided - no generation", + requireManagement: true, + providedManagement: []string{"sk-management-provided"}, + shouldGenerate: false, + }, + { + name: "management auth required, no keys - should auto-generate", + requireManagement: true, + providedManagement: []string{}, + shouldGenerate: true, + }, + { + name: "management auth not required - no generation", + requireManagement: false, + providedManagement: []string{}, + shouldGenerate: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := config.AuthConfig{ - RequireInferenceAuth: tt.requireInference, RequireManagementAuth: tt.requireManagement, - InferenceKeys: tt.providedInference, ManagementKeys: tt.providedManagement, } - middleware := server.NewAPIAuthMiddleware(cfg) - - // Test inference behavior if inference auth is required - if tt.requireInference { - req := httptest.NewRequest("GET", "/v1/models", nil) - recorder := httptest.NewRecorder() - - handler := middleware.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - - handler.ServeHTTP(recorder, req) - - // Should always be unauthorized without a key (since middleware assumes auth is required) - if recorder.Code != http.StatusUnauthorized { - t.Errorf("Expected unauthorized for inference without key, got status %v", recorder.Code) - } - } + middleware := server.NewAPIAuthMiddleware(cfg, nil) // Test management behavior if management auth is required if tt.requireManagement { req := httptest.NewRequest("GET", "/api/v1/instances", nil) recorder := httptest.NewRecorder() - handler := middleware.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := middleware.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -352,3 +275,16 @@ func TestAutoGeneration(t *testing.T) { }) } } + +func TestConfigBasedInferenceKeysDeprecationWarning(t *testing.T) { + // Test that config-based inference keys trigger a warning (captured in logs) + cfg := config.AuthConfig{ + InferenceKeys: []string{"sk-inference-old"}, + } + + // Creating middleware should log a warning, but shouldn't fail + _ = server.NewAPIAuthMiddleware(cfg, nil) + + // If we get here without panic, the test passes + // The warning is logged but not returned as an error +} diff --git a/pkg/server/routes.go b/pkg/server/routes.go index b159968..d8f8c35 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -27,7 +27,7 @@ func SetupRouter(handler *Handler) *chi.Mux { })) // Add API authentication middleware - authMiddleware := NewAPIAuthMiddleware(handler.cfg.Auth) + authMiddleware := NewAPIAuthMiddleware(handler.cfg.Auth, handler.authStore) if handler.cfg.Server.EnableSwagger { r.Get("/swagger/*", httpSwagger.Handler( @@ -39,13 +39,24 @@ func SetupRouter(handler *Handler) *chi.Mux { r.Route("/api/v1", func(r chi.Router) { if authMiddleware != nil && handler.cfg.Auth.RequireManagementAuth { - r.Use(authMiddleware.AuthMiddleware(KeyTypeManagement)) + r.Use(authMiddleware.ManagementAuthMiddleware()) } r.Get("/version", handler.VersionHandler()) r.Get("/config", handler.ConfigHandler()) + // API key management endpoints + r.Route("/auth", func(r chi.Router) { + r.Route("/keys", func(r chi.Router) { + r.Post("/", handler.CreateKey()) // Create API key + r.Get("/", handler.ListKeys()) // List API keys + r.Get("/{id}", handler.GetKey()) // Get API key details + r.Delete("/{id}", handler.DeleteKey()) // Delete API key + r.Get("/{id}/permissions", handler.GetKeyPermissions()) // Get key permissions + }) + }) + // Backend-specific endpoints r.Route("/backends", func(r chi.Router) { r.Route("/llama-cpp", func(r chi.Router) { @@ -67,7 +78,7 @@ func SetupRouter(handler *Handler) *chi.Mux { r.Get("/", handler.ListNodes()) // List all nodes r.Route("/{name}", func(r chi.Router) { - r.Get("/", handler.GetNode()) + r.Get("/", handler.GetNode()) // Get node details }) }) @@ -94,13 +105,13 @@ func SetupRouter(handler *Handler) *chi.Mux { }) }) - r.Route(("/v1"), func(r chi.Router) { + r.Route("/v1", func(r chi.Router) { if authMiddleware != nil && handler.cfg.Auth.RequireInferenceAuth { - r.Use(authMiddleware.AuthMiddleware(KeyTypeInference)) + r.Use(authMiddleware.InferenceAuthMiddleware()) } - r.Get(("/models"), handler.OpenAIListInstances()) // List instances in OpenAI-compatible format + r.Get("/models", handler.OpenAIListInstances()) // List instances in OpenAI-compatible format // OpenAI-compatible proxy endpoint // Handles all POST requests to /v1/*, including: @@ -125,10 +136,10 @@ func SetupRouter(handler *Handler) *chi.Mux { r.Group(func(r chi.Router) { if authMiddleware != nil && handler.cfg.Auth.RequireInferenceAuth { - r.Use(authMiddleware.AuthMiddleware(KeyTypeInference)) + r.Use(authMiddleware.InferenceAuthMiddleware()) } - // This handler auto start the server if it's not running + // This handler auto starts the server if it's not running llamaCppHandler := handler.LlamaCppProxy() // llama.cpp server specific proxy endpoints diff --git a/test_client.py b/test_client.py new file mode 100644 index 0000000..e151a23 --- /dev/null +++ b/test_client.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Simple Python script to interact with local LLM server's OpenAI-compatible API +""" + +import requests +import json +import sys + +# Local LLM server configuration +BASE_URL = "http://localhost:8080" +API_KEY = None +MODEL_NAME = None + +def get_models(): + """Fetch available models from /v1/models endpoint""" + headers = {} + if API_KEY: + headers["Authorization"] = f"Bearer {API_KEY}" + + try: + response = requests.get(f"{BASE_URL}/v1/models", headers=headers, timeout=10) + response.raise_for_status() + return response.json()["data"] + except Exception as e: + print(f"Error fetching models: {e}") + return [] + +def send_message(message): + """ + Send a message to local LLM server API + + Args: + message (str): The message to send + + Returns: + str: The AI response or error message + """ + + headers = { + "Content-Type": "application/json", + } + + if API_KEY: + headers["Authorization"] = f"Bearer {API_KEY}" + + data = { + "model": MODEL_NAME, + "messages": [ + { + "role": "user", + "content": message + } + ], + "temperature": 0.7, + "max_tokens": 1000, + "stream": False, + } + + response = requests.post(f"{BASE_URL}/v1/chat/completions", headers=headers, json=data, timeout=60) + response.raise_for_status() + return response.json()["choices"][0]["message"]["content"] + +def interactive_mode(): + """Run in interactive mode for continuous conversation""" + global BASE_URL, API_KEY, MODEL_NAME + + # Get base URL + url_input = input(f"Base URL [{BASE_URL}]: ").strip() + if url_input: + BASE_URL = url_input + + # Get API key (optional) + key_input = input("API key (optional): ").strip() + if key_input: + API_KEY = key_input + + # Fetch and select model + models = get_models() + if not models: + print("No models available. Exiting.") + return + + print("\nAvailable models:") + for i, m in enumerate(models, 1): + print(f"{i}. {m['id']}") + + while True: + try: + selection = int(input("\nSelect model: ")) + if 1 <= selection <= len(models): + MODEL_NAME = models[selection - 1]["id"] + break + print(f"Please enter a number between 1 and {len(models)}") + except ValueError: + print("Please enter a valid number") + + print(f"\nUsing model: {MODEL_NAME}") + print("Type 'quit' or 'exit' to stop") + print("-" * 40) + + while True: + try: + user_input = input("\nYou: ").strip() + + if user_input.lower() in ['quit', 'exit', 'q']: + print("Goodbye!") + break + + if not user_input: + continue + + print("AI: ", end="", flush=True) + response = send_message(user_input) + print(response) + + except KeyboardInterrupt: + print("\nGoodbye!") + break + except EOFError: + print("\nGoodbye!") + break + +def main(): + """Main function""" + if len(sys.argv) > 1: + # Single message mode + message = " ".join(sys.argv[1:]) + response = send_message(message) + print(response) + else: + # Interactive mode + interactive_mode() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/webui/package-lock.json b/webui/package-lock.json index 0d657f7..0053670 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -9,13 +9,15 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@radix-ui/react-checkbox": "^1.3.2", - "@radix-ui/react-dialog": "^1.1.14", - "@radix-ui/react-label": "^2.1.7", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/vite": "^4.1.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -29,7 +31,6 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/eslint__js": "^9.14.0", "@types/node": "^24.10.1", "@types/react": "^19.2.4", "@types/react-dom": "^19.2.3", @@ -160,7 +161,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -510,7 +510,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -554,7 +553,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1251,21 +1249,21 @@ "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", - "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", - "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", @@ -1286,6 +1284,50 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1317,20 +1359,20 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", - "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", @@ -1352,13 +1394,46 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", - "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", @@ -1380,9 +1455,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", - "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1438,12 +1513,35 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", - "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", @@ -1485,9 +1583,9 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", - "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", @@ -1531,7 +1629,7 @@ } } }, - "node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -1549,6 +1647,87 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -2352,7 +2531,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2417,17 +2597,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint__js": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-9.14.0.tgz", - "integrity": "sha512-s0jepCjOJWB/GKcuba4jISaVpBudw3ClXJ3fUK4tugChUMQsp6kSwuA8Dcx6wFd/JsJqcY8n4rEpa5RTHs5ypA==", - "deprecated": "This is a stub types definition. @eslint/js provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint/js": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2447,7 +2616,6 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2458,7 +2626,6 @@ "integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2469,7 +2636,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2520,7 +2686,6 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -2869,7 +3034,6 @@ "integrity": "sha512-F9jI5rSstNknPlTlPN2gcc4gpbaagowuRzw/OJzl368dvPun668Q182S8Q8P9PITgGCl5LAKXpzuue106eM4wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.8", "fflate": "^0.8.2", @@ -2906,7 +3070,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2957,6 +3120,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3228,7 +3392,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3540,6 +3703,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3651,7 +3824,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3955,7 +4129,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5181,7 +5354,6 @@ "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.23", "@asamuzakjp/dom-selector": "^6.7.4", @@ -5592,6 +5764,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5977,7 +6150,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6039,6 +6211,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6054,6 +6227,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6095,7 +6269,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6105,7 +6278,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6118,7 +6290,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -7072,7 +7245,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7220,7 +7392,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7296,7 +7467,6 @@ "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.8", "@vitest/mocker": "4.0.8", @@ -7625,7 +7795,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/webui/package.json b/webui/package.json index e998fba..5d7c357 100644 --- a/webui/package.json +++ b/webui/package.json @@ -18,13 +18,15 @@ "lint:fix": "eslint . --ext .ts,.tsx --fix" }, "dependencies": { - "@radix-ui/react-checkbox": "^1.3.2", - "@radix-ui/react-dialog": "^1.1.14", - "@radix-ui/react-label": "^2.1.7", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/vite": "^4.1.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -38,7 +40,6 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/eslint__js": "^9.14.0", "@types/node": "^24.10.1", "@types/react": "^19.2.4", "@types/react-dom": "^19.2.3", diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 04c8c01..fc62225 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -4,6 +4,7 @@ import InstanceList from "@/components/InstanceList"; import InstanceDialog from "@/components/InstanceDialog"; import LoginDialog from "@/components/LoginDialog"; import SystemInfoDialog from "./components/SystemInfoDialog"; +import SettingsDialog from "./components/settings/SettingsDialog"; import { type CreateInstanceOptions, type Instance } from "@/types/instance"; import { useInstances } from "@/contexts/InstancesContext"; import { useAuth } from "@/contexts/AuthContext"; @@ -14,6 +15,7 @@ function App() { const { isAuthenticated, isLoading: authLoading } = useAuth(); const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false); const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [editingInstance, setEditingInstance] = useState( undefined ); @@ -41,6 +43,10 @@ function App() { setIsSystemInfoModalOpen(true); }; + const handleShowSettings = () => { + setIsSettingsModalOpen(true); + }; + // Show loading spinner while checking auth if (authLoading) { return ( @@ -70,7 +76,11 @@ function App() { return (
-
+
@@ -86,7 +96,12 @@ function App() { open={isSystemInfoModalOpen} onOpenChange={setIsSystemInfoModalOpen} /> - + + +
diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx index eb212a4..2aa39f6 100644 --- a/webui/src/__tests__/App.test.tsx +++ b/webui/src/__tests__/App.test.tsx @@ -75,8 +75,8 @@ function renderApp() { describe('App Component - Critical Business Logic Only', () => { const mockInstances: Instance[] = [ - { name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } }, - { name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } } + { id: 1, name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } }, + { id: 2, name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } } ] beforeEach(() => { @@ -109,6 +109,7 @@ describe('App Component - Critical Business Logic Only', () => { it('creates new instance with correct API call and updates UI', async () => { const user = userEvent.setup() const newInstance: Instance = { + id: 3, name: 'new-test-instance', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'new-model.gguf' } } @@ -151,6 +152,7 @@ describe('App Component - Critical Business Logic Only', () => { it('updates existing instance with correct API call', async () => { const user = userEvent.setup() const updatedInstance: Instance = { + id: 1, name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'updated-model.gguf' } } diff --git a/webui/src/components/Header.tsx b/webui/src/components/Header.tsx index 3c7e0e1..d2af49f 100644 --- a/webui/src/components/Header.tsx +++ b/webui/src/components/Header.tsx @@ -1,14 +1,15 @@ import { Button } from "@/components/ui/button"; -import { HelpCircle, LogOut, Moon, Sun } from "lucide-react"; +import { HelpCircle, LogOut, Moon, Settings, Sun } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { useTheme } from "@/contexts/ThemeContext"; interface HeaderProps { onCreateInstance: () => void; onShowSystemInfo: () => void; + onShowSettings: () => void; } -function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { +function Header({ onCreateInstance, onShowSystemInfo, onShowSettings }: HeaderProps) { const { logout } = useAuth(); const { theme, toggleTheme } = useTheme(); @@ -41,6 +42,16 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { {theme === 'light' ? : } + + + + + + + + ); +} + +export default CreateApiKeyDialog; diff --git a/webui/src/components/form/KeyValueInput.tsx b/webui/src/components/form/KeyValueInput.tsx index 62585c4..f136ecd 100644 --- a/webui/src/components/form/KeyValueInput.tsx +++ b/webui/src/components/form/KeyValueInput.tsx @@ -59,7 +59,7 @@ const KeyValueInput: React.FC = ({ // Reset to single empty row if value is explicitly undefined/null setPairs([{ key: '', value: '' }]) } - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) // Update parent component when pairs change diff --git a/webui/src/components/instance/AutoRestartConfiguration.tsx b/webui/src/components/instance/AutoRestartConfiguration.tsx index fe3a900..bb40b7d 100644 --- a/webui/src/components/instance/AutoRestartConfiguration.tsx +++ b/webui/src/components/instance/AutoRestartConfiguration.tsx @@ -5,7 +5,7 @@ import NumberInput from '@/components/form/NumberInput' interface AutoRestartConfigurationProps { formData: CreateInstanceOptions - onChange: (key: keyof CreateInstanceOptions, value: any) => void + onChange: (key: K, value: CreateInstanceOptions[K]) => void } const AutoRestartConfiguration: React.FC = ({ diff --git a/webui/src/components/instance/BackendConfiguration.tsx b/webui/src/components/instance/BackendConfiguration.tsx index 8f10e41..064bbcb 100644 --- a/webui/src/components/instance/BackendConfiguration.tsx +++ b/webui/src/components/instance/BackendConfiguration.tsx @@ -3,9 +3,11 @@ import type { CreateInstanceOptions } from '@/types/instance' import { getBasicBackendFields, getAdvancedBackendFields } from '@/lib/zodFormUtils' import BackendFormField from '@/components/BackendFormField' +type BackendFieldValue = string | number | boolean | string[] | Record | undefined + interface BackendConfigurationProps { formData: CreateInstanceOptions - onBackendFieldChange: (key: string, value: any) => void + onBackendFieldChange: (key: string, value: BackendFieldValue) => void showAdvanced?: boolean } @@ -26,7 +28,7 @@ const BackendConfiguration: React.FC = ({ | undefined)?.[fieldKey]} onChange={onBackendFieldChange} /> ))} @@ -41,7 +43,7 @@ const BackendConfiguration: React.FC = ({ | undefined)?.[fieldKey]} onChange={onBackendFieldChange} /> ))} @@ -53,7 +55,7 @@ const BackendConfiguration: React.FC = ({ | undefined)?.extra_args} onChange={onBackendFieldChange} /> diff --git a/webui/src/components/settings/ApiKeysSection.tsx b/webui/src/components/settings/ApiKeysSection.tsx new file mode 100644 index 0000000..2ca9633 --- /dev/null +++ b/webui/src/components/settings/ApiKeysSection.tsx @@ -0,0 +1,270 @@ +import { useEffect, useState, Fragment } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Trash2, Copy, Check, X, ChevronDown, ChevronRight } from "lucide-react"; +import { apiKeysApi } from "@/lib/api"; +import { type ApiKey, type KeyPermissionResponse, PermissionMode } from "@/types/apiKey"; +import CreateApiKeyDialog from "@/components/apikeys/CreateApiKeyDialog"; +import { format, formatDistanceToNow } from "date-fns"; + +function ApiKeysSection() { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedRowId, setExpandedRowId] = useState(null); + const [newKeyPlainText, setNewKeyPlainText] = useState(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [copiedKey, setCopiedKey] = useState(false); + const [permissions, setPermissions] = useState>({}); + const [loadingPermissions, setLoadingPermissions] = useState>({}); + + useEffect(() => { + void fetchKeys(); + }, []); + + const fetchKeys = async () => { + setLoading(true); + setError(null); + try { + const data = await apiKeysApi.list(); + setKeys(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load API keys"); + } finally { + setLoading(false); + } + }; + + const fetchPermissions = async (keyId: number) => { + if (permissions[keyId]) return; + + setLoadingPermissions({ ...loadingPermissions, [keyId]: true }); + try { + const data = await apiKeysApi.getPermissions(keyId); + setPermissions({ ...permissions, [keyId]: data }); + } catch (err) { + console.error("Failed to load permissions:", err); + } finally { + setLoadingPermissions({ ...loadingPermissions, [keyId]: false }); + } + }; + + const handleKeyCreated = (plainTextKey: string) => { + setNewKeyPlainText(plainTextKey); + void fetchKeys(); + setCreateDialogOpen(false); + }; + + const dismissSuccessBanner = () => { + setNewKeyPlainText(null); + }; + + const handleCopyKey = async () => { + if (newKeyPlainText) { + await navigator.clipboard.writeText(newKeyPlainText); + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + } + }; + + const handleDeleteKey = async (id: number, name: string) => { + if (!confirm(`Are you sure you want to delete the key '${name}'?\n\nThis action cannot be undone.`)) { + return; + } + + try { + await apiKeysApi.delete(id); + void fetchKeys(); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to delete API key"); + } + }; + + const handleRowClick = (key: ApiKey) => { + if (expandedRowId === key.id) { + setExpandedRowId(null); + } else { + setExpandedRowId(key.id); + if (key.permission_mode === PermissionMode.PerInstance) { + void fetchPermissions(key.id); + } + } + }; + + const formatDate = (timestamp: number) => { + return format(new Date(timestamp * 1000), "MMM d, yyyy"); + }; + + const formatLastUsed = (timestamp: number | null) => { + if (!timestamp) return "Never"; + return formatDistanceToNow(new Date(timestamp * 1000), { addSuffix: true }); + }; + + const isExpired = (expiresAt: number | null) => { + if (!expiresAt) return false; + return expiresAt * 1000 < Date.now(); + }; + + return ( +
+
+

API Keys

+ +
+ + {newKeyPlainText && ( + + +
+
+

API key created successfully

+

+ Make sure to copy this key now. You won't be able to see it again! +

+
+ +
+
+ + {newKeyPlainText} + + +
+
+
+ )} + + {error && ( + + {error} + + )} + + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : keys.length === 0 ? ( +
+ No API keys yet. Create your first key to get started. +
+ ) : ( +
+ + + + + + + + + + + + + {keys.map((key) => ( + + handleRowClick(key)} + > + + + + + + + + {expandedRowId === key.id && ( + + + + )} + + ))} + +
NamePermissionsCreatedExpiresLast AccessedActions
+
+ {expandedRowId === key.id ? ( + + ) : ( + + )} + {key.name} +
+
+ {key.permission_mode === PermissionMode.AllowAll ? ( + Full Access + ) : ( + Limited Access + )} + {formatDate(key.created_at)} + {key.expires_at ? ( + isExpired(key.expires_at) ? ( + Expired + ) : ( + {formatDate(key.expires_at)} + ) + ) : ( + Never + )} + {formatLastUsed(key.last_used_at)} + +
+ {key.permission_mode === PermissionMode.AllowAll ? ( +

+ This key has full access to all instances +

+ ) : loadingPermissions[key.id] ? ( +

Loading permissions...

+ ) : permissions[key.id] ? ( +
+

Allowed Instances:

+
    + {permissions[key.id].map((perm) => ( +
  • + + {perm.instance_name} +
  • + ))} +
+
+ ) : ( +

No permissions data

+ )} +
+
+ )} + + +
+ ); +} + +export default ApiKeysSection; diff --git a/webui/src/components/settings/SettingsDialog.tsx b/webui/src/components/settings/SettingsDialog.tsx new file mode 100644 index 0000000..0f00aef --- /dev/null +++ b/webui/src/components/settings/SettingsDialog.tsx @@ -0,0 +1,25 @@ +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import ApiKeysSection from "./ApiKeysSection"; + +interface SettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { + return ( + + + + Settings + + Manage your application settings and API keys. + + + + + + ); +} + +export default SettingsDialog; diff --git a/webui/src/components/ui/alert.tsx b/webui/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/webui/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/webui/src/components/ui/badge.tsx b/webui/src/components/ui/badge.tsx index 0205413..fd3a406 100644 --- a/webui/src/components/ui/badge.tsx +++ b/webui/src/components/ui/badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( - "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { variant: { diff --git a/webui/src/components/ui/button.tsx b/webui/src/components/ui/button.tsx index a2df8dc..21409a0 100644 --- a/webui/src/components/ui/button.tsx +++ b/webui/src/components/ui/button.tsx @@ -9,14 +9,13 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", @@ -26,6 +25,8 @@ const buttonVariants = cva( sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", }, }, defaultVariants: { diff --git a/webui/src/components/ui/card.tsx b/webui/src/components/ui/card.tsx index d05bbc6..681ad98 100644 --- a/webui/src/components/ui/card.tsx +++ b/webui/src/components/ui/card.tsx @@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
diff --git a/webui/src/components/ui/input.tsx b/webui/src/components/ui/input.tsx index 03295ca..8916905 100644 --- a/webui/src/components/ui/input.tsx +++ b/webui/src/components/ui/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className diff --git a/webui/src/components/ui/radio-group.tsx b/webui/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..dc682eb --- /dev/null +++ b/webui/src/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx index 1920d6a..f35c49a 100644 --- a/webui/src/contexts/__tests__/InstancesContext.test.tsx +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -3,8 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { InstancesProvider, useInstances } from "@/contexts/InstancesContext"; import { instancesApi } from "@/lib/api"; -import type { Instance } from "@/types/instance"; -import { BackendType } from "@/types/instance"; +import { BackendType, type Instance } from "@/types/instance"; import { AuthProvider } from "../AuthContext"; // Mock the API module @@ -71,37 +70,37 @@ function TestComponent() { {/* Action buttons for testing with specific instances */}