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:
@@ -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
|
||||
|
||||
+10
-5
@@ -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
|
||||
|
||||
+12
-10
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
"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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: [],
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script id="hosts-data" type="application/json">{{.HostsJSON}}</script>
|
||||
<script src="/static/xterm/xterm.js"></script>
|
||||
<script src="/static/js/terminal.js"></script>
|
||||
</body>
|
||||
|
||||
+29
-22
@@ -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
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user