Files
HostKeeper/docs/API.md
T
swanadiva c215d68c81 refactor: flatten docs/ — move V2 docs from docs/v2/ to docs/
- git mv docs/v2/*.md docs/ (11 planning documents)
- Remove empty docs/v2/ directory
- Update all references: AGENTS.md, CHANGELOG.md, README.md, ARCHITECTURE.md
- Fix outdated repo tree in ARCHITECTURE.md to match actual structure
2026-07-07 12:04:59 +07:00

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 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):

{
    "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:

  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

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])
    }
}