Compare commits

...
Author SHA1 Message Date
Justin Lin 0e37b13d99 wip: not working yet... 2025-03-11 19:56:56 +08:00
Justin Lin b2a251576c fix: save stdout and stderr without tee 2025-02-22 14:07:15 +08:00
Justin Lin 8cc006e3a4 fix: check files using abs path (#175)
the docker user is changed from commander to root due to #168 pr.
Use absolute path instead of relative path to prevent false detection.
2025-02-15 20:55:03 +08:00
Justin Lin d6ae6d818d Fix network (#174) 2025-02-15 20:52:25 +08:00
wancat 6692ae2b9b fix: check files using abs path
(the docker user is changed due to feat-ssh pr)
2025-02-15 20:42:50 +08:00
Justin Lin fab02d03a9 fix: update env samples, use dash only for naming networks 2024-12-27 21:01:31 +08:00
Justin Lin 9e44ebe415 fix: remove unwanted error logging 2024-12-27 20:56:23 +08:00
Justin Lin 019caf58e4 fix: use a different docker network for dev to prevent "interna" settings conflicts 2024-12-27 20:56:05 +08:00
Justin Lin 97ad1a7b50 fix: set mongodb version to 6 (#170) 2024-10-21 13:51:17 +08:00
Justin Lin cb4b9f429a fix: set mongodb version to 6 2024-10-21 13:43:58 +08:00
Justin Lin 5e11d2da11 fix: pass env file to containers directly (#169) 2024-10-21 12:59:56 +08:00
Justin Lin a901ce005c fix: pass env file to containers directly 2024-10-21 12:53:39 +08:00
Justin Lin 4dd2a7cbe1 Feat ssh (#168) 2024-10-21 12:15:43 +08:00
Justin Lin 5cf375905d feat: remove debug logs 2024-10-21 12:12:52 +08:00
Justin Lin d7c5c756f3 feat: add production env sample 2024-10-21 12:11:32 +08:00
Justin Lin 44ecd3b87a fix: mounting quest home into production container 2024-10-21 12:11:24 +08:00
Justin Lin 1888781a47 fix: copy entrypoint into quest container. Disable mounting for entrypoint & public key. Use Env to pass ssh public key into container. 2024-10-21 11:54:32 +08:00
Justin Lin 860b257ea7 doc: update Windows setup guide 2024-10-20 20:06:39 +08:00
Justin Lin ba251fb933 feat: update dump script 2024-10-17 22:08:52 +08:00
Chen, Tai-Lin 84bd2620d8 docs: add windows environment setup document draft 2024-10-16 23:53:58 +08:00
Justin Lin b449d84c54 feat: auto create config folder and player network 2024-10-16 14:13:41 +08:00
Justin Lin 91ebaec22c feat: update document and remove some debug logging 2024-10-14 18:32:02 +08:00
Justin Lin 58354b7cd8 feat: clean up /etc/skel and move ssh key to /etc/ssh/authorized_keys/ 2024-10-14 17:48:05 +08:00
Justin Lin 31301419ad feat: add ssh connection retry 2024-10-14 17:31:35 +08:00
Justin Lin 251139c159 feat: move sshd_config to .include folder 2024-10-14 17:31:23 +08:00
Justin Lin a4ed4cf533 feat: remove unnecessary quest Dockerfiles 2024-10-14 12:40:53 +08:00
Justin Lin 247f03b71a feat: mount quest home to /etc/skel to prevent building images 2024-10-14 12:30:28 +08:00
Justin Lin be22a121c8 feat: use entrypoint to create user account and copy ssh key 2024-10-14 12:06:05 +08:00
Justin Lin 261c6fc029 feat: generate key pair using ssh2 for right public key format 2024-10-14 11:34:53 +08:00
Justin Lin 03b2993b56 feat: load or create key 2024-10-14 11:23:25 +08:00
Justin Lin 2cc348687a feat: generate key pair function 2024-10-14 10:53:15 +08:00
Justin Lin 2a3429106c feat: remove welcome banner 2024-10-13 16:31:17 +08:00
Justin Lin a6720cfe39 feat: connect through ssh 2024-10-13 15:59:01 +08:00
Justin Lin 4c292fd6ba wip: allow connection from docker container 2024-10-13 15:05:20 +08:00
Justin Lin 5ba0021c37 fix: ssh server 2024-10-10 22:43:39 +08:00
Justin Lin 19897e607f wip: connect container through ssh 2024-10-10 20:54:42 +08:00
Justin Lin 08e9191c4b feat: replace alpine with debian slim 2024-10-10 20:54:09 +08:00
Justin Lin aa95eda23c Doc (#167) 2024-10-10 19:52:04 +08:00
Justin Lin 6bc37900d8 doc: update README 2024-10-10 17:41:47 +08:00
Justin Lin 98f18627e5 feat: mount quest 2024-10-10 17:21:38 +08:00
42 changed files with 862 additions and 263 deletions

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
HOST=localhost
PORT=3000
BASE_URL=https://example.com
SECRET_KEY=YOU_SHOULD_CHANGE_THIS
BACKEND_URL=http://backend:3000
DOCKER_NETWORK=linux-odyssey-players
MONGO_ROOT_PASSWORD=adminpassword
MONGO_USER=commander
MONGO_PASS=commander
MONGO_DB=linux-odyssey
MONGO_URL=mongodb://commander:commander@db:27017/linux-odyssey
# Admin user credentials for analytics
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
ADMIN_HOST=0.0.0.0
ADMIN_PORT=3001
# ADMIN_BASE_URL=http://0.0.0.0:3001
# Optional
# CONTAINER_EXPIRY=60 # 60 minutes
# SESSION_MAX_AGE=168 # 7 days
# TRUSTED_PROXIES=1.2.3.4
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# SURVEY_URL=
# BUG_REPORT_URL=
# SURVEY_URL=
# BUG_REPORT_URL=
# Docker related config
# DOCKER_NETWORK=host
# QUEST_IMAGE=linuxodyssey/quest-base
# DOCKER_PREFIX=linuxodyssey/quest-
# To mount quest folder from host into container, for development purpose
# MOUNT_QUEST=true
# LOG_PATH=
# Testing, create a user with the following credentials, disable rate limiting
# TESTING=true
# TESTING_USERNAME=alex
# TESTING_PASSWORD=Alex1234
+38
View File
@@ -0,0 +1,38 @@
HOST=localhost
PORT=3000
BASE_URL=http://localhost:5173
MONGO_URL=mongodb://localhost:27017/odyssey-test
SECRET_KEY=sample-secret
DOCKER_NETWORK=linux-odyssey-players-dev
# Optional
# BACKEND_URL=http://host.docker.internal:3000
# CONTAINER_EXPIRY=60 # 60 minutes
# SESSION_MAX_AGE=168 # 7 days
# TRUSTED_PROXIES=1.2.3.4
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# SURVEY_URL=
# BUG_REPORT_URL=
# SURVEY_URL=
# BUG_REPORT_URL=
# Docker related config
# DOCKER_NETWORK=host
# QUEST_IMAGE=linuxodyssey/quest-base
# DOCKER_PREFIX=linuxodyssey/quest-
# To mount quest folder from host into container, for development purpose
# MOUNT_QUEST=true
# LOG_PATH=
# Testing, create a user with the following credentials, disable rate limiting
# TESTING=true
# TESTING_USERNAME=alex
# TESTING_PASSWORD=Alex1234
+3
View File
@@ -1,3 +1,5 @@
config
# Logs # Logs
logs logs
*.log *.log
@@ -85,6 +87,7 @@ built
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local .env.local
.env.prod
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache
+105 -76
View File
@@ -5,9 +5,15 @@
## Requirements ## Requirements
- NodeJS - NodeJS v20+
- Docker - Docker
- Yarn - Yarn: v1.22
## Known Issues
- MongoDB requires x86 architecture to run for v5+ versions. You can track [this issue](https://github.com/linux-odyssey/linux-odyssey/issues/166) for updates.
Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for setup instructions.
## Installation ## Installation
@@ -15,30 +21,66 @@
cd linux-odyssey cd linux-odyssey
yarn install yarn install
## Development ## Project Structures
Run mongoDB first: 1. **app**: frontend
2. **server**: backend
3. **quests**: tutorials scripts for each quest
4. **packages**: other packages
1. **analytics**: admin dashboard to analyze collected data
2. **constants**: some shared config values for app and server
3. **container**: the script run in container to capture users' command
4. **file-graph**: the algorithm to construct and modify file graph, used in both app and server
5. **models**: definitions of MondoDB collections (data schema)
6. **utils**: other utility code shared between packages
7. **game (in the future)**: core logic of game playing, will be extracted from server
## Configuration
The configuration is stored in the `.env` file. You can copy from the sample file.
cp .env.sample .env
The backend will use a SSH key pair to login to the player's container. The private key is on the host, and the public key is copied to the player's container.
The key pair is stored in `./config/ssh_key` and `./config/ssh_key.pub`. If it doesn't exist, the backend will generate a new pair and store them.
## Docker Setup
### Docker compose files explaination
1. **docker-compose.yml**: The one used for development. It sets up MongoDB and builds base quest image.
2. **docker-compose.prod.yml**: The one used for production deployment. It has a Nginx service, a backend, a database, an analytics service, and creates isolated networks for player containers.
3. **docker-compose.testing.yml**: Similar to production config, but sets up an one-time environment for end-to-end testing.
### Common Development Setup
This sets up a development database, and running development server for frontend and backend.
Pull the latest images:
docker compose pull
Start the containers:
docker compose up -d docker compose up -d
The db will automatically restarts by Docker. This will start the db. it will automatically restarts by Docker.
Build up dependencies: Building quest images:
yarn build:deps docker compose build base
yarn build:quests
Run dev server (frontend, backend): ### Build Quest Images in Details
yarn dev In each quest folder, there is a `game.yml` file, which is the quest definition.
If you want to enable social login, you should have OAuth client token in a `.env` file: If you want to include additional files, put them in a `home` folder. The files in `home` will be copied to the user's home on container creation automatically.
### Build Quest Images
In each quest folder, there is a `game.yml` file, which is the quest definition. And a optional `Dockerfile` to overwrite the base image. If you want to include additional files, put them in a `home` folder. The files in `home` will **not** be copied to the image automatically, you have to copy them in the `Dockerfile`.
quests/ quests/
├── Dockerfile # Base image ├── Dockerfile # Base image
├── entrypoint.sh # Entrypoint for the container
├── discover ├── discover
│ └── game.yml │ └── game.yml
├── helloworld ├── helloworld
@@ -54,14 +96,55 @@ In each quest folder, there is a `game.yml` file, which is the quest definition.
└── home └── home
└── ancient_scroll.txt └── ancient_scroll.txt
An optional `Dockerfile` can overwrite the base image. You can use this to install additional dependencies or modify the environment.
Customized `entrypoint.sh` for each quest is not yet supported.
To build all quests: To build all quests:
yarn build:quests yarn build:quests
The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests. The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests.
### Optional: Mounting Host Directory
To improve the development experience, we can mount the host directory to the container in the pattern:
/path/to/linux-odyssey/quests/${quest_id}/home:/home/commander
/path/to/linux-odyssey/packages/container:/usr/local/lib/container
You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables:
MOUNT_QUEST=true
## Development
Copy `.env.sample` to `.env` and customize it for your needs.
Build the whole project for the first time:
yarn build
This will build the typescript for each package and the server.
Run dev server (frontend, backend):
yarn dev
**IMPORTANT NOTE**: only the app and server have hot-reloading, other packages require manually running `yarn dev` in each package.
To develop analytics dashboard:
yarn analytics
The default username and password is `alex` and `Alex1234`.
## Testing ## Testing
Unit tests:
yarn test:unit
Run Cypress locally, using the current development containers: Run Cypress locally, using the current development containers:
docker compose up -d docker compose up -d
@@ -78,76 +161,22 @@ Run Cypress in container, create brand-new containers along with it:
# tearing off the containers # tearing off the containers
./docker-scripts.sh down ./docker-scripts.sh down
### Deployment ## Deployment
# Build the images (only allowed for maintainers)
docker compose -f docker-compose.prod.yml build docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml push docker compose -f docker-compose.prod.yml push
# Server side # Server side
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
yarn build:quests
docker compose -f docker-compose.prod.yml up -d docker compose -f docker-compose.prod.yml up -d
### Necessary Configuration
To enable social login, you should have OAuth client token in a `.env` file: To enable social login, you should have OAuth client token in a `.env` file:
You can copy from `example.env` You can copy from `.env.sample`
# .env **YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
BASE_URL=https://example.com
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
To enable it: SECRET_KEY=your-secret-key
docker compose --env-file .env -f docker-compose.prod.yml up -d
## Standalone App (without Docker)
Source: `app/`
# Connect to dev server (https://odyssey.wancat.cc)
yarn app
# Connect to localhost (http://localhost:3000)
yarn app:local
# Connect to anyhost
API_ENDPOINT=http://example.com yarn app:local
## Swagger
docker compose up -d swagger
Open http://localhost:8080 to open Swagger
## CLI terminal client (Docker not required)
yarn cli
Usage: client [options] [command]
Options:
-s, --session <string> Session ID
-c, --create Create a new session
-h, --host <string> Server host (default: "http://localhost:3000")
--help display help for command
Commands:
list List all sessions
connet to recent session:
yarn cli
connect to new session:
yarn cli -c
connect to specific session:
yarn cli -s 64d0de0367459c13004bc83f
connect to other host:
yarn cli -h https://example.com
list sessions:
yarn cli list
+8 -23
View File
@@ -1,4 +1,3 @@
version: '3'
services: services:
app: app:
image: linuxodyssey/frontend image: linuxodyssey/frontend
@@ -29,15 +28,14 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./logs:/app/logs - ./logs:/app/logs
- ./config:/app/config
- ./quests:/app/quests:ro
env_file: env_file:
- .env - .env
environment: environment:
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
- DOCKER_NETWORK=linux-odyssey-players - DOCKER_NETWORK=linux-odyssey-players
- SECRET_KEY=${SECRET_KEY}
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
- BACKEND_URL=http://backend:3000 - BACKEND_URL=http://backend:3000
- BASE_URL=${BASE_URL} - HOST_PROJECT_ROOT=${PWD}
analytics: analytics:
image: linuxodyssey/analytics image: linuxodyssey/analytics
@@ -52,13 +50,11 @@ services:
- internal - internal
ports: ports:
- '3001:3001' - '3001:3001'
environment: env_file:
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB} - .env
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
db: db:
image: mongo image: mongo:6
restart: always restart: always
networks: networks:
- internal - internal
@@ -67,19 +63,8 @@ services:
volumes: volumes:
- mongo:/data/db - mongo:/data/db
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
environment: env_file:
- BASE_URL=${BASE_URL} - .env
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
- MONGO_USER=${MONGO_USER}
- MONGO_PASS=${MONGO_PASSWORD}
- MONGO_DB=${MONGO_DB}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- SURVEY_URL=${SURVEY_URL}
- BUG_REPORT_URL=${BUG_REPORT_URL}
base: base:
image: linuxodyssey/quest-base image: linuxodyssey/quest-base
+8 -4
View File
@@ -1,7 +1,7 @@
version: '3' version: '3'
services: services:
db: db:
image: mongo image: mongo:6
restart: always restart: always
networks: networks:
- internal - internal
@@ -12,12 +12,16 @@ services:
base: base:
image: linuxodyssey/quest-base image: linuxodyssey/quest-base
networks:
- players-dev
build: build:
context: . context: .
dockerfile: ./quests/Dockerfile dockerfile: ./quests/Dockerfile
extra_hosts:
- 'host.docker.internal:host-gateway'
networks: networks:
internal: internal:
players: players-dev:
name: linux-odyssey-players name: linux-odyssey-players-dev
internal: true internal: false
+4 -3
View File
@@ -32,15 +32,16 @@ down() {
dump() { dump() {
# Define service name and dump directory name # Define service name and dump directory name
SERVICE_NAME="db" SERVICE_NAME="db"
COMPOSE_FILE="docker-compose.prod.yml"
DUMP_NAME="mongodump-$(date --iso).archive" DUMP_NAME="mongodump-$(date --iso).archive"
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME) CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q $SERVICE_NAME)
# Fetch credentials from Docker environment variables # Fetch credentials from Docker environment variables
MONGO_URL=$(docker compose exec -T backend printenv MONGO_URL) MONGO_URL=$(docker compose -f $COMPOSE_FILE exec -T backend printenv MONGO_URL)
echo mongo uri: $MONGO_URL echo mongo uri: $MONGO_URL
# Dump MongoDB data using credentials # Dump MongoDB data using credentials
docker compose exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME -d odyssey-test" && docker compose -f $COMPOSE_FILE exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME --uri=$MONGO_URL" &&
# Copy the dump from the container to the host # Copy the dump from the container to the host
# Note: You will need to know the exact container name for this step # Note: You will need to know the exact container name for this step
+141
View File
@@ -0,0 +1,141 @@
# Windows Environment Setup
## Install WSL and Ubuntu
Please refer to the Official [Setup WSL](https://learn.microsoft.com/en-us/windows/wsl/setup/environment) on Microsoft's website.
Run the following command in PowerShell as Administrator:
wsl --install
Then restart the computer. After restarting, you can open Ubuntu terminal by searching "Ubuntu" in Start menu. You will be prompted to create a new user account. Once you have done that, you can continue with the following steps.
## Setup Environment (In Ubuntu terminal)
1. Update and upgrade
```
sudo apt-get update
sudo apt-get upgrade
```
1. Install Git
```
sudo apt-get install git
```
1. Install Node.js (Version 20.x)
```
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install nodejs
sudo apt install build-essential
sudo npm install -g yarn
```
## Install Docker
(Refer to [get.docker.com](https://get.docker.com/))
To install the latest stable versions of Docker CLI, Docker Engine, and their
dependencies:
1. Download the script
```
curl -fsSL https://get.docker.com -o install-docker.sh
```
1. verify the script's content
```
cat install-docker.sh
```
1. run the script with --dry-run to verify the steps it executes
```
sh install-docker.sh --dry-run
```
**NOTE: the script will recommend you to use Docker Desktop instead, but we need to use Docker inside WSL for the backend to work.** Just ignore it and wait for the installation to finish.
1. run the script either as root, or using sudo to perform the installation.
```
sudo sh install-docker.sh
```
1. Verify the installation
```
docker --version
```
1. Add your user to the docker group
```
sudo usermod -aG docker $USER
```
**NOTE: You need to create a new WSL terminal to apply the changes.**
1. Verify the installation
```
docker ps
```
This should return something like:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
## Run the Project (In Ubuntu terminal)
1. Clone the repository
```
git clone https://github.com/lancatlin/linux-odyssey.git
cd linux-odyssey
```
Optional: To open the project in VSCode, run the following command inside the project folder:
```
code .
```
1. Install dependencies
```
yarn install
```
It is ok if there are some warnings and `gyp: ERR` errors, just verify there is `done` at the end.
1. Copy the environment variables
```
cp .env.sample .env
```
1. Docker setup
```
docker compose pull
docker compose build
docker compose up -d db
```
<!-- Check folder ./config exist and is empty -->
<!-- Check .env file exist -->
1. Run the project
```
yarn build
yarn dev
```
<!-- Check ./config/ssh_key.pub is a file not a folder -->
Enjoy!
+2 -1
View File
@@ -56,5 +56,6 @@
"dependencies": { "dependencies": {
"latest": "^0.2.0", "latest": "^0.2.0",
"typescript-eslint": "^7.4.0" "typescript-eslint": "^7.4.0"
} },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }
+6 -4
View File
@@ -21,14 +21,16 @@ interface Config {
} }
const config: Config = { const config: Config = {
host: process.env.HOST || 'localhost', host: process.env.ADMIN_HOST || 'localhost',
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3001, port: process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001,
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test', db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
username: getOrFailed('ADMIN_USERNAME'), username: getOrFailed('ADMIN_USERNAME'),
password: getOrFailed('ADMIN_PASSWORD'), password: getOrFailed('ADMIN_PASSWORD'),
baseUrl: baseUrl:
process.env.BASE_URL || process.env.ADMIN_BASE_URL ||
`http://${process.env.HOST || 'localhost'}:${process.env.PORT ? parseInt(process.env.PORT, 10) : 3001}`, `http://${process.env.ADMIN_HOST || 'localhost'}:${
process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001
}`,
} }
export default config export default config
+121
View File
@@ -0,0 +1,121 @@
# This is the sshd server system-wide configuration file. See
# sshd_config(5) for more information.
# This sshd was compiled with PATH=/usr/local/bin:/usr/bin:/bin:/usr/games
# The strategy used for options in the default sshd_config shipped with
# OpenSSH is to specify options with their default value where
# possible, but leave them commented. Uncommented options override the
# default value.
Include /etc/ssh/sshd_config.d/*.conf
#Port 22
#AddressFamily any
#ListenAddress 0.0.0.0
#ListenAddress ::
#HostKey /etc/ssh/ssh_host_rsa_key
#HostKey /etc/ssh/ssh_host_ecdsa_key
#HostKey /etc/ssh/ssh_host_ed25519_key
# Ciphers and keying
#RekeyLimit default none
# Logging
#SyslogFacility AUTH
#LogLevel INFO
# Authentication:
#LoginGraceTime 2m
#PermitRootLogin prohibit-password
#StrictModes yes
#MaxAuthTries 6
#MaxSessions 10
PubkeyAuthentication yes
# Expect .ssh/authorized_keys2 to be disregarded by default in future.
AuthorizedKeysFile /etc/ssh/authorized_keys/%u
#AuthorizedPrincipalsFile none
#AuthorizedKeysCommand none
#AuthorizedKeysCommandUser nobody
# For this to work you will also need host keys in /etc/ssh/ssh_known_hosts
#HostbasedAuthentication no
# Change to yes if you don't trust ~/.ssh/known_hosts for
# HostbasedAuthentication
#IgnoreUserKnownHosts no
# Don't read the user's ~/.rhosts and ~/.shosts files
#IgnoreRhosts yes
# To disable tunneled clear text passwords, change to no here!
#PasswordAuthentication yes
#PermitEmptyPasswords no
# Change to yes to enable challenge-response passwords (beware issues with
# some PAM modules and threads)
KbdInteractiveAuthentication no
# Kerberos options
#KerberosAuthentication no
#KerberosOrLocalPasswd yes
#KerberosTicketCleanup yes
#KerberosGetAFSToken no
# GSSAPI options
#GSSAPIAuthentication no
#GSSAPICleanupCredentials yes
#GSSAPIStrictAcceptorCheck yes
#GSSAPIKeyExchange no
# Set this to 'yes' to enable PAM authentication, account processing,
# and session processing. If this is enabled, PAM authentication will
# be allowed through the KbdInteractiveAuthentication and
# PasswordAuthentication. Depending on your PAM configuration,
# PAM authentication via KbdInteractiveAuthentication may bypass
# the setting of "PermitRootLogin prohibit-password".
# If you just want the PAM account and session checks to run without
# PAM authentication, then enable this but set PasswordAuthentication
# and KbdInteractiveAuthentication to 'no'.
UsePAM yes
#AllowAgentForwarding yes
#AllowTcpForwarding yes
#GatewayPorts no
X11Forwarding no
#X11DisplayOffset 10
#X11UseLocalhost yes
#PermitTTY yes
#PrintMotd yes
#PrintLastLog yes
#TCPKeepAlive yes
#PermitUserEnvironment no
#Compression delayed
#ClientAliveInterval 0
#ClientAliveCountMax 3
#UseDNS no
#PidFile /run/sshd.pid
#MaxStartups 10:30:100
#PermitTunnel no
#ChrootDirectory none
#VersionAddendum none
# no default banner path
#Banner none
# Allow client to pass locale environment variables
AcceptEnv LANG LC_* NODE_PATH ZDOTDIR API_ENDPOINT TOKEN
# override default of no subsystems
Subsystem sftp /usr/lib/openssh/sftp-server
# Example of overriding settings on a per-user basis
#Match User anoncvs
# X11Forwarding no
# AllowTcpForwarding no
# PermitTTY no
# ForceCommand cvs server
+29 -8
View File
@@ -1,24 +1,39 @@
PROMPT='%F{2}%n%f:%F{6}%~%f $ ' PROMPT='%F{2}%n%f:%F{6}%~%f $ '
unsetopt CASE_GLOB unsetopt CASE_GLOB
setopt CHASE_LINKS setopt CHASE_LINKS
emulate bash # emulate bash
set +o prompt_cr set +o prompt_cr
preexec() { preexec() {
export CMD_START_TIME=$(date +%s%N) # capture start time export CMD_START_TIME=$(date +%s%N) # capture start time
export CMD_NAME=$1 # capture command export CMD_NAME="$1" # capture command
export CMD_PID=$$
# A list of interactive commands # # A list of interactive commands
interactive_commands=("vim" "nano" "top" "ssh" "less" "zsh" "bash") # readlink /proc/$PPID/exe
interactive_commands=("vim" "nano" "top" "ssh" "less" "zsh" "bash" "script" "exec")
# # if [[ $CMD_EXECUTED = 1 ]]; then
# # export CMD_EXECUTED=0
# # return
# # fi
# Check if the command is in the list of interactive commands # # Check if the command is in the list of interactive commands
if [[ " ${interactive_commands[@]} " =~ " $1 " ]]; then if [[ " ${interactive_commands[@]} " =~ " $1 " ]]; then
# If it is, just execute it normally # If it is, just execute it normally
exec # exec
# echo IF
return
else else
# If it's not, execute it and redirect its output to a file # If it's not, execute it and redirect its output to a file
exec > >(tee /tmp/cmd.out) 2> >(tee /tmp/cmd.error >&2) # exec > >(tee /tmp/cmd.out) 2> >(tee /tmp/cmd.error >&2)
# echo ELSE
# export CMD_EXECUTED=1
# eval >&1 >/tmp/cmd.out
# exec >&1
# exec
script --quiet -O /tmp/cmd.out -c "$CMD_NAME"
return 1
fi fi
} }
@@ -29,7 +44,12 @@ 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 node /usr/local/lib/container/cli.js
if [[ -n $CMD_PID ]]; then
# echo $CMD_PID
# kill $CMD_PID
fi
# rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null
# Reset variables # Reset variables
unset CMD_START_TIME unset CMD_START_TIME
@@ -38,5 +58,6 @@ precmd() {
unset CMD_OUTPUT_FILE unset CMD_OUTPUT_FILE
unset CMD_ERROR_FILE unset CMD_ERROR_FILE
unset CMD_NAME unset CMD_NAME
export CMD_EXECUTED=0
} }
+18 -12
View File
@@ -1,26 +1,32 @@
FROM alpine:3.18 FROM debian:bookworm-slim
LABEL maintainer="wancat@wancat.cc" LABEL maintainer="wancat@wancat.cc"
RUN apk add --no-cache \ RUN apt-get update && apt-get install -y \
vim \ vim \
zsh \ zsh \
nodejs \ curl \
yarn openssh-server
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 WORKDIR /usr/local/lib/container
COPY ./packages/container/package.json ./yarn.lock ./ COPY ./packages/container/package.json ./yarn.lock ./
RUN yarn install --modules-folder /usr/src/node_modules RUN yarn install --modules-folder /usr/src/node_modules
ENV NODE_PATH=/usr/src/node_modules ENV NODE_PATH=/usr/src/node_modules
COPY ./packages/container /usr/local/lib/container 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 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
ENV USERNAME=commander EXPOSE 22
RUN adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
USER ${USERNAME}
WORKDIR /home/${USERNAME}
CMD [ "/bin/zsh" ]
-3
View File
@@ -1,3 +0,0 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
-3
View File
@@ -1,3 +0,0 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
-7
View File
@@ -1,7 +0,0 @@
FROM linuxodyssey/quest-base
USER root
COPY home .
RUN chown -R ${USERNAME}:${USERNAME} .
USER ${USERNAME}
-3
View File
@@ -1,3 +0,0 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
+4 -4
View File
@@ -164,7 +164,7 @@ stages:
command: command:
- ^cp hello.txt hello2.txt$ - ^cp hello.txt hello2.txt$
files: files:
- path: hello2.txt - path: /home/commander/hello2.txt
type: file type: file
exceptions: exceptions:
- condition: - condition:
@@ -224,7 +224,7 @@ stages:
command: command:
- ^cp world.txt world2.txt$ - ^cp world.txt world2.txt$
files: files:
- path: world2.txt - path: /home/commander/world2.txt
type: file type: file
exceptions: exceptions:
- condition: - condition:
@@ -292,9 +292,9 @@ stages:
command: command:
- ^cp - ^cp
files: files:
- path: notes2 - path: /home/commander/notes2
type: directory type: directory
- path: notes2/note.txt - path: /home/commander/notes2/note.txt
type: file type: file
exceptions: exceptions:
- condition: - condition:
+6 -6
View File
@@ -44,12 +44,12 @@ stages:
- ls - ls
condition: condition:
files: files:
- path: hello.txt - path: /home/commander/hello.txt
type: file type: file
exceptions: exceptions:
- condition: - condition:
files: files:
- path: hello$ - path: /home/commander/hello
type: file type: file
responses: responses:
- type: narrative - type: narrative
@@ -107,12 +107,12 @@ stages:
- ls-touch - ls-touch
condition: condition:
files: files:
- path: world.txt - path: /home/commander/world.txt
type: file type: file
exceptions: exceptions:
- condition: - condition:
files: files:
- path: world$ - path: /home/commander/world
type: file type: file
responses: responses:
- type: narrative - type: narrative
@@ -154,7 +154,7 @@ stages:
- ls-2 - ls-2
condition: condition:
files: files:
- path: notes - path: /home/commander/notes
type: folder type: folder
responses: responses:
- type: narrative - type: narrative
@@ -203,7 +203,7 @@ stages:
- cd-notes - cd-notes
condition: condition:
files: files:
- path: notes/note.txt - path: /home/commander/notes/note.txt
type: file type: file
responses: responses:
- type: narrative - type: narrative
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
USERNAME=commander
echo "Initializing container for $USERNAME"
if ! id -u ${USERNAME} >/dev/null 2>&1; then
echo "Adding user $USERNAME"
adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} && \
find / -name ".gitkeep" -type f -exec rm -f {} +
echo "Adding public key for $USERNAME"
echo "Public key: $PUB_KEY"
PUB_KEY_PATH=/etc/ssh/authorized_keys/${USERNAME}
echo $PUB_KEY > ${PUB_KEY_PATH}
chmod 644 ${PUB_KEY_PATH}
fi
mkdir -p /run/sshd
echo "Starting SSH server"
/usr/sbin/sshd -D -e
-6
View File
@@ -1,6 +0,0 @@
FROM linuxodyssey/quest-base
USER root
COPY home ${HOME}
USER commander
-3
View File
@@ -1,3 +0,0 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
-3
View File
@@ -1,3 +0,0 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
-3
View File
@@ -1,3 +0,0 @@
FROM linuxodyssey/quest-base
COPY --chown=${USERNAME} home ${HOME}
+8 -8
View File
@@ -49,9 +49,9 @@ stages:
command: command:
- ^mv hello.txt notes$ - ^mv hello.txt notes$
files: files:
- path: notes/hello.txt - path: /home/commander/notes/hello.txt
type: file type: file
- path: hello.txt - path: /home/commander/hello.txt
exists: false exists: false
responses: responses:
- type: narrative - type: narrative
@@ -106,9 +106,9 @@ stages:
command: command:
- ^mv - ^mv
files: files:
- path: wow.txt - path: /home/commander/wow.txt
type: file type: file
- path: world.txt - path: /home/commander/world.txt
exists: false exists: false
responses: responses:
- type: narrative - type: narrative
@@ -148,9 +148,9 @@ stages:
command: command:
- ^mv - ^mv
files: files:
- path: docs - path: /home/commander/docs
type: directory type: directory
- path: notes - path: /home/commander/notes
exists: false exists: false
responses: responses:
- type: narrative - type: narrative
@@ -187,9 +187,9 @@ stages:
command: command:
- ^mv - ^mv
files: files:
- path: hi.txt - path: /home/commander/hi.txt
type: file type: file
- path: docs/hello.txt - path: /home/commander/docs/hello.txt
exists: false exists: false
exceptions: exceptions:
- condition: - condition:
-3
View File
@@ -1,3 +0,0 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
-3
View File
@@ -1,3 +0,0 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
-4
View File
@@ -1,4 +0,0 @@
FROM linuxodyssey/quest-base
COPY --chown=${USERNAME} home ${HOME}
RUN mkdir empty
+10 -10
View File
@@ -44,7 +44,7 @@ stages:
command: command:
- ^rm\s - ^rm\s
files: files:
- path: hello.txt - path: /home/commander/hello.txt
exists: false exists: false
responses: responses:
- content: | - content: |
@@ -58,7 +58,7 @@ stages:
command: command:
- ^ls$ - ^ls$
files: files:
- path: hello.txt - path: /home/commander/hello.txt
exists: false exists: false
responses: responses:
- content: | - content: |
@@ -78,9 +78,9 @@ stages:
command: command:
- ^rm - ^rm
files: files:
- path: world.txt - path: /home/commander/world.txt
exists: false exists: false
- path: wow.txt - path: /home/commander/wow.txt
exists: false exists: false
responses: responses:
- content: | - content: |
@@ -111,7 +111,7 @@ stages:
command: command:
- ^rmdir empty$ - ^rmdir empty$
files: files:
- path: empty - path: /home/commander/empty
exists: false exists: false
responses: responses:
- content: | - content: |
@@ -139,7 +139,7 @@ stages:
command: command:
- ^rmdir notes$ - ^rmdir notes$
files: files:
- path: notes - path: /home/commander/notes
type: folder type: folder
exists: true exists: true
responses: responses:
@@ -161,10 +161,10 @@ stages:
command: command:
- ^rm - ^rm
files: files:
- path: notes - path: /home/commander/notes
type: folder type: folder
exists: true exists: true
- path: notes/note.txt - path: /home/commander/notes/note.txt
exists: false exists: false
exceptions: exceptions:
- condition: - condition:
@@ -203,7 +203,7 @@ stages:
task: 使用 `rmdir` 刪除 `notes` 資料夾 task: 使用 `rmdir` 刪除 `notes` 資料夾
condition: condition:
files: files:
- path: notes - path: /home/commander/notes
exists: false exists: false
responses: responses:
- content: | - content: |
@@ -238,7 +238,7 @@ stages:
command: command:
- ^rm -r trash$ - ^rm -r trash$
files: files:
- path: trash - path: /home/commander/trash
exists: false exists: false
responses: responses:
- content: | - content: |
View File
Whitespace-only changes.
-3
View File
@@ -1,3 +0,0 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
+2
View File
@@ -40,6 +40,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"socket.io": "^4.7.1", "socket.io": "^4.7.1",
"ssh2": "^1.16.0",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"validator": "^13.11.0", "validator": "^13.11.0",
"winston": "^3.11.0", "winston": "^3.11.0",
@@ -61,6 +62,7 @@
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/passport-oauth2": "^1.4.15", "@types/passport-oauth2": "^1.4.15",
"@types/source-map-support": "^0", "@types/source-map-support": "^0",
"@types/ssh2": "^1.15.1",
"@types/swagger-ui-express": "^4.1.6", "@types/swagger-ui-express": "^4.1.6",
"@types/validator": "^13.11.9", "@types/validator": "^13.11.9",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
@@ -8,6 +8,7 @@ import { asyncHandler } from '../../middleware/error.js'
export const newCommand = asyncHandler(async (req: Request, res: Response) => { export const newCommand = asyncHandler(async (req: Request, res: Response) => {
const { command, pwd, output, error, params } = matchedData(req) const { command, pwd, output, error, params } = matchedData(req)
console.log(command, output, error)
const { sessionId } = req.user as any const { sessionId } = req.user as any
const session = await Session.findById(sessionId) const session = await Session.findById(sessionId)
+14 -9
View File
@@ -1,8 +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 { Session } from '@linux-odyssey/models' import { ISession, Session } from '@linux-odyssey/models'
import { getAndStartContainer, attachContainer } from '../containers/docker.js' 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 { genJWT } from '../utils/auth.js'
@@ -77,30 +78,34 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
stream, stream,
} }
next() next()
} catch { } catch (err) {
next(new Error('Failed to start container.')) logger.error(err)
next(new Error('Failed to connect to container.'))
} }
} }
function onConnect(socket: Socket) { function onConnect(socket: Socket) {
const { session, stream } = socket.data.context const { session, stream } = socket.data.context as {
stream.socket.on('data', (chunk: Buffer) => { session: ISession
stream: Duplex
}
stream.on('data', (chunk: Buffer) => {
socket.emit('terminal', chunk.toString()) socket.emit('terminal', chunk.toString())
}) })
socket.on('terminal', function incoming(message) { socket.on('terminal', function incoming(message) {
stream.socket.write(message) stream.write(message)
}) })
const socketCallback = (event: string, ...data: any[]) => { const socketCallback = (event: string, ...data: any[]) => {
socket.emit(event, ...data) socket.emit(event, ...data)
} }
listenToSession(session.id, socketCallback) listenToSession(session._id.toString(), socketCallback)
socket.on('disconnect', () => { socket.on('disconnect', () => {
stream.socket.write('exit\n') stream.write('exit\n')
removeFromSession(session.id, socketCallback) removeFromSession(session._id.toString(), socketCallback)
stream.destroy() stream.destroy()
}) })
} }
+31 -8
View File
@@ -1,6 +1,9 @@
import validator from 'validator' import validator from 'validator'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import { fileURLToPath } from 'node:url'
import path from 'path'
import { get } from './utils/env.js' import { get } from './utils/env.js'
import { loadOrCreateKeyPair } from './utils/crypto.js'
// Import dotenv and load ../.env // Import dotenv and load ../.env
dotenv.config({ path: '../.env' }) dotenv.config({ path: '../.env' })
@@ -29,22 +32,31 @@ function getUrl(key: string): string {
function createConfig() { function createConfig() {
const isProduction = process.env.NODE_ENV === 'production' const isProduction = process.env.NODE_ENV === 'production'
const host = get('HOST', 'localhost') const host = get('HOST', '0.0.0.0')
const port = Number(get('PORT', 3000)) const port = Number(get('PORT', 3000))
const baseUrl = get('BASE_URL', `http://${host}:${port}`) const baseUrl = get('BASE_URL', `http://${host}:${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 keypairPath = get(
'KEYPAIR_PATH',
path.join(projectRoot, 'config', 'ssh_key')
)
const keypair = loadOrCreateKeyPair(keypairPath)
return { return {
host, host,
port, port,
baseUrl, baseUrl,
domain: url.hostname, domain: url.hostname,
protocol: url.protocol, protocol: url.protocol,
backendUrl: get('BACKEND_URL', baseUrl), backendUrl,
db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'), db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'),
secret: get('SECRET_KEY', ''), secret: get('SECRET_KEY', ''),
isProduction: process.env.NODE_ENV === 'production', isProduction: process.env.NODE_ENV === 'production',
containerExpiry: get('EXPIRY', 1000 * 60 * 60), containerExpiry: Number(process.env.CONTAINER_EXPIRY ?? 60) * 1000 * 60,
sessionMaxAge: get('SESSION_MAX_AGE', 1000 * 60 * 60 * 24 * 7), sessionMaxAge:
Number(process.env.SESSION_MAX_AGE ?? 7 * 24) * 1000 * 60 * 60,
google: { google: {
clientID: get('GOOGLE_CLIENT_ID', ''), clientID: get('GOOGLE_CLIENT_ID', ''),
clientSecret: get('GOOGLE_CLIENT_SECRET', ''), clientSecret: get('GOOGLE_CLIENT_SECRET', ''),
@@ -57,16 +69,20 @@ function createConfig() {
surveyUrl: getUrl('SURVEY_URL'), surveyUrl: getUrl('SURVEY_URL'),
bugReportUrl: getUrl('BUG_REPORT_URL'), bugReportUrl: getUrl('BUG_REPORT_URL'),
projectRoot,
docker: { docker: {
network: get('DOCKER_NETWORK', 'host'), network: get('DOCKER_NETWORK', 'linux-odyssey-players-dev'),
defaultImage: get('QUEST_IMAGE', 'linuxodyssey/quest-base'), defaultImage: get('QUEST_IMAGE', 'linuxodyssey/quest-base'),
imagePrefix: get('DOCKER_PREFIX', 'linuxodyssey/quest-'), imagePrefix: get('DOCKER_PREFIX', 'linuxodyssey/quest-'),
mountQuest: get('MOUNT_QUEST', ''), mountQuest: process.env.MOUNT_QUEST === 'true',
hostPwd: get('HOST_PWD', ''), hostProjectRoot: get('HOST_PROJECT_ROOT', projectRoot),
keypairPath,
keypair,
}, },
log: { log: {
path: get('LOG_PATH', '../logs'), path: get('LOG_PATH', path.join(projectRoot, 'logs')),
}, },
testing: { testing: {
enabled: !isProduction && process.env.TESTING === 'true', enabled: !isProduction && process.env.TESTING === 'true',
@@ -76,9 +92,16 @@ function createConfig() {
} }
} }
function getProjectRoot(): string {
const filename = fileURLToPath(import.meta.url)
const root = path.join(path.dirname(filename), '..', '..')
return root
}
const config = createConfig() const config = createConfig()
export function getQuestImage(id: string): string { export function getQuestImage(id: string): string {
// TODO: handle staging images (add version tag)
return `${config.docker.imagePrefix}${id}` return `${config.docker.imagePrefix}${id}`
} }
+78 -39
View File
@@ -1,41 +1,80 @@
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()
const containerOptions = { const newContainerOptions = (
AttachStdin: true, name: string,
AttachStdout: true, imageId: string,
AttachStderr: true, options: {
Tty: true, binds?: string[]
OpenStdin: true, } = {}
StdinOnce: false, ): Docker.ContainerCreateOptions => ({
name,
Image: getQuestImage(imageId),
HostConfig: { HostConfig: {
NetworkMode: config.docker.network, NetworkMode: config.docker.network,
Binds: options.binds,
ExtraHosts: ['host.docker.internal:host-gateway'],
}, },
} as Docker.ContainerCreateOptions ExposedPorts: {
'22/tcp': {},
},
Env: [`PUB_KEY=${config.docker.keypair.publicKey}`],
})
export function createContainer( export async function createContainer(
name: string, name: string,
questId: string questId: string,
imageId: string
): Promise<Docker.Container> { ): Promise<Docker.Container> {
const option = { let binds: string[] = []
...containerOptions, binds.push(
name, `${config.docker.hostProjectRoot}/quests/.includes/zshrc:/etc/zsh/zshrc`
Image: getQuestImage(questId), )
if (await questHomeExists(questId)) {
logger.info('Mounting quest home', questId)
binds.push(
`${config.docker.hostProjectRoot}/quests/${questId}/home:/etc/skel:ro`
)
} }
const { hostPwd, mountQuest } = config.docker if (!config.isProduction && config.docker.mountQuest && imageId !== 'base') {
if (!config.isProduction && hostPwd && mountQuest === questId) { logger.info('Mounting quest folder', questId)
logger.info('Mounting quest folder', mountQuest) binds = [
option.HostConfig!.Binds = [ `${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`,
`${hostPwd}/quests/${mountQuest}/home:/home/commander`, `${config.docker.hostProjectRoot}/packages/container:/usr/local/lib/container`,
`${hostPwd}/packages/container:/usr/local/lib/container`,
] ]
} }
const option = newContainerOptions(name, imageId, { binds })
await createNetworkIfNotExists(config.docker.network)
return engine.createContainer(option) return engine.createContainer(option)
} }
async function createNetworkIfNotExists(network: string) {
const dockerNetworks = await engine.listNetworks({
filters: {
name: [network],
},
})
if (dockerNetworks.length === 0) {
logger.log('Creating player network', network)
await engine.createNetwork({ Name: network })
}
}
async function questHomeExists(imageId: string) {
try {
const stat = await fs.stat(`${config.projectRoot}/quests/${imageId}/home`)
return stat.isDirectory()
} catch {
return false
}
}
export async function getAndStartContainer( export async function getAndStartContainer(
id: string id: string
): Promise<Docker.Container> { ): Promise<Docker.Container> {
@@ -53,26 +92,26 @@ export async function getAndStartContainer(
export async function attachContainer( export async function attachContainer(
container: Docker.Container, container: Docker.Container,
{ token }: { token: string } { token }: { token: string }
) { ): Promise<Duplex> {
const exec = await container.exec({ const containerIp = (await container.inspect()).NetworkSettings.Networks[
AttachStdin: true, config.docker.network
AttachStdout: true, ].IPAddress
AttachStderr: true,
Cmd: ['/bin/zsh'],
Tty: true,
Env: [
`TOKEN=${token}`,
`API_ENDPOINT=${config.backendUrl}`,
'ZDOTDIR=/etc/zsh',
],
})
const execOutput = await exec.start({ let error: Error | null = null
Detach: false, for (let i = 0; i < 10; i++) {
Tty: true, try {
}) // eslint-disable-next-line no-await-in-loop
const stream = await connectToSSH(containerIp, { token })
return execOutput 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) {
+45
View File
@@ -0,0 +1,45 @@
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 -1
View File
@@ -72,7 +72,7 @@ async function main() {
app.use('/api/v1', apiRouter) app.use('/api/v1', apiRouter)
app.use(errorHandler) app.use(errorHandler)
server.listen(config.port, config.host, () => { server.listen(config.port, '0.0.0.0', () => {
logger.info(`Server listening at ${config.baseUrl}`) logger.info(`Server listening at ${config.baseUrl}`)
}) })
} }
+1
View File
@@ -43,6 +43,7 @@ export async function createNewSession(
const container = await createContainer( const container = await createContainer(
`quest-${quest.id}-${user.username}-${Date.now()}`, `quest-${quest.id}-${user.username}-${Date.now()}`,
quest.id,
quest.image quest.image
) )
+39
View File
@@ -0,0 +1,39 @@
import fs from 'fs'
import path from 'path'
import ssh2 from 'ssh2'
const {
utils: { generateKeyPairSync },
} = ssh2
type KeyPair = {
privateKey: string
publicKey: string
}
export function loadOrCreateKeyPair(privateKeyPath: string): KeyPair {
const publicKeyPath = `${privateKeyPath}.pub`
if (fs.existsSync(privateKeyPath) && fs.existsSync(publicKeyPath)) {
const privateKey = fs.readFileSync(privateKeyPath, 'utf8')
const publicKey = fs.readFileSync(publicKeyPath, 'utf8')
return { privateKey, publicKey }
}
const dir = path.dirname(privateKeyPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
const keyPair = generateKeyPair()
fs.writeFileSync(privateKeyPath, keyPair.privateKey, {
mode: 0o600,
})
fs.writeFileSync(publicKeyPath, keyPair.publicKey, {
mode: 0o600,
})
return keyPair
}
export function generateKeyPair(): KeyPair {
const { private: privateKey, public: publicKey } =
generateKeyPairSync('ed25519')
return { privateKey, publicKey }
}
+9
View File
@@ -0,0 +1,9 @@
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')
})
})
+59 -3
View File
@@ -2067,6 +2067,13 @@
dependencies: dependencies:
"@types/node" "^18.11.18" "@types/node" "^18.11.18"
"@types/ssh2@^1.15.1":
version "1.15.1"
resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.15.1.tgz#4db4b6864abca09eb299fe5354fa591add412223"
integrity sha512-ZIbEqKAsi5gj35y4P4vkJYly642wIbY6PqoN0xiyQGshKUGXR9WQjF/iF9mXBQ8uBKy3ezfsCkcoHKhd0BzuDA==
dependencies:
"@types/node" "^18.11.18"
"@types/stack-utils@^2.0.0": "@types/stack-utils@^2.0.0":
version "2.0.3" version "2.0.3"
resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz" resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz"
@@ -3579,6 +3586,14 @@ cors@~2.8.5:
object-assign "^4" object-assign "^4"
vary "^1" vary "^1"
cpu-features@~0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5"
integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==
dependencies:
buildcheck "~0.0.6"
nan "^2.19.0"
cpu-features@~0.0.9: cpu-features@~0.0.9:
version "0.0.9" version "0.0.9"
resolved "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.9.tgz" resolved "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.9.tgz"
@@ -6823,6 +6838,11 @@ nan@^2.17.0, nan@^2.18.0:
resolved "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz" resolved "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz"
integrity sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w== integrity sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==
nan@^2.19.0, nan@^2.20.0:
version "2.21.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.21.0.tgz#203ab765a02e6676c8cb92e1cad9503e7976d55b"
integrity sha512-MCpOGmdWvAOMi4RWnpxS5G24l7dVMtdSHtV87I3ltjaLdFOTO74HVJ+DfYiAXjxGKsYR/UCmm1rBwhMN7KqS1A==
nanoid@^3.3.7: nanoid@^3.3.7:
version "3.3.7" version "3.3.7"
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz" resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz"
@@ -8584,6 +8604,17 @@ ssh2@^1.11.0:
cpu-features "~0.0.9" cpu-features "~0.0.9"
nan "^2.18.0" nan "^2.18.0"
ssh2@^1.16.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.16.0.tgz#79221d40cbf4d03d07fe881149de0a9de928c9f0"
integrity sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==
dependencies:
asn1 "^0.2.6"
bcrypt-pbkdf "^1.0.2"
optionalDependencies:
cpu-features "~0.0.10"
nan "^2.20.0"
sshpk@^1.14.1, sshpk@^1.7.0: sshpk@^1.14.1, sshpk@^1.7.0:
version "1.18.0" version "1.18.0"
resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz" resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz"
@@ -8631,7 +8662,7 @@ string-length@^4.0.1:
char-regex "^1.0.2" char-regex "^1.0.2"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: "string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3" version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -8649,6 +8680,15 @@ string-width@^1.0.1:
is-fullwidth-code-point "^1.0.0" is-fullwidth-code-point "^1.0.0"
strip-ansi "^3.0.0" strip-ansi "^3.0.0"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^5.0.1, string-width@^5.1.2: string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2" version "5.1.2"
resolved "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz" resolved "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz"
@@ -8709,7 +8749,7 @@ stringstream@~0.0.4:
resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.6.tgz#7880225b0d4ad10e30927d167a1d6f2fd3b33a72" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.6.tgz#7880225b0d4ad10e30927d167a1d6f2fd3b33a72"
integrity sha512-87GEBAkegbBcweToUrdzf3eLhWNg06FJTebl4BVJz/JgWy8CvEr9dRtX5qWphiynMSQlxxi+QqN0z5T32SLlhA== integrity sha512-87GEBAkegbBcweToUrdzf3eLhWNg06FJTebl4BVJz/JgWy8CvEr9dRtX5qWphiynMSQlxxi+QqN0z5T32SLlhA==
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: "strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1" version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -8723,6 +8763,13 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1, strip-ansi@~3.0.1:
dependencies: dependencies:
ansi-regex "^2.0.0" ansi-regex "^2.0.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1: strip-ansi@^7.0.1:
version "7.1.0" version "7.1.0"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz"
@@ -9575,7 +9622,7 @@ wordwrap@^1.0.0:
resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0" version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -9593,6 +9640,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0" string-width "^4.1.0"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0: wrap-ansi@^8.1.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"