diff --git a/docker-compose.yml b/docker-compose.yml index 60910b3..4436231 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: . ports: - "2222:2222" + - "8080:8080" volumes: - catacombs-data:/app/data restart: unless-stopped diff --git a/go.mod b/go.mod index 3d22c30..8a21088 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/creack/pty v1.1.21 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index 1e17dd9..5d09ced 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/main.go b/main.go index 0510770..daa5ab0 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "github.com/tolelom/catacombs/game" "github.com/tolelom/catacombs/server" "github.com/tolelom/catacombs/store" + "github.com/tolelom/catacombs/web" ) func main() { @@ -20,7 +21,14 @@ func main() { lobby := game.NewLobby() - log.Println("Catacombs server starting on :2222") + // Start web terminal server in background + go func() { + if err := web.Start(":8080", 2222); err != nil { + log.Printf("Web server error: %v", err) + } + }() + + log.Println("Catacombs server starting — SSH :2222, Web :8080") if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil { log.Fatal(err) } diff --git a/web/server.go b/web/server.go new file mode 100644 index 0000000..d15f902 --- /dev/null +++ b/web/server.go @@ -0,0 +1,161 @@ +package web + +import ( + "embed" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sync" + + "github.com/gorilla/websocket" + "golang.org/x/crypto/ssh" +) + +//go:embed static +var staticFiles embed.FS + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type resizeMsg struct { + Type string `json:"type"` + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +// Start launches the HTTP server for the web terminal. +func Start(addr string, sshPort int) error { + mux := http.NewServeMux() + + // Serve static files from embedded FS + mux.Handle("/", http.FileServer(http.FS(staticFiles))) + + // WebSocket endpoint + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + handleWS(w, r, sshPort) + }) + + log.Printf("Starting web terminal on %s", addr) + return http.ListenAndServe(addr, mux) +} + +func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) { + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket upgrade error: %v", err) + return + } + defer ws.Close() + + // Connect to local SSH server + sshConfig := &ssh.ClientConfig{ + User: "web-player", + Auth: []ssh.AuthMethod{ + ssh.Password(""), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + sshAddr := fmt.Sprintf("localhost:%d", sshPort) + client, err := ssh.Dial("tcp", sshAddr, sshConfig) + if err != nil { + log.Printf("SSH dial error: %v", err) + ws.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("Failed to connect to game server: %v\r\n", err))) + return + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + log.Printf("SSH session error: %v", err) + return + } + defer session.Close() + + // Request PTY + if err := session.RequestPty("xterm-256color", 24, 80, ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + }); err != nil { + log.Printf("PTY request error: %v", err) + return + } + + stdin, err := session.StdinPipe() + if err != nil { + log.Printf("stdin pipe error: %v", err) + return + } + + stdout, err := session.StdoutPipe() + if err != nil { + log.Printf("stdout pipe error: %v", err) + return + } + + if err := session.Shell(); err != nil { + log.Printf("shell error: %v", err) + return + } + + var once sync.Once + done := make(chan struct{}) + cleanup := func() { + once.Do(func() { + close(done) + }) + } + + // SSH stdout → WebSocket + go func() { + defer cleanup() + buf := make([]byte, 4096) + for { + n, err := stdout.Read(buf) + if n > 0 { + if writeErr := ws.WriteMessage(websocket.TextMessage, buf[:n]); writeErr != nil { + return + } + } + if err != nil { + return + } + } + }() + + // WebSocket → SSH stdin (text frames) or resize (binary frames) + go func() { + defer cleanup() + for { + msgType, data, err := ws.ReadMessage() + if err != nil { + return + } + switch msgType { + case websocket.TextMessage: + if _, err := stdin.Write(data); err != nil { + return + } + case websocket.BinaryMessage: + var msg resizeMsg + if json.Unmarshal(data, &msg) == nil && msg.Type == "resize" { + session.WindowChange(msg.Rows, msg.Cols) + } + } + } + }() + + // Wait for either side to close + select { + case <-done: + } + + // Ensure SSH session ends + _ = session.Close() + _ = client.Close() + _ = io.WriteCloser(stdin).Close() +} diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..5b3796b --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,120 @@ + + + + + + Catacombs + + + + +
+
+
+ Connection lost.
+ Press any key to reconnect. +
+
+ + + + + +