# Hostkeeper V2 — REST API + WebSocket Specification > **Status**: V2 Planning Complete > **Last Updated**: 2026-06-29 > **Framework**: GoFiber v2 (fasthttp) --- ## 1. Base URL ``` http://localhost:{random_port} ``` The Go backend starts on a random available port. The port is communicated to Electron via stdout: ``` HOSTKEEPER_PORT=54321 ``` --- ## 2. Common Response Formats ### Success ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Production Server", "hostname": "192.168.1.100", "port": 22, "username": "admin" } ``` ### List Response ```json { "items": [...], "total": 42, "page": 1, "per_page": 50 } ``` ### Error Response ```json { "error": "Host not found", "code": "HOST_NOT_FOUND", "details": "No host with ID 'abc123' exists" } ``` ### HTTP Status Codes | Code | Meaning | |------|---------| | 200 | Success | | 201 | Created | | 204 | No Content (delete success) | | 400 | Bad Request (validation error) | | 401 | Unauthorized (vault locked) | | 404 | Not Found | | 409 | Conflict (duplicate name) | | 500 | Internal Server Error | --- ## 3. Endpoints ### 3.1 Health Check ``` GET /api/health ``` **Response** (200): ```json { "status": "ok", "version": "2.0.0", "vault_locked": false } ``` --- ### 3.2 Hosts #### List Hosts ``` GET /api/hosts ``` **Query Parameters**: | Param | Type | Default | Description | |-------|------|---------|-------------| | `group` | string | — | Filter by group ID | | `tag` | string | — | Filter by tag | | `search` | string | — | Search by name/hostname | | `favorite` | boolean | — | Filter favorites only | | `sort` | string | `name` | Sort field (name, hostname, created_at, last_used_at) | | `order` | string | `asc` | Sort order (asc, desc) | | `page` | int | 1 | Page number | | `per_page` | int | 50 | Items per page | **Response** (200): ```json { "items": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Production Server", "hostname": "192.168.1.100", "port": 22, "username": "admin", "auth": { "type": "key", "key_id": "key-uuid" }, "group_id": "group-uuid", "tags": ["production", "linux"], "is_favorite": false, "color": "#e78a4e", "order": 0, "notes": "Main production server", "created_at": "2024-06-22T10:00:00Z", "updated_at": "2024-06-22T10:00:00Z", "last_used_at": "2024-06-22T15:30:00Z" } ], "total": 1, "page": 1, "per_page": 50 } ``` #### Create Host ``` POST /api/hosts ``` **Request Body**: ```json { "name": "Production Server", "hostname": "192.168.1.100", "port": 22, "username": "admin", "auth": { "type": "key", "key_id": "key-uuid", "password": "" }, "group_id": "group-uuid", "tags": ["production", "linux"], "is_favorite": false, "color": "#e78a4e", "notes": "Main production server" } ``` **Response** (201): Full host object with generated `id`, `created_at`, `updated_at`. **Validation**: - `name`: required, max 100 chars - `hostname`: required, valid hostname or IP - `port`: required, 1-65535 - `username`: required, max 100 chars - `auth.type`: required, one of "password", "key", "agent" #### Get Host ``` GET /api/hosts/:id ``` **Response** (200): Full host object. **Error** (404): ```json { "error": "Host not found", "code": "HOST_NOT_FOUND" } ``` #### Update Host ``` PUT /api/hosts/:id ``` **Request Body**: Same as create (all fields optional for partial update). **Response** (200): Updated host object. #### Delete Host ``` DELETE /api/hosts/:id ``` **Response** (204): No content. #### Toggle Favorite ``` PATCH /api/hosts/:id/favorite ``` **Response** (200): ```json { "id": "host-uuid", "is_favorite": true } ``` --- ### 3.3 Groups #### List Groups (Tree) ``` GET /api/groups ``` **Response** (200): ```json { "items": [ { "id": "group-uuid", "name": "Production", "parent_id": null, "color": "#e78a4e", "icon": "server", "host_count": 5, "children": [ { "id": "child-group-uuid", "name": "Web Servers", "parent_id": "group-uuid", "color": "#a9b665", "icon": "globe", "host_count": 3, "children": [] } ] } ] } ``` #### Create Group ``` POST /api/groups ``` **Request Body**: ```json { "name": "Production", "parent_id": null, "color": "#e78a4e", "icon": "server" } ``` **Response** (201): Group object with generated `id`. #### Update Group ``` PUT /api/groups/:id ``` **Request Body**: Same as create (all fields optional). **Response** (200): Updated group object. #### Delete Group ``` DELETE /api/groups/:id ``` **Behavior**: Hosts in this group are moved to parent group (or ungrouped). **Response** (204): No content. #### Move Group ``` PUT /api/groups/:id/move ``` **Request Body**: ```json { "parent_id": "new-parent-uuid" } ``` **Response** (200): Updated group with new parent. #### Move Host to Group ``` PUT /api/hosts/:id/move ``` **Request Body**: ```json { "group_id": "target-group-uuid" } ``` **Response** (200): Updated host with new group. --- ### 3.4 Keys #### List Keys ``` GET /api/keys ``` **Response** (200): ```json { "items": [ { "id": "key-uuid", "name": "My SSH Key", "type": "ed25519", "public_key": "ssh-ed25519 AAAA...", "fingerprint": "SHA256:abc123...", "has_passphrase": true, "created_at": "2024-06-22T10:00:00Z", "updated_at": "2024-06-22T10:00:00Z" } ] } ``` **Note**: Private key is NEVER returned in list or get responses. #### Create/Import Key ``` POST /api/keys ``` **Request Body** (import): ```json { "name": "My SSH Key", "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...", "passphrase": "optional-passphrase" } ``` **Response** (201): Key object (without private key). #### Generate Key ``` POST /api/keys/generate ``` **Request Body**: ```json { "name": "New Key", "type": "ed25519", "passphrase": "optional-passphrase" } ``` **Response** (201): ```json { "id": "key-uuid", "name": "New Key", "type": "ed25519", "public_key": "ssh-ed25519 AAAA...", "fingerprint": "SHA256:abc123...", "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n..." } ``` **Note**: Private key is ONLY returned in generate response (one time). #### Delete Key ``` DELETE /api/keys/:id ``` **Response** (204): No content. --- ### 3.5 Snippets #### List Snippets ``` GET /api/snippets ``` **Query Parameters**: | Param | Type | Description | |-------|------|-------------| | `tag` | string | Filter by tag | | `search` | string | Search by name/command | **Response** (200): ```json { "items": [ { "id": "snippet-uuid", "name": "Update packages", "command": "sudo apt update && sudo apt upgrade -y", "description": "Update all packages on Debian/Ubuntu", "tags": ["apt", "update"], "created_at": "2024-06-22T10:00:00Z", "updated_at": "2024-06-22T10:00:00Z" } ] } ``` #### Create Snippet ``` POST /api/snippets ``` **Request Body**: ```json { "name": "Update packages", "command": "sudo apt update && sudo apt upgrade -y", "description": "Update all packages on Debian/Ubuntu", "tags": ["apt", "update"] } ``` **Response** (201): Snippet object with generated `id`. #### Update Snippet ``` PUT /api/snippets/:id ``` **Request Body**: Same as create (all fields optional). **Response** (200): Updated snippet object. #### Delete Snippet ``` DELETE /api/snippets/:id ``` **Response** (204): No content. #### Run Snippet on Host ``` POST /api/snippets/:id/run ``` **Request Body**: ```json { "host_id": "host-uuid" } ``` **Response** (200): ```json { "output": "Hit:1 http://archive.ubuntu.com/ubuntu jammy InRelease\n...", "exit_code": 0, "duration_ms": 1523 } ``` --- ### 3.6 Port Forwarding #### List Forwards ``` GET /api/forwards ``` **Response** (200): ```json { "items": [ { "id": "forward-uuid", "name": "Local Redis", "host_id": "host-uuid", "type": "local", "local_addr": "127.0.0.1:6379", "remote_addr": "localhost:6379", "status": "running", "auto_start": false, "created_at": "2024-06-22T10:00:00Z" } ] } ``` #### Create Forward ``` POST /api/forwards ``` **Request Body**: ```json { "name": "Local Redis", "host_id": "host-uuid", "type": "local", "local_addr": "127.0.0.1:6379", "remote_addr": "localhost:6379", "auto_start": false } ``` **Response** (201): Forward object. #### Delete Forward ``` DELETE /api/forwards/:id ``` **Response** (204): No content. #### Start Forward ``` POST /api/forwards/:id/start ``` **Response** (200): ```json { "status": "running" } ``` #### Stop Forward ``` POST /api/forwards/:id/stop ``` **Response** (200): ```json { "status": "stopped" } ``` --- ### 3.7 SFTP #### List Directory ``` GET /api/sftp/ls ``` **Query Parameters**: | Param | Type | Description | |-------|------|-------------| | `host_id` | string | Required. Host to connect to | | `path` | string | Directory path (default: "/") | **Response** (200): ```json { "path": "/home/admin", "items": [ { "name": "documents", "path": "/home/admin/documents", "is_dir": true, "size": 4096, "mode": "drwxr-xr-x", "mod_time": "2024-06-22T10:00:00Z" }, { "name": "file.txt", "path": "/home/admin/file.txt", "is_dir": false, "size": 1234, "mode": "-rw-r--r--", "mod_time": "2024-06-22T10:00:00Z" } ] } ``` #### Upload File ``` POST /api/sftp/upload ``` **Request**: `multipart/form-data` | Field | Type | Description | |-------|------|-------------| | `host_id` | string | Required. Host to upload to | | `path` | string | Required. Remote directory path | | `file` | file | Required. File to upload | **Response** (200): ```json { "success": true, "path": "/home/admin/file.txt", "size": 1234 } ``` #### Download File ``` GET /api/sftp/download ``` **Query Parameters**: | Param | Type | Description | |-------|------|-------------| | `host_id` | string | Required. Host to download from | | `path` | string | Required. Remote file path | **Response** (200): Binary file stream. ``` Content-Type: application/octet-stream Content-Disposition: attachment; filename="file.txt" Content-Length: 1234 ``` #### Create Directory ``` POST /api/sftp/mkdir ``` **Request Body**: ```json { "host_id": "host-uuid", "path": "/home/admin/new-folder" } ``` **Response** (201): ```json { "success": true, "path": "/home/admin/new-folder" } ``` #### Delete File/Directory ``` POST /api/sftp/rm ``` **Request Body**: ```json { "host_id": "host-uuid", "path": "/home/admin/file.txt", "recursive": false } ``` **Response** (204): No content. #### Rename/Move ``` POST /api/sftp/rename ``` **Request Body**: ```json { "host_id": "host-uuid", "old_path": "/home/admin/old-name.txt", "new_path": "/home/admin/new-name.txt" } ``` **Response** (200): ```json { "success": true, "path": "/home/admin/new-name.txt" } ``` #### Change Permissions ``` POST /api/sftp/chmod ``` **Request Body**: ```json { "host_id": "host-uuid", "path": "/home/admin/script.sh", "mode": "0755" } ``` **Response** (200): ```json { "success": true, "mode": "0755" } ``` --- ### 3.8 Config #### Get Config ``` GET /api/config ``` **Response** (200): ```json { "version": "2.0.0", "default_port": 22, "connection_timeout": 30, "keepalive_interval": 60, "theme": "dark", "editor": "vim", "auto_sync": false, "encryption_enabled": true, "known_hosts_file": "~/.hostkeeper/known_hosts", "active_profile": "default" } ``` #### Update Config ``` PUT /api/config ``` **Request Body**: Partial config object (only fields to update). **Response** (200): Updated config object. #### Unlock Vault ``` POST /api/config/unlock ``` **Request Body**: ```json { "password": "user-password" } ``` **Response** (200): ```json { "success": true, "message": "Vault unlocked" } ``` **Error** (401): ```json { "error": "Invalid password", "code": "INVALID_PASSWORD" } ``` #### Lock Vault ``` POST /api/config/lock ``` **Response** (200): ```json { "success": true, "message": "Vault locked" } ``` #### Vault Status ``` GET /api/config/status ``` **Response** (200): ```json { "locked": false, "encryption_enabled": true, "auto_lock_enabled": true, "auto_lock_minutes": 5 } ``` --- ### 3.9 Export/Import #### Export Data ``` GET /api/export ``` **Query Parameters**: | Param | Type | Default | Description | |-------|------|---------|-------------| | `format` | string | `json` | Export format (json) | | `include_keys` | boolean | false | Include private keys | | `password` | string | — | Encrypt export with password | **Response** (200): JSON file download. ``` Content-Type: application/json Content-Disposition: attachment; filename="hostkeeper-export-2024-06-22.json" ``` **Export Structure**: ```json { "version": "2.0.0", "exported_at": "2024-06-22T16:00:00Z", "encrypted": false, "data": { "hosts": [...], "groups": [...], "keys": [...], "snippets": [...], "forwards": [...], "config": {} } } ``` #### Import Data ``` POST /api/import ``` **Request**: `multipart/form-data` | Field | Type | Description | |-------|------|-------------| | `file` | file | Required. JSON export file | | `strategy` | string | "merge" or "replace" (default: "merge") | | `password` | string | Password if file is encrypted | **Response** (200): ```json { "success": true, "imported": { "hosts": 5, "groups": 2, "keys": 3, "snippets": 10, "forwards": 1 }, "skipped": { "hosts": 1 } } ``` #### Import SSH Config ``` POST /api/import/ssh-config ``` **Request**: `multipart/form-data` | Field | Type | Description | |-------|------|-------------| | `file` | file | Optional. SSH config file (default: ~/.ssh/config) | **Response** (200): ```json { "success": true, "imported": 3, "hosts": [ { "name": "myserver", "hostname": "192.168.1.100", "port": 22 } ] } ``` #### Import CSV ``` POST /api/import/csv ``` **Request**: `multipart/form-data` | Field | Type | Description | |-------|------|-------------| | `file` | file | Required. CSV file | | `strategy` | string | "merge" or "replace" | **CSV Columns**: ``` name,hostname,port,username,auth_type,group,tags,notes Production Server,192.168.1.100,22,admin,key,Production,"production,linux",Main server ``` **Response** (200): ```json { "success": true, "imported": 2 } ``` --- ### 3.10 Profiles #### List Profiles ``` GET /api/profiles ``` **Response** (200): ```json { "items": [ { "name": "default", "theme": "dark", "default_group": null, "default_auth": "key", "editor": "vim" } ], "active": "default" } ``` #### Create Profile ``` POST /api/profiles ``` **Request Body**: ```json { "name": "work", "theme": "light", "default_group": "work-servers", "default_auth": "key", "editor": "nano" } ``` **Response** (201): Profile object. #### Switch Active Profile ``` PUT /api/profiles/active ``` **Request Body**: ```json { "name": "work" } ``` **Response** (200): ```json { "active": "work" } ``` #### Delete Profile ``` DELETE /api/profiles/:name ``` **Response** (204): No content. --- ## 4. WebSocket Endpoints ### 4.1 Terminal Connection ``` WS /api/terminal/connect?host_id=xxx&cols=80&rows=24 ``` **Connection Parameters** (query string): | Param | Type | Description | |-------|------|-------------| | `host_id` | string | Required. Host to connect to | | `cols` | int | Terminal columns (default: 80) | | `rows` | int | Terminal rows (default: 24) | **Connection Flow**: 1. Client opens WebSocket connection 2. Server validates host_id exists 3. Server creates SSH connection to host (or reuses pool) 4. Server requests PTY with xterm-256color 5. Server sends `{ "type": "status", "state": "connected" }` 6. Bidirectional streaming begins **Message Protocol** (binary frames): | Direction | Content | Description | |-----------|---------|-------------| | Client → Server | Raw bytes | Keyboard input | | Server → Client | Raw bytes | Terminal output | | Client → Server | JSON resize | `{"type":"resize","cols":120,"rows":40}` | | Server → Client | JSON status | `{"type":"status","state":"connected"}` | | Server → Client | JSON error | `{"type":"error","message":"Connection refused"}` | **Keepalive**: - Client sends WebSocket ping every 30 seconds - Server responds with pong - If no pong received for 60 seconds, connection is closed **Disconnect**: - Client sends close frame (1000 normal, 1001 going away) - Server closes SSH session and responds with close frame --- ### 4.2 SFTP Stream (Future) ``` WS /api/sftp/stream?host_id=xxx ``` **Note**: Initial version uses REST for SFTP. WebSocket streaming may be added later for large file transfers with progress. --- ## 5. Middleware ### CORS ```go app.Use(cors.New(cors.Config{ AllowOrigins: "http://localhost:5173,http://localhost:*", AllowMethods: "GET,POST,PUT,PATCH,DELETE,OPTIONS", AllowHeaders: "Origin,Content-Type,Authorization", })) ``` ### Request Logging ```go app.Use(logger.New(logger.Config{ Format: "${time} ${method} ${path} ${status} ${latency}\n", })) ``` ### Panic Recovery ```go app.Use(recover.New()) ``` ### Compression ```go app.Use(compress.New(compress.Config{ Level: compress.LevelBestSpeed, })) ``` ### Error Handler ```go app.Use(func(c *fiber.Ctx) error { err := c.Next() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": err.Error(), "code": "INTERNAL_ERROR", }) } return nil }) ``` --- ## 6. Rate Limiting | Endpoint | Limit | Window | |----------|-------|--------| | `/api/config/unlock` | 5 attempts | 5 minutes | | `/api/terminal/connect` | 10 connections | 1 minute | | `/api/sftp/upload` | 100 MB | per request | --- ## 7. GoFiber Handler Examples ### REST Handler ```go package bridge import ( "github.com/gofiber/fiber/v2" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" ) func ListHosts(c *fiber.Ctx) error { // Parse query params search := c.Query("search", "") group := c.Query("group", "") // Call storage hosts, err := storage.ListHosts(c.Context()) if err != nil { return c.Status(500).JSON(fiber.Map{ "error": "Failed to list hosts", "code": "STORAGE_ERROR", }) } // Filter if search != "" { hosts = filterBySearch(hosts, search) } return c.JSON(fiber.Map{ "items": hosts, "total": len(hosts), }) } func CreateHost(c *fiber.Ctx) error { var host models.Host if err := c.BodyParser(&host); err != nil { return c.Status(400).JSON(fiber.Map{ "error": "Invalid request body", "code": "INVALID_BODY", }) } // Validate if host.Name == "" { return c.Status(400).JSON(fiber.Map{ "error": "Name is required", "code": "VALIDATION_ERROR", }) } // Save if err := storage.SaveHost(c.Context(), &host); err != nil { return c.Status(500).JSON(fiber.Map{ "error": "Failed to save host", "code": "STORAGE_ERROR", }) } return c.Status(201).JSON(host) } ``` ### WebSocket Handler ```go package ws import ( "github.com/gofiber/contrib/websocket" "git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh" ) func HandleTerminal(c *websocket.Conn) { hostID := c.Query("host_id") cols := c.QueryInt("cols", 80) rows := c.QueryInt("rows", 24) // Lookup host host, err := storage.GetHost(c.Context(), hostID) if err != nil { c.WriteJSON(fiber.Map{"type": "error", "message": "Host not found"}) return } // Create SSH client client := ssh.NewClient(host, 30*time.Second) if err := client.Connect(c.Context()); err != nil { c.WriteJSON(fiber.Map{"type": "error", "message": err.Error()}) return } defer client.Close() // Request PTY session, err := client.GetClient().NewSession() if err != nil { c.WriteJSON(fiber.Map{"type": "error", "message": "Failed to create session"}) return } defer session.Close() modes := ssh.TerminalModes{ssh.ECHO: 1} session.RequestPty("xterm-256color", rows, cols, modes) // Pipe I/O stdin, _ := session.StdinPipe() stdout, _ := session.StdoutPipe() session.Shell() // Read from WebSocket -> write to SSH go func() { for { _, msg, err := c.ReadMessage() if err != nil { break } // Check if resize message if len(msg) > 0 && msg[0] == '{' { var resize struct { Type string `json:"type"` Cols int `json:"cols"` Rows int `json:"rows"` } if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" { session.WindowChange(resize.Rows, resize.Cols) continue } } stdin.Write(msg) } }() // Read from SSH -> write to WebSocket buf := make([]byte, 4096) for { n, err := stdout.Read(buf) if err != nil { break } c.WriteMessage(websocket.BinaryMessage, buf[:n]) } } ```