22 KiB
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
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Production Server",
"hostname": "192.168.1.100",
"port": 22,
"username": "admin"
}
List Response
{
"items": [...],
"total": 42,
"page": 1,
"per_page": 50
}
Error Response
{
"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):
{
"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):
{
"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:
{
"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 charshostname: required, valid hostname or IPport: required, 1-65535username: required, max 100 charsauth.type: required, one of "password", "key", "agent"
Get Host
GET /api/hosts/:id
Response (200): Full host object.
Error (404):
{
"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):
{
"id": "host-uuid",
"is_favorite": true
}
3.3 Groups
List Groups (Tree)
GET /api/groups
Response (200):
{
"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:
{
"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:
{
"parent_id": "new-parent-uuid"
}
Response (200): Updated group with new parent.
Move Host to Group
PUT /api/hosts/:id/move
Request Body:
{
"group_id": "target-group-uuid"
}
Response (200): Updated host with new group.
3.4 Keys
List Keys
GET /api/keys
Response (200):
{
"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):
{
"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:
{
"name": "New Key",
"type": "ed25519",
"passphrase": "optional-passphrase"
}
Response (201):
{
"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):
{
"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:
{
"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:
{
"host_id": "host-uuid"
}
Response (200):
{
"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):
{
"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:
{
"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):
{
"status": "running"
}
Stop Forward
POST /api/forwards/:id/stop
Response (200):
{
"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):
{
"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):
{
"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:
{
"host_id": "host-uuid",
"path": "/home/admin/new-folder"
}
Response (201):
{
"success": true,
"path": "/home/admin/new-folder"
}
Delete File/Directory
POST /api/sftp/rm
Request Body:
{
"host_id": "host-uuid",
"path": "/home/admin/file.txt",
"recursive": false
}
Response (204): No content.
Rename/Move
POST /api/sftp/rename
Request Body:
{
"host_id": "host-uuid",
"old_path": "/home/admin/old-name.txt",
"new_path": "/home/admin/new-name.txt"
}
Response (200):
{
"success": true,
"path": "/home/admin/new-name.txt"
}
Change Permissions
POST /api/sftp/chmod
Request Body:
{
"host_id": "host-uuid",
"path": "/home/admin/script.sh",
"mode": "0755"
}
Response (200):
{
"success": true,
"mode": "0755"
}
3.8 Config
Get Config
GET /api/config
Response (200):
{
"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:
{
"password": "user-password"
}
Response (200):
{
"success": true,
"message": "Vault unlocked"
}
Error (401):
{
"error": "Invalid password",
"code": "INVALID_PASSWORD"
}
Lock Vault
POST /api/config/lock
Response (200):
{
"success": true,
"message": "Vault locked"
}
Vault Status
GET /api/config/status
Response (200):
{
"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:
{
"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):
{
"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):
{
"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):
{
"success": true,
"imported": 2
}
3.10 Profiles
List Profiles
GET /api/profiles
Response (200):
{
"items": [
{
"name": "default",
"theme": "dark",
"default_group": null,
"default_auth": "key",
"editor": "vim"
}
],
"active": "default"
}
Create Profile
POST /api/profiles
Request Body:
{
"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:
{
"name": "work"
}
Response (200):
{
"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:
- Client opens WebSocket connection
- Server validates host_id exists
- Server creates SSH connection to host (or reuses pool)
- Server requests PTY with xterm-256color
- Server sends
{ "type": "status", "state": "connected" } - 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
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
app.Use(logger.New(logger.Config{
Format: "${time} ${method} ${path} ${status} ${latency}\n",
}))
Panic Recovery
app.Use(recover.New())
Compression
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed,
}))
Error Handler
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
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
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])
}
}