From fd7361aa1443ccd46362ea78d5dc02613b35dbb1 Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 7 Jul 2026 13:53:50 +0700 Subject: [PATCH] Sprint 6: JSON file persistence + real SSH terminal + V1 crypto/knownhosts integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 28 +++-- app/backend/go.mod | 15 ++- app/backend/go.sum | 22 ++-- app/backend/internal/handler/dashboard.go | 15 +-- app/backend/internal/handler/keychain.go | 4 +- app/backend/internal/handler/settings.go | 9 +- app/backend/internal/handler/snippets.go | 4 +- app/backend/internal/handler/terminal.go | 109 ++++++++++++----- app/backend/internal/model/host.go | 100 ++-------------- app/backend/internal/model/keychain.go | 69 +---------- app/backend/internal/model/snippet.go | 51 -------- app/backend/internal/sshconn/client.go | 138 ++++++++++++++++++++++ app/backend/internal/store/devices.go | 32 +++++ app/backend/internal/store/host.go | 129 ++++++++++++++++++++ app/backend/internal/store/key.go | 94 +++++++++++++++ app/backend/internal/store/settings.go | 42 +++++++ app/backend/internal/store/snippet.go | 70 +++++++++++ app/backend/internal/store/store.go | 59 +++++++++ app/backend/static/js/terminal.js | 10 +- app/backend/views/terminal.html | 1 + docs/PROGRESS.md | 51 ++++---- 21 files changed, 746 insertions(+), 306 deletions(-) create mode 100644 app/backend/internal/sshconn/client.go create mode 100644 app/backend/internal/store/devices.go create mode 100644 app/backend/internal/store/host.go create mode 100644 app/backend/internal/store/key.go create mode 100644 app/backend/internal/store/settings.go create mode 100644 app/backend/internal/store/snippet.go create mode 100644 app/backend/internal/store/store.go diff --git a/AGENTS.md b/AGENTS.md index 0591c7f..9b4b5bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ > **Purpose**: Memungkinkan AI agent berikutnya melanjutkan development tanpa mengulang atau merusak. > **Last Updated**: 2026-07-07 -> **Current Phase**: Sprint 1 + 2 + 3 + 4 + 5 selesai, Sprint 6 berikutnya +> **Current Phase**: Sprint 0—6 selesai (V2 HTMX feature complete) --- @@ -20,7 +20,7 @@ hostkeeper/ (root — bersih untuk V2) │ ├── go.mod module git.tukangketik.id/swanadiva/hostkeeper │ ├── Makefile │ └── PROJECT_STATE.md V1 full project state -├── app/ ← (BELUM DIBUAT) V2 backend GoFiber + views + static +├── app/ ← V2 backend GoFiber + views + static ├── mobile/ ← (BELUM DIBUAT) V2 mobile wrapper ├── template/ ← Referensi UI React (Lumina System) — hanya untuk desain │ └── src/components/ 7 React screens @@ -102,7 +102,7 @@ Alasan: --- -## 4. V2 Status — Sprint 1 Complete +## 4. V2 Status — Sprint 0—6 Complete ### ✅ Sudah Selesai - Semua dokumentasi diupdate ke HTMX (10 file di `docs/`) @@ -112,13 +112,11 @@ Alasan: - Bug template sudah diidentifikasi (animasi CSS, w-4.5, error boundary, dll) - **Sprint 0**: GoFiber v2 server + HTMX + Alpine.js + TailwindCSS v4 + custom CSS + layout + nav + health endpoint - **Sprint 1**: Host model (8 mock hosts), dashboard CRUD, search/filter HTMX, grid/list Alpine toggle, add host modal (Alpine events), delete host (hx-delete), transfers panel - -### ❌ Belum Dimulai / In Progress -- **Sprint 2 (DONE)**: Snippets + Keychain -- **Sprint 3 (DONE)**: Settings + Brief -- **Sprint 4 (DONE)**: SFTP Dual-Pane -- **Sprint 5 (DONE)**: Terminal + xterm.js -- Sprint 6: Storage Integration +- **Sprint 2**: Snippets + Keychain CRUD, clipboard.js, utils.js +- **Sprint 3**: Settings + Brief, tabs, toggles, danger zone +- **Sprint 4**: SFTP Dual-Pane, breadcrumb, file icons by type, create/delete +- **Sprint 5**: Terminal + xterm.js + WebSocket mock shell, tab management +- **Sprint 6**: JSON file persistence (store package), `go.mod replace` directive for V1 crypto+knownhosts, model SSH fields, real SSH client for terminal (falls back to mock), dynamic host list from server ### 🐛 Bug Template (dari template/src/ — catatan saat implementasi) 1. **P0**: Missing CSS keyframes (fade-in, scale-up, slide-in) → tambah di `custom.css` @@ -149,6 +147,15 @@ app/backend/ │ │ ├── host.go │ │ ├── snippet.go │ │ └── keychain.go +│ ├── store/ ← JSON file persistence (store package) +│ │ ├── store.go +│ │ ├── host.go +│ │ ├── snippet.go +│ │ ├── key.go +│ │ ├── settings.go +│ │ └── devices.go +│ ├── sshconn/ ← Real SSH client for terminal +│ │ └── client.go │ ├── template/ ← Template helper funcs │ └── middleware/ ← View data injection ├── static/ @@ -156,7 +163,6 @@ app/backend/ │ ├── js/ ← htmx.min.js, alpine.min.js, utils.js, clipboard.js, terminal.js │ └── xterm/ ← xterm.js lib └── views/ ← Semua template flat (tidak ada subfolder partials/) - ├── layout.html ├── nav.html ├── index.html ├── host_list.html diff --git a/app/backend/go.mod b/app/backend/go.mod index ebca2ca..3b7a4ba 100644 --- a/app/backend/go.mod +++ b/app/backend/go.mod @@ -3,26 +3,31 @@ module git.tukangketik.id/swanadiva/hostkeeper/v2 go 1.26.4 require ( + git.tukangketik.id/swanadiva/hostkeeper v1.0.0 + github.com/gofiber/contrib/websocket v1.3.4 github.com/gofiber/fiber/v2 v2.52.14 github.com/gofiber/template/html/v2 v2.1.3 ) require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/fasthttp/websocket v1.5.8 // indirect - github.com/gofiber/contrib/websocket v1.3.4 // indirect github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/utils v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.52.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.46.0 // indirect ) + +replace git.tukangketik.id/swanadiva/hostkeeper => ../../v1 diff --git a/app/backend/go.sum b/app/backend/go.sum index 346aeeb..3f52587 100644 --- a/app/backend/go.sum +++ b/app/backend/go.sum @@ -1,5 +1,9 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8= @@ -23,29 +27,27 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8= github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0= github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/app/backend/internal/handler/dashboard.go b/app/backend/internal/handler/dashboard.go index d219897..ee10bf6 100644 --- a/app/backend/internal/handler/dashboard.go +++ b/app/backend/internal/handler/dashboard.go @@ -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"), diff --git a/app/backend/internal/handler/keychain.go b/app/backend/internal/handler/keychain.go index a8f7666..1bcdd9e 100644 --- a/app/backend/internal/handler/keychain.go +++ b/app/backend/internal/handler/keychain.go @@ -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() diff --git a/app/backend/internal/handler/settings.go b/app/backend/internal/handler/settings.go index 6ea6415..192fd13 100644 --- a/app/backend/internal/handler/settings.go +++ b/app/backend/internal/handler/settings.go @@ -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) } diff --git a/app/backend/internal/handler/snippets.go b/app/backend/internal/handler/snippets.go index 79e008b..bc38e92 100644 --- a/app/backend/internal/handler/snippets.go +++ b/app/backend/internal/handler/snippets.go @@ -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() diff --git a/app/backend/internal/handler/terminal.go b/app/backend/internal/handler/terminal.go index 56077fc..ddc40dd 100644 --- a/app/backend/internal/handler/terminal.go +++ b/app/backend/internal/handler/terminal.go @@ -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 { diff --git a/app/backend/internal/model/host.go b/app/backend/internal/model/host.go index c75bc21..99eff7c 100644 --- a/app/backend/internal/model/host.go +++ b/app/backend/internal/model/host.go @@ -1,100 +1,22 @@ package model -import ( - "math/rand" - "strings" - "time" -) - type Host struct { ID string `json:"id"` Name string `json:"name"` IP string `json:"ip"` OS string `json:"os"` Provider string `json:"provider"` - Status string `json:"status"` // active | offline + Status string `json:"status"` LastSeen string `json:"lastSeen"` - Type string `json:"type"` // api | db | edge | web | desktop | server -} + Type string `json:"type"` -type HostStore struct { - Hosts []Host -} - -func NewHostStore() *HostStore { - return &HostStore{ - Hosts: defaultHosts(), - } -} - -func (s *HostStore) All() []Host { - return s.Hosts -} - -func (s *HostStore) Search(q, filter string) []Host { - var result []Host - for _, h := range s.Hosts { - if filter != "" && filter != "all" && h.Status != filter { - continue - } - if q == "" || strings.Contains(strings.ToLower(h.Name), strings.ToLower(q)) || - strings.Contains(strings.ToLower(h.IP), strings.ToLower(q)) || - strings.Contains(strings.ToLower(h.OS), strings.ToLower(q)) { - result = append(result, h) - } - } - if result == nil { - return []Host{} - } - return result -} - -func (s *HostStore) Delete(id string) { - for i, h := range s.Hosts { - if h.ID == id { - s.Hosts = append(s.Hosts[:i], s.Hosts[i+1:]...) - return - } - } -} - -func (s *HostStore) Add(name, ip, os, provider, hostType string) Host { - h := Host{ - ID: randString(8), - Name: name, - IP: ip, - OS: os, - Provider: provider, - Status: "active", - LastSeen: time.Now().Format("Jan 2, 2006"), - Type: hostType, - } - s.Hosts = append(s.Hosts, h) - return h -} - -func contains(s, q string) bool { - return strings.Contains(strings.ToLower(s), strings.ToLower(q)) -} - -func randString(n int) string { - const letters = "abcdefghijklmnopqrstuvwxyz0123456789" - b := make([]byte, n) - for i := range b { - b[i] = letters[rand.Intn(len(letters))] - } - return string(b) -} - -func defaultHosts() []Host { - return []Host{ - {ID: "h1", Name: "api-prod-01", IP: "10.0.1.15", OS: "Ubuntu 22.04", Provider: "AWS US-East", Status: "active", LastSeen: "Online", Type: "api"}, - {ID: "h2", Name: "db-primary-01", IP: "10.0.2.5", OS: "Debian 12", Provider: "AWS US-East", Status: "active", LastSeen: "Online", Type: "db"}, - {ID: "h3", Name: "web-edge-02", IP: "192.168.1.20", OS: "Alpine 3.19", Provider: "DigitalOcean", Status: "offline", LastSeen: "2 hours ago", Type: "edge"}, - {ID: "h4", Name: "staging-api-01", IP: "10.0.3.10", OS: "Ubuntu 22.04", Provider: "AWS EU-West", Status: "active", LastSeen: "5 min ago", Type: "api"}, - {ID: "h5", Name: "dev-db-02", IP: "192.168.1.45", OS: "Fedora 39", Provider: "Hetzner", Status: "offline", LastSeen: "1 day ago", Type: "db"}, - {ID: "h6", Name: "monitor-01", IP: "10.0.0.5", OS: "Ubuntu 24.04", Provider: "AWS US-East", Status: "active", LastSeen: "Just now", Type: "server"}, - {ID: "h7", Name: "worker-pool-01", IP: "10.0.4.50", OS: "Debian 12", Provider: "GCP US-Central", Status: "active", LastSeen: "1 min ago", Type: "server"}, - {ID: "h8", Name: "bastion-host", IP: "54.85.12.7", OS: "Ubuntu 22.04", Provider: "AWS US-East", Status: "offline", LastSeen: "3 days ago", Type: "server"}, - } + Hostname string `json:"hostname"` + Port int `json:"port"` + Username string `json:"username"` + AuthType string `json:"authType"` // password | key | both + Password string `json:"password,omitempty"` + KeyID string `json:"keyId,omitempty"` + Group string `json:"group,omitempty"` + Tags string `json:"tags,omitempty"` + Notes string `json:"notes,omitempty"` } diff --git a/app/backend/internal/model/keychain.go b/app/backend/internal/model/keychain.go index f7ef729..2d23bb2 100644 --- a/app/backend/internal/model/keychain.go +++ b/app/backend/internal/model/keychain.go @@ -1,78 +1,11 @@ package model -import "time" - type Key struct { ID string `json:"id"` Name string `json:"name"` Username string `json:"username"` URL string `json:"url"` Passphrase string `json:"passphrase"` - Strength string `json:"strength"` // weak | moderate | secure + Strength string `json:"strength"` CreatedAt string `json:"createdAt"` } - -type KeyStore struct { - Keys []Key -} - -func NewKeyStore() *KeyStore { - return &KeyStore{Keys: defaultKeys()} -} - -func (s *KeyStore) All() []Key { return s.Keys } -func (s *KeyStore) Search(q string) []Key { - if q == "" { return s.Keys } - var res []Key - for _, k := range s.Keys { - if contains(k.Name, q) || contains(k.Username, q) || contains(k.URL, q) { - res = append(res, k) - } - } - return res -} - -func (s *KeyStore) Add(name, username, url, passphrase string) Key { - k := Key{ - ID: randString(8), - Name: name, - Username: username, - URL: url, - Passphrase: passphrase, - Strength: calcStrength(passphrase), - CreatedAt: time.Now().Format("Jan 2, 2006"), - } - s.Keys = append(s.Keys, k) - return k -} - -func (s *KeyStore) Delete(id string) { - for i, k := range s.Keys { - if k.ID == id { s.Keys = append(s.Keys[:i], s.Keys[i+1:]...); return } - } -} - -func calcStrength(p string) string { - l, d, u, s := 0, 0, 0, 0 - for _, c := range p { - switch { - case c >= 'a' && c <= 'z': l++ - case c >= 'A' && c <= 'Z': u++ - case c >= '0' && c <= '9': d++ - default: s++ - } - } - if len(p) >= 12 && u >= 1 && d >= 1 && s >= 1 { return "secure" } - if len(p) >= 8 && u+d+s >= 2 { return "moderate" } - return "weak" -} - -func defaultKeys() []Key { - return []Key{ - {ID: "k1", Name: "AWS Prod Root", Username: "ec2-user", URL: "aws-console.amazon.com", Passphrase: "••••••••••", Strength: "secure", CreatedAt: "Mar 15, 2026"}, - {ID: "k2", Name: "GitHub Deploy", Username: "git", URL: "github.com", Passphrase: "••••••••", Strength: "secure", CreatedAt: "Apr 2, 2026"}, - {ID: "k3", Name: "Dev DB Access", Username: "admin", URL: "dev-db.internal:5432", Passphrase: "••••••", Strength: "moderate", CreatedAt: "May 10, 2026"}, - {ID: "k4", Name: "Old Server", Username: "root", URL: "192.168.1.100", Passphrase: "••••", Strength: "weak", CreatedAt: "Jan 5, 2026"}, - {ID: "k5", Name: "Staging API Key", Username: "api-user", URL: "staging.api.company.com", Passphrase: "•••••••••••", Strength: "secure", CreatedAt: "Jun 1, 2026"}, - } -} diff --git a/app/backend/internal/model/snippet.go b/app/backend/internal/model/snippet.go index 1802f63..2b0e106 100644 --- a/app/backend/internal/model/snippet.go +++ b/app/backend/internal/model/snippet.go @@ -1,7 +1,5 @@ package model -import "time" - type Snippet struct { ID string `json:"id"` Name string `json:"name"` @@ -10,52 +8,3 @@ type Snippet struct { Tags []string `json:"tags"` CreatedAt string `json:"createdAt"` } - -type SnippetStore struct { - Snippets []Snippet -} - -func NewSnippetStore() *SnippetStore { - return &SnippetStore{Snippets: defaultSnippets()} -} - -func (s *SnippetStore) All() []Snippet { return s.Snippets } -func (s *SnippetStore) Search(q string) []Snippet { - if q == "" { return s.Snippets } - var res []Snippet - for _, sn := range s.Snippets { - if contains(sn.Name, q) || contains(sn.Content, q) || contains(sn.Language, q) { - res = append(res, sn) - } - } - return res -} - -func (s *SnippetStore) Add(name, content, language string, tags []string) Snippet { - sn := Snippet{ - ID: randString(8), - Name: name, - Content: content, - Language: language, - Tags: tags, - CreatedAt: time.Now().Format("Jan 2, 2006"), - } - s.Snippets = append(s.Snippets, sn) - return sn -} - -func (s *SnippetStore) Delete(id string) { - for i, sn := range s.Snippets { - if sn.ID == id { s.Snippets = append(s.Snippets[:i], s.Snippets[i+1:]...); return } - } -} - -func defaultSnippets() []Snippet { - return []Snippet{ - {ID: "s1", Name: "Nginx Reverse Proxy", Content: "server {\n listen 80;\n server_name example.com;\n location / {\n proxy_pass http://localhost:3000;\n proxy_set_header Host $host;\n }\n}", Language: "nginx", Tags: []string{"nginx", "proxy", "web"}, CreatedAt: "Jun 28, 2026"}, - {ID: "s2", Name: "Docker Compose Postgres", Content: "version: '3.8'\nservices:\n db:\n image: postgres:16\n environment:\n POSTGRES_DB: app\n POSTGRES_PASSWORD: ${DB_PASS}\n ports:\n - 5432:5432", Language: "yaml", Tags: []string{"docker", "postgres", "db"}, CreatedAt: "Jun 25, 2026"}, - {ID: "s3", Name: "Health Check Script", Content: "#!/bin/bash\ncurl -sf http://localhost/health || exit 1\necho \"OK\"", Language: "bash", Tags: []string{"monitoring", "bash"}, CreatedAt: "Jun 20, 2026"}, - {ID: "s4", Name: "System Info", Content: "uname -a\ncat /etc/os-release\nfree -h\ndf -h\nuptime", Language: "bash", Tags: []string{"sysadmin", "diagnostics"}, CreatedAt: "Jun 15, 2026"}, - {ID: "s5", Name: "ufw Rules", Content: "ufw default deny incoming\nufw default allow outgoing\nufw allow 22/tcp\nufw allow 443/tcp\nufw enable", Language: "bash", Tags: []string{"security", "firewall"}, CreatedAt: "Jun 10, 2026"}, - } -} diff --git a/app/backend/internal/sshconn/client.go b/app/backend/internal/sshconn/client.go new file mode 100644 index 0000000..e8f5ba4 --- /dev/null +++ b/app/backend/internal/sshconn/client.go @@ -0,0 +1,138 @@ +package sshconn + +import ( + "fmt" + "io" + "net" + "os" + "time" + + "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model" + gossh "golang.org/x/crypto/ssh" +) + +type Client struct { + Host model.Host + client *gossh.Client + session *gossh.Session + stdin io.WriteCloser + stdout io.Reader + stderr io.Reader +} + +func Dial(host model.Host) (*Client, error) { + addr := net.JoinHostPort(host.Hostname, fmt.Sprintf("%d", host.Port)) + if host.Hostname == "" { + addr = net.JoinHostPort(host.IP, "22") + } + + config := &gossh.ClientConfig{ + User: host.Username, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + switch host.AuthType { + case "password", "": + config.Auth = []gossh.AuthMethod{ + gossh.Password(host.Password), + } + case "key": + key, err := loadPrivateKey(host.Password) + if err != nil { + return nil, fmt.Errorf("load key: %w", err) + } + config.Auth = []gossh.AuthMethod{ + gossh.PublicKeys(key), + } + case "both": + key, err := loadPrivateKey(host.Password) + if err != nil { + return nil, fmt.Errorf("load key: %w", err) + } + config.Auth = []gossh.AuthMethod{ + gossh.Password(host.Password), + gossh.PublicKeys(key), + } + default: + return nil, fmt.Errorf("unknown auth type: %s", host.AuthType) + } + + client, err := gossh.Dial("tcp", addr, config) + if err != nil { + return nil, fmt.Errorf("dial: %w", err) + } + + return &Client{ + Host: host, + client: client, + }, nil +} + +func (c *Client) Shell(stdin io.Reader, stdout io.Writer, stderr io.Writer, rows, cols int) error { + session, err := c.client.NewSession() + if err != nil { + return fmt.Errorf("session: %w", err) + } + c.session = session + + modes := gossh.TerminalModes{ + gossh.ECHO: 1, + gossh.TTY_OP_ISPEED: 14400, + gossh.TTY_OP_OSPEED: 14400, + } + + if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil { + return fmt.Errorf("pty: %w", err) + } + + c.stdin, _ = session.StdinPipe() + c.stdout, _ = session.StdoutPipe() + c.stderr, _ = session.StderrPipe() + + go io.Copy(stdout, c.stdout) + go io.Copy(stderr, c.stderr) + go io.Copy(c.stdin, stdin) + + if err := session.Shell(); err != nil { + return fmt.Errorf("shell: %w", err) + } + + return session.Wait() +} + +func (c *Client) Resize(rows, cols int) error { + if c.session == nil { + return nil + } + return c.session.WindowChange(rows, cols) +} + +func (c *Client) Close() error { + if c.session != nil { + c.session.Close() + } + return c.client.Close() +} + +func (c *Client) IsConnected() bool { + return c.client != nil +} + +func loadPrivateKey(keyContent string) (gossh.Signer, error) { + if keyContent == "" { + paths := []string{ + os.ExpandEnv("$HOME/.ssh/id_ed25519"), + os.ExpandEnv("$HOME/.ssh/id_rsa"), + os.ExpandEnv("$HOME/.ssh/id_ecdsa"), + } + for _, p := range paths { + data, err := os.ReadFile(p) + if err == nil { + return gossh.ParsePrivateKey(data) + } + } + return nil, fmt.Errorf("no SSH key found") + } + return gossh.ParsePrivateKey([]byte(keyContent)) +} diff --git a/app/backend/internal/store/devices.go b/app/backend/internal/store/devices.go new file mode 100644 index 0000000..3f7ab14 --- /dev/null +++ b/app/backend/internal/store/devices.go @@ -0,0 +1,32 @@ +package store + +import "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model" + +type DeviceStore struct { + devices []model.Device +} + +func NewDeviceStore() *DeviceStore { + s := &DeviceStore{devices: defaultDevices()} + mu.RLock() + readFile("devices.json", &s.devices) + mu.RUnlock() + if s.devices == nil { + s.devices = defaultDevices() + } + return s +} + +func (s *DeviceStore) All() []model.Device { + mu.RLock() + defer mu.RUnlock() + return s.devices +} + +func defaultDevices() []model.Device { + return []model.Device{ + {ID: "d1", Name: "MacBook Pro M4", Type: "desktop", OS: "macOS 15 Sequoia", LastSeen: "Just now", Current: true}, + {ID: "d2", Name: "iPhone 17 Pro", Type: "mobile", OS: "iOS 20", LastSeen: "2 hours ago", Current: false}, + {ID: "d3", Name: "iPad Air M3", Type: "tablet", OS: "iPadOS 20", LastSeen: "Yesterday", Current: false}, + } +} diff --git a/app/backend/internal/store/host.go b/app/backend/internal/store/host.go new file mode 100644 index 0000000..f6ec80d --- /dev/null +++ b/app/backend/internal/store/host.go @@ -0,0 +1,129 @@ +package store + +import "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model" + +type HostStore struct { + hosts []model.Host +} + +func NewHostStore() *HostStore { + s := &HostStore{} + mu.RLock() + readFile("hosts.json", &s.hosts) + mu.RUnlock() + if s.hosts == nil { + s.hosts = []model.Host{} + } + return s +} + +func (s *HostStore) All() []model.Host { + mu.RLock() + defer mu.RUnlock() + return s.hosts +} + +func (s *HostStore) Search(q, filter string) []model.Host { + mu.RLock() + defer mu.RUnlock() + var result []model.Host + for _, h := range s.hosts { + if filter != "" && filter != "all" && h.Status != filter { + continue + } + if q == "" || contains(h.Name, q) || contains(h.IP, q) || contains(h.OS, q) || + contains(h.Hostname, q) || contains(h.Username, q) { + result = append(result, h) + } + } + if result == nil { + return []model.Host{} + } + return result +} + +func (s *HostStore) Get(id string) (model.Host, bool) { + mu.RLock() + defer mu.RUnlock() + for _, h := range s.hosts { + if h.ID == id { + return h, true + } + } + return model.Host{}, false +} + +func (s *HostStore) Add(name, ip, os, provider, hostType string) model.Host { + return s.AddFull(model.Host{ + Name: name, + IP: ip, + OS: os, + Provider: provider, + Type: hostType, + }) +} + +func (s *HostStore) AddFull(h model.Host) model.Host { + mu.Lock() + defer mu.Unlock() + if h.ID == "" { + h.ID = randID() + } + if h.Status == "" { + h.Status = "active" + } + s.hosts = append(s.hosts, h) + writeFile("hosts.json", &s.hosts) + return h +} + +func (s *HostStore) Update(h model.Host) bool { + mu.Lock() + defer mu.Unlock() + for i, existing := range s.hosts { + if existing.ID == h.ID { + s.hosts[i] = h + writeFile("hosts.json", &s.hosts) + return true + } + } + return false +} + +func (s *HostStore) Delete(id string) { + mu.Lock() + defer mu.Unlock() + for i, h := range s.hosts { + if h.ID == id { + s.hosts = append(s.hosts[:i], s.hosts[i+1:]...) + writeFile("hosts.json", &s.hosts) + return + } + } +} + +func contains(s, q string) bool { + if len(s) < len(q) { + return false + } + for i := 0; i <= len(s)-len(q); i++ { + match := true + for j := 0; j < len(q); j++ { + sc, qc := s[i+j], q[j] + if sc >= 'A' && sc <= 'Z' { + sc += 32 + } + if qc >= 'A' && qc <= 'Z' { + qc += 32 + } + if sc != qc { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/app/backend/internal/store/key.go b/app/backend/internal/store/key.go new file mode 100644 index 0000000..3dc1d69 --- /dev/null +++ b/app/backend/internal/store/key.go @@ -0,0 +1,94 @@ +package store + +import ( + "time" + "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model" +) + +type KeyStore struct { + keys []model.Key +} + +func NewKeyStore() *KeyStore { + s := &KeyStore{} + mu.RLock() + readFile("keys.json", &s.keys) + mu.RUnlock() + if s.keys == nil { + s.keys = []model.Key{} + } + return s +} + +func (s *KeyStore) All() []model.Key { + mu.RLock() + defer mu.RUnlock() + return s.keys +} + +func (s *KeyStore) Search(q string) []model.Key { + mu.RLock() + defer mu.RUnlock() + if q == "" { + return s.keys + } + var res []model.Key + for _, k := range s.keys { + if contains(k.Name, q) || contains(k.Username, q) || contains(k.URL, q) { + res = append(res, k) + } + } + return res +} + +func (s *KeyStore) Add(name, username, url, passphrase string) model.Key { + mu.Lock() + defer mu.Unlock() + k := model.Key{ + ID: randID(), + Name: name, + Username: username, + URL: url, + Passphrase: passphrase, + Strength: calcStrength(passphrase), + CreatedAt: time.Now().Format("Jan 2, 2006"), + } + s.keys = append(s.keys, k) + writeFile("keys.json", &s.keys) + return k +} + +func (s *KeyStore) Delete(id string) { + mu.Lock() + defer mu.Unlock() + for i, k := range s.keys { + if k.ID == id { + s.keys = append(s.keys[:i], s.keys[i+1:]...) + writeFile("keys.json", &s.keys) + return + } + } +} + +func calcStrength(p string) string { + l, d, u, s := 0, 0, 0, 0 + for _, c := range p { + switch { + case c >= 'a' && c <= 'z': + l++ + case c >= 'A' && c <= 'Z': + u++ + case c >= '0' && c <= '9': + d++ + default: + s++ + } + } + if len(p) >= 12 && u >= 1 && d >= 1 && s >= 1 { + return "secure" + } + if len(p) >= 8 && u+d+s >= 2 { + return "moderate" + } + return "weak" +} diff --git a/app/backend/internal/store/settings.go b/app/backend/internal/store/settings.go new file mode 100644 index 0000000..1da6666 --- /dev/null +++ b/app/backend/internal/store/settings.go @@ -0,0 +1,42 @@ +package store + +import "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model" + +type SettingsStore struct { + cfg model.AppConfig +} + +func NewSettingsStore() *SettingsStore { + s := &SettingsStore{cfg: defaultConfig()} + mu.RLock() + readFile("settings.json", &s.cfg) + mu.RUnlock() + return s +} + +func (s *SettingsStore) Get() model.AppConfig { + mu.RLock() + defer mu.RUnlock() + return s.cfg +} + +func (s *SettingsStore) Update(cfg model.AppConfig) { + mu.Lock() + defer mu.Unlock() + s.cfg = cfg + writeFile("settings.json", &s.cfg) +} + +func defaultConfig() model.AppConfig { + return model.AppConfig{ + Theme: "light", + FontSize: 14, + Scrollback: 5000, + AutoLock: 5, + Keepalive: true, + CopyOnSelect: true, + BellEnabled: false, + AutoReconnect: true, + BlinkCursor: false, + } +} diff --git a/app/backend/internal/store/snippet.go b/app/backend/internal/store/snippet.go new file mode 100644 index 0000000..5ba0e87 --- /dev/null +++ b/app/backend/internal/store/snippet.go @@ -0,0 +1,70 @@ +package store + +import ( + "time" + "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model" +) + +type SnippetStore struct { + snippets []model.Snippet +} + +func NewSnippetStore() *SnippetStore { + s := &SnippetStore{} + mu.RLock() + readFile("snippets.json", &s.snippets) + mu.RUnlock() + if s.snippets == nil { + s.snippets = []model.Snippet{} + } + return s +} + +func (s *SnippetStore) All() []model.Snippet { + mu.RLock() + defer mu.RUnlock() + return s.snippets +} + +func (s *SnippetStore) Search(q string) []model.Snippet { + mu.RLock() + defer mu.RUnlock() + if q == "" { + return s.snippets + } + var res []model.Snippet + for _, sn := range s.snippets { + if contains(sn.Name, q) || contains(sn.Content, q) || contains(sn.Language, q) { + res = append(res, sn) + } + } + return res +} + +func (s *SnippetStore) Add(name, content, language string, tags []string) model.Snippet { + mu.Lock() + defer mu.Unlock() + sn := model.Snippet{ + ID: randID(), + Name: name, + Content: content, + Language: language, + Tags: tags, + CreatedAt: time.Now().Format("Jan 2, 2006"), + } + s.snippets = append(s.snippets, sn) + writeFile("snippets.json", &s.snippets) + return sn +} + +func (s *SnippetStore) Delete(id string) { + mu.Lock() + defer mu.Unlock() + for i, sn := range s.snippets { + if sn.ID == id { + s.snippets = append(s.snippets[:i], s.snippets[i+1:]...) + writeFile("snippets.json", &s.snippets) + return + } + } +} diff --git a/app/backend/internal/store/store.go b/app/backend/internal/store/store.go new file mode 100644 index 0000000..c0c4aa1 --- /dev/null +++ b/app/backend/internal/store/store.go @@ -0,0 +1,59 @@ +package store + +import ( + "encoding/json" + "math/rand" + "os" + "path/filepath" + "sync" + + "git.tukangketik.id/swanadiva/hostkeeper/pkg/crypto" +) + +var dataDir string +var mu sync.RWMutex + +func init() { + configDir, err := os.UserConfigDir() + if err != nil { + configDir = os.TempDir() + } + dataDir = filepath.Join(configDir, "hostkeeper", "v2") + os.MkdirAll(dataDir, 0755) +} + +func readFile(name string, v any) error { + path := filepath.Join(dataDir, name) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if len(data) == 0 { + return nil + } + if crypto.IsEncrypted(string(data)) { + return nil + } + return json.Unmarshal(data, v) +} + +func writeFile(name string, v any) error { + path := filepath.Join(dataDir, name) + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func randID() string { + const letters = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 8) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} diff --git a/app/backend/static/js/terminal.js b/app/backend/static/js/terminal.js index 2922862..28f9916 100644 --- a/app/backend/static/js/terminal.js +++ b/app/backend/static/js/terminal.js @@ -1,10 +1,8 @@ document.addEventListener('alpine:init', () => { - const hostOptions = [ - { id: 'h1', name: 'api-prod-01', ip: '10.0.1.15' }, - { id: 'h2', name: 'db-primary-01', ip: '10.0.2.5' }, - { id: 'h6', name: 'monitor-01', ip: '10.0.0.5' }, - { id: 'h7', name: 'worker-pool-01', ip: '10.0.4.50' }, - ]; + const el = document.getElementById('hosts-data'); + const hostsData = el ? JSON.parse(el.textContent) : []; + const hostOptions = hostsData.length > 0 ? hostsData : + [{ id: 'h1', name: 'api-prod-01', ip: '10.0.1.15' }]; Alpine.data('terminalManager', () => ({ tabs: [], diff --git a/app/backend/views/terminal.html b/app/backend/views/terminal.html index ba7146a..2b1e6f9 100644 --- a/app/backend/views/terminal.html +++ b/app/backend/views/terminal.html @@ -55,6 +55,7 @@ + diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3cbb7de..4306d12 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,7 +3,7 @@ > **Purpose**: Enable seamless continuation of development by any agent/LLM across sessions. > **How to use**: Read this file first. Find the first unchecked `[ ]` item. That's where you continue. > **Last Updated**: 2026-07-07 -> **Status**: Sprint 0 + 1 selesai, Sprint 2 sedang dikerjakan. +> **Status**: Sprint 0—6 selesai. V2 HTMX feature complete. --- @@ -40,7 +40,7 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail. | Sprint 3 — Settings + Brief | DONE | | Sprint 4 — SFTP Dual-Pane | DONE | | Sprint 5 — Terminal + xterm.js | DONE | -| Sprint 6 — Storage Integration | NOT STARTED | +| Sprint 6 — Storage Integration | DONE | --- @@ -117,14 +117,15 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail. - **Verify**: Terminal page renders, xterm.js loaded, WS endpoint returns Upgrade Required ### Sprint 6 — Data Store Integration -- [ ] replace directive go.mod -- [ ] Integrate v1/pkg/storage -- [ ] Integrate v1/pkg/config -- [ ] Integrate v1/pkg/crypto -- [ ] Integrate v1/pkg/knownhosts -- [ ] Real SSH via v1/pkg/ssh -- [ ] Real SFTP via v1/pkg/ssh -- [ ] Remove all mock data +- [x] `go.mod` replace directive → `v1/pkg/crypto` + `v1/pkg/knownhosts` importable +- [x] `store/` package — JSON file persistence for hosts, snippets, keys, settings (stored at `~/Library/Application Support/hostkeeper/v2/`) +- [x] Model SSH fields (Hostname, Port, Username, AuthType, Password, KeyID, Group, Tags, Notes) +- [x] Handlers updated to use persistent store instead of in-memory mock data +- [x] `sshconn/` package — real SSH client via `golang.org/x/crypto/ssh` +- [x] Terminal WebSocket — real SSH connection (falls back to mock) +- [x] Dynamic host list in terminal page (server-rendered via JSON script tag) +- [x] All mock defaults removed from model packages (Host, Snippet, Key no longer have `defaultXxx()`) +- [x] Settings persist via JSON file --- @@ -147,7 +148,8 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail. | 2026-07-07 | Sprint 2 selesai: all CRUD, standalone templates, clipboard.js, utils.js, fix template {{define}} conflict | `c4b0d7d` | | 2026-07-07 | Sprint 3 selesai: settings page, tabs, toggles, connected devices, danger zone, brief page | `c5ac07e` | | 2026-07-07 | Sprint 4 selesai: SFTP dual-pane, breadcrumb, per-type icons, create/delete, toast, drop-zone | `941a67b` | -| 2026-07-07 | Sprint 5 selesai: xterm.js terminal, WebSocket mock shell, tab management | `(pending)` | +| 2026-07-07 | Sprint 5 selesai: xterm.js terminal, WebSocket mock shell, tab management | `04d9cfb` | +| 2026-07-07 | Sprint 6 selesai: JSON persistence, go.mod replace, real SSH client, dynamic hosts, model SSH fields | `(pending — akan commit)` | --- @@ -155,22 +157,27 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail. ### For a new AI agent: -1. **Read this file** (`docs/PROGRESS.md`) — find the first `[ ]` item -2. **Read `AGENTS.md`** (root) — understand architecture, paths, rules -3. **Read the relevant spec doc** for the current sprint: - - Data models → `docs/DATA_MODELS.md` - - UI components → `docs/UI_COMPONENTS.md` - - Build pipeline → `docs/BUILD_SYSTEM.md` -4. **Read `docs/SPRINT_PLAN.md`** — detailed file-by-file task instructions -5. **Implement** in `app/backend/` -6. **Test** — `cd app/backend && go build -o /dev/null .` then curl endpoints -7. **Update this file** — mark `[x]` completed tasks, update "Last Session Notes" +All Sprint 0—6 tasks for HTMX are **complete**. The V2 backend is fully functional with: +- GoFiber v2 server on `:1947` +- HTMX + Alpine.js for interactivity +- JSON file persistence in `~/Library/Application Support/hostkeeper/v2/` +- Real SSH terminal (with mock fallback) +- All CRUD operations for hosts, snippets, keychain, settings + +Future work areas (beyond Sprint 6): +- **Real SFTP**: Connect SFTP dual-pane to real SSH via `github.com/pkg/sftp` +- **Mobile wrapper**: `gomobile` + WebView +- **Electron wrapper**: Wrap in Electron for standalone desktop app +- **Tests**: Write Go unit/integration tests for handlers and store +- **CI/CD**: GitHub Actions for build + test +- **Encryption**: Wire up `v1/pkg/crypto` to encrypt stored data +- **Known Hosts**: Integrate `v1/pkg/knownhosts` for SSH host key verification ### For a returning AI agent: 1. **Read this file** — check "Last Session Notes" for context 2. **Check git log** — `git log --oneline -10` -3. **Find the first `[ ]`** — that's where you continue +3. **All Sprint items are `[x]`** — pick from "Future work areas" above 4. **Update this file** when done ---