From 58b6448c3da6fae5aca9fe107bb37b26a0eb21c4 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Wed, 30 Apr 2025 11:08:15 +0800 Subject: [PATCH 01/26] feat: init terminal-service feat: make interactive ws terminal --- .gitignore | 1 + .vscode/settings.json | 3 + terminal-service/.air.toml | 52 +++++++++++ terminal-service/README.md | 56 +++++++++++ terminal-service/go.mod | 10 ++ terminal-service/go.sum | 8 ++ terminal-service/index.html | 82 +++++++++++++++++ terminal-service/main.go | 179 ++++++++++++++++++++++++++++++++++++ 8 files changed, 391 insertions(+) create mode 100644 terminal-service/.air.toml create mode 100644 terminal-service/README.md create mode 100644 terminal-service/go.mod create mode 100644 terminal-service/go.sum create mode 100644 terminal-service/index.html create mode 100644 terminal-service/main.go 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)) +} From 0b08a9a5d9fc47ce15a4c95cca204d8314531778 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Wed, 30 Apr 2025 13:02:14 +0800 Subject: [PATCH 02/26] feat: init cmd-hook and move terminal-service to services directory feat: put cmd-hook in container feat: cmd-hook request type, auto restart setup feat: handle ls --- .gitignore | 2 + packages/game/schema/command.ts | 2 +- quests/.includes/zshrc | 2 +- quests/Dockerfile | 27 ++-- server/src/containers/docker.ts | 10 +- services/cmd-hook/.air.toml | 52 +++++++ services/cmd-hook/command.go | 142 ++++++++++++++++++ services/cmd-hook/go.mod | 3 + services/cmd-hook/main.go | 83 ++++++++++ services/cmd-hook/utils.go | 39 +++++ .../terminal-service}/.air.toml | 0 .../terminal-service}/README.md | 0 .../terminal-service}/go.mod | 2 +- .../terminal-service}/go.sum | 0 .../terminal-service}/index.html | 0 .../terminal-service}/main.go | 0 16 files changed, 343 insertions(+), 21 deletions(-) create mode 100644 services/cmd-hook/.air.toml create mode 100644 services/cmd-hook/command.go create mode 100644 services/cmd-hook/go.mod create mode 100644 services/cmd-hook/main.go create mode 100644 services/cmd-hook/utils.go rename {terminal-service => services/terminal-service}/.air.toml (100%) rename {terminal-service => services/terminal-service}/README.md (100%) rename {terminal-service => services/terminal-service}/go.mod (72%) rename {terminal-service => services/terminal-service}/go.sum (100%) rename {terminal-service => services/terminal-service}/index.html (100%) rename {terminal-service => services/terminal-service}/main.go (100%) diff --git a/.gitignore b/.gitignore index 0b6a78d..5bc2bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ config .quest tmp +services/terminal-service/terminal-service +bin # Logs logs diff --git a/packages/game/schema/command.ts b/packages/game/schema/command.ts index b11633b..c2655f3 100644 --- a/packages/game/schema/command.ts +++ b/packages/game/schema/command.ts @@ -21,7 +21,7 @@ export const commandSchema = z.object({ output: z.string().optional(), error: z.string().optional(), pwd: z.string().optional(), - params: commandParamsSchema.optional(), + params: commandParamsSchema.optional().nullable(), }) export type ICommand = z.infer diff --git a/quests/.includes/zshrc b/quests/.includes/zshrc index f2835f7..82cf55c 100644 --- a/quests/.includes/zshrc +++ b/quests/.includes/zshrc @@ -29,7 +29,7 @@ precmd() { export CMD_ERROR_FILE=/tmp/cmd.error # Now run your Node.js script with the result as an argument - node /usr/local/lib/container/cli.js; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null + /usr/local/bin/cmd-hook; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null # Reset variables unset CMD_START_TIME diff --git a/quests/Dockerfile b/quests/Dockerfile index 99bfbab..833db7e 100644 --- a/quests/Dockerfile +++ b/quests/Dockerfile @@ -1,4 +1,16 @@ -FROM debian:bookworm-slim +FROM golang:1.24-alpine as builder + +WORKDIR /app + +COPY ./services/cmd-hook/go.mod ./ + +RUN go mod download + +COPY ./services/cmd-hook ./ + +RUN go build -o cmd-hook + +FROM debian:bookworm-slim as main LABEL maintainer="wancat@wancat.cc" @@ -8,18 +20,7 @@ RUN apt-get update && apt-get install -y \ curl \ openssh-server - -RUN curl -fsSL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh -RUN bash nodesource_setup.sh -RUN apt-get install -y nodejs -RUN npm install -g yarn -RUN node -v && yarn -v - -WORKDIR /usr/local/lib/container -COPY ./packages/container/package.json ./yarn.lock ./ -RUN yarn install --modules-folder /usr/src/node_modules -ENV NODE_PATH=/usr/src/node_modules -COPY ./packages/container /usr/local/lib/container +COPY --from=builder /app/cmd-hook /usr/local/bin/cmd-hook COPY ./quests/.includes/zshrc /etc/zsh/zshrc RUN rm -f /etc/motd /etc/issue /etc/issue.net /etc/update-motd.d/* RUN rm -rf /etc/skel && mkdir -p /etc/skel diff --git a/server/src/containers/docker.ts b/server/src/containers/docker.ts index b5c7592..9e4557a 100644 --- a/server/src/containers/docker.ts +++ b/server/src/containers/docker.ts @@ -32,7 +32,7 @@ export async function createContainer( questId: string, imageId: string ): Promise { - let binds: string[] = [] + const binds: string[] = [] if (await questHomeExists(questId)) { logger.info('Mounting quest home', questId) binds.push( @@ -41,14 +41,14 @@ export async function createContainer( } if (config.docker.mountQuest && imageId !== 'base') { logger.info('Mounting quest directory', questId) - binds = [ - `${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`, - ] + binds.push( + `${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander` + ) } if (config.docker.mountCLI) { logger.info('Mounting CLI', questId) binds.push( - `${config.docker.hostProjectRoot}/packages/container:/usr/local/lib/container` + `${config.docker.hostProjectRoot}/services/cmd-hook/bin:/usr/local/bin:ro` ) } const option = newContainerOptions(name, imageId, { binds }) diff --git a/services/cmd-hook/.air.toml b/services/cmd-hook/.air.toml new file mode 100644 index 0000000..d4bd6a8 --- /dev/null +++ b/services/cmd-hook/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./bin/cmd-hook" + cmd = "go build -o ./bin/cmd-hook ." + 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/services/cmd-hook/command.go b/services/cmd-hook/command.go new file mode 100644 index 0000000..53bc88a --- /dev/null +++ b/services/cmd-hook/command.go @@ -0,0 +1,142 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type CommandHandler func(cmd string) (CommandParams, error) + +type CommandParams struct { + Discover []FileObject `json:"discover,omitempty"` + Add []FileObject `json:"add,omitempty"` + Remove []FileObject `json:"remove,omitempty"` + Pwd string `json:"pwd,omitempty"` +} + +type FileObject struct { + Path string `json:"path"` + Type FileType `json:"type"` + Discovered bool `json:"discovered"` + Empty bool `json:"empty,omitempty"` +} + +var commandHandlers = map[string]CommandHandler{ + "ls": handleLs, + "cd": handleCd, + "touch": handleTouch, + "mkdir": handleMkdir, + "cp": handleCp, + "rm": handleRm, + "rmdir": handleRmdir, + "mv": handleMv, +} + +func handleCommand(cmd string) (CommandParams, error) { + parts := strings.Fields(cmd) + if len(parts) == 0 { + return CommandParams{}, nil + } + + handler, exists := commandHandlers[parts[0]] + if !exists { + return CommandParams{}, nil + } + + return handler(cmd) +} + +func handleCd(cmd string) (CommandParams, error) { + return CommandParams{ + Pwd: os.Getenv("PWD"), + }, nil +} + +func handleLs(cmd string) (CommandParams, error) { + fmt.Println("handleLs", cmd) + parts := strings.Fields(cmd)[1:] + path := "." + if len(parts) > 1 { + path = parts[1] + } + + files := make([]FileObject, 0) + err := discover(&files, path, 0) + if err != nil { + return CommandParams{}, err + } + + return CommandParams{ + Discover: files, + }, nil +} + +func discover(files *[]FileObject, path string, level int) error { + absPath := resolvePath(path) + fileType, err := fileExists(absPath) + if err != nil { + return fmt.Errorf("ls: cannot access '%s': %v", path, err) + } + + if fileType == FileTypeFile { + *files = append(*files, FileObject{ + Path: absPath, + Type: fileType, + Discovered: level <= 1, + }) + return nil + } + + entries, err := os.ReadDir(absPath) + if err != nil { + return fmt.Errorf("error reading directory: %v", err) + } + + *files = append(*files, FileObject{ + Path: absPath, + Type: fileType, + Discovered: level <= 1, + Empty: len(entries) == 0, + }) + + if level >= 2 { + return nil + } + + for _, entry := range entries { + discover(files, filepath.Join(absPath, entry.Name()), level+1) + } + return nil +} + +func handleTouch(cmd string) (CommandParams, error) { + // TODO: Implement touch command + return CommandParams{}, nil +} + +func handleMkdir(cmd string) (CommandParams, error) { + // TODO: Implement mkdir command + return CommandParams{}, nil +} + +func handleCp(cmd string) (CommandParams, error) { + // TODO: Implement cp command + return CommandParams{}, nil +} + +func handleRm(cmd string) (CommandParams, error) { + // TODO: Implement rm command + return CommandParams{}, nil +} + +func handleRmdir(cmd string) (CommandParams, error) { + // TODO: Implement rmdir command + return CommandParams{}, nil +} + +func handleMv(cmd string) (CommandParams, error) { + // TODO: Implement mv command + return CommandParams{}, nil +} diff --git a/services/cmd-hook/go.mod b/services/cmd-hook/go.mod new file mode 100644 index 0000000..f8d6225 --- /dev/null +++ b/services/cmd-hook/go.mod @@ -0,0 +1,3 @@ +module github.com/linux-odyssey/linux-odyssey/services/cmd-hook + +go 1.24.2 diff --git a/services/cmd-hook/main.go b/services/cmd-hook/main.go new file mode 100644 index 0000000..f298f7f --- /dev/null +++ b/services/cmd-hook/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" +) + +type CommandPayload struct { + Command string `json:"command"` + Output string `json:"output"` + Error string `json:"error"` + ExitCode string `json:"exit_code"` + PWD string `json:"pwd"` + Params CommandParams `json:"params"` +} + +func readOrNone(file string) string { + data, err := os.ReadFile(file) + if err != nil { + return "" + } + return string(data) +} + +func main() { + cmdName := os.Getenv("CMD_NAME") + if cmdName == "" { + os.Exit(0) + } + + payload := CommandPayload{ + Command: cmdName, + Output: readOrNone(os.Getenv("CMD_OUTPUT_FILE")), + Error: readOrNone(os.Getenv("CMD_ERROR_FILE")), + ExitCode: os.Getenv("CMD_EXIT_CODE"), + PWD: os.Getenv("PWD"), + } + + params, err := handleCommand(cmdName) + if err != nil { + fmt.Fprintf(os.Stderr, "Error handling command: %v\n", err) + os.Exit(1) + } + + payload.Params = params + + apiEndpoint := os.Getenv("API_ENDPOINT") + token := os.Getenv("TOKEN") + + jsonData, err := json.MarshalIndent(payload, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling payload: %v\n", err) + os.Exit(1) + } + + fmt.Println(string(jsonData)) + req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/commands", apiEndpoint), bytes.NewBuffer(jsonData)) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) + os.Exit(1) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "Error sending request: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + fmt.Fprintf(os.Stderr, "Failed to send command: %s\nResponse Body: %s\n", resp.Status, string(body)) + os.Exit(1) + } +} diff --git a/services/cmd-hook/utils.go b/services/cmd-hook/utils.go new file mode 100644 index 0000000..efc2ca5 --- /dev/null +++ b/services/cmd-hook/utils.go @@ -0,0 +1,39 @@ +package main + +import ( + "os" + "path/filepath" +) + +type FileType string + +const ( + FileTypeFile FileType = "file" + FileTypeDir FileType = "directory" +) + +func resolvePath(path string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(os.Getenv("PWD"), path) +} + +func fileExists(path string) (FileType, error) { + fileInfo, err := os.Stat(path) + if err != nil { + return "", err + } + if fileInfo.IsDir() { + return FileTypeDir, nil + } + return FileTypeFile, nil +} + +func isDir(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} diff --git a/terminal-service/.air.toml b/services/terminal-service/.air.toml similarity index 100% rename from terminal-service/.air.toml rename to services/terminal-service/.air.toml diff --git a/terminal-service/README.md b/services/terminal-service/README.md similarity index 100% rename from terminal-service/README.md rename to services/terminal-service/README.md diff --git a/terminal-service/go.mod b/services/terminal-service/go.mod similarity index 72% rename from terminal-service/go.mod rename to services/terminal-service/go.mod index 8530e2c..ca153ab 100644 --- a/terminal-service/go.mod +++ b/services/terminal-service/go.mod @@ -1,4 +1,4 @@ -module github.com/linux-odyssey/linux-odyssey/terminal-service +module github.com/linux-odyssey/linux-odyssey/services/terminal-service go 1.24.2 diff --git a/terminal-service/go.sum b/services/terminal-service/go.sum similarity index 100% rename from terminal-service/go.sum rename to services/terminal-service/go.sum diff --git a/terminal-service/index.html b/services/terminal-service/index.html similarity index 100% rename from terminal-service/index.html rename to services/terminal-service/index.html diff --git a/terminal-service/main.go b/services/terminal-service/main.go similarity index 100% rename from terminal-service/main.go rename to services/terminal-service/main.go From 5e81db2759a1a76f1bb2ce304d971954858b472e Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Wed, 30 Apr 2025 21:43:49 +0800 Subject: [PATCH 03/26] feat: connect terminal service to backend feat: put terminal service into container refactor: separate main into terminal and websocket feat: create zsh using commander in home feat: pass backendUrl to container feat: pass token to ws server to cmd-hook feat: gen terminal access token for frontend feat: success to connect through traefik fix: handle invalid jwt feat: connect to session container via traefik --- app/src/components/game/QuestPart.vue | 1 + app/src/store/session.ts | 12 +- app/src/types.ts | 1 + docker-compose.yml | 24 +++ quests/.includes/zshrc | 2 +- quests/Dockerfile | 26 ++-- quests/entrypoint.sh | 15 +- .../src/api/controllers/commandController.ts | 19 ++- server/src/api/routes/index.ts | 7 +- server/src/api/socket.ts | 38 +---- server/src/containers/docker.ts | 16 +- server/src/models/sessionManager.ts | 18 ++- server/src/routers/sessionRouter.ts | 8 +- services/cmd-hook/main.go | 8 +- services/terminal-service/.air.toml | 4 +- services/terminal-service/assets.go | 6 + services/terminal-service/index.html | 6 +- services/terminal-service/main.go | 144 +++--------------- services/terminal-service/terminal.go | 40 +++++ services/terminal-service/websocket.go | 95 ++++++++++++ 20 files changed, 281 insertions(+), 209 deletions(-) create mode 100644 services/terminal-service/assets.go create mode 100644 services/terminal-service/terminal.go create mode 100644 services/terminal-service/websocket.go diff --git a/app/src/components/game/QuestPart.vue b/app/src/components/game/QuestPart.vue index ba807f4..abffd60 100644 --- a/app/src/components/game/QuestPart.vue +++ b/app/src/components/game/QuestPart.vue @@ -22,6 +22,7 @@ const tasks = computed(() => {

{{ sessionStore.quest.title }} + {{ sessionStore.session?.token }}


diff --git a/app/src/store/session.ts b/app/src/store/session.ts index 75a35d8..c701142 100644 --- a/app/src/store/session.ts +++ b/app/src/store/session.ts @@ -94,12 +94,12 @@ const useSession = defineStore('session', { }, setup() { if (hasSetup) return - socket.on('terminal', (data: string) => { - term.write(data) - }) - term.onData((data: string) => { - socket.emit('terminal', data) - }) + // socket.on('terminal', (data: string) => { + // term.write(data) + // }) + // term.onData((data: string) => { + // socket.emit('terminal', data) + // }) socket.on('graph', (event: FileGraphUpdateEvent) => { if (!this.session) return this.session.graph.handleEvent(event) diff --git a/app/src/types.ts b/app/src/types.ts index 72192f7..ec4a6ea 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -21,4 +21,5 @@ export interface Session { pwd: string tasks: ITask[] responses: IResponse[] + token: string } diff --git a/docker-compose.yml b/docker-compose.yml index d07b75a..6d90b61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,22 @@ version: '3' services: + traefik: + image: traefik:v3.3 + networks: + - players-dev + command: + - '--api.insecure=true' + - '--providers.docker=true' + - '--providers.docker.exposedbydefault=false' + - '--entrypoints.web.address=:80' + - '--entrypoints.websecure.address=:443' + ports: + - '80:80' + - '443:443' + - '8080:8080' + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + db: image: mongo:6 restart: always @@ -19,6 +36,13 @@ services: dockerfile: ./quests/Dockerfile extra_hosts: - 'host.docker.internal:host-gateway' + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.base.rule=PathPrefix(`/terminal/base`)' + - 'traefik.http.middlewares.base-stripprefix.stripprefix.prefixes=/terminal/base' + - 'traefik.http.routers.base.middlewares=base-stripprefix' + - 'traefik.http.routers.base.entrypoints=web' + - 'traefik.http.services.base.loadbalancer.server.port=9090' networks: internal: diff --git a/quests/.includes/zshrc b/quests/.includes/zshrc index 82cf55c..df145be 100644 --- a/quests/.includes/zshrc +++ b/quests/.includes/zshrc @@ -29,7 +29,7 @@ precmd() { export CMD_ERROR_FILE=/tmp/cmd.error # Now run your Node.js script with the result as an argument - /usr/local/bin/cmd-hook; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null + /usr/local/lib/cmd-hook/cmd-hook; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null # Reset variables unset CMD_START_TIME diff --git a/quests/Dockerfile b/quests/Dockerfile index 833db7e..451a637 100644 --- a/quests/Dockerfile +++ b/quests/Dockerfile @@ -1,15 +1,18 @@ -FROM golang:1.24-alpine as builder - +FROM golang:1.24-alpine as cmd-hook-builder WORKDIR /app - COPY ./services/cmd-hook/go.mod ./ - RUN go mod download - COPY ./services/cmd-hook ./ - RUN go build -o cmd-hook +FROM golang:1.24-alpine as terminal-builder +WORKDIR /app +COPY ./services/terminal-service/go.mod ./ +COPY ./services/terminal-service/go.sum ./ +RUN go mod download +COPY ./services/terminal-service ./ +RUN go build -o terminal-service + FROM debian:bookworm-slim as main LABEL maintainer="wancat@wancat.cc" @@ -18,16 +21,13 @@ RUN apt-get update && apt-get install -y \ vim \ zsh \ curl \ - openssh-server - -COPY --from=builder /app/cmd-hook /usr/local/bin/cmd-hook + sudo COPY ./quests/.includes/zshrc /etc/zsh/zshrc -RUN rm -f /etc/motd /etc/issue /etc/issue.net /etc/update-motd.d/* RUN rm -rf /etc/skel && mkdir -p /etc/skel -RUN mkdir -p /etc/ssh/authorized_keys && chmod 755 /etc/ssh/authorized_keys -COPY ./quests/.includes/sshd_config /etc/ssh/sshd_config COPY ./quests/entrypoint.sh /entrypoint.sh +COPY --from=cmd-hook-builder /app/cmd-hook /usr/local/lib/cmd-hook/cmd-hook +COPY --from=terminal-builder /app/terminal-service /usr/local/lib/terminal-service/terminal-service -EXPOSE 22 +EXPOSE 9090 ENTRYPOINT ["/bin/sh", "/entrypoint.sh"] diff --git a/quests/entrypoint.sh b/quests/entrypoint.sh index 7951103..ba81899 100644 --- a/quests/entrypoint.sh +++ b/quests/entrypoint.sh @@ -3,19 +3,16 @@ USERNAME=commander echo "Initializing container for $USERNAME" +echo "BACKEND_URL: $BACKEND_URL" if ! id -u ${USERNAME} >/dev/null 2>&1; then echo "Adding user $USERNAME" adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} && \ find / -name ".gitkeep" -type f -exec rm -f {} + - - echo "Adding public key for $USERNAME" - echo "Public key: $PUB_KEY" - PUB_KEY_PATH=/etc/ssh/authorized_keys/${USERNAME} - echo $PUB_KEY > ${PUB_KEY_PATH} - chmod 644 ${PUB_KEY_PATH} fi -mkdir -p /run/sshd -echo "Starting SSH server" -/usr/sbin/sshd -D -e +# Preserve environment variables +export BACKEND_URL + +echo "Starting terminal service" +su - ${USERNAME} -w BACKEND_URL -c "/usr/local/lib/terminal-service/terminal-service" diff --git a/server/src/api/controllers/commandController.ts b/server/src/api/controllers/commandController.ts index d41afac..876a7db 100644 --- a/server/src/api/controllers/commandController.ts +++ b/server/src/api/controllers/commandController.ts @@ -7,11 +7,29 @@ import { asyncHandler } from '../../middleware/error' import { questManager } from '../../models/quest' import { CLIFileExistenceChecker } from '../../containers/cli' import config from '../../config' +import { verifyJWT } from '../../utils/auth' export const newCommand = asyncHandler(async (req: Request, res: Response) => { if (!config.isProduction) { console.log('newCommand', JSON.stringify(req.body, null, 2)) } + const token = req.headers.authorization?.split(' ')[1] + if (!token) { + res.status(401).json({ message: 'Unauthorized' }) + return + } + let sessionId: string + try { + const decoded = await verifyJWT(token) + sessionId = decoded.sessionId + if (!sessionId) { + res.status(401).json({ message: 'Unauthorized' }) + return + } + } catch (error) { + res.status(401).json({ message: 'Unauthorized' }) + return + } const body = commandSchema.safeParse(req.body) if (!body.success) { res.status(400).json({ message: 'Invalid command', errors: body.error }) @@ -19,7 +37,6 @@ export const newCommand = asyncHandler(async (req: Request, res: Response) => { } const command = body.data - const { sessionId } = req.user as any const session = await Session.findById(sessionId) if (!session) { res.status(404).json({ message: 'session not found' }) diff --git a/server/src/api/routes/index.ts b/server/src/api/routes/index.ts index 71cc14c..7dcec05 100644 --- a/server/src/api/routes/index.ts +++ b/server/src/api/routes/index.ts @@ -17,12 +17,7 @@ if (!config.isProduction) { }) } -router.use( - '/commands', - passport.authenticate('jwt', { session: false }), - authRequired, - commands -) +router.use('/commands', commands) router.use('/auth', authRouter) router.get('/leaderboard', leaderboardController) diff --git a/server/src/api/socket.ts b/server/src/api/socket.ts index 5b1a90f..af63c1a 100644 --- a/server/src/api/socket.ts +++ b/server/src/api/socket.ts @@ -1,12 +1,9 @@ import { Server } from 'socket.io' import type { Socket } from 'socket.io' -import { Duplex } from 'stream' import validator from 'validator' import type { Server as HttpServer } from 'http' import { ISession, Session } from '../../../packages/models' -import { getAndStartContainer, attachContainer } from '../containers/docker.js' import SessionMiddleware from '../middleware/session.js' -import { genJWT } from '../utils/auth.js' import logger from '../utils/logger.js' type SocketCallback = (event: string, ...args: any[]) => void @@ -61,41 +58,16 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) { return } - const token = await genJWT({ - sessionId: session.id, - }) - - if (!session.containerId) { - next(new Error('Session has no container.')) - return - } - - try { - const container = await getAndStartContainer(session.containerId) - const stream = await attachContainer(container, { token }) - socket.data.context = { - session, - stream, - } - next() - } catch (err) { - logger.error(err) - next(new Error('Failed to connect to container.')) + socket.data.context = { + session, } + next() } function onConnect(socket: Socket) { - const { session, stream } = socket.data.context as { + const { session } = socket.data.context as { session: ISession - stream: Duplex } - stream.on('data', (chunk: Buffer) => { - socket.emit('terminal', chunk.toString()) - }) - - socket.on('terminal', function incoming(message) { - stream.write(message) - }) const socketCallback = (event: string, ...data: any[]) => { socket.emit(event, ...data) @@ -104,9 +76,7 @@ function onConnect(socket: Socket) { listenToSession(session._id.toString(), socketCallback) socket.on('disconnect', () => { - stream.write('exit\n') removeFromSession(session._id.toString(), socketCallback) - stream.destroy() }) } diff --git a/server/src/containers/docker.ts b/server/src/containers/docker.ts index 9e4557a..f2738cd 100644 --- a/server/src/containers/docker.ts +++ b/server/src/containers/docker.ts @@ -22,9 +22,17 @@ const newContainerOptions = ( ExtraHosts: ['host.docker.internal:host-gateway'], }, ExposedPorts: { - '22/tcp': {}, + '9090/tcp': {}, + }, + Env: [`BACKEND_URL=${config.backendUrl}`], + Labels: { + 'traefik.enable': 'true', + [`traefik.http.routers.terminal-${name}.rule`]: `PathPrefix(\`/terminal/${name}\`)`, + [`traefik.http.middlewares.terminal-${name}-stripprefix.stripprefix.prefixes`]: `/terminal/${name}`, + [`traefik.http.routers.terminal-${name}.middlewares`]: `terminal-${name}-stripprefix`, + [`traefik.http.services.terminal-${name}.loadbalancer.server.port`]: '9090', + [`traefik.http.routers.terminal-${name}.entrypoints`]: 'web', }, - Env: [`PUB_KEY=${config.docker.keypair.publicKey}`], }) export async function createContainer( @@ -48,7 +56,8 @@ export async function createContainer( if (config.docker.mountCLI) { logger.info('Mounting CLI', questId) binds.push( - `${config.docker.hostProjectRoot}/services/cmd-hook/bin:/usr/local/bin:ro` + `${config.docker.hostProjectRoot}/services/cmd-hook/bin:/usr/local/lib/cmd-hook:ro`, + `${config.docker.hostProjectRoot}/services/terminal-service/bin:/usr/local/lib/terminal-service:ro` ) } const option = newContainerOptions(name, imageId, { binds }) @@ -98,6 +107,7 @@ export async function attachContainer( const containerIp = (await container.inspect()).NetworkSettings.Networks[ config.docker.network ].IPAddress + console.log('containerIp', containerIp) let error: Error | null = null for (let i = 0; i < 10; i++) { diff --git a/server/src/models/sessionManager.ts b/server/src/models/sessionManager.ts index 82039a1..c6bb814 100644 --- a/server/src/models/sessionManager.ts +++ b/server/src/models/sessionManager.ts @@ -1,7 +1,11 @@ import { HydratedDocument } from 'mongoose' import { Session, UserProfile } from '../../../packages/models' import type { ISession, IUser } from '../../../packages/models' -import { createContainer, deleteContainer } from '../containers/docker.js' +import { + createContainer, + deleteContainer, + getAndStartContainer, +} from '../containers/docker.js' import logger from '../utils/logger.js' import { questManager } from './quest.js' @@ -48,11 +52,10 @@ export async function createNewSession( throw new Error(`UserProfile ${userId} not found`) } - const container = await createContainer( - `quest-${quest.id}-${userProfile.user.username}-${Date.now()}`, - quest.id, - quest.image - ) + const containerName = `quest-${quest.id}-${userProfile.user.username}-${Date.now()}` + + const container = await createContainer(containerName, quest.id, quest.image) + await getAndStartContainer(container.id) const newSession = new Session({ user: userId, @@ -60,6 +63,9 @@ export async function createNewSession( containerId: container.id, }) + console.log('container', container.id) + console.log('containerName', `http://localhost/terminal/${containerName}`) + const progress = userProfile.progress.get(quest.id) if (!progress) { userProfile.progress.set(quest.id, { diff --git a/server/src/routers/sessionRouter.ts b/server/src/routers/sessionRouter.ts index 20db84d..c5dad08 100644 --- a/server/src/routers/sessionRouter.ts +++ b/server/src/routers/sessionRouter.ts @@ -6,10 +6,11 @@ import { createNewSession, isQuestUnlocked } from '../models/sessionManager.js' import config from '../config.js' import { protectedProcedure, router } from '../trpc.js' import { questManager } from '../models/quest.js' +import { genJWT } from '../utils/auth' export type SessionDetail = Awaited> -function sessionDetail(session: ISession) { +async function sessionDetail(session: ISession) { const quest = questManager.get(session.quest) if (!quest) { throw new TRPCError({ @@ -22,6 +23,10 @@ function sessionDetail(session: ISession) { quest, new VoidFileExistenceChecker() ) + const token = await genJWT({ + sessionId: session._id.toString(), + }) + return { _id: session._id.toString(), user: session.user.toString(), @@ -33,6 +38,7 @@ function sessionDetail(session: ISession) { responses: gameSession.getResponses(), tasks: gameSession.getTasks(), graph: gameSession.getGraph(), + token, } } diff --git a/services/cmd-hook/main.go b/services/cmd-hook/main.go index f298f7f..9ecd5bf 100644 --- a/services/cmd-hook/main.go +++ b/services/cmd-hook/main.go @@ -48,7 +48,11 @@ func main() { payload.Params = params - apiEndpoint := os.Getenv("API_ENDPOINT") + backendUrl := os.Getenv("BACKEND_URL") + if backendUrl == "" { + fmt.Fprintf(os.Stderr, "BACKEND_URL is not set\n") + os.Exit(1) + } token := os.Getenv("TOKEN") jsonData, err := json.MarshalIndent(payload, "", " ") @@ -58,7 +62,7 @@ func main() { } fmt.Println(string(jsonData)) - req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/commands", apiEndpoint), bytes.NewBuffer(jsonData)) + req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/commands", backendUrl), bytes.NewBuffer(jsonData)) if err != nil { fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) os.Exit(1) diff --git a/services/terminal-service/.air.toml b/services/terminal-service/.air.toml index 498951f..dfc0da2 100644 --- a/services/terminal-service/.air.toml +++ b/services/terminal-service/.air.toml @@ -4,8 +4,8 @@ tmp_dir = "tmp" [build] args_bin = [] - bin = "./tmp/main" - cmd = "go build -o ./tmp/main ." + bin = "./bin/terminal-service" + cmd = "go build -o ./bin/terminal-service ." delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_file = [] diff --git a/services/terminal-service/assets.go b/services/terminal-service/assets.go new file mode 100644 index 0000000..5b78555 --- /dev/null +++ b/services/terminal-service/assets.go @@ -0,0 +1,6 @@ +package main + +import _ "embed" + +//go:embed index.html +var indexHTML []byte diff --git a/services/terminal-service/index.html b/services/terminal-service/index.html index 738a22e..8da7235 100644 --- a/services/terminal-service/index.html +++ b/services/terminal-service/index.html @@ -39,9 +39,13 @@ term.open(document.getElementById('terminal')) fitAddon.fit() + const token = new URLSearchParams(window.location.search).get('token') + // Create WebSocket connection const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const ws = new WebSocket(`${protocol}//${window.location.host}/ws`) + const ws = new WebSocket( + `${protocol}//${window.location.host}${window.location.pathname}/ws?token=${token}` + ) // Handle WebSocket connection ws.onopen = () => { diff --git a/services/terminal-service/main.go b/services/terminal-service/main.go index deccaf3..da3b6b7 100644 --- a/services/terminal-service/main.go +++ b/services/terminal-service/main.go @@ -2,136 +2,16 @@ package main import ( "flag" - "io" + "fmt" "log" "net/http" "os" - "os/exec" - "time" - - "github.com/creack/pty" - "github.com/gorilla/websocket" ) var ( - addr = flag.String("addr", ":8080", "http service address") + addr = flag.String("addr", ":9090", "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 { @@ -140,8 +20,14 @@ func serveWs(w http.ResponseWriter, r *http.Request) { } defer ws.Close() + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + // Create new terminal - term, err := newTerminal() + term, err := newTerminal(token) if err != nil { internalError(ws, "start:", err) return @@ -167,7 +53,15 @@ func serveHome(w http.ResponseWriter, r *http.Request) { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - http.ServeFile(w, r, "index.html") + w.Header().Set("Content-Type", "text/html") + w.Write(indexHTML) +} + +func serveHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + for _, e := range os.Environ() { + fmt.Fprintln(w, e) + } } func main() { @@ -175,5 +69,7 @@ func main() { log.SetFlags(0) http.HandleFunc("/", serveHome) http.HandleFunc("/ws", serveWs) + http.HandleFunc("/health", serveHealth) + fmt.Println("Starting terminal service on", *addr) log.Fatal(http.ListenAndServe(*addr, nil)) } diff --git a/services/terminal-service/terminal.go b/services/terminal-service/terminal.go new file mode 100644 index 0000000..bb016a8 --- /dev/null +++ b/services/terminal-service/terminal.go @@ -0,0 +1,40 @@ +package main + +import ( + "os" + "os/exec" + + "github.com/creack/pty" +) + +type terminal struct { + cmd *exec.Cmd + pty *os.File +} + +func newTerminal(token string) (*terminal, error) { + // Start a new shell in the home directory + cmd := exec.Command("/usr/bin/zsh") + cmd.Dir = os.Getenv("HOME") + cmd.Env = append(os.Environ(), "ZDOTDIR=/etc/zsh", "TOKEN="+token) + 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 +} diff --git a/services/terminal-service/websocket.go b/services/terminal-service/websocket.go new file mode 100644 index 0000000..64710ec --- /dev/null +++ b/services/terminal-service/websocket.go @@ -0,0 +1,95 @@ +package main + +import ( + "io" + "log" + "net/http" + "time" + + "github.com/gorilla/websocket" +) + +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 + }, +} + +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.")) +} From 9c9456693d3024aeaa3940db9a9bdb398062f18f Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Fri, 2 May 2025 16:53:53 +0800 Subject: [PATCH 04/26] feat: handle dev server proxy by traefik --- app/vite.config.ts | 1 + docker-compose.yml | 5 +++++ traefik/conf/dev.yml | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 traefik/conf/dev.yml diff --git a/app/vite.config.ts b/app/vite.config.ts index 542e7d1..8ab4871 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ }, }, server: { + host: '0.0.0.0', proxy: { '/trpc': { target: process.env.API_TARGET || 'http://localhost:3000', diff --git a/docker-compose.yml b/docker-compose.yml index 6d90b61..f4d3dfc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,14 +8,19 @@ services: - '--api.insecure=true' - '--providers.docker=true' - '--providers.docker.exposedbydefault=false' + - '--providers.file.directory=/etc/traefik/conf' + - '--providers.file.watch=true' - '--entrypoints.web.address=:80' - '--entrypoints.websecure.address=:443' ports: - '80:80' - '443:443' - '8080:8080' + extra_hosts: + - 'host.docker.internal:host-gateway' volumes: - /var/run/docker.sock:/var/run/docker.sock:ro + - ./traefik/conf:/etc/traefik/conf db: image: mongo:6 diff --git a/traefik/conf/dev.yml b/traefik/conf/dev.yml new file mode 100644 index 0000000..dd0d99f --- /dev/null +++ b/traefik/conf/dev.yml @@ -0,0 +1,24 @@ +http: + routers: + frontend: + rule: 'PathPrefix(`/`)' + service: frontend + entryPoints: + - web + + backend: + rule: 'PathPrefix(`/api`, `/socket.io`, `/trpc`, `/api-docs`)' + service: backend + entryPoints: + - web + + services: + frontend: + loadBalancer: + servers: + - url: 'http://host.docker.internal:5173' + + backend: + loadBalancer: + servers: + - url: 'http://host.docker.internal:3000' From c7e6d31b9ccc199c7558f68fd1deec4e964dd4a1 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Fri, 2 May 2025 17:33:14 +0800 Subject: [PATCH 05/26] feat: connect frontend terminal to terminal service fix: use ReconnectWebSocket to make the connection more stable fix: typescript error on importing css in vue --- app/package.json | 5 ++-- app/src/components/game/QuestPart.vue | 6 +++- app/src/store/session.ts | 4 +++ app/src/types.ts | 1 + app/src/types/css.d.ts | 4 +++ app/src/utils/terminal.ts | 41 ++++++++++++++++++++++++--- packages/models/session.ts | 2 ++ server/src/models/sessionManager.ts | 1 + server/src/routers/sessionRouter.ts | 1 + services/terminal-service/main.go | 1 + traefik/conf/dev.yml | 22 ++++++++++++-- yarn.lock | 5 ++++ 12 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 app/src/types/css.d.ts diff --git a/app/package.json b/app/package.json index 7fdc36f..c44e96f 100644 --- a/app/package.json +++ b/app/package.json @@ -22,6 +22,7 @@ "dompurify": "^3.2.4", "marked": "^10.0.0", "pinia": "^2.1.7", + "reconnecting-websocket": "^4.4.0", "socket.io-client": "^4.7.1", "validator": "^13.11.0", "vue": "^3.3.4", @@ -34,12 +35,12 @@ }, "devDependencies": { "@types/validator": "^13.11.9", + "@vitejs/plugin-vue": "^5.2.2", "@vue/compiler-sfc": "^3.3.13", + "@vue/tsconfig": "^0.7.0", "autoprefixer": "^10.4.14", "postcss": "^8.4.31", "tailwindcss": "^3.3.3", - "@vitejs/plugin-vue": "^5.2.2", - "@vue/tsconfig": "^0.7.0", "typescript": "~5.7.2", "vite": "^6.3.1", "vue-tsc": "^2.2.8" diff --git a/app/src/components/game/QuestPart.vue b/app/src/components/game/QuestPart.vue index abffd60..7ef9630 100644 --- a/app/src/components/game/QuestPart.vue +++ b/app/src/components/game/QuestPart.vue @@ -22,7 +22,11 @@ const tasks = computed(() => {

{{ sessionStore.quest.title }} - {{ sessionStore.session?.token }} + + Open Terminal +


diff --git a/app/src/store/session.ts b/app/src/store/session.ts index c701142..d19469b 100644 --- a/app/src/store/session.ts +++ b/app/src/store/session.ts @@ -50,8 +50,12 @@ const useSession = defineStore('session', { ...session, graph: new FileGraph(session.graph), pwd: '/home/commander', + containerName: session.containerName || '', } term.reset() + term.connect( + `/terminal/${session.containerName}/ws?token=${session.token}` + ) await socket.connect(this.session) term.focus() }, diff --git a/app/src/types.ts b/app/src/types.ts index ec4a6ea..a057d56 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -22,4 +22,5 @@ export interface Session { tasks: ITask[] responses: IResponse[] token: string + containerName: string } diff --git a/app/src/types/css.d.ts b/app/src/types/css.d.ts new file mode 100644 index 0000000..8be70df --- /dev/null +++ b/app/src/types/css.d.ts @@ -0,0 +1,4 @@ +declare module '*.css' { + const content: string + export default content +} diff --git a/app/src/utils/terminal.ts b/app/src/utils/terminal.ts index b384844..a585900 100644 --- a/app/src/utils/terminal.ts +++ b/app/src/utils/terminal.ts @@ -1,10 +1,12 @@ import { Terminal } from 'xterm' import { FitAddon } from 'xterm-addon-fit' +import ReconnectingWebSocket from 'reconnecting-websocket' import 'xterm/css/xterm.css' class SocketTerminal { private term: Terminal private fitAddon: FitAddon + private socket: ReconnectingWebSocket | null = null constructor() { this.term = new Terminal({ @@ -19,6 +21,40 @@ class SocketTerminal { }) this.fitAddon = new FitAddon() this.term.loadAddon(this.fitAddon) + this.term.onData((data) => { + if (this.socket) { + this.socket.send(data) + } + }) + } + + connect(url: string): void { + console.log('Connecting to terminal', url) + this.socket = new ReconnectingWebSocket(url) + this.socket.onopen = () => { + console.log('Connected to terminal') + this.term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n') + } + + this.socket.onclose = () => { + console.log('Disconnected from terminal') + this.term.write('\r\n\x1B[1;31mDisconnected from terminal\x1B[0m\r\n') + } + + this.socket.onerror = (error) => { + console.error('WebSocket error:', error) + this.term.write( + `\r\n\x1B[1;31mWebSocket error: ${'message' in error ? error.message : ''}\x1B[0m\r\n` + ) + } + this.socket.onmessage = (event) => { + console.log('Received message from terminal') + const data = + typeof event.data === 'string' + ? event.data + : new TextDecoder().decode(event.data) + this.term.write(data) + } } resizeScreen(): void { @@ -38,16 +74,13 @@ class SocketTerminal { this.term.write(message) } - onData(callback: (data: string) => void): void { - this.term.onData(callback) - } - focus(): void { this.term.focus() } reset(): void { this.term.reset() + this.socket?.close() } } diff --git a/packages/models/session.ts b/packages/models/session.ts index cae81e8..30f7d4e 100644 --- a/packages/models/session.ts +++ b/packages/models/session.ts @@ -28,6 +28,7 @@ export interface ISession { user: Types.ObjectId quest: string containerId: string | null + containerName: string | null status: 'active' | 'finished' | 'inactive' createdAt: Date updatedAt: Date @@ -52,6 +53,7 @@ export const Session = model( required: true, }, containerId: String, + containerName: String, status: { type: String, enum: ['active', 'finished', 'inactive'], diff --git a/server/src/models/sessionManager.ts b/server/src/models/sessionManager.ts index c6bb814..046235d 100644 --- a/server/src/models/sessionManager.ts +++ b/server/src/models/sessionManager.ts @@ -61,6 +61,7 @@ export async function createNewSession( user: userId, quest: quest.id, containerId: container.id, + containerName, }) console.log('container', container.id) diff --git a/server/src/routers/sessionRouter.ts b/server/src/routers/sessionRouter.ts index c5dad08..3c5ecbf 100644 --- a/server/src/routers/sessionRouter.ts +++ b/server/src/routers/sessionRouter.ts @@ -38,6 +38,7 @@ async function sessionDetail(session: ISession) { responses: gameSession.getResponses(), tasks: gameSession.getTasks(), graph: gameSession.getGraph(), + containerName: session.containerName, token, } } diff --git a/services/terminal-service/main.go b/services/terminal-service/main.go index da3b6b7..9d1ee88 100644 --- a/services/terminal-service/main.go +++ b/services/terminal-service/main.go @@ -13,6 +13,7 @@ var ( ) func serveWs(w http.ResponseWriter, r *http.Request) { + fmt.Println("Serving WS connection") ws, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("upgrade:", err) diff --git a/traefik/conf/dev.yml b/traefik/conf/dev.yml index dd0d99f..a73b692 100644 --- a/traefik/conf/dev.yml +++ b/traefik/conf/dev.yml @@ -6,8 +6,26 @@ http: entryPoints: - web - backend: - rule: 'PathPrefix(`/api`, `/socket.io`, `/trpc`, `/api-docs`)' + backend-api: + rule: 'PathPrefix(`/api`)' + service: backend + entryPoints: + - web + + backend-socket: + rule: 'PathPrefix(`/socket.io`)' + service: backend + entryPoints: + - web + + backend-trpc: + rule: 'PathPrefix(`/trpc`)' + service: backend + entryPoints: + - web + + backend-api-docs: + rule: 'PathPrefix(`/api-docs`)' service: backend entryPoints: - web diff --git a/yarn.lock b/yarn.lock index 3c13f5b..ee06019 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8179,6 +8179,11 @@ realize-package-specifier@~3.0.1: dezalgo "^1.0.1" npm-package-arg "^4.1.1" +reconnecting-websocket@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783" + integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng== + regenerate-unicode-properties@^10.1.0: version "10.1.1" resolved "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz" From fb863ba030afc71a36a45d8f816d3f93623d45b1 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Fri, 2 May 2025 18:50:54 +0800 Subject: [PATCH 06/26] fix: make quest image building process cleaner --- .dockerignore | 3 ++- quests/Dockerfile | 4 ++-- services/terminal-service/index.html | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index 2b9daea..a23c257 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ **/node_modules .git **/dist -**/tsconfig.tsbuildinfo \ No newline at end of file +**/tsconfig.tsbuildinfo +data \ No newline at end of file diff --git a/quests/Dockerfile b/quests/Dockerfile index 451a637..73660db 100644 --- a/quests/Dockerfile +++ b/quests/Dockerfile @@ -2,7 +2,7 @@ FROM golang:1.24-alpine as cmd-hook-builder WORKDIR /app COPY ./services/cmd-hook/go.mod ./ RUN go mod download -COPY ./services/cmd-hook ./ +COPY ./services/cmd-hook/*.go ./ RUN go build -o cmd-hook FROM golang:1.24-alpine as terminal-builder @@ -10,7 +10,7 @@ WORKDIR /app COPY ./services/terminal-service/go.mod ./ COPY ./services/terminal-service/go.sum ./ RUN go mod download -COPY ./services/terminal-service ./ +COPY ./services/terminal-service/*.go ./services/terminal-service/index.html ./ RUN go build -o terminal-service FROM debian:bookworm-slim as main diff --git a/services/terminal-service/index.html b/services/terminal-service/index.html index 8da7235..7b13162 100644 --- a/services/terminal-service/index.html +++ b/services/terminal-service/index.html @@ -44,7 +44,7 @@ // Create WebSocket connection const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket( - `${protocol}//${window.location.host}${window.location.pathname}/ws?token=${token}` + `${protocol}//${window.location.host}${window.location.pathname}ws?token=${token}` ) // Handle WebSocket connection From 21ff710f830b6002baa22dbf63c232b58544685c Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 14:51:08 +0800 Subject: [PATCH 07/26] fix: cannot backspace in terminal --- services/terminal-service/terminal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/terminal-service/terminal.go b/services/terminal-service/terminal.go index bb016a8..d024171 100644 --- a/services/terminal-service/terminal.go +++ b/services/terminal-service/terminal.go @@ -16,7 +16,7 @@ func newTerminal(token string) (*terminal, error) { // Start a new shell in the home directory cmd := exec.Command("/usr/bin/zsh") cmd.Dir = os.Getenv("HOME") - cmd.Env = append(os.Environ(), "ZDOTDIR=/etc/zsh", "TOKEN="+token) + cmd.Env = append(os.Environ(), "ZDOTDIR=/etc/zsh", "TOKEN="+token, "TERM=xterm-256color") ptmx, err := pty.Start(cmd) if err != nil { return nil, err From 156abc044a5330f58831cc006c348900666bfc03 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 14:58:17 +0800 Subject: [PATCH 08/26] refactor: clean up unused code --- server/src/api/socket.ts | 26 ++++++------------- server/src/containers/docker.ts | 28 -------------------- server/src/containers/ssh.ts | 45 --------------------------------- 3 files changed, 8 insertions(+), 91 deletions(-) delete mode 100644 server/src/containers/ssh.ts diff --git a/server/src/api/socket.ts b/server/src/api/socket.ts index af63c1a..7db3c27 100644 --- a/server/src/api/socket.ts +++ b/server/src/api/socket.ts @@ -2,7 +2,7 @@ import { Server } from 'socket.io' import type { Socket } from 'socket.io' import validator from 'validator' import type { Server as HttpServer } from 'http' -import { ISession, Session } from '../../../packages/models' +import { Session } from '../../../packages/models' import SessionMiddleware from '../middleware/session.js' import logger from '../utils/logger.js' @@ -34,11 +34,11 @@ export function pushToSession( } } -// eslint-disable-next-line no-unused-vars -async function connectContainer(socket: Socket, next: (err?: Error) => void) { +async function onConnect(socket: Socket) { const user = (socket.request as any).session?.passport?.user if (!user) { - next(new Error('User not found.')) + socket.emit('error', 'User not found.') + socket.disconnect() return } const { sessionId } = socket.handshake.query @@ -48,27 +48,18 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) { !validator.isMongoId(sessionId) ) { logger.warn('Invalid Session ID.', user.username, sessionId) - next(new Error('Invalid Session ID.')) + socket.emit('error', 'Invalid Session ID.') + socket.disconnect() return } const session = await Session.findById(sessionId) if (!session) { - next(new Error('Session not found.')) + socket.emit('error', 'Session not found.') + socket.disconnect() return } - socket.data.context = { - session, - } - next() -} - -function onConnect(socket: Socket) { - const { session } = socket.data.context as { - session: ISession - } - const socketCallback = (event: string, ...data: any[]) => { socket.emit(event, ...data) } @@ -84,7 +75,6 @@ export default (server: HttpServer) => { const io = new Server(server) io.engine.use(SessionMiddleware) - io.use(connectContainer) io.on('connection', onConnect) io.on('connect_error', (err) => { diff --git a/server/src/containers/docker.ts b/server/src/containers/docker.ts index f2738cd..ef4b84b 100644 --- a/server/src/containers/docker.ts +++ b/server/src/containers/docker.ts @@ -1,9 +1,7 @@ import fs from 'fs/promises' -import { Duplex } from 'stream' import Docker from 'dockerode' import config, { getQuestImage } from '../config.js' import logger from '../utils/logger.js' -import { connectToSSH } from './ssh.js' const engine = new Docker() @@ -100,32 +98,6 @@ export async function getAndStartContainer( return container } -export async function attachContainer( - container: Docker.Container, - { token }: { token: string } -): Promise { - const containerIp = (await container.inspect()).NetworkSettings.Networks[ - config.docker.network - ].IPAddress - console.log('containerIp', containerIp) - - let error: Error | null = null - for (let i = 0; i < 10; i++) { - try { - // eslint-disable-next-line no-await-in-loop - const stream = await connectToSSH(containerIp, { token }) - return stream - } catch (err) { - error = err as Error - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => { - setTimeout(resolve, 1000) - }) - } - } - throw new Error('Failed to connect to SSH', { cause: error }) -} - export async function deleteContainer(id: string) { const container = engine.getContainer(id) if (!container) { diff --git a/server/src/containers/ssh.ts b/server/src/containers/ssh.ts deleted file mode 100644 index 4eb54b9..0000000 --- a/server/src/containers/ssh.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Client } from 'ssh2' -import { Duplex } from 'stream' -import config from '../config.js' - -export async function connectToSSH( - containerIp: string, - { token }: { token: string } -): Promise { - const conn = new Client() - - return new Promise((resolve, reject) => { - conn - .on('ready', () => { - conn.shell( - { - env: { - TOKEN: token, - API_ENDPOINT: config.backendUrl, - ZDOTDIR: '/etc/zsh', - NODE_PATH: '/usr/src/node_modules', - }, - }, - (err, stream) => { - if (err) { - reject(err) - } - stream.on('close', () => { - conn.end() - }) - resolve(stream) - } - ) - }) - .on('error', (err) => { - reject(err) - }) - .connect({ - host: containerIp, - port: 22, - username: 'commander', - privateKey: config.docker.keypair.privateKey, - timeout: 5000, - }) - }) -} From 2f7845e63e12edd0de0ca458e838130855321ccd Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 15:05:41 +0800 Subject: [PATCH 09/26] fix: write echo start on starting --- app/src/store/session.ts | 6 ++-- app/src/utils/socket.ts | 4 +-- app/src/utils/terminal.ts | 61 ++++++++++++++++++++++----------------- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/app/src/store/session.ts b/app/src/store/session.ts index d19469b..9a8e35c 100644 --- a/app/src/store/session.ts +++ b/app/src/store/session.ts @@ -53,10 +53,10 @@ const useSession = defineStore('session', { containerName: session.containerName || '', } term.reset() - term.connect( + await socket.connect(this.session) + await term.connect( `/terminal/${session.containerName}/ws?token=${session.token}` ) - await socket.connect(this.session) term.focus() }, async createSession() { @@ -64,7 +64,7 @@ const useSession = defineStore('session', { questId: this.questId, }) await this.setSession(session) - socket.emit('terminal', 'echo start\n') + term.send('echo start\n') }, async getActiveSession() { const session = await trpc.session.getActiveSession.query({ diff --git a/app/src/utils/socket.ts b/app/src/utils/socket.ts index e520533..48666b7 100644 --- a/app/src/utils/socket.ts +++ b/app/src/utils/socket.ts @@ -23,11 +23,9 @@ class SocketWrapper { this.socket.on('connect', () => { console.log('Socket connected to session', session._id) - }) - - this.socket.once('terminal', () => { resolve() }) + this.socket.on('connect_error', reject) this.bindListeners() }) diff --git a/app/src/utils/terminal.ts b/app/src/utils/terminal.ts index a585900..54cd54d 100644 --- a/app/src/utils/terminal.ts +++ b/app/src/utils/terminal.ts @@ -28,33 +28,38 @@ class SocketTerminal { }) } - connect(url: string): void { - console.log('Connecting to terminal', url) - this.socket = new ReconnectingWebSocket(url) - this.socket.onopen = () => { - console.log('Connected to terminal') - this.term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n') - } + connect(url: string): Promise { + return new Promise((resolve, reject) => { + console.log('Connecting to terminal', url) + this.socket = new ReconnectingWebSocket(url) + this.socket.onopen = () => { + console.log('Connected to terminal') + this.term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n') + resolve() + } - this.socket.onclose = () => { - console.log('Disconnected from terminal') - this.term.write('\r\n\x1B[1;31mDisconnected from terminal\x1B[0m\r\n') - } + this.socket.onclose = () => { + console.log('Disconnected from terminal') + this.term.write('\r\n\x1B[1;31mDisconnected from terminal\x1B[0m\r\n') + reject(new Error('Disconnected from terminal')) + } - this.socket.onerror = (error) => { - console.error('WebSocket error:', error) - this.term.write( - `\r\n\x1B[1;31mWebSocket error: ${'message' in error ? error.message : ''}\x1B[0m\r\n` - ) - } - this.socket.onmessage = (event) => { - console.log('Received message from terminal') - const data = - typeof event.data === 'string' - ? event.data - : new TextDecoder().decode(event.data) - this.term.write(data) - } + this.socket.onerror = (error) => { + console.error('WebSocket error:', error) + this.term.write( + `\r\n\x1B[1;31mWebSocket error: ${'message' in error ? error.message : ''}\x1B[0m\r\n` + ) + reject(error) + } + this.socket.onmessage = (event) => { + console.log('Received message from terminal') + const data = + typeof event.data === 'string' + ? event.data + : new TextDecoder().decode(event.data) + this.term.write(data) + } + }) } resizeScreen(): void { @@ -70,8 +75,10 @@ class SocketTerminal { this.resizeScreen() } - write(message: string): void { - this.term.write(message) + send(message: string): void { + if (this.socket) { + this.socket.send(message) + } } focus(): void { From 63e36c1c98241de51c77f6edf380fda51312533b Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 15:38:29 +0800 Subject: [PATCH 10/26] fix: reduce size of quest image by removing unnecessary apt cache --- quests/Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/quests/Dockerfile b/quests/Dockerfile index 73660db..46c371c 100644 --- a/quests/Dockerfile +++ b/quests/Dockerfile @@ -20,8 +20,10 @@ LABEL maintainer="wancat@wancat.cc" RUN apt-get update && apt-get install -y \ vim \ zsh \ - curl \ - sudo + sudo \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /var/cache/apt/* + COPY ./quests/.includes/zshrc /etc/zsh/zshrc RUN rm -rf /etc/skel && mkdir -p /etc/skel COPY ./quests/entrypoint.sh /entrypoint.sh From 372378021d057e19e43fce0b78661d0642b51f12 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 15:38:47 +0800 Subject: [PATCH 11/26] feat: add timout settings for reconnectin websocket --- app/src/utils/terminal.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/utils/terminal.ts b/app/src/utils/terminal.ts index 54cd54d..4a937c9 100644 --- a/app/src/utils/terminal.ts +++ b/app/src/utils/terminal.ts @@ -31,7 +31,10 @@ class SocketTerminal { connect(url: string): Promise { return new Promise((resolve, reject) => { console.log('Connecting to terminal', url) - this.socket = new ReconnectingWebSocket(url) + this.socket = new ReconnectingWebSocket(url, [], { + connectionTimeout: 5000, + maxRetries: 10, + }) this.socket.onopen = () => { console.log('Connected to terminal') this.term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n') From d7558229080f4fe4fb8f4bc85558bfce6a4b9efe Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 15:41:46 +0800 Subject: [PATCH 12/26] chore: update go.mod to remove unnecessary indirect dependencies --- services/terminal-service/go.mod | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/services/terminal-service/go.mod b/services/terminal-service/go.mod index ca153ab..f7f4ba0 100644 --- a/services/terminal-service/go.mod +++ b/services/terminal-service/go.mod @@ -3,8 +3,6 @@ module github.com/linux-odyssey/linux-odyssey/services/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 + github.com/creack/pty v1.1.24 + github.com/gorilla/websocket v1.5.3 ) From 95c4fb71ae1a5116e2bd9d72dff9c2341982407c Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 15:53:44 +0800 Subject: [PATCH 13/26] feat: handle command hooks feat: handle copy feat: handle remove feat: handle move and home ~ path refactor: improve error handling in cmd-hook feat: handlePwd --- services/cmd-hook/command.go | 148 ++++++++++++++++++++++++++--------- services/cmd-hook/main.go | 1 - services/cmd-hook/utils.go | 20 +++-- 3 files changed, 127 insertions(+), 42 deletions(-) diff --git a/services/cmd-hook/command.go b/services/cmd-hook/command.go index 53bc88a..0eec2d1 100644 --- a/services/cmd-hook/command.go +++ b/services/cmd-hook/command.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" "path/filepath" @@ -26,12 +27,13 @@ type FileObject struct { var commandHandlers = map[string]CommandHandler{ "ls": handleLs, "cd": handleCd, - "touch": handleTouch, - "mkdir": handleMkdir, - "cp": handleCp, - "rm": handleRm, - "rmdir": handleRmdir, - "mv": handleMv, + "touch": handleCreate, + "mkdir": handleCreate, + "cp": handleCopy, + "rm": handleRemove, + "rmdir": handleRemove, + "mv": handleMove, + "pwd": handlePwd, } func handleCommand(cmd string) (CommandParams, error) { @@ -55,27 +57,30 @@ func handleCd(cmd string) (CommandParams, error) { } func handleLs(cmd string) (CommandParams, error) { - fmt.Println("handleLs", cmd) - parts := strings.Fields(cmd)[1:] - path := "." - if len(parts) > 1 { - path = parts[1] + files := getFilePathList(cmd) + if len(files) == 0 { + files = append(files, ".") } - files := make([]FileObject, 0) - err := discover(&files, path, 0) - if err != nil { - return CommandParams{}, err + results := make([]FileObject, 0) + for _, path := range files { + err := discover(&results, path, 0) + if err != nil { + return CommandParams{}, err + } } return CommandParams{ - Discover: files, + Discover: results, }, nil } func discover(files *[]FileObject, path string, level int) error { absPath := resolvePath(path) fileType, err := fileExists(absPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } if err != nil { return fmt.Errorf("ls: cannot access '%s': %v", path, err) } @@ -111,32 +116,103 @@ func discover(files *[]FileObject, path string, level int) error { return nil } -func handleTouch(cmd string) (CommandParams, error) { - // TODO: Implement touch command - return CommandParams{}, nil +func handleCreate(cmd string) (CommandParams, error) { + files := getFilePathList(cmd) + results := make([]FileObject, 0, len(files)) + for _, absPath := range files { + fileType, err := fileExists(absPath) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return CommandParams{}, err + } + results = append(results, FileObject{ + Path: absPath, + Type: fileType, + Discovered: true, + }) + } + return CommandParams{Add: results}, nil } -func handleMkdir(cmd string) (CommandParams, error) { - // TODO: Implement mkdir command - return CommandParams{}, nil +func handleCopy(cmd string) (CommandParams, error) { + files := getFilePathList(cmd) + if len(files) < 2 { + return CommandParams{}, nil + } + dest := files[len(files)-1] + results := make([]FileObject, 0) + discover(&results, dest, 0) + return CommandParams{Discover: results}, nil } -func handleCp(cmd string) (CommandParams, error) { - // TODO: Implement cp command - return CommandParams{}, nil +func handleRemove(cmd string) (CommandParams, error) { + files := getFilePathList(cmd) + results := make([]FileObject, 0, len(files)) + for _, absPath := range files { + _, err := fileExists(absPath) + // If the file exists, ignore it + if err == nil { + continue + } + if !errors.Is(err, os.ErrNotExist) { + return CommandParams{}, err + } + results = append(results, FileObject{ + Path: absPath, + Type: FileTypeFile, // whatever + Discovered: true, + }) + } + return CommandParams{Remove: results}, nil } -func handleRm(cmd string) (CommandParams, error) { - // TODO: Implement rm command - return CommandParams{}, nil +func handleMove(cmd string) (CommandParams, error) { + files := getFilePathList(cmd) + if len(files) < 2 { + return CommandParams{}, nil + } + sources := files[:len(files)-1] + dest := files[len(files)-1] + result := CommandParams{ + Add: make([]FileObject, 0, len(sources)), + Remove: make([]FileObject, 0, len(sources)), + } + for _, source := range sources { + _, err := fileExists(source) + if err == nil { + continue + } + if !errors.Is(err, os.ErrNotExist) { + return CommandParams{}, err + } + result.Remove = append(result.Remove, FileObject{ + Path: source, + Type: FileTypeFile, // whatever + Discovered: true, + }) + } + + discover(&result.Add, dest, 0) + return result, nil } -func handleRmdir(cmd string) (CommandParams, error) { - // TODO: Implement rmdir command - return CommandParams{}, nil -} - -func handleMv(cmd string) (CommandParams, error) { - // TODO: Implement mv command - return CommandParams{}, nil +func handlePwd(cmd string) (CommandParams, error) { + pwd := os.Getenv("PWD") + parts := strings.Split(pwd, "/") + currentPath := "/" + results := make([]FileObject, 0, len(parts)) + for _, part := range parts { + currentPath = filepath.Join(currentPath, part) + results = append(results, FileObject{ + Path: currentPath, + Type: FileTypeDir, + Discovered: true, + }) + } + return CommandParams{ + Pwd: os.Getenv("PWD"), + Add: results, + }, nil } diff --git a/services/cmd-hook/main.go b/services/cmd-hook/main.go index 9ecd5bf..ed3474f 100644 --- a/services/cmd-hook/main.go +++ b/services/cmd-hook/main.go @@ -61,7 +61,6 @@ func main() { os.Exit(1) } - fmt.Println(string(jsonData)) req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/commands", backendUrl), bytes.NewBuffer(jsonData)) if err != nil { fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) diff --git a/services/cmd-hook/utils.go b/services/cmd-hook/utils.go index efc2ca5..61d6880 100644 --- a/services/cmd-hook/utils.go +++ b/services/cmd-hook/utils.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" ) type FileType string @@ -16,6 +17,10 @@ func resolvePath(path string) string { if filepath.IsAbs(path) { return path } + if strings.HasPrefix(path, "~") { + homeDir := os.Getenv("HOME") + return filepath.Join(homeDir, path[1:]) + } return filepath.Join(os.Getenv("PWD"), path) } @@ -30,10 +35,15 @@ func fileExists(path string) (FileType, error) { return FileTypeFile, nil } -func isDir(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false +func getFilePathList(cmd string) []string { + parts := strings.Fields(cmd)[1:] + results := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.HasPrefix(part, "-") { + continue + } + absPath := resolvePath(part) + results = append(results, absPath) } - return info.IsDir() + return results } From e43cbe26cee43bb01b4b54468c7a1d18aec90793 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 16:30:42 +0800 Subject: [PATCH 14/26] refactor: remove deprecated container package --- packages/container/cli.js | 69 ----------- packages/container/discover.js | 69 ----------- packages/container/handleCommand.js | 174 ---------------------------- packages/container/package.json | 13 --- packages/container/utils.js | 10 -- 5 files changed, 335 deletions(-) delete mode 100644 packages/container/cli.js delete mode 100644 packages/container/discover.js delete mode 100644 packages/container/handleCommand.js delete mode 100644 packages/container/package.json delete mode 100644 packages/container/utils.js diff --git a/packages/container/cli.js b/packages/container/cli.js deleted file mode 100644 index 1f5a7fb..0000000 --- a/packages/container/cli.js +++ /dev/null @@ -1,69 +0,0 @@ -const fs = require('fs').promises -const { exit } = require('process') -const handleCommand = require('./handleCommand.js') - -const { - API_ENDPOINT, - TOKEN, - PWD, - CMD_NAME, - CMD_OUTPUT_FILE, - CMD_ERROR_FILE, - CMD_EXIT_CODE, -} = process.env - -async function readOrNone(file) { - try { - return await fs.readFile(file, 'utf8') - } catch (_) { - return '' - } -} - -async function main() { - if (!CMD_NAME) exit(0) - - const params = await handleCommand(CMD_NAME) - - const output = await readOrNone(CMD_OUTPUT_FILE) - const error = await readOrNone(CMD_ERROR_FILE) - const payload = { - command: CMD_NAME, - output, - error, - exit_code: CMD_EXIT_CODE, - pwd: PWD, - params, - } - try { - const response = await fetch(`${API_ENDPOINT}/api/v1/commands`, { - method: 'POST', - headers: { - Authorization: `Bearer ${TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }) - if (!response.ok) { - console.error(`Failed to send command: ${response.statusText}`) - const responseBody = await response.text() - console.error(`Response Body: ${responseBody}`) - } - } catch (e) { - console.error('Error occurred while sending command:', e) - } -} - -main() - .then(exit) - .catch((err) => { - console.log( - '發生預期外的錯誤,請嘗試重新啟動 Quest,如果問題持續發生,請幫我們填寫右上角的錯誤回報表單。' - ) - if (err.response && err.response.data) { - console.log(err.response.data) - } else { - console.log(err) - } - exit(1) - }) diff --git a/packages/container/discover.js b/packages/container/discover.js deleted file mode 100644 index 5955bb7..0000000 --- a/packages/container/discover.js +++ /dev/null @@ -1,69 +0,0 @@ -const fs = require('fs').promises -const path = require('path') -const { resolvePath } = require('./utils') - -async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) { - let stats - try { - stats = await fs.stat(inputPath) - } catch (e) { - if (e.code === 'ENOENT') { - return [] - } - throw e - } - const name = path.basename(inputPath) - const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.')) - const self = { - path: inputPath, - name, - type: stats.isDirectory() ? 'directory' : 'file', - discovered, - } - const files = [self] - if (stats.isDirectory() && level < 2) { - try { - let entries = await fs.readdir(inputPath) - if (entries.length === 0) { - self.empty = true - return files - } - if (level > 0) { - entries = entries.slice(0, 5) - } - const result = await Promise.all( - entries.map((entry) => - collectFilesInfo(path.join(inputPath, entry), level + 1, hiddenFiles) - ) - ) - files.push(...result.flat()) - } catch (_) { - // ignore - } - } - return files -} - -async function discoverFiles(argv) { - const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1) - try { - const result = ( - await Promise.all( - targetPath - .map((p) => resolvePath(p)) - .map((p) => collectFilesInfo(p, 0, argv.a || argv.all)) - ) - ).flat() - if (result.length === 0) { - return {} - } - return { discover: result } - } catch (_) { - return {} - } -} - -module.exports = { - discoverFiles, - collectFilesInfo, -} diff --git a/packages/container/handleCommand.js b/packages/container/handleCommand.js deleted file mode 100644 index 7c8ad45..0000000 --- a/packages/container/handleCommand.js +++ /dev/null @@ -1,174 +0,0 @@ -const fs = require('fs').promises -const path = require('path') -const minimist = require('minimist') -const { discoverFiles, collectFilesInfo } = require('./discover') -const { resolvePath } = require('./utils') - -const commandListeners = { - ls: { - opts: { - boolean: ['a', 'l', 'h'], - }, - handler: discoverFiles, - }, - cd: { - handler: changeDirectory, - }, - touch: { - handler: createFiles, - }, - mkdir: { - opts: { - boolean: ['p'], - }, - handler: createFiles, - }, - cp: { - opts: { - boolean: ['r'], - }, - handler: copyFiles, - }, - rm: { - opts: { - boolean: ['r', 'f', 'v'], - }, - handler: removeFiles, - }, - rmdir: { - opts: { - boolean: ['p', 'v'], - }, - handler: removeFiles, - }, - mv: { - handler: moveFiles, - }, - pwd: { - handler: showCurrentPath, - }, -} - -async function handleCommand(command) { - const name = command.split(' ')[0] - const listener = commandListeners[name] - if (!listener) return {} - const argv = minimist(command.split(' '), listener.opts) - return listener.handler(argv) -} - -function changeDirectory() { - return { - pwd: process.cwd(), - } -} - -async function createFiles(argv) { - const { _ } = argv - const files = _.slice(1) - const results = [] - for (const file of files) { - const filePath = resolvePath(file) - try { - const exists = await fs.stat(filePath) - if (exists) { - results.push({ - path: filePath, - type: exists.isFile() ? 'file' : 'directory', - discovered: true, - }) - } - } catch (err) { - if (err.code !== 'ENOENT') { - throw err - } - } - } - return { - add: results, - } -} - -async function copyFiles(argv) { - const { _ } = argv - const files = _.slice(1) - if (files.length === 0) { - return undefined - } - const dest = files.pop() - const destPath = resolvePath(dest) - return { - discover: await collectFilesInfo(destPath, 0, true), - } -} - -async function removeFiles(argv) { - const files = argv._.slice(1) - const results = [] - for (const file of files) { - const filePath = resolvePath(file) - try { - await fs.access(filePath, fs.constants.F_OK) - } catch { - results.push({ - path: filePath, - type: 'file', - discovered: false, - }) - } - } - return { - remove: results, - } -} - -async function moveFiles(argv) { - const { _ } = argv - const sources = _.slice(1) - if (sources.length === 0) { - return undefined - } - const dest = sources.pop() - const results = { - add: [], - remove: [], - } - // check source - for (const source of sources) { - const sourcePath = resolvePath(source) - try { - await fs.access(sourcePath, fs.constants.F_OK) - } catch { - results.remove.push({ - path: sourcePath, - type: 'file', - discovered: false, - }) - } - } - - // check dest - const destPath = resolvePath(dest) - results.add = await collectFilesInfo(destPath, 0, true) - return results -} - -function showCurrentPath() { - const pwd = process.cwd() - const dirs = pwd.split('/') - let currentPath = '/' - const result = [] - for (const dir of dirs) { - currentPath = path.join(currentPath, dir) - result.push({ - path: currentPath, - type: 'directory', - discovered: true, - }) - } - return { - add: result, - } -} - -module.exports = handleCommand diff --git a/packages/container/package.json b/packages/container/package.json deleted file mode 100644 index 91ed8ec..0000000 --- a/packages/container/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "container", - "version": "1.0.0", - "main": "index.js", - "license": "MIT", - "scripts": { - "build": "", - "watch": "" - }, - "dependencies": { - "minimist": "^1.2.8" - } -} diff --git a/packages/container/utils.js b/packages/container/utils.js deleted file mode 100644 index 378ef68..0000000 --- a/packages/container/utils.js +++ /dev/null @@ -1,10 +0,0 @@ -const os = require('os') -const path = require('path') - -function resolvePath(p) { - return path.resolve(process.cwd(), p.replace('~', os.homedir())) -} - -module.exports = { - resolvePath, -} From 740cbcecb490fef841dfc2d7293eaf13a6a49597 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 16:33:33 +0800 Subject: [PATCH 15/26] refactor: move analytics into services directory --- package.json | 1 + {packages => services}/analytics/.env.dev | 0 {packages => services}/analytics/Dockerfile | 0 {packages => services}/analytics/README.md | 0 {packages => services}/analytics/package.json | 0 {packages => services}/analytics/public/css/style.css | 0 {packages => services}/analytics/public/icon_totem.svg | 0 {packages => services}/analytics/src/config.ts | 0 {packages => services}/analytics/src/controllers/commands.ts | 0 {packages => services}/analytics/src/controllers/index.ts | 0 {packages => services}/analytics/src/controllers/quests.ts | 0 {packages => services}/analytics/src/controllers/sessions.ts | 0 {packages => services}/analytics/src/controllers/users.ts | 0 {packages => services}/analytics/src/index.ts | 2 +- {packages => services}/analytics/src/interface.ts | 0 {packages => services}/analytics/src/models/commands.ts | 2 +- {packages => services}/analytics/src/models/pagination.ts | 0 {packages => services}/analytics/src/models/quests.ts | 2 +- {packages => services}/analytics/src/models/sessions.ts | 2 +- {packages => services}/analytics/src/models/users.ts | 2 +- {packages => services}/analytics/src/router.ts | 0 {packages => services}/analytics/tests/pagination.test.ts | 0 {packages => services}/analytics/tsconfig.json | 0 {packages => services}/analytics/views/commands.handlebars | 0 {packages => services}/analytics/views/home.handlebars | 0 {packages => services}/analytics/views/layouts/main.handlebars | 0 {packages => services}/analytics/views/quests.handlebars | 0 {packages => services}/analytics/views/sessionDetail.handlebars | 0 {packages => services}/analytics/views/sessions.handlebars | 0 {packages => services}/analytics/views/users.handlebars | 0 {packages => services}/analytics/views/usersDetail.handlebars | 0 31 files changed, 6 insertions(+), 5 deletions(-) rename {packages => services}/analytics/.env.dev (100%) rename {packages => services}/analytics/Dockerfile (100%) rename {packages => services}/analytics/README.md (100%) rename {packages => services}/analytics/package.json (100%) rename {packages => services}/analytics/public/css/style.css (100%) rename {packages => services}/analytics/public/icon_totem.svg (100%) rename {packages => services}/analytics/src/config.ts (100%) rename {packages => services}/analytics/src/controllers/commands.ts (100%) rename {packages => services}/analytics/src/controllers/index.ts (100%) rename {packages => services}/analytics/src/controllers/quests.ts (100%) rename {packages => services}/analytics/src/controllers/sessions.ts (100%) rename {packages => services}/analytics/src/controllers/users.ts (100%) rename {packages => services}/analytics/src/index.ts (93%) rename {packages => services}/analytics/src/interface.ts (100%) rename {packages => services}/analytics/src/models/commands.ts (96%) rename {packages => services}/analytics/src/models/pagination.ts (100%) rename {packages => services}/analytics/src/models/quests.ts (95%) rename {packages => services}/analytics/src/models/sessions.ts (97%) rename {packages => services}/analytics/src/models/users.ts (96%) rename {packages => services}/analytics/src/router.ts (100%) rename {packages => services}/analytics/tests/pagination.test.ts (100%) rename {packages => services}/analytics/tsconfig.json (100%) rename {packages => services}/analytics/views/commands.handlebars (100%) rename {packages => services}/analytics/views/home.handlebars (100%) rename {packages => services}/analytics/views/layouts/main.handlebars (100%) rename {packages => services}/analytics/views/quests.handlebars (100%) rename {packages => services}/analytics/views/sessionDetail.handlebars (100%) rename {packages => services}/analytics/views/sessions.handlebars (100%) rename {packages => services}/analytics/views/users.handlebars (100%) rename {packages => services}/analytics/views/usersDetail.handlebars (100%) diff --git a/package.json b/package.json index f7b812a..3bbb48e 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "app", "server", "packages/*", + "services/*", "tests" ], "scripts": { diff --git a/packages/analytics/.env.dev b/services/analytics/.env.dev similarity index 100% rename from packages/analytics/.env.dev rename to services/analytics/.env.dev diff --git a/packages/analytics/Dockerfile b/services/analytics/Dockerfile similarity index 100% rename from packages/analytics/Dockerfile rename to services/analytics/Dockerfile diff --git a/packages/analytics/README.md b/services/analytics/README.md similarity index 100% rename from packages/analytics/README.md rename to services/analytics/README.md diff --git a/packages/analytics/package.json b/services/analytics/package.json similarity index 100% rename from packages/analytics/package.json rename to services/analytics/package.json diff --git a/packages/analytics/public/css/style.css b/services/analytics/public/css/style.css similarity index 100% rename from packages/analytics/public/css/style.css rename to services/analytics/public/css/style.css diff --git a/packages/analytics/public/icon_totem.svg b/services/analytics/public/icon_totem.svg similarity index 100% rename from packages/analytics/public/icon_totem.svg rename to services/analytics/public/icon_totem.svg diff --git a/packages/analytics/src/config.ts b/services/analytics/src/config.ts similarity index 100% rename from packages/analytics/src/config.ts rename to services/analytics/src/config.ts diff --git a/packages/analytics/src/controllers/commands.ts b/services/analytics/src/controllers/commands.ts similarity index 100% rename from packages/analytics/src/controllers/commands.ts rename to services/analytics/src/controllers/commands.ts diff --git a/packages/analytics/src/controllers/index.ts b/services/analytics/src/controllers/index.ts similarity index 100% rename from packages/analytics/src/controllers/index.ts rename to services/analytics/src/controllers/index.ts diff --git a/packages/analytics/src/controllers/quests.ts b/services/analytics/src/controllers/quests.ts similarity index 100% rename from packages/analytics/src/controllers/quests.ts rename to services/analytics/src/controllers/quests.ts diff --git a/packages/analytics/src/controllers/sessions.ts b/services/analytics/src/controllers/sessions.ts similarity index 100% rename from packages/analytics/src/controllers/sessions.ts rename to services/analytics/src/controllers/sessions.ts diff --git a/packages/analytics/src/controllers/users.ts b/services/analytics/src/controllers/users.ts similarity index 100% rename from packages/analytics/src/controllers/users.ts rename to services/analytics/src/controllers/users.ts diff --git a/packages/analytics/src/index.ts b/services/analytics/src/index.ts similarity index 93% rename from packages/analytics/src/index.ts rename to services/analytics/src/index.ts index f718f41..00347c2 100644 --- a/packages/analytics/src/index.ts +++ b/services/analytics/src/index.ts @@ -1,7 +1,7 @@ import express from 'express' import { engine } from 'express-handlebars' import basicAuth from 'express-basic-auth' -import { connectDB } from '../../models' +import { connectDB } from '../../../packages/models' import config from './config' import router from './router' diff --git a/packages/analytics/src/interface.ts b/services/analytics/src/interface.ts similarity index 100% rename from packages/analytics/src/interface.ts rename to services/analytics/src/interface.ts diff --git a/packages/analytics/src/models/commands.ts b/services/analytics/src/models/commands.ts similarity index 96% rename from packages/analytics/src/models/commands.ts rename to services/analytics/src/models/commands.ts index faebf9a..479f2e9 100644 --- a/packages/analytics/src/models/commands.ts +++ b/services/analytics/src/models/commands.ts @@ -1,4 +1,4 @@ -import { Command } from '../../../models' +import { Command } from '../../../../packages/models' import Pagination from './pagination' // eslint-disable-next-line import/prefer-default-export diff --git a/packages/analytics/src/models/pagination.ts b/services/analytics/src/models/pagination.ts similarity index 100% rename from packages/analytics/src/models/pagination.ts rename to services/analytics/src/models/pagination.ts diff --git a/packages/analytics/src/models/quests.ts b/services/analytics/src/models/quests.ts similarity index 95% rename from packages/analytics/src/models/quests.ts rename to services/analytics/src/models/quests.ts index be8dbf8..4a52c45 100644 --- a/packages/analytics/src/models/quests.ts +++ b/services/analytics/src/models/quests.ts @@ -1,4 +1,4 @@ -import { UserProfile } from '../../../models' +import { UserProfile } from '../../../../packages/models' export interface QuestProgress { _id: string diff --git a/packages/analytics/src/models/sessions.ts b/services/analytics/src/models/sessions.ts similarity index 97% rename from packages/analytics/src/models/sessions.ts rename to services/analytics/src/models/sessions.ts index 7107b87..19928d7 100644 --- a/packages/analytics/src/models/sessions.ts +++ b/services/analytics/src/models/sessions.ts @@ -1,4 +1,4 @@ -import { Command, Session } from '../../../models' +import { Command, Session } from '../../../../packages/models' import Pagination from './pagination' import { CommandObject, diff --git a/packages/analytics/src/models/users.ts b/services/analytics/src/models/users.ts similarity index 96% rename from packages/analytics/src/models/users.ts rename to services/analytics/src/models/users.ts index 09444b7..84c2cc7 100644 --- a/packages/analytics/src/models/users.ts +++ b/services/analytics/src/models/users.ts @@ -1,5 +1,5 @@ import { HydratedDocument, Types } from 'mongoose' -import { Session, User, IUser, ISession } from '../../../models' +import { Session, User, IUser, ISession } from '../../../../packages/models' import Pagination from './pagination' import { UserSessionDetail } from '../interface' diff --git a/packages/analytics/src/router.ts b/services/analytics/src/router.ts similarity index 100% rename from packages/analytics/src/router.ts rename to services/analytics/src/router.ts diff --git a/packages/analytics/tests/pagination.test.ts b/services/analytics/tests/pagination.test.ts similarity index 100% rename from packages/analytics/tests/pagination.test.ts rename to services/analytics/tests/pagination.test.ts diff --git a/packages/analytics/tsconfig.json b/services/analytics/tsconfig.json similarity index 100% rename from packages/analytics/tsconfig.json rename to services/analytics/tsconfig.json diff --git a/packages/analytics/views/commands.handlebars b/services/analytics/views/commands.handlebars similarity index 100% rename from packages/analytics/views/commands.handlebars rename to services/analytics/views/commands.handlebars diff --git a/packages/analytics/views/home.handlebars b/services/analytics/views/home.handlebars similarity index 100% rename from packages/analytics/views/home.handlebars rename to services/analytics/views/home.handlebars diff --git a/packages/analytics/views/layouts/main.handlebars b/services/analytics/views/layouts/main.handlebars similarity index 100% rename from packages/analytics/views/layouts/main.handlebars rename to services/analytics/views/layouts/main.handlebars diff --git a/packages/analytics/views/quests.handlebars b/services/analytics/views/quests.handlebars similarity index 100% rename from packages/analytics/views/quests.handlebars rename to services/analytics/views/quests.handlebars diff --git a/packages/analytics/views/sessionDetail.handlebars b/services/analytics/views/sessionDetail.handlebars similarity index 100% rename from packages/analytics/views/sessionDetail.handlebars rename to services/analytics/views/sessionDetail.handlebars diff --git a/packages/analytics/views/sessions.handlebars b/services/analytics/views/sessions.handlebars similarity index 100% rename from packages/analytics/views/sessions.handlebars rename to services/analytics/views/sessions.handlebars diff --git a/packages/analytics/views/users.handlebars b/services/analytics/views/users.handlebars similarity index 100% rename from packages/analytics/views/users.handlebars rename to services/analytics/views/users.handlebars diff --git a/packages/analytics/views/usersDetail.handlebars b/services/analytics/views/usersDetail.handlebars similarity index 100% rename from packages/analytics/views/usersDetail.handlebars rename to services/analytics/views/usersDetail.handlebars From 307af617266ccc7cf12db2cf9bce4df55e11ce6a Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 16:37:32 +0800 Subject: [PATCH 16/26] refactor: remove generating keypair --- server/src/config.ts | 8 ------- server/src/utils/crypto.ts | 39 ------------------------------- server/tests/utils/crypto.test.ts | 9 ------- 3 files changed, 56 deletions(-) delete mode 100644 server/src/utils/crypto.ts delete mode 100644 server/tests/utils/crypto.test.ts diff --git a/server/src/config.ts b/server/src/config.ts index cdcbcc9..bebb46b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -2,7 +2,6 @@ import validator from 'validator' import { config as dotenvConfig } from 'dotenv' import path from 'path' import fs from 'fs' -import { loadOrCreateKeyPair } from './utils/crypto.js' import { get } from './utils/env.js' // Import dotenv and load ../.env @@ -38,11 +37,6 @@ function createConfig() { const backendUrl = get('BACKEND_URL', `http://host.docker.internal:${port}`) const url = new URL(baseUrl) const projectRoot = getProjectRoot() - const keypairPath = get( - 'KEYPAIR_PATH', - path.join(projectRoot, 'config', 'ssh_key') - ) - const keypair = loadOrCreateKeyPair(keypairPath) return { host, @@ -85,8 +79,6 @@ function createConfig() { mountQuest: !isProduction && process.env.MOUNT_QUEST === 'true', mountCLI: !isProduction && process.env.MOUNT_CLI === 'true', hostProjectRoot: get('HOST_PROJECT_ROOT', projectRoot), - keypairPath, - keypair, }, log: { diff --git a/server/src/utils/crypto.ts b/server/src/utils/crypto.ts deleted file mode 100644 index 7f6ed0f..0000000 --- a/server/src/utils/crypto.ts +++ /dev/null @@ -1,39 +0,0 @@ -import fs from 'fs' -import path from 'path' -import ssh2 from 'ssh2' - -const { - utils: { generateKeyPairSync }, -} = ssh2 - -type KeyPair = { - privateKey: string - publicKey: string -} - -export function loadOrCreateKeyPair(privateKeyPath: string): KeyPair { - const publicKeyPath = `${privateKeyPath}.pub` - if (fs.existsSync(privateKeyPath) && fs.existsSync(publicKeyPath)) { - const privateKey = fs.readFileSync(privateKeyPath, 'utf8') - const publicKey = fs.readFileSync(publicKeyPath, 'utf8') - return { privateKey, publicKey } - } - const dir = path.dirname(privateKeyPath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - const keyPair = generateKeyPair() - fs.writeFileSync(privateKeyPath, keyPair.privateKey, { - mode: 0o600, - }) - fs.writeFileSync(publicKeyPath, keyPair.publicKey, { - mode: 0o600, - }) - return keyPair -} - -export function generateKeyPair(): KeyPair { - const { private: privateKey, public: publicKey } = - generateKeyPairSync('ed25519') - return { privateKey, publicKey } -} diff --git a/server/tests/utils/crypto.test.ts b/server/tests/utils/crypto.test.ts deleted file mode 100644 index 4c4b17f..0000000 --- a/server/tests/utils/crypto.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { generateKeyPair } from '../../src/utils/crypto' - -describe('generateKeyPair', () => { - it('should generate a key pair', () => { - const keyPair = generateKeyPair() - expect(keyPair).toHaveProperty('privateKey') - expect(keyPair).toHaveProperty('publicKey') - }) -}) From c2566cf3dd7a819b312a9e09d768e354ee22099e Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 16:50:59 +0800 Subject: [PATCH 17/26] fix: change dev port of traefik to 8000 --- .env.sample | 2 +- docker-compose.yml | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.env.sample b/.env.sample index 16a135b..73df773 100644 --- a/.env.sample +++ b/.env.sample @@ -1,6 +1,6 @@ HOST=localhost PORT=3000 -BASE_URL=http://localhost:5173 +BASE_URL=http://localhost:8000 MONGO_URL=mongodb://localhost:27017/odyssey-test SECRET_KEY=sample-secret DOCKER_NETWORK=linux-odyssey-players-dev diff --git a/docker-compose.yml b/docker-compose.yml index f4d3dfc..79e5d0b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,11 +10,9 @@ services: - '--providers.docker.exposedbydefault=false' - '--providers.file.directory=/etc/traefik/conf' - '--providers.file.watch=true' - - '--entrypoints.web.address=:80' - - '--entrypoints.websecure.address=:443' + - '--entrypoints.web.address=:8000' ports: - - '80:80' - - '443:443' + - '8000:8000' - '8080:8080' extra_hosts: - 'host.docker.internal:host-gateway' From aba492ff43c3b880cd4996d32d1ae34cb04e4829 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 16:51:19 +0800 Subject: [PATCH 18/26] fix: reset terminal on connection and improve error handling --- app/src/utils/terminal.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/utils/terminal.ts b/app/src/utils/terminal.ts index 4a937c9..a32ff1b 100644 --- a/app/src/utils/terminal.ts +++ b/app/src/utils/terminal.ts @@ -37,6 +37,7 @@ class SocketTerminal { }) this.socket.onopen = () => { console.log('Connected to terminal') + this.term.reset() this.term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n') resolve() } @@ -44,7 +45,6 @@ class SocketTerminal { this.socket.onclose = () => { console.log('Disconnected from terminal') this.term.write('\r\n\x1B[1;31mDisconnected from terminal\x1B[0m\r\n') - reject(new Error('Disconnected from terminal')) } this.socket.onerror = (error) => { @@ -52,7 +52,6 @@ class SocketTerminal { this.term.write( `\r\n\x1B[1;31mWebSocket error: ${'message' in error ? error.message : ''}\x1B[0m\r\n` ) - reject(error) } this.socket.onmessage = (event) => { console.log('Received message from terminal') From 0341efdd5067409ae767a2482da5edd8afae74be Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 16:59:42 +0800 Subject: [PATCH 19/26] docs: update README with TLDR section and improve development instructions --- README.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 32114ca..7643e06 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,13 @@ [![Production](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml) [![Staging](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml) +## TLDR + + yarn install + cp .env.sample .env + docker compose up -d + yarn dev + ## Requirements - NodeJS v20+ @@ -41,10 +48,6 @@ The configuration is stored in the `.env` file. You can copy from the sample fil cp .env.sample .env -The backend will use a SSH key pair to login to the player's container. The private key is on the host, and the public key is copied to the player's container. - -The key pair is stored in `./config/ssh_key` and `./config/ssh_key.pub`. If it doesn't exist, the backend will generate a new pair and store them. - ## Docker Setup ### Docker compose files explaination @@ -70,7 +73,6 @@ This will start the db. it will automatically restarts by Docker. Building quest images: docker compose build base - yarn build:quests ### Build Quest Images in Details @@ -108,15 +110,24 @@ The base image is built with docker-compose, so you can use `docker compose buil ### Optional: Mounting Host Directory -To improve the development experience, we can mount the host directory to the container in the pattern: +To update the home content of a quest, simply re-create a new session will do. The home content is mounted and copied on container creation. + +To further improve the development experience, we can mount the host directory to the container in the pattern: /path/to/linux-odyssey/quests/${quest_id}/home:/home/commander - /path/to/linux-odyssey/packages/container:/usr/local/lib/container You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables: MOUNT_QUEST=true +### Optional: Mounting terminal service & cmd-hook + +To make the development of terminal services & cmd-hook smoother, we can enable `MOUNT_CLI` to mount the local binaries into container. + + MOUNT_CLI + +And you can run `air` inside of `services/terminal-service` or `services/cmd-hook` to get auto recompiling. + ## Development Copy `.env.sample` to `.env` and customize it for your needs. @@ -125,8 +136,6 @@ Run dev server (frontend, backend): yarn dev -**IMPORTANT NOTE**: only the app and server have hot-reloading, other packages require manually running `yarn dev` in each package. - To develop analytics dashboard: yarn analytics From 3c2dfcf3228d1c83bd8ed9b14f68ceb0f49ee4a8 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sun, 4 May 2025 17:04:42 +0800 Subject: [PATCH 20/26] refactor: update Dockerfile paths for analytics service --- .github/workflows/build.yml | 2 +- app/Dockerfile | 4 ++-- docker-compose.prod.yml | 2 +- quests/Dockerfile | 6 +++--- server/Dockerfile | 2 +- services/analytics/Dockerfile | 11 ++++++----- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a9bf3e..ef704eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: uses: ./.github/workflows/build-docker-image.yml with: imageName: 'linuxodyssey/analytics' - dockerfilePath: './packages/analytics/Dockerfile' + dockerfilePath: './services/analytics/Dockerfile' target: prod secrets: inherit diff --git a/app/Dockerfile b/app/Dockerfile index c3be3df..3c3e03b 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,5 +1,5 @@ # Base stage -FROM node:20-alpine as build +FROM node:20-alpine AS build WORKDIR /app COPY package.json yarn.lock ./ @@ -14,7 +14,7 @@ COPY app ./app RUN yarn workspace app build # Production stage -FROM nginx:stable-alpine as prod +FROM nginx:stable-alpine AS prod COPY --from=build /app/app/dist /usr/share/nginx/html COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 06099cb..ad4fddd 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -41,7 +41,7 @@ services: image: linuxodyssey/analytics build: context: . - dockerfile: packages/analytics/Dockerfile + dockerfile: services/analytics/Dockerfile target: prod restart: always depends_on: diff --git a/quests/Dockerfile b/quests/Dockerfile index 46c371c..fcbb330 100644 --- a/quests/Dockerfile +++ b/quests/Dockerfile @@ -1,11 +1,11 @@ -FROM golang:1.24-alpine as cmd-hook-builder +FROM golang:1.24-alpine AS cmd-hook-builder WORKDIR /app COPY ./services/cmd-hook/go.mod ./ RUN go mod download COPY ./services/cmd-hook/*.go ./ RUN go build -o cmd-hook -FROM golang:1.24-alpine as terminal-builder +FROM golang:1.24-alpine AS terminal-builder WORKDIR /app COPY ./services/terminal-service/go.mod ./ COPY ./services/terminal-service/go.sum ./ @@ -13,7 +13,7 @@ RUN go mod download COPY ./services/terminal-service/*.go ./services/terminal-service/index.html ./ RUN go build -o terminal-service -FROM debian:bookworm-slim as main +FROM debian:bookworm-slim AS main LABEL maintainer="wancat@wancat.cc" diff --git a/server/Dockerfile b/server/Dockerfile index e6aa1f6..f2b5e93 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -11,7 +11,7 @@ COPY tsconfig.json ./tsconfig.json RUN yarn workspace server build # Production -FROM node:20-alpine as prod +FROM node:20-alpine AS prod WORKDIR /app COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ diff --git a/services/analytics/Dockerfile b/services/analytics/Dockerfile index ba32932..973c62c 100644 --- a/services/analytics/Dockerfile +++ b/services/analytics/Dockerfile @@ -1,26 +1,27 @@ -FROM node:20-alpine as build +FROM node:20-alpine AS build WORKDIR /app COPY ./package.json yarn.lock ./ -COPY packages/analytics/package.json ./packages/analytics/package.json +COPY services/analytics/package.json ./services/analytics/package.json RUN yarn install --frozen-lockfile COPY tsconfig.json ./tsconfig.json COPY packages ./packages +COPY services/analytics ./services/analytics RUN yarn workspace analytics build # Production stage -FROM node:20-alpine as prod +FROM node:20-alpine AS prod WORKDIR /app COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/packages/analytics ./packages/analytics +COPY --from=build /app/services/analytics ./services/analytics ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=3001 EXPOSE 3001 -WORKDIR /app/packages/analytics +WORKDIR /app/services/analytics CMD [ "yarn", "start" ] \ No newline at end of file From 816184950498bdfba1f5c58866b711d5c28482e7 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Fri, 9 May 2025 16:36:21 +0800 Subject: [PATCH 21/26] feat: use traefik to proxy in production --- deployment/nginx.conf | 41 ----------------------------------------- docker-compose.prod.yml | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/deployment/nginx.conf b/deployment/nginx.conf index feb075f..f7af775 100644 --- a/deployment/nginx.conf +++ b/deployment/nginx.conf @@ -7,45 +7,4 @@ server { index index.html; try_files $uri $uri/ /index.html; } - - location /api/ { - proxy_pass http://backend:3000; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - proxy_buffering off; - } - - location /trpc/ { - proxy_pass http://backend:3000; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - proxy_buffering off; - } - - location /api-docs/ { - proxy_pass http://backend:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - location /socket.io/ { - proxy_pass http://backend:3000; - proxy_http_version 1.1; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - proxy_buffering off; - } } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ad4fddd..0d84b90 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,4 +1,20 @@ services: + traefik: + image: traefik:v3.3 + ports: + - 8000:8000 + - 8080:8080 + networks: + - players + - internal + command: + - '--api.insecure=true' + - '--providers.docker=true' + - '--providers.docker.exposedbydefault=false' + - '--entrypoints.web.address=:8000' + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + app: image: linuxodyssey/frontend depends_on: @@ -10,8 +26,12 @@ services: target: prod networks: - internal - ports: - - 8080:80 + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.app.entrypoints=web' + - 'traefik.http.routers.app.rule=PathPrefix(`/`)' + - 'traefik.http.routers.app.service=app' + - 'traefik.http.services.app.loadbalancer.server.port=80' backend: image: linuxodyssey/backend @@ -36,6 +56,21 @@ services: - DOCKER_NETWORK=linux-odyssey-players - BACKEND_URL=http://backend:3000 - HOST_PROJECT_ROOT=${PWD} + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.backend-api.entrypoints=web' + - 'traefik.http.routers.backend-api.rule=PathPrefix(`/api`)' + - 'traefik.http.routers.backend-api.service=backend' + - 'traefik.http.routers.backend-socket.entrypoints=web' + - 'traefik.http.routers.backend-socket.rule=PathPrefix(`/socket.io`)' + - 'traefik.http.routers.backend-socket.service=backend' + - 'traefik.http.routers.backend-trpc.entrypoints=web' + - 'traefik.http.routers.backend-trpc.rule=PathPrefix(`/trpc`)' + - 'traefik.http.routers.backend-trpc.service=backend' + - 'traefik.http.routers.backend-api-docs.entrypoints=web' + - 'traefik.http.routers.backend-api-docs.rule=PathPrefix(`/api-docs`)' + - 'traefik.http.routers.backend-api-docs.service=backend' + - 'traefik.http.services.backend.loadbalancer.server.port=3000' analytics: image: linuxodyssey/analytics From dca6861940d3eab1731ad9d24e706ff76c5d5dbc Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Fri, 9 May 2025 16:36:53 +0800 Subject: [PATCH 22/26] fix: remove writng error user terminal on disconnecting --- app/src/utils/terminal.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/utils/terminal.ts b/app/src/utils/terminal.ts index a32ff1b..ec2ef91 100644 --- a/app/src/utils/terminal.ts +++ b/app/src/utils/terminal.ts @@ -31,6 +31,7 @@ class SocketTerminal { connect(url: string): Promise { return new Promise((resolve, reject) => { console.log('Connecting to terminal', url) + this.term.write('\r\n\x1B[1;32mConnecting to terminal\x1B[0m\r\n') this.socket = new ReconnectingWebSocket(url, [], { connectionTimeout: 5000, maxRetries: 10, @@ -44,14 +45,10 @@ class SocketTerminal { this.socket.onclose = () => { console.log('Disconnected from terminal') - this.term.write('\r\n\x1B[1;31mDisconnected from terminal\x1B[0m\r\n') } this.socket.onerror = (error) => { console.error('WebSocket error:', error) - this.term.write( - `\r\n\x1B[1;31mWebSocket error: ${'message' in error ? error.message : ''}\x1B[0m\r\n` - ) } this.socket.onmessage = (event) => { console.log('Received message from terminal') From d9fc465731bf9cc9cab577b952fc113e737db79d Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Fri, 9 May 2025 16:46:13 +0800 Subject: [PATCH 23/26] feat: remove container on finish --- server/src/models/sessionManager.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/src/models/sessionManager.ts b/server/src/models/sessionManager.ts index 046235d..6b312ed 100644 --- a/server/src/models/sessionManager.ts +++ b/server/src/models/sessionManager.ts @@ -103,6 +103,15 @@ export async function finishSession( progress.completed = true } await userProfile.save() + if (session.containerId) { + deleteContainer(session.containerId) + .catch((err) => logger.error('Failed to delete container', err)) + .finally(() => { + return Session.findByIdAndUpdate(session._id, { + $set: { containerId: null }, + }).catch((err) => logger.error('Failed to update session', err)) + }) + } } export async function isQuestUnlocked( From ab4d06498aa0e2d72f2400e44a4f61f4c11ebf76 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Fri, 27 Feb 2026 16:01:43 +1100 Subject: [PATCH 24/26] chore: update traefik version --- docker-compose.prod.yml | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0d84b90..b0b520e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: traefik: - image: traefik:v3.3 + image: traefik:v3.6.9 ports: - 8000:8000 - 8080:8080 diff --git a/docker-compose.yml b/docker-compose.yml index 79e5d0b..716fc98 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3' services: traefik: - image: traefik:v3.3 + image: traefik:v3.6.9 networks: - players-dev command: From 5b7bf0794c24fca298f7d0781de8f2d7035c8c43 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Fri, 27 Feb 2026 16:02:28 +1100 Subject: [PATCH 25/26] chore: Move open terminal in new tab to terminal component --- .gitignore | 3 ++- app/src/components/game/ControlPalette.vue | 2 +- app/src/components/game/DescriptionPart.vue | 2 +- app/src/components/game/QuestPart.vue | 7 +------ app/src/components/game/StartButton.vue | 2 +- app/src/components/game/TerminalPart.vue | 12 +++++++++++- app/src/components/game/TreeChart.vue | 2 +- app/src/components/header/GameHeaderComponents.vue | 2 +- app/src/store/session.ts | 4 +--- app/src/utils/formbricks.ts | 2 +- app/src/views/GamePage.vue | 2 +- server/src/models/sessionManager.ts | 3 ++- 12 files changed, 24 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 5bc2bc2..0c32b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -144,4 +144,5 @@ dist .pnp.* #screenshots -tests/cypress/screenshots \ No newline at end of file +tests/cypress/screenshots +services/cmd-hook/cmd-hook diff --git a/app/src/components/game/ControlPalette.vue b/app/src/components/game/ControlPalette.vue index 8d82084..f5dd047 100644 --- a/app/src/components/game/ControlPalette.vue +++ b/app/src/components/game/ControlPalette.vue @@ -1,7 +1,7 @@ diff --git a/app/src/components/header/GameHeaderComponents.vue b/app/src/components/header/GameHeaderComponents.vue index 1987895..38a8006 100644 --- a/app/src/components/header/GameHeaderComponents.vue +++ b/app/src/components/header/GameHeaderComponents.vue @@ -5,7 +5,7 @@ import { useToast } from 'vue-toastification' import { logout } from '../../utils/auth' import { trpc } from '../../utils/trpc' import { openBugReport } from '../../utils/formbricks' -import useSession from '../../store/session' +import { useSession } from '../../store/session' import useUserProfile from '../../store/userProfile' const userStore = useUserProfile() diff --git a/app/src/store/session.ts b/app/src/store/session.ts index 9a8e35c..87e3f2d 100644 --- a/app/src/store/session.ts +++ b/app/src/store/session.ts @@ -34,7 +34,7 @@ interface Store { quest: QuestDetailResponse | null } -const useSession = defineStore('session', { +export const useSession = defineStore('session', { state: (): Store => ({ session: null, questId: '', @@ -126,5 +126,3 @@ const useSession = defineStore('session', { export function useTerminal() { return term } - -export default useSession diff --git a/app/src/utils/formbricks.ts b/app/src/utils/formbricks.ts index 761a489..b7f4caa 100644 --- a/app/src/utils/formbricks.ts +++ b/app/src/utils/formbricks.ts @@ -1,5 +1,5 @@ import formbricks from '@formbricks/js/website' -import useSession from '../store/session' +import { useSession } from '../store/session' import useUserProfile from '../store/userProfile' if (typeof window !== 'undefined') { diff --git a/app/src/views/GamePage.vue b/app/src/views/GamePage.vue index ad7cc2b..f281126 100644 --- a/app/src/views/GamePage.vue +++ b/app/src/views/GamePage.vue @@ -1,6 +1,6 @@