Compare commits

...
30 Commits
Author SHA1 Message Date
Justin Lin f1038e40f4 fix: analytics start path 2026-02-27 20:59:21 +11:00
Justin Lin c5fb65db51 feat: add IMAGE_TAG variables in production compose 2026-02-27 20:59:21 +11:00
Justin Lin d4c67a7d81 fix: forward headers to backend to setup cookies properly 2026-02-27 20:43:53 +11:00
Justin Lin 0a1973f220 Feat terminal service (#196) 2026-02-27 16:06:58 +11:00
Justin Lin 776ef1162a chore: change console log to logger 2026-02-27 16:04:27 +11:00
Justin Lin 5b7bf0794c chore: Move open terminal in new tab to terminal component 2026-02-27 16:02:28 +11:00
Justin Lin ab4d06498a chore: update traefik version 2026-02-27 16:01:43 +11:00
Justin Lin d9fc465731 feat: remove container on finish 2025-05-09 16:46:13 +08:00
Justin Lin dca6861940 fix: remove writng error user terminal on disconnecting 2025-05-09 16:36:53 +08:00
Justin Lin 8161849504 feat: use traefik to proxy in production 2025-05-09 16:36:21 +08:00
Justin Lin 3c2dfcf322 refactor: update Dockerfile paths for analytics service 2025-05-05 19:55:41 +08:00
Justin Lin 0341efdd50 docs: update README with TLDR section and improve development instructions 2025-05-05 19:55:41 +08:00
Justin Lin aba492ff43 fix: reset terminal on connection and improve error handling 2025-05-05 19:55:41 +08:00
Justin Lin c2566cf3dd fix: change dev port of traefik to 8000 2025-05-05 19:55:41 +08:00
Justin Lin 307af61726 refactor: remove generating keypair 2025-05-05 19:55:41 +08:00
Justin Lin 740cbcecb4 refactor: move analytics into services directory 2025-05-05 19:55:40 +08:00
Justin Lin e43cbe26ce refactor: remove deprecated container package 2025-05-05 19:55:40 +08:00
Justin Lin 95c4fb71ae 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
2025-05-05 19:55:40 +08:00
Justin Lin d755822908 chore: update go.mod to remove unnecessary indirect dependencies 2025-05-05 19:55:40 +08:00
Justin Lin 372378021d feat: add timout settings for reconnectin websocket 2025-05-05 19:55:40 +08:00
Justin Lin 63e36c1c98 fix: reduce size of quest image by removing unnecessary apt cache 2025-05-05 19:55:40 +08:00
Justin Lin 2f7845e63e fix: write echo start on starting 2025-05-05 19:55:40 +08:00
Justin Lin 156abc044a refactor: clean up unused code 2025-05-05 19:55:40 +08:00
Justin Lin 21ff710f83 fix: cannot backspace in terminal 2025-05-05 19:55:40 +08:00
Justin Lin fb863ba030 fix: make quest image building process cleaner 2025-05-05 19:55:40 +08:00
Justin Lin c7e6d31b9c feat: connect frontend terminal to terminal service
fix: use ReconnectWebSocket to make the connection more stable

fix: typescript error on importing css in vue
2025-05-05 19:55:35 +08:00
Justin Lin 9c9456693d feat: handle dev server proxy by traefik 2025-05-05 19:54:35 +08:00
Justin Lin 5e81db2759 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
2025-05-05 19:53:33 +08:00
Justin Lin 0b08a9a5d9 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
2025-05-05 19:51:06 +08:00
Justin Lin 58b6448c3d feat: init terminal-service
feat: make interactive ws terminal
2025-05-05 19:50:34 +08:00
94 changed files with 1191 additions and 667 deletions

No files matched your search

+2 -1
View File
@@ -1,4 +1,5 @@
**/node_modules **/node_modules
.git .git
**/dist **/dist
**/tsconfig.tsbuildinfo **/tsconfig.tsbuildinfo
data
+1 -1
View File
@@ -1,6 +1,6 @@
HOST=localhost HOST=localhost
PORT=3000 PORT=3000
BASE_URL=http://localhost:5173 BASE_URL=http://localhost:8000
MONGO_URL=mongodb://localhost:27017/odyssey-test MONGO_URL=mongodb://localhost:27017/odyssey-test
SECRET_KEY=sample-secret SECRET_KEY=sample-secret
DOCKER_NETWORK=linux-odyssey-players-dev DOCKER_NETWORK=linux-odyssey-players-dev
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: ./.github/workflows/build-docker-image.yml uses: ./.github/workflows/build-docker-image.yml
with: with:
imageName: 'linuxodyssey/analytics' imageName: 'linuxodyssey/analytics'
dockerfilePath: './packages/analytics/Dockerfile' dockerfilePath: './services/analytics/Dockerfile'
target: prod target: prod
secrets: inherit secrets: inherit
+5 -1
View File
@@ -1,5 +1,8 @@
config config
.quest .quest
tmp
services/terminal-service/terminal-service
bin
# Logs # Logs
logs logs
@@ -141,4 +144,5 @@ dist
.pnp.* .pnp.*
#screenshots #screenshots
tests/cypress/screenshots tests/cypress/screenshots
services/cmd-hook/cmd-hook
+3
View File
@@ -4,5 +4,8 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"yaml.schemas": { "yaml.schemas": {
"packages/game/quest.json": "quests/**/game.yml" "packages/game/quest.json": "quests/**/game.yml"
},
"[go]": {
"editor.defaultFormatter": "golang.go"
} }
} }
+18 -9
View File
@@ -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) [![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) [![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 ## Requirements
- NodeJS v20+ - 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 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 Setup
### Docker compose files explaination ### Docker compose files explaination
@@ -70,7 +73,6 @@ This will start the db. it will automatically restarts by Docker.
Building quest images: Building quest images:
docker compose build base docker compose build base
yarn build:quests
### Build Quest Images in Details ### 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 ### 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/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: 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 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 ## Development
Copy `.env.sample` to `.env` and customize it for your needs. Copy `.env.sample` to `.env` and customize it for your needs.
@@ -125,8 +136,6 @@ Run dev server (frontend, backend):
yarn dev 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: To develop analytics dashboard:
yarn analytics yarn analytics
+2 -2
View File
@@ -1,5 +1,5 @@
# Base stage # Base stage
FROM node:20-alpine as build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
@@ -14,7 +14,7 @@ COPY app ./app
RUN yarn workspace app build RUN yarn workspace app build
# Production stage # 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 --from=build /app/app/dist /usr/share/nginx/html
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
+3 -2
View File
@@ -22,6 +22,7 @@
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"marked": "^10.0.0", "marked": "^10.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"reconnecting-websocket": "^4.4.0",
"socket.io-client": "^4.7.1", "socket.io-client": "^4.7.1",
"validator": "^13.11.0", "validator": "^13.11.0",
"vue": "^3.3.4", "vue": "^3.3.4",
@@ -34,12 +35,12 @@
}, },
"devDependencies": { "devDependencies": {
"@types/validator": "^13.11.9", "@types/validator": "^13.11.9",
"@vitejs/plugin-vue": "^5.2.2",
"@vue/compiler-sfc": "^3.3.13", "@vue/compiler-sfc": "^3.3.13",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"@vitejs/plugin-vue": "^5.2.2",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.3.1", "vite": "^6.3.1",
"vue-tsc": "^2.2.8" "vue-tsc": "^2.2.8"
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import useSession from '../../store/session' import { useSession } from '../../store/session'
import { openBugReport, openQuestSurvey } from '../../utils/formbricks' import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
const router = useRouter() const router = useRouter()
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useSession from '../../store/session' import { useSession } from '../../store/session'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from '../MarkdownText.vue'
const sessionStore = useSession() const sessionStore = useSession()
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from '../MarkdownText.vue'
import useSession from '../../store/session' import { useSession } from '../../store/session'
const sessionStore = useSession() const sessionStore = useSession()
const tasks = computed(() => { const tasks = computed(() => {
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { computed } from 'vue' import { computed } from 'vue'
import useSession from '../../store/session' import { useSession } from '../../store/session'
const toast = useToast() const toast = useToast()
const sessionStore = useSession() const sessionStore = useSession()
+11 -1
View File
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { useTerminal } from '../../store/session' import { useSession, useTerminal } from '../../store/session'
const sessionStore = useSession()
const terminal = ref<HTMLElement | null>(null) const terminal = ref<HTMLElement | null>(null)
const term = useTerminal() const term = useTerminal()
@@ -23,6 +25,14 @@ onMounted(() => {
<div class="flex items-center gap-2 p-1"> <div class="flex items-center gap-2 p-1">
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text" /> <font-awesome-icon :icon="['fas', 'terminal']" class="text-text" />
<button id="Terminal" class="text-text">終端機</button> <button id="Terminal" class="text-text">終端機</button>
<a
class="text-sm font-normal text-gray-200 ml-10"
:href="`/terminal/${sessionStore.session?.containerName}?token=${sessionStore.session?.token}`"
target="_blank"
>
Open Terminal in New Tab
</a>
</div> </div>
<div <div
id="terminal" id="terminal"
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import FileNode from '../FileNode.vue' import FileNode from '../FileNode.vue'
import useSession from '../../store/session' import { useSession } from '../../store/session'
const sessionStore = useSession() const sessionStore = useSession()
</script> </script>
@@ -5,7 +5,7 @@ import { useToast } from 'vue-toastification'
import { logout } from '../../utils/auth' import { logout } from '../../utils/auth'
import { trpc } from '../../utils/trpc' import { trpc } from '../../utils/trpc'
import { openBugReport } from '../../utils/formbricks' import { openBugReport } from '../../utils/formbricks'
import useSession from '../../store/session' import { useSession } from '../../store/session'
import useUserProfile from '../../store/userProfile' import useUserProfile from '../../store/userProfile'
const userStore = useUserProfile() const userStore = useUserProfile()
+12 -10
View File
@@ -34,7 +34,7 @@ interface Store {
quest: QuestDetailResponse | null quest: QuestDetailResponse | null
} }
const useSession = defineStore('session', { export const useSession = defineStore('session', {
state: (): Store => ({ state: (): Store => ({
session: null, session: null,
questId: '', questId: '',
@@ -50,9 +50,13 @@ const useSession = defineStore('session', {
...session, ...session,
graph: new FileGraph(session.graph), graph: new FileGraph(session.graph),
pwd: '/home/commander', pwd: '/home/commander',
containerName: session.containerName || '',
} }
term.reset() term.reset()
await socket.connect(this.session) await socket.connect(this.session)
await term.connect(
`/terminal/${session.containerName}/ws?token=${session.token}`
)
term.focus() term.focus()
}, },
async createSession() { async createSession() {
@@ -60,7 +64,7 @@ const useSession = defineStore('session', {
questId: this.questId, questId: this.questId,
}) })
await this.setSession(session) await this.setSession(session)
socket.emit('terminal', 'echo start\n') term.send('echo start\n')
}, },
async getActiveSession() { async getActiveSession() {
const session = await trpc.session.getActiveSession.query({ const session = await trpc.session.getActiveSession.query({
@@ -94,12 +98,12 @@ const useSession = defineStore('session', {
}, },
setup() { setup() {
if (hasSetup) return if (hasSetup) return
socket.on('terminal', (data: string) => { // socket.on('terminal', (data: string) => {
term.write(data) // term.write(data)
}) // })
term.onData((data: string) => { // term.onData((data: string) => {
socket.emit('terminal', data) // socket.emit('terminal', data)
}) // })
socket.on('graph', (event: FileGraphUpdateEvent) => { socket.on('graph', (event: FileGraphUpdateEvent) => {
if (!this.session) return if (!this.session) return
this.session.graph.handleEvent(event) this.session.graph.handleEvent(event)
@@ -122,5 +126,3 @@ const useSession = defineStore('session', {
export function useTerminal() { export function useTerminal() {
return term return term
} }
export default useSession
+2
View File
@@ -21,4 +21,6 @@ export interface Session {
pwd: string pwd: string
tasks: ITask[] tasks: ITask[]
responses: IResponse[] responses: IResponse[]
token: string
containerName: string
} }
+4
View File
@@ -0,0 +1,4 @@
declare module '*.css' {
const content: string
export default content
}
+1 -1
View File
@@ -1,5 +1,5 @@
import formbricks from '@formbricks/js/website' import formbricks from '@formbricks/js/website'
import useSession from '../store/session' import { useSession } from '../store/session'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
+1 -3
View File
@@ -23,11 +23,9 @@ class SocketWrapper {
this.socket.on('connect', () => { this.socket.on('connect', () => {
console.log('Socket connected to session', session._id) console.log('Socket connected to session', session._id)
})
this.socket.once('terminal', () => {
resolve() resolve()
}) })
this.socket.on('connect_error', reject) this.socket.on('connect_error', reject)
this.bindListeners() this.bindListeners()
}) })
+45 -6
View File
@@ -1,10 +1,12 @@
import { Terminal } from 'xterm' import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit' import { FitAddon } from 'xterm-addon-fit'
import ReconnectingWebSocket from 'reconnecting-websocket'
import 'xterm/css/xterm.css' import 'xterm/css/xterm.css'
class SocketTerminal { class SocketTerminal {
private term: Terminal private term: Terminal
private fitAddon: FitAddon private fitAddon: FitAddon
private socket: ReconnectingWebSocket | null = null
constructor() { constructor() {
this.term = new Terminal({ this.term = new Terminal({
@@ -19,6 +21,44 @@ class SocketTerminal {
}) })
this.fitAddon = new FitAddon() this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon) this.term.loadAddon(this.fitAddon)
this.term.onData((data) => {
if (this.socket) {
this.socket.send(data)
}
})
}
connect(url: string): Promise<void> {
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 { resizeScreen(): void {
@@ -34,12 +74,10 @@ class SocketTerminal {
this.resizeScreen() this.resizeScreen()
} }
write(message: string): void { send(message: string): void {
this.term.write(message) if (this.socket) {
} this.socket.send(message)
}
onData(callback: (data: string) => void): void {
this.term.onData(callback)
} }
focus(): void { focus(): void {
@@ -48,6 +86,7 @@ class SocketTerminal {
reset(): void { reset(): void {
this.term.reset() this.term.reset()
this.socket?.close()
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import useSession from '../store/session' import { useSession } from '../store/session'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
import HeaderPart from '../components/header/HeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue' import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
}, },
}, },
server: { server: {
host: '0.0.0.0',
proxy: { proxy: {
'/trpc': { '/trpc': {
target: process.env.API_TARGET || 'http://localhost:3000', target: process.env.API_TARGET || 'http://localhost:3000',
-41
View File
@@ -7,45 +7,4 @@ server {
index index.html; index index.html;
try_files $uri $uri/ /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;
}
} }
+43 -7
View File
@@ -1,6 +1,23 @@
services: services:
traefik:
image: traefik:v3.6.9
ports:
- 8000:8000
- 8080:8080
networks:
- players
- internal
command:
- '--api.insecure=true'
- '--providers.docker=true'
- '--providers.docker.exposedbydefault=false'
- '--entrypoints.web.address=:8000'
- '--entrypoints.web.forwardedHeaders.insecure=true' # trust all X-Forwarded headers
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
app: app:
image: linuxodyssey/frontend image: linuxodyssey/frontend:${IMAGE_TAG:-latest}
depends_on: depends_on:
- backend - backend
restart: always restart: always
@@ -10,11 +27,15 @@ services:
target: prod target: prod
networks: networks:
- internal - internal
ports: labels:
- 8080:80 - '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: backend:
image: linuxodyssey/backend image: linuxodyssey/backend:${IMAGE_TAG:-latest}
depends_on: depends_on:
- db - db
restart: always restart: always
@@ -36,12 +57,27 @@ services:
- DOCKER_NETWORK=linux-odyssey-players - DOCKER_NETWORK=linux-odyssey-players
- BACKEND_URL=http://backend:3000 - BACKEND_URL=http://backend:3000
- HOST_PROJECT_ROOT=${PWD} - 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: analytics:
image: linuxodyssey/analytics image: linuxodyssey/analytics:${IMAGE_TAG:-latest}
build: build:
context: . context: .
dockerfile: packages/analytics/Dockerfile dockerfile: services/analytics/Dockerfile
target: prod target: prod
restart: always restart: always
depends_on: depends_on:
@@ -67,7 +103,7 @@ services:
- .env.prod - .env.prod
base: base:
image: linuxodyssey/quest-base image: linuxodyssey/quest-base:${IMAGE_TAG:-latest}
build: build:
context: . context: .
dockerfile: ./quests/Dockerfile dockerfile: ./quests/Dockerfile
+27
View File
@@ -1,5 +1,25 @@
version: '3' version: '3'
services: services:
traefik:
image: traefik:v3.6.9
networks:
- players-dev
command:
- '--api.insecure=true'
- '--providers.docker=true'
- '--providers.docker.exposedbydefault=false'
- '--providers.file.directory=/etc/traefik/conf'
- '--providers.file.watch=true'
- '--entrypoints.web.address=:8000'
ports:
- '8000:8000'
- '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: db:
image: mongo:6 image: mongo:6
restart: always restart: always
@@ -19,6 +39,13 @@ services:
dockerfile: ./quests/Dockerfile dockerfile: ./quests/Dockerfile
extra_hosts: extra_hosts:
- 'host.docker.internal:host-gateway' - '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: networks:
internal: internal:
+1
View File
@@ -9,6 +9,7 @@
"app", "app",
"server", "server",
"packages/*", "packages/*",
"services/*",
"tests" "tests"
], ],
"scripts": { "scripts": {
-69
View File
@@ -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)
})
-69
View File
@@ -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,
}
-174
View File
@@ -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
-13
View File
@@ -1,13 +0,0 @@
{
"name": "container",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "",
"watch": ""
},
"dependencies": {
"minimist": "^1.2.8"
}
}
-10
View File
@@ -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,
}
+1 -1
View File
@@ -21,7 +21,7 @@ export const commandSchema = z.object({
output: z.string().optional(), output: z.string().optional(),
error: z.string().optional(), error: z.string().optional(),
pwd: z.string().optional(), pwd: z.string().optional(),
params: commandParamsSchema.optional(), params: commandParamsSchema.optional().nullable(),
}) })
export type ICommand = z.infer<typeof commandSchema> export type ICommand = z.infer<typeof commandSchema>
+2
View File
@@ -28,6 +28,7 @@ export interface ISession {
user: Types.ObjectId user: Types.ObjectId
quest: string quest: string
containerId: string | null containerId: string | null
containerName: string | null
status: 'active' | 'finished' | 'inactive' status: 'active' | 'finished' | 'inactive'
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
@@ -52,6 +53,7 @@ export const Session = model<ISession>(
required: true, required: true,
}, },
containerId: String, containerId: String,
containerName: String,
status: { status: {
type: String, type: String,
enum: ['active', 'finished', 'inactive'], enum: ['active', 'finished', 'inactive'],
+1 -1
View File
@@ -29,7 +29,7 @@ precmd() {
export CMD_ERROR_FILE=/tmp/cmd.error export CMD_ERROR_FILE=/tmp/cmd.error
# Now run your Node.js script with the result as an argument # 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/lib/cmd-hook/cmd-hook; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null
# Reset variables # Reset variables
unset CMD_START_TIME unset CMD_START_TIME
+22 -19
View File
@@ -1,32 +1,35 @@
FROM debian:bookworm-slim 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
WORKDIR /app
COPY ./services/terminal-service/go.mod ./
COPY ./services/terminal-service/go.sum ./
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
LABEL maintainer="wancat@wancat.cc" LABEL maintainer="wancat@wancat.cc"
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
vim \ vim \
zsh \ zsh \
curl \ sudo \
openssh-server && rm -rf /var/lib/apt/lists/* \
&& rm -rf /var/cache/apt/*
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 ./quests/.includes/zshrc /etc/zsh/zshrc 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 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 ./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"] ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
+6 -9
View File
@@ -3,19 +3,16 @@
USERNAME=commander USERNAME=commander
echo "Initializing container for $USERNAME" echo "Initializing container for $USERNAME"
echo "BACKEND_URL: $BACKEND_URL"
if ! id -u ${USERNAME} >/dev/null 2>&1; then if ! id -u ${USERNAME} >/dev/null 2>&1; then
echo "Adding user $USERNAME" echo "Adding user $USERNAME"
adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} && \ adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} && \
find / -name ".gitkeep" -type f -exec rm -f {} + 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 fi
mkdir -p /run/sshd # Preserve environment variables
echo "Starting SSH server" export BACKEND_URL
/usr/sbin/sshd -D -e
echo "Starting terminal service"
su - ${USERNAME} -w BACKEND_URL -c "/usr/local/lib/terminal-service/terminal-service"
+1 -1
View File
@@ -11,7 +11,7 @@ COPY tsconfig.json ./tsconfig.json
RUN yarn workspace server build RUN yarn workspace server build
# Production # Production
FROM node:20-alpine as prod FROM node:20-alpine AS prod
WORKDIR /app WORKDIR /app
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
@@ -7,11 +7,29 @@ import { asyncHandler } from '../../middleware/error'
import { questManager } from '../../models/quest' import { questManager } from '../../models/quest'
import { CLIFileExistenceChecker } from '../../containers/cli' import { CLIFileExistenceChecker } from '../../containers/cli'
import config from '../../config' import config from '../../config'
import { verifyJWT } from '../../utils/auth'
export const newCommand = asyncHandler(async (req: Request, res: Response) => { export const newCommand = asyncHandler(async (req: Request, res: Response) => {
if (!config.isProduction) { if (!config.isProduction) {
console.log('newCommand', JSON.stringify(req.body, null, 2)) 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) const body = commandSchema.safeParse(req.body)
if (!body.success) { if (!body.success) {
res.status(400).json({ message: 'Invalid command', errors: body.error }) 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 command = body.data
const { sessionId } = req.user as any
const session = await Session.findById(sessionId) const session = await Session.findById(sessionId)
if (!session) { if (!session) {
res.status(404).json({ message: 'session not found' }) res.status(404).json({ message: 'session not found' })
+1 -6
View File
@@ -17,12 +17,7 @@ if (!config.isProduction) {
}) })
} }
router.use( router.use('/commands', commands)
'/commands',
passport.authenticate('jwt', { session: false }),
authRequired,
commands
)
router.use('/auth', authRouter) router.use('/auth', authRouter)
router.get('/leaderboard', leaderboardController) router.get('/leaderboard', leaderboardController)
+8 -48
View File
@@ -1,12 +1,9 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import type { Socket } from 'socket.io' import type { Socket } from 'socket.io'
import { Duplex } from 'stream'
import validator from 'validator' import validator from 'validator'
import type { Server as HttpServer } from 'http' import type { Server as HttpServer } from 'http'
import { ISession, Session } from '../../../packages/models' import { Session } from '../../../packages/models'
import { getAndStartContainer, attachContainer } from '../containers/docker.js'
import SessionMiddleware from '../middleware/session.js' import SessionMiddleware from '../middleware/session.js'
import { genJWT } from '../utils/auth.js'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
type SocketCallback = (event: string, ...args: any[]) => void type SocketCallback = (event: string, ...args: any[]) => void
@@ -37,11 +34,11 @@ export function pushToSession(
} }
} }
// eslint-disable-next-line no-unused-vars async function onConnect(socket: Socket) {
async function connectContainer(socket: Socket, next: (err?: Error) => void) {
const user = (socket.request as any).session?.passport?.user const user = (socket.request as any).session?.passport?.user
if (!user) { if (!user) {
next(new Error('User not found.')) socket.emit('error', 'User not found.')
socket.disconnect()
return return
} }
const { sessionId } = socket.handshake.query const { sessionId } = socket.handshake.query
@@ -51,52 +48,18 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
!validator.isMongoId(sessionId) !validator.isMongoId(sessionId)
) { ) {
logger.warn('Invalid Session ID.', user.username, sessionId) logger.warn('Invalid Session ID.', user.username, sessionId)
next(new Error('Invalid Session ID.')) socket.emit('error', 'Invalid Session ID.')
socket.disconnect()
return return
} }
const session = await Session.findById(sessionId) const session = await Session.findById(sessionId)
if (!session) { if (!session) {
next(new Error('Session not found.')) socket.emit('error', 'Session not found.')
socket.disconnect()
return 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.'))
}
}
function onConnect(socket: Socket) {
const { session, stream } = 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[]) => { const socketCallback = (event: string, ...data: any[]) => {
socket.emit(event, ...data) socket.emit(event, ...data)
} }
@@ -104,9 +67,7 @@ function onConnect(socket: Socket) {
listenToSession(session._id.toString(), socketCallback) listenToSession(session._id.toString(), socketCallback)
socket.on('disconnect', () => { socket.on('disconnect', () => {
stream.write('exit\n')
removeFromSession(session._id.toString(), socketCallback) removeFromSession(session._id.toString(), socketCallback)
stream.destroy()
}) })
} }
@@ -114,7 +75,6 @@ export default (server: HttpServer) => {
const io = new Server(server) const io = new Server(server)
io.engine.use(SessionMiddleware) io.engine.use(SessionMiddleware)
io.use(connectContainer)
io.on('connection', onConnect) io.on('connection', onConnect)
io.on('connect_error', (err) => { io.on('connect_error', (err) => {
-8
View File
@@ -2,7 +2,6 @@ import validator from 'validator'
import { config as dotenvConfig } from 'dotenv' import { config as dotenvConfig } from 'dotenv'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import { loadOrCreateKeyPair } from './utils/crypto.js'
import { get } from './utils/env.js' import { get } from './utils/env.js'
// Import dotenv and load ../.env // Import dotenv and load ../.env
@@ -38,11 +37,6 @@ function createConfig() {
const backendUrl = get('BACKEND_URL', `http://host.docker.internal:${port}`) const backendUrl = get('BACKEND_URL', `http://host.docker.internal:${port}`)
const url = new URL(baseUrl) const url = new URL(baseUrl)
const projectRoot = getProjectRoot() const projectRoot = getProjectRoot()
const keypairPath = get(
'KEYPAIR_PATH',
path.join(projectRoot, 'config', 'ssh_key')
)
const keypair = loadOrCreateKeyPair(keypairPath)
return { return {
host, host,
@@ -85,8 +79,6 @@ function createConfig() {
mountQuest: !isProduction && process.env.MOUNT_QUEST === 'true', mountQuest: !isProduction && process.env.MOUNT_QUEST === 'true',
mountCLI: !isProduction && process.env.MOUNT_CLI === 'true', mountCLI: !isProduction && process.env.MOUNT_CLI === 'true',
hostProjectRoot: get('HOST_PROJECT_ROOT', projectRoot), hostProjectRoot: get('HOST_PROJECT_ROOT', projectRoot),
keypairPath,
keypair,
}, },
log: { log: {
+16 -34
View File
@@ -1,9 +1,7 @@
import fs from 'fs/promises' import fs from 'fs/promises'
import { Duplex } from 'stream'
import Docker from 'dockerode' import Docker from 'dockerode'
import config, { getQuestImage } from '../config.js' import config, { getQuestImage } from '../config.js'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
import { connectToSSH } from './ssh.js'
const engine = new Docker() const engine = new Docker()
@@ -22,9 +20,17 @@ const newContainerOptions = (
ExtraHosts: ['host.docker.internal:host-gateway'], ExtraHosts: ['host.docker.internal:host-gateway'],
}, },
ExposedPorts: { 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( export async function createContainer(
@@ -32,7 +38,7 @@ export async function createContainer(
questId: string, questId: string,
imageId: string imageId: string
): Promise<Docker.Container> { ): Promise<Docker.Container> {
let binds: string[] = [] const binds: string[] = []
if (await questHomeExists(questId)) { if (await questHomeExists(questId)) {
logger.info('Mounting quest home', questId) logger.info('Mounting quest home', questId)
binds.push( binds.push(
@@ -41,14 +47,15 @@ export async function createContainer(
} }
if (config.docker.mountQuest && imageId !== 'base') { if (config.docker.mountQuest && imageId !== 'base') {
logger.info('Mounting quest directory', questId) logger.info('Mounting quest directory', questId)
binds = [ binds.push(
`${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`, `${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`
] )
} }
if (config.docker.mountCLI) { if (config.docker.mountCLI) {
logger.info('Mounting CLI', questId) logger.info('Mounting CLI', questId)
binds.push( binds.push(
`${config.docker.hostProjectRoot}/packages/container:/usr/local/lib/container` `${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 }) const option = newContainerOptions(name, imageId, { binds })
@@ -91,31 +98,6 @@ export async function getAndStartContainer(
return container return container
} }
export async function attachContainer(
container: Docker.Container,
{ token }: { token: string }
): Promise<Duplex> {
const containerIp = (await container.inspect()).NetworkSettings.Networks[
config.docker.network
].IPAddress
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) { export async function deleteContainer(id: string) {
const container = engine.getContainer(id) const container = engine.getContainer(id)
if (!container) { if (!container) {
-45
View File
@@ -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<Duplex> {
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,
})
})
}
+23 -6
View File
@@ -1,9 +1,14 @@
import { HydratedDocument } from 'mongoose' import { HydratedDocument } from 'mongoose'
import { Session, UserProfile } from '../../../packages/models' import { Session, UserProfile } from '../../../packages/models'
import type { ISession, IUser } 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 logger from '../utils/logger.js'
import { questManager } from './quest.js' import { questManager } from './quest.js'
import config from '../config'
async function deactivateSessions(userId: string, quest: string) { async function deactivateSessions(userId: string, quest: string) {
const sessions = await Session.find({ const sessions = await Session.find({
@@ -48,18 +53,21 @@ export async function createNewSession(
throw new Error(`UserProfile ${userId} not found`) throw new Error(`UserProfile ${userId} not found`)
} }
const container = await createContainer( const containerName = `quest-${quest.id}-${userProfile.user.username}-${Date.now()}`
`quest-${quest.id}-${userProfile.user.username}-${Date.now()}`,
quest.id, const container = await createContainer(containerName, quest.id, quest.image)
quest.image await getAndStartContainer(container.id)
)
const newSession = new Session({ const newSession = new Session({
user: userId, user: userId,
quest: quest.id, quest: quest.id,
containerId: container.id, containerId: container.id,
containerName,
}) })
logger.info('container', container.id)
logger.info('containerName', `${config.baseUrl}/terminal/${containerName}`)
const progress = userProfile.progress.get(quest.id) const progress = userProfile.progress.get(quest.id)
if (!progress) { if (!progress) {
userProfile.progress.set(quest.id, { userProfile.progress.set(quest.id, {
@@ -96,6 +104,15 @@ export async function finishSession(
progress.completed = true progress.completed = true
} }
await userProfile.save() 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( export async function isQuestUnlocked(
+8 -1
View File
@@ -6,10 +6,11 @@ import { createNewSession, isQuestUnlocked } from '../models/sessionManager.js'
import config from '../config.js' import config from '../config.js'
import { protectedProcedure, router } from '../trpc.js' import { protectedProcedure, router } from '../trpc.js'
import { questManager } from '../models/quest.js' import { questManager } from '../models/quest.js'
import { genJWT } from '../utils/auth'
export type SessionDetail = Awaited<ReturnType<typeof sessionDetail>> export type SessionDetail = Awaited<ReturnType<typeof sessionDetail>>
function sessionDetail(session: ISession) { async function sessionDetail(session: ISession) {
const quest = questManager.get(session.quest) const quest = questManager.get(session.quest)
if (!quest) { if (!quest) {
throw new TRPCError({ throw new TRPCError({
@@ -22,6 +23,10 @@ function sessionDetail(session: ISession) {
quest, quest,
new VoidFileExistenceChecker() new VoidFileExistenceChecker()
) )
const token = await genJWT({
sessionId: session._id.toString(),
})
return { return {
_id: session._id.toString(), _id: session._id.toString(),
user: session.user.toString(), user: session.user.toString(),
@@ -33,6 +38,8 @@ function sessionDetail(session: ISession) {
responses: gameSession.getResponses(), responses: gameSession.getResponses(),
tasks: gameSession.getTasks(), tasks: gameSession.getTasks(),
graph: gameSession.getGraph(), graph: gameSession.getGraph(),
containerName: session.containerName,
token,
} }
} }
-39
View File
@@ -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 }
}
-9
View File
@@ -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')
})
})
File renamed without changes.
@@ -1,26 +1,27 @@
FROM node:20-alpine as build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY ./package.json yarn.lock ./ 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 RUN yarn install --frozen-lockfile
COPY tsconfig.json ./tsconfig.json COPY tsconfig.json ./tsconfig.json
COPY packages ./packages COPY packages ./packages
COPY services/analytics ./services/analytics
RUN yarn workspace analytics build RUN yarn workspace analytics build
# Production stage # Production stage
FROM node:20-alpine as prod FROM node:20-alpine AS prod
WORKDIR /app WORKDIR /app
COPY --from=build /app/node_modules ./node_modules 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 NODE_ENV=production
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=3001 ENV PORT=3001
EXPOSE 3001 EXPOSE 3001
WORKDIR /app/packages/analytics WORKDIR /app/services/analytics
CMD [ "yarn", "start" ] CMD [ "yarn", "start" ]
File renamed without changes.
@@ -5,7 +5,7 @@
"private": true, "private": true,
"license": "GPL", "license": "GPL",
"scripts": { "scripts": {
"start": "node dist/analytics/src/index.js", "start": "node dist/services/analytics/src/index.js",
"build": "tsc", "build": "tsc",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"dev": "tsx watch src/index.ts" "dev": "tsx watch src/index.ts"
File renamed without changes.

Before

Width:  |  Height:  |  Size: 630 KiB

After

Width:  |  Height:  |  Size: 630 KiB

File renamed without changes.
@@ -1,7 +1,7 @@
import express from 'express' import express from 'express'
import { engine } from 'express-handlebars' import { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth' import basicAuth from 'express-basic-auth'
import { connectDB } from '../../models' import { connectDB } from '../../../packages/models'
import config from './config' import config from './config'
import router from './router' import router from './router'
File renamed without changes.
@@ -1,4 +1,4 @@
import { Command } from '../../../models' import { Command } from '../../../../packages/models'
import Pagination from './pagination' import Pagination from './pagination'
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
@@ -1,4 +1,4 @@
import { UserProfile } from '../../../models' import { UserProfile } from '../../../../packages/models'
export interface QuestProgress { export interface QuestProgress {
_id: string _id: string
@@ -1,4 +1,4 @@
import { Command, Session } from '../../../models' import { Command, Session } from '../../../../packages/models'
import Pagination from './pagination' import Pagination from './pagination'
import { import {
CommandObject, CommandObject,
@@ -1,5 +1,5 @@
import { HydratedDocument, Types } from 'mongoose' 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 Pagination from './pagination'
import { UserSessionDetail } from '../interface' import { UserSessionDetail } from '../interface'
File renamed without changes.
File renamed without changes.
+52
View File
@@ -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
+218
View File
@@ -0,0 +1,218 @@
package main
import (
"errors"
"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": handleCreate,
"mkdir": handleCreate,
"cp": handleCopy,
"rm": handleRemove,
"rmdir": handleRemove,
"mv": handleMove,
"pwd": handlePwd,
}
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) {
files := getFilePathList(cmd)
if len(files) == 0 {
files = append(files, ".")
}
results := make([]FileObject, 0)
for _, path := range files {
err := discover(&results, path, 0)
if err != nil {
return CommandParams{}, err
}
}
return CommandParams{
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)
}
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 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 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 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 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 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
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/linux-odyssey/linux-odyssey/services/cmd-hook
go 1.24.2
+86
View File
@@ -0,0 +1,86 @@
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
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, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error marshaling payload: %v\n", err)
os.Exit(1)
}
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)
}
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)
}
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"os"
"path/filepath"
"strings"
)
type FileType string
const (
FileTypeFile FileType = "file"
FileTypeDir FileType = "directory"
)
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)
}
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 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 results
}
+52
View File
@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./bin/terminal-service"
cmd = "go build -o ./bin/terminal-service ."
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
+56
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
package main
import _ "embed"
//go:embed index.html
var indexHTML []byte
+8
View File
@@ -0,0 +1,8 @@
module github.com/linux-odyssey/linux-odyssey/services/terminal-service
go 1.24.2
require (
github.com/creack/pty v1.1.24
github.com/gorilla/websocket v1.5.3
)
+8
View File
@@ -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=
+86
View File
@@ -0,0 +1,86 @@
<!doctype html>
<html>
<head>
<title>WebSocket Terminal</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css"
/>
<style>
body {
margin: 0;
padding: 20px;
background-color: #1e1e1e;
}
#terminal {
width: 100%;
height: calc(100vh - 40px);
}
</style>
</head>
<body>
<div id="terminal"></div>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script>
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'monospace',
theme: {
background: '#1e1e1e',
foreground: '#ffffff',
},
})
const fitAddon = new FitAddon.FitAddon()
term.loadAddon(fitAddon)
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}${window.location.pathname}ws?token=${token}`
)
// Handle WebSocket connection
ws.onopen = () => {
term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n')
}
ws.onclose = () => {
term.write('\r\n\x1B[1;31mDisconnected from terminal\x1B[0m\r\n')
}
ws.onerror = (error) => {
term.write(
'\r\n\x1B[1;31mWebSocket error: ' + error.message + '\x1B[0m\r\n'
)
}
// Handle terminal input
term.onData((data) => {
ws.send(data)
})
// Handle WebSocket messages
ws.onmessage = (event) => {
// Convert the data to a string if it's not already
const data =
typeof event.data === 'string'
? event.data
: new TextDecoder().decode(event.data)
term.write(data)
}
// Handle window resize
window.addEventListener('resize', () => {
fitAddon.fit()
})
</script>
</body>
</html>
+76
View File
@@ -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))
}
+40
View File
@@ -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
}
+95
View File
@@ -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."))
}
+42
View File
@@ -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'
+5
View File
@@ -8179,6 +8179,11 @@ realize-package-specifier@~3.0.1:
dezalgo "^1.0.1" dezalgo "^1.0.1"
npm-package-arg "^4.1.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: regenerate-unicode-properties@^10.1.0:
version "10.1.1" version "10.1.1"
resolved "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz" resolved "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz"