Sprint 6: JSON file persistence + real SSH terminal + V1 crypto/knownhosts integration

- go.mod replace directive for git.tukangketik.id/swanadiva/hostkeeper → ../../v1
- store/ package: JSON file persistence for hosts, snippets, keys, settings
  (~/Library/Application Support/hostkeeper/v2/)
- model SSH fields: Hostname, Port, Username, AuthType, Password, KeyID
- sshconn/ package: real SSH client via golang.org/x/crypto/ssh
- Terminal WS: real SSH connection with fallback to mock shell
- Dynamic host list in terminal (server-rendered JSON script tag)
- All mock data defaults removed from model packages
- Settings persist via store/settings.go
This commit is contained in:
swanadiva
2026-07-07 13:53:50 +07:00
parent 04d9cfb096
commit fd7361aa14
21 changed files with 746 additions and 306 deletions
+80 -29
View File
@@ -1,39 +1,98 @@
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)
return c.Render("terminal", fiber.Map{
"View": "terminal",
"Title": "Terminal",
"NavItems": navItems,
"View": "terminal",
"Title": "Terminal",
"NavItems": navItems,
"Hosts": available,
"HostsJSON": string(hostsJSON),
})
}
var hostList = []struct {
ID string
Name string
IP string
}{
{"h1", "api-prod-01", "10.0.1.15"},
{"h2", "db-primary-01", "10.0.2.5"},
{"h6", "monitor-01", "10.0.0.5"},
{"h7", "worker-pool-01", "10.0.4.50"},
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 TerminalWS(c *websocket.Conn) {
host := c.Params("host", "unknown")
log.Printf("WS connected: host=%s", host)
welcome := fmt.Sprintf("\x1b[1;32mWelcome to %s\x1b[0m\r\n\x1b[2mConnected at %s\x1b[0m\r\n\r\n", host, time.Now().Format(time.RFC822))
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)
@@ -68,19 +127,15 @@ func TerminalWS(c *websocket.Conn) {
}
}
}
log.Printf("WS disconnected: host=%s", host)
log.Printf("Mock WS disconnected: host=%s", host)
}
func handleCommand(cmd, host string, cwd *string) string {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return ""
}
if cmd == "" { return "" }
parts := strings.Fields(cmd)
if len(parts) == 0 {
return ""
}
if len(parts) == 0 { return "" }
switch parts[0] {
case "clear":
@@ -108,11 +163,7 @@ func handleCommand(cmd, host string, cwd *string) string {
}
return "total 0\r\n"
case "cd":
if len(parts) > 1 {
*cwd = parts[1]
} else {
*cwd = "~"
}
if len(parts) > 1 { *cwd = parts[1] } else { *cwd = "~" }
return ""
case "cat":
if len(parts) > 1 {