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.')">
+
+
+
+
+
+
+ Connected
+
+
+
+
{{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