chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-infra</artifactId>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-sandbox-spec</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,135 @@
package com.xspaceagi.sandbox.infra.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 反向代理服务器配置属性
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "reverse.server")
public class ReverseServerProperties {
/**
* 内部服务器配置
*/
private Inner inner = new Inner();
/**
* 外部服务器配置
*/
private Outer outer = new Outer();
/**
* 内部服务器配置
*/
@Data
public static class Inner {
/**
* 内部服务器主机地址
* 默认: 127.0.0.1
*/
private String serviceHost = "127.0.0.1";
private String bindHost = "127.0.0.1";
/**
* 内部服务器端口范围
* 格式: "起始端口-结束端口",例如 "30000-40000"
* 默认: 30000-40000
*/
private String ports = "30000-40000";
/**
* 解析端口范围
*
* @return 端口范围数组 [起始端口, 结束端口]
*/
public int[] parsePortRange() {
if (ports == null || ports.trim().isEmpty()) {
throw new IllegalArgumentException("Port range cannot be empty");
}
String[] parts = ports.split("-");
if (parts.length != 2) {
throw new IllegalArgumentException("Invalid port range format, expected 'min-max', got: " + ports);
}
try {
int minPort = Integer.parseInt(parts[0].trim());
int maxPort = Integer.parseInt(parts[1].trim());
if (minPort <= 0 || maxPort <= 0) {
throw new IllegalArgumentException("Port numbers must be positive");
}
if (minPort > maxPort) {
throw new IllegalArgumentException("Min port cannot be greater than max port");
}
return new int[]{minPort, maxPort};
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid port number format", e);
}
}
/**
* 获取起始端口
*/
public int getMinPort() {
return parsePortRange()[0];
}
/**
* 获取结束端口
*/
public int getMaxPort() {
return parsePortRange()[1];
}
public String getServiceHost() {
//从环境变量中获取服务器主机地址
String envServiceHost = System.getenv("SERVICE_HOST");
if (envServiceHost != null && !envServiceHost.trim().isEmpty()) {
return envServiceHost;
}
return serviceHost;
}
public String getBindHost() {
//从环境变量中获取服务器主机地址
String envBindHost = System.getenv("BIND_HOST");
if (envBindHost != null && !envBindHost.trim().isEmpty()) {
return envBindHost;
}
return bindHost;
}
}
/**
* 外部服务器配置
*/
@Data
public static class Outer {
/**
* 外部服务器主机地址
* 默认: 空(需要配置)
*/
private String host = "";
/**
* 外部服务器端口
* 默认: 6443
*/
private int port = 6443;
/**
* 检查外部服务器是否已配置
*/
public boolean isConfigured() {
return host != null && !host.trim().isEmpty();
}
}
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.sandbox.infra.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* Sandbox 模块配置类
*/
@Configuration
@EnableConfigurationProperties({
ReverseServerProperties.class
})
public class SandboxConfiguration {
// 配置类,用于启用配置属性扫描
}

View File

@@ -0,0 +1,78 @@
package com.xspaceagi.sandbox.infra.dao.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
/**
* 沙盒配置实体类
*/
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@TableName(value = "sandbox_config", autoResultMap = true)
public class SandboxConfig implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "scope")
private SandboxScopeEnum scope;
@TableField(value = "_tenant_id")
private Long tenantId;
@TableField(value = "user_id")
private Long userId;
@TableField(value = "name")
private String name;
@TableField(value = "config_key")
private String configKey;
@TableField(value = "config_value")
private String configValue;
@TableField(value = "description")
private String description;
@TableField(value = "is_active")
private Boolean isActive;
@TableField(value = "server_info")
private String serverInfo;
@TableField(value = "agent_id")
private Long agentId;
@TableField(value = "max_agent")
private Integer maxAgentCount;
@TableField(value = "bind_info")
private String bindInfo;
@TableField(value = "type")
private String type;
@TableField(value = "isolation")
private String isolation;
@TableField(value = "created")
private Date created;
@TableField(value = "modified")
private Date modified;
}

View File

@@ -0,0 +1,53 @@
package com.xspaceagi.sandbox.infra.dao.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
/**
* 临时代理实体类
*/
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@TableName(value = "sandbox_proxy", autoResultMap = true)
public class SandboxProxy implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
@TableField(value = "user_id")
private Long userId;
@TableField(value = "sandbox_id")
private Long sandboxId;
@TableField(value = "proxy_key")
private String proxyKey;
@TableField(value = "backend_host")
private String backendHost;
@TableField(value = "backend_port")
private Integer backendPort;
@TableField(value = "created")
private Date created;
@TableField(value = "modified")
private Date modified;
}

View File

@@ -0,0 +1,13 @@
package com.xspaceagi.sandbox.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import org.apache.ibatis.annotations.Mapper;
/**
* 沙盒配置 Mapper 接口
*/
@Mapper
public interface SandboxConfigMapper extends BaseMapper<SandboxConfig> {
}

View File

@@ -0,0 +1,13 @@
package com.xspaceagi.sandbox.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxProxy;
import org.apache.ibatis.annotations.Mapper;
/**
* 临时代理 Mapper 接口
*/
@Mapper
public interface SandboxProxyMapper extends BaseMapper<SandboxProxy> {
}

View File

@@ -0,0 +1,45 @@
package com.xspaceagi.sandbox.infra.dao.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import java.util.List;
/**
* 沙盒配置服务接口
*/
public interface SandboxConfigService extends IService<SandboxConfig> {
/**
* 根据用户ID查询配置列表
*
* @param userId 用户ID
* @return 配置列表
*/
List<SandboxConfig> queryUserConfigsByType(Long userId);
/**
* 查询全局配置列表
*
* @return 配置列表
*/
List<SandboxConfig> queryGlobalConfigs(Boolean isActive);
/**
* 根据配置键查询用户配置
*
* @param configKey 配置键
* @return 配置信息
*/
SandboxConfig queryUserConfigByKey(String configKey);
/**
* 根据配置键查询全局配置
*
* @param configKey 配置键
* @return 配置信息
*/
SandboxConfig queryGlobalConfigByKey(String configKey);
SandboxConfig querySandboxConfigById(Long sandboxId);
}

View File

@@ -0,0 +1,44 @@
package com.xspaceagi.sandbox.infra.dao.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxProxy;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxProxyBackend;
import java.util.List;
/**
* 临时代理服务接口
*/
public interface SandboxProxyService extends IService<SandboxProxy> {
/**
* 根据代理键查询代理配置
*
* @param proxyKey 代理键
* @return 代理配置
*/
SandboxProxy getByProxyKey(String proxyKey);
/**
* 根据沙盒ID查询代理配置
*
* @param sandboxId 沙盒ID
* @return 代理配置
*/
SandboxProxy getBySandboxId(Long sandboxId);
SandboxProxyBackend getBackendByProxyKey(String proxyKey);
/**
* 根据用户ID查询代理配置列表
*
* @param userId 用户ID
* @return 代理配置列表
*/
java.util.List<SandboxProxy> listByUserId(Long userId);
void removeByProxyKey(Long userId, String proxyKey);
List<SandboxProxy> listByUserIdAndSandboxId(Long userId, Long sandboxId);
}

View File

@@ -0,0 +1,70 @@
package com.xspaceagi.sandbox.infra.dao.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import com.xspaceagi.sandbox.infra.dao.mapper.SandboxConfigMapper;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.spec.enums.SandboxScopeEnum;
import com.xspaceagi.system.spec.tenant.thread.TenantFunctions;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 沙盒配置服务实现
*/
@Service
public class SandboxConfigServiceImpl extends ServiceImpl<SandboxConfigMapper, SandboxConfig> implements SandboxConfigService {
@Override
public List<SandboxConfig> queryUserConfigsByType(Long userId) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, SandboxScopeEnum.USER)
.eq(SandboxConfig::getUserId, userId)
.orderByDesc(SandboxConfig::getId);
return list(queryWrapper);
}
@Override
public List<SandboxConfig> queryGlobalConfigs(Boolean isActive) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, SandboxScopeEnum.GLOBAL)
.eq(isActive != null, SandboxConfig::getIsActive, isActive)
.orderByAsc(SandboxConfig::getId);
return list(queryWrapper);
}
@Override
public SandboxConfig queryUserConfigByKey(String configKey) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, SandboxScopeEnum.USER)
.eq(SandboxConfig::getConfigKey, configKey)
.eq(SandboxConfig::getIsActive, true)
.last("LIMIT 1");
return TenantFunctions.callWithIgnoreCheck(() -> getOne(queryWrapper));
}
@Override
public SandboxConfig queryGlobalConfigByKey(String configKey) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getScope, SandboxScopeEnum.GLOBAL)
.eq(SandboxConfig::getConfigKey, configKey)
.eq(SandboxConfig::getIsActive, true)
.last("LIMIT 1");
return getOne(queryWrapper);
}
@Override
public SandboxConfig querySandboxConfigById(Long sandboxId) {
LambdaQueryWrapper<SandboxConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxConfig::getId, sandboxId)
.eq(SandboxConfig::getIsActive, true)
.last("LIMIT 1");
return getOne(queryWrapper);
}
}

View File

@@ -0,0 +1,98 @@
package com.xspaceagi.sandbox.infra.dao.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxProxy;
import com.xspaceagi.sandbox.infra.dao.mapper.SandboxProxyMapper;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.infra.dao.service.SandboxProxyService;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxProxyBackend;
import com.xspaceagi.system.spec.cache.SimpleJvmHashCache;
import com.xspaceagi.system.spec.tenant.thread.TenantFunctions;
import com.xspaceagi.system.spec.utils.RedisUtil;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 临时代理服务实现
*/
@Service
public class SandboxProxyServiceImpl extends ServiceImpl<SandboxProxyMapper, SandboxProxy> implements SandboxProxyService {
@Resource
private SandboxConfigService sandboxConfigService;
@Resource
private RedisUtil redisUtil;
@Override
public SandboxProxy getByProxyKey(String proxyKey) {
LambdaQueryWrapper<SandboxProxy> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxProxy::getProxyKey, proxyKey)
.last("LIMIT 1");
return getOne(queryWrapper);
}
@Override
public SandboxProxy getBySandboxId(Long sandboxId) {
LambdaQueryWrapper<SandboxProxy> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxProxy::getSandboxId, sandboxId)
.last("LIMIT 1");
return getOne(queryWrapper);
}
@Override
public SandboxProxyBackend getBackendByProxyKey(String proxyKey) {
Object val = SimpleJvmHashCache.getHash(SandboxProxy.class.getName(), proxyKey);
if (val != null) {
return (SandboxProxyBackend) val;
}
SandboxProxyBackend backend = TenantFunctions.callWithIgnoreCheck(() -> {
SandboxProxy byProxyKey = getByProxyKey(proxyKey);
if (byProxyKey == null) {
return null;
}
SandboxConfig sandboxConfig = sandboxConfigService.querySandboxConfigById(byProxyKey.getSandboxId());
if (sandboxConfig == null) {
return null;
}
return SandboxProxyBackend.builder()
.sandboxId(byProxyKey.getSandboxId())
.proxyKey(byProxyKey.getProxyKey())
.backendHost(byProxyKey.getBackendHost())
.backendPort(byProxyKey.getBackendPort())
.sandboxConfigKey(sandboxConfig.getConfigKey())
.build();
});
SimpleJvmHashCache.putHash(SandboxProxy.class.getName(), proxyKey, backend, 10);
return backend;
}
@Override
public List<SandboxProxy> listByUserId(Long userId) {
LambdaQueryWrapper<SandboxProxy> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxProxy::getUserId, userId)
.orderByDesc(SandboxProxy::getCreated);
return list(queryWrapper);
}
@Override
public void removeByProxyKey(Long userId, String proxyKey) {
LambdaQueryWrapper<SandboxProxy> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxProxy::getUserId, userId)
.eq(SandboxProxy::getProxyKey, proxyKey);
remove(queryWrapper);
}
@Override
public List<SandboxProxy> listByUserIdAndSandboxId(Long userId, Long sandboxId) {
LambdaQueryWrapper<SandboxProxy> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SandboxProxy::getUserId, userId)
.eq(SandboxProxy::getSandboxId, sandboxId)
.orderByDesc(SandboxProxy::getCreated);
return list(queryWrapper);
}
}

View File

@@ -0,0 +1,30 @@
package com.xspaceagi.sandbox.infra.dao.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 沙盒配置值
*/
@Schema(description = "沙盒配置值")
@Data
public class SandboxConfigValue {
@Schema(description = "服务根地址,例如 http://192.168.1.11,不允许携带端口")
private String hostWithScheme;
@Schema(description = "Agent服务端口")
private int agentPort;
@Schema(description = "VNC服务端口")
private int vncPort;
@Schema(description = "文件服务端口")
private int fileServerPort;
@Schema(description = "API密钥")
private String apiKey;
@Schema(description = "最大用户数")
private Integer maxUsers;
}

View File

@@ -0,0 +1,28 @@
package com.xspaceagi.sandbox.infra.dao.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 临时代理实体类
*/
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class SandboxProxyBackend implements Serializable {
private Long sandboxId;
private String proxyKey;
private String backendHost;
private Integer backendPort;
private String sandboxConfigKey;
}

View File

@@ -0,0 +1,30 @@
package com.xspaceagi.sandbox.infra.dao.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 沙盒配置值
*/
@Schema(description = "沙盒服务端内部交互信息")
@Data
public class SandboxServerInfo {
@Schema(description = "协议")
private String scheme;
@Schema(description = "主机地址")
private String host;
@Schema(description = "Agent服务端口")
private int agentPort;
@Schema(description = "VNC服务端口")
private int vncPort;
@Schema(description = "文件服务端口")
private int fileServerPort;
@Schema(description = "API密钥")
private String apiKey;
}

View File

@@ -0,0 +1,408 @@
package com.xspaceagi.sandbox.infra.network;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 端口池管理器
*
* 功能:
* 1. 管理指定范围的端口池
* 2. 支持端口借用和归还
* 3. 归还的端口需要等待冷却时间默认1分钟后才能再次被使用
* 4. 线程安全
*/
@Slf4j
public class PortPoolManager {
/**
* 端口信息
*/
private static class PortInfo {
private final int port;
private long allocateTime; // 分配时间
private long releaseTime; // 释放时间(用于冷却)
private boolean inUse; // 是否在使用中
private boolean cooling; // 是否在冷却中
public PortInfo(int port) {
this.port = port;
this.inUse = false;
this.cooling = false;
}
public void allocate() {
this.inUse = true;
this.allocateTime = System.currentTimeMillis();
this.releaseTime = 0;
}
public void release() {
this.inUse = false;
this.cooling = true;
this.releaseTime = System.currentTimeMillis();
}
public boolean isAvailable(long coolDownMillis) {
if (inUse) {
return false;
}
if (cooling) {
return System.currentTimeMillis() - releaseTime >= coolDownMillis;
}
return true;
}
public void reset() {
this.inUse = false;
this.cooling = false;
this.releaseTime = 0;
}
public int getPort() {
return port;
}
public long getAllocateTime() {
return allocateTime;
}
public long getReleaseTime() {
return releaseTime;
}
}
private final int minPort;
private final int maxPort;
private final long coolDownMillis; // 冷却时间(毫秒)
// 端口信息映射
private final Map<Integer, PortInfo> portInfoMap;
// 可用端口队列(线程安全)
private final BlockingQueue<PortInfo> availablePorts;
// 定时检查冷却端口
private final ScheduledExecutorService scheduler;
/**
* 构造函数
*
* @param minPort 起始端口(包含)
* @param maxPort 结束端口(包含)
* @param coolDownSeconds 冷却时间(秒)
*/
public PortPoolManager(int minPort, int maxPort, int coolDownSeconds) {
if (minPort <= 0 || maxPort <= 0 || minPort > maxPort) {
throw new IllegalArgumentException("Invalid port range");
}
if (coolDownSeconds <= 0) {
throw new IllegalArgumentException("Cool down time must be positive");
}
this.minPort = minPort;
this.maxPort = maxPort;
this.coolDownMillis = coolDownSeconds * 1000L;
this.portInfoMap = new ConcurrentHashMap<>();
this.availablePorts = new LinkedBlockingQueue<>();
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, "port-pool-cooler");
thread.setDaemon(true);
return thread;
});
initializePortPool();
startCoolDownChecker();
}
/**
* 默认构造函数1分钟冷却时间
*
* @param minPort 起始端口(包含)
* @param maxPort 结束端口(包含)
*/
public PortPoolManager(int minPort, int maxPort) {
this(minPort, maxPort, 60);
}
/**
* 初始化端口池
*/
private void initializePortPool() {
log.info("Initializing port pool: {} -> {}, cool down: {}ms", minPort, maxPort, coolDownMillis);
for (int port = minPort; port <= maxPort; port++) {
PortInfo portInfo = new PortInfo(port);
portInfoMap.put(port, portInfo);
availablePorts.offer(portInfo);
}
log.info("Port pool initialized with {} ports", maxPort - minPort + 1);
}
/**
* 启动冷却端口检查器
*/
private void startCoolDownChecker() {
// 每10秒检查一次冷却端口
scheduler.scheduleAtFixedRate(() -> {
try {
checkAndReturnCoolingPorts();
} catch (Exception e) {
log.error("Error checking cooling ports", e);
}
}, 10, 10, TimeUnit.SECONDS);
}
/**
* 检查并返回已冷却的端口
*/
private void checkAndReturnCoolingPorts() {
int returnedCount = 0;
for (PortInfo portInfo : portInfoMap.values()) {
if (portInfo.cooling && portInfo.isAvailable(0)) {
// 冷却完成,重置状态并放回可用队列
portInfo.reset();
if (availablePorts.offer(portInfo)) {
returnedCount++;
log.debug("Port {} returned to pool after cooling", portInfo.getPort());
}
}
}
if (returnedCount > 0) {
log.info("Returned {} ports to pool after cooling", returnedCount);
}
}
/**
* 借用端口(阻塞等待)
*
* @return 可用的端口
* @throws InterruptedException 如果等待被中断
*/
public int borrow() throws Exception {
return borrow(0, TimeUnit.MILLISECONDS);
}
/**
* 借用端口(带超时)
*
* @param timeout 超时时间
* @param unit 时间单位
* @return 可用的端口
* @throws InterruptedException 如果等待被中断
*/
public int borrow(long timeout, TimeUnit unit) throws Exception {
PortInfo portInfo;
if (timeout > 0) {
portInfo = availablePorts.poll(timeout, unit);
} else {
portInfo = availablePorts.take();
}
if (portInfo == null) {
throw new TimeoutException("No available ports in pool");
}
portInfo.allocate();
log.debug("Port {} borrowed", portInfo.getPort());
return portInfo.getPort();
}
/**
* 归还端口
*
* @param port 要归还的端口
* @throws IllegalArgumentException 如果端口不在池中
*/
public void release(int port) {
PortInfo portInfo = portInfoMap.get(port);
if (portInfo == null) {
throw new IllegalArgumentException("Port " + port + " is not managed by this pool");
}
if (!portInfo.inUse) {
log.warn("Port {} is not in use, ignore release", port);
return;
}
portInfo.release();
log.debug("Port {} released, entering cooling period", port);
}
/**
* 强制归还端口(不进入冷却期)
*
* @param port 要归还的端口
*/
public void releaseImmediately(int port) {
PortInfo portInfo = portInfoMap.get(port);
if (portInfo == null) {
throw new IllegalArgumentException("Port " + port + " is not managed by this pool");
}
boolean wasInUse = portInfo.inUse;
portInfo.reset();
if (wasInUse) {
// 如果之前在使用中,放回可用队列
availablePorts.offer(portInfo);
log.debug("Port {} released immediately (no cooling)", port);
}
}
/**
* 获取池中的端口总数
*
* @return 端口总数
*/
public int getTotalPorts() {
return portInfoMap.size();
}
/**
* 获取可用端口数
*
* @return 可用端口数
*/
public int getAvailablePortCount() {
return availablePorts.size();
}
/**
* 获取使用中的端口数
*
* @return 使用中的端口数
*/
public int getInUsePortCount() {
int count = 0;
for (PortInfo portInfo : portInfoMap.values()) {
if (portInfo.inUse) {
count++;
}
}
return count;
}
/**
* 获取冷却中的端口数
*
* @return 冷却中的端口数
*/
public int getCoolingPortCount() {
int count = 0;
for (PortInfo portInfo : portInfoMap.values()) {
if (portInfo.cooling && !portInfo.inUse) {
count++;
}
}
return count;
}
/**
* 检查端口是否在池中
*
* @param port 端口
* @return 是否在池中
*/
public boolean containsPort(int port) {
return portInfoMap.containsKey(port);
}
/**
* 检查端口是否可用
*
* @param port 端口
* @return 是否可用
*/
public boolean isAvailable(int port) {
PortInfo portInfo = portInfoMap.get(port);
return portInfo != null && portInfo.isAvailable(coolDownMillis);
}
/**
* 获取端口状态
*
* @param port 端口
* @return 状态字符串AVAILABLE, IN_USE, COOLING, NOT_FOUND
*/
public String getPortStatus(int port) {
PortInfo portInfo = portInfoMap.get(port);
if (portInfo == null) {
return "NOT_FOUND";
}
if (portInfo.inUse) {
return "IN_USE";
}
if (portInfo.cooling) {
return "COOLING";
}
return "AVAILABLE";
}
/**
* 关闭端口池
*/
public void shutdown() {
log.info("Shutting down port pool manager");
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
log.info("Port pool manager shutdown complete");
}
/**
* 获取端口池统计信息
*
* @return 统计信息
*/
public PortPoolStats getStats() {
int inUse = getInUsePortCount();
int available = getAvailablePortCount();
int cooling = getCoolingPortCount();
return new PortPoolStats(
minPort,
maxPort,
getTotalPorts(),
available,
inUse,
cooling,
coolDownMillis / 1000
);
}
/**
* 端口池统计信息
*/
public record PortPoolStats(
int minPort,
int maxPort,
int totalPorts,
int availablePorts,
int inUsePorts,
int coolingPorts,
long coolDownSeconds
) {
@Override
public String toString() {
return String.format(
"PortPoolStats{range=%d-%d, total=%d, available=%d, inUse=%d, cooling=%d, coolDown=%ds}",
minPort, maxPort, totalPorts, availablePorts, inUsePorts, coolingPorts, coolDownSeconds
);
}
}
}

View File

@@ -0,0 +1,208 @@
package com.xspaceagi.sandbox.infra.network;
import com.xspaceagi.sandbox.infra.network.protocol.Constants;
import io.netty.channel.Channel;
import io.netty.util.AttributeKey;
import lombok.Builder;
import lombok.Data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 代理服务连接管理(代理客户端连接+用户请求连接)
*
* @author fengfei
*/
public class ProxyChannelManager {
private static final Logger logger = LoggerFactory.getLogger(ProxyChannelManager.class);
public static final AttributeKey<Map<String, Channel>> USER_CHANNELS = AttributeKey.newInstance("user_channels");
private static final AttributeKey<String> REQUEST_LAN_INFO = AttributeKey.newInstance("request_lan_info");
public static final AttributeKey<List<Integer>> CHANNEL_PORT = AttributeKey.newInstance("channel_port");
public static final AttributeKey<String> CHANNEL_CLIENT_KEY = AttributeKey.newInstance("channel_client_key");
private static final Map<Integer, Channel> portCmdChannelMapping = new ConcurrentHashMap<Integer, Channel>();
private static final Map<String, Channel> cmdChannels = new ConcurrentHashMap<String, Channel>();
private static final Map<Integer, LanInfoHolder> portLanInfoMapping = new ConcurrentHashMap<>();
public static void addCmdChannel(List<Integer> ports, String clientKey, Channel channel) {
if (ports == null) {
throw new IllegalArgumentException("port can not be null");
}
// 客户端proxy-client相对较少这里同步的比较重
// 保证服务器对外端口与客户端到服务器的连接关系在临界情况时调用removeChannel(Channel channel)时不出问题
synchronized (portCmdChannelMapping) {
for (int port : ports) {
portCmdChannelMapping.put(port, channel);
}
}
channel.attr(CHANNEL_PORT).set(ports);
channel.attr(CHANNEL_CLIENT_KEY).set(clientKey);
channel.attr(USER_CHANNELS).set(new ConcurrentHashMap<String, Channel>());
cmdChannels.put(clientKey, channel);
}
/**
* 代理客户端连接断开后清除关系
*
* @param channel
*/
public static void removeCmdChannel(Channel channel) {
logger.info("channel closed, clear user channels, {}", channel);
if (channel.attr(CHANNEL_PORT).get() == null) {
return;
}
String clientKey = channel.attr(CHANNEL_CLIENT_KEY).get();
Channel channel0 = cmdChannels.remove(clientKey);
logger.info("remove cmd channel, {}", channel0);
List<Integer> ports = channel.attr(CHANNEL_PORT).get();
for (int port : ports) {
Channel proxyChannel = portCmdChannelMapping.remove(port);
if (proxyChannel == null) {
continue;
}
// 在执行断连之前新的连接已经连上来了
if (!proxyChannel.id().equals(channel.id())) {
portCmdChannelMapping.put(port, proxyChannel);
}
}
if (channel.isActive()) {
logger.info("disconnect proxy channel {}", channel);
channel.close();
}
Map<String, Channel> userChannels = getUserChannels(channel);
for (String s : new ArrayList<>(userChannels.keySet())) {
Channel userChannel = userChannels.get(s);
if (userChannel.isActive()) {
userChannel.close();
logger.info("disconnect user channel {}", userChannel);
}
}
}
public static Channel getCmdChannel(Integer port) {
return portCmdChannelMapping.get(port);
}
public static Channel getCmdChannel(String clientKey) {
return cmdChannels.get(clientKey);
}
/**
* 增加用户连接与代理客户端连接关系
*
* @param cmdChannel
* @param userId
* @param userChannel
*/
public static void addUserChannelToCmdChannel(Channel cmdChannel, String userId, Channel userChannel) {
InetSocketAddress sa = (InetSocketAddress) userChannel.localAddress();
//String lanInfo = ProxyConfig.getInstance().getLanInfo(sa.getPort());
userChannel.attr(Constants.USER_ID).set(userId);
//userChannel.attr(REQUEST_LAN_INFO).set(lanInfo);
cmdChannel.attr(USER_CHANNELS).get().put(userId, userChannel);
}
/**
* 删除用户连接与代理客户端连接关系
*
* @param cmdChannel
* @param userId
* @return
*/
public static Channel removeUserChannelFromCmdChannel(Channel cmdChannel, String userId) {
if (cmdChannel.attr(USER_CHANNELS).get() == null) {
return null;
}
synchronized (cmdChannel) {
return cmdChannel.attr(USER_CHANNELS).get().remove(userId);
}
}
/**
* 根据代理客户端连接与用户编号获取用户连接
*
* @param cmdChannel
* @param userId
* @return
*/
public static Channel getUserChannel(Channel cmdChannel, String userId) {
return cmdChannel.attr(USER_CHANNELS).get().get(userId);
}
/**
* 获取用户编号
*
* @param userChannel
* @return
*/
public static String getUserChannelUserId(Channel userChannel) {
return userChannel.attr(Constants.USER_ID).get();
}
/**
* 获取用户请求的内网IP端口信息
*
* @param userChannel
* @return
*/
public static String getUserChannelRequestLanInfo(Channel userChannel) {
return userChannel.attr(REQUEST_LAN_INFO).get();
}
/**
* 获取代理控制客户端连接绑定的所有用户连接
*
* @param cmdChannel
* @return
*/
public static Map<String, Channel> getUserChannels(Channel cmdChannel) {
return cmdChannel.attr(USER_CHANNELS).get();
}
public static void addPortLanInfo(int port, String lanInfo, boolean isVnc) {
portLanInfoMapping.put(port, new LanInfoHolder(lanInfo, isVnc));
}
public static String getPortLanInfo(int port) {
LanInfoHolder lanInfoHolder = portLanInfoMapping.get(port);
return lanInfoHolder != null ? lanInfoHolder.getLanInfo() : null;
}
public static boolean isVncPort(int port) {
LanInfoHolder lanInfoHolder = portLanInfoMapping.get(port);
return lanInfoHolder != null && lanInfoHolder.isVnc();
}
public static void removePortLanInfo(int port) {
portLanInfoMapping.remove(port);
}
@Data
@Builder
private static class LanInfoHolder {
private String lanInfo;
private boolean isVnc;
}
}

View File

@@ -0,0 +1,78 @@
package com.xspaceagi.sandbox.infra.network;
import com.xspaceagi.sandbox.infra.dao.service.SandboxProxyService;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpServerCodec;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 模型代理服务器
*/
@Slf4j
@Component
public class ReverseHttpProxyServer {
@Value("${sandbox.temp-link.proxy.port:18087}")
private int port;
@Resource
private SandboxProxyService sandboxProxyService;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
private Channel serverChannel;
@PostConstruct
public void start() throws Exception {
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors() * 2);
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
p.addLast("httpServerCodec", new HttpServerCodec());
p.addLast(new TempLinkHttpProxyHandler(sandboxProxyService));
}
})
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true);
ChannelFuture f = b.bind(port).sync();
serverChannel = f.channel();
log.info("sandbox proxy server started on port {}", port);
} catch (Exception e) {
log.error("Failed to start sandbox proxy server", e);
shutdown();
throw e;
}
}
@PreDestroy
public void shutdown() {
if (serverChannel != null) {
serverChannel.close();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
}
}

View File

@@ -0,0 +1,223 @@
package com.xspaceagi.sandbox.infra.network;
import com.xspaceagi.sandbox.infra.config.ReverseServerProperties;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.infra.network.protocol.IdleCheckHandler;
import com.xspaceagi.sandbox.infra.network.protocol.ProxyMessageDecoder;
import com.xspaceagi.sandbox.infra.network.protocol.ProxyMessageEncoder;
import com.xspaceagi.system.spec.utils.RedisUtil;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import java.net.BindException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class ReverseServerContainer {
/**
* max packet is 2M.
*/
private static final int MAX_FRAME_LENGTH = 2 * 1024 * 1024;
private static final int LENGTH_FIELD_OFFSET = 0;
private static final int LENGTH_FIELD_LENGTH = 4;
private static final int INITIAL_BYTES_TO_STRIP = 0;
private static final int LENGTH_ADJUSTMENT = 0;
private static final Logger logger = LoggerFactory.getLogger(ReverseServerContainer.class);
private NioEventLoopGroup serverWorkerGroup;
private NioEventLoopGroup serverBossGroup;
private ServerBootstrap clientBootstrap;
private volatile boolean started = false;
@Resource
private SandboxConfigService sandboxConfigService;
@Getter
private PortPoolManager portPoolManager;
@Getter
@Resource
private ReverseServerProperties reverseServerProperties;
@Resource
private RedisUtil redisUtil;
private final Map<Integer, ChannelFuture> channelFutureMap = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
synchronized (ReverseServerContainer.class) {
if (!started) {
start();
started = true;
}
}
}
/**
* 最大HTTP内容长度 (8MB)
*/
private static final int MAX_HTTP_CONTENT_LENGTH = 8 * 1024 * 1024;
/**
* 最大WebSocket帧长度 (10MB)
*/
private static final int MAX_FRAME_SIZE = 10 * 1024 * 1024;
public void start() {
serverBossGroup = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors());
serverWorkerGroup = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors() * 2);
clientBootstrap = new ServerBootstrap();
clientBootstrap.group(serverBossGroup, serverWorkerGroup).channel(NioServerSocketChannel.class).childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) {
int port = ch.localAddress().getPort();
if (ProxyChannelManager.isVncPort(port)) {
// HTTP Codec - 解码HTTP请求
ch.pipeline().addLast("httpCodec", new HttpServerCodec());
// HTTP Object Aggregator - 将HTTP消息聚合为完整请求
ch.pipeline().addLast("httpAggregator", new HttpObjectAggregator(MAX_HTTP_CONTENT_LENGTH));
// Chunked Write Handler - 支持大文件传输
ch.pipeline().addLast("chunkedWrite", new ChunkedWriteHandler());
// WebSocket Server Protocol Handler - 处理握手和协议升级
ch.pipeline().addLast("wsProtocol", new WebSocketServerProtocolHandler(
"/websockify",
null, // subprotocols
true, // allowExtensions
MAX_FRAME_SIZE // maxFrameSize
));
// WebSocket Frame to ByteBuf Handler - WebSocket与TCP转换层
ch.pipeline().addLast("wsConverter", new WebSocketFrameToByteBufHandler());
}
ch.pipeline().addLast(new UserChannelHandler());
}
});
initializeSSLTCPTransport(new SslContextCreator().initSSLContext());
portPoolManager = new PortPoolManager(reverseServerProperties.getInner().getMinPort(), reverseServerProperties.getInner().getMaxPort(), 30);
}
private void initializeSSLTCPTransport(final SSLContext sslContext) {
ServerBootstrap b = new ServerBootstrap();
b.group(serverBossGroup, serverWorkerGroup).channel(NioServerSocketChannel.class).childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
try {
pipeline.addLast("ssl", createSslHandler(sslContext));
ch.pipeline().addLast(new ProxyMessageDecoder(MAX_FRAME_LENGTH, LENGTH_FIELD_OFFSET, LENGTH_FIELD_LENGTH, LENGTH_ADJUSTMENT, INITIAL_BYTES_TO_STRIP));
ch.pipeline().addLast(new ProxyMessageEncoder());
ch.pipeline().addLast(new IdleCheckHandler(IdleCheckHandler.READ_IDLE_TIME, IdleCheckHandler.WRITE_IDLE_TIME, 0));
ch.pipeline().addLast(new ServerChannelHandler(sandboxConfigService, ReverseServerContainer.this));
} catch (Throwable th) {
logger.error("Severe error during pipeline creation", th);
throw th;
}
}
});
try {
// Bind and start to accept incoming connections.
b.bind("0.0.0.0", reverseServerProperties.getOuter().getPort()).get();
logger.info("proxy ssl server start on port {}", reverseServerProperties.getOuter().getPort());
} catch (Exception ex) {
logger.error("An interruptedException was caught while initializing server", ex);
}
}
public boolean startUserPort(String host, Integer port) {
Assert.notNull(port, "port must not be null");
try {
ChannelFuture future = clientBootstrap.bind(port);
future.get();
channelFutureMap.put(port, future);
logger.info("bind user port {}", port);
} catch (Exception ex) {
// BindException表示该端口已经绑定过
if (!(ex.getCause() instanceof BindException)) {
logger.error("An interruptedException was caught while initializing server", ex);
throw new RuntimeException(ex);
}
logger.warn("bind port {} error", port, ex);
return false;
}
return true;
}
public void releaseUserPort(Integer port) {
ChannelFuture channelFuture = channelFutureMap.get(port);
if (channelFuture != null) {
channelFuture.channel().close();
channelFutureMap.remove(port);
logger.info("release user port {}", port);
}
}
@PreDestroy
public void stop() {
serverBossGroup.shutdownGracefully();
serverWorkerGroup.shutdownGracefully();
}
private ChannelHandler createSslHandler(SSLContext sslContext) {
SSLEngine sslEngine = sslContext.createSSLEngine();
sslEngine.setUseClientMode(false);
return new SslHandler(sslEngine);
}
public void userSandboxOnlineTouch(String key) {
redisUtil.set("user:sandbox:status:" + key, String.valueOf(System.currentTimeMillis()), 60);
}
public Long getUserSandboxAliveTime(String key) {
Object value = redisUtil.get("user:sandbox:status:" + key);
if (value != null) {
return Long.parseLong(value.toString());
}
return null;
}
public void userSandboxOfflineTouch(String key) {
redisUtil.expire("user:sandbox:status:" + key, 0);
}
public void offlineClient(String key) {
Channel cmdChannel = ProxyChannelManager.getCmdChannel(key);
if (cmdChannel != null && cmdChannel.isActive()) {
cmdChannel.close();
}
}
}

View File

@@ -0,0 +1,337 @@
package com.xspaceagi.sandbox.infra.network;
import cn.hutool.core.collection.ConcurrentHashSet;
import com.alibaba.fastjson2.JSON;
import com.xspaceagi.sandbox.infra.dao.entity.SandboxConfig;
import com.xspaceagi.sandbox.infra.dao.service.SandboxConfigService;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxConfigValue;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxServerInfo;
import com.xspaceagi.sandbox.infra.network.protocol.Constants;
import com.xspaceagi.sandbox.infra.network.protocol.ProxyMessage;
import com.xspaceagi.system.spec.tenant.thread.TenantFunctions;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.util.ReferenceCountUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import static com.xspaceagi.sandbox.infra.network.ProxyChannelManager.*;
/**
* @author fengfei
*/
public class ServerChannelHandler extends SimpleChannelInboundHandler<ProxyMessage> {
private static final Logger logger = LoggerFactory.getLogger(ServerChannelHandler.class);
private static final Set<String> authLockSet = new ConcurrentHashSet<>();
private final SandboxConfigService sandboxConfigService;
private final ReverseServerContainer reverseServerContainer;
public ServerChannelHandler(SandboxConfigService sandboxConfigService, ReverseServerContainer reverseServerContainer) {
this.sandboxConfigService = sandboxConfigService;
this.reverseServerContainer = reverseServerContainer;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, ProxyMessage proxyMessage) throws Exception {
logger.debug("ProxyMessage received {}", proxyMessage.getType());
switch (proxyMessage.getType()) {
case ProxyMessage.TYPE_HEARTBEAT:
handleHeartbeatMessage(ctx, proxyMessage);
break;
case ProxyMessage.C_TYPE_AUTH:
try {
if (!authLockSet.add(proxyMessage.getUri())) {
ctx.channel().close();
return;
}
handleAuthMessage(ctx, proxyMessage);
} finally {
authLockSet.remove(proxyMessage.getUri());
}
break;
case ProxyMessage.TYPE_CONNECT:
handleConnectMessage(ctx, proxyMessage);
break;
case ProxyMessage.TYPE_DISCONNECT:
handleDisconnectMessage(ctx, proxyMessage);
break;
case ProxyMessage.P_TYPE_TRANSFER:
handleTransferMessage(ctx, proxyMessage);
break;
default:
break;
}
}
private void handleTransferMessage(ChannelHandlerContext ctx, ProxyMessage proxyMessage) {
Channel userChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (userChannel != null) {
ByteBuf buf = ctx.alloc().buffer(proxyMessage.getData().length);
buf.writeBytes(proxyMessage.getData());
userChannel.writeAndFlush(buf);
}
}
private void handleDisconnectMessage(ChannelHandlerContext ctx, ProxyMessage proxyMessage) {
String clientKey = ctx.channel().attr(Constants.CLIENT_KEY).get();
// 代理连接没有连上服务器由控制连接发送用户端断开连接消息
if (clientKey == null) {
String userId = proxyMessage.getUri();
Channel userChannel = ProxyChannelManager.removeUserChannelFromCmdChannel(ctx.channel(), userId);
if (userChannel != null) {
// 数据发送完成后再关闭连接解决http1.0数据传输问题
userChannel.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
}
return;
}
Channel cmdChannel = ProxyChannelManager.getCmdChannel(clientKey);
if (cmdChannel == null) {
logger.warn("ConnectMessage:error cmd channel key {}", ctx.channel().attr(Constants.CLIENT_KEY).get());
return;
}
Channel userChannel = ProxyChannelManager.removeUserChannelFromCmdChannel(cmdChannel, ctx.channel().attr(Constants.USER_ID).get());
if (userChannel != null) {
// 数据发送完成后再关闭连接解决http1.0数据传输问题
userChannel.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
ctx.channel().attr(Constants.NEXT_CHANNEL).set(null);
ctx.channel().attr(Constants.CLIENT_KEY).set(null);
ctx.channel().attr(Constants.USER_ID).set(null);
}
}
private void handleConnectMessage(ChannelHandlerContext ctx, ProxyMessage proxyMessage) {
String uri = proxyMessage.getUri();
if (uri == null) {
ctx.channel().close();
logger.warn("ConnectMessage:null uri");
return;
}
String[] tokens = uri.split("@");
if (tokens.length != 2) {
ctx.channel().close();
logger.warn("ConnectMessage:error uri");
return;
}
Channel cmdChannel = ProxyChannelManager.getCmdChannel(tokens[1]);
if (cmdChannel == null) {
ctx.channel().close();
logger.warn("ConnectMessage:error cmd channel key {}", tokens[1]);
return;
}
Channel userChannel = ProxyChannelManager.getUserChannel(cmdChannel, tokens[0]);
if (userChannel != null) {
ctx.channel().attr(Constants.USER_ID).set(tokens[0]);
ctx.channel().attr(Constants.CLIENT_KEY).set(tokens[1]);
ctx.channel().attr(Constants.NEXT_CHANNEL).set(userChannel);
userChannel.attr(Constants.NEXT_CHANNEL).set(ctx.channel());
// 读取http代理 TempLinkHttpProxyHandler 连接时的缓存数据
Queue<Object> objects = userChannel.attr(Constants.MESSAGE_QUEUE).get();
if (objects != null) {
while (!objects.isEmpty()) {
Object poll = objects.poll();
if (poll instanceof ByteBuf buf) {
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
String userId = ProxyChannelManager.getUserChannelUserId(userChannel);
ProxyMessage proxyMessage0 = new ProxyMessage();
proxyMessage0.setType(ProxyMessage.P_TYPE_TRANSFER);
proxyMessage0.setUri(userId);
proxyMessage0.setData(bytes);
ctx.channel().writeAndFlush(proxyMessage0);
}
ReferenceCountUtil.release(poll);
}
}
// 代理客户端与后端服务器连接成功,修改用户连接为可读状态
userChannel.config().setOption(ChannelOption.AUTO_READ, true);
}
}
private void handleHeartbeatMessage(ChannelHandlerContext ctx, ProxyMessage proxyMessage) {
ProxyMessage heartbeatMessage = new ProxyMessage();
heartbeatMessage.setSerialNumber(heartbeatMessage.getSerialNumber());
heartbeatMessage.setType(ProxyMessage.TYPE_HEARTBEAT);
logger.debug("response heartbeat message {}", ctx.channel());
ctx.channel().writeAndFlush(heartbeatMessage);
String clientKey = ctx.channel().attr(CHANNEL_CLIENT_KEY).get();
Map<String, Channel> channelMap = ctx.channel().attr(USER_CHANNELS).get();
if (clientKey != null && channelMap != null) {
reverseServerContainer.userSandboxOnlineTouch(clientKey);
}
}
private void handleAuthMessage(ChannelHandlerContext ctx, ProxyMessage proxyMessage) {
String clientKey = proxyMessage.getUri();
SandboxConfig sandboxConfig = sandboxConfigService.queryUserConfigByKey(clientKey);
if (sandboxConfig == null) {
logger.warn("error clientKey {}, {}", clientKey, ctx.channel());
ctx.channel().close();
return;
}
Channel channel = ProxyChannelManager.getCmdChannel(clientKey);
if (channel != null) {
logger.warn("exist channel for key {}, {}", clientKey, channel);
ctx.channel().close();
channel.close();
return;
}
SandboxConfigValue configValue = JSON.parseObject(sandboxConfig.getConfigValue(), SandboxConfigValue.class);
if (configValue == null) {
logger.warn("error configValue for key {}, {}", clientKey, ctx.channel());
ctx.channel().close();
return;
}
Integer agentInnerPort = null;
Integer vncInnerPort = null;
Integer fileServerPort = null;
try {
agentInnerPort = acquireUserPort();
vncInnerPort = acquireUserPort();
fileServerPort = acquireUserPort();
URL url;
try {
url = new URL(configValue.getHostWithScheme());
} catch (MalformedURLException e) {
logger.warn("error get url for key {}, {}", clientKey, ctx.channel(), e);
ctx.channel().close();
return;
}
ProxyChannelManager.addPortLanInfo(agentInnerPort, url.getHost() + ":" + configValue.getAgentPort(), false);
ProxyChannelManager.addPortLanInfo(vncInnerPort, url.getHost() + ":" + configValue.getVncPort(), false);//后续支持直连vnc
ProxyChannelManager.addPortLanInfo(fileServerPort, url.getHost() + ":" + configValue.getFileServerPort(), false);
String innerHost = reverseServerContainer.getReverseServerProperties().getInner().getServiceHost();
SandboxServerInfo sandboxServerInfo = new SandboxServerInfo();
sandboxServerInfo.setScheme(url.getProtocol());
sandboxServerInfo.setHost(innerHost);
sandboxServerInfo.setAgentPort(agentInnerPort);
sandboxServerInfo.setVncPort(vncInnerPort);
sandboxServerInfo.setFileServerPort(fileServerPort);
sandboxServerInfo.setApiKey(configValue.getApiKey());
SandboxConfig sandboxConfigUpdate = new SandboxConfig();
sandboxConfigUpdate.setId(sandboxConfig.getId());
sandboxConfigUpdate.setServerInfo(JSON.toJSONString(sandboxServerInfo));
TenantFunctions.runWithIgnoreCheck(() -> sandboxConfigService.updateById(sandboxConfigUpdate));
List<Integer> ports = List.of(agentInnerPort, vncInnerPort, fileServerPort);
logger.info("set port => channel, {}, {}, {}", clientKey, ports, ctx.channel());
ProxyChannelManager.addCmdChannel(ports, clientKey, ctx.channel());
reverseServerContainer.userSandboxOnlineTouch(clientKey);
} catch (Exception e) {
if (agentInnerPort != null) {
reverseServerContainer.getPortPoolManager().release(agentInnerPort);
}
if (vncInnerPort != null) {
reverseServerContainer.getPortPoolManager().release(vncInnerPort);
}
if (fileServerPort != null) {
reverseServerContainer.getPortPoolManager().release(fileServerPort);
}
logger.warn("error get port for key {}, {}", clientKey, ctx.channel(), e);
ctx.channel().close();
}
}
// acquire user port
private Integer acquireUserPort() throws Exception {
Integer port = null;
int i = 0;
while (i < 10) {
port = reverseServerContainer.getPortPoolManager().borrow();
boolean bind = reverseServerContainer.startUserPort(reverseServerContainer.getReverseServerProperties().getInner().getBindHost(), port);
if (!bind) {
logger.warn("port {} bind failed", port);
reverseServerContainer.getPortPoolManager().release(port);
port = null;
} else {
break;
}
i++;
}
if (port == null) {
logger.warn("acquire port failed");
throw new RuntimeException("acquire port failed");
}
return port;
}
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
Channel userChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (userChannel != null) {
userChannel.config().setOption(ChannelOption.AUTO_READ, ctx.channel().isWritable());
}
super.channelWritabilityChanged(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
Channel userChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (userChannel != null && userChannel.isActive()) {
String clientKey = ctx.channel().attr(Constants.CLIENT_KEY).get();
String userId = ctx.channel().attr(Constants.USER_ID).get();
Channel cmdChannel = ProxyChannelManager.getCmdChannel(clientKey);
if (cmdChannel != null) {
ProxyChannelManager.removeUserChannelFromCmdChannel(cmdChannel, userId);
} else {
logger.warn("null cmdChannel, clientKey is {}", clientKey);
}
// 数据发送完成后再关闭连接解决http1.0数据传输问题
userChannel.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
} else {
Map<String, Channel> stringChannelMap = ctx.channel().attr(USER_CHANNELS).get();
List<Integer> ports = ctx.channel().attr(CHANNEL_PORT).get();
if (ports != null) {
for (int port : ports) {
ProxyChannelManager.removePortLanInfo(port);
// 如果之前有连接在使用,避免客户端重试导致混乱,端口进入冷却期
if (stringChannelMap != null && !stringChannelMap.isEmpty()) {
reverseServerContainer.getPortPoolManager().release(port);
} else {
reverseServerContainer.getPortPoolManager().releaseImmediately(port);
}
reverseServerContainer.releaseUserPort(port);
}
}
String clientKey = ctx.channel().attr(CHANNEL_CLIENT_KEY).get();
if (clientKey != null) {
reverseServerContainer.userSandboxOfflineTouch(clientKey);
}
ProxyChannelManager.removeCmdChannel(ctx.channel());
}
super.channelInactive(ctx);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
logger.error("exception caught", cause);
super.exceptionCaught(ctx, cause);
}
}

View File

@@ -0,0 +1,77 @@
package com.xspaceagi.sandbox.infra.network;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import java.io.*;
import java.net.URL;
import java.security.*;
import java.security.cert.CertificateException;
public class SslContextCreator {
private static Logger logger = LoggerFactory.getLogger(SslContextCreator.class);
public SSLContext initSSLContext() {
logger.info("Checking SSL configuration properties...");
final String jksPath = "ssl.jks";
logger.info("Initializing SSL context. KeystorePath = {}.", jksPath);
// if we have the port also the jks then keyStorePassword and
// keyManagerPassword
// has to be defined
final String keyStorePassword = "123456";
final String keyManagerPassword = "123456";
// if client authentification is enabled a trustmanager needs to be
// added to the ServerContext
boolean needsClientAuth = false;
try {
logger.info("Loading keystore. KeystorePath = {}.", jksPath);
try (InputStream jksInputStream = jksDatastore(jksPath)) {
if (jksInputStream == null) {
logger.warn("The keystore input stream is null. The SSL context won't be initialized.");
return null;
}
SSLContext serverContext = SSLContext.getInstance("TLS");
final KeyStore ks = KeyStore.getInstance("JKS");
ks.load(jksInputStream, keyStorePassword.toCharArray());
logger.info("Initializing key manager...");
final KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, keyManagerPassword.toCharArray());
// init sslContext
logger.info("Initializing SSL context...");
serverContext.init(kmf.getKeyManagers(), null, null);
logger.info("The SSL context has been initialized successfully.");
return serverContext;
}
} catch (NoSuchAlgorithmException | UnrecoverableKeyException | CertificateException | KeyStoreException
| KeyManagementException | IOException ex) {
logger.error("Unable to initialize SSL context. Cause = {}, errorMessage = {}.", ex.getCause(),
ex.getMessage());
return null;
}
}
private InputStream jksDatastore(String jksPath) throws FileNotFoundException {
URL jksUrl = getClass().getClassLoader().getResource(jksPath);
if (jksUrl != null) {
logger.info("Starting with jks at {}, jks normal {}", jksUrl.toExternalForm(), jksUrl);
return getClass().getClassLoader().getResourceAsStream(jksPath);
}
logger.warn("No keystore has been found in the bundled resources. Scanning filesystem...");
File jksFile = new File(jksPath);
if (jksFile.exists()) {
logger.info("Loading external keystore. Url = {}.", jksFile.getAbsolutePath());
return new FileInputStream(jksFile);
}
logger.warn("The keystore file does not exist. Url = {}.", jksFile.getAbsolutePath());
return null;
}
}

View File

@@ -0,0 +1,189 @@
package com.xspaceagi.sandbox.infra.network;
import com.xspaceagi.sandbox.infra.dao.service.SandboxProxyService;
import com.xspaceagi.sandbox.infra.dao.vo.SandboxProxyBackend;
import com.xspaceagi.sandbox.infra.network.protocol.Constants;
import com.xspaceagi.sandbox.infra.network.protocol.ProxyMessage;
import com.xspaceagi.system.spec.cache.SimpleJvmHashCache;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;
import io.netty.util.ReferenceCountUtil;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.SSLHandshakeException;
import java.net.InetAddress;
import java.util.LinkedList;
import java.util.Queue;
import static com.xspaceagi.sandbox.infra.network.UserChannelHandler.newUserId;
/**
* 客户端临时代理
*/
@Slf4j
public class TempLinkHttpProxyHandler extends ChannelInboundHandlerAdapter {
private static final Logger logger = LoggerFactory.getLogger(TempLinkHttpProxyHandler.class);
private final SandboxProxyService sandboxProxyService;
private final Queue<Object> receivedLastMessagesWhenConnect = new LinkedList<>();
public TempLinkHttpProxyHandler(SandboxProxyService sandboxProxyService) {
this.sandboxProxyService = sandboxProxyService;
}
@Override
public void channelRead(final ChannelHandlerContext ctx, final Object msg) {
if (msg instanceof HttpRequest) {
DefaultHttpRequest request = (DefaultHttpRequest) msg;
String host = request.headers().get("Host");
//获取域名开头字符串
String proxyKey = extractDomainPrefix(host);
SandboxProxyBackend sandboxProxyBackend = proxyKey == null ? null : sandboxProxyService.getBackendByProxyKey(proxyKey);
if (sandboxProxyBackend == null) {
sendError(ctx, "Invalid domain", msg);
return;
}
ctx.channel().pipeline().remove("httpServerCodec");
Channel cmdChannel = ProxyChannelManager.getCmdChannel(sandboxProxyBackend.getSandboxConfigKey());
if (cmdChannel == null) {
log.warn("no cmd channel {}", sandboxProxyBackend);
// 该端口还没有代理客户端
sendError(ctx, "Claw is offline", msg);
return;
} else {
String userId = newUserId();
Channel userChannel = ctx.channel();
// 用户连接到代理服务器时,设置用户连接不可读,等待代理后端服务器连接成功后再改变为可读状态
userChannel.config().setOption(ChannelOption.AUTO_READ, false);
ProxyChannelManager.addUserChannelToCmdChannel(cmdChannel, userId, userChannel);
ProxyMessage proxyMessage = new ProxyMessage();
proxyMessage.setType(ProxyMessage.TYPE_CONNECT);
proxyMessage.setUri(userId);
proxyMessage.setData((sandboxProxyBackend.getBackendHost() + ":" + sandboxProxyBackend.getBackendPort()).getBytes());
cmdChannel.writeAndFlush(proxyMessage);
}
ByteBuf firstRequest = convertToByteBuf(request);
receivedLastMessagesWhenConnect.offer(firstRequest);
ctx.channel().attr(Constants.MESSAGE_QUEUE).set(receivedLastMessagesWhenConnect);
ReferenceCountUtil.release(request);
} else {
Channel targetChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (targetChannel != null) {
if (msg instanceof ByteBuf buf) {
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
String userId = ProxyChannelManager.getUserChannelUserId(ctx.channel());
ProxyMessage proxyMessage0 = new ProxyMessage();
proxyMessage0.setType(ProxyMessage.P_TYPE_TRANSFER);
proxyMessage0.setUri(userId);
proxyMessage0.setData(bytes);
targetChannel.writeAndFlush(proxyMessage0);
ReferenceCountUtil.release(buf);
}
} else {
if (msg instanceof HttpContent) {
ByteBuf content = ((HttpContent) msg).content();
receivedLastMessagesWhenConnect.offer(content);
} else if (msg instanceof ByteBuf) {
receivedLastMessagesWhenConnect.offer(msg);
} else {
log.warn("unexpected message type {}", msg.getClass());
ReferenceCountUtil.release(msg);
}
}
}
}
private void sendError(ChannelHandlerContext ctx, String message, Object msg) {
FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_FOUND,
Unpooled.copiedBuffer("{\"error\":{\"message\":\"" + message + "\"}}", CharsetUtil.UTF_8));
resp.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=UTF-8");
ctx.channel().writeAndFlush(resp).addListener(ChannelFutureListener.CLOSE);
ReferenceCountUtil.release(msg);
}
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
Channel targetChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (targetChannel != null) {
targetChannel.config().setOption(ChannelOption.AUTO_READ, ctx.channel().isWritable());
}
super.channelWritabilityChanged(ctx);
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
super.channelActive(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
Channel targetChannel = ctx.channel().attr(Constants.NEXT_CHANNEL).get();
if (targetChannel != null) {
try {
targetChannel.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
} catch (Exception ignored) {
}
}
super.channelInactive(ctx);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
if (cause.getCause() != null && (cause.getCause() instanceof SSLHandshakeException)) {
logger.warn("SSLHandshakeException: {}", cause.getCause().getMessage());
return;
}
super.exceptionCaught(ctx, cause);
}
private static String extractDomainPrefix(String domain) {
if (domain == null || domain.isEmpty()) {
return null;
}
int dotIndex = domain.indexOf('.');
if (dotIndex == -1) {
return domain;
}
return domain.substring(0, dotIndex);
}
// 方法1: 将HttpRequest序列化为ByteBuf
public static ByteBuf convertToByteBuf(HttpRequest request) {
ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
try {
// 写入请求行
buffer.writeBytes(request.method().name().getBytes());
buffer.writeByte(' ');
buffer.writeBytes(request.uri().getBytes());
buffer.writeByte(' ');
buffer.writeBytes(request.protocolVersion().text().getBytes());
buffer.writeBytes("\r\n".getBytes());
// 写入请求头
request.headers().forEach(entry -> {
buffer.writeBytes(entry.getKey().getBytes());
buffer.writeBytes(": ".getBytes());
buffer.writeBytes(entry.getValue().getBytes());
buffer.writeBytes("\r\n".getBytes());
});
// 写入空行
buffer.writeBytes("\r\n".getBytes());
return buffer;
} catch (Exception e) {
buffer.release();
throw e;
}
}
}

View File

@@ -0,0 +1,146 @@
package com.xspaceagi.sandbox.infra.network;
import com.xspaceagi.sandbox.infra.network.protocol.Constants;
import com.xspaceagi.sandbox.infra.network.protocol.ProxyMessage;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelOption;
import io.netty.channel.SimpleChannelInboundHandler;
import lombok.extern.slf4j.Slf4j;
import java.net.InetSocketAddress;
import java.util.concurrent.atomic.AtomicLong;
/**
* 处理服务端 channel.
*/
@Slf4j
public class UserChannelHandler extends SimpleChannelInboundHandler<ByteBuf> {
private static final AtomicLong userIdProducer = new AtomicLong(0);
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
// 当出现异常就关闭连接
ctx.close();
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) {
// 通知代理客户端
Channel userChannel = ctx.channel();
Channel proxyChannel = userChannel.attr(Constants.NEXT_CHANNEL).get();
if (proxyChannel == null) {
// 该端口还没有代理客户端
ctx.channel().close();
} else {
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
String userId = ProxyChannelManager.getUserChannelUserId(userChannel);
ProxyMessage proxyMessage = new ProxyMessage();
proxyMessage.setType(ProxyMessage.P_TYPE_TRANSFER);
proxyMessage.setUri(userId);
proxyMessage.setData(bytes);
proxyChannel.writeAndFlush(proxyMessage);
}
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
Channel userChannel = ctx.channel();
InetSocketAddress sa = (InetSocketAddress) userChannel.localAddress();
Channel cmdChannel = ProxyChannelManager.getCmdChannel(sa.getPort());
if (cmdChannel == null) {
log.warn("no cmd channel for port {}", sa.getPort());
// 该端口还没有代理客户端
ctx.channel().close();
} else {
String userId = newUserId();
String lanInfo = ProxyChannelManager.getPortLanInfo(sa.getPort());
if (lanInfo == null) {
log.warn("no lan info for port {}", sa.getPort());
ctx.channel().close();
return;
}
// 用户连接到代理服务器时,设置用户连接不可读,等待代理后端服务器连接成功后再改变为可读状态
userChannel.config().setOption(ChannelOption.AUTO_READ, false);
ProxyChannelManager.addUserChannelToCmdChannel(cmdChannel, userId, userChannel);
ProxyMessage proxyMessage = new ProxyMessage();
proxyMessage.setType(ProxyMessage.TYPE_CONNECT);
proxyMessage.setUri(userId);
proxyMessage.setData(lanInfo.getBytes());
cmdChannel.writeAndFlush(proxyMessage);
}
super.channelActive(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
// 通知代理客户端
Channel userChannel = ctx.channel();
InetSocketAddress sa = (InetSocketAddress) userChannel.localAddress();
Channel cmdChannel = ProxyChannelManager.getCmdChannel(sa.getPort());
if (cmdChannel == null) {
// 该端口还没有代理客户端
ctx.channel().close();
} else {
// 用户连接断开,从控制连接中移除
String userId = ProxyChannelManager.getUserChannelUserId(userChannel);
ProxyChannelManager.removeUserChannelFromCmdChannel(cmdChannel, userId);
Channel proxyChannel = userChannel.attr(Constants.NEXT_CHANNEL).get();
if (proxyChannel != null && proxyChannel.isActive()) {
proxyChannel.attr(Constants.NEXT_CHANNEL).remove();
proxyChannel.attr(Constants.CLIENT_KEY).remove();
proxyChannel.attr(Constants.USER_ID).remove();
proxyChannel.config().setOption(ChannelOption.AUTO_READ, true);
// 通知客户端,用户连接已经断开
ProxyMessage proxyMessage = new ProxyMessage();
proxyMessage.setType(ProxyMessage.TYPE_DISCONNECT);
proxyMessage.setUri(userId);
proxyChannel.writeAndFlush(proxyMessage);
}
}
super.channelInactive(ctx);
}
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
// 通知代理客户端
Channel userChannel = ctx.channel();
InetSocketAddress sa = (InetSocketAddress) userChannel.localAddress();
Channel cmdChannel = ProxyChannelManager.getCmdChannel(sa.getPort());
if (cmdChannel == null) {
// 该端口还没有代理客户端
ctx.channel().close();
} else {
Channel proxyChannel = userChannel.attr(Constants.NEXT_CHANNEL).get();
if (proxyChannel != null) {
proxyChannel.config().setOption(ChannelOption.AUTO_READ, userChannel.isWritable());
}
}
super.channelWritabilityChanged(ctx);
}
/**
* 为用户连接产生ID
*
* @return
*/
public static String newUserId() {
return String.valueOf(userIdProducer.incrementAndGet());
}
}

View File

@@ -0,0 +1,412 @@
package com.xspaceagi.sandbox.infra.network;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelDuplexHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPromise;
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
import io.netty.handler.codec.http.websocketx.ContinuationWebSocketFrame;
import io.netty.handler.codec.http.websocketx.PingWebSocketFrame;
import io.netty.handler.codec.http.websocketx.PongWebSocketFrame;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import lombok.extern.slf4j.Slf4j;
import java.util.Queue;
/**
* WebSocket Frame转换为ByteBuf的Handler
* <p>
* 该Handler作为透明转换层位于WebSocket解码器和UserChannelHandler之间
* 将WebSocket Frame转换为ByteBuf使UserChannelHandler无感知地处理WebSocket数据
* <p>
* 数据流向:
* WebSocket Frame -> WebSocketFrameToByteBufHandler -> ByteBuf -> UserChannelHandler
*
* @author sandbox
*/
@Slf4j
public class WebSocketFrameToByteBufHandler extends ChannelDuplexHandler {
/**
* WebSocket握手是否完成
*/
private volatile boolean handshakeComplete = false;
/**
* 握手期间的待发送数据队列
*/
private final java.util.Queue<Object> pendingWrites = new java.util.LinkedList<>();
/**
* 用于累加分片消息的缓冲区
*/
private ByteBuf fragmentationBuffer;
/**
* 当前分片消息的类型true为文本false为二进制
*/
private Boolean isTextFragment;
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
handshakeComplete = true;
log.info("🤝 WebSocket handshake completed! Channel: {}, flushing {} pending messages...",
ctx.channel().id().asShortText(), pendingWrites.size());
// 发送所有缓存的数据
Object pendingMsg;
int count = 0;
while ((pendingMsg = pendingWrites.poll()) != null) {
ctx.write(pendingMsg);
count++;
}
// 批量flush
if (count > 0) {
ctx.flush();
log.info("✅ Flushed {} pending messages after handshake", count);
}
}
super.userEventTriggered(ctx, evt);
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (!(msg instanceof WebSocketFrame)) {
// 非WebSocket Frame直接传递给下一个handler
log.trace("Passing non-WebSocket message: {}", msg.getClass().getSimpleName());
ctx.fireChannelRead(msg);
return;
}
WebSocketFrame frame = (WebSocketFrame) msg;
log.trace("Received WebSocket frame: {}", frame.getClass().getSimpleName());
try {
if (frame instanceof CloseWebSocketFrame) {
handleCloseFrame(ctx, (CloseWebSocketFrame) frame);
} else if (frame instanceof PingWebSocketFrame) {
handlePingFrame(ctx, (PingWebSocketFrame) frame);
} else if (frame instanceof PongWebSocketFrame) {
handlePongFrame(ctx, (PongWebSocketFrame) frame);
} else if (frame instanceof TextWebSocketFrame) {
handleTextFrame(ctx, (TextWebSocketFrame) frame);
} else if (frame instanceof BinaryWebSocketFrame) {
handleBinaryFrame(ctx, (BinaryWebSocketFrame) frame);
} else if (frame instanceof ContinuationWebSocketFrame) {
handleContinuationFrame(ctx, (ContinuationWebSocketFrame) frame);
} else {
log.warn("Unsupported WebSocket frame type: {}", frame.getClass().getName());
frame.release();
}
} catch (Exception e) {
log.error("Error processing WebSocket frame", e);
frame.release();
throw e;
}
}
/**
* 处理关闭帧
* Close帧不应该传递给UserChannelHandler而是直接关闭连接
*/
private void handleCloseFrame(ChannelHandlerContext ctx, CloseWebSocketFrame frame) {
log.info("Received Close WebSocket Frame, closing connection");
try {
// 回复Close帧
ctx.writeAndFlush(frame.retain()).addListener(future -> {
ctx.close();
log.debug("WebSocket connection closed after receiving Close frame");
});
} catch (Exception e) {
log.error("Error handling close frame", e);
ctx.close();
}
}
/**
* 处理Ping帧
*/
private void handlePingFrame(ChannelHandlerContext ctx, PingWebSocketFrame frame) {
log.debug("Received Ping WebSocket Frame");
try {
// 自动回复Pong帧
ByteBuf content = frame.content();
if (content.isReadable()) {
ctx.writeAndFlush(new PongWebSocketFrame(content.retainedDuplicate()));
} else {
ctx.writeAndFlush(new PongWebSocketFrame());
}
} finally {
frame.release();
}
}
/**
* 处理Pong帧
*/
private void handlePongFrame(ChannelHandlerContext ctx, PongWebSocketFrame frame) {
log.debug("Received Pong WebSocket Frame");
// Pong帧不需要转发给UserChannelHandler
frame.release();
}
/**
* 处理文本帧
*/
private void handleTextFrame(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
try {
String text = frame.text();
log.debug("Received Text WebSocket Frame, length: {}", text.length());
// 检查是否为分片的最后一帧
boolean isFinal = frame.isFinalFragment();
if (!isFinal) {
// 开始接收分片消息
if (fragmentationBuffer == null) {
fragmentationBuffer = ctx.alloc().buffer();
isTextFragment = true;
log.debug("Start receiving text fragment");
}
// 累积分片数据
fragmentationBuffer.writeBytes(text.getBytes(io.netty.util.CharsetUtil.UTF_8));
} else {
// 最后一帧或完整的消息
if (fragmentationBuffer != null && Boolean.TRUE.equals(isTextFragment)) {
// 分片结束
fragmentationBuffer.writeBytes(text.getBytes(io.netty.util.CharsetUtil.UTF_8));
// 转发给UserChannelHandler
ByteBuf completeBuffer = fragmentationBuffer.retainedSlice();
ctx.fireChannelRead(completeBuffer);
log.debug("Completed text fragment, total length: {}", fragmentationBuffer.readableBytes());
// 释放并重置缓冲区
fragmentationBuffer.release();
fragmentationBuffer = null;
isTextFragment = null;
} else {
// 单个完整的消息转换为ByteBuf转发
byte[] textBytes = text.getBytes(io.netty.util.CharsetUtil.UTF_8);
ByteBuf buffer = Unpooled.wrappedBuffer(textBytes);
ctx.fireChannelRead(buffer);
log.debug("Forwarded text data as ByteBuf, length: {}", textBytes.length);
}
}
} finally {
frame.release();
}
}
/**
* 处理二进制帧
*/
private void handleBinaryFrame(ChannelHandlerContext ctx, BinaryWebSocketFrame frame) {
try {
ByteBuf binaryData = frame.content();
int length = binaryData.readableBytes();
log.debug("Received Binary WebSocket Frame, length: {}, final: {}", length, frame.isFinalFragment());
// 检查是否为分片的最后一帧
boolean isFinal = frame.isFinalFragment();
if (!isFinal) {
// 开始接收分片消息
if (fragmentationBuffer == null) {
fragmentationBuffer = ctx.alloc().buffer();
isTextFragment = false;
log.info("Start receiving binary fragment");
}
// 累积分片数据
fragmentationBuffer.writeBytes(binaryData);
log.trace("Accumulated binary fragment, current size: {}", fragmentationBuffer.readableBytes());
} else {
// 最后一帧或完整的消息
if (fragmentationBuffer != null && Boolean.FALSE.equals(isTextFragment)) {
// 分片结束
fragmentationBuffer.writeBytes(binaryData);
// 转发给UserChannelHandler
ByteBuf completeBuffer = fragmentationBuffer.retainedSlice();
log.info("Completed binary fragment, total length: {}, forwarding to UserChannelHandler", completeBuffer.readableBytes());
ctx.fireChannelRead(completeBuffer);
log.trace("Binary fragment successfully forwarded to UserChannelHandler");
// 释放并重置缓冲区
fragmentationBuffer.release();
fragmentationBuffer = null;
isTextFragment = null;
} else {
// 单个完整的消息,直接转发
ByteBuf forwardBuffer = binaryData.retain();
log.info("Forwarding complete binary message to UserChannelHandler, length: {}", length);
ctx.fireChannelRead(forwardBuffer);
log.trace("Binary message successfully forwarded to UserChannelHandler");
}
}
} finally {
frame.release();
}
}
/**
* 处理续传帧(分片消息的中间帧)
*/
private void handleContinuationFrame(ChannelHandlerContext ctx, ContinuationWebSocketFrame frame) {
try {
log.debug("Received Continuation WebSocket Frame");
// 检查是否有正在进行的分片接收
if (fragmentationBuffer == null) {
log.warn("Received ContinuationFrame without active fragmentation, discarding");
return;
}
ByteBuf continuationData = frame.content();
boolean isFinal = frame.isFinalFragment();
// 累加续传数据
fragmentationBuffer.writeBytes(continuationData);
if (isFinal) {
// 分片结束,转发完整数据
ByteBuf completeBuffer = fragmentationBuffer.retainedSlice();
ctx.fireChannelRead(completeBuffer);
log.debug("Completed continuation fragment, total length: {}, type: {}",
completeBuffer.readableBytes(), isTextFragment ? "TEXT" : "BINARY");
// 释放并重置缓冲区
fragmentationBuffer.release();
fragmentationBuffer = null;
isTextFragment = null;
}
} finally {
frame.release();
}
}
/**
* 写数据时将ByteBuf转换为WebSocket Frame
* 这个方法会在Channel.write()时被调用
*/
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
// 检查WebSocket握手是否完成
if (!handshakeComplete) {
String msgType = msg.getClass().getSimpleName();
// 握手期间只缓存需要转换为WebSocket Frame的数据
// HTTP相关消息HttpResponse等不缓存让WebSocketServerProtocolHandler处理
if (msg instanceof ByteBuf || msg instanceof byte[] || msg instanceof String) {
log.debug("⏳ WebSocket handshake in progress, buffering message. Type: {}, Queue size: {}",
msgType, pendingWrites.size());
Object convertedMsg = convertToWebSocketFrame(ctx, msg);
if (convertedMsg != null) {
pendingWrites.offer(convertedMsg);
promise.setSuccess();
return;
}
}
// HTTP消息握手响应等直接透传不缓存
log.trace("Passing through HTTP message during handshake: {}", msgType);
ctx.write(msg, promise);
return;
}
// 检查channel是否可写
if (!ctx.channel().isWritable()) {
log.warn("⚠️ Channel is NOT writable! Data will be buffered. Channel: {}, write buffer: {}",
ctx.channel().id().asShortText(), ctx.channel().bytesBeforeUnwritable());
}
try {
Object convertedMsg = convertToWebSocketFrame(ctx, msg);
if (convertedMsg != null) {
// 关键修复使用writeAndFlush而不是write
ctx.writeAndFlush(convertedMsg, promise);
log.trace("✅ WebSocket frame sent successfully, type: {}", convertedMsg.getClass().getSimpleName());
} else {
// 其他类型直接透传
ctx.write(msg, promise);
}
} catch (Exception e) {
log.error("❌ Error in write, channel: {}, isActive: {}", ctx.channel(), ctx.channel().isActive(), e);
promise.setFailure(e);
throw e;
}
}
/**
* 将消息转换为WebSocket Frame
*/
private Object convertToWebSocketFrame(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof ByteBuf) {
ByteBuf byteBuf = (ByteBuf) msg;
log.debug("📤 Convert ByteBuf to BinaryWebSocketFrame, length: {}, channel: {}",
byteBuf.readableBytes(), ctx.channel().id().asShortText());
return new BinaryWebSocketFrame(byteBuf.retain());
} else if (msg instanceof byte[]) {
byte[] bytes = (byte[]) msg;
log.debug("📤 Convert byte[] to BinaryWebSocketFrame, length: {}", bytes.length);
return new BinaryWebSocketFrame(Unpooled.wrappedBuffer(bytes));
} else if (msg instanceof String) {
String text = (String) msg;
log.debug("📤 Convert String to TextWebSocketFrame, length: {}", text.length());
return new TextWebSocketFrame(text);
} else if (msg instanceof BinaryWebSocketFrame || msg instanceof TextWebSocketFrame) {
// 已经是WebSocket Frame直接返回
return msg;
} else {
log.trace("Passing through non-converted message: {}", msg.getClass().getSimpleName());
return null; // 表示不需要转换
}
}
@Override
public void flush(ChannelHandlerContext ctx) throws Exception {
ctx.flush();
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.debug("WebSocket to ByteBuf converter inactive");
cleanupResources();
super.channelInactive(ctx);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("WebSocket to ByteBuf converter exception", cause);
cleanupResources();
ctx.fireExceptionCaught(cause);
}
/**
* 清理资源
*/
private void cleanupResources() {
if (fragmentationBuffer != null) {
fragmentationBuffer.release();
fragmentationBuffer = null;
}
isTextFragment = null;
// 清空待发送队列
if (!pendingWrites.isEmpty()) {
log.warn("⚠️ Cleaning up {} pending messages due to channel inactive", pendingWrites.size());
Object msg;
while ((msg = pendingWrites.poll()) != null) {
if (msg instanceof io.netty.util.ReferenceCounted) {
((io.netty.util.ReferenceCounted) msg).release();
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.sandbox.infra.network.protocol;
import io.netty.channel.Channel;
import io.netty.util.AttributeKey;
import java.util.Queue;
public interface Constants {
AttributeKey<Channel> NEXT_CHANNEL = AttributeKey.newInstance("nxt_channel");
AttributeKey<String> USER_ID = AttributeKey.newInstance("user_id");
AttributeKey<String> CLIENT_KEY = AttributeKey.newInstance("client_key");
AttributeKey<Queue<Object>> MESSAGE_QUEUE = AttributeKey.newInstance("message_queue");
}

View File

@@ -0,0 +1,43 @@
package com.xspaceagi.sandbox.infra.network.protocol;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.handler.timeout.IdleStateHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* check idle chanel.
*
* @author fengfei
*
*/
public class IdleCheckHandler extends IdleStateHandler {
public static final int USER_CHANNEL_READ_IDLE_TIME = 1200;
public static final int READ_IDLE_TIME = 60;
public static final int WRITE_IDLE_TIME = 40;
private static Logger logger = LoggerFactory.getLogger(IdleCheckHandler.class);
public IdleCheckHandler(int readerIdleTimeSeconds, int writerIdleTimeSeconds, int allIdleTimeSeconds) {
super(readerIdleTimeSeconds, writerIdleTimeSeconds, allIdleTimeSeconds);
}
@Override
protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) throws Exception {
if (IdleStateEvent.FIRST_WRITER_IDLE_STATE_EVENT == evt) {
logger.debug("channel write timeout {}", ctx.channel());
ProxyMessage proxyMessage = new ProxyMessage();
proxyMessage.setType(ProxyMessage.TYPE_HEARTBEAT);
ctx.channel().writeAndFlush(proxyMessage);
} else if (IdleStateEvent.FIRST_READER_IDLE_STATE_EVENT == evt) {
logger.warn("channel read timeout {}", ctx.channel());
ctx.channel().close();
}
super.channelIdle(ctx, evt);
}
}

View File

@@ -0,0 +1,83 @@
package com.xspaceagi.sandbox.infra.network.protocol;
import java.util.Arrays;
/**
* 代理客户端与代理服务器消息交换协议
*
* @author fengfei
*
*/
public class ProxyMessage {
/** 心跳消息 */
public static final byte TYPE_HEARTBEAT = 0x07;
/** 认证消息检测clientKey是否正确 */
public static final byte C_TYPE_AUTH = 0x01;
// /** 保活确认消息 */
// public static final byte TYPE_ACK = 0x02;
/** 代理后端服务器建立连接消息 */
public static final byte TYPE_CONNECT = 0x03;
/** 代理后端服务器断开连接消息 */
public static final byte TYPE_DISCONNECT = 0x04;
/** 代理数据传输 */
public static final byte P_TYPE_TRANSFER = 0x05;
/** 用户与代理服务器以及代理客户端与真实服务器连接是否可写状态同步 */
public static final byte C_TYPE_WRITE_CONTROL = 0x06;
/** 消息类型 */
private byte type;
/** 消息流水号 */
private long serialNumber;
/** 消息命令请求信息 */
private String uri;
/** 消息传输数据 */
private byte[] data;
public void setUri(String uri) {
this.uri = uri;
}
public String getUri() {
return uri;
}
public byte[] getData() {
return data;
}
public void setData(byte[] data) {
this.data = data;
}
public byte getType() {
return type;
}
public void setType(byte type) {
this.type = type;
}
public long getSerialNumber() {
return serialNumber;
}
public void setSerialNumber(long serialNumber) {
this.serialNumber = serialNumber;
}
@Override
public String toString() {
return "ProxyMessage [type=" + type + ", serialNumber=" + serialNumber + ", uri=" + uri + ", data=" + Arrays.toString(data) + "]";
}
}

View File

@@ -0,0 +1,78 @@
package com.xspaceagi.sandbox.infra.network.protocol;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
public class ProxyMessageDecoder extends LengthFieldBasedFrameDecoder {
private static final byte HEADER_SIZE = 4;
private static final int TYPE_SIZE = 1;
private static final int SERIAL_NUMBER_SIZE = 8;
private static final int URI_LENGTH_SIZE = 1;
/**
* @param maxFrameLength
* @param lengthFieldOffset
* @param lengthFieldLength
* @param lengthAdjustment
* @param initialBytesToStrip
*/
public ProxyMessageDecoder(int maxFrameLength, int lengthFieldOffset, int lengthFieldLength, int lengthAdjustment,
int initialBytesToStrip) {
super(maxFrameLength, lengthFieldOffset, lengthFieldLength, lengthAdjustment, initialBytesToStrip);
}
/**
* @param maxFrameLength
* @param lengthFieldOffset
* @param lengthFieldLength
* @param lengthAdjustment
* @param initialBytesToStrip
* @param failFast
*/
public ProxyMessageDecoder(int maxFrameLength, int lengthFieldOffset, int lengthFieldLength, int lengthAdjustment,
int initialBytesToStrip, boolean failFast) {
super(maxFrameLength, lengthFieldOffset, lengthFieldLength, lengthAdjustment, initialBytesToStrip, failFast);
}
@Override
protected ProxyMessage decode(ChannelHandlerContext ctx, ByteBuf in2) throws Exception {
ByteBuf in = (ByteBuf) super.decode(ctx, in2);
if (in == null) {
return null;
}
if (in.readableBytes() < HEADER_SIZE) {
return null;
}
int frameLength = in.readInt();
if (in.readableBytes() < frameLength) {
return null;
}
ProxyMessage proxyMessage = new ProxyMessage();
byte type = in.readByte();
long sn = in.readLong();
proxyMessage.setSerialNumber(sn);
proxyMessage.setType(type);
byte uriLength = in.readByte();
byte[] uriBytes = new byte[uriLength];
in.readBytes(uriBytes);
proxyMessage.setUri(new String(uriBytes));
byte[] data = new byte[frameLength - TYPE_SIZE - SERIAL_NUMBER_SIZE - URI_LENGTH_SIZE - uriLength];
in.readBytes(data);
proxyMessage.setData(data);
in.release();
return proxyMessage;
}
}

View File

@@ -0,0 +1,45 @@
package com.xspaceagi.sandbox.infra.network.protocol;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;
public class ProxyMessageEncoder extends MessageToByteEncoder<ProxyMessage> {
private static final int TYPE_SIZE = 1;
private static final int SERIAL_NUMBER_SIZE = 8;
private static final int URI_LENGTH_SIZE = 1;
@Override
protected void encode(ChannelHandlerContext ctx, ProxyMessage msg, ByteBuf out) throws Exception {
int bodyLength = TYPE_SIZE + SERIAL_NUMBER_SIZE + URI_LENGTH_SIZE;
byte[] uriBytes = null;
if (msg.getUri() != null) {
uriBytes = msg.getUri().getBytes();
bodyLength += uriBytes.length;
}
if (msg.getData() != null) {
bodyLength += msg.getData().length;
}
// write the total packet length but without length field's length.
out.writeInt(bodyLength);
out.writeByte(msg.getType());
out.writeLong(msg.getSerialNumber());
if (uriBytes != null) {
out.writeByte((byte) uriBytes.length);
out.writeBytes(uriBytes);
} else {
out.writeByte((byte) 0x00);
}
if (msg.getData() != null) {
out.writeBytes(msg.getData());
}
}
}