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

- go.mod replace directive for git.tukangketik.id/swanadiva/hostkeeper → ../../v1
- store/ package: JSON file persistence for hosts, snippets, keys, settings
  (~/Library/Application Support/hostkeeper/v2/)
- model SSH fields: Hostname, Port, Username, AuthType, Password, KeyID
- sshconn/ package: real SSH client via golang.org/x/crypto/ssh
- Terminal WS: real SSH connection with fallback to mock shell
- Dynamic host list in terminal (server-rendered JSON script tag)
- All mock data defaults removed from model packages
- Settings persist via store/settings.go
This commit is contained in:
swanadiva
2026-07-07 13:53:50 +07:00
parent 04d9cfb096
commit fd7361aa14
21 changed files with 746 additions and 306 deletions
+10 -5
View File
@@ -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
View File
@@ -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=
+8 -7
View File
@@ -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 -2
View File
@@ -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()
+5 -4
View File
@@ -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 -2
View File
@@ -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()
+80 -29
View File
@@ -1,39 +1,98 @@
package handler
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"time"
"github.com/gofiber/contrib/websocket"
"github.com/gofiber/fiber/v2"
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/sshconn"
)
func Terminal(c *fiber.Ctx) error {
hosts := hostStore.All()
type hostItem struct {
ID string `json:"id"`
Name string `json:"name"`
IP string `json:"ip"`
}
var available []hostItem
for _, h := range hosts {
if h.Status == "active" {
available = append(available, hostItem{h.ID, h.Name, h.IP})
}
}
hostsJSON, _ := json.Marshal(available)
return c.Render("terminal", fiber.Map{
"View": "terminal",
"Title": "Terminal",
"NavItems": navItems,
"View": "terminal",
"Title": "Terminal",
"NavItems": navItems,
"Hosts": available,
"HostsJSON": string(hostsJSON),
})
}
var hostList = []struct {
ID string
Name string
IP string
}{
{"h1", "api-prod-01", "10.0.1.15"},
{"h2", "db-primary-01", "10.0.2.5"},
{"h6", "monitor-01", "10.0.0.5"},
{"h7", "worker-pool-01", "10.0.4.50"},
func TerminalWS(c *websocket.Conn) {
hostID := c.Params("host", "unknown")
log.Printf("WS connected: host=%s", hostID)
host, found := hostStore.Get(hostID)
if !found || host.Hostname == "" {
mockTerminal(c, hostID)
return
}
client, err := sshconn.Dial(host)
if err != nil {
log.Printf("SSH failed for %s: %v, falling back to mock", hostID, err)
mockTerminal(c, hostID)
return
}
defer client.Close()
welcome := fmt.Sprintf("\x1b[1;32mConnected to %s (%s)\x1b[0m\r\n", host.Name, host.IP)
c.WriteMessage(websocket.TextMessage, []byte(welcome))
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
go func() {
scanner := bufio.NewScanner(stdoutR)
for scanner.Scan() {
c.WriteMessage(websocket.TextMessage, []byte(scanner.Text()+"\r\n"))
}
}()
go func() {
defer stdinW.Close()
for {
_, msg, err := c.ReadMessage()
if err != nil {
return
}
input := string(msg)
if input == "\x03" {
client.Close()
return
}
stdinW.Write([]byte(input))
}
}()
if err := client.Shell(stdinR, stdoutW, stdoutW, 24, 80); err != nil {
log.Printf("SSH session ended: %v", err)
}
log.Printf("WS disconnected: host=%s", hostID)
}
func TerminalWS(c *websocket.Conn) {
host := c.Params("host", "unknown")
log.Printf("WS connected: host=%s", host)
welcome := fmt.Sprintf("\x1b[1;32mWelcome to %s\x1b[0m\r\n\x1b[2mConnected at %s\x1b[0m\r\n\r\n", host, time.Now().Format(time.RFC822))
func mockTerminal(c *websocket.Conn, host string) {
welcome := fmt.Sprintf("\x1b[1;33m%s (mock mode)\x1b[0m\r\n\x1b[2mConnected at %s\x1b[0m\r\n\r\n", host, time.Now().Format(time.RFC822))
c.WriteMessage(websocket.TextMessage, []byte(welcome))
prompt := fmt.Sprintf("\x1b[1;34m%s:~$\x1b[0m ", host)
@@ -68,19 +127,15 @@ func TerminalWS(c *websocket.Conn) {
}
}
}
log.Printf("WS disconnected: host=%s", host)
log.Printf("Mock WS disconnected: host=%s", host)
}
func handleCommand(cmd, host string, cwd *string) string {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return ""
}
if cmd == "" { return "" }
parts := strings.Fields(cmd)
if len(parts) == 0 {
return ""
}
if len(parts) == 0 { return "" }
switch parts[0] {
case "clear":
@@ -108,11 +163,7 @@ func handleCommand(cmd, host string, cwd *string) string {
}
return "total 0\r\n"
case "cd":
if len(parts) > 1 {
*cwd = parts[1]
} else {
*cwd = "~"
}
if len(parts) > 1 { *cwd = parts[1] } else { *cwd = "~" }
return ""
case "cat":
if len(parts) > 1 {
+11 -89
View File
@@ -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 -68
View File
@@ -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"},
}
}
-51
View File
@@ -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"},
}
}
+138
View File
@@ -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))
}
+32
View File
@@ -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},
}
}
+129
View File
@@ -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
}
+94
View File
@@ -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"
}
+42
View File
@@ -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,
}
}
+70
View File
@@ -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
}
}
}
+59
View File
@@ -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)
}
+4 -6
View File
@@ -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: [],
+1
View File
@@ -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>