Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions internal/app/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
118 changes: 118 additions & 0 deletions internal/http/console.go
Original file line number Diff line number Diff line change
@@ -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():
}
}
2 changes: 2 additions & 0 deletions internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
144 changes: 144 additions & 0 deletions internal/http/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
<base href="{{.BasePath}}">
<title>{{.Title}} - Dockify</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<style>
:root {
--bg: #f5f5f5;
Expand Down Expand Up @@ -280,6 +283,27 @@
.container { padding: 0.8em 1em; }
.grid { grid-template-columns: 1fr; }
}

/* Console */
.console-section { margin-top: 1em; }
.console-section #console-toggle { margin-bottom: 0.5em; }
.console-container {
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
background: #0d0d0d;
}
.console-toolbar {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.4em 0.7em;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
}
#terminal { padding: 0.3em; }
html.dark .console-container { background: #0d0d0d; }
html:not(.dark) .console-container { background: #1a1a1a; }
</style>
{{end}}

Expand Down Expand Up @@ -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';
}
</script>
{{end}}
11 changes: 11 additions & 0 deletions internal/http/templates/servers_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ <h3>Connection</h3>
onsubmit="return confirm('Remove {{.Server.Name}}? This cannot be undone.')">
<button type="submit" class="btn btn-ghost btn-red">Remove Server</button>
</form>
<button class="btn btn-ghost" id="console-toggle" data-server-id="{{.Server.ID}}" onclick="toggleConsole()" style="margin-left:auto">Console</button>
</div>

<div class="console-section">
<div id="console-container" class="console-container" style="display:none">
<div class="console-toolbar">
<span class="badge badge-online" id="console-status">Connected</span>
<button class="btn btn-ghost btn-red" id="console-disconnect" onclick="toggleConsole()">Disconnect</button>
</div>
<div id="terminal"></div>
</div>
</div>
</div>
{{template "scripts" .}}
Expand Down
Loading
Loading