Files
swanadiva 71864336f6 fix: terminal WebSocket connection timing + better mock fallback
- Fix xterm.js container not found: use requestAnimationFrame retry loop
  instead of single $nextTick (Alpine x-for renders asynchronously)
- Better mock fallback: use host name+IP instead of raw UUID
- Add logging for WebSocket connection path (found/not-found/hostname)
2026-07-09 18:40:35 +07:00

225 lines
7.0 KiB
Go

package handler
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"time"
"github.com/gofiber/contrib/websocket"
"github.com/gofiber/fiber/v2"
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/sshconn"
)
func Terminal(c *fiber.Ctx) error {
hosts := hostStore.All()
type hostItem struct {
ID string `json:"id"`
Name string `json:"name"`
IP string `json:"ip"`
}
var available []hostItem
for _, h := range hosts {
if h.Status == "active" {
available = append(available, hostItem{h.ID, h.Name, h.IP})
}
}
hostsJSON, _ := json.Marshal(available)
selectedHost := c.Query("host", "")
data := fiber.Map{
"View": "terminal",
"Title": "Terminal",
"NavItems": navItems,
"Hosts": available,
"HostsJSON": string(hostsJSON),
"SelectedHost": selectedHost,
}
if c.Get("HX-Request") != "" {
return c.Render("terminal_content", data)
}
return c.Render("terminal", data)
}
func TerminalWS(c *websocket.Conn) {
hostID := c.Params("host", "unknown")
log.Printf("WS connected: host=%s", hostID)
host, found := hostStore.Get(hostID)
if !found {
log.Printf("Host %s not found, using mock", hostID)
mockTerminal(c, hostID)
return
}
if host.Hostname == "" {
log.Printf("Host %s has no hostname, using mock (name=%s, ip=%s)", hostID, host.Name, host.IP)
mockTerminal(c, host.Name+" ("+host.IP+")")
return
}
client, err := sshconn.Dial(host)
if err != nil {
log.Printf("SSH failed for %s: %v, falling back to mock", hostID, err)
mockTerminal(c, hostID)
return
}
defer client.Close()
welcome := fmt.Sprintf("\x1b[1;32mConnected to %s (%s)\x1b[0m\r\n", host.Name, host.IP)
c.WriteMessage(websocket.TextMessage, []byte(welcome))
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
go func() {
scanner := bufio.NewScanner(stdoutR)
for scanner.Scan() {
c.WriteMessage(websocket.TextMessage, []byte(scanner.Text()+"\r\n"))
}
}()
go func() {
defer stdinW.Close()
for {
_, msg, err := c.ReadMessage()
if err != nil {
return
}
input := string(msg)
if input == "\x03" {
client.Close()
return
}
stdinW.Write([]byte(input))
}
}()
if err := client.Shell(stdinR, stdoutW, stdoutW, 24, 80); err != nil {
log.Printf("SSH session ended: %v", err)
}
log.Printf("WS disconnected: host=%s", hostID)
}
func mockTerminal(c *websocket.Conn, host string) {
welcome := fmt.Sprintf("\x1b[1;33m%s (mock mode)\x1b[0m\r\n\x1b[2mConnected at %s\x1b[0m\r\n\r\n", host, time.Now().Format(time.RFC822))
c.WriteMessage(websocket.TextMessage, []byte(welcome))
prompt := fmt.Sprintf("\x1b[1;34m%s:~$\x1b[0m ", host)
c.WriteMessage(websocket.TextMessage, []byte(prompt))
cwd := "~"
buf := ""
for {
_, msg, err := c.ReadMessage()
if err != nil {
break
}
for _, ch := range string(msg) {
switch {
case ch == '\r':
output := handleCommand(buf, host, &cwd)
c.WriteMessage(websocket.TextMessage, []byte(output+prompt))
buf = ""
case ch == '\x7f':
if len(buf) > 0 {
buf = buf[:len(buf)-1]
c.WriteMessage(websocket.TextMessage, []byte("\b \b"))
}
case ch == '\x03':
buf = ""
c.WriteMessage(websocket.TextMessage, []byte("^C\r\n"+prompt))
case ch >= ' ' && ch <= '~':
buf += string(ch)
c.WriteMessage(websocket.TextMessage, []byte(string(ch)))
}
}
}
log.Printf("Mock WS disconnected: host=%s", host)
}
func handleCommand(cmd, host string, cwd *string) string {
cmd = strings.TrimSpace(cmd)
if cmd == "" { return "" }
parts := strings.Fields(cmd)
if len(parts) == 0 { return "" }
switch parts[0] {
case "clear":
return "\x1b[2J\x1b[H"
case "exit", "logout":
return "logout\r\n\x1b[2J\x1b[H\x1b[1;31mConnection closed.\x1b[0m\r\n"
case "help":
return "HostKeeper Mock SSH Interactive Command Parser:\r\n" +
" help - Display this support manifest list\r\n" +
" ls - List contents of the current working directory\r\n" +
" docker ps - List simulated running Docker containers on cluster\r\n" +
" uname -a - Show operating system and machine kernel data\r\n" +
" ping 8.8.8.8 - Probe network gateway performance\r\n" +
" cat server.js - Output snippet of remote index server configuration\r\n" +
" keychain - Query keychain credentials loaded for target session\r\n" +
" clear - Wipe the terminal display buffer clean\r\n"
case "ls":
if *cwd == "~" || *cwd == "/home/deploy" {
return "drwxr-xr-x 3 root root 4096 Jul 6 12:00 controllers\r\n" +
"drwxr-xr-x 2 root root 4096 Jul 6 12:00 models\r\n" +
"drwxr-xr-x 2 root root 4096 Jul 6 12:00 routes\r\n" +
"-rw-r--r-- 1 root root 280 Jul 6 11:34 .env\r\n" +
"-rw-r--r-- 1 root root 1432 Jul 6 14:20 package.json\r\n" +
"-rwxr-xr-x 1 root root 8412 Jul 6 15:43 server.js\r\n"
}
return "total 0\r\n"
case "cd":
if len(parts) > 1 { *cwd = parts[1] } else { *cwd = "~" }
return ""
case "docker ps":
return "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS\r\n" +
"f87a2d12e9b0 node:18-alpine \"docker-entrypoint.s…\" 2 hours ago Up 2 hours 0.0.0.0:3000->3000/tcp\r\n" +
"c23e84bf9211 postgres:15-alpine \"docker-entrypoint.s…\" 5 hours ago Up 5 hours 0.0.0.0:5432->5432/tcp\r\n" +
"78da12b84e0c redis:7-alpine \"docker-entrypoint.s…\" 10 hours ago Up 10 hours 0.0.0.0:6379->6379/tcp\r\n"
case "uname":
if len(parts) > 1 && parts[1] == "-a" {
return fmt.Sprintf("Linux %s 5.15.0-101-generic #111-Ubuntu SMP Wed Jul 6 21:27:00 UTC 2026 x86_64 GNU/Linux\r\n", host)
}
return "Linux\r\n"
case "ping":
return "64 bytes from 8.8.8.8: icmp_seq=1 ttl=116 time=12.4 ms\r\n" +
"64 bytes from 8.8.8.8: icmp_seq=2 ttl=116 time=14.1 ms\r\n" +
"64 bytes from 8.8.8.8: icmp_seq=3 ttl=116 time=11.8 ms\r\n" +
"--- 8.8.8.8 ping statistics ---\r\n" +
"3 packets transmitted, 3 received, 0% packet loss, rtt min/avg/max = 11.8/12.76/14.1 ms\r\n"
case "cat":
if len(parts) > 1 && parts[1] == "server.js" {
return "const express = require(\"express\");\r\n" +
"const app = express();\r\n" +
"const PORT = process.env.PORT || 3000;\r\n" +
"app.get(\"/api/health\", (req, res) => res.send({ status: \"healthy\" }));\r\n" +
"app.listen(PORT, () => console.log(\"Server active on cluster ingress\"));\r\n"
}
if len(parts) > 1 {
return fmt.Sprintf("# Content of %s\r\n(not implemented in mock shell)\r\n", parts[1])
}
return ""
case "keychain":
return "Keychain Credential Mapping Selected:\r\n" +
" Active Key: id_ed25519_alex (ED25519 standard)\r\n" +
" Encryption: AES-256 GCM cryptokey payload\r\n" +
" Fingerprint: SHA256:7mP9K9+fVj5bW0vQ8zD1y2u3t4m5n6p7q8r9s0v1w2x\r\n"
case "whoami":
return "deploy\r\n"
case "hostname":
return host + "\r\n"
case "pwd":
return *cwd + "\r\n"
case "echo":
return strings.Join(parts[1:], " ") + "\r\n"
default:
return fmt.Sprintf("hostkeeper: command not found: \"%s\". Type \"help\" to view custom commands list.\r\n", parts[0])
}
}