SSH PTY output contains non-UTF-8 bytes (terminal escape sequences). Sending as TextMessage caused WebSocket decode errors. Switch to BinaryMessage and handle arraybuffer on the client side. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
176 lines
3.7 KiB
Go
176 lines
3.7 KiB
Go
package web
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"golang.org/x/crypto/ssh"
|
|
"github.com/tolelom/catacombs/game"
|
|
"github.com/tolelom/catacombs/store"
|
|
)
|
|
|
|
//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 and returns the server handle.
|
|
func Start(addr string, sshPort int, lobby *game.Lobby, db *store.DB, startTime time.Time) *http.Server {
|
|
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)
|
|
})
|
|
|
|
// Admin endpoint
|
|
mux.Handle("/admin", AdminHandler(lobby, db, startTime))
|
|
|
|
srv := &http.Server{Addr: addr, Handler: mux}
|
|
|
|
slog.Info("starting web terminal", "addr", addr)
|
|
go func() {
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
slog.Error("web server error", "error", err)
|
|
}
|
|
}()
|
|
|
|
return srv
|
|
}
|
|
|
|
func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
|
|
ws, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
slog.Error("WebSocket upgrade error", "error", 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 {
|
|
slog.Error("SSH dial error", "error", 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 {
|
|
slog.Error("SSH session error", "error", 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 {
|
|
slog.Error("PTY request error", "error", err)
|
|
return
|
|
}
|
|
|
|
stdin, err := session.StdinPipe()
|
|
if err != nil {
|
|
slog.Error("stdin pipe error", "error", err)
|
|
return
|
|
}
|
|
|
|
stdout, err := session.StdoutPipe()
|
|
if err != nil {
|
|
slog.Error("stdout pipe error", "error", err)
|
|
return
|
|
}
|
|
|
|
if err := session.Shell(); err != nil {
|
|
slog.Error("shell error", "error", 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.BinaryMessage, 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()
|
|
}
|