From f178089625c0802ac4eb088c778480226d394aa9 Mon Sep 17 00:00:00 2001 From: Indra Gunawan <1284811+coderbuzz@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:06:05 +0700 Subject: [PATCH] feat: add SSH console to server detail page --- go.mod | 1 + go.sum | 2 + internal/app/service.go | 2 + internal/http/console.go | 118 ++++++++++++++++ internal/http/router.go | 2 + internal/http/templates/layout.html | 144 ++++++++++++++++++++ internal/http/templates/servers_detail.html | 11 ++ internal/ssh/client.go | 73 ++++++++++ internal/ssh/interface.go | 21 ++- internal/ssh/mock.go | 68 +++++++++ 10 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 internal/http/console.go diff --git a/go.mod b/go.mod index 2d64507..74da7af 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.3.0 + github.com/gorilla/websocket v1.5.3 golang.org/x/crypto v0.53.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.53.0 diff --git a/go.sum b/go.sum index c800581..50e6937 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/app/service.go b/internal/app/service.go index f91782b..25bb012 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -284,6 +284,8 @@ func (s *Service) Undeploy(id int64) error { log.Printf("Undeploying %q from %s...", app.Name, svr.Name) client.Exec(fmt.Sprintf("%s -f %s down 2>&1 || true", dc, composePath)) + client.Exec("docker image prune -af 2>&1 || true") + client.Exec("docker builder prune -af 2>&1 || true") if app.Domain != "" { caddyClient := caddy.NewClient(client) diff --git a/internal/http/console.go b/internal/http/console.go new file mode 100644 index 0000000..6ba3cf4 --- /dev/null +++ b/internal/http/console.go @@ -0,0 +1,118 @@ +package http + +import ( + "context" + "encoding/json" + "log" + "net/http" + "strconv" + + "github.com/coderbuzz/dockify/internal/server" + "github.com/coderbuzz/dockify/internal/ssh" + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + ReadBufferSize: 4096, + WriteBufferSize: 4096, +} + +type ConsoleHandler struct { + serverSvc *server.Service + sshKeyDir string +} + +func NewConsoleHandler(svc *server.Service, sshKeyDir string) *ConsoleHandler { + return &ConsoleHandler{serverSvc: svc, sshKeyDir: sshKeyDir} +} + +func (h *ConsoleHandler) ServeWS(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "invalid server id", http.StatusBadRequest) + return + } + + svr, err := h.serverSvc.Get(id) + if err != nil { + http.Error(w, "server not found", http.StatusNotFound) + return + } + + // Upgrade to WebSocket before SSH connect (fail fast for WS issues) + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("console ws upgrade error: %v", err) + return + } + defer conn.Close() + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + var client ssh.Connector + + if GetDevMock(r) { + client = ssh.NewMockClient() + } else { + var err error + client, err = ssh.Connect(svr.Host, svr.Port, svr.User, svr.SSHKey) + if err != nil { + log.Printf("console ssh connect error: %v", err) + conn.WriteMessage(websocket.TextMessage, []byte("SSH connection failed: "+err.Error()+"\r\n")) + return + } + defer client.Close() + } + + outCh, inCh, err := client.Shell(ctx, 24, 80) + if err != nil { + log.Printf("console shell error: %v", err) + conn.WriteMessage(websocket.TextMessage, []byte("SSH shell failed: "+err.Error()+"\r\n")) + return + } + + errCh := make(chan error, 2) + + // SSH stdout → WebSocket (raw binary) + go func() { + for out := range outCh { + if out.Closed { + conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + return + } + if err := conn.WriteMessage(websocket.BinaryMessage, out.Data); err != nil { + errCh <- err + return + } + } + }() + + // WebSocket → SSH stdin + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + errCh <- err + return + } + + var input ssh.Input + if json.Unmarshal(msg, &input) == nil && input.Resize != nil { + inCh <- input + continue + } + + inCh <- ssh.Input{Data: msg} + } + }() + + // Block until error or context done + select { + case <-errCh: + case <-ctx.Done(): + } +} diff --git a/internal/http/router.go b/internal/http/router.go index 8cf8d2f..d89c3a2 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -28,6 +28,7 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv apiHandler := server.NewHandler(svc, sshKeyDir) webHandler := server.NewWebHandler(svc, sshKeyDir) + consoleHandler := NewConsoleHandler(svc, sshKeyDir) appAPIHandler := app.NewHandler(appSvc) appWebHandler := app.NewWebHandler(appSvc, serverListAdapter) @@ -72,6 +73,7 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv r.Delete("/{id}", apiHandler.Delete) r.Post("/{id}/init", apiHandler.Init) r.Post("/{id}/refresh", apiHandler.Refresh) + r.Get("/{id}/console", consoleHandler.ServeWS) }) r.Route("/servers", func(r chi.Router) { diff --git a/internal/http/templates/layout.html b/internal/http/templates/layout.html index a911bb3..88aa93c 100644 --- a/internal/http/templates/layout.html +++ b/internal/http/templates/layout.html @@ -4,6 +4,9 @@ {{.Title}} - Dockify + + + {{end}} @@ -321,5 +345,125 @@ var p=function(n){return String(n).padStart(2,'0');}; el.textContent = d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }); + +/* SSH Console */ +var term = null; +var ws = null; + +function toggleConsole() { + var container = document.getElementById('console-container'); + if (container.style.display === 'none') { + container.style.display = 'block'; + document.getElementById('console-toggle').textContent = 'Close Console'; + connectConsole(); + } else { + container.style.display = 'none'; + document.getElementById('console-toggle').textContent = 'Console'; + disconnectConsole(); + } +} + +function connectConsole() { + var btn = document.getElementById('console-toggle'); + var serverId = btn.getAttribute('data-server-id'); + if (!serverId) return; + var base = document.querySelector('base').getAttribute('href') || '/'; + var proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + var host = window.location.host; + + term = new Terminal({ + fontFamily: '"Berkeley Mono", "IBM Plex Mono", ui-monospace, monospace', + fontSize: 13, + lineHeight: 1.35, + cursorBlink: true, + cursorStyle: 'block', + theme: { + background: '#0d0d0d', + foreground: '#cfcecd', + cursor: '#cfcecd', + selectionBackground: '#3a3a3a', + black: '#0d0d0d', + red: '#b33a3a', + green: '#3d8b4a', + yellow: '#b8942e', + blue: '#4a7bbb', + magenta: '#b84a7b', + cyan: '#4ab8b8', + white: '#cfcecd', + brightBlack: '#555', + brightRed: '#b33a3a', + brightGreen: '#3d8b4a', + brightYellow: '#b8942e', + brightBlue: '#4a7bbb', + brightMagenta: '#b84a7b', + brightCyan: '#4ab8b8', + brightWhite: '#cfcecd' + } + }); + + var fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + term.open(document.getElementById('terminal')); + + // Delay fit until terminal is visible and rendered + setTimeout(function() { fitAddon.fit(); }, 50); + + ws = new WebSocket(proto + '//' + host + base + 'api/servers/' + serverId + '/console'); + + ws.onopen = function() { + document.getElementById('console-status').textContent = 'Connected'; + document.getElementById('console-status').className = 'badge badge-online'; + term.focus(); + var dims = fitAddon.proposeDimensions(); + if (dims) { + ws.send(JSON.stringify({resize: {width: dims.cols, height: dims.rows}})); + } + }; + + ws.onmessage = function(evt) { + term.write(evt.data); + }; + + ws.onclose = function() { + document.getElementById('console-status').textContent = 'Disconnected'; + document.getElementById('console-status').className = 'badge badge-offline'; + if (term) { + term.write('\r\n\x1b[31mSession ended.\x1b[0m\r\n'); + } + }; + + term.onData(function(data) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({data: data})); + } + }); + + window._consoleResizeHandler = function() { + if (!fitAddon || !ws || ws.readyState !== WebSocket.OPEN) return; + fitAddon.fit(); + var dims = fitAddon.proposeDimensions(); + if (dims) { + ws.send(JSON.stringify({resize: {width: dims.cols, height: dims.rows}})); + } + }; + window.addEventListener('resize', window._consoleResizeHandler); +} + +function disconnectConsole() { + if (window._consoleResizeHandler) { + window.removeEventListener('resize', window._consoleResizeHandler); + window._consoleResizeHandler = null; + } + if (ws) { + ws.close(); + ws = null; + } + if (term) { + term.dispose(); + term = null; + } + document.getElementById('console-status').textContent = 'Disconnected'; + document.getElementById('console-status').className = 'badge badge-offline'; +} {{end}} diff --git a/internal/http/templates/servers_detail.html b/internal/http/templates/servers_detail.html index 70568a5..1df2ad9 100644 --- a/internal/http/templates/servers_detail.html +++ b/internal/http/templates/servers_detail.html @@ -41,6 +41,17 @@

Connection

onsubmit="return confirm('Remove {{.Server.Name}}? This cannot be undone.')"> + + + +
+
{{template "scripts" .}} diff --git a/internal/ssh/client.go b/internal/ssh/client.go index 188e475..fed6d00 100644 --- a/internal/ssh/client.go +++ b/internal/ssh/client.go @@ -2,6 +2,7 @@ package ssh import ( "bytes" + "context" "fmt" "os" "time" @@ -85,3 +86,75 @@ func RealFactory() Factory { } } + +func (c *Client) Shell(ctx context.Context, rows, cols int) (<-chan Output, chan<- Input, error) { + session, err := c.conn.NewSession() + if err != nil { + return nil, nil, fmt.Errorf("new session: %w", err) + } + + if err := session.RequestPty("xterm-256color", rows, cols, gossh.TerminalModes{ + gossh.ECHO: 1, + gossh.OCRNL: 1, + }); err != nil { + session.Close() + return nil, nil, fmt.Errorf("request pty: %w", err) + } + + wIn, err := session.StdinPipe() + if err != nil { + session.Close() + return nil, nil, fmt.Errorf("stdin pipe: %w", err) + } + + outCh := make(chan Output, 64) + inCh := make(chan Input, 64) + + session.Stdout = channelWriter{outCh} + session.Stderr = channelWriter{outCh} + + if err := session.Shell(); err != nil { + session.Close() + return nil, nil, fmt.Errorf("shell: %w", err) + } + + // Forward input channel → SSH stdin + go func() { + defer wIn.Close() + for input := range inCh { + if input.Resize != nil { + session.WindowChange(input.Resize.Height, input.Resize.Width) + continue + } + if len(input.Data) > 0 { + wIn.Write(input.Data) + } + } + }() + + // Wait for remote exit + go func() { + session.Wait() + session.Close() + outCh <- Output{Closed: true} + }() + + // Context cancellation → kill session + go func() { + <-ctx.Done() + session.Close() + }() + + return outCh, inCh, nil +} + +type channelWriter struct { + ch chan<- Output +} + +func (w channelWriter) Write(p []byte) (int, error) { + data := make([]byte, len(p)) + copy(data, p) + w.ch <- Output{Data: data} + return len(p), nil +} diff --git a/internal/ssh/interface.go b/internal/ssh/interface.go index fd7cfc6..629ec0d 100644 --- a/internal/ssh/interface.go +++ b/internal/ssh/interface.go @@ -1,9 +1,28 @@ package ssh -import "os" +import ( + "context" + "os" +) + +type Output struct { + Data []byte + Closed bool +} + +type WindowSize struct { + Width int + Height int +} + +type Input struct { + Data []byte + Resize *WindowSize +} type Connector interface { Exec(cmd string) (string, error) + Shell(ctx context.Context, rows, cols int) (<-chan Output, chan<- Input, error) WriteFile(path, content string, mode os.FileMode) error Close() error } diff --git a/internal/ssh/mock.go b/internal/ssh/mock.go index 66ae2ab..6d4810e 100644 --- a/internal/ssh/mock.go +++ b/internal/ssh/mock.go @@ -1,8 +1,10 @@ package ssh import ( + "context" "os" "strings" + "time" ) type MockClient struct { @@ -41,6 +43,72 @@ func (m *MockClient) Close() error { return nil } +func (m *MockClient) Shell(ctx context.Context, rows, cols int) (<-chan Output, chan<- Input, error) { + outCh := make(chan Output, 64) + inCh := make(chan Input, 64) + + go func() { + defer close(outCh) + + prompt := []byte("\r\n$ ") + + // Initial prompt + outCh <- Output{Data: []byte("\r\nDockify Dev Mock — SSH console simulation\r\n")} + outCh <- Output{Data: prompt} + + var buf []byte + ticker := time.NewTicker(30 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + outCh <- Output{Data: []byte("\r\nSession closed.\r\n")} + outCh <- Output{Closed: true} + return + case input, ok := <-inCh: + if !ok { + return + } + if input.Resize != nil { + continue + } + for _, b := range input.Data { + if b == '\r' { + outCh <- Output{Data: []byte("\r\n")} + cmd := string(buf) + buf = nil + + // Simulate command response + if cmd == "exit" || cmd == "exit " { + outCh <- Output{Data: []byte("logout\r\n")} + outCh <- Output{Closed: true} + return + } + + // Echo back the command + result := "Mock response for: " + cmd + "\r\n" + outCh <- Output{Data: []byte(result)} + outCh <- Output{Data: prompt} + } else if b == '\x7f' { // backspace + if len(buf) > 0 { + buf = buf[:len(buf)-1] + outCh <- Output{Data: []byte{'\b', ' ', '\b'}} + } + } else { + buf = append(buf, b) + outCh <- Output{Data: []byte{b}} + } + } + case <-ticker.C: + // Keep the goroutine alive for cleanup + } + } + }() + + return outCh, inCh, nil +} + func MockFactory() Factory { return func(host string, port int, user, keyPath string) (Connector, error) { return NewMockClient(), nil