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 || host.Hostname == "" { mockTerminal(c, hostID) 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]) } }