Feat terminal service (#196)

This commit is contained in:
Justin Lin authored and GitHub committed 2026-02-27 16:06:58 +11:00
commit 0a1973f220
94 files changed
+1185 -662

No files matched your search

+2 -1
View File
@@ -1,4 +1,5 @@
**/node_modules
.git
**/dist
**/tsconfig.tsbuildinfo
**/tsconfig.tsbuildinfo
data
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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
tests/cypress/screenshots
services/cmd-hook/cmd-hook
+3
View File
@@ -4,5 +4,8 @@
"editor.formatOnSave": true,
"yaml.schemas": {
"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)
[![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
+2 -2
View File
@@ -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
+3 -2
View File
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import useSession from '../../store/session'
import { useSession } from '../../store/session'
import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
const router = useRouter()
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import useSession from '../../store/session'
import { useSession } from '../../store/session'
import MarkdownText from '../MarkdownText.vue'
const sessionStore = useSession()
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownText from '../MarkdownText.vue'
import useSession from '../../store/session'
import { useSession } from '../../store/session'
const sessionStore = useSession()
const tasks = computed(() => {
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { computed } from 'vue'
import useSession from '../../store/session'
import { useSession } from '../../store/session'
const toast = useToast()
const sessionStore = useSession()
+11 -1
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
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 term = useTerminal()
@@ -23,6 +25,14 @@ onMounted(() => {
<div class="flex items-center gap-2 p-1">
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text" />
<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
id="terminal"
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import FileNode from '../FileNode.vue'
import useSession from '../../store/session'
import { useSession } from '../../store/session'
const sessionStore = useSession()
</script>
@@ -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()
+12 -10
View File
@@ -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
+2
View File
@@ -21,4 +21,6 @@ export interface Session {
pwd: string
tasks: ITask[]
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 useSession from '../store/session'
import { useSession } from '../store/session'
import useUserProfile from '../store/userProfile'
if (typeof window !== 'undefined') {
+1 -3
View File
@@ -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()
})
+45 -6
View File
@@ -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<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 {
@@ -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()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import useSession from '../store/session'
import { useSession } from '../store/session'
import useUserProfile from '../store/userProfile'
import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
},
},
server: {
host: '0.0.0.0',
proxy: {
'/trpc': {
target: process.env.API_TARGET || 'http://localhost:3000',
-41
View File
@@ -7,45 +7,4 @@ server {
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
}
location /trpc/ {
proxy_pass http://backend:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
}
location /api-docs/ {
proxy_pass http://backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /socket.io/ {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
}
}
+38 -3
View File
@@ -1,4 +1,20 @@
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'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
app:
image: linuxodyssey/frontend
depends_on:
@@ -10,8 +26,12 @@ services:
target: prod
networks:
- internal
ports:
- 8080:80
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.app.entrypoints=web'
- 'traefik.http.routers.app.rule=PathPrefix(`/`)'
- 'traefik.http.routers.app.service=app'
- 'traefik.http.services.app.loadbalancer.server.port=80'
backend:
image: linuxodyssey/backend
@@ -36,12 +56,27 @@ services:
- DOCKER_NETWORK=linux-odyssey-players
- BACKEND_URL=http://backend:3000
- HOST_PROJECT_ROOT=${PWD}
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.backend-api.entrypoints=web'
- 'traefik.http.routers.backend-api.rule=PathPrefix(`/api`)'
- 'traefik.http.routers.backend-api.service=backend'
- 'traefik.http.routers.backend-socket.entrypoints=web'
- 'traefik.http.routers.backend-socket.rule=PathPrefix(`/socket.io`)'
- 'traefik.http.routers.backend-socket.service=backend'
- 'traefik.http.routers.backend-trpc.entrypoints=web'
- 'traefik.http.routers.backend-trpc.rule=PathPrefix(`/trpc`)'
- 'traefik.http.routers.backend-trpc.service=backend'
- 'traefik.http.routers.backend-api-docs.entrypoints=web'
- 'traefik.http.routers.backend-api-docs.rule=PathPrefix(`/api-docs`)'
- 'traefik.http.routers.backend-api-docs.service=backend'
- 'traefik.http.services.backend.loadbalancer.server.port=3000'
analytics:
image: linuxodyssey/analytics
build:
context: .
dockerfile: packages/analytics/Dockerfile
dockerfile: services/analytics/Dockerfile
target: prod
restart: always
depends_on:
+27
View File
@@ -1,5 +1,25 @@
version: '3'
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:
image: mongo:6
restart: always
@@ -19,6 +39,13 @@ services:
dockerfile: ./quests/Dockerfile
extra_hosts:
- 'host.docker.internal:host-gateway'
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.base.rule=PathPrefix(`/terminal/base`)'
- 'traefik.http.middlewares.base-stripprefix.stripprefix.prefixes=/terminal/base'
- 'traefik.http.routers.base.middlewares=base-stripprefix'
- 'traefik.http.routers.base.entrypoints=web'
- 'traefik.http.services.base.loadbalancer.server.port=9090'
networks:
internal:
+1
View File
@@ -9,6 +9,7 @@
"app",
"server",
"packages/*",
"services/*",
"tests"
],
"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(),
error: z.string().optional(),
pwd: z.string().optional(),
params: commandParamsSchema.optional(),
params: commandParamsSchema.optional().nullable(),
})
export type ICommand = z.infer<typeof commandSchema>
+2
View File
@@ -28,6 +28,7 @@ export interface ISession {
user: Types.ObjectId
quest: string
containerId: string | null
containerName: string | null
status: 'active' | 'finished' | 'inactive'
createdAt: Date
updatedAt: Date
@@ -52,6 +53,7 @@ export const Session = model<ISession>(
required: true,
},
containerId: String,
containerName: String,
status: {
type: String,
enum: ['active', 'finished', 'inactive'],
+1 -1
View File
@@ -29,7 +29,7 @@ precmd() {
export CMD_ERROR_FILE=/tmp/cmd.error
# Now run your Node.js script with the result as an argument
node /usr/local/lib/container/cli.js; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null
/usr/local/lib/cmd-hook/cmd-hook; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null
# Reset variables
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"
RUN apt-get update && apt-get install -y \
vim \
zsh \
curl \
openssh-server
sudo \
&& 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
RUN rm -f /etc/motd /etc/issue /etc/issue.net /etc/update-motd.d/*
RUN rm -rf /etc/skel && mkdir -p /etc/skel
RUN mkdir -p /etc/ssh/authorized_keys && chmod 755 /etc/ssh/authorized_keys
COPY ./quests/.includes/sshd_config /etc/ssh/sshd_config
COPY ./quests/entrypoint.sh /entrypoint.sh
COPY --from=cmd-hook-builder /app/cmd-hook /usr/local/lib/cmd-hook/cmd-hook
COPY --from=terminal-builder /app/terminal-service /usr/local/lib/terminal-service/terminal-service
EXPOSE 22
EXPOSE 9090
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
+6 -9
View File
@@ -3,19 +3,16 @@
USERNAME=commander
echo "Initializing container for $USERNAME"
echo "BACKEND_URL: $BACKEND_URL"
if ! id -u ${USERNAME} >/dev/null 2>&1; then
echo "Adding user $USERNAME"
adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} && \
find / -name ".gitkeep" -type f -exec rm -f {} +
echo "Adding public key for $USERNAME"
echo "Public key: $PUB_KEY"
PUB_KEY_PATH=/etc/ssh/authorized_keys/${USERNAME}
echo $PUB_KEY > ${PUB_KEY_PATH}
chmod 644 ${PUB_KEY_PATH}
fi
mkdir -p /run/sshd
echo "Starting SSH server"
/usr/sbin/sshd -D -e
# Preserve environment variables
export BACKEND_URL
echo "Starting terminal service"
su - ${USERNAME} -w BACKEND_URL -c "/usr/local/lib/terminal-service/terminal-service"
+1 -1
View File
@@ -11,7 +11,7 @@ COPY tsconfig.json ./tsconfig.json
RUN yarn workspace server build
# Production
FROM node:20-alpine as prod
FROM node:20-alpine AS prod
WORKDIR /app
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
@@ -7,11 +7,29 @@ import { asyncHandler } from '../../middleware/error'
import { questManager } from '../../models/quest'
import { CLIFileExistenceChecker } from '../../containers/cli'
import config from '../../config'
import { verifyJWT } from '../../utils/auth'
export const newCommand = asyncHandler(async (req: Request, res: Response) => {
if (!config.isProduction) {
console.log('newCommand', JSON.stringify(req.body, null, 2))
}
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
res.status(401).json({ message: 'Unauthorized' })
return
}
let sessionId: string
try {
const decoded = await verifyJWT(token)
sessionId = decoded.sessionId
if (!sessionId) {
res.status(401).json({ message: 'Unauthorized' })
return
}
} catch (error) {
res.status(401).json({ message: 'Unauthorized' })
return
}
const body = commandSchema.safeParse(req.body)
if (!body.success) {
res.status(400).json({ message: 'Invalid command', errors: body.error })
@@ -19,7 +37,6 @@ export const newCommand = asyncHandler(async (req: Request, res: Response) => {
}
const command = body.data
const { sessionId } = req.user as any
const session = await Session.findById(sessionId)
if (!session) {
res.status(404).json({ message: 'session not found' })
+1 -6
View File
@@ -17,12 +17,7 @@ if (!config.isProduction) {
})
}
router.use(
'/commands',
passport.authenticate('jwt', { session: false }),
authRequired,
commands
)
router.use('/commands', commands)
router.use('/auth', authRouter)
router.get('/leaderboard', leaderboardController)
+8 -48
View File
@@ -1,12 +1,9 @@
import { Server } from 'socket.io'
import type { Socket } from 'socket.io'
import { Duplex } from 'stream'
import validator from 'validator'
import type { Server as HttpServer } from 'http'
import { ISession, Session } from '../../../packages/models'
import { getAndStartContainer, attachContainer } from '../containers/docker.js'
import { Session } from '../../../packages/models'
import SessionMiddleware from '../middleware/session.js'
import { genJWT } from '../utils/auth.js'
import logger from '../utils/logger.js'
type SocketCallback = (event: string, ...args: any[]) => void
@@ -37,11 +34,11 @@ export function pushToSession(
}
}
// eslint-disable-next-line no-unused-vars
async function connectContainer(socket: Socket, next: (err?: Error) => void) {
async function onConnect(socket: Socket) {
const user = (socket.request as any).session?.passport?.user
if (!user) {
next(new Error('User not found.'))
socket.emit('error', 'User not found.')
socket.disconnect()
return
}
const { sessionId } = socket.handshake.query
@@ -51,52 +48,18 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
!validator.isMongoId(sessionId)
) {
logger.warn('Invalid Session ID.', user.username, sessionId)
next(new Error('Invalid Session ID.'))
socket.emit('error', 'Invalid Session ID.')
socket.disconnect()
return
}
const session = await Session.findById(sessionId)
if (!session) {
next(new Error('Session not found.'))
socket.emit('error', 'Session not found.')
socket.disconnect()
return
}
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[]) => {
socket.emit(event, ...data)
}
@@ -104,9 +67,7 @@ function onConnect(socket: Socket) {
listenToSession(session._id.toString(), socketCallback)
socket.on('disconnect', () => {
stream.write('exit\n')
removeFromSession(session._id.toString(), socketCallback)
stream.destroy()
})
}
@@ -114,7 +75,6 @@ export default (server: HttpServer) => {
const io = new Server(server)
io.engine.use(SessionMiddleware)
io.use(connectContainer)
io.on('connection', onConnect)
io.on('connect_error', (err) => {
-8
View File
@@ -2,7 +2,6 @@ import validator from 'validator'
import { config as dotenvConfig } from 'dotenv'
import path from 'path'
import fs from 'fs'
import { loadOrCreateKeyPair } from './utils/crypto.js'
import { get } from './utils/env.js'
// Import dotenv and load ../.env
@@ -38,11 +37,6 @@ function createConfig() {
const backendUrl = get('BACKEND_URL', `http://host.docker.internal:${port}`)
const url = new URL(baseUrl)
const projectRoot = getProjectRoot()
const keypairPath = get(
'KEYPAIR_PATH',
path.join(projectRoot, 'config', 'ssh_key')
)
const keypair = loadOrCreateKeyPair(keypairPath)
return {
host,
@@ -85,8 +79,6 @@ function createConfig() {
mountQuest: !isProduction && process.env.MOUNT_QUEST === 'true',
mountCLI: !isProduction && process.env.MOUNT_CLI === 'true',
hostProjectRoot: get('HOST_PROJECT_ROOT', projectRoot),
keypairPath,
keypair,
},
log: {
+16 -34
View File
@@ -1,9 +1,7 @@
import fs from 'fs/promises'
import { Duplex } from 'stream'
import Docker from 'dockerode'
import config, { getQuestImage } from '../config.js'
import logger from '../utils/logger.js'
import { connectToSSH } from './ssh.js'
const engine = new Docker()
@@ -22,9 +20,17 @@ const newContainerOptions = (
ExtraHosts: ['host.docker.internal:host-gateway'],
},
ExposedPorts: {
'22/tcp': {},
'9090/tcp': {},
},
Env: [`BACKEND_URL=${config.backendUrl}`],
Labels: {
'traefik.enable': 'true',
[`traefik.http.routers.terminal-${name}.rule`]: `PathPrefix(\`/terminal/${name}\`)`,
[`traefik.http.middlewares.terminal-${name}-stripprefix.stripprefix.prefixes`]: `/terminal/${name}`,
[`traefik.http.routers.terminal-${name}.middlewares`]: `terminal-${name}-stripprefix`,
[`traefik.http.services.terminal-${name}.loadbalancer.server.port`]: '9090',
[`traefik.http.routers.terminal-${name}.entrypoints`]: 'web',
},
Env: [`PUB_KEY=${config.docker.keypair.publicKey}`],
})
export async function createContainer(
@@ -32,7 +38,7 @@ export async function createContainer(
questId: string,
imageId: string
): Promise<Docker.Container> {
let binds: string[] = []
const binds: string[] = []
if (await questHomeExists(questId)) {
logger.info('Mounting quest home', questId)
binds.push(
@@ -41,14 +47,15 @@ export async function createContainer(
}
if (config.docker.mountQuest && imageId !== 'base') {
logger.info('Mounting quest directory', questId)
binds = [
`${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`,
]
binds.push(
`${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`
)
}
if (config.docker.mountCLI) {
logger.info('Mounting CLI', questId)
binds.push(
`${config.docker.hostProjectRoot}/packages/container:/usr/local/lib/container`
`${config.docker.hostProjectRoot}/services/cmd-hook/bin:/usr/local/lib/cmd-hook:ro`,
`${config.docker.hostProjectRoot}/services/terminal-service/bin:/usr/local/lib/terminal-service:ro`
)
}
const option = newContainerOptions(name, imageId, { binds })
@@ -91,31 +98,6 @@ export async function getAndStartContainer(
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) {
const container = engine.getContainer(id)
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 { Session, UserProfile } from '../../../packages/models'
import type { ISession, IUser } from '../../../packages/models'
import { createContainer, deleteContainer } from '../containers/docker.js'
import {
createContainer,
deleteContainer,
getAndStartContainer,
} from '../containers/docker.js'
import logger from '../utils/logger.js'
import { questManager } from './quest.js'
import config from '../config'
async function deactivateSessions(userId: string, quest: string) {
const sessions = await Session.find({
@@ -48,18 +53,21 @@ export async function createNewSession(
throw new Error(`UserProfile ${userId} not found`)
}
const container = await createContainer(
`quest-${quest.id}-${userProfile.user.username}-${Date.now()}`,
quest.id,
quest.image
)
const containerName = `quest-${quest.id}-${userProfile.user.username}-${Date.now()}`
const container = await createContainer(containerName, quest.id, quest.image)
await getAndStartContainer(container.id)
const newSession = new Session({
user: userId,
quest: quest.id,
containerId: container.id,
containerName,
})
logger.info('container', container.id)
logger.info('containerName', `${config.baseUrl}/terminal/${containerName}`)
const progress = userProfile.progress.get(quest.id)
if (!progress) {
userProfile.progress.set(quest.id, {
@@ -96,6 +104,15 @@ export async function finishSession(
progress.completed = true
}
await userProfile.save()
if (session.containerId) {
deleteContainer(session.containerId)
.catch((err) => logger.error('Failed to delete container', err))
.finally(() => {
return Session.findByIdAndUpdate(session._id, {
$set: { containerId: null },
}).catch((err) => logger.error('Failed to update session', err))
})
}
}
export async function isQuestUnlocked(
+8 -1
View File
@@ -6,10 +6,11 @@ import { createNewSession, isQuestUnlocked } from '../models/sessionManager.js'
import config from '../config.js'
import { protectedProcedure, router } from '../trpc.js'
import { questManager } from '../models/quest.js'
import { genJWT } from '../utils/auth'
export type SessionDetail = Awaited<ReturnType<typeof sessionDetail>>
function sessionDetail(session: ISession) {
async function sessionDetail(session: ISession) {
const quest = questManager.get(session.quest)
if (!quest) {
throw new TRPCError({
@@ -22,6 +23,10 @@ function sessionDetail(session: ISession) {
quest,
new VoidFileExistenceChecker()
)
const token = await genJWT({
sessionId: session._id.toString(),
})
return {
_id: session._id.toString(),
user: session.user.toString(),
@@ -33,6 +38,8 @@ function sessionDetail(session: ISession) {
responses: gameSession.getResponses(),
tasks: gameSession.getTasks(),
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
COPY ./package.json yarn.lock ./
COPY packages/analytics/package.json ./packages/analytics/package.json
COPY services/analytics/package.json ./services/analytics/package.json
RUN yarn install --frozen-lockfile
COPY tsconfig.json ./tsconfig.json
COPY packages ./packages
COPY services/analytics ./services/analytics
RUN yarn workspace analytics build
# Production stage
FROM node:20-alpine as prod
FROM node:20-alpine AS prod
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/analytics ./packages/analytics
COPY --from=build /app/services/analytics ./services/analytics
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3001
EXPOSE 3001
WORKDIR /app/packages/analytics
WORKDIR /app/services/analytics
CMD [ "yarn", "start" ]
File renamed without changes.
File renamed without changes.
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 { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth'
import { connectDB } from '../../models'
import { connectDB } from '../../../packages/models'
import config from './config'
import router from './router'
File renamed without changes.
@@ -1,4 +1,4 @@
import { Command } from '../../../models'
import { Command } from '../../../../packages/models'
import Pagination from './pagination'
// eslint-disable-next-line import/prefer-default-export
@@ -1,4 +1,4 @@
import { UserProfile } from '../../../models'
import { UserProfile } from '../../../../packages/models'
export interface QuestProgress {
_id: string
@@ -1,4 +1,4 @@
import { Command, Session } from '../../../models'
import { Command, Session } from '../../../../packages/models'
import Pagination from './pagination'
import {
CommandObject,
@@ -1,5 +1,5 @@
import { HydratedDocument, Types } from 'mongoose'
import { Session, User, IUser, ISession } from '../../../models'
import { Session, User, IUser, ISession } from '../../../../packages/models'
import Pagination from './pagination'
import { UserSessionDetail } from '../interface'
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"
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"