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
+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)
}