mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
Feat ssh (#168)
This commit is contained in:
34 files changed
+674
-132
No files matched your search
@@ -0,0 +1,46 @@
|
||||
HOST=localhost
|
||||
PORT=3000
|
||||
BASE_URL=https://example.com
|
||||
SECRET_KEY=YOU_SHOULD_CHANGE_THIS
|
||||
BACKEND_URL=http://backend:3000
|
||||
|
||||
MONGO_ROOT_PASSWORD=adminpassword
|
||||
MONGO_USER=commander
|
||||
MONGO_PASS=commander
|
||||
MONGO_DB=linux-odyssey
|
||||
|
||||
# Admin user credentials for analytics
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
|
||||
# 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=
|
||||
# DOCKER_NETWORK=
|
||||
|
||||
# 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
|
||||
+1
-1
@@ -1,11 +1,11 @@
|
||||
HOST=localhost
|
||||
PORT=3000
|
||||
BASE_URL=http://localhost:5173
|
||||
BACKEND_URL=http://localhost:3000
|
||||
MONGO_URL=mongodb://localhost:27017/odyssey-test
|
||||
SECRET_KEY=sample-secret
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
config
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -85,6 +87,7 @@ built
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.prod
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
|
||||
## Known Issues
|
||||
|
||||
- The player's terminal doesn't work correctly on Mac OS and Windows for now. Please use Linux or Virtual Machines. You can track [this issue](https://github.com/linux-odyssey/linux-odyssey/issues/165) for updates.
|
||||
- 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
|
||||
|
||||
git clone https://github.com/lancatlin/linux-odyssey.git
|
||||
@@ -34,6 +35,16 @@
|
||||
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
|
||||
@@ -58,14 +69,18 @@ This will start the db. it will automatically restarts by Docker.
|
||||
|
||||
Building quest images:
|
||||
|
||||
docker compose build base
|
||||
yarn build:quests
|
||||
|
||||
### Build Quest Images in Details
|
||||
|
||||
In each quest folder, there is a `game.yml` file, which is the quest definition. And a optional `Dockerfile` can 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`.
|
||||
In each quest folder, there is a `game.yml` file, which is the quest definition.
|
||||
|
||||
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.
|
||||
|
||||
quests/
|
||||
├── Dockerfile # Base image
|
||||
├── entrypoint.sh # Entrypoint for the container
|
||||
├── discover
|
||||
│ └── game.yml
|
||||
├── helloworld
|
||||
@@ -81,17 +96,22 @@ In each quest folder, there is a `game.yml` file, which is the quest definition.
|
||||
└── home
|
||||
└── 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:
|
||||
|
||||
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.
|
||||
|
||||
### Mounting Host Directory
|
||||
### 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:
|
||||
|
||||
@@ -121,6 +141,10 @@ The default username and password is `alex` and `Alex1234`.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests:
|
||||
|
||||
yarn test:unit
|
||||
|
||||
Run Cypress locally, using the current development containers:
|
||||
|
||||
docker compose up -d
|
||||
@@ -145,7 +169,14 @@ Run Cypress in container, create brand-new containers along with it:
|
||||
|
||||
# Server side
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
yarn build:quests
|
||||
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:
|
||||
You can copy from `.env.sample`
|
||||
|
||||
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
|
||||
|
||||
SECRET_KEY=your-secret-key
|
||||
+16
-9
@@ -29,15 +29,25 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./logs:/app/logs
|
||||
- ./config:/app/config
|
||||
- ./quests:/app/quests:ro
|
||||
env_file:
|
||||
- .env
|
||||
- .env.prod
|
||||
environment:
|
||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASS}@db:27017/${MONGO_DB}
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
|
||||
- BACKEND_URL=http://backend:3000
|
||||
- BASE_URL=${BASE_URL}
|
||||
- HOST_PROJECT_ROOT=${PWD}
|
||||
- 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}
|
||||
|
||||
analytics:
|
||||
image: linuxodyssey/analytics
|
||||
@@ -52,8 +62,11 @@ services:
|
||||
- internal
|
||||
ports:
|
||||
- '3001:3001'
|
||||
env_file:
|
||||
- .env
|
||||
- .env.prod
|
||||
environment:
|
||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASS}@db:27017/${MONGO_DB}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
|
||||
@@ -72,14 +85,8 @@ services:
|
||||
- MONGO_INITDB_ROOT_USERNAME=admin
|
||||
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
|
||||
- MONGO_USER=${MONGO_USER}
|
||||
- MONGO_PASS=${MONGO_PASSWORD}
|
||||
- MONGO_PASS=${MONGO_PASS}
|
||||
- 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:
|
||||
image: linuxodyssey/quest-base
|
||||
|
||||
+6
-1
@@ -12,12 +12,17 @@ services:
|
||||
|
||||
base:
|
||||
image: linuxodyssey/quest-base
|
||||
networks:
|
||||
- players
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./quests/Dockerfile
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
networks:
|
||||
internal:
|
||||
players:
|
||||
name: linux-odyssey-players
|
||||
internal: true
|
||||
internal: false
|
||||
external: true
|
||||
+4
-3
@@ -32,15 +32,16 @@ down() {
|
||||
dump() {
|
||||
# Define service name and dump directory name
|
||||
SERVICE_NAME="db"
|
||||
COMPOSE_FILE="docker-compose.prod.yml"
|
||||
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
|
||||
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
|
||||
|
||||
# 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
|
||||
# Note: You will need to know the exact container name for this step
|
||||
|
||||
@@ -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!
|
||||
@@ -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
|
||||
+18
-12
@@ -1,26 +1,32 @@
|
||||
FROM alpine:3.18
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
LABEL maintainer="wancat@wancat.cc"
|
||||
|
||||
RUN apk add --no-cache \
|
||||
RUN apt-get update && apt-get install -y \
|
||||
vim \
|
||||
zsh \
|
||||
nodejs \
|
||||
yarn
|
||||
curl \
|
||||
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
|
||||
|
||||
COPY ./packages/container/package.json ./yarn.lock ./
|
||||
RUN yarn install --modules-folder /usr/src/node_modules
|
||||
ENV NODE_PATH=/usr/src/node_modules
|
||||
COPY ./packages/container /usr/local/lib/container
|
||||
COPY ./quests/.includes/zshrc /etc/zsh/zshrc
|
||||
RUN rm -f /etc/motd /etc/issue /etc/issue.net /etc/update-motd.d/*
|
||||
RUN rm -rf /etc/skel && mkdir -p /etc/skel
|
||||
RUN mkdir -p /etc/ssh/authorized_keys && chmod 755 /etc/ssh/authorized_keys
|
||||
COPY ./quests/.includes/sshd_config /etc/ssh/sshd_config
|
||||
COPY ./quests/entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENV USERNAME=commander
|
||||
EXPOSE 22
|
||||
|
||||
RUN adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME}
|
||||
|
||||
USER ${USERNAME}
|
||||
|
||||
WORKDIR /home/${USERNAME}
|
||||
CMD [ "/bin/zsh" ]
|
||||
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
COPY home ${HOME}
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
COPY home ${HOME}
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
USER root
|
||||
COPY home .
|
||||
RUN chown -R ${USERNAME}:${USERNAME} .
|
||||
|
||||
USER ${USERNAME}
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
COPY home ${HOME}
|
||||
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
USER root
|
||||
COPY home ${HOME}
|
||||
|
||||
USER commander
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
COPY home ${HOME}
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
COPY home ${HOME}
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
COPY --chown=${USERNAME} home ${HOME}
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
COPY home ${HOME}
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
COPY home ${HOME}
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
COPY --chown=${USERNAME} home ${HOME}
|
||||
RUN mkdir empty
|
||||
Whitespace-only changes.
@@ -1,3 +0,0 @@
|
||||
FROM linuxodyssey/quest-base
|
||||
|
||||
COPY home ${HOME}
|
||||
@@ -40,6 +40,7 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"socket.io": "^4.7.1",
|
||||
"ssh2": "^1.16.0",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"validator": "^13.11.0",
|
||||
"winston": "^3.11.0",
|
||||
@@ -61,6 +62,7 @@
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/passport-oauth2": "^1.4.15",
|
||||
"@types/source-map-support": "^0",
|
||||
"@types/ssh2": "^1.15.1",
|
||||
"@types/swagger-ui-express": "^4.1.6",
|
||||
"@types/validator": "^13.11.9",
|
||||
"concurrently": "^8.2.2",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Server } from 'socket.io'
|
||||
import type { Socket } from 'socket.io'
|
||||
import { Duplex } from 'stream'
|
||||
import validator from 'validator'
|
||||
import type { Server as HttpServer } from 'http'
|
||||
import { Session } from '@linux-odyssey/models'
|
||||
import { ISession, Session } from '@linux-odyssey/models'
|
||||
import { getAndStartContainer, attachContainer } from '../containers/docker.js'
|
||||
import SessionMiddleware from '../middleware/session.js'
|
||||
import { genJWT } from '../utils/auth.js'
|
||||
@@ -77,30 +78,34 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
|
||||
stream,
|
||||
}
|
||||
next()
|
||||
} catch {
|
||||
next(new Error('Failed to start container.'))
|
||||
} catch (err) {
|
||||
logger.error(err)
|
||||
next(new Error('Failed to connect to container.'))
|
||||
}
|
||||
}
|
||||
|
||||
function onConnect(socket: Socket) {
|
||||
const { session, stream } = socket.data.context
|
||||
stream.socket.on('data', (chunk: Buffer) => {
|
||||
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.socket.write(message)
|
||||
stream.write(message)
|
||||
})
|
||||
|
||||
const socketCallback = (event: string, ...data: any[]) => {
|
||||
socket.emit(event, ...data)
|
||||
}
|
||||
|
||||
listenToSession(session.id, socketCallback)
|
||||
listenToSession(session._id.toString(), socketCallback)
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
stream.socket.write('exit\n')
|
||||
removeFromSession(session.id, socketCallback)
|
||||
stream.write('exit\n')
|
||||
removeFromSession(session._id.toString(), socketCallback)
|
||||
stream.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
+18
-6
@@ -3,6 +3,7 @@ import dotenv from 'dotenv'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'path'
|
||||
import { get } from './utils/env.js'
|
||||
import { loadOrCreateKeyPair } from './utils/crypto.js'
|
||||
|
||||
// Import dotenv and load ../.env
|
||||
dotenv.config({ path: '../.env' })
|
||||
@@ -31,17 +32,25 @@ function getUrl(key: string): string {
|
||||
|
||||
function createConfig() {
|
||||
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 baseUrl = get('BASE_URL', `http://${host}:${port}`)
|
||||
const backendUrl = get('BACKEND_URL', `http://host.docker.internal:${port}`)
|
||||
const url = new URL(baseUrl)
|
||||
const projectRoot = getProjectRoot()
|
||||
const keypairPath = get(
|
||||
'KEYPAIR_PATH',
|
||||
path.join(projectRoot, 'config', 'ssh_key')
|
||||
)
|
||||
const keypair = loadOrCreateKeyPair(keypairPath)
|
||||
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
baseUrl,
|
||||
domain: url.hostname,
|
||||
protocol: url.protocol,
|
||||
backendUrl: get('BACKEND_URL', baseUrl),
|
||||
backendUrl,
|
||||
db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'),
|
||||
secret: get('SECRET_KEY', ''),
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
@@ -60,17 +69,20 @@ function createConfig() {
|
||||
surveyUrl: getUrl('SURVEY_URL'),
|
||||
bugReportUrl: getUrl('BUG_REPORT_URL'),
|
||||
|
||||
projectRoot,
|
||||
|
||||
docker: {
|
||||
network: get('DOCKER_NETWORK', 'host'),
|
||||
network: get('DOCKER_NETWORK', 'linux-odyssey-players'),
|
||||
defaultImage: get('QUEST_IMAGE', 'linuxodyssey/quest-base'),
|
||||
imagePrefix: get('DOCKER_PREFIX', 'linuxodyssey/quest-'),
|
||||
mountQuest: process.env.MOUNT_QUEST === 'true',
|
||||
hostProjectRoot: get('HOST_PROJECT_ROOT', projectRoot),
|
||||
keypairPath,
|
||||
keypair,
|
||||
},
|
||||
|
||||
projectRoot: getProjectRoot(),
|
||||
|
||||
log: {
|
||||
path: get('LOG_PATH', path.join(getProjectRoot(), 'logs')),
|
||||
path: get('LOG_PATH', path.join(projectRoot, 'logs')),
|
||||
},
|
||||
testing: {
|
||||
enabled: !isProduction && process.env.TESTING === 'true',
|
||||
|
||||
@@ -1,41 +1,77 @@
|
||||
import fs from 'fs/promises'
|
||||
import { Duplex } from 'stream'
|
||||
import Docker from 'dockerode'
|
||||
import config, { getQuestImage } from '../config.js'
|
||||
import logger from '../utils/logger.js'
|
||||
import { connectToSSH } from './ssh.js'
|
||||
|
||||
const engine = new Docker()
|
||||
|
||||
const containerOptions = {
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
OpenStdin: true,
|
||||
StdinOnce: false,
|
||||
HostConfig: {
|
||||
NetworkMode: config.docker.network,
|
||||
},
|
||||
} as Docker.ContainerCreateOptions
|
||||
|
||||
export function createContainer(
|
||||
const newContainerOptions = (
|
||||
name: string,
|
||||
imageId: string
|
||||
): Promise<Docker.Container> {
|
||||
const option = {
|
||||
...containerOptions,
|
||||
imageId: string,
|
||||
options: {
|
||||
binds?: string[]
|
||||
} = {}
|
||||
): Docker.ContainerCreateOptions => ({
|
||||
name,
|
||||
Image: getQuestImage(imageId),
|
||||
}
|
||||
HostConfig: {
|
||||
NetworkMode: config.docker.network,
|
||||
Binds: options.binds,
|
||||
ExtraHosts: ['host.docker.internal:host-gateway'],
|
||||
},
|
||||
ExposedPorts: {
|
||||
'22/tcp': {},
|
||||
},
|
||||
Env: [`PUB_KEY=${config.docker.keypair.publicKey}`],
|
||||
})
|
||||
|
||||
export async function createContainer(
|
||||
name: string,
|
||||
questId: string,
|
||||
imageId: string
|
||||
): Promise<Docker.Container> {
|
||||
let binds: string[] = []
|
||||
if (await questHomeExists(questId)) {
|
||||
logger.info('Mounting quest home', questId)
|
||||
binds.push(
|
||||
`${config.docker.hostProjectRoot}/quests/${questId}/home:/etc/skel:ro`
|
||||
)
|
||||
}
|
||||
if (!config.isProduction && config.docker.mountQuest && imageId !== 'base') {
|
||||
logger.info('Mounting quest folder', imageId)
|
||||
option.HostConfig!.Binds = [
|
||||
`${config.projectRoot}/quests/${imageId}/home:/home/commander`,
|
||||
`${config.projectRoot}/packages/container:/usr/local/lib/container`,
|
||||
logger.info('Mounting quest folder', questId)
|
||||
binds = [
|
||||
`${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`,
|
||||
`${config.docker.hostProjectRoot}/packages/container:/usr/local/lib/container`,
|
||||
]
|
||||
}
|
||||
const option = newContainerOptions(name, imageId, { binds })
|
||||
await createNetworkIfNotExists(config.docker.network)
|
||||
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(
|
||||
id: string
|
||||
): Promise<Docker.Container> {
|
||||
@@ -53,26 +89,25 @@ export async function getAndStartContainer(
|
||||
export async function attachContainer(
|
||||
container: Docker.Container,
|
||||
{ token }: { token: string }
|
||||
) {
|
||||
const exec = await container.exec({
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Cmd: ['/bin/zsh'],
|
||||
Tty: true,
|
||||
Env: [
|
||||
`TOKEN=${token}`,
|
||||
`API_ENDPOINT=${config.backendUrl}`,
|
||||
'ZDOTDIR=/etc/zsh',
|
||||
],
|
||||
})
|
||||
): Promise<Duplex> {
|
||||
const containerIp = (await container.inspect()).NetworkSettings.Networks[
|
||||
config.docker.network
|
||||
].IPAddress
|
||||
|
||||
const execOutput = await exec.start({
|
||||
Detach: false,
|
||||
Tty: true,
|
||||
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) {
|
||||
logger.error('Failed to connect to SSH', err)
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
|
||||
return execOutput
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to connect to SSH')
|
||||
}
|
||||
|
||||
export async function deleteContainer(id: string) {
|
||||
|
||||
@@ -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
@@ -72,7 +72,7 @@ async function main() {
|
||||
app.use('/api/v1', apiRouter)
|
||||
app.use(errorHandler)
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
server.listen(config.port, '0.0.0.0', () => {
|
||||
logger.info(`Server listening at ${config.baseUrl}`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function createNewSession(
|
||||
|
||||
const container = await createContainer(
|
||||
`quest-${quest.id}-${user.username}-${Date.now()}`,
|
||||
quest.id,
|
||||
quest.image
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -2067,6 +2067,13 @@
|
||||
dependencies:
|
||||
"@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":
|
||||
version "2.0.3"
|
||||
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"
|
||||
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:
|
||||
version "0.0.9"
|
||||
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"
|
||||
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:
|
||||
version "3.3.7"
|
||||
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz"
|
||||
@@ -8584,6 +8604,17 @@ ssh2@^1.11.0:
|
||||
cpu-features "~0.0.9"
|
||||
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:
|
||||
version "1.18.0"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -8649,6 +8680,15 @@ string-width@^1.0.1:
|
||||
is-fullwidth-code-point "^1.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:
|
||||
version "5.1.2"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -8723,6 +8763,13 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1, strip-ansi@~3.0.1:
|
||||
dependencies:
|
||||
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:
|
||||
version "7.1.0"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
@@ -9593,6 +9640,15 @@ wrap-ansi@^6.2.0:
|
||||
string-width "^4.1.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:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"
|
||||
|
||||
Reference in new issue
Block a user