1304 lines
22 KiB
Markdown
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])
|
|
}
|
|
}
|
|
```
|