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/.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/.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/.gitignore b/.gitignore index b831f99..0c32b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ config .quest +tmp +services/terminal-service/terminal-service +bin # Logs logs @@ -141,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/.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/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 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/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/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 75a35d8..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: '', @@ -50,9 +50,13 @@ const useSession = defineStore('session', { ...session, graph: new FileGraph(session.graph), pwd: '/home/commander', + containerName: session.containerName || '', } term.reset() await socket.connect(this.session) + await term.connect( + `/terminal/${session.containerName}/ws?token=${session.token}` + ) term.focus() }, async createSession() { @@ -60,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({ @@ -94,12 +98,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) @@ -122,5 +126,3 @@ const useSession = defineStore('session', { export function useTerminal() { return term } - -export default useSession diff --git a/app/src/types.ts b/app/src/types.ts index 72192f7..a057d56 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -21,4 +21,6 @@ export interface Session { pwd: string 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/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/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 b384844..ec2ef91 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,44 @@ 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): 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, + }) + 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() + } + + this.socket.onclose = () => { + console.log('Disconnected from terminal') + } + + this.socket.onerror = (error) => { + console.error('WebSocket error:', 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 { @@ -34,12 +74,10 @@ class SocketTerminal { this.resizeScreen() } - write(message: string): void { - this.term.write(message) - } - - onData(callback: (data: string) => void): void { - this.term.onData(callback) + send(message: string): void { + if (this.socket) { + this.socket.send(message) + } } focus(): void { @@ -48,6 +86,7 @@ class SocketTerminal { reset(): void { this.term.reset() + this.socket?.close() } } 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 @@ + + + + diff --git a/services/terminal-service/main.go b/services/terminal-service/main.go new file mode 100644 index 0000000..9d1ee88 --- /dev/null +++ b/services/terminal-service/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" +) + +var ( + addr = flag.String("addr", ":9090", "http service address") +) + +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) + return + } + defer ws.Close() + + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Create new terminal + term, err := newTerminal(token) + 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 + } + 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() { + flag.Parse() + 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..d024171 --- /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, "TERM=xterm-256color") + 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.")) +} diff --git a/traefik/conf/dev.yml b/traefik/conf/dev.yml new file mode 100644 index 0000000..a73b692 --- /dev/null +++ b/traefik/conf/dev.yml @@ -0,0 +1,42 @@ +http: + routers: + frontend: + rule: 'PathPrefix(`/`)' + service: frontend + entryPoints: + - web + + 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 + + services: + frontend: + loadBalancer: + servers: + - url: 'http://host.docker.internal:5173' + + backend: + loadBalancer: + servers: + - url: 'http://host.docker.internal:3000' 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"