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.
|
> **Purpose**: Memungkinkan AI agent berikutnya melanjutkan development tanpa mengulang atau merusak.
|
||||||
> **Last Updated**: 2026-07-07
|
> **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
|
│ ├── go.mod module git.tukangketik.id/swanadiva/hostkeeper
|
||||||
│ ├── Makefile
|
│ ├── Makefile
|
||||||
│ └── PROJECT_STATE.md V1 full project state
|
│ └── 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
|
├── mobile/ ← (BELUM DIBUAT) V2 mobile wrapper
|
||||||
├── template/ ← Referensi UI React (Lumina System) — hanya untuk desain
|
├── template/ ← Referensi UI React (Lumina System) — hanya untuk desain
|
||||||
│ └── src/components/ 7 React screens
|
│ └── 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
|
### ✅ Sudah Selesai
|
||||||
- Semua dokumentasi diupdate ke HTMX (10 file di `docs/`)
|
- 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)
|
- 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 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
|
- **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
|
||||||
|
- **Sprint 2**: Snippets + Keychain CRUD, clipboard.js, utils.js
|
||||||
### ❌ Belum Dimulai / In Progress
|
- **Sprint 3**: Settings + Brief, tabs, toggles, danger zone
|
||||||
- **Sprint 2 (DONE)**: Snippets + Keychain
|
- **Sprint 4**: SFTP Dual-Pane, breadcrumb, file icons by type, create/delete
|
||||||
- **Sprint 3 (DONE)**: Settings + Brief
|
- **Sprint 5**: Terminal + xterm.js + WebSocket mock shell, tab management
|
||||||
- **Sprint 4 (DONE)**: SFTP Dual-Pane
|
- **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
|
||||||
- **Sprint 5 (DONE)**: Terminal + xterm.js
|
|
||||||
- Sprint 6: Storage Integration
|
|
||||||
|
|
||||||
### 🐛 Bug Template (dari template/src/ — catatan saat implementasi)
|
### 🐛 Bug Template (dari template/src/ — catatan saat implementasi)
|
||||||
1. **P0**: Missing CSS keyframes (fade-in, scale-up, slide-in) → tambah di `custom.css`
|
1. **P0**: Missing CSS keyframes (fade-in, scale-up, slide-in) → tambah di `custom.css`
|
||||||
@@ -149,6 +147,15 @@ app/backend/
|
|||||||
│ │ ├── host.go
|
│ │ ├── host.go
|
||||||
│ │ ├── snippet.go
|
│ │ ├── snippet.go
|
||||||
│ │ └── keychain.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
|
│ ├── template/ ← Template helper funcs
|
||||||
│ └── middleware/ ← View data injection
|
│ └── middleware/ ← View data injection
|
||||||
├── static/
|
├── static/
|
||||||
@@ -156,7 +163,6 @@ app/backend/
|
|||||||
│ ├── js/ ← htmx.min.js, alpine.min.js, utils.js, clipboard.js, terminal.js
|
│ ├── js/ ← htmx.min.js, alpine.min.js, utils.js, clipboard.js, terminal.js
|
||||||
│ └── xterm/ ← xterm.js lib
|
│ └── xterm/ ← xterm.js lib
|
||||||
└── views/ ← Semua template flat (tidak ada subfolder partials/)
|
└── views/ ← Semua template flat (tidak ada subfolder partials/)
|
||||||
├── layout.html
|
|
||||||
├── nav.html
|
├── nav.html
|
||||||
├── index.html
|
├── index.html
|
||||||
├── host_list.html
|
├── host_list.html
|
||||||
|
|||||||
+10
-5
@@ -3,26 +3,31 @@ module git.tukangketik.id/swanadiva/hostkeeper/v2
|
|||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
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/fiber/v2 v2.52.14
|
||||||
github.com/gofiber/template/html/v2 v2.1.3
|
github.com/gofiber/template/html/v2 v2.1.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
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/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/template v1.8.3 // indirect
|
||||||
github.com/gofiber/utils v1.1.0 // indirect
|
github.com/gofiber/utils v1.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.17.9 // indirect
|
github.com/klauspost/compress v1.17.9 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/rivo/uniseg v0.2.0 // indirect
|
|
||||||
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
|
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.52.0 // indirect
|
github.com/valyala/fasthttp v1.52.0 // indirect
|
||||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
golang.org/x/net v0.33.0 // indirect
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
golang.org/x/sys v0.28.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 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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=
|
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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8=
|
||||||
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
|
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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
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 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
|
||||||
github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ=
|
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 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
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/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
|
"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{
|
var navItems = []fiber.Map{
|
||||||
{"ID": "hosts", "Label": "Hosts", "Icon": "server"},
|
{"ID": "hosts", "Label": "Hosts", "Icon": "server"},
|
||||||
@@ -17,7 +18,7 @@ var navItems = []fiber.Map{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Dashboard(c *fiber.Ctx) error {
|
func Dashboard(c *fiber.Ctx) error {
|
||||||
hosts := store.All()
|
hosts := hostStore.All()
|
||||||
return c.Render("index", fiber.Map{
|
return c.Render("index", fiber.Map{
|
||||||
"View": "hosts",
|
"View": "hosts",
|
||||||
"Title": "Hosts",
|
"Title": "Hosts",
|
||||||
@@ -31,7 +32,7 @@ func Dashboard(c *fiber.Ctx) error {
|
|||||||
func DashboardList(c *fiber.Ctx) error {
|
func DashboardList(c *fiber.Ctx) error {
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
filter := c.Query("filter", "all")
|
filter := c.Query("filter", "all")
|
||||||
hosts := store.Search(q, filter)
|
hosts := hostStore.Search(q, filter)
|
||||||
return c.Render("host_list", fiber.Map{
|
return c.Render("host_list", fiber.Map{
|
||||||
"Hosts": hosts,
|
"Hosts": hosts,
|
||||||
"Active": countByStatus(hosts, "active"),
|
"Active": countByStatus(hosts, "active"),
|
||||||
@@ -50,8 +51,8 @@ func CreateHost(c *fiber.Ctx) error {
|
|||||||
return c.Status(400).SendString("name and ip required")
|
return c.Status(400).SendString("name and ip required")
|
||||||
}
|
}
|
||||||
|
|
||||||
store.Add(name, ip, os, provider, hostType)
|
hostStore.Add(name, ip, os, provider, hostType)
|
||||||
hosts := store.All()
|
hosts := hostStore.All()
|
||||||
return c.Render("host_list", fiber.Map{
|
return c.Render("host_list", fiber.Map{
|
||||||
"Hosts": hosts,
|
"Hosts": hosts,
|
||||||
"Active": countByStatus(hosts, "active"),
|
"Active": countByStatus(hosts, "active"),
|
||||||
@@ -61,8 +62,8 @@ func CreateHost(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func DeleteHost(c *fiber.Ctx) error {
|
func DeleteHost(c *fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
store.Delete(id)
|
hostStore.Delete(id)
|
||||||
hosts := store.All()
|
hosts := hostStore.All()
|
||||||
return c.Render("host_list", fiber.Map{
|
return c.Render("host_list", fiber.Map{
|
||||||
"Hosts": hosts,
|
"Hosts": hosts,
|
||||||
"Active": countByStatus(hosts, "active"),
|
"Active": countByStatus(hosts, "active"),
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gofiber/fiber/v2"
|
"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 {
|
func Keychain(c *fiber.Ctx) error {
|
||||||
keys := keyStore.All()
|
keys := keyStore.All()
|
||||||
|
|||||||
@@ -3,23 +3,24 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
|
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
|
||||||
|
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var deviceStore = model.NewDeviceStore()
|
var deviceStore = model.NewDeviceStore()
|
||||||
var configStore = model.NewConfigStore()
|
var settingsStore = store.NewSettingsStore()
|
||||||
|
|
||||||
func Settings(c *fiber.Ctx) error {
|
func Settings(c *fiber.Ctx) error {
|
||||||
return c.Render("settings", fiber.Map{
|
return c.Render("settings", fiber.Map{
|
||||||
"View": "settings",
|
"View": "settings",
|
||||||
"Title": "Settings",
|
"Title": "Settings",
|
||||||
"Config": configStore.Get(),
|
"Config": settingsStore.Get(),
|
||||||
"Devices": deviceStore.All(),
|
"Devices": deviceStore.All(),
|
||||||
"NavItems": navItems,
|
"NavItems": navItems,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateConfig(c *fiber.Ctx) error {
|
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("theme"); v != "" { cfg.Theme = v }
|
||||||
if v := c.FormValue("fontSize"); v != "" { cfg.FontSize = parseInt(v, 14) }
|
if v := c.FormValue("fontSize"); v != "" { cfg.FontSize = parseInt(v, 14) }
|
||||||
if v := c.FormValue("scrollback"); v != "" { cfg.Scrollback = parseInt(v, 5000) }
|
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.BellEnabled = c.FormValue("bellEnabled") == "on"
|
||||||
cfg.AutoReconnect = c.FormValue("autoReconnect") == "on"
|
cfg.AutoReconnect = c.FormValue("autoReconnect") == "on"
|
||||||
cfg.BlinkCursor = c.FormValue("blinkCursor") == "on"
|
cfg.BlinkCursor = c.FormValue("blinkCursor") == "on"
|
||||||
configStore.Update(cfg)
|
settingsStore.Update(cfg)
|
||||||
return c.Redirect("/settings", 303)
|
return c.Redirect("/settings", 303)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gofiber/fiber/v2"
|
"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 {
|
func Snippets(c *fiber.Ctx) error {
|
||||||
snippets := snippetStore.All()
|
snippets := snippetStore.All()
|
||||||
|
|||||||
@@ -1,39 +1,98 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/contrib/websocket"
|
"github.com/gofiber/contrib/websocket"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/sshconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Terminal(c *fiber.Ctx) error {
|
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{
|
return c.Render("terminal", fiber.Map{
|
||||||
"View": "terminal",
|
"View": "terminal",
|
||||||
"Title": "Terminal",
|
"Title": "Terminal",
|
||||||
"NavItems": navItems,
|
"NavItems": navItems,
|
||||||
|
"Hosts": available,
|
||||||
|
"HostsJSON": string(hostsJSON),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var hostList = []struct {
|
func TerminalWS(c *websocket.Conn) {
|
||||||
ID string
|
hostID := c.Params("host", "unknown")
|
||||||
Name string
|
log.Printf("WS connected: host=%s", hostID)
|
||||||
IP string
|
|
||||||
}{
|
host, found := hostStore.Get(hostID)
|
||||||
{"h1", "api-prod-01", "10.0.1.15"},
|
if !found || host.Hostname == "" {
|
||||||
{"h2", "db-primary-01", "10.0.2.5"},
|
mockTerminal(c, hostID)
|
||||||
{"h6", "monitor-01", "10.0.0.5"},
|
return
|
||||||
{"h7", "worker-pool-01", "10.0.4.50"},
|
}
|
||||||
|
|
||||||
|
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) {
|
func mockTerminal(c *websocket.Conn, host string) {
|
||||||
host := c.Params("host", "unknown")
|
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))
|
||||||
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))
|
|
||||||
c.WriteMessage(websocket.TextMessage, []byte(welcome))
|
c.WriteMessage(websocket.TextMessage, []byte(welcome))
|
||||||
|
|
||||||
prompt := fmt.Sprintf("\x1b[1;34m%s:~$\x1b[0m ", host)
|
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 {
|
func handleCommand(cmd, host string, cwd *string) string {
|
||||||
cmd = strings.TrimSpace(cmd)
|
cmd = strings.TrimSpace(cmd)
|
||||||
if cmd == "" {
|
if cmd == "" { return "" }
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Fields(cmd)
|
parts := strings.Fields(cmd)
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 { return "" }
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
switch parts[0] {
|
switch parts[0] {
|
||||||
case "clear":
|
case "clear":
|
||||||
@@ -108,11 +163,7 @@ func handleCommand(cmd, host string, cwd *string) string {
|
|||||||
}
|
}
|
||||||
return "total 0\r\n"
|
return "total 0\r\n"
|
||||||
case "cd":
|
case "cd":
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 { *cwd = parts[1] } else { *cwd = "~" }
|
||||||
*cwd = parts[1]
|
|
||||||
} else {
|
|
||||||
*cwd = "~"
|
|
||||||
}
|
|
||||||
return ""
|
return ""
|
||||||
case "cat":
|
case "cat":
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
|
|||||||
@@ -1,100 +1,22 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Host struct {
|
type Host struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Status string `json:"status"` // active | offline
|
Status string `json:"status"`
|
||||||
LastSeen string `json:"lastSeen"`
|
LastSeen string `json:"lastSeen"`
|
||||||
Type string `json:"type"` // api | db | edge | web | desktop | server
|
Type string `json:"type"`
|
||||||
}
|
|
||||||
|
|
||||||
type HostStore struct {
|
Hostname string `json:"hostname"`
|
||||||
Hosts []Host
|
Port int `json:"port"`
|
||||||
}
|
Username string `json:"username"`
|
||||||
|
AuthType string `json:"authType"` // password | key | both
|
||||||
func NewHostStore() *HostStore {
|
Password string `json:"password,omitempty"`
|
||||||
return &HostStore{
|
KeyID string `json:"keyId,omitempty"`
|
||||||
Hosts: defaultHosts(),
|
Group string `json:"group,omitempty"`
|
||||||
}
|
Tags string `json:"tags,omitempty"`
|
||||||
}
|
Notes string `json:"notes,omitempty"`
|
||||||
|
|
||||||
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"},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,11 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type Key struct {
|
type Key struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Passphrase string `json:"passphrase"`
|
Passphrase string `json:"passphrase"`
|
||||||
Strength string `json:"strength"` // weak | moderate | secure
|
Strength string `json:"strength"`
|
||||||
CreatedAt string `json:"createdAt"`
|
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
|
package model
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type Snippet struct {
|
type Snippet struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -10,52 +8,3 @@ type Snippet struct {
|
|||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
CreatedAt string `json:"createdAt"`
|
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', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
const hostOptions = [
|
const el = document.getElementById('hosts-data');
|
||||||
{ id: 'h1', name: 'api-prod-01', ip: '10.0.1.15' },
|
const hostsData = el ? JSON.parse(el.textContent) : [];
|
||||||
{ id: 'h2', name: 'db-primary-01', ip: '10.0.2.5' },
|
const hostOptions = hostsData.length > 0 ? hostsData :
|
||||||
{ id: 'h6', name: 'monitor-01', ip: '10.0.0.5' },
|
[{ id: 'h1', name: 'api-prod-01', ip: '10.0.1.15' }];
|
||||||
{ id: 'h7', name: 'worker-pool-01', ip: '10.0.4.50' },
|
|
||||||
];
|
|
||||||
|
|
||||||
Alpine.data('terminalManager', () => ({
|
Alpine.data('terminalManager', () => ({
|
||||||
tabs: [],
|
tabs: [],
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script id="hosts-data" type="application/json">{{.HostsJSON}}</script>
|
||||||
<script src="/static/xterm/xterm.js"></script>
|
<script src="/static/xterm/xterm.js"></script>
|
||||||
<script src="/static/js/terminal.js"></script>
|
<script src="/static/js/terminal.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+29
-22
@@ -3,7 +3,7 @@
|
|||||||
> **Purpose**: Enable seamless continuation of development by any agent/LLM across sessions.
|
> **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.
|
> **How to use**: Read this file first. Find the first unchecked `[ ]` item. That's where you continue.
|
||||||
> **Last Updated**: 2026-07-07
|
> **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 3 — Settings + Brief | DONE |
|
||||||
| Sprint 4 — SFTP Dual-Pane | DONE |
|
| Sprint 4 — SFTP Dual-Pane | DONE |
|
||||||
| Sprint 5 — Terminal + xterm.js | 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
|
- **Verify**: Terminal page renders, xterm.js loaded, WS endpoint returns Upgrade Required
|
||||||
|
|
||||||
### Sprint 6 — Data Store Integration
|
### Sprint 6 — Data Store Integration
|
||||||
- [ ] replace directive go.mod
|
- [x] `go.mod` replace directive → `v1/pkg/crypto` + `v1/pkg/knownhosts` importable
|
||||||
- [ ] Integrate v1/pkg/storage
|
- [x] `store/` package — JSON file persistence for hosts, snippets, keys, settings (stored at `~/Library/Application Support/hostkeeper/v2/`)
|
||||||
- [ ] Integrate v1/pkg/config
|
- [x] Model SSH fields (Hostname, Port, Username, AuthType, Password, KeyID, Group, Tags, Notes)
|
||||||
- [ ] Integrate v1/pkg/crypto
|
- [x] Handlers updated to use persistent store instead of in-memory mock data
|
||||||
- [ ] Integrate v1/pkg/knownhosts
|
- [x] `sshconn/` package — real SSH client via `golang.org/x/crypto/ssh`
|
||||||
- [ ] Real SSH via v1/pkg/ssh
|
- [x] Terminal WebSocket — real SSH connection (falls back to mock)
|
||||||
- [ ] Real SFTP via v1/pkg/ssh
|
- [x] Dynamic host list in terminal page (server-rendered via JSON script tag)
|
||||||
- [ ] Remove all mock data
|
- [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 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 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 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:
|
### For a new AI agent:
|
||||||
|
|
||||||
1. **Read this file** (`docs/PROGRESS.md`) — find the first `[ ]` item
|
All Sprint 0—6 tasks for HTMX are **complete**. The V2 backend is fully functional with:
|
||||||
2. **Read `AGENTS.md`** (root) — understand architecture, paths, rules
|
- GoFiber v2 server on `:1947`
|
||||||
3. **Read the relevant spec doc** for the current sprint:
|
- HTMX + Alpine.js for interactivity
|
||||||
- Data models → `docs/DATA_MODELS.md`
|
- JSON file persistence in `~/Library/Application Support/hostkeeper/v2/`
|
||||||
- UI components → `docs/UI_COMPONENTS.md`
|
- Real SSH terminal (with mock fallback)
|
||||||
- Build pipeline → `docs/BUILD_SYSTEM.md`
|
- All CRUD operations for hosts, snippets, keychain, settings
|
||||||
4. **Read `docs/SPRINT_PLAN.md`** — detailed file-by-file task instructions
|
|
||||||
5. **Implement** in `app/backend/`
|
Future work areas (beyond Sprint 6):
|
||||||
6. **Test** — `cd app/backend && go build -o /dev/null .` then curl endpoints
|
- **Real SFTP**: Connect SFTP dual-pane to real SSH via `github.com/pkg/sftp`
|
||||||
7. **Update this file** — mark `[x]` completed tasks, update "Last Session Notes"
|
- **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:
|
### For a returning AI agent:
|
||||||
|
|
||||||
1. **Read this file** — check "Last Session Notes" for context
|
1. **Read this file** — check "Last Session Notes" for context
|
||||||
2. **Check git log** — `git log --oneline -10`
|
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
|
4. **Update this file** when done
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user