Merge pull request #121 from dromara/dev

Dev
This commit is contained in:
李佳航
2025-07-09 16:32:33 +08:00
committed by GitHub
149 changed files with 4370 additions and 1591 deletions

View File

@@ -15,6 +15,8 @@ MYSQL_ROOT_PASSWORD=Data@123456
REDIS_HOST=redis
REDIS_PASSWORD=Data@123456
REDIS_DATABASE=0
REDIS_DATA_VERSION=1
GUACD_HOST=guacd
GUACD_PORT=4822

View File

@@ -3,127 +3,131 @@ name: Docker Publish
on:
push:
tags:
- 'v*' # Trigger on version tags like v1.0.0
workflow_dispatch: # Allow manual trigger
- 'v*'
workflow_dispatch:
jobs:
build-and-push:
build-project:
runs-on: ubuntu-latest
permissions:
contents: read # To read repository content
packages: write # To push packages to GitHub Container Registry
contents: read
packages: write
env:
DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_ORGNAME }}
steps:
- name: Checkout repository
- name: 🌱 Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
- name: ⚙️ Set up JDK 8
uses: actions/setup-java@v4
with:
java-version: '8'
distribution: 'temurin'
cache: 'maven'
- name: ⚙️ Set up Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
- name: 🔧 Install pnpm
run: npm i -g pnpm
- name: 📦 Build Java
run: mvn -U clean install -DskipTests
- name: 📦️ Build UI
working-directory: ./orion-visor-ui
run: |
pnpm install
pnpm build
- name: 📁 Prepare build context
run: |
cp -r ./sql ./docker/mysql/sql
cp -r ./orion-visor-ui/dist ./docker/ui/dist
cp ./orion-visor-launch/target/orion-visor-launch.jar ./docker/service/orion-visor-launch.jar
- name: 📤 Upload build context
uses: actions/upload-artifact@v4
with:
name: docker-context
path: docker
build-and-push:
needs: build-project
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
service: [ adminer, guacd, mysql, redis, service, ui ]
env:
GITHUB_REGISTRY: ghcr.io
ALIYUN_REGISTRY: registry.cn-hangzhou.aliyuncs.com
ALIYUN_NAMESPACE: ${{ vars.ALIYUN_NAMESPACE }}
DOCKERHUB_NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE }}
steps:
- name: 📥 Download build context
uses: actions/download-artifact@v4
with:
name: docker-context
path: docker
- name: ⚙️ Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
- name: 🔧 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
- name: 🐳 Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
- name: 🐳 Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta # Giving an ID to this step to reference its outputs later
- name: 🐳 Login to Aliyun Registry
uses: docker/login-action@v3
with:
registry: ${{ env.ALIYUN_REGISTRY }}
username: ${{ secrets.ALIYUN_USERNAME }}
password: ${{ secrets.ALIYUN_TOKEN }}
- name: 📦 Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: | # Define base image names for metadata generation
orion-visor-adminer
orion-visor-guacd
orion-visor-mysql
orion-visor-redis
orion-visor-service
orion-visor-ui
tags: | # Define how tags are generated
type=semver,pattern={{version}} # Main strategy: git tag v1.2.3 will produce tag 1.2.3
type=semver,pattern={{major}}.{{minor}} # e.g., v1.2.3 -> 1.2
type=semver,pattern={{major}} # e.g., v1.2.3 -> 1
images: |
${{ env.DOCKERHUB_NAMESPACE }}/orion-visor-${{ matrix.service }}
${{ env.GITHUB_REGISTRY }}/${{ github.repository_owner }}/orion-visor-${{ matrix.service }}
${{ env.ALIYUN_REGISTRY }}/${{ env.ALIYUN_NAMESPACE }}/orion-visor-${{ matrix.service }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# --- Build and push generic images ---
- name: Build and push orion-visor-adminer
- name: 🛠️ Build and push Docker image for orion-visor-${{ matrix.service }}
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/adminer/Dockerfile
context: ./docker
file: ./docker/${{ matrix.service }}/Dockerfile
push: true
tags: |
${{ env.DOCKERHUB_USERNAME }}/orion-visor-adminer:${{ steps.meta.outputs.version }}
ghcr.io/${{ github.repository_owner }}/orion-visor-adminer:${{ steps.meta.outputs.version }}
${{ env.DOCKERHUB_NAMESPACE }}/orion-visor-${{ matrix.service }}:${{ steps.meta.outputs.version }}
${{ env.DOCKERHUB_NAMESPACE }}/orion-visor-${{ matrix.service }}:latest
${{ env.GITHUB_REGISTRY }}/${{ github.repository_owner }}/orion-visor-${{ matrix.service }}:${{ steps.meta.outputs.version }}
${{ env.GITHUB_REGISTRY }}/${{ github.repository_owner }}/orion-visor-${{ matrix.service }}:latest
${{ env.ALIYUN_REGISTRY }}/${{ env.ALIYUN_NAMESPACE }}/orion-visor-${{ matrix.service }}:${{ steps.meta.outputs.version }}
${{ env.ALIYUN_REGISTRY }}/${{ env.ALIYUN_NAMESPACE }}/orion-visor-${{ matrix.service }}:latest
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
- name: Build and push orion-visor-guacd
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/guacd/Dockerfile
push: true
tags: |
${{ env.DOCKERHUB_USERNAME }}/orion-visor-guacd:${{ steps.meta.outputs.version }}
ghcr.io/${{ github.repository_owner }}/orion-visor-guacd:${{ steps.meta.outputs.version }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
- name: Build and push orion-visor-mysql
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/mysql/Dockerfile
push: true
tags: |
${{ env.DOCKERHUB_USERNAME }}/orion-visor-mysql:${{ steps.meta.outputs.version }}
ghcr.io/${{ github.repository_owner }}/orion-visor-mysql:${{ steps.meta.outputs.version }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
- name: Build and push orion-visor-redis
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/redis/Dockerfile
push: true
tags: |
${{ env.DOCKERHUB_USERNAME }}/orion-visor-redis:${{ steps.meta.outputs.version }}
ghcr.io/${{ github.repository_owner }}/orion-visor-redis:${{ steps.meta.outputs.version }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
- name: Build and push orion-visor-service
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/service/Dockerfile
push: true
tags: |
${{ env.DOCKERHUB_USERNAME }}/orion-visor-service:${{ steps.meta.outputs.version }}
ghcr.io/${{ github.repository_owner }}/orion-visor-service:${{ steps.meta.outputs.version }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
- name: Build and push orion-visor-ui
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/ui/Dockerfile
push: true
tags: |
${{ env.DOCKERHUB_USERNAME }}/orion-visor-ui:${{ steps.meta.outputs.version }}
ghcr.io/${{ github.repository_owner }}/orion-visor-ui:${{ steps.meta.outputs.version }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64 # Uncomment for multi-platform builds

View File

@@ -48,7 +48,7 @@
**`orion-visor`** 提供一站式自动化运维解决方案。
* **资产管理**:支持对资产进行分组,实现对主机、密钥和身份的统一管理和授权。
* **在线终端**:提供在线终端 SSH/RDP 等多种协议,支持快捷命令、自定义快捷键和主题风格。
* **在线终端**:提供在线终端 SSH/RDP/VNC 等多种协议,支持快捷命令、自定义快捷键和主题风格。
* **文件管理**:支持远程主机 SFTP 大文件的批量上传、下载和在线编辑等操作。
* **批量操作**:支持批量执行主机命令、多主机文件分发等功能。
* **计划任务**:支持配置 cron 表达式,定时执行主机命令。

View File

@@ -1,38 +0,0 @@
#/bin/bash
set -e
# ./build_docker.sh --push 这样使用会编译完成后自动推送镜像到阿里云仓库
version=2.4.1
push_images=false
# 解析参数
while [[ $# -gt 0 ]]; do
case "$1" in
--push)
push_images=true
shift
;;
*)
echo "未知参数: $1"
exit 1
;;
esac
done
docker build -f ./docker/ui/Dockerfile -t orion-visor-ui:${version} -t registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-ui:${version} . && \
docker build -f ./docker/service/Dockerfile -t orion-visor-service:${version} -t registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-service:${version} . && \
docker build -f ./docker/mysql/Dockerfile -t orion-visor-mysql:${version} -t registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-mysql:${version} . && \
docker build -f ./docker/redis/Dockerfile -t orion-visor-redis:${version} -t registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-redis:${version} . && \
docker build -f ./docker/adminer/Dockerfile -t orion-visor-adminer:${version} -t registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-adminer:${version} . && \
docker build -f ./docker/guacd/Dockerfile -t orion-visor-guacd:${version} -t registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-guacd:${version} .
# 如果需要推送镜像
if [ "$push_images" = true ]; then
docker push registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-adminer:${version}
docker push registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-mysql:${version}
docker push registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-redis:${version}
docker push registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-guacd:${version}
docker push registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-service:${version}
docker push registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-ui:${version}
fi

View File

@@ -1,6 +1,12 @@
version: '3.3'
# latest = 2.4.1
# latest = 2.4.2
# 支持以下源
# lijiahangmax/*
# ghcr.io/dromara/*
# registry.cn-hangzhou.aliyuncs.com/orionsec/*
services:
ui:
image: registry.cn-hangzhou.aliyuncs.com/orionsec/orion-visor-ui:latest
@@ -27,6 +33,8 @@ services:
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-Data@123456}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PASSWORD: ${REDIS_PASSWORD:-Data@123456}
REDIS_DATABASE: ${REDIS_DATABASE:-0}
REDIS_DATA_VERSION: ${REDIS_DATA_VERSION:-1}
GUACD_HOST: ${GUACD_HOST:-guacd}
GUACD_PORT: ${GUACD_PORT:-4822}
GUACD_DRIVE_PATH: ${GUACD_DRIVE_PATH:-/drive}

View File

@@ -1,16 +1,51 @@
#!/bin/bash
# 停止并移除现有容器
docker compose down --remove-orphans
# 初始化标志变量
PULL_IMAGES=false
DEMO_MODE=false
if [ "$1" == "demo" ]; then
# 设置 DEMO_MODE 环境变量为 true
# 解析命令行参数
for arg in "$@"
do
case $arg in
--pull)
PULL_IMAGES=true
shift
;;
--demo)
DEMO_MODE=true
shift
;;
*)
echo "Unknown argument: $arg"
exit 1
;;
esac
done
# 停止并移除现有容器
echo "Stopping all services..."
docker compose down --remove-orphans
echo "Stopped all services..."
# 拉取镜像
if [ "$PULL_IMAGES" = true ]; then
echo "Pulling latest images..."
docker compose pull
echo "Pulled latest images..."
fi
if [ "$DEMO_MODE" = true ]; then
# 启用 demo 模式
export DEMO_MODE=true
echo "Starting services for demo mode..."
# 启动指定的服务
docker compose up -d --remove-orphans mysql redis ui service adminer
docker compose up -d --remove-orphans mysql redis ui service guacd adminer
echo "Started services for demo mode..."
else
# 启动所有服务
echo "Starting all services..."
# 正常启动所有服务
docker compose up -d --remove-orphans
echo "Started all services..."
fi

View File

@@ -0,0 +1,16 @@
FROM maven:3.9.10-eclipse-temurin-8-alpine AS builder
# 设置阿里云镜像加速
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 拷贝 settings.xml
COPY ./docker/builder/maven-settings.xml /root/.m2/settings.xml
WORKDIR /build
COPY . .
# 复制 POM 文件先进行依赖下载 (利用 Docker 缓存)
RUN mvn dependency:go-offline --settings=/root/.m2/settings.xml
# 构建
RUN mvn clean package -DskipTests --settings=/root/.m2/settings.xml

View File

@@ -0,0 +1,25 @@
FROM node:18-alpine AS builder
# 设置阿里云镜像加速
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 安装 pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /build
# 设置 pnpm 使用指定的 registry
ARG REGISTRY_URL=https://registry.npmmirror.com
RUN pnpm config set registry $REGISTRY_URL
# 复制项目文件
COPY ./orion-visor-ui/package.json ./orion-visor-ui/pnpm-lock.yaml* ./
# 安装依赖 (利用 Docker 缓存层)
RUN pnpm install --frozen-lockfile
# 复制源代码
COPY ./orion-visor-ui/ .
# 构建项目
RUN pnpm build

View File

@@ -0,0 +1,54 @@
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<profiles>
<profile>
<id>repos</id>
<repositories>
<!-- 阿里云 Maven 公共仓库 -->
<repository>
<id>aliyun</id>
<name>Aliyun Repository</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
<!-- Maven 中央仓库 -->
<repository>
<id>central</id>
<name>Maven Central Repository</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>aliyun-plugin</id>
<url>https://maven.aliyun.com/repository/public</url>
</pluginRepository>
<pluginRepository>
<id>central-plugin</id>
<url>https://repo.maven.apache.org/maven2</url>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<!-- 激活 profile -->
<activeProfiles>
<activeProfile>repos</activeProfile>
</activeProfiles>
</settings>

207
docker/docker-build.sh Normal file
View File

@@ -0,0 +1,207 @@
#!/bin/bash
set -e
# DockerContext: orion-visor/docker
# 加载项目构建
source ./project-build.sh "$@"
# 版本号
version=2.4.2
# 是否推送镜像
push_image=false
# 是否构建 latest
latest_image=false
# 是否本地构建
locally_build=false
# 备份后缀
backup_suffix=".bak"
# 镜像命名空间
namespace="registry.cn-hangzhou.aliyuncs.com/orionsec"
# 解析命令行参数
while [[ $# -gt 0 ]]; do
case "$1" in
-l|--locally)
locally_build=true
shift
;;
-latest|--latest-image)
latest_image=true
shift
;;
-push|--push-image)
push_image=true
shift
;;
*)
shift
;;
esac
done
# 要处理的 Dockerfile 列表及对应的镜像名称
declare -A images=(
["./ui/Dockerfile"]="orion-visor-ui"
["./service/Dockerfile"]="orion-visor-service"
["./mysql/Dockerfile"]="orion-visor-mysql"
["./redis/Dockerfile"]="orion-visor-redis"
["./adminer/Dockerfile"]="orion-visor-adminer"
["./guacd/Dockerfile"]="orion-visor-guacd"
)
# 准备 service jar
function prepare_app_jar() {
local source_file="../orion-visor-launch/target/orion-visor-launch.jar"
local target_file="./service/orion-visor-launch.jar"
if [ ! -f "$target_file" ]; then
echo "警告: $target_file 不存在, 正在尝试从 $source_file 复制..."
if [ -f "$source_file" ]; then
cp "$source_file" "$target_file"
echo "已成功复制 $source_file$target_file"
else
echo "错误: $source_file 不存在, 无法继续构建."
exit 1
fi
else
echo "$target_file 已存在, 无需复制."
fi
}
# 准备前端 dist 目录
function prepare_dist_directory() {
local source_dir="../orion-visor-ui/dist"
local target_dir="./ui/dist"
if [ ! -d "$target_dir" ]; then
echo "警告: $target_dir 不存在, 正在尝试从 $source_dir 复制..."
if [ -d "$source_dir" ]; then
cp -r "$source_dir" "$target_dir"
echo "已成功复制 $source_dir$target_dir"
else
echo "错误: $source_dir 不存在, 无法继续构建."
exit 1
fi
else
echo "$target_dir 已存在, 无需复制."
fi
}
# 准备 mysql sql 目录
function prepare_sql_directory() {
local source_dir="../sql"
local target_dir="./mysql/sql"
if [ ! -d "$target_dir" ]; then
echo "警告: $target_dir 不存在, 正在尝试从 $source_dir 复制..."
if [ -d $source_dir ]; then
cp -r $source_dir "$target_dir"
echo "已成功复制 ../sql 至 $target_dir"
else
echo "错误: $source_dir 不存在!根据预期它应该存在, 请确认路径或项目结构是否正确"
exit 1
fi
else
echo "$target_dir 已存在, 无需复制."
fi
}
# 修改 Dockerfile 前的备份
function modify_dockerfiles() {
if [ "$locally_build" = false ]; then
echo "跳过 Dockerfile 修改"
return
fi
echo "正在备份并修改 Dockerfile..."
for file in "${!images[@]}"; do
if [ -f "$file" ]; then
echo "备份并修改: $file"
cp "$file" "$file$backup_suffix"
sed -i 's/--platform=\$BUILDPLATFORM//g' "$file"
else
echo "文件不存在 -> $file"
fi
done
}
# 恢复原始 Dockerfile
function restore_dockerfiles() {
if [ "$locally_build" = false ]; then
return
fi
echo "开始恢复 Dockerfile"
for file in "${!images[@]}"; do
if [ -f "$file$backup_suffix" ]; then
echo "恢复: $file"
rm -rf "$file"
mv "$file$backup_suffix" "$file"
fi
done
echo "Dockerfile 已恢复为原始版本"
}
# 构建镜像
function build_images() {
echo "构建镜像开始..."
for dockerfile in "${!images[@]}"; do
image_name="${images[$dockerfile]}"
echo "Building $image_name with version $version."
# 构建 Docker 镜像
docker build -f "$dockerfile" -t "${image_name}:${version}" -t "${namespace}/${image_name}:${version}" .
# 添加 latest 标签
if [ "$latest_image" = true ]; then
echo "Tag $image_name with latest version."
docker tag "${image_name}:${version}" "${image_name}:latest"
docker tag "${namespace}/${image_name}:${version}" "${namespace}/${image_name}:latest"
fi
done
echo "构建镜像结束..."
}
# 推送镜像
function push_image_to_registry() {
if [ "$push_image" = true ]; then
echo "推送镜像开始..."
for image_name in "${images[@]}"; do
# 推送版本
docker push "${namespace}/${image_name}:${version}"
# 推送 latest
if [ "latest_image" = true ]; then
docker push "${namespace}/${image_name}:latest"
fi
done
echo "推送镜像结束..."
fi
}
# 构建项目-service
if [ "$build_service" = true ]; then
run_build_service
fi
# 构建项目-ui
if [ "$build_ui" = true ]; then
run_build_ui
fi
# 检查资源
echo "正在检查并准备必要的构建资源..."
prepare_app_jar
prepare_dist_directory
prepare_sql_directory
echo "所有前置资源已准备完毕"
# 修改镜像文件
modify_dockerfiles
# 设置异常捕获, 确保失败时恢复 Dockerfile
trap 'restore_dockerfiles; echo "构建失败, 已恢复原始 Dockerfile"; exit 1' ERR INT
# 构建镜像
build_images
# 推送镜像
push_image_to_registry
# 恢复原始 Dockerfile
restore_dockerfiles
trap - ERR INT
echo "构建完成"

View File

@@ -1,10 +1,17 @@
FROM --platform=$BUILDPLATFORM guacamole/guacd:1.6.0
USER root
# 系统时区
ARG TZ=Asia/Shanghai
# 设置时区
RUN ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
echo '${TZ}' > /etc/timezone
# 添加包 & 设置时区
RUN \
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk update && \
apk add --no-cache tzdata && \
ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
echo "${TZ}" > /etc/timezone
# 创建所需目录
RUN mkdir -p /home/guacd/drive /usr/share/guacd/drive

View File

@@ -1,16 +1,14 @@
FROM --platform=$BUILDPLATFORM mysql:8.0.28
# 系统时区
ARG TZ=Asia/Shanghai
# 设置时区
RUN ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
echo '${TZ}' > /etc/timezone
# 复制配置
COPY ./docker/mysql/my.cnf /etc/mysql/conf.d/my.cnf
echo "${TZ}" > /etc/timezone
# 复制配置文件
COPY ./mysql/my.cnf /etc/mysql/conf.d/my.cnf
# 复制初始化脚本
COPY ./sql /tmp
# 设置初始化脚本
RUN cat /tmp/init-1-schema-databases.sql >> /tmp/init.sql && \
cat /tmp/init-2-schema-tables.sql >> /tmp/init.sql && \
cat /tmp/init-3-schema-quartz.sql >> /tmp/init.sql && \
cat /tmp/init-4-data.sql >> /tmp/init.sql && \
cp /tmp/init.sql /docker-entrypoint-initdb.d
COPY ./mysql/sql/init-*.sql /docker-entrypoint-initdb.d/

125
docker/project-build.sh Normal file
View File

@@ -0,0 +1,125 @@
#!/bin/bash
set -e
# DockerContext: orion-visor
# 版本号
version=2.4.2
# 是否构建 service
export build_service=false
# 是否构建 ui
export build_ui=false
# 解析命令行参数
for arg in "$@"; do
case "$arg" in
-service|--build-service)
export build_service=true
;;
-ui|--build-ui)
export build_ui=true
;;
esac
done
# 执行构建 service
function run_build_service() {
echo "开始执行 service 构建流程..."
local builder_dockerfile="./builder/Dockerfile.service"
local builder_image="orion-visor-service-builder"
local builder_container="orion-visor-service-builder-ctn"
local builder_output="/build/orion-visor-launch/target/orion-visor-launch.jar"
local target_dir="../orion-visor-launch/target"
local target_jar="$target_dir/orion-visor-launch.jar"
# 确保目标目录存在
if [ ! -d "$target_dir" ]; then
echo "创建目标目录: $target_dir"
mkdir -p "$target_dir"
else
# 如果 jar 已存在, 先删除
if [ -f "$target_jar" ]; then
echo "删除已有文件: $target_jar"
rm -f "$target_jar"
fi
fi
# 清理旧容器
local container_id=$(docker ps -a -f "name=$builder_container" --format "{{.ID}}")
if [ -n "$container_id" ]; then
echo "删除旧容器: $builder_container"
docker rm -f "$container_id"
fi
# 构建构建镜像
echo "正在构建 service builder image..."
docker build \
-f "$builder_dockerfile" \
-t "$builder_image:$version" ../
# 创建一个临时容器用于拷贝文件
echo "创建临时容器以提取 jar 文件..."
docker create --name "$builder_container" "$builder_image:$version" > /dev/null
# 拷贝构建好的 jar 文件到目标路径
echo "正在从容器中拷贝 jar 文件..."
docker cp "$builder_container:$builder_output" "$target_jar"
# 清理临时容器
docker rm -f "$builder_container" > /dev/null
echo "后端构建完成, jar 文件已保存至: $target_jar"
}
# 执行构建 ui
function run_build_ui() {
echo "开始执行 ui 构建流程..."
local builder_dockerfile="./builder/Dockerfile.ui"
local builder_image="orion-visor-ui-builder"
local builder_container="orion-visor-ui-builder-ctn"
local builder_output="/build/dist"
local target_dir="../orion-visor-ui/dist"
# 如果 dist 已存在, 先删除
if [ -d "$target_dir" ]; then
echo "删除已有目录: $target_dir"
rm -rf "$target_dir"
fi
# 清理旧容器
local container_id=$(docker ps -a -f "name=$builder_container" --format "{{.ID}}")
if [ -n "$container_id" ]; then
echo "删除旧容器: $builder_container"
docker rm -f "$container_id"
fi
# 构建前端镜像
echo "正在构建 ui builder image..."
docker build \
-f "$builder_dockerfile" \
-t "$builder_image:$version" ../
# 创建临时容器用于拷贝文件
echo "创建临时容器以提取 dist 文件..."
docker create --name "$builder_container" "$builder_image:$version" > /dev/null
# 拷贝 dist 目录
echo "正在从容器中拷贝 dist 文件..."
docker cp "$builder_container:$builder_output" "$target_dir"
# 清理临时容器
docker rm "$builder_container" > /dev/null
echo "前端构建完成, dist 已保存至: $target_dir"
}
# 构建项目-service
if [ "$build_service" = true ]; then
run_build_service
fi
# 构建项目-ui
if [ "$build_ui" = true ]; then
run_build_ui
fi

View File

@@ -1,15 +1,22 @@
FROM --platform=$BUILDPLATFORM redis:6.0.16-alpine
WORKDIR /data
# 系统时区
ARG TZ=Asia/Shanghai
# 添加包
# 添加包 & 设置时区
RUN \
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk update && \
apk add tzdata
# 设置时区
RUN ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
echo '${TZ}' > /etc/timezone
# redis 配置
COPY ./docker/redis/redis.conf /tmp
RUN cat /tmp/redis.conf > /usr/local/redis.conf
apk add --no-cache tzdata && \
ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
echo "${TZ}" > /etc/timezone && \
rm -rf /var/cache/apk/* && \
rm -f /usr/local/redis.conf
# 复制配置文件
COPY ./redis/redis.conf /usr/local/redis.conf
# 启动 Redis 并加载自定义配置
CMD ["redis-server", "/usr/local/redis.conf"]

View File

@@ -1,36 +1,24 @@
# 第一阶段Maven构建阶段
FROM --platform=$BUILDPLATFORM maven:3.9.10-eclipse-temurin-8-alpine AS builder
# 设置阿里云镜像加速
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 复制POM文件先进行依赖下载利用Docker缓存
WORKDIR /build
COPY . .
RUN mvn dependency:go-offline
# 构建
RUN mvn clean package -DskipTests
FROM --platform=$BUILDPLATFORM openjdk:8-jdk-alpine
USER root
WORKDIR /app
# 系统时区
ARG TZ=Asia/Shanghai
# 添加包
# 添加包 & 设置时区
RUN \
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk update && \
apk add curl && \
apk add udev && \
apk add tzdata && \
apk add dmidecode
# 设置时区
RUN ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
echo '${TZ}' > /etc/timezone
ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
echo "${TZ}" > /etc/timezone
# 从构建阶段复制jar包
COPY --from=builder /build/orion-visor-launch/target/orion-visor-launch.jar /app/app.jar
# 复制 jar
COPY ./service/orion-visor-launch.jar /app/app.jar
# 启动
CMD ["java", "-jar", "/app/app.jar"]
CMD ["java", "-jar", "/app/app.jar"]

View File

@@ -1,40 +1,23 @@
FROM --platform=$BUILDPLATFORM node:18-alpine AS builder
# 设置阿里云镜像加速
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 安装pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# 复制项目文件包括package.json等
COPY ./orion-visor-ui/package.json ./orion-visor-ui/pnpm-lock.yaml* ./
# 安装依赖利用Docker缓存层
RUN pnpm install --frozen-lockfile
# 复制源代码
COPY ./orion-visor-ui/ .
# 构建项目
RUN pnpm build
FROM --platform=$BUILDPLATFORM nginx:alpine
# 系统时区
ARG TZ=Asia/Shanghai
# 添加包
# 添加包 & 设置时区
RUN \
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk update && \
apk add tzdata
# 设置时区
RUN ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
echo '${TZ}' > /etc/timezone
# 删除原 nginx 配置
RUN rm -rf /etc/nginx/conf.d/*
apk add --no-cache tzdata && \
ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
echo "${TZ}" > /etc/timezone && \
rm -rf /var/cache/apk/* && \
rm -rf /etc/nginx/conf.d/*
# 复制包
COPY --from=builder /app/dist /usr/share/nginx/html
COPY ./docker/ui/nginx.conf /etc/nginx/conf.d
COPY ./ui/dist /usr/share/nginx/html
# 复制配置
COPY ./ui/nginx.conf /etc/nginx/conf.d
# 启动
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

5
docs/README.md Normal file
View File

@@ -0,0 +1,5 @@
## 文档已迁移至网页端
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn

View File

@@ -1,4 +1,4 @@
#/bin/bash
#!/bin/bash
git clean -df
git reset --hard HEAD
git pull

View File

@@ -36,7 +36,7 @@ public interface AppConst extends OrionConst {
/**
* 同 ${orion.version} 迭代时候需要手动更改
*/
String VERSION = "2.4.1";
String VERSION = "2.4.2";
/**
* 同 ${spring.application.name}

View File

@@ -51,9 +51,6 @@ public interface Const extends cn.orionsec.kit.lang.constant.Const, FieldConst,
String SYSTEM_USERNAME = "system";
// FIXME KIT
String ADMINISTRATOR = "Administrator";
Long ALL_HOST_ID = -1L;
int BATCH_COUNT = 500;

View File

@@ -22,16 +22,14 @@
*/
package org.dromara.visor.common.constant;
import cn.orionsec.kit.lang.constant.StandardHttpHeader;
/**
* http 请求头
* 自定义请求头
*
* @author Jiahang Li
* @version 1.0.0
* @since 2025/7/1 1:02
*/
public interface HttpHeaderConst extends StandardHttpHeader {
public interface CustomHeaderConst {
String APP_VERSION = "X-App-Version";

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2023 - present Dromara, All rights reserved.
*
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn
*
* Members:
* Jiahang Li - ljh1553488six@139.com - author
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dromara.visor.common.session.config;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
/**
* VNC 连接参数
*
* @author Jiahang Li
* @version 1.0.0
* @since 2025/4/1 16:57
*/
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@Schema(name = "VncConnectConfig", description = "VNC 连接参数")
public class VncConnectConfig extends BaseConnectConfig {
@Schema(description = "低带宽模式")
private Boolean lowBandwidthMode;
@Schema(description = "交换红蓝")
private Boolean swapRedBlue;
@Schema(description = "时区")
private String timezone;
@Schema(description = "剪切板编码")
private String clipboardEncoding;
}

View File

@@ -110,7 +110,10 @@ public class SessionStores {
}
}
// 超时时间
session.timeout(config.getTimeout());
Integer timeout = config.getTimeout();
if (timeout != null) {
session.timeout(timeout);
}
return session;
}

View File

@@ -14,11 +14,11 @@
<url>https://github.com/dromara/orion-visor</url>
<properties>
<revision>2.4.1</revision>
<revision>2.4.2</revision>
<spring.boot.version>2.7.17</spring.boot.version>
<spring.boot.admin.version>2.7.15</spring.boot.admin.version>
<flatten.maven.plugin.version>1.5.0</flatten.maven.plugin.version>
<orion.kit.version>2.0.1</orion.kit.version>
<orion.kit.version>2.0.2</orion.kit.version>
<aspectj.version>1.9.7</aspectj.version>
<lombok.version>1.18.26</lombok.version>
<springdoc.version>1.6.15</springdoc.version>

View File

@@ -37,7 +37,7 @@
<artifactId>netty-all</artifactId>
</dependency>
<!-- test redis noRedis -->
<!-- test redis MockRedis -->
<dependency>
<groupId>com.github.fppt</groupId>
<artifactId>jedis-mock</artifactId>

View File

@@ -38,17 +38,17 @@ import java.net.InetAddress;
import java.util.function.Supplier;
/**
* noRedis 配置
* MockRedis
* 仅用于本地调试无 redis 的情况
*
* @author Jiahang Li
* @version 1.0.0
* @since 2024/12/26 10:02
*/
@ConditionalOnProperty(value = "no.redis", havingValue = "true")
@ConditionalOnProperty(value = "spring.redis.mock", havingValue = "true")
@AutoConfiguration
@AutoConfigureOrder(AutoConfigureOrderConst.FRAMEWORK_REDIS - 10)
public class OrionNoRedisAutoConfiguration {
public class OrionMockRedisAutoConfiguration {
/**
* @return mocked redis server

View File

@@ -22,6 +22,7 @@
*/
package org.dromara.visor.framework.redis.configuration;
import cn.orionsec.kit.lang.define.cache.key.CacheKeyDefine;
import org.dromara.visor.common.constant.AutoConfigureOrderConst;
import org.dromara.visor.common.interfaces.Locker;
import org.dromara.visor.common.utils.LockerUtils;
@@ -31,6 +32,7 @@ import org.dromara.visor.framework.redis.core.utils.RedisUtils;
import org.redisson.api.RedissonClient;
import org.redisson.config.SingleServerConfig;
import org.redisson.spring.starter.RedissonAutoConfigurationCustomizer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureOrder;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -101,5 +103,15 @@ public class OrionRedisAutoConfiguration {
return redisLocker;
}
/**
* 设置 redis 数据版本
*
* @param dataVersion dataVersion
*/
@Value("${spring.redis.data-version}")
public void setDataVersion(String dataVersion) {
CacheKeyDefine.setGlobalPrefix("v" + dataVersion + ":");
}
}

View File

@@ -26,9 +26,15 @@
"defaultValue": "16"
},
{
"name": "no.redis",
"name": "spring.redis.data-version",
"type": "java.lang.String",
"description": "redis 数据版本.",
"defaultValue": "1"
},
{
"name": "spring.redis.mock",
"type": "java.lang.Boolean",
"description": "是否无 redis.",
"description": "是否使用 mock redis, 一般用于无 redis 调试时使用.",
"defaultValue": false
}
]

View File

@@ -1,3 +1,3 @@
org.dromara.visor.framework.redis.configuration.OrionNoRedisAutoConfiguration
org.dromara.visor.framework.redis.configuration.OrionMockRedisAutoConfiguration
org.dromara.visor.framework.redis.configuration.OrionRedisAutoConfiguration
org.dromara.visor.framework.redis.configuration.OrionCacheAutoConfiguration

View File

@@ -13,6 +13,9 @@ spring:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Data@123456}
database: ${REDIS_DATABASE:10}
data-version: ${REDIS_DATA_VERSION:1}
mock: false
redisson:
threads: 2
netty-threads: 2
@@ -40,6 +43,3 @@ mybatis-plus:
configuration:
# 日志打印
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
no:
redis: false

View File

@@ -24,6 +24,8 @@ spring:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Data@123456}
database: ${REDIS_DATABASE:0}
data-version: ${REDIS_DATA_VERSION:1}
redisson:
threads: 4
netty-threads: 4

View File

@@ -39,20 +39,15 @@ import java.util.function.Function;
*/
public class ReplaceVersion {
private static final String TARGET_VERSION = "2.4.0";
private static final String TARGET_VERSION = "2.4.1";
private static final String REPLACE_VERSION = "2.4.1";
private static final String REPLACE_VERSION = "2.4.2";
private static final String PATH = new File("").getAbsolutePath();
private static final String[] DOCKER_FILES = new String[]{
"docker/push.sh",
"docker/adminer/build.sh",
"docker/mysql/build.sh",
"docker/redis/build.sh",
"docker/guacd/build.sh",
"docker/service/build.sh",
"docker/ui/build.sh",
"docker/docker-build.sh",
"docker/project-build.sh",
"docker-compose.yml",
"docker-compose-testing.yml"
};

View File

@@ -24,6 +24,7 @@ package org.dromara.visor.module.asset.api;
import org.dromara.visor.common.session.config.RdpConnectConfig;
import org.dromara.visor.common.session.config.SshConnectConfig;
import org.dromara.visor.common.session.config.VncConnectConfig;
import org.dromara.visor.module.asset.entity.dto.host.HostDTO;
/**
@@ -87,4 +88,30 @@ public interface HostConnectApi {
*/
RdpConnectConfig getRdpConnectConfig(HostDTO host, Long userId);
/**
* 获取 VNC 连接配置
*
* @param hostId hostId
* @return session
*/
VncConnectConfig getVncConnectConfig(Long hostId);
/**
* 使用用户配置获取 VNC 连接配置
*
* @param hostId hostId
* @param userId userId
* @return session
*/
VncConnectConfig getVncConnectConfig(Long hostId, Long userId);
/**
* 使用用户配置获取 VNC 连接配置
*
* @param host host
* @param userId userId
* @return session
*/
VncConnectConfig getVncConnectConfig(HostDTO host, Long userId);
}

View File

@@ -71,7 +71,7 @@ public class HostSshConfigDTO implements GenericsDataModel, UpdatePasswordAction
private Long keyId;
@NotNull
@Min(value = 1)
@Min(value = 0)
@Max(value = 100000)
@Schema(description = "连接超时时间")
private Integer connectTimeout;

View File

@@ -0,0 +1,88 @@
/*
* Copyright (c) 2023 - present Dromara, All rights reserved.
*
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn
*
* Members:
* Jiahang Li - ljh1553488six@139.com - author
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dromara.visor.module.asset.entity.dto.host;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.dromara.visor.common.handler.data.model.GenericsDataModel;
import org.dromara.visor.common.security.UpdatePasswordAction;
import javax.validation.constraints.*;
/**
* 主机 VNC 配置
*
* @author Jiahang Li
* @version 1.0.0
* @since 2023/9/13 16:18
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(name = "HostVncConfigDTO", description = "主机 VNC 配置业务对象")
public class HostVncConfigDTO implements GenericsDataModel, UpdatePasswordAction {
@NotNull
@Min(value = 1)
@Max(value = 65535)
@Schema(description = "主机端口")
private Integer port;
@Size(max = 128)
@Schema(description = "用户名")
private String username;
@NotBlank
@Size(max = 12)
@Schema(description = "认证方式")
private String authType;
@Schema(description = "密码")
private String password;
@Schema(description = "身份id")
private Long identityId;
@Schema(description = "无用户名")
private Boolean noUsername;
@Schema(description = "无密码")
private Boolean noPassword;
@Schema(description = "时区")
private String timezone;
@Schema(description = "剪切板编码")
private String clipboardEncoding;
@Schema(description = "是否使用新密码 仅参数")
private Boolean useNewPassword;
@Schema(description = "是否已设置密码 仅返回")
private Boolean hasPassword;
}

View File

@@ -27,6 +27,7 @@ import lombok.AllArgsConstructor;
import org.dromara.visor.common.constant.Const;
import org.dromara.visor.module.asset.entity.dto.host.HostRdpConfigDTO;
import org.dromara.visor.module.asset.entity.dto.host.HostSshConfigDTO;
import org.dromara.visor.module.asset.entity.dto.host.HostVncConfigDTO;
import java.util.ArrayList;
import java.util.Arrays;
@@ -54,6 +55,11 @@ public enum HostTypeEnum {
*/
RDP(HostRdpConfigDTO.class),
/**
* VNC
*/
VNC(HostVncConfigDTO.class),
;
private final Class<?> clazz;

View File

@@ -25,6 +25,7 @@ package org.dromara.visor.module.asset.api.impl;
import lombok.extern.slf4j.Slf4j;
import org.dromara.visor.common.session.config.RdpConnectConfig;
import org.dromara.visor.common.session.config.SshConnectConfig;
import org.dromara.visor.common.session.config.VncConnectConfig;
import org.dromara.visor.module.asset.api.HostConnectApi;
import org.dromara.visor.module.asset.convert.HostProviderConvert;
import org.dromara.visor.module.asset.entity.dto.host.HostDTO;
@@ -77,4 +78,19 @@ public class HostConnectApiImpl implements HostConnectApi {
return hostConnectService.getRdpConnectConfig(HostProviderConvert.MAPPER.to(host), userId);
}
@Override
public VncConnectConfig getVncConnectConfig(Long hostId) {
return hostConnectService.getVncConnectConfig(hostId);
}
@Override
public VncConnectConfig getVncConnectConfig(Long hostId, Long userId) {
return hostConnectService.getVncConnectConfig(hostId, userId);
}
@Override
public VncConnectConfig getVncConnectConfig(HostDTO host, Long userId) {
return hostConnectService.getVncConnectConfig(HostProviderConvert.MAPPER.to(host), userId);
}
}

View File

@@ -49,6 +49,11 @@ public enum HostConfigStrategyEnum implements GenericsStrategyDefinition {
*/
RDP(HostRdpConfigStrategy.class),
/**
* VNC
*/
VNC(HostVncConfigStrategy.class),
;
private final Class<? extends GenericsDataStrategy<? extends GenericsDataModel>> strategyClass;

View File

@@ -0,0 +1,116 @@
/*
* Copyright (c) 2023 - present Dromara, All rights reserved.
*
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn
*
* Members:
* Jiahang Li - ljh1553488six@139.com - author
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dromara.visor.module.asset.handler.host.config;
import cn.orionsec.kit.lang.utils.Booleans;
import cn.orionsec.kit.lang.utils.Strings;
import org.dromara.visor.common.constant.Const;
import org.dromara.visor.common.constant.ErrorMessage;
import org.dromara.visor.common.utils.Valid;
import org.dromara.visor.module.asset.dao.HostIdentityDAO;
import org.dromara.visor.module.asset.entity.domain.HostIdentityDO;
import org.dromara.visor.module.asset.entity.dto.host.HostVncConfigDTO;
import org.dromara.visor.module.asset.enums.HostAuthTypeEnum;
import org.dromara.visor.module.asset.enums.HostIdentityTypeEnum;
import org.dromara.visor.module.asset.enums.HostTypeEnum;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 主机 VNC 配置策略
*
* @author Jiahang Li
* @version 1.0.0
* @since 2023/9/19 14:26
*/
@Component
public class HostVncConfigStrategy extends AbstractHostConfigStrategy<HostVncConfigDTO> {
@Resource
private HostIdentityDAO hostIdentityDAO;
public HostVncConfigStrategy() {
super(HostVncConfigDTO.class);
}
@Override
public HostVncConfigDTO getDefault() {
return HostVncConfigDTO.builder()
.port(5900)
.username(Const.ROOT)
.authType(HostAuthTypeEnum.PASSWORD.name())
.noUsername(false)
.noPassword(false)
.clipboardEncoding(Const.UTF_8)
.build();
}
@Override
protected void preValid(HostVncConfigDTO model) {
// 验证剪切板编码格式
String clipboardEncoding = model.getClipboardEncoding();
if (!Strings.isBlank(clipboardEncoding)) {
this.validCharset(clipboardEncoding);
}
// 检查主机身份是否存在
Long identityId = model.getIdentityId();
if (identityId != null) {
HostIdentityDO identity = Valid.notNull(hostIdentityDAO.selectById(identityId), ErrorMessage.IDENTITY_ABSENT);
Valid.eq(HostIdentityTypeEnum.PASSWORD.name(), identity.getType(), ErrorMessage.CHECK_IDENTITY_PASSWORD);
}
}
@Override
protected void valid(HostVncConfigDTO model) {
// 验证填充后的参数
Valid.valid(model);
}
@Override
protected void updateFill(HostVncConfigDTO beforeModel, HostVncConfigDTO afterModel) {
// 无密码设置认证方式为密码验证
if (Booleans.isTrue(afterModel.getNoPassword())) {
afterModel.setAuthType(HostAuthTypeEnum.PASSWORD.name());
}
// 加密密码
this.checkEncryptPassword(afterModel.getAuthType(), beforeModel, afterModel);
afterModel.setHasPassword(null);
afterModel.setUseNewPassword(null);
}
@Override
public HostVncConfigDTO parse(String serialModel) {
return HostTypeEnum.VNC.parse(serialModel);
}
@Override
public void toView(HostVncConfigDTO model) {
if (model == null) {
return;
}
model.setHasPassword(Strings.isNotBlank(model.getPassword()));
model.setPassword(null);
}
}

View File

@@ -27,10 +27,7 @@ import lombok.Getter;
import org.dromara.visor.common.handler.data.GenericsStrategyDefinition;
import org.dromara.visor.common.handler.data.model.GenericsDataModel;
import org.dromara.visor.common.handler.data.strategy.GenericsDataStrategy;
import org.dromara.visor.module.asset.handler.host.extra.strategy.HostLabelExtraStrategy;
import org.dromara.visor.module.asset.handler.host.extra.strategy.HostRdpExtraStrategy;
import org.dromara.visor.module.asset.handler.host.extra.strategy.HostSpecExtraStrategy;
import org.dromara.visor.module.asset.handler.host.extra.strategy.HostSshExtraStrategy;
import org.dromara.visor.module.asset.handler.host.extra.strategy.*;
/**
* 主机额外配置项策略枚举
@@ -58,6 +55,11 @@ public enum HostExtraItemEnum implements GenericsStrategyDefinition {
*/
RDP(HostRdpExtraStrategy.class, true),
/**
* VNC 额外配置
*/
VNC(HostVncExtraStrategy.class, true),
/**
* 规格信息配置
*/

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2023 - present Dromara, All rights reserved.
*
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn
*
* Members:
* Jiahang Li - ljh1553488six@139.com - author
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dromara.visor.module.asset.handler.host.extra.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.dromara.visor.common.handler.data.model.GenericsDataModel;
/**
* 主机拓展信息 - vnc 模型
*
* @author Jiahang Li
* @version 1.0.0
* @since 2023/12/20 21:36
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HostVncExtraModel implements GenericsDataModel {
/**
* 端口号
*/
private Integer port;
/**
* 低带宽模式
*/
private Boolean lowBandwidthMode;
/**
* 交换红蓝
*/
private Boolean swapRedBlue;
}

View File

@@ -59,6 +59,7 @@ public class HostRdpExtraStrategy extends AbstractGenericsDataStrategy<HostRdpEx
public HostRdpExtraModel getDefault() {
return HostRdpExtraModel.builder()
.authType(HostExtraAuthTypeEnum.DEFAULT.name())
.lowBandwidthMode(false)
.build();
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (c) 2023 - present Dromara, All rights reserved.
*
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn
*
* Members:
* Jiahang Li - ljh1553488six@139.com - author
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dromara.visor.module.asset.handler.host.extra.strategy;
import org.dromara.visor.common.handler.data.strategy.AbstractGenericsDataStrategy;
import org.dromara.visor.module.asset.handler.host.extra.model.HostVncExtraModel;
import org.springframework.stereotype.Component;
/**
* 主机拓展信息 - rdp 模型处理策略
*
* @author Jiahang Li
* @version 1.0.0
* @since 2023/12/20 22:17
*/
@Component
public class HostVncExtraStrategy extends AbstractGenericsDataStrategy<HostVncExtraModel> {
public HostVncExtraStrategy() {
super(HostVncExtraModel.class);
}
@Override
public HostVncExtraModel getDefault() {
return HostVncExtraModel.builder()
.lowBandwidthMode(false)
.swapRedBlue(false)
.build();
}
}

View File

@@ -24,6 +24,7 @@ package org.dromara.visor.module.asset.service;
import org.dromara.visor.common.session.config.RdpConnectConfig;
import org.dromara.visor.common.session.config.SshConnectConfig;
import org.dromara.visor.common.session.config.VncConnectConfig;
import org.dromara.visor.module.asset.entity.domain.HostDO;
import org.dromara.visor.module.asset.entity.request.host.HostTestConnectRequest;
@@ -95,4 +96,30 @@ public interface HostConnectService {
*/
RdpConnectConfig getRdpConnectConfig(HostDO host, Long userId);
/**
* 获取 VNC 连接配置
*
* @param hostId hostId
* @return session
*/
VncConnectConfig getVncConnectConfig(Long hostId);
/**
* 使用用户配置获取 VNC 连接配置
*
* @param hostId hostId
* @param userId userId
* @return session
*/
VncConnectConfig getVncConnectConfig(Long hostId, Long userId);
/**
* 使用用户配置获取 VNC 连接配置
*
* @param host host
* @param userId userId
* @return session
*/
VncConnectConfig getVncConnectConfig(HostDO host, Long userId);
}

View File

@@ -22,6 +22,7 @@
*/
package org.dromara.visor.module.asset.service.impl;
import cn.orionsec.kit.lang.utils.Booleans;
import cn.orionsec.kit.lang.utils.Exceptions;
import cn.orionsec.kit.lang.utils.io.Streams;
import cn.orionsec.kit.net.host.SessionStore;
@@ -30,6 +31,7 @@ import org.dromara.visor.common.constant.ErrorMessage;
import org.dromara.visor.common.session.config.BaseConnectConfig;
import org.dromara.visor.common.session.config.RdpConnectConfig;
import org.dromara.visor.common.session.config.SshConnectConfig;
import org.dromara.visor.common.session.config.VncConnectConfig;
import org.dromara.visor.common.session.ssh.SessionStores;
import org.dromara.visor.common.utils.Valid;
import org.dromara.visor.module.asset.dao.HostDAO;
@@ -40,6 +42,7 @@ import org.dromara.visor.module.asset.entity.domain.HostIdentityDO;
import org.dromara.visor.module.asset.entity.domain.HostKeyDO;
import org.dromara.visor.module.asset.entity.dto.host.HostRdpConfigDTO;
import org.dromara.visor.module.asset.entity.dto.host.HostSshConfigDTO;
import org.dromara.visor.module.asset.entity.dto.host.HostVncConfigDTO;
import org.dromara.visor.module.asset.entity.request.host.HostTestConnectRequest;
import org.dromara.visor.module.asset.enums.HostAuthTypeEnum;
import org.dromara.visor.module.asset.enums.HostExtraAuthTypeEnum;
@@ -48,6 +51,7 @@ import org.dromara.visor.module.asset.enums.HostTypeEnum;
import org.dromara.visor.module.asset.handler.host.extra.HostExtraItemEnum;
import org.dromara.visor.module.asset.handler.host.extra.model.HostRdpExtraModel;
import org.dromara.visor.module.asset.handler.host.extra.model.HostSshExtraModel;
import org.dromara.visor.module.asset.handler.host.extra.model.HostVncExtraModel;
import org.dromara.visor.module.asset.service.AssetAuthorizedDataService;
import org.dromara.visor.module.asset.service.HostConfigService;
import org.dromara.visor.module.asset.service.HostConnectService;
@@ -108,7 +112,6 @@ public class HostConnectServiceImpl implements HostConnectService {
Streams.close(sessionStore);
}
}
// TODO: 其他连接方式
}
@Override
@@ -189,6 +192,41 @@ public class HostConnectServiceImpl implements HostConnectService {
return this.getRdpConnectConfig(host, config, extra);
}
@Override
public VncConnectConfig getVncConnectConfig(Long hostId) {
log.info("HostConnectService.getVncConnectConfig-withHost hostId: {}", hostId);
// 查询主机
HostDO host = hostDAO.selectById(hostId);
// 查询主机配置
HostVncConfigDTO config = hostConfigService.getHostConfig(hostId, HostTypeEnum.VNC.name());
// 获取配置
return this.getVncConnectConfig(host, config, null);
}
@Override
public VncConnectConfig getVncConnectConfig(Long hostId, Long userId) {
// 查询主机
HostDO host = hostDAO.selectById(hostId);
Valid.notNull(host, ErrorMessage.HOST_ABSENT);
// 获取配置
return this.getVncConnectConfig(host, userId);
}
@Override
public VncConnectConfig getVncConnectConfig(HostDO host, Long userId) {
Long hostId = host.getId();
log.info("HostConnectService.getVncConnectConfig hostId: {}, userId: {}", hostId, userId);
// 验证权限
this.validHostAuthorized(userId, hostId);
// 获取主机配置
HostVncConfigDTO config = hostConfigService.getHostConfig(hostId, HostTypeEnum.VNC.name());
Valid.notNull(config, ErrorMessage.CONFIG_ABSENT);
// 查询主机额外配置
HostVncExtraModel extra = hostExtraService.getHostExtra(userId, hostId, HostExtraItemEnum.VNC);
// 获取连接配置
return this.getVncConnectConfig(host, config, extra);
}
/**
* 获取主机 SSH 连接配置
*
@@ -254,14 +292,7 @@ public class HostConnectServiceImpl implements HostConnectService {
connectConfig.setPassword(config.getPassword());
} else if (HostAuthTypeEnum.KEY.equals(authType)) {
// 密钥认证
Long keyId = config.getKeyId();
Valid.notNull(keyId, ErrorMessage.KEY_ABSENT);
HostKeyDO key = hostKeyDAO.selectById(keyId);
Valid.notNull(key, ErrorMessage.KEY_ABSENT);
connectConfig.setKeyId(keyId);
connectConfig.setPublicKey(key.getPublicKey());
connectConfig.setPrivateKey(key.getPrivateKey());
connectConfig.setPrivateKeyPassword(key.getPassword());
this.setSshKey(config.getKeyId(), connectConfig);
}
return connectConfig;
}
@@ -279,6 +310,8 @@ public class HostConnectServiceImpl implements HostConnectService {
// 填充认证信息
RdpConnectConfig connectConfig = RdpConnectConfig.builder()
.hostPort(config.getPort())
.username(config.getUsername())
.password(config.getPassword())
.versionGt81(config.getVersionGt81())
.timezone(config.getTimezone())
.keyboardLayout(config.getKeyboardLayout())
@@ -304,25 +337,54 @@ public class HostConnectServiceImpl implements HostConnectService {
config.setIdentityId(extra.getIdentityId());
}
}
// 身份认证
HostAuthTypeEnum authType = HostAuthTypeEnum.of(config.getAuthType());
if (HostAuthTypeEnum.IDENTITY.equals(authType)) {
// 身份认证 - 仅密码
authType = HostAuthTypeEnum.PASSWORD;
Valid.notNull(config.getIdentityId(), ErrorMessage.IDENTITY_ABSENT);
HostIdentityDO identity = hostIdentityDAO.selectById(config.getIdentityId());
Valid.notNull(identity, ErrorMessage.IDENTITY_ABSENT);
// 设置身份信息
config.setUsername(identity.getUsername());
config.setPassword(identity.getPassword());
// 填充身份认证信息
if (HostAuthTypeEnum.IDENTITY.name().equals(config.getAuthType())) {
this.setIdentityPasswordAuthorization(config.getIdentityId(), connectConfig);
}
return connectConfig;
}
/**
* 获取 VNC 连接信息
*
* @param host host
* @param config config
* @return info
*/
private VncConnectConfig getVncConnectConfig(HostDO host,
HostVncConfigDTO config,
HostVncExtraModel extra) {
// 填充认证信息
connectConfig.setUsername(config.getUsername());
if (HostAuthTypeEnum.PASSWORD.equals(authType)) {
// 密码认证
connectConfig.setPassword(config.getPassword());
VncConnectConfig connectConfig = VncConnectConfig.builder()
.hostPort(config.getPort())
.username(config.getUsername())
.password(config.getPassword())
.timezone(config.getTimezone())
.clipboardEncoding(config.getClipboardEncoding())
.build();
// 填充基础主机信息
this.setBaseConnectConfig(connectConfig, host);
if (extra != null) {
// 设置额外配置信息
connectConfig.setLowBandwidthMode(extra.getLowBandwidthMode());
connectConfig.setSwapRedBlue(extra.getSwapRedBlue());
// 设置自定义端口
Integer extraPort = extra.getPort();
if (extraPort != null) {
connectConfig.setHostPort(extraPort);
}
}
// 填充身份认证信息
if (HostAuthTypeEnum.IDENTITY.name().equals(config.getAuthType())) {
this.setIdentityPasswordAuthorization(config.getIdentityId(), connectConfig);
}
// 无用户名
if (Booleans.isTrue(config.getNoUsername())) {
connectConfig.setUsername(null);
}
// 无密码
if (Booleans.isTrue(config.getNoPassword())) {
connectConfig.setPassword(null);
}
return connectConfig;
}
@@ -366,6 +428,41 @@ public class HostConnectServiceImpl implements HostConnectService {
}
}
/**
* 设置密码认证
*
* @param identityId identityId
* @param connectConfig connectConfig
*/
private void setIdentityPasswordAuthorization(Long identityId, BaseConnectConfig connectConfig) {
if (identityId == null) {
return;
}
// 查询身份信息
HostIdentityDO identity = hostIdentityDAO.selectById(identityId);
Valid.notNull(identity, ErrorMessage.IDENTITY_ABSENT);
// 设置身份信息
connectConfig.setUsername(identity.getUsername());
connectConfig.setPassword(identity.getPassword());
}
/**
* 设置 SSH 密钥信息
*
* @param keyId keyId
* @param connectConfig connectConfig
*/
private void setSshKey(Long keyId, SshConnectConfig connectConfig) {
Valid.notNull(keyId, ErrorMessage.KEY_ABSENT);
// 查询密钥信息
HostKeyDO key = hostKeyDAO.selectById(keyId);
Valid.notNull(key, ErrorMessage.KEY_ABSENT);
connectConfig.setKeyId(keyId);
connectConfig.setPublicKey(key.getPublicKey());
connectConfig.setPrivateKey(key.getPrivateKey());
connectConfig.setPrivateKeyPassword(key.getPassword());
}
/**
* 设置基础主机信息
*

View File

@@ -22,11 +22,12 @@
*/
package org.dromara.visor.module.infra.controller;
import cn.orionsec.kit.web.servlet.web.Servlets;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.dromara.visor.common.constant.AppConst;
import org.dromara.visor.common.constant.HttpHeaderConst;
import org.dromara.visor.common.constant.CustomHeaderConst;
import org.dromara.visor.framework.log.core.annotation.IgnoreLog;
import org.dromara.visor.framework.log.core.enums.IgnoreLogMode;
import org.dromara.visor.framework.web.core.annotation.RestWrapper;
@@ -71,10 +72,8 @@ public class UserAggregateController {
@GetMapping("/user")
@Operation(summary = "获取用户权限聚合信息")
public UserAggregateVO getUserAggregateInfo(HttpServletResponse response) {
// FIXME KIT
// 设置版本号请求头
response.setHeader(HttpHeaderConst.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaderConst.APP_VERSION);
response.setHeader(HttpHeaderConst.APP_VERSION, AppConst.VERSION);
Servlets.addCustomHeader(response, CustomHeaderConst.APP_VERSION, AppConst.VERSION);
// 获取用户信息
return userAggregateService.getUserAggregateInfo();
}

View File

@@ -39,7 +39,7 @@ import java.util.concurrent.TimeUnit;
public interface PreferenceCacheKeyDefine {
CacheKeyDefine PREFERENCE = new CacheKeyBuilder()
.key("v1:user:prefer:{}:{}")
.key("user:prefer:{}:{}")
.desc("用户偏好 ${userId} ${type}")
.type(JSONObject.class)
.struct(RedisCacheStruct.STRING)

View File

@@ -95,6 +95,16 @@ public class TerminalPreferenceModel implements GenericsDataModel {
*/
private JSONObject rdpActionBarSetting;
/**
* vnc 图形化设置
*/
private JSONObject vncGraphSetting;
/**
* vnc 操作栏设置
*/
private JSONObject vncActionBarSetting;
/**
* 快捷键设置
*/
@@ -297,6 +307,11 @@ public class TerminalPreferenceModel implements GenericsDataModel {
*/
private Integer scrollBackLine;
/**
* 替换退格符
*/
private Boolean replaceBackspace;
}
@Data
@@ -421,6 +436,11 @@ public class TerminalPreferenceModel implements GenericsDataModel {
*/
private String position;
/**
* 会话信息
*/
private Boolean info;
/**
* 显示设置
*/
@@ -431,18 +451,33 @@ public class TerminalPreferenceModel implements GenericsDataModel {
*/
private Boolean combinationKey;
/**
* 长按键
*/
private Boolean triggerKey;
/**
* 剪切板
*/
private Boolean clipboard;
/**
* 上传
* RDP 上传
*/
private Boolean upload;
private Boolean rdpUpload;
/**
* 保存为 rdp 文件
* SFTP 上传
*/
private Boolean sftpUpload;
/**
* 打开 SFTP
*/
private Boolean openSftp;
/**
* 保存为 RDP 文件
*/
private Boolean saveRdp;
@@ -451,6 +486,11 @@ public class TerminalPreferenceModel implements GenericsDataModel {
*/
private Boolean disconnect;
/**
* 重新连接
*/
private Boolean reconnect;
/**
* 关闭
*/
@@ -481,6 +521,118 @@ public class TerminalPreferenceModel implements GenericsDataModel {
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class VncGraphSettingModel implements IJsonObject {
/**
* 显示大小
*/
private String displaySize;
/**
* 显示宽度
*/
private Integer displayWidth;
/**
* 显示高度
*/
private Integer displayHeight;
/**
* 颜色深度
*/
private Integer colorDepth;
/**
* 无损压缩
*/
private Boolean forceLossless;
/**
* 光标
*/
private String cursor;
/**
* 质量等级
*/
private Integer compressLevel;
/**
* 压缩等级
*/
private Integer qualityLevel;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class VncActionBarSettingModel implements IJsonObject {
/**
* 位置
*/
private String position;
/**
* 会话信息
*/
private Boolean info;
/**
* 显示设置
*/
private Boolean display;
/**
* 组合键
*/
private Boolean combinationKey;
/**
* 长按键
*/
private Boolean triggerKey;
/**
* 剪切板
*/
private Boolean clipboard;
/**
* SFTP 上传
*/
private Boolean sftpUpload;
/**
* 打开 SFTP
*/
private Boolean openSftp;
/**
* 断开连接
*/
private Boolean disconnect;
/**
* 重新连接
*/
private Boolean reconnect;
/**
* 关闭
*/
private Boolean close;
}
@Data
@Builder
@NoArgsConstructor

View File

@@ -68,6 +68,10 @@ public class TerminalPreferenceStrategy extends AbstractGenericsDataStrategy<Ter
.rdpActionBarSetting(JSONObject.parseObject(this.getDefaultRdpActionBarSetting()))
// rdp 会话设置
.rdpSessionSetting(JSONObject.parseObject(this.getDefaultRdpSessionSetting()))
// vnc 图形化设置
.vncGraphSetting(JSONObject.parseObject(this.getDefaultVncGraphSetting()))
// vnc 图形化设置
.vncActionBarSetting(JSONObject.parseObject(this.getDefaultVncSessionSetting()))
// 快捷键设置
.shortcutSetting(JSONObject.parseObject(this.getDefaultShortcutSetting()))
.build();
@@ -148,6 +152,7 @@ public class TerminalPreferenceStrategy extends AbstractGenericsDataStrategy<Ter
.wordSeparator("/\\()\"'` -.,:;<>~!@#$%^&*|+=[]{}~?│")
.terminalEmulationType(TerminalType.XTERM.getType())
.scrollBackLine(1000)
.replaceBackspace(false)
.build()
.toJsonString();
}
@@ -201,12 +206,17 @@ public class TerminalPreferenceStrategy extends AbstractGenericsDataStrategy<Ter
private String getDefaultRdpActionBarSetting() {
return TerminalPreferenceModel.RdpActionBarSettingModel.builder()
.position("top")
.info(true)
.display(true)
.combinationKey(true)
.triggerKey(false)
.clipboard(true)
.upload(true)
.saveRdp(true)
.rdpUpload(true)
.sftpUpload(false)
.openSftp(false)
.saveRdp(false)
.disconnect(true)
.reconnect(false)
.close(true)
.build()
.toJsonString();
@@ -226,6 +236,47 @@ public class TerminalPreferenceStrategy extends AbstractGenericsDataStrategy<Ter
.toJsonString();
}
/**
* 获取 vnc 图形化默认设置
*
* @return setting
*/
private String getDefaultVncGraphSetting() {
return TerminalPreferenceModel.VncGraphSettingModel.builder()
.displaySize("fit")
.displayWidth(0)
.displayHeight(0)
.colorDepth(24)
.forceLossless(true)
.cursor("local")
.compressLevel(5)
.qualityLevel(5)
.build()
.toJsonString();
}
/**
* 获取 vnc 工具栏默认设置
*
* @return setting
*/
private String getDefaultVncSessionSetting() {
return TerminalPreferenceModel.VncActionBarSettingModel.builder()
.position("top")
.info(true)
.display(true)
.combinationKey(true)
.triggerKey(false)
.clipboard(true)
.sftpUpload(true)
.openSftp(true)
.disconnect(true)
.reconnect(false)
.close(true)
.build()
.toJsonString();
}
/**
* 获取默认快捷键设置
*

View File

@@ -60,7 +60,7 @@
)
WHERE deleted = 0
AND type = 'HOST'
AND item IN ('SSH', 'RDP')
AND item IN ('SSH', 'RDP', 'VNC')
<foreach collection="identityIdList" item="item" separator="OR" open="AND (" close=")">
JSON_CONTAINS(value, JSON_OBJECT('identityId', #{item}))
</foreach>

View File

@@ -25,6 +25,7 @@ package org.dromara.visor.module.terminal.configuration;
import org.dromara.visor.module.terminal.handler.terminal.TerminalAccessRdpHandler;
import org.dromara.visor.module.terminal.handler.terminal.TerminalAccessSftpHandler;
import org.dromara.visor.module.terminal.handler.terminal.TerminalAccessSshHandler;
import org.dromara.visor.module.terminal.handler.terminal.TerminalAccessVncHandler;
import org.dromara.visor.module.terminal.handler.transfer.TransferMessageDispatcher;
import org.dromara.visor.module.terminal.interceptor.TerminalAccessInterceptor;
import org.dromara.visor.module.terminal.interceptor.TerminalTransferInterceptor;
@@ -63,6 +64,9 @@ public class TerminalWebSocketConfiguration implements WebSocketConfigurer {
@Resource
private TerminalAccessRdpHandler terminalAccessRdpHandler;
@Resource
private TerminalAccessVncHandler terminalAccessVncHandler;
@Resource
private TransferMessageDispatcher transferMessageDispatcher;
@@ -80,6 +84,10 @@ public class TerminalWebSocketConfiguration implements WebSocketConfigurer {
registry.addHandler(terminalAccessRdpHandler, prefix + "/terminal/access/rdp/{accessToken}")
.addInterceptors(terminalAccessInterceptor)
.setAllowedOrigins("*");
// VNC 终端会话
registry.addHandler(terminalAccessVncHandler, prefix + "/terminal/access/vnc/{accessToken}")
.addInterceptors(terminalAccessInterceptor)
.setAllowedOrigins("*");
// 文件传输
registry.addHandler(transferMessageDispatcher, prefix + "/terminal/transfer/{transferToken}")
.addInterceptors(terminalTransferInterceptor)

View File

@@ -24,9 +24,11 @@ package org.dromara.visor.module.terminal.convert;
import org.dromara.visor.common.session.config.RdpConnectConfig;
import org.dromara.visor.common.session.config.SshConnectConfig;
import org.dromara.visor.common.session.config.VncConnectConfig;
import org.dromara.visor.module.terminal.handler.terminal.model.config.TerminalSessionRdpConfig;
import org.dromara.visor.module.terminal.handler.terminal.model.config.TerminalSessionSftpConfig;
import org.dromara.visor.module.terminal.handler.terminal.model.config.TerminalSessionSshConfig;
import org.dromara.visor.module.terminal.handler.terminal.model.config.TerminalSessionVncConfig;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
@@ -48,4 +50,6 @@ public interface TerminalSessionConvert {
TerminalSessionRdpConfig toRdp(RdpConnectConfig domain);
TerminalSessionVncConfig toVnc(VncConnectConfig domain);
}

View File

@@ -89,7 +89,6 @@ public class GuacdTunnel implements IGuacdTunnel {
@Override
public void connect() throws GuacdException {
try {
// TODO 端口转发
this.socket = new ConfiguredGuacamoleSocket(new InetGuacamoleSocket(serverAddress, serverPort), serverConfig, clientConfig);
this.tunnel = new CustomGuacamoleTunnel(uuid, socket);
} catch (GuacamoleException e) {
@@ -192,12 +191,16 @@ public class GuacdTunnel implements IGuacdTunnel {
}
@Override
public void size(int width, int height, int dpi) {
public void size(int width, int height) {
clientConfig.setOptimalScreenWidth(width);
clientConfig.setOptimalScreenHeight(height);
clientConfig.setOptimalResolution(dpi);
this.setParameter(GuacdConst.WIDTH, width);
this.setParameter(GuacdConst.HEIGHT, height);
}
@Override
public void dpi(int dpi) {
clientConfig.setOptimalResolution(dpi);
this.setParameter(GuacdConst.DPI, dpi);
}

View File

@@ -64,9 +64,15 @@ public interface IGuacdTunnel extends Runnable, Executable, SafeCloseable {
*
* @param width width
* @param height height
* @param dpi dpi
*/
void size(int width, int height, int dpi);
void size(int width, int height);
/**
* dpi
*
* @param dpi dpi
*/
void dpi(int dpi);
/**
* 设置时区

View File

@@ -392,6 +392,16 @@ public interface GuacdConst {
*/
String CLIPBOARD_ENCODING = "clipboard-encoding";
/**
* 压缩等级
*/
String COMPRESS_LEVEL = "compress-level";
/**
* 质量等级
*/
String QUALITY_LEVEL = "quality-level";
// -------------------- const --------------------
String RESIZE_METHOD_DISPLAY_UPDATE = "display-update";

View File

@@ -0,0 +1,66 @@
/*
* Copyright (c) 2023 - present Dromara, All rights reserved.
*
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn
*
* Members:
* Jiahang Li - ljh1553488six@139.com - author
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dromara.visor.module.terminal.handler.terminal;
import lombok.extern.slf4j.Slf4j;
import org.dromara.visor.module.terminal.handler.terminal.enums.InputProtocolEnum;
import org.dromara.visor.module.terminal.handler.terminal.model.TerminalChannelProps;
import org.dromara.visor.module.terminal.handler.terminal.sender.IGuacdTerminalSender;
import org.dromara.visor.module.terminal.handler.terminal.sender.WebsocketGuacdTerminalSender;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
/**
* VNC 终端处理器
*
* @author Jiahang Li
* @version 1.0.0
* @since 2023/12/28 14:33
*/
@Slf4j
@Component
public class TerminalAccessVncHandler extends AbstractTerminalAccessHandler<IGuacdTerminalSender> {
@Override
protected IGuacdTerminalSender createSender(WebSocketSession channel) {
return new WebsocketGuacdTerminalSender(channel);
}
@Override
protected void handleMessage(WebSocketSession channel, TextMessage message, TerminalChannelProps props, IGuacdTerminalSender sender) {
String payload = message.getPayload();
try {
// 解析类型
InputProtocolEnum type = InputProtocolEnum.of(payload);
if (type == null) {
return;
}
// 解析并处理消息
type.getHandler().handle(props, sender, type.parse(payload));
} catch (Exception e) {
log.error("TerminalAccessVncHandler-handleMessage-error id: {}, msg: {}", channel.getId(), payload, e);
}
}
}

View File

@@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import org.dromara.visor.module.terminal.handler.terminal.model.TerminalChannelProps;
import org.dromara.visor.module.terminal.handler.terminal.model.request.GuacdInstructionRequest;
import org.dromara.visor.module.terminal.handler.terminal.sender.IGuacdTerminalSender;
import org.dromara.visor.module.terminal.handler.terminal.session.IRdpSession;
import org.dromara.visor.module.terminal.handler.terminal.session.IGuacdSession;
import org.dromara.visor.module.terminal.handler.terminal.session.ITerminalSession;
import org.springframework.stereotype.Component;
@@ -45,20 +45,10 @@ public class GuacdInstructionHandler extends AbstractTerminalHandler<IGuacdTermi
public void handle(TerminalChannelProps props, IGuacdTerminalSender sender, GuacdInstructionRequest payload) {
// 获取会话
ITerminalSession session = terminalManager.getSession(props.getId());
if (session instanceof IRdpSession) {
// 处理 rdp 指令
this.processRdpInstruction((IRdpSession) session, payload.getInstruction());
// 发送指令
if (session instanceof IGuacdSession) {
((IGuacdSession) session).write(payload.getInstruction());
}
}
/**
* 处理 rdp 指令
*
* @param session session
* @param instruction instruction
*/
private void processRdpInstruction(IRdpSession session, String instruction) {
session.write(instruction);
}
}

View File

@@ -35,6 +35,7 @@ import org.dromara.visor.common.constant.ExtraFieldConst;
import org.dromara.visor.common.session.config.BaseConnectConfig;
import org.dromara.visor.common.session.config.RdpConnectConfig;
import org.dromara.visor.common.session.config.SshConnectConfig;
import org.dromara.visor.common.session.config.VncConnectConfig;
import org.dromara.visor.common.session.ssh.SessionStores;
import org.dromara.visor.framework.biz.operator.log.core.model.OperatorLogModel;
import org.dromara.visor.framework.biz.operator.log.core.utils.OperatorLogs;
@@ -52,10 +53,7 @@ import org.dromara.visor.module.terminal.handler.terminal.constant.SessionCloseC
import org.dromara.visor.module.terminal.handler.terminal.constant.TerminalMessage;
import org.dromara.visor.module.terminal.handler.terminal.model.TerminalChannelExtra;
import org.dromara.visor.module.terminal.handler.terminal.model.TerminalChannelProps;
import org.dromara.visor.module.terminal.handler.terminal.model.config.ITerminalSessionConfig;
import org.dromara.visor.module.terminal.handler.terminal.model.config.TerminalSessionRdpConfig;
import org.dromara.visor.module.terminal.handler.terminal.model.config.TerminalSessionSftpConfig;
import org.dromara.visor.module.terminal.handler.terminal.model.config.TerminalSessionSshConfig;
import org.dromara.visor.module.terminal.handler.terminal.model.config.*;
import org.dromara.visor.module.terminal.handler.terminal.model.request.TerminalConnectRequest;
import org.dromara.visor.module.terminal.handler.terminal.model.transport.TerminalConnectBody;
import org.dromara.visor.module.terminal.handler.terminal.model.transport.TerminalSetInfo;
@@ -63,10 +61,7 @@ import org.dromara.visor.module.terminal.handler.terminal.sender.IGuacdTerminalS
import org.dromara.visor.module.terminal.handler.terminal.sender.ISftpTerminalSender;
import org.dromara.visor.module.terminal.handler.terminal.sender.ISshTerminalSender;
import org.dromara.visor.module.terminal.handler.terminal.sender.ITerminalSender;
import org.dromara.visor.module.terminal.handler.terminal.session.ITerminalSession;
import org.dromara.visor.module.terminal.handler.terminal.session.RdpSession;
import org.dromara.visor.module.terminal.handler.terminal.session.SftpSession;
import org.dromara.visor.module.terminal.handler.terminal.session.SshSession;
import org.dromara.visor.module.terminal.handler.terminal.session.*;
import org.dromara.visor.module.terminal.service.TerminalConnectLogService;
import org.springframework.stereotype.Component;
@@ -238,6 +233,12 @@ public class TerminalConnectHandler extends AbstractTerminalHandler<ITerminalSen
config.setDpi(connectParams.getDpi());
this.setBaseSessionConfig(config, connectLog, connectParams);
session = new RdpSession(props, (IGuacdTerminalSender) sender, config, guacdConfig);
} else if (TerminalConnectTypeEnum.VNC.name().equals(connectType)) {
// 打开 vnc 会话
TerminalSessionVncConfig config = TerminalSessionConvert.MAPPER.toVnc((VncConnectConfig) connectConfig);
config.setDpi(connectParams.getDpi());
this.setBaseSessionConfig(config, connectLog, connectParams);
session = new VncSession(props, (IGuacdTerminalSender) sender, config, guacdConfig);
} else {
throw Exceptions.unsupported();
}

View File

@@ -26,8 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import org.dromara.visor.module.terminal.handler.terminal.model.TerminalChannelProps;
import org.dromara.visor.module.terminal.handler.terminal.model.request.TerminalResizeRequest;
import org.dromara.visor.module.terminal.handler.terminal.sender.ITerminalSender;
import org.dromara.visor.module.terminal.handler.terminal.session.IRdpSession;
import org.dromara.visor.module.terminal.handler.terminal.session.ISshSession;
import org.dromara.visor.module.terminal.handler.terminal.session.IResizeableSession;
import org.dromara.visor.module.terminal.handler.terminal.session.ITerminalSession;
import org.springframework.stereotype.Component;
@@ -44,16 +43,10 @@ public class TerminalResizeHandler extends AbstractTerminalHandler<ITerminalSend
@Override
public void handle(TerminalChannelProps props, ITerminalSender sender, TerminalResizeRequest payload) {
Integer width = payload.getWidth();
Integer height = payload.getHeight();
// 获取会话
ITerminalSession session = terminalManager.getSession(props.getId());
if (session instanceof ISshSession) {
// SSH
((ISshSession) session).resize(width, height);
} else if (session instanceof IRdpSession) {
// RDP
((IRdpSession) session).resize(width, height);
if (session instanceof IResizeableSession) {
((IResizeableSession) session).resize(payload.getWidth(), payload.getHeight());
}
}

View File

@@ -138,4 +138,19 @@ public class TerminalChannelExtra {
// -------------------- vnc --------------------
/**
* 光标
*/
private String cursor;
/**
* 压缩等级
*/
private Integer compressLevel;
/**
* 质量等级
*/
private Integer qualityLevel;
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2023 - present Dromara, All rights reserved.
*
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn
*
* Members:
* Jiahang Li - ljh1553488six@139.com - author
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dromara.visor.module.terminal.handler.terminal.model.config;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import org.dromara.visor.common.session.config.VncConnectConfig;
/**
* 终端会话配置 VNC
*
* @author Jiahang Li
* @version 1.0.0
* @since 2025/6/24 17:08
*/
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class TerminalSessionVncConfig extends VncConnectConfig implements ITerminalSessionConfig {
@Schema(description = "logId")
private Long logId;
@Schema(description = "")
private Integer width;
@Schema(description = "")
private Integer height;
@Schema(description = "dpi")
private Integer dpi;
}

View File

@@ -23,9 +23,11 @@
package org.dromara.visor.module.terminal.handler.terminal.session;
import cn.orionsec.kit.lang.utils.Exceptions;
import org.dromara.visor.common.utils.AesEncryptUtils;
import org.dromara.visor.module.terminal.define.TerminalThreadPools;
import org.dromara.visor.module.terminal.handler.guacd.IGuacdTunnel;
import org.dromara.visor.module.terminal.handler.terminal.constant.TerminalMessage;
import org.dromara.visor.module.terminal.handler.terminal.model.TerminalChannelExtra;
import org.dromara.visor.module.terminal.handler.terminal.model.TerminalChannelProps;
import org.dromara.visor.module.terminal.handler.terminal.model.config.ITerminalSessionConfig;
import org.dromara.visor.module.terminal.handler.terminal.sender.IGuacdTerminalSender;
@@ -70,10 +72,51 @@ public abstract class AbstractGuacdSession<C extends ITerminalSessionConfig>
*/
protected abstract IGuacdTunnel createTunnel();
/**
* 是否为低带宽模式
*
* @return is
*/
protected abstract boolean isLowBandwidthMode();
/**
* 设置 tunnel 参数
*/
protected abstract void setTunnelParams();
protected void setTunnelParams() {
// 设置低带宽模式
if (this.isLowBandwidthMode()) {
this.setLowBandwidthMode();
}
// 主机信息
tunnel.remote(config.getHostAddress(), config.getHostPort());
// 身份信息
tunnel.auth(config.getUsername(), AesEncryptUtils.decryptAsString(config.getPassword()));
// 大小
tunnel.size(config.getWidth(), config.getHeight());
}
/**
* 设置低带宽模式
*/
protected void setLowBandwidthMode() {
TerminalChannelExtra extra = props.getExtra();
extra.setColorDepth(8);
extra.setForceLossless(false);
extra.setEnableWallpaper(false);
extra.setEnableTheming(false);
extra.setEnableFontSmoothing(false);
extra.setEnableFullWindowDrag(false);
extra.setEnableDesktopComposition(false);
extra.setEnableMenuAnimations(false);
extra.setDisableBitmapCaching(false);
extra.setDisableOffscreenCaching(false);
extra.setDisableGlyphCaching(false);
extra.setDisableGfx(false);
extra.setEnableAudioInput(false);
extra.setEnableAudioOutput(false);
extra.setCompressLevel(9);
extra.setQualityLevel(1);
}
/**
* 执行连接
@@ -92,6 +135,13 @@ public abstract class AbstractGuacdSession<C extends ITerminalSessionConfig>
tunnel.write(data);
}
@Override
public void resize(int width, int height) {
config.setWidth(width);
config.setHeight(height);
tunnel.writeInstruction("size", String.valueOf(width), String.valueOf(height));
}
@Override
public void keepAlive() {
// guacd 有内部实现

View File

@@ -29,7 +29,7 @@ package org.dromara.visor.module.terminal.handler.terminal.session;
* @version 1.0.0
* @since 2025/3/30 17:42
*/
public interface IGuacdSession extends ITerminalSession {
public interface IGuacdSession extends ITerminalSession, IResizeableSession {
/**
* 写入数据

View File

@@ -30,13 +30,4 @@ package org.dromara.visor.module.terminal.handler.terminal.session;
* @since 2025/3/30 17:42
*/
public interface IRdpSession extends IGuacdSession {
/**
* 重置大小
*
* @param width width
* @param height height
*/
void resize(int width, int height);
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 - present Dromara, All rights reserved.
*
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn
*
* Members:
* Jiahang Li - ljh1553488six@139.com - author
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dromara.visor.module.terminal.handler.terminal.session;
/**
* 可修改大小的会话
*
* @author Jiahang Li
* @version 1.0.0
* @since 2025/7/3 2:16
*/
public interface IResizeableSession {
/**
* 修改大小
*
* @param width width
* @param height height
*/
void resize(int width, int height);
}

View File

@@ -32,15 +32,7 @@ import org.dromara.visor.module.terminal.handler.terminal.sender.ISshTerminalSen
* @version 1.0.0
* @since 2024/2/4 16:47
*/
public interface ISshSession extends ITerminalSession {
/**
* 重置大小
*
* @param width width
* @param height height
*/
void resize(int width, int height);
public interface ISshSession extends ITerminalSession, IResizeableSession {
/**
* 写入内容

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 - present Dromara, All rights reserved.
*
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn
*
* Members:
* Jiahang Li - ljh1553488six@139.com - author
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dromara.visor.module.terminal.handler.terminal.session;
/**
* vnc 会话
*
* @author Jiahang Li
* @version 1.0.0
* @since 2025/7/3 2:04
*/
public interface IVncSession extends IGuacdSession {
}

View File

@@ -27,7 +27,6 @@ import cn.orionsec.kit.lang.utils.Strings;
import cn.orionsec.kit.lang.utils.io.Files1;
import lombok.extern.slf4j.Slf4j;
import org.dromara.visor.common.constant.AppConst;
import org.dromara.visor.common.utils.AesEncryptUtils;
import org.dromara.visor.module.common.config.GuacdConfig;
import org.dromara.visor.module.terminal.enums.DriveMountModeEnum;
import org.dromara.visor.module.terminal.handler.guacd.GuacdTunnel;
@@ -66,19 +65,13 @@ public class RdpSession extends AbstractGuacdSession<TerminalSessionRdpConfig> i
@Override
protected void setTunnelParams() {
super.setTunnelParams();
// 设置额外参数
TerminalChannelExtra extra = props.getExtra();
// 音频输入会导致无法连接先写死
extra.setEnableAudioInput(false);
// 设置低带宽模式
if (Booleans.isTrue(config.getLowBandwidthMode())) {
this.setLowBandwidthMode(extra);
}
// 主机信息
tunnel.remote(config.getHostAddress(), config.getHostPort());
// 身份信息
tunnel.auth(config.getUsername(), AesEncryptUtils.decryptAsString(config.getPassword()));
// 大小
tunnel.size(config.getWidth(), config.getHeight(), config.getDpi());
// dpi
tunnel.dpi(config.getDpi());
// 时区
tunnel.timezone(config.getTimezone());
// 忽略证书
@@ -140,32 +133,8 @@ public class RdpSession extends AbstractGuacdSession<TerminalSessionRdpConfig> i
}
@Override
public void resize(int width, int height) {
config.setWidth(width);
config.setHeight(height);
tunnel.writeInstruction("size", String.valueOf(width), String.valueOf(height));
}
/**
* 低带宽模式
*
* @param extra extra
*/
private void setLowBandwidthMode(TerminalChannelExtra extra) {
extra.setColorDepth(8);
extra.setForceLossless(false);
extra.setEnableWallpaper(false);
extra.setEnableTheming(false);
extra.setEnableFontSmoothing(false);
extra.setEnableFullWindowDrag(false);
extra.setEnableDesktopComposition(false);
extra.setEnableMenuAnimations(false);
extra.setDisableBitmapCaching(false);
extra.setDisableOffscreenCaching(false);
extra.setDisableGlyphCaching(false);
extra.setDisableGfx(false);
extra.setEnableAudioInput(false);
extra.setEnableAudioOutput(false);
protected boolean isLowBandwidthMode() {
return Booleans.isTrue(config.getLowBandwidthMode());
}
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (c) 2023 - present Dromara, All rights reserved.
*
* https://visor.dromara.org
* https://visor.dromara.org.cn
* https://visor.orionsec.cn
*
* Members:
* Jiahang Li - ljh1553488six@139.com - author
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dromara.visor.module.terminal.handler.terminal.session;
import cn.orionsec.kit.lang.utils.Booleans;
import lombok.extern.slf4j.Slf4j;
import org.dromara.visor.module.common.config.GuacdConfig;
import org.dromara.visor.module.terminal.handler.guacd.GuacdTunnel;
import org.dromara.visor.module.terminal.handler.guacd.IGuacdTunnel;
import org.dromara.visor.module.terminal.handler.guacd.constant.GuacdConst;
import org.dromara.visor.module.terminal.handler.guacd.constant.GuacdProtocol;
import org.dromara.visor.module.terminal.handler.terminal.model.TerminalChannelExtra;
import org.dromara.visor.module.terminal.handler.terminal.model.TerminalChannelProps;
import org.dromara.visor.module.terminal.handler.terminal.model.config.TerminalSessionVncConfig;
import org.dromara.visor.module.terminal.handler.terminal.sender.IGuacdTerminalSender;
/**
* vnc 会话
*
* @author Jiahang Li
* @version 1.0.0
* @since 2025/3/30 17:44
*/
@Slf4j
public class VncSession extends AbstractGuacdSession<TerminalSessionVncConfig> implements IVncSession {
private final GuacdConfig guacdConfig;
public VncSession(TerminalChannelProps props,
IGuacdTerminalSender sender,
TerminalSessionVncConfig config,
GuacdConfig guacdConfig) {
super(props, sender, config);
this.guacdConfig = guacdConfig;
}
@Override
protected IGuacdTunnel createTunnel() {
return new GuacdTunnel(GuacdProtocol.VNC, sessionId, guacdConfig.getHost(), guacdConfig.getPort());
}
@Override
protected void setTunnelParams() {
super.setTunnelParams();
// 设置额外参数
TerminalChannelExtra extra = props.getExtra();
// 时区
tunnel.timezone(config.getTimezone());
// 显示设置
tunnel.setParameter(GuacdConst.COLOR_DEPTH, extra.getColorDepth());
tunnel.setParameter(GuacdConst.FORCE_LOSSLESS, extra.getForceLossless());
tunnel.setParameter(GuacdConst.COMPRESS_LEVEL, extra.getCompressLevel());
tunnel.setParameter(GuacdConst.QUALITY_LEVEL, extra.getQualityLevel());
// 交换红蓝
tunnel.setParameter(GuacdConst.SWAP_RED_BLUE, config.getSwapRedBlue());
// 光标设置
tunnel.setParameter(GuacdConst.CURSOR, extra.getCursor());
// 编码设置
tunnel.setParameter(GuacdConst.CLIPBOARD_ENCODING, config.getClipboardEncoding());
}
@Override
protected boolean isLowBandwidthMode() {
return Booleans.isTrue(config.getLowBandwidthMode());
}
}

View File

@@ -79,7 +79,7 @@ public class SftpFileUtils {
SftpFileVO vo = new SftpFileVO();
vo.setName(file.getName());
vo.setPath(file.getPath());
vo.setSuffix(Files1.getSuffix(file.getName()));
vo.setSuffix(Files1.getFileNameSuffix(file.getName()));
vo.setSize(file.getSize());
vo.setPermission(file.getPermission());
vo.setUid(file.getUid());

View File

@@ -3,4 +3,4 @@ VITE_API_BASE_URL=http://127.0.0.1:9200/orion-visor/api
# websocket 路径
VITE_WS_BASE_URL=ws://127.0.0.1:9200/orion-visor/keep-alive
# 版本号
VITE_APP_VERSION=2.4.1
VITE_APP_VERSION=2.4.2

View File

@@ -3,4 +3,4 @@ VITE_API_BASE_URL=/orion-visor/api
# websocket 路径
VITE_WS_BASE_URL=/orion-visor/keep-alive
# 版本号
VITE_APP_VERSION=2.4.1
VITE_APP_VERSION=2.4.2

View File

@@ -1,7 +1,7 @@
{
"name": "orion-visor-ui",
"description": "Orion Visor UI",
"version": "2.4.1",
"version": "2.4.2",
"private": true,
"author": "Jiahang Li",
"license": "Apache 2.0",

View File

@@ -51,6 +51,16 @@ export interface HostRdpConfig extends HostBaseConfig {
remoteAppArgs?: string;
}
// 主机 VNC 配置
export interface HostVncConfig extends HostBaseConfig {
identityId?: number;
noUsername?: boolean;
noPassword?: boolean;
portForwardId?: number;
timezone?: string;
clipboardEncoding?: string;
}
/**
* 更新主机配置
*/

View File

@@ -33,6 +33,13 @@ export interface HostRdpExtraSettingModel {
initialProgram: string;
}
// VNC 额外配置
export interface HostVncExtraSettingModel {
port: number;
lowBandwidthMode: boolean;
swapRedBlue: boolean;
}
// 标签额外配置
export interface HostLabelExtraSettingModel {
alias: string;

View File

@@ -5,7 +5,7 @@ import axios from 'axios';
import qs from 'query-string';
// 主机类型
export type HostType = 'SSH' | string | undefined;
export type HostType = 'SSH' | 'RDP' | 'VNC' | string | undefined;
/**
* 主机创建请求

View File

@@ -619,5 +619,189 @@ body[terminal-theme='dark'] .arco-modal-container {
font-size: 16px;
margin: 0 8px 0 4px;
}
}
// guacd 容器
.guacd-container {
// guacd 视口
.guacd-viewport {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
:deep(> div) {
position: relative;
z-index: 8;
}
}
// guacd 状态遮罩
.guacd-status-mask {
width: 100%;
height: 100%;
position: absolute;
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
// guacd 工具栏
.guacd-action-bar {
position: absolute;
cursor: pointer;
z-index: 9998;
&.top {
top: 4px;
left: 50%;
transform: translateX(-50%);
}
&.right {
right: 4px;
top: 50%;
transform: translateY(-50%);
}
// 工具栏触发器
.action-bar-trigger {
display: flex;
border-radius: 8px;
transition: .3s all;
background: var(--color-bg-rdp-toolbar);
filter: contrast(50%) brightness(50%);
&.top {
width: 240px;
height: 8px;
&:hover {
transform: translateY(2px);
}
}
&.right {
width: 8px;
height: 228px;
&:hover {
transform: translateX(-2px);
}
}
&:hover {
background: var(--color-bg-rdp-toolbar-hover);
}
}
}
}
// guacd 工具栏
@guacd-action-size: 42px;
.guacd-action-bar-popover {
--actions-width: calc(var(--action-count) * @guacd-action-size + (var(--action-count) - 1) * 16px);
background: var(--color-bg-2);
.arco-popover-content {
margin-top: 0;
display: flex;
}
.action-bar-button {
width: @guacd-action-size !important;
height: @guacd-action-size !important;
font-size: 20px;
}
.action-bar-content {
display: flex;
flex-direction: column;
}
.action-bar-content-footer {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.display-size-label {
padding-right: 6px;
user-select: none;
text-align: end;
}
.display-size-input {
width: 198px;
}
.action-bar-upload, .action-bar-clipboard {
display: flex;
}
.combination-key-item {
span {
display: block;
padding: 6px 12px;
cursor: pointer;
background: var(--color-fill-1);
border-radius: 2px;
user-select: none;
transition: 0.2s ALL;
&:hover {
background: var(--color-fill-2);
}
}
}
}
.guacd-action-bar-popover.top {
.arco-popover-content {
flex-direction: column;
width: var(--actions-width);
}
.action-bar-content {
margin-top: 16px;
max-height: 224px;
overflow-x: hidden;
overflow-y: auto;
&::-webkit-scrollbar {
display: none;
}
}
.action-bar-upload, .action-bar-clipboard {
height: 186px;
}
}
.guacd-action-bar-popover.right {
.arco-popover-content {
flex-direction: row-reverse;
height: var(--actions-width);
}
.action-bar-content {
margin-right: 16px;
width: 344px;
overflow-x: hidden;
overflow-y: auto;
&::-webkit-scrollbar {
display: none;
}
}
.action-bar-upload, .action-bar-clipboard {
height: calc(var(--actions-width) - 40px);
}
}

View File

@@ -9,7 +9,6 @@
:bordered="false"
:hoverable="true"
:body-style="cardBodyStyle as Record<string, any>"
@contextmenu.prevent="() => false"
@click="bubblesEmitter(CardEmitter.CLICK, item, index)"
@dblclick="bubblesEmitter(CardEmitter.DBL_CLICK, item, index)">
<!-- 标题 -->

View File

@@ -33,7 +33,9 @@
v-bind="cardLayoutCols"
:class="{ 'disabled-col': item.disabled === true }">
<!-- 右键菜单 -->
<a-dropdown trigger="contextMenu" alignPoint>
<a-dropdown trigger="contextMenu"
:disabled="!$slots.contextMenu"
alignPoint>
<!-- 卡片 -->
<card-item v-bind="props"
:index="index"
@@ -48,7 +50,7 @@
</template>
</card-item>
<!-- 右键菜单 -->
<template v-if="contextMenu" #content>
<template v-if="$slots.contextMenu" #content>
<slot name="contextMenu"
:record="item"
:index="index"
@@ -92,7 +94,6 @@
pagination: false,
loading: false,
cardHeight: '100%',
contextMenu: true,
filterCount: 0,
searchInputWidth: '200px',
searchValue: '',

View File

@@ -13,7 +13,6 @@ export interface CardProps {
cardHeight?: string;
cardClass?: string;
cardBodyStyle?: CSSProperties;
contextMenu?: boolean;
filterCount?: number;
searchInputPlaceholder?: string;
searchInputWidth?: string;

View File

@@ -8,7 +8,9 @@ import type {
TerminalSshDisplaySetting,
TerminalSshInteractSetting,
TerminalSshPluginsSetting,
TerminalState
TerminalState,
TerminalVncActionBarSetting,
TerminalVncGraphSetting
} from './types';
import type {
IDomViewportHandler,
@@ -28,7 +30,7 @@ import { getPreference, updatePreference } from '@/api/user/preference';
import { getLatestConnectHostId } from '@/api/terminal/terminal-connect-log';
import { useCacheStore } from '@/store';
import { nextId } from '@/utils';
import { isObject } from '@/utils/is';
import { isArray, isObject } from '@/utils/is';
import { Message } from '@arco-design/web-vue';
import { TerminalSessionTypes, TerminalTabs } from '@/views/terminal/types/const';
import TerminalTabManager from '@/views/terminal/service/tab/terminal-tab-manager';
@@ -56,8 +58,12 @@ export const TerminalPreferenceItem = {
RDP_GRAPH_SETTING: 'rdpGraphSetting',
// rdp 操作栏设置
RDP_ACTION_BAR_SETTING: 'rdpActionBarSetting',
// 会话设置
// rdp 会话设置
RDP_SESSION_SETTING: 'rdpSessionSetting',
// vnc 图形化设置
VNC_GRAPH_SETTING: 'vncGraphSetting',
// vnc 工具栏设置
VNC_ACTION_BAR_SETTING: 'vncActionBarSetting',
// 快捷键设置
SHORTCUT_SETTING: 'shortcutSetting',
};
@@ -77,6 +83,8 @@ export default defineStore('terminal', {
rdpGraphSetting: {} as TerminalRdpGraphSetting,
rdpSessionSetting: {} as TerminalRdpSessionSetting,
rdpActionBarSetting: {} as TerminalRdpActionBarSetting,
vncGraphSetting: {} as TerminalVncGraphSetting,
vncActionBarSetting: {} as TerminalVncActionBarSetting,
shortcutSetting: {
enabled: false,
keys: []
@@ -138,7 +146,7 @@ export default defineStore('terminal', {
await updatePreference({
type: 'TERMINAL',
item,
value: isObject(value) ? JSON.stringify(value) : value,
value: (isObject(value) || isArray(value)) ? JSON.stringify(value) : value,
});
} catch (e) {
Message.error('同步失败');

View File

@@ -23,6 +23,8 @@ export interface TerminalPreference {
rdpGraphSetting: TerminalRdpGraphSetting;
rdpActionBarSetting: TerminalRdpActionBarSetting;
rdpSessionSetting: TerminalRdpSessionSetting;
vncGraphSetting: TerminalVncGraphSetting;
vncActionBarSetting: TerminalVncActionBarSetting;
shortcutSetting: TerminalShortcutSetting;
}
@@ -68,6 +70,7 @@ export interface TerminalSshInteractSetting {
wordSeparator: string;
terminalEmulationType: string;
scrollBackLine: number;
replaceBackspace: boolean;
}
// RDP 图形化设置
@@ -111,6 +114,30 @@ export interface TerminalRdpSessionSetting {
driveMountMode?: string;
}
// VNC 图形化设置
export interface TerminalVncGraphSetting {
displaySize?: string;
displayWidth?: number;
displayHeight?: number;
colorDepth?: number;
forceLossless?: boolean;
cursor?: string;
compressLevel?: number;
qualityLevel?: number;
}
// VNC 操作栏设置
export interface TerminalVncActionBarSetting {
position?: string;
display?: boolean;
combinationKey?: boolean;
clipboard?: boolean;
disconnect?: boolean;
close?: boolean;
[key: string]: unknown;
}
// 终端快捷键设置
export interface TerminalShortcutSetting {
enabled: boolean;

View File

@@ -30,7 +30,7 @@ const checkForVersionUpdate = (serverVersion: string) => {
return;
}
// 提示用户更新
if (window.confirm('检测到新版本, 是否刷新页面以获取最新内容?')) {
if (window.confirm('检测到新版本, 请强制刷新页面以获取最新内容!')) {
window.location.reload();
}
// 更新 localStorage 记录

View File

@@ -1,5 +1,5 @@
<template>
<div class="layout-container view-container">
<div class="layout-container view-container" v-if="render">
<a-tabs v-model:active-key="activeKey"
class="tabs-container simple-card"
size="large"
@@ -32,12 +32,14 @@
const route = useRoute();
const render = ref();
const activeKey = ref();
// 加载字典项
onBeforeMount(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
render.value = true;
});
// 跳转到指定页

View File

@@ -1,5 +1,5 @@
<template>
<div class="layout-container">
<div class="layout-container" v-if="render">
<!-- 列表-表格 -->
<host-identity-table v-if="renderTable"
ref="table"
@@ -36,6 +36,7 @@
import HostIdentityFormModal from './components/host-identity-form-modal.vue';
import HostKeyFormDrawer from '../host-key/components/host-key-form-drawer.vue';
const render = ref();
const table = ref();
const card = ref();
const modal = ref();
@@ -57,6 +58,7 @@
onBeforeMount(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
render.value = true;
});
</script>

View File

@@ -208,6 +208,31 @@
<!-- 拓展操作 -->
<template #extra="{ record }">
<a-space>
<!-- 单协议连接 -->
<a-button v-if="record.types?.length === 1"
size="mini"
v-permission="['terminal:terminal:access']"
@click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: record.types[0] } })">
连接
</a-button>
<!-- 多协议连接 -->
<a-popover v-if="(record.types?.length || 0) > 1"
:title="undefined"
:content-style="{ padding: '8px' }">
<a-button v-permission="['terminal:terminal:access']" size="mini">
连接
</a-button>
<template #content>
<a-space>
<a-button v-for="type in record.types"
:key="type"
size="mini"
@click="openNewRoute({ name: 'terminal', query: { connect: record.id, type} })">
{{ type }}
</a-button>
</a-space>
</template>
</a-popover>
<!-- 更多操作 -->
<a-dropdown trigger="hover" :popup-max-height="false">
<icon-more class="card-extra-icon" />
@@ -236,18 +261,6 @@
@click="deleteRow(record.id)">
<span class="more-doption error">删除</span>
</a-doption>
<!-- SSH -->
<a-doption v-if="record.types.includes(HostType.SSH.value)"
v-permission="['terminal:terminal:access']"
@click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: 'SSH' } })">
<span class="more-doption normal">SSH</span>
</a-doption>
<!-- RDP -->
<a-doption v-if="record.types.includes(HostType.RDP.value)"
v-permission="['terminal:terminal:access']"
@click="openNewRoute({ name: 'terminal', query: { connect: record.id, type: 'RDP' } })">
<span class="more-doption normal">RDP</span>
</a-doption>
</template>
</a-dropdown>
</a-space>

View File

@@ -51,6 +51,15 @@
class="form-panel"
:hostId="hostId" />
</a-tab-pane>
<!-- VNC 配置 -->
<a-tab-pane v-permission="['asset:host:update-config']"
key="vnc"
title="VNC"
:disabled="!hostId || !types.includes(HostType.VNC.value)">
<host-form-vnc v-if="hostId"
class="form-panel"
:hostId="hostId" />
</a-tab-pane>
</a-tabs>
</div>
</a-drawer>
@@ -64,14 +73,15 @@
<script lang="ts" setup>
import { ref, nextTick } from 'vue';
import useVisible from '@/hooks/visible';
import { Message } from '@arco-design/web-vue';
import { useCacheStore } from '@/store';
import { HostType } from '../types/const';
import useVisible from '@/hooks/visible';
import HostFormInfo from './host-form-info.vue';
import HostFormSpec from './host-form-spec.vue';
import HostFormSsh from './host-form-ssh.vue';
import HostFormRdp from './host-form-rdp.vue';
import HostFormVnc from './host-form-vnc.vue';
const { visible, setVisible } = useVisible();
@@ -115,8 +125,8 @@
hostId.value = id;
hostViewUpdated.value = false;
types.value = [];
checkHostGroup();
setVisible(true);
checkHostGroup();
};
// 检查是否有主机分组
@@ -187,5 +197,21 @@
width: 100%;
height: 100%;
padding: 0 24px;
:deep(.password-switch) {
width: 148px;
margin-left: 8px;
}
:deep(.password-auth-type-group) {
width: 100%;
display: flex;
justify-content: space-between;
.arco-radio-button {
width: 50%;
text-align: center;
}
}
}
</style>

View File

@@ -6,7 +6,7 @@
label-align="right"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 18 }"
:rules="rdpFormRules">
:rules="formRules">
<!-- 端口 -->
<a-form-item field="port"
label="端口"
@@ -18,7 +18,6 @@
<!-- 用户名 -->
<a-form-item field="username"
label="用户名"
:rules="usernameRules"
:help="HostAuthType.IDENTITY === formModel.authType ? '将使用主机身份的用户名' : undefined"
hide-asterisk>
<a-input v-model="formModel.username"
@@ -30,7 +29,7 @@
label="认证方式"
hide-asterisk>
<a-radio-group type="button"
class="auth-type-group usn"
class="password-auth-type-group usn"
v-model="formModel.authType"
:options="toRadioOptions(passwordAuthTypeKey)" />
</a-form-item>
@@ -38,7 +37,6 @@
<a-form-item v-if="HostAuthType.PASSWORD === formModel.authType"
field="password"
label="主机密码"
:rules="passwordRules"
hide-asterisk>
<a-input-password v-model="formModel.password"
:disabled="!formModel.useNewPassword && formModel.hasPassword"
@@ -154,18 +152,6 @@
@click="saveConfig">
保存
</a-button>
<!-- 测试连接 -->
<a-tooltip position="tr"
content="请先保存后测试连接"
mini>
<a-button class="extra-button"
type="primary"
:loading="connectLoading"
long
@click="testConnect">
测试连接
</a-button>
</a-tooltip>
</a-form-item>
</a-form>
</a-spin>
@@ -178,7 +164,6 @@
</script>
<script lang="ts" setup>
import type { FieldRule } from '@arco-design/web-vue';
import type { HostRdpConfig } from '@/api/asset/host-config';
import { ref, onMounted } from 'vue';
import useLoading from '@/hooks/loading';
@@ -186,10 +171,7 @@
import { passwordAuthTypeKey, HostAuthType, HostType, timezoneKey, keyboardLayoutKey, clipboardNormalizeKey } from '../types/const';
import { IdentityType } from '@/views/asset/host-identity/types/const';
import { rdpFormRules } from '../types/form.rules';
import { Message } from '@arco-design/web-vue';
import { encrypt } from '@/utils/rsa';
import { testHostConnect } from '@/api/asset/host';
import { getHostConfig, updateHostConfig } from '@/api/asset/host-config';
import useHostConfigForm from '../types/use-host-config';
import HostIdentitySelector from '@/components/asset/host-identity/selector/index.vue';
const props = defineProps<{
@@ -197,130 +179,28 @@
}>();
const { loading, setLoading } = useLoading();
const { loading: connectLoading, setLoading: setConnectLoading } = useLoading();
const { toOptions, toRadioOptions } = useDictStore();
const formRef = ref();
const formModel = ref<HostRdpConfig>({} as HostRdpConfig);
// 用户名验证
const usernameRules = [{
validator: (value, cb) => {
if (value && value.length > 128) {
cb('用户名长度不能大于128位');
return;
}
if (formModel.value.authType !== HostAuthType.IDENTITY && !value) {
cb('请输入用户名');
return;
}
}
}] as FieldRule[];
// 密码验证
const passwordRules = [{
validator: (value, cb) => {
if (value && value.length > 256) {
cb('密码长度不能大于256位');
return;
}
if (formModel.value.useNewPassword && !value) {
cb('请输入密码');
return;
}
}
}] as FieldRule[];
// 加载配置
const fetchHostConfig = async () => {
try {
setLoading(true);
// 加载配置
const { data } = await getHostConfig<HostRdpConfig>({
hostId: props.hostId,
type: HostType.RDP.value,
});
formModel.value = data;
// 使用新密码默认为不包含密码
formModel.value.useNewPassword = !formModel.value.hasPassword;
} catch ({ message }) {
Message.error(`配置加载失败 ${message}`);
} finally {
setLoading(false);
}
};
// 测试连接
const testConnect = async () => {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return;
}
try {
setConnectLoading(true);
// 测试连接
await testHostConnect({
id: props.hostId,
type: HostType.RDP.value,
});
Message.success('连接成功');
} catch (e) {
} finally {
setConnectLoading(false);
}
};
// 保存配置
const saveConfig = async () => {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return;
}
// 加密参数
const requestData = { ...formModel.value };
try {
requestData.password = await encrypt(formModel.value.password);
} catch (e) {
return;
}
try {
setLoading(true);
// 更新
await updateHostConfig({
hostId: props.hostId,
type: HostType.RDP.value,
config: JSON.stringify(requestData),
});
Message.success('修改成功');
} catch (e) {
} finally {
setLoading(false);
}
};
const {
formRef,
formRules,
fetchHostConfig,
saveConfig,
} = useHostConfigForm({
type: HostType.RDP.value,
hostId: props.hostId,
rules: rdpFormRules,
formModel,
setLoading,
});
onMounted(fetchHostConfig);
</script>
<style lang="less" scoped>
.auth-type-group {
width: 100%;
display: flex;
justify-content: space-between;
:deep(.arco-radio-button) {
width: 50%;
text-align: center;
}
}
.password-switch {
width: 148px;
margin-left: 8px;
}
.advanced-settings {
margin-bottom: 16px;
@@ -339,5 +219,4 @@
}
}
}
</style>

View File

@@ -4,8 +4,9 @@
<a-form :model="formModel"
ref="formRef"
label-align="right"
:auto-label-width="true"
:rules="sshFormRules">
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 18 }"
:rules="formRules">
<!-- 端口 -->
<a-form-item field="port"
label="端口"
@@ -17,7 +18,6 @@
<!-- 用户名 -->
<a-form-item field="username"
label="用户名"
:rules="usernameRules"
:help="HostAuthType.IDENTITY === formModel.authType ? '将使用主机身份的用户名' : undefined">
<a-input v-model="formModel.username"
:disabled="HostAuthType.IDENTITY === formModel.authType"
@@ -35,8 +35,7 @@
<!-- 主机密码 -->
<a-form-item v-if="HostAuthType.PASSWORD === formModel.authType"
field="password"
label="主机密码"
:rules="passwordRules">
label="主机密码">
<a-input-password v-model="formModel.password"
:disabled="!formModel.useNewPassword && formModel.hasPassword"
placeholder="主机密码" />
@@ -105,7 +104,6 @@
mini>
<a-button class="extra-button"
type="primary"
:loading="connectLoading"
long
@click="testConnect">
测试连接
@@ -123,7 +121,6 @@
</script>
<script lang="ts" setup>
import type { FieldRule } from '@arco-design/web-vue';
import type { HostSshConfig } from '@/api/asset/host-config';
import { ref, onMounted } from 'vue';
import useLoading from '@/hooks/loading';
@@ -131,9 +128,8 @@
import { sshAuthTypeKey, HostAuthType, HostType } from '../types/const';
import { sshFormRules } from '../types/form.rules';
import { Message } from '@arco-design/web-vue';
import { encrypt } from '@/utils/rsa';
import { testHostConnect } from '@/api/asset/host';
import { getHostConfig, updateHostConfig } from '@/api/asset/host-config';
import useHostConfigForm from '../types/use-host-config';
import HostIdentitySelector from '@/components/asset/host-identity/selector/index.vue';
import HostKeySelector from '@/components/asset/host-key/selector/index.vue';
@@ -142,103 +138,34 @@
}>();
const { loading, setLoading } = useLoading();
const { loading: connectLoading, setLoading: setConnectLoading } = useLoading();
const { toRadioOptions } = useDictStore();
const formRef = ref();
const formModel = ref<HostSshConfig>({} as HostSshConfig);
// 用户名验证
const usernameRules = [{
validator: (value, cb) => {
if (value && value.length > 128) {
cb('用户名长度不能大于128位');
return;
}
if (formModel.value.authType !== HostAuthType.IDENTITY && !value) {
cb('请输入用户名');
return;
}
}
}] as FieldRule[];
// 密码验证
const passwordRules = [{
validator: (value, cb) => {
if (value && value.length > 256) {
cb('密码长度不能大于256位');
return;
}
if (formModel.value.useNewPassword && !value) {
cb('请输入密码');
return;
}
}
}] as FieldRule[];
// 加载配置
const fetchHostConfig = async () => {
try {
setLoading(true);
// 加载配置
const { data } = await getHostConfig<HostSshConfig>({
hostId: props.hostId,
type: HostType.SSH.value,
});
formModel.value = data;
// 使用新密码默认为不包含密码
formModel.value.useNewPassword = !formModel.value.hasPassword;
} catch ({ message }) {
Message.error(`配置加载失败 ${message}`);
} finally {
setLoading(false);
}
};
const {
formRef,
formRules,
fetchHostConfig,
saveConfig,
} = useHostConfigForm({
type: HostType.SSH.value,
hostId: props.hostId,
rules: sshFormRules,
formModel,
setLoading,
});
// 测试连接
const testConnect = async () => {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return;
}
try {
setConnectLoading(true);
// 测试连接
await testHostConnect({
id: props.hostId,
type: HostType.SSH.value,
});
Message.success('连接成功');
} catch (e) {
} finally {
setConnectLoading(false);
}
};
// 保存配置
const saveConfig = async () => {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return;
}
// 加密参数
const requestData = { ...formModel.value };
try {
requestData.password = await encrypt(formModel.value.password);
} catch (e) {
return;
}
try {
setLoading(true);
// 更新
await updateHostConfig({
hostId: props.hostId,
type: HostType.SSH.value,
config: JSON.stringify(requestData),
});
Message.success('修改成功');
// 测试连接
await testHostConnect({ id: props.hostId, type: HostType.SSH.value });
Message.success('连接成功');
} catch (e) {
} finally {
setLoading(false);
@@ -261,9 +188,4 @@
}
}
.password-switch {
width: 148px;
margin-left: 8px;
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<a-spin :loading="loading">
<!-- 表单 -->
<a-form :model="formModel"
ref="formRef"
label-align="right"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 18 }"
:rules="formRules">
<!-- 端口 -->
<a-form-item field="port"
label="端口"
hide-asterisk>
<a-input-number v-model="formModel.port"
placeholder="请输入 VNC 端口"
hide-button />
</a-form-item>
<a-row>
<a-col :span="8">
<!-- 无用户名 -->
<a-form-item field="noUsername"
label="无用户名"
:label-col-props="{ span: 18 }"
:wrapper-col-props="{ span: 6 }"
hide-asterisk>
<a-switch v-model="formModel.noUsername" type="round" />
</a-form-item>
</a-col>
<a-col :span="8">
<!-- 无密码 -->
<a-form-item field="noPassword"
label="无密码"
:label-col-props="{ span: 18 }"
:wrapper-col-props="{ span: 6 }"
hide-asterisk>
<a-switch v-model="formModel.noPassword" type="round" />
</a-form-item>
</a-col>
</a-row>
<!-- 用户名 -->
<a-form-item v-if="formModel.noUsername !== true"
field="username"
label="用户名"
:help="HostAuthType.IDENTITY === formModel.authType ? '将使用主机身份的用户名' : undefined"
hide-asterisk>
<a-input v-model="formModel.username"
:disabled="HostAuthType.IDENTITY === formModel.authType"
placeholder="请输入用户名" />
</a-form-item>
<!-- 认证方式 -->
<a-form-item v-if="formModel.noPassword !== true"
field="authType"
label="认证方式"
hide-asterisk>
<a-radio-group type="button"
class="password-auth-type-group usn"
v-model="formModel.authType"
:options="toRadioOptions(passwordAuthTypeKey)" />
</a-form-item>
<!-- 主机密码 -->
<a-form-item v-if="formModel.noPassword !== true && HostAuthType.PASSWORD === formModel.authType"
field="password"
label="主机密码"
hide-asterisk>
<a-input-password v-model="formModel.password"
:disabled="!formModel.useNewPassword && formModel.hasPassword"
placeholder="主机密码" />
<a-switch v-if="formModel.hasPassword"
v-model="formModel.useNewPassword"
class="password-switch"
type="round"
checked-text="使用新密码"
unchecked-text="使用原密码" />
</a-form-item>
<!-- 主机身份 -->
<a-form-item v-if="HostAuthType.IDENTITY === formModel.authType"
field="identityId"
label="主机身份"
hide-asterisk>
<host-identity-selector v-model="formModel.identityId"
:type="IdentityType.PASSWORD" />
</a-form-item>
<!-- 系统时区 -->
<a-form-item field="timezone"
label="系统时区"
hide-asterisk>
<a-select v-model="formModel.timezone"
placeholder="请选择系统时区"
:options="toOptions(timezoneKey)" />
</a-form-item>
<!-- 剪切板编码 -->
<a-form-item field="clipboardEncoding"
label="剪切板编码"
hide-asterisk>
<a-select v-model="formModel.clipboardEncoding"
placeholder="请选择剪切板编码"
:options="toOptions(clipboardEncodingKey)" />
</a-form-item>
<!-- 操作 -->
<a-form-item style="margin-bottom: 0;">
<!-- 保存 -->
<a-button type="primary"
long
@click="saveConfig">
保存
</a-button>
</a-form-item>
</a-form>
</a-spin>
</template>
<script lang="ts">
export default {
name: 'hostFormVnc'
};
</script>
<script lang="ts" setup>
import type { HostVncConfig } from '@/api/asset/host-config';
import { ref, onMounted } from 'vue';
import useLoading from '@/hooks/loading';
import { useDictStore } from '@/store';
import { passwordAuthTypeKey, HostAuthType, HostType, clipboardEncodingKey, timezoneKey } from '../types/const';
import { IdentityType } from '@/views/asset/host-identity/types/const';
import { vncFormRules } from '../types/form.rules';
import useHostConfigForm from '../types/use-host-config';
import HostIdentitySelector from '@/components/asset/host-identity/selector/index.vue';
const props = defineProps<{
hostId: number;
}>();
const { loading, setLoading } = useLoading();
const { toOptions, toRadioOptions } = useDictStore();
const formModel = ref<HostVncConfig>({} as HostVncConfig);
const {
formRef,
formRules,
fetchHostConfig,
saveConfig,
} = useHostConfigForm({
type: HostType.VNC.value,
hostId: props.hostId,
rules: vncFormRules,
formModel,
setLoading,
});
onMounted(fetchHostConfig);
</script>
<style lang="less" scoped>
</style>

View File

@@ -274,7 +274,6 @@
<a-space>
<a-button v-for="type in record.types"
:key="type"
type="text"
size="mini"
@click="openNewRoute({ name: 'terminal', query: { connect: record.id, type} })">
{{ type }}

View File

@@ -1,5 +1,5 @@
<template>
<div class="layout-container">
<div class="layout-container" v-if="render">
<!-- 列表-表格 -->
<host-table v-if="renderTable"
ref="table"
@@ -36,6 +36,7 @@
import HostFormDrawer from './components/host-form-drawer.vue';
import HostGroupDrawer from '../host-group/drawer/index.vue';
const render = ref();
const table = ref();
const card = ref();
const drawer = ref();
@@ -57,6 +58,7 @@
// 加载字典配置
onBeforeMount(() => {
useDictStore().loadKeys(dictKeys);
render.value = true;
});
</script>

View File

@@ -24,6 +24,10 @@ export const HostType = {
value: 'RDP',
port: 3389,
},
VNC: {
value: 'VNC',
port: 5900,
},
};
// 系统类型

View File

@@ -1,4 +1,7 @@
import type { FieldRule } from '@arco-design/web-vue';
import type { Ref } from 'vue';
import type { HostBaseConfig } from '@/api/asset/host-config';
import { HostAuthType } from './const';
// 主机表单规则
export const hostFormRules = {
@@ -62,11 +65,11 @@ export const hostFormRules = {
export const sshFormRules = {
port: [{
required: true,
message: '请输入 SSH 端口'
message: '请输入端口'
}, {
min: 1,
max: 65535,
message: 'SSH 端口不合法'
message: '端口不合法'
}],
authType: [{
required: true,
@@ -116,11 +119,11 @@ export const sshFormRules = {
export const rdpFormRules = {
port: [{
required: true,
message: '请输入 RDP 端口'
message: '请输入端口'
}, {
min: 1,
max: 65535,
message: 'RDP 端口不合法'
message: '端口不合法'
}],
authType: [{
required: true,
@@ -132,4 +135,64 @@ export const rdpFormRules = {
}],
} as Record<string, FieldRule | FieldRule[]>;
// vnc 表单规则
export const vncFormRules = {
port: [{
required: true,
message: '请输入端口'
}, {
min: 1,
max: 65535,
message: '端口不合法'
}],
authType: [{
required: true,
message: '请选择认证方式'
}],
identityId: [{
required: true,
message: '请选择主机身份'
}],
clipboardEncoding: [{
required: true,
message: '请选择剪切板编码'
}],
} as Record<string, FieldRule | FieldRule[]>;
// 基础规则
export const baseFormRules = {
// 用户名验证规则
username(formModel: Ref<HostBaseConfig>) {
return [{
validator: (value: string, cb: (msg?: string) => void) => {
if (value && value.length > 128) {
cb('用户名长度不能大于128位');
return;
}
if (formModel.value.authType !== HostAuthType.IDENTITY && !value) {
cb('请输入用户名');
return;
}
cb();
}
}] as FieldRule[];
},
// 密码验证规则
password(formModel: Ref<HostBaseConfig>) {
return [{
validator: (value: string, cb: (msg?: string) => void) => {
if (value && value.length > 256) {
cb('密码长度不能大于256位');
return;
}
if (formModel.value.useNewPassword && !value) {
cb('请输入密码');
return;
}
cb();
}
}] as FieldRule[];
}
};
export default null;

View File

@@ -0,0 +1,77 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
import type { HostBaseConfig } from '@/api/asset/host-config';
import { getHostConfig, updateHostConfig } from '@/api/asset/host-config';
import type { FieldRule } from '@arco-design/web-vue';
import { Message } from '@arco-design/web-vue';
import { baseFormRules } from './form.rules';
import { encrypt } from '@/utils/rsa';
// 主机配置表单信息
export interface UseHostConfigFormOptions<T extends HostBaseConfig> {
type: string;
hostId: number;
formModel: Ref<T>;
rules?: Record<string, FieldRule | FieldRule[]>;
setLoading: (loading: boolean) => void;
}
// 使用主机配置表单
export default function useHostConfigForm<T extends HostBaseConfig>(options: UseHostConfigFormOptions<T>) {
const { type, hostId, formModel, rules, setLoading } = options;
const formRef = ref();
const formRules = ref({ ...rules, username: baseFormRules.username(formModel), password: baseFormRules.password(formModel) });
// 加载配置
const fetchHostConfig = async () => {
try {
setLoading(true);
const { data } = await getHostConfig<T>({ hostId, type });
data.useNewPassword = !data.hasPassword;
formModel.value = data;
} catch (err: any) {
Message.error('配置加载失败');
} finally {
setLoading(false);
}
};
// 保存配置
const saveConfig = async () => {
if (!formRef?.value) {
return;
}
const error = await formRef.value.validate();
if (error) {
return;
}
// 加密密码
const data = { ...formModel.value };
try {
data.password = await encrypt(data.password);
} catch (e) {
return;
}
try {
setLoading(true);
// 更新
await updateHostConfig({
hostId,
type,
config: JSON.stringify(data),
});
Message.success('修改成功');
} catch (e) {
} finally {
setLoading(false);
}
};
return {
formRef,
formRules,
fetchHostConfig,
saveConfig,
};
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="layout-container upload-container">
<div class="layout-container upload-container" v-if="render">
<!-- 上传面板 -->
<upload-panel ref="panel" />
</div>
@@ -20,12 +20,14 @@
const route = useRoute();
const render = ref();
const panel = ref();
// 加载字典值
onMounted(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
render.value = true;
});
// 跳转日志

View File

@@ -1,5 +1,5 @@
<template>
<div class="layout-container full">
<div class="layout-container full" v-if="render">
<!-- 执行面板 -->
<div v-show="!logVisible" class="panel-wrapper">
<exec-command-panel @submit="openLog" />
@@ -34,6 +34,7 @@
const { visible: logVisible, setVisible: setLogVisible } = useVisible();
const route = useRoute();
const render = ref();
const log = ref();
// 打开日志
@@ -56,6 +57,7 @@
onMounted(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
render.value = true;
});
// 跳转日志

View File

@@ -31,45 +31,21 @@
<icon-right />
</a-button>
</a-tooltip>
<!-- 打开 SSH -->
<a-tooltip v-if="handler.host?.types?.includes(TerminalSessionTypes.SSH.type)"
position="top"
:mini="true"
:auto-fix-position="false"
content-class="terminal-tooltip-content"
arrow-class="terminal-tooltip-content"
content="打开 SSH">
<a-button class="combined-handler-action icon-button"
@click="openSession(handler.host as any, TerminalSessionTypes.SSH)">
<icon-thunderbolt />
</a-button>
</a-tooltip>
<!-- 打开 SFTP -->
<a-tooltip v-if="handler.host?.types?.includes(TerminalSessionTypes.SSH.type)"
position="top"
:mini="true"
:auto-fix-position="false"
content-class="terminal-tooltip-content"
arrow-class="terminal-tooltip-content"
content="打开 SFTP">
<a-button class="combined-handler-action icon-button"
@click="openSession(handler.host as any, TerminalSessionTypes.SFTP)">
<icon-folder />
</a-button>
</a-tooltip>
<!-- 打开 RDP -->
<a-tooltip v-if="handler.host?.types?.includes(TerminalSessionTypes.RDP.type)"
position="top"
:mini="true"
:auto-fix-position="false"
content-class="terminal-tooltip-content"
arrow-class="terminal-tooltip-content"
content="打开 RDP">
<a-button class="combined-handler-action icon-button"
@click="openSession(handler.host as any, TerminalSessionTypes.RDP)">
<icon-computer />
</a-button>
</a-tooltip>
<!-- 打开会话 -->
<template v-for="type in TerminalSessionTypes">
<template v-if="handler.host?.types?.includes(type.protocol)">
<a-tooltip position="top"
:mini="true"
:auto-fix-position="false"
:content="`打开 ${type.type}`"
content-class="terminal-tooltip-content"
arrow-class="terminal-tooltip-content">
<a-button class="combined-handler-action icon-button" @click="openSession(handler.host as any, type)">
<component :is="type.connectIcon || type.icon" />
</a-button>
</a-tooltip>
</template>
</template>
</div>
</div>
</div>

View File

@@ -32,15 +32,25 @@
<span class="tab-title-icon">
<component :is="item.icon" />
</span>
{{ item.title }}
<span>{{ item.title }}</span>
</span>
</template>
<!-- ssh -->
<ssh-view v-if="item.type === TerminalSessionTypes.SSH.type" :item="item" />
<ssh-view v-if="item.type === TerminalSessionTypes.SSH.type"
class="session-container"
:item="item" />
<!-- sftp -->
<sftp-view v-else-if="item.type === TerminalSessionTypes.SFTP.type" :item="item" />
<sftp-view v-else-if="item.type === TerminalSessionTypes.SFTP.type"
class="session-container"
:item="item" />
<!-- rdp -->
<rdp-view v-else-if="item.type === TerminalSessionTypes.RDP.type" :item="item" />
<rdp-view v-else-if="item.type === TerminalSessionTypes.RDP.type"
class="session-container"
:item="item" />
<!-- vnc -->
<vnc-view v-else-if="item.type === TerminalSessionTypes.VNC.type"
class="session-container"
:item="item" />
</a-tab-pane>
</a-tabs>
</div>
@@ -60,6 +70,7 @@
import SshView from '../view/ssh/ssh-view.vue';
import SftpView from '../view/sftp/sftp-view.vue';
import RdpView from '../view/rdp/rdp-view.vue';
import VncView from '../view/vnc/vnc-view.vue';
const props = defineProps<{
index: number;
@@ -118,56 +129,62 @@
.terminal-panel-container {
width: 100%;
height: 100%;
}
.tab-title-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
padding: 11px 18px 9px 14px;
background: var(--bg);
position: relative;
transition: all .3s;
.tab-title-icon {
font-size: 16px;
margin-right: 6px;
}
&:hover {
filter: brightness(1.04);
}
&::after {
content: '';
width: calc(100% - 3px);
height: 2px;
background: var(--color);
position: absolute;
left: 1px;
bottom: -1px;
}
}
.panel-extra {
margin-right: 8px;
.extra-icon {
color: var(--color-panel-text-1);
transition: 0.2s;
font-size: 16px;
cursor: pointer;
width: 24px;
height: 24px;
.tab-title-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
padding: 4px 18px 4px 14px;
background: var(--bg);
position: relative;
transition: all .3s;
.tab-title-icon {
font-size: 16px;
margin-right: 6px;
}
&:hover {
background: var(--color-bg-panel-icon-1);
filter: brightness(1.04);
}
&::after {
content: '';
width: calc(100% - 3px);
height: 2px;
background: var(--color);
position: absolute;
left: 1px;
bottom: -1px;
}
}
.panel-extra {
margin-right: 8px;
.extra-icon {
color: var(--color-panel-text-1);
transition: 0.2s;
font-size: 16px;
cursor: pointer;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&:hover {
background: var(--color-bg-panel-icon-1);
}
}
}
.session-container {
width: 100%;
height: 100%;
position: relative;
}
}

Some files were not shown because too many files have changed in this diff Show More