Files
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

1304 lines
22 KiB
Markdown

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