mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-06 03:20:13 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1038e40f4 | ||
|
|
c5fb65db51 | ||
|
|
d4c67a7d81 | ||
|
|
0a1973f220 | ||
|
|
776ef1162a | ||
|
|
5b7bf0794c | ||
|
|
ab4d06498a | ||
|
|
d9fc465731 | ||
|
|
dca6861940 | ||
|
|
8161849504 | ||
|
|
3c2dfcf322 | ||
|
|
0341efdd50 | ||
|
|
aba492ff43 | ||
|
|
c2566cf3dd | ||
|
|
307af61726 | ||
|
|
740cbcecb4 | ||
|
|
e43cbe26ce | ||
|
|
95c4fb71ae | ||
|
|
d755822908 | ||
|
|
372378021d | ||
|
|
63e36c1c98 | ||
|
|
2f7845e63e | ||
|
|
156abc044a | ||
|
|
21ff710f83 | ||
|
|
fb863ba030 | ||
|
|
c7e6d31b9c | ||
|
|
9c9456693d | ||
|
|
5e81db2759 | ||
|
|
0b08a9a5d9 | ||
|
|
58b6448c3d |
No files matched your search
@@ -2,3 +2,4 @@
|
|||||||
.git
|
.git
|
||||||
**/dist
|
**/dist
|
||||||
**/tsconfig.tsbuildinfo
|
**/tsconfig.tsbuildinfo
|
||||||
|
data
|
||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
config
|
config
|
||||||
.quest
|
.quest
|
||||||
|
tmp
|
||||||
|
services/terminal-service/terminal-service
|
||||||
|
bin
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -142,3 +145,4 @@ dist
|
|||||||
|
|
||||||
#screenshots
|
#screenshots
|
||||||
tests/cypress/screenshots
|
tests/cypress/screenshots
|
||||||
|
services/cmd-hook/cmd-hook
|
||||||
Vendored
+3
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml)
|
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml)
|
||||||
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml)
|
[](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
@@ -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
@@ -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,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,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,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,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()
|
||||||
|
|||||||
@@ -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,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
@@ -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
|
|
||||||
@@ -21,4 +21,6 @@ export interface Session {
|
|||||||
pwd: string
|
pwd: string
|
||||||
tasks: ITask[]
|
tasks: ITask[]
|
||||||
responses: IResponse[]
|
responses: IResponse[]
|
||||||
|
token: string
|
||||||
|
containerName: string
|
||||||
}
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.css' {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,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'
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"app",
|
"app",
|
||||||
"server",
|
"server",
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
"services/*",
|
||||||
"tests"
|
"tests"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "container",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "index.js",
|
|
||||||
"license": "MIT",
|
|
||||||
"scripts": {
|
|
||||||
"build": "",
|
|
||||||
"watch": ""
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"minimist": "^1.2.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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' })
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -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.
File renamed without changes.
|
Before Width: | Height: | Size: 630 KiB After Width: | Height: | Size: 630 KiB |
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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
-1
@@ -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
|
||||||
File renamed without changes.
@@ -1,4 +1,4 @@
|
|||||||
import { UserProfile } from '../../../models'
|
import { UserProfile } from '../../../../packages/models'
|
||||||
|
|
||||||
export interface QuestProgress {
|
export interface QuestProgress {
|
||||||
_id: string
|
_id: string
|
||||||
+1
-1
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/linux-odyssey/linux-odyssey/services/cmd-hook
|
||||||
|
|
||||||
|
go 1.24.2
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed index.html
|
||||||
|
var indexHTML []byte
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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>
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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."))
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user