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:
@@ -3,9 +3,10 @@ package handler
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/store"
|
||||
)
|
||||
|
||||
var store = model.NewHostStore()
|
||||
var hostStore = store.NewHostStore()
|
||||
|
||||
var navItems = []fiber.Map{
|
||||
{"ID": "hosts", "Label": "Hosts", "Icon": "server"},
|
||||
@@ -17,7 +18,7 @@ var navItems = []fiber.Map{
|
||||
}
|
||||
|
||||
func Dashboard(c *fiber.Ctx) error {
|
||||
hosts := store.All()
|
||||
hosts := hostStore.All()
|
||||
return c.Render("index", fiber.Map{
|
||||
"View": "hosts",
|
||||
"Title": "Hosts",
|
||||
@@ -31,7 +32,7 @@ func Dashboard(c *fiber.Ctx) error {
|
||||
func DashboardList(c *fiber.Ctx) error {
|
||||
q := c.Query("q")
|
||||
filter := c.Query("filter", "all")
|
||||
hosts := store.Search(q, filter)
|
||||
hosts := hostStore.Search(q, filter)
|
||||
return c.Render("host_list", fiber.Map{
|
||||
"Hosts": hosts,
|
||||
"Active": countByStatus(hosts, "active"),
|
||||
@@ -50,8 +51,8 @@ func CreateHost(c *fiber.Ctx) error {
|
||||
return c.Status(400).SendString("name and ip required")
|
||||
}
|
||||
|
||||
store.Add(name, ip, os, provider, hostType)
|
||||
hosts := store.All()
|
||||
hostStore.Add(name, ip, os, provider, hostType)
|
||||
hosts := hostStore.All()
|
||||
return c.Render("host_list", fiber.Map{
|
||||
"Hosts": hosts,
|
||||
"Active": countByStatus(hosts, "active"),
|
||||
@@ -61,8 +62,8 @@ func CreateHost(c *fiber.Ctx) error {
|
||||
|
||||
func DeleteHost(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
store.Delete(id)
|
||||
hosts := store.All()
|
||||
hostStore.Delete(id)
|
||||
hosts := hostStore.All()
|
||||
return c.Render("host_list", fiber.Map{
|
||||
"Hosts": hosts,
|
||||
"Active": countByStatus(hosts, "active"),
|
||||
|
||||
@@ -2,10 +2,10 @@ package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/store"
|
||||
)
|
||||
|
||||
var keyStore = model.NewKeyStore()
|
||||
var keyStore = store.NewKeyStore()
|
||||
|
||||
func Keychain(c *fiber.Ctx) error {
|
||||
keys := keyStore.All()
|
||||
|
||||
@@ -3,23 +3,24 @@ package handler
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/store"
|
||||
)
|
||||
|
||||
var deviceStore = model.NewDeviceStore()
|
||||
var configStore = model.NewConfigStore()
|
||||
var settingsStore = store.NewSettingsStore()
|
||||
|
||||
func Settings(c *fiber.Ctx) error {
|
||||
return c.Render("settings", fiber.Map{
|
||||
"View": "settings",
|
||||
"Title": "Settings",
|
||||
"Config": configStore.Get(),
|
||||
"Config": settingsStore.Get(),
|
||||
"Devices": deviceStore.All(),
|
||||
"NavItems": navItems,
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateConfig(c *fiber.Ctx) error {
|
||||
cfg := configStore.Get()
|
||||
cfg := settingsStore.Get()
|
||||
if v := c.FormValue("theme"); v != "" { cfg.Theme = v }
|
||||
if v := c.FormValue("fontSize"); v != "" { cfg.FontSize = parseInt(v, 14) }
|
||||
if v := c.FormValue("scrollback"); v != "" { cfg.Scrollback = parseInt(v, 5000) }
|
||||
@@ -29,7 +30,7 @@ func UpdateConfig(c *fiber.Ctx) error {
|
||||
cfg.BellEnabled = c.FormValue("bellEnabled") == "on"
|
||||
cfg.AutoReconnect = c.FormValue("autoReconnect") == "on"
|
||||
cfg.BlinkCursor = c.FormValue("blinkCursor") == "on"
|
||||
configStore.Update(cfg)
|
||||
settingsStore.Update(cfg)
|
||||
return c.Redirect("/settings", 303)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/store"
|
||||
)
|
||||
|
||||
var snippetStore = model.NewSnippetStore()
|
||||
var snippetStore = store.NewSnippetStore()
|
||||
|
||||
func Snippets(c *fiber.Ctx) error {
|
||||
snippets := snippetStore.All()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user