Merge pull request #5 from lancatlin/server-commands-api

Server commands api
This commit is contained in:
Lin, Hung-Shin authored and GitHub committed 2023-07-31 17:22:36 +08:00
commit b729e30e0b
28 files changed
+529 -79

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules
.git
+2 -1
View File
@@ -1,3 +1,4 @@
vite.config.js
.gitignore
data
data
mongo-init.js
+3
View File
@@ -6,6 +6,9 @@ yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
.bash_history
.viminfo
*.swp
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+21
View File
@@ -0,0 +1,21 @@
FROM node
WORKDIR /app
COPY package.json yarn.lock ./
COPY server/package.json ./server/
RUN yarn install --frozen-lockfile
WORKDIR /app/server
ARG DOCKER_GID=140
RUN groupadd -g $DOCKER_GID docker && usermod -aG docker node
USER node
ENV NODE_ENV=development
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD [ "yarn", "dev" ]
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
containers=$(docker ps -a | grep 'quest-' | awk '{print $1}')
echo $containers
docker rm -f $containers
+42
View File
@@ -0,0 +1,42 @@
version: '3'
services:
app:
image: lancatlin/linux-odyssey
restart: on-failure
build:
context: .
dockerfile: Dockerfile
networks:
- internal
- players
ports:
- '3000:3000'
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- MONGO_URL=mongodb://user:user-password@db:27017/odyssey-test
- DOCKER_NETWORK=linux-odyssey-players
db:
image: mongo
restart: always
networks:
- internal
ports:
- '27017:27017'
volumes:
- mongo:/data/db
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
environment:
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=admin-password
- MONGO_USER=user
- MONGO_PASS=user-password
- MONGO_DB=odyssey-test
networks:
internal:
players:
name: linux-odyssey-players
volumes:
mongo:
+16 -4
View File
@@ -1,20 +1,32 @@
version: '3'
services:
app:
image: lancatlin/linux-odyssey-server
build: .
restart: on-failure
build:
context: .
dockerfile: Dockerfile.dev
networks:
- internal
- players
ports:
- '3000:3000'
links:
- db
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./server:/app/server
environment:
- MONGO_URL=mongodb://db:27017/odyssey-test
- DOCKER_NETWORK=linux-odyssey-players
db:
image: mongo
restart: always
networks:
- internal
ports:
- '27017:27017'
volumes:
- ./data:/data/db
networks:
internal:
players:
name: linux-odyssey-players
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
docker compose logs app -f --no-log-prefix
+10
View File
@@ -0,0 +1,10 @@
const user = process.env.MONGO_USER
const password = process.env.MONGO_PASS
const dbName = process.env.MONGO_DB
db = db.getSiblingDB(dbName)
db.createUser({
user: user,
pwd: password,
roles: [{ role: 'readWrite', db: dbName }],
})
+2
View File
@@ -10,8 +10,10 @@
"test": "yarn node --experimental-vm-modules $(yarn bin jest)"
},
"dependencies": {
"commander": "^11.0.0",
"dockerode": "^3.3.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.1",
"marked": "^5.1.1",
"mongoose": "^7.4.0",
"socket.io": "^4.7.1",
+16
View File
@@ -0,0 +1,16 @@
FROM ubuntu:latest
RUN apt-get update && apt-get install -y \
curl \
vim \
jq
RUN adduser --disabled-password --gecos '' rudeus
USER rudeus
WORKDIR /home/rudeus
COPY ./home /home/rudeus/
CMD [ "/bin/bash" ]
+30
View File
@@ -0,0 +1,30 @@
# This function will be called before each command is executed
preexec() {
[ -n "$COMP_LINE" ] && return # do nothing if completing
[ "$BASH_COMMAND" = "$PROMPT_COMMAND" ] && return # do nothing if completing
local command="$BASH_COMMAND"
local pwd="$PWD"
if [[ $command =~ ^(vim|top|less|more|nano) ]]; then
local payload=$(printf '{"token": "%s", "pwd": "%s", "command": "%s"}' "$TOKEN" "$pwd" "$command")
curl -X POST $API_ENDPOINT/api/v1/commands -H 'accept: */*' -H 'Content-Type: application/json' -d "$payload"
return
fi
# Execute the command, capture output and exit code
local output=$( { eval $command 2>&1; echo $? > /tmp/exit_code; })
local exit_code=$(cat /tmp/exit_code)
local output_esc=$(echo $output | jq -Rs .)
# Store command in variable
local payload=$(printf '{"token": "%s", "pwd": "%s", "command": "%s", "exit_code": "%s", "output": %s}' "$TOKEN" "$pwd" "$command" "$exit_code" "$output_esc")
# Send command to the API
curl -X POST $API_ENDPOINT/api/v1/commands -H 'accept: */*' -H 'Content-Type: application/json' -d "$payload" || echo $payload
BASH_COMMAND=""
}
# Install the trap
trap 'preexec' DEBUG
@@ -0,0 +1,37 @@
/* eslint-disable import/prefer-default-export */
import Command from '../../models/command.js'
import Session from '../../models/session.js'
import { verifySessionJWT } from '../../utils/auth.js'
export async function newCommand(req, res) {
console.log('new command:', req.body)
const { token, command, pwd, exit_code, output } = req.body
let sessionId
try {
const decoded = await verifySessionJWT(token)
sessionId = decoded.session_id
} catch (err) {
res.status(401).json({ message: 'token not valid' })
return
}
const session = await Session.findById(sessionId)
if (!session) {
res.status(401).json({ message: 'session not found' })
return
}
const c = new Command({
session,
command,
pwd,
exit_code,
output,
})
await c.save()
res.status(201).end()
}
@@ -22,13 +22,13 @@ export async function createSession(req, res) {
return
}
const newSession = new Session({
user: req.user,
quest,
containerId: `quest-${quest.id}-${req.user.username}-${Date.now()}`,
})
try {
const newSession = new Session({
user: req.user,
quest,
containerId: `quest-${quest.id}-${req.user.username}-${Date.now()}`,
})
const session = await newSession.save()
res.status(201).json(session)
} catch (err) {
+8
View File
@@ -0,0 +1,8 @@
import { Router } from 'express'
import * as commandController from '../controllers/commandController.js'
const router = Router()
router.post('/', commandController.newCommand)
export default router
+2
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import sessions from './sessions.js'
import quests from './quests.js'
import commands from './commands.js'
import { authMiddleware } from '../../middleware/auth.js'
const router = Router()
@@ -11,5 +12,6 @@ router.get('/', (req, res) => {
router.use('/sessions', authMiddleware, sessions)
router.use('/quests', quests)
router.use('/commands', commands)
export default router
+23 -10
View File
@@ -1,7 +1,7 @@
import { Server } from 'socket.io'
import { getOrCreateContainer, attachContainer } from '../utils/docker.js'
import Session from '../models/session.js'
import { defaultUser } from '../utils/auth.js'
import { defaultUser, genSessionJWT } from '../utils/auth.js'
export default (server) => {
const io = new Server(server)
@@ -21,26 +21,39 @@ export default (server) => {
io.on('connection', async (socket) => {
console.log('Connected to the client.')
const session = await Session.findOne({
_id: socket.handshake.query.session_id,
user: socket.user,
})
if (!session) {
socket.send('Session not found.')
const sessionId = socket.handshake.query.session_id
if (!sessionId) {
socket.send('Session ID not found.')
socket.disconnect()
return
}
let session
try {
session = await Session.findOne({
_id: sessionId,
user: socket.user,
})
if (!session) {
socket.send('Session not found.')
socket.disconnect()
return
}
} catch (err) {
socket.send(err.message)
socket.disconnect()
return
}
const token = await genSessionJWT(session)
const container = await getOrCreateContainer(session.containerId)
const stream = await attachContainer(container)
const stream = await attachContainer(container, { token })
stream.on('data', (chunk) => {
socket.send(chunk)
})
socket.on('message', function incoming(message) {
console.log(`From client: ${message}`)
stream.write(message)
})
+105 -27
View File
@@ -1,37 +1,115 @@
/* eslint-disable no-underscore-dangle */
import { stdin, stdout, exit } from 'process'
import { io } from 'socket.io-client'
import { program } from 'commander'
import { get } from './utils/env.js'
stdin.setRawMode(true)
stdin.resume()
stdin.setEncoding('utf8')
const sessionId = process.argv[2]
console.log(`Session ID: ${sessionId}`)
async function createdSession() {
console.log('Creating a new session...')
const res = await fetch(`${program.opts().host}/api/v1/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
quest_id: 'helloworld',
}),
})
const data = await res.json()
console.log('Created Session ID:', data._id)
return data
}
const socket = io('ws://localhost:3000', {
query: {
session_id: sessionId,
},
})
async function getSessionList() {
const res = await fetch(`${program.opts().host}/api/v1/sessions`)
const data = await res.json()
return data
}
socket.on('open', function open() {
console.log('Connected to the server.')
})
socket.on('message', function incoming(data) {
stdout.write(data)
})
socket.on('close', function close() {
console.log('Disconnected from the server.')
exit()
})
stdin.on('data', (key) => {
if (key === '\u0003') {
socket.close()
exit()
} else {
socket.send(key)
async function lastSession() {
const sessions = await getSessionList()
if (sessions.length === 0) {
console.log(
'No session found. Please use --create to create a new session.'
)
exit(1)
}
})
const session = sessions[sessions.length - 1]
console.log(
`Last Session ID: ${session._id}, Quest: ${session.quest}, Created At: ${session.createdAt}`
)
return session
}
async function connect(sessionId) {
console.log(`Session ID: ${sessionId}`)
console.log('Use Ctrl + D to exit.')
if (!sessionId) exit()
const socket = io(program.opts().host, {
query: {
session_id: sessionId,
},
})
socket.on('open', function open() {
console.log('Connected to the server.')
})
socket.on('message', function incoming(data) {
stdout.write(data)
})
socket.on('close', function close() {
console.log('Disconnected from the server.')
exit()
})
stdin.on('data', (key) => {
if (key === '\u0004') {
socket.close()
exit()
} else {
socket.send(key)
}
})
}
async function main() {
console.log(program.opts().session)
// main()
const sessionId =
program.opts().session ||
(program.opts().create ? (await createdSession())._id : null) ||
(await lastSession())._id
await connect(sessionId)
}
program
.option('-s, --session <string>', 'Session ID')
.option('-c, --create', 'Create a new session')
.option(
'-h, --host <string>',
'Server host',
get('API_ENDPOINT', 'http://localhost:3000')
)
.action(main)
program
.command('list')
.description('List all sessions')
.action(async () => {
const sessions = await getSessionList()
sessions.forEach(({ _id, quest, createdAt }) => {
console.log(`${_id} ${quest} ${createdAt}`)
})
exit()
})
program.parse()
+4 -7
View File
@@ -1,13 +1,10 @@
function get(key, defaultValue) {
const value = process.env[key]
if (value != null) {
return value
}
return defaultValue
}
import { get } from './utils/env.js'
export default {
host: get('HOST', 'localhost'),
port: get('PORT', 3000),
db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'),
jwtSecret: get('JWT_SECRET', 'secret'),
dockerNetwork: get('DOCKER_NETWORK', 'linux-odyssey-players'),
isProduction: process.env.NODE_ENV === 'production',
}
+2 -1
View File
@@ -6,6 +6,7 @@ export default async function connectDB() {
await mongoose.connect(config.db)
console.log('Connected to MongoDB')
} catch (err) {
console.log(err)
console.error(err)
process.exit(1)
}
}
+3
View File
@@ -9,6 +9,7 @@ import connectDB from './db.js'
import apiRouter from './api/routes/index.js'
import loadAndUpdateQuests from './utils/quest.js'
import config from './config.js'
import errorHandler from './middleware/error.js'
async function main() {
const app = express()
@@ -28,6 +29,8 @@ async function main() {
app.use('/api/v1', apiRouter)
app.use(errorHandler)
server.listen(config.port, config.host, () => {
console.log(`Server listening at http://${config.host}:${config.port}`)
})
+5
View File
@@ -0,0 +1,5 @@
// eslint-disable-next-line no-unused-vars
export default function errorHandler(err, req, res, next) {
console.error(err.stack) // Log error stack trace to the console
res.status(500).send({ message: err.message }) // Send error response
}
+33
View File
@@ -0,0 +1,33 @@
import { model, Schema } from 'mongoose'
const Command = model(
'Command',
new Schema(
{
session: {
type: Schema.ObjectId,
ref: 'Session',
required: true,
},
pwd: {
type: String,
required: true,
},
command: {
type: String,
required: true,
},
exit_code: {
type: Number,
required: false,
},
output: {
type: String,
required: false,
},
},
{ timestamps: true }
)
)
export default Command
+31 -1
View File
@@ -1,4 +1,5 @@
/* eslint-disable import/prefer-default-export */
import jwt from 'jsonwebtoken'
import config from '../config.js'
import User from '../models/user.js'
export async function defaultUser() {
@@ -12,3 +13,32 @@ export async function defaultUser() {
}
return user
}
export function genSessionJWT(session) {
return new Promise((resolve, reject) => {
jwt.sign(
{ session_id: session.id },
config.jwtSecret,
{ expiresIn: '1h' },
(err, token) => {
if (err) {
reject(err)
} else {
resolve(token)
}
}
)
})
}
export function verifySessionJWT(token) {
return new Promise((resolve, reject) => {
jwt.verify(token, config.jwtSecret, (err, decoded) => {
if (err) {
reject(err)
} else {
resolve(decoded)
}
})
})
}
+25 -20
View File
@@ -1,9 +1,12 @@
import Docker from 'dockerode'
import config from '../config.js'
const engine = new Docker()
const questPath = '/home/zeko/src/linux-odyssey/server/quests'
const containerOptions = {
Image: 'ubuntu',
Image: 'lancatlin/linux-odyssey:helloworld',
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
@@ -11,42 +14,43 @@ const containerOptions = {
Cmd: ['/bin/bash'],
OpenStdin: true,
StdinOnce: false,
HostConfig: {
Binds: config.isProduction
? []
: [`${questPath}/helloworld/home:/home/rudeus`],
},
}
export async function createContainer() {
const container = await engine.createContainer({
...containerOptions,
})
await container.start()
return container
}
export async function getContainer(id) {
const container = await engine.getContainer(id)
await container.start()
return container
}
const network = engine.getNetwork(config.dockerNetwork)
export async function getOrCreateContainer(id) {
console.log(`Getting container: ${id}`)
let container
try {
const container = engine.getContainer(id)
container = engine.getContainer(id)
if (!(await container.inspect()).State.Running) {
await container.start()
}
return container
} catch (error) {
const container = await engine.createContainer({
...containerOptions,
name: id,
})
try {
container = await engine.createContainer({
...containerOptions,
name: id,
})
} catch (err) {
console.log(err)
throw err
}
await network.connect({ Container: container.id })
await container.start()
console.log(container.id)
return container
}
}
export async function attachContainer(container) {
export async function attachContainer(container, { token }) {
// Create an exec instance with bash shell
const exec = await container.exec({
AttachStdin: true,
@@ -54,6 +58,7 @@ export async function attachContainer(container) {
AttachStderr: true,
Cmd: ['/bin/bash'],
Tty: true,
Env: [`TOKEN=${token}`, `API_ENDPOINT=http://app:3000`],
})
const execOutput = await exec.start({
+8
View File
@@ -0,0 +1,8 @@
/* eslint-disable import/prefer-default-export */
export function get(key, defaultValue) {
const value = process.env[key]
if (value != null) {
return value
}
return defaultValue
}
+40
View File
@@ -291,6 +291,24 @@ paths:
properties:
message:
type: string
/commands:
post:
summary: Record a command that was executed in a session
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Command'
responses:
'201':
description: Command recorded successfully
'400':
description: Invalid request body
'401':
description: Unauthorized
components:
schemas:
User:
@@ -422,6 +440,28 @@ components:
source:
type: string
Command:
type: object
required:
- token
- pwd
- command
properties:
token:
type: string
description: The JWT token of the session
pwd:
type: string
example: '/root'
description: The current working directory
command:
type: string
example: 'ls -al /'
description: The command that was executed
output:
type: string
description: The output of the command
securitySchemes:
BearerAuth:
type: http
+46 -2
View File
@@ -1323,6 +1323,11 @@ bson@^5.4.0:
resolved "https://registry.yarnpkg.com/bson/-/bson-5.4.0.tgz#0eea77276d490953ad8616b483298dbff07384c6"
integrity sha512-WRZ5SQI5GfUuKnPTNmAYPiKIof3ORXAF4IRU5UcgmivNIon01rWQlw5RUH954dpu8yGL8T59YShVddIPaU/gFA==
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==
buffer-from@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
@@ -1481,6 +1486,11 @@ color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
commander@^11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67"
integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==
commander@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
@@ -1698,6 +1708,13 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
ecdsa-sig-formatter@1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
dependencies:
safe-buffer "^5.0.1"
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@@ -3218,6 +3235,33 @@ json5@^2.2.2:
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
jsonwebtoken@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.1.tgz#81d8c901c112c24e497a55daf6b2be1225b40145"
integrity sha512-K8wx7eJ5TPvEjuiVSkv167EVboBDv9PZdDoF7BgeQnBLVvZWW9clr2PsQHVJDTKaEIH5JBIwHujGcHp7GgI2eg==
dependencies:
jws "^3.2.2"
lodash "^4.17.21"
ms "^2.1.1"
semver "^7.3.8"
jwa@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
dependencies:
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
dependencies:
jwa "^1.4.1"
safe-buffer "^5.0.1"
kareem@2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.5.1.tgz#7b8203e11819a8e77a34b3517d3ead206764d15d"
@@ -4010,7 +4054,7 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
safe-buffer@5.2.1, safe-buffer@~5.2.0:
safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -4041,7 +4085,7 @@ semver@^6.0.0, semver@^6.3.0, semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.3.5, semver@^7.3.6, semver@^7.5.3:
semver@^7.3.5, semver@^7.3.6, semver@^7.3.8, semver@^7.5.3:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==