8357d9d76e
- All 7 pages rewritten: Dashboard, Terminal, Snippets, Keychain, SFTP, Settings, Brief - Apply consistent Lumina glassmorphic theme (rounded-2xl, shadows, borders, tracking) - Fix SVG icons not rendering — return template.HTML instead of string - Fix Alpine x-data scoping: terminal tabs, SFTP modals, snippet tag buttons - Fix dashboard search sending literal ?q=... - Rebuild CSS with all new Tailwind classes (62KB) - Add .air.toml with hot-reload config, docs/PERBAIKANUI.md checklist - Remove unused backup partials
187 lines
4.6 KiB
Go
187 lines
4.6 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)
|
|
data := fiber.Map{
|
|
"View": "terminal",
|
|
"Title": "Terminal",
|
|
"NavItems": navItems,
|
|
"Hosts": available,
|
|
"HostsJSON": string(hostsJSON),
|
|
}
|
|
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)
|
|
cwd := "~"
|
|
|
|
buf := ""
|
|
for {
|
|
_, msg, err := c.ReadMessage()
|
|
if err != nil {
|
|
break
|
|
}
|
|
|
|
input := string(msg)
|
|
|
|
switch {
|
|
case input == "\r":
|
|
output := handleCommand(buf, host, &cwd)
|
|
c.WriteMessage(websocket.TextMessage, []byte(output+prompt))
|
|
buf = ""
|
|
case input == "\x7f":
|
|
if len(buf) > 0 {
|
|
buf = buf[:len(buf)-1]
|
|
c.WriteMessage(websocket.TextMessage, []byte("\b \b"))
|
|
}
|
|
case input == "\x03":
|
|
buf = ""
|
|
c.WriteMessage(websocket.TextMessage, []byte("^C\r\n"+prompt))
|
|
default:
|
|
if input >= " " && input <= "~" {
|
|
buf += input
|
|
c.WriteMessage(websocket.TextMessage, []byte(input))
|
|
}
|
|
}
|
|
}
|
|
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 "pwd":
|
|
return *cwd + "\r\n"
|
|
case "whoami":
|
|
return "deploy\r\n"
|
|
case "hostname":
|
|
return host + "\r\n"
|
|
case "date":
|
|
return time.Now().Format("Mon Jan 2 15:04:05 MST 2006") + "\r\n"
|
|
case "uptime":
|
|
return fmt.Sprintf(" 13:42:17 up %d day, 1 user, load average: 0.08, 0.12, 0.10\r\n", 1+time.Now().Day()%30)
|
|
case "uname":
|
|
if len(parts) > 1 && parts[1] == "-a" {
|
|
return fmt.Sprintf("Linux %s 6.8.0-45-generic #47-Ubuntu SMP PREEMPT x86_64 x86_64 x86_64 GNU/Linux\r\n", host)
|
|
}
|
|
return "Linux\r\n"
|
|
case "ls":
|
|
if *cwd == "~" || *cwd == "/home/deploy" {
|
|
return "Documents Downloads projects config.yml README.md .env\r\n"
|
|
}
|
|
return "total 0\r\n"
|
|
case "cd":
|
|
if len(parts) > 1 { *cwd = parts[1] } else { *cwd = "~" }
|
|
return ""
|
|
case "cat":
|
|
if len(parts) > 1 {
|
|
return fmt.Sprintf("\x1b[1;37m# Content of %s\x1b[0m\r\n(not implemented in mock shell)\r\n", parts[1])
|
|
}
|
|
return ""
|
|
case "echo":
|
|
return strings.Join(parts[1:], " ") + "\r\n"
|
|
case "ssh":
|
|
return "\x1b[1;33mSSH forwarding not available in mock mode\x1b[0m\r\n"
|
|
case "help":
|
|
return "Available commands: clear, exit, pwd, whoami, hostname, date, uptime, uname, ls, cd, cat, echo, ssh, help\r\n"
|
|
default:
|
|
return fmt.Sprintf("\x1b[1;31m%s: command not found\x1b[0m\r\n", parts[0])
|
|
}
|
|
}
|