diff --git a/.gitignore b/.gitignore index b831f99..0b6a78d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ config .quest +tmp # Logs logs diff --git a/.vscode/settings.json b/.vscode/settings.json index fd4883f..1aa2f62 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,8 @@ "editor.formatOnSave": true, "yaml.schemas": { "packages/game/quest.json": "quests/**/game.yml" + }, + "[go]": { + "editor.defaultFormatter": "golang.go" } } diff --git a/terminal-service/.air.toml b/terminal-service/.air.toml new file mode 100644 index 0000000..498951f --- /dev/null +++ b/terminal-service/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/terminal-service/README.md b/terminal-service/README.md new file mode 100644 index 0000000..61865d9 --- /dev/null +++ b/terminal-service/README.md @@ -0,0 +1,56 @@ +# Terminal Service + +A lightweight terminal service for Linux Odyssey player containers, replacing SSH with a more efficient WebSocket-based solution. + +## Features + +- WebSocket-based terminal sessions +- PTY (pseudo-terminal) management +- Quest command execution +- File status and content inspection +- Direct integration with Traefik for frontend access + +## Architecture + +The service is designed to be lightweight and efficient, using: + +- gorilla/websocket for WebSocket connections +- creack/pty for terminal management +- Traefik for reverse proxying + +## Development + +### Prerequisites + +- Go 1.24 or later +- Docker and Docker Compose +- Traefik (for production deployment) + +### Setup + +1. Install dependencies: + +```bash +go mod init github.com/linux-odyssey/linux-odyssey/terminal-service +go get github.com/gorilla/websocket +go get github.com/creack/pty +``` + +2. Build and run: + +```bash +go build +./terminal-service +``` + +## Configuration + +Configuration details will be added as the service develops. + +## Security + +The service implements security measures to ensure safe container access and command execution. + +## License + +Same as the main Linux Odyssey project. diff --git a/terminal-service/go.mod b/terminal-service/go.mod new file mode 100644 index 0000000..8530e2c --- /dev/null +++ b/terminal-service/go.mod @@ -0,0 +1,10 @@ +module github.com/linux-odyssey/linux-odyssey/terminal-service + +go 1.24.2 + +require ( + github.com/creack/pty v1.1.24 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.31.0 // indirect +) diff --git a/terminal-service/go.sum b/terminal-service/go.sum new file mode 100644 index 0000000..5ba15ab --- /dev/null +++ b/terminal-service/go.sum @@ -0,0 +1,8 @@ +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= diff --git a/terminal-service/index.html b/terminal-service/index.html new file mode 100644 index 0000000..738a22e --- /dev/null +++ b/terminal-service/index.html @@ -0,0 +1,82 @@ + + + + WebSocket Terminal + + + + +
+ + + + + diff --git a/terminal-service/main.go b/terminal-service/main.go new file mode 100644 index 0000000..deccaf3 --- /dev/null +++ b/terminal-service/main.go @@ -0,0 +1,179 @@ +package main + +import ( + "flag" + "io" + "log" + "net/http" + "os" + "os/exec" + "time" + + "github.com/creack/pty" + "github.com/gorilla/websocket" +) + +var ( + addr = flag.String("addr", ":8080", "http service address") +) + +const ( + // Time allowed to write a message to the peer. + writeWait = 10 * time.Second + + // Maximum message size allowed from peer. + maxMessageSize = 8192 + + // Time allowed to read the next pong message from the peer. + pongWait = 60 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 + + // Time to wait before force close on connection. + closeGracePeriod = 10 * time.Second +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // Allow all origins for now - we'll restrict this in production + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +type terminal struct { + cmd *exec.Cmd + pty *os.File +} + +func newTerminal() (*terminal, error) { + // Start a new shell + cmd := exec.Command("bash") + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, err + } + + return &terminal{ + cmd: cmd, + pty: ptmx, + }, nil +} + +func (t *terminal) Close() error { + if t.pty != nil { + t.pty.Close() + } + if t.cmd != nil { + t.cmd.Process.Kill() + t.cmd.Wait() + } + return nil +} + +func pumpStdin(ws *websocket.Conn, w io.Writer) { + defer ws.Close() + ws.SetReadLimit(maxMessageSize) + ws.SetReadDeadline(time.Now().Add(pongWait)) + ws.SetPongHandler(func(string) error { ws.SetReadDeadline(time.Now().Add(pongWait)); return nil }) + for { + _, message, err := ws.ReadMessage() + if err != nil { + break + } + if _, err := w.Write(message); err != nil { + break + } + } +} + +func pumpStdout(ws *websocket.Conn, r io.Reader, done chan struct{}) { + defer func() { + }() + buf := make([]byte, 1024) + for { + n, err := r.Read(buf) + if err != nil { + break + } + ws.SetWriteDeadline(time.Now().Add(writeWait)) + if err := ws.WriteMessage(websocket.TextMessage, buf[:n]); err != nil { + ws.Close() + break + } + } + close(done) + + ws.SetWriteDeadline(time.Now().Add(writeWait)) + ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + time.Sleep(closeGracePeriod) + ws.Close() +} + +func ping(ws *websocket.Conn, done chan struct{}) { + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := ws.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(writeWait)); err != nil { + log.Println("ping:", err) + } + case <-done: + return + } + } +} + +func internalError(ws *websocket.Conn, msg string, err error) { + log.Println(msg, err) + ws.WriteMessage(websocket.TextMessage, []byte("Internal server error.")) +} + +func serveWs(w http.ResponseWriter, r *http.Request) { + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println("upgrade:", err) + return + } + defer ws.Close() + + // Create new terminal + term, err := newTerminal() + if err != nil { + internalError(ws, "start:", err) + return + } + defer term.Close() + + stdoutDone := make(chan struct{}) + go pumpStdout(ws, term.pty, stdoutDone) + go ping(ws, stdoutDone) + + pumpStdin(ws, term.pty) + + // Wait for stdout to finish + <-stdoutDone +} + +func serveHome(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.Error(w, "Not found", http.StatusNotFound) + return + } + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + http.ServeFile(w, r, "index.html") +} + +func main() { + flag.Parse() + log.SetFlags(0) + http.HandleFunc("/", serveHome) + http.HandleFunc("/ws", serveWs) + log.Fatal(http.ListenAndServe(*addr, nil)) +}