本代码共提供两种实现方案
仓库地址:https://gitee.com/cui-lingyu/CodeSanbox
实现功能
- api 签名认证
- docker 实现
- 本地实现 (本地实现未改造为 抽象模板方式,推荐使用 docker 容器方式)
- 容器池管理 docker 容器
- 运行 java 代码 (目前只支持 java 代码,后续可能会继续扩展,也可 fork 自定义扩展,只需继承 AbstractionCodeSandBox 抽象模板 即可自定义)
签名认证
在拦截器中去验证签名并防止请求重放攻击
主要参数
- accessKey:每个用户独有,配合secretKey使用
- nonce:用来防止请求重放,会放在 redis 中缓存三秒,若三秒内nonce 相同责视为重放攻击
- timestamp:与nonce 配合使用都是用来防止重放的,判断条件:请求发送到这里时,若与当前时间戳相差大于 3 秒则视为重放
- autograph:签名。生成规则为:通过 算法的方法名+盐值(可以在配置文件中配置)+secretKey 生成 32 位 MD5 摘要值并转为 16 进制字符串作为签名的值
autograph:
salt: "CrazyRain"
拦截器
@Component
public class SignInterceptor implements HandlerInterceptor {
@Value("${autograph.salt}")
private String salt;
@Resource
private UsersService usersService;
@Resource
private RedisTemplate<String, String> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
RepeatableHttpServletRequest repeatableHttpServletRequest = (RepeatableHttpServletRequest) request;
//在读取参数之前设置响应编码 否则设置失效
response.setContentType("application/json;charset=UTF-8");
String accessKey = repeatableHttpServletRequest.getHeader("access-key");
String nonce = repeatableHttpServletRequest.getHeader("nonce");
String timestamp = repeatableHttpServletRequest.getHeader("timestamp");
String autograph = repeatableHttpServletRequest.getHeader("autograph");
//非空判断
if (StringUtils.isAnyBlank(accessKey, nonce, timestamp, autograph)) {
PrintWriter writer = response.getWriter();
writer.write(error("验证失败", 4001));
return false;
}
//判断是否为请求重放
ValueOperations<String, String> stringStringValueOperations = redisTemplate.opsForValue();
String nonceTmp = stringStringValueOperations.get(nonce);
if (StringUtils.isNotBlank(nonceTmp)) {
PrintWriter writer = response.getWriter();
writer.write(error("重放攻击", 4002));
return false;
}
//设置随机数过期时间为3秒
stringStringValueOperations.set("nonce", nonce, 3, TimeUnit.SECONDS);
//判断时间戳是否离当前时间过长
long timestampLong = Long.parseLong(timestamp);
long currentTimeMillis = System.currentTimeMillis() / 1000;
// 如果请求发送到这里已经间隔三秒 则表示不通过
if (currentTimeMillis - timestampLong > 3) {
PrintWriter writer = response.getWriter();
writer.write(error("请求超时", 4003));
return false;
}
//获取请求参数
BufferedReader requestReader = repeatableHttpServletRequest.getReader();
StringBuilder body = new StringBuilder();
String line = "";
while ((line = requestReader.readLine()) != null) {
body.append(line);
}
String bodyString = body.toString();
ExecutionParameter executionParameter = JSONUtil.toBean(bodyString, ExecutionParameter.class);
LambdaQueryWrapper<Users> usersLambdaQueryWrapper = new LambdaQueryWrapper<>();
usersLambdaQueryWrapper.eq(Users::getAccessKey, accessKey);
Users user = usersService.getOne(usersLambdaQueryWrapper);
if (user == null) {
PrintWriter writer = response.getWriter();
writer.write(error("用户不存在", 4003));
return false;
}
//根据相同的数据 和算法生成签名 对比 传递过来的签名是否相同
String secretKey = user.getSecretKey();
String methodName = executionParameter.getMethodName();
String sign = DigestUtil.md5Hex(methodName
+ salt
+ secretKey);
if (!autograph.equals(sign)) {
PrintWriter writer = response.getWriter();
writer.write(error("签名认证失败", 4004));
return false;
}
return HandlerInterceptor.super.preHandle(repeatableHttpServletRequest, response, handler);
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
private String error(String errorMessage, Integer errorCode) {
HashMap<String, Object> stringObjectHashMap = new HashMap<>() {
{
put("success", false);
put("errorMessage", errorMessage);
put("errorCode", errorCode);
}
};
return JSONUtil.toJsonStr(stringObjectHashMap);
}
}
因为 请求体只可以被获取一次所以需要自定义包装类缓存当请求体
自定义请求包装类
package com.crazy.rain.inkfragrancepavilion.wrapper;
import cn.hutool.core.io.IoUtil;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.apache.commons.io.IOUtils;
import java.io.*;
public class RepeatableHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] bytes;
public RepeatableHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
bytes = IoUtil.readBytes(request.getInputStream());
}
@Override
public ServletInputStream getInputStream() {
return new ServletInputStream() {
private int lastIndexRetrieved = -1;
private ReadListener readListener = null;
@Override
public boolean isFinished() {
return (lastIndexRetrieved == bytes.length - 1);
}
@Override
public boolean isReady() {
return isFinished();
}
@Override
public void setReadListener(ReadListener readListener) {
this.readListener = readListener;
if (!isFinished()) {
try {
readListener.onDataAvailable();
} catch (IOException e) {
readListener.onError(e);
}
} else {
try {
readListener.onAllDataRead();
} catch (IOException e) {
readListener.onError(e);
}
}
}
@Override
public int read() throws IOException {
int i;
if (!isFinished()) {
i = bytes[lastIndexRetrieved + 1];
lastIndexRetrieved++;
if (isFinished() && (readListener != null)) {
try {
readListener.onAllDataRead();
} catch (IOException ex) {
readListener.onError(ex);
throw ex;
}
}
return i;
} else {
return -1;
}
}
};
}
@Override
public BufferedReader getReader() {
ByteArrayInputStream is = new ByteArrayInputStream(bytes);
return new BufferedReader(new InputStreamReader(is));
}
}
过滤器
在过滤器中包装请求对象
package com.crazy.rain.inkfragrancepavilion.filter;
import com.crazy.rain.inkfragrancepavilion.wrapper.RepeatableHttpServletRequest;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
@WebFilter(filterName = "HttpServletRequestFilter",urlPatterns = {"/**"})
@Order(0)
@Component
public class HttpServletRequestFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = new RepeatableHttpServletRequest((HttpServletRequest) servletRequest);
HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
}
docker 方式
docker 容器池
-
ContainerPool 容器构造函数
-
创建 dockerClient 客户端
-
调用容器初始化方法 initializePool
-
容器初始化
-
HostConfig 配置 docker 容器相关配置(比如:数据卷挂载、内存限制等)
-
设置 linux 安全模块 seccomp 配置
-
getAvailableContainer 获取容器 id
-
returnContainer 将容器设置为空闲状态
-
ContainerInfo 容器相关属性信息
/**
* 容器池
*/
@Slf4j
public class ContainerPool {
private static final String USER_DIT = System.getProperty("user.dir");
private static final String SEPARATOR = File.separator;
private static final String RESOURCES_DIT = "src" + SEPARATOR + "main" + SEPARATOR + "resources";
private static final String GLOBAL_USER_CATALOG = USER_DIT + SEPARATOR + RESOURCES_DIT + SEPARATOR + "userCode";
private final String imageName;
private final List<ContainerInfo> containerPool;
private final int poolSize;
@Getter
private final DockerClient dockerClient;
public ContainerPool(int poolSize, String imageName) {
this.poolSize = poolSize;
this.imageName = imageName;
containerPool = new ArrayList<>();
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
.withApiVersion("1.46")
.build();
//创建docker http信息
DockerHttpClient httpClient = new ApacheDockerHttpClient
.Builder()
.dockerHost(config.getDockerHost())
.maxConnections(100)
.connectionTimeout(Duration.ofSeconds(30))
.responseTimeout(Duration.ofSeconds(45))
.build();
dockerClient = DockerClientImpl.getInstance(config, httpClient);
initializePool();
}
private void initializePool() {
for (int i = 0; i < poolSize; i++) {
//容器配置
HostConfig hostConfig = new HostConfig();
//限制容器最大内存
hostConfig.withMemory(100 * 1000 * 1000L);
//限制内存交换
hostConfig.withMemorySwap(0L);
//限制使用cpu数量
hostConfig.withCpuCount(1L);
//限制用户不能向Root写文件
hostConfig.withReadonlyRootfs(true);
String containerDirName = "container" + i;
String containerDirPath = GLOBAL_USER_CATALOG + SEPARATOR + containerDirName;
if (!FileUtil.exist(containerDirPath)) {
FileUtil.mkdir(containerDirPath);
}
hostConfig.setBinds(new Bind(containerDirPath,
new Volume(SEPARATOR + "home" + SEPARATOR + "rain" + SEPARATOR + "app")));
//读取安全配置文件
List<String> seccompList = FileUtil.readUtf8Lines(USER_DIT + SEPARATOR + RESOURCES_DIT
+ SEPARATOR + "seccomp.json");
StringBuilder seccompStr = new StringBuilder("seccomp=");
for (String str : seccompList) {
seccompStr.append(str);
}
//开启安全配置文件
hostConfig.withSecurityOpts(List.of(seccompStr.toString()));
CreateContainerCmd containerCmd = dockerClient.createContainerCmd(imageName);
CreateContainerResponse container = containerCmd.withAttachStdin(true)
.withName("container" + i)
//这三个属性表示让这个容器返回一个命令行而不是Jshell
.withTty(true)
.withStdinOpen(true)
.withCmd("bin/sh")
//关闭网络
.withNetworkDisabled(true)
.withHostConfig(hostConfig)
.withAttachStderr(true)
.withAttachStdout(true)
.exec();
dockerClient.startContainerCmd(container.getId()).exec();
containerPool.add(new ContainerInfo(container.getId(), true, containerDirName));
}
}
/**
* 返回容器id
* @return 容器id
*/
public ContainerInfo getAvailableContainer() {
for (ContainerInfo containerInfo : containerPool) {
if (containerInfo.isAvailable()) {
containerInfo.setAvailable(false);
return containerInfo;
}
}
return null;
}
/**
* 刷新容器状态
* @param containerId 容器id
*/
public void returnContainer(String containerId) {
if (StringUtils.isBlank(containerId)) {
log.info("容器id不存在:{}", containerId);
return;
}
for (ContainerInfo containerInfo : containerPool) {
if (containerInfo.getId().equals(containerId)) {
containerInfo.setAvailable(true);
break;
}
}
}
@Getter
public static class ContainerInfo {
private final String id;
@Setter
private boolean available;
private final String containerDirName;
public ContainerInfo(String id, boolean available, String containerDirName) {
this.id = id;
this.containerDirName = containerDirName;
this.available = available;
}
}
}
docker 运行代码执行流程
-
对上传的代码进行安全校验
-
获取 docker 容器
-
若 docker 容器有空闲就可以直接获取,否则阻塞(通过 semaphore 实现)
-
将代码保存到本地
-
读取默认代码模板,将用户上传的代码追加到默认代码模板中,再保存到本地
-
因为配置了数据卷挂载所以 docker 容器会自动同步这个代码文件
-
运行代码
-
生成代码执行命令
-
通过 docker 客户端执行该命令
-
收集代码执行结果返回
-
收集结果
-
清理文件等资源
@Slf4j
public abstract class AbstractionCodeSandBox implements CodeSandBox {
//TODO 修改获取资源的方式
private static final String USER_DIT = System.getProperty("user.dir");
private static final String SEPARATOR = File.separator;
private static final String RESOURCES_DIT = "src" + SEPARATOR + "main" + SEPARATOR + "resources";
private static final String GLOBAL_USER_CATALOG = USER_DIT + SEPARATOR + RESOURCES_DIT + SEPARATOR + "userCode";
private static final String GLOBAL_USER_CODE_FILE_NAME = "Main.java";
public static final List<String> BLACKLIST = new ArrayList<>();
public static WordTree WORD_TREE;
static {
WORD_TREE = new WordTree();
WORD_TREE.addWord(BLACKLIST.toString());
}
@Value("${code-sand-box.image-name}")
private String imageName;
@Resource
private ContainerPool containerPool;
/**
* 处理异常情况
* @param problemStatusEnum 系统执行状态
* @param testResultEnum 用户代码执行状态
* @param executiveMessage 系统执行信息
* @param judgeInfoMessage 用户代码执行信息
* @return 返回执行信息
*/
public static ExecutiveOutcome exceptionProcessor(ProblemStatusEnum problemStatusEnum, TestResultEnum testResultEnum
, String executiveMessage, String judgeInfoMessage) {
ExecutiveOutcome executiveOutcome = new ExecutiveOutcome();
if (problemStatusEnum != null) {
executiveOutcome.setStatus(problemStatusEnum.getStatus());
if (StringUtils.isNotBlank(executiveMessage)) {
executiveOutcome.setMessage(executiveMessage);
} else {
executiveOutcome.setMessage(problemStatusEnum.getMessage());
}
}
if (testResultEnum != null) {
JudgeInfo judgeInfo = new JudgeInfo();
judgeInfo.setStatus(testResultEnum.getStatus());
judgeInfo.setMessage(testResultEnum.getMessage());
if (StringUtils.isNotBlank(judgeInfoMessage)) {
judgeInfo.setMessage(judgeInfoMessage);
} else {
judgeInfo.setMessage(testResultEnum.getMessage());
}
executiveOutcome.setJudgeInfo(judgeInfo);
}
return executiveOutcome;
}
/**
* 校验用户代码
* @param code 待校验的代码
* @return 校验结果
*/
public boolean verifyCode(String code) {
//获取到用户代码 校验用户代码中是否存在违规关键字
FoundWord foundWord = WORD_TREE.matchWord(code);
if (foundWord != null) {
log.error("代码不合法:{}", foundWord.getFoundWord());
return false;
}
return true;
}
/**
* 保存用户代码
*/
public String saveUserCodeFile(String code, String containerDirName) {
//判断用户代码目录是否存在,不存在则创建
if (!FileUtil.exist(GLOBAL_USER_CATALOG)) {
FileUtil.mkdir(GLOBAL_USER_CATALOG);
}
//读取默认调用用户代码的模板
List<String> strings = FileUtil.readLines(GLOBAL_USER_CATALOG + SEPARATOR
+ "Main.java", StandardCharsets.UTF_8);
//拼接成字符串
StringBuilder stringBuilder = new StringBuilder();
strings.forEach(item -> stringBuilder.append(item).append("\n"));
//拼接用户代码(这里主要是将用户代码作为我们默认模板的内部类去使用,主要通过反射去实例化内部类对象,并调用方法)
stringBuilder.append(code);
//创建本次用户上传的目录(唯一)
String userDirName = GLOBAL_USER_CATALOG + SEPARATOR + containerDirName + SEPARATOR;
String userCodeFile = userDirName + SEPARATOR + GLOBAL_USER_CODE_FILE_NAME;
//将拼接好的文件保存
File file = FileUtil.writeUtf8String(stringBuilder.toString(), userCodeFile);
return file.getAbsolutePath();
}
/**
* 组装执行命令
* @param inputList 用户输入,可为null
* @param methodName 待执行算法名
* @return 待执行命令
*/
public List<String[]> createExecuteCommands(List<String> inputList, String methodName, String containerDirName) {
ArrayList<String[]> strings = new ArrayList<>();
if (inputList != null && !inputList.isEmpty()) {
inputList.forEach(item -> {
String[] inputArgsArray = item.split(",");
strings.add(ArrayUtil.append(new String[]{"java", SEPARATOR + "home" + SEPARATOR + "rain" +
SEPARATOR + "app" + SEPARATOR + containerDirName
+ SEPARATOR + "Main.java", methodName},
inputArgsArray));
});
} else {
//构建命令
strings.add(ArrayUtil.append(new String[]{"java", SEPARATOR + "home" + SEPARATOR + "rain" +
SEPARATOR + "app" + SEPARATOR + "Main.java", methodName}));
}
return strings;
}
public List<ExecutionInformation> runFile(String containerId, List<String> inputs, String containerDirName
, String methodName) {
//用户代码是否执行异常
ArrayList<ExecutionInformation> executionInformationArrayList = new ArrayList<>();
DockerClient dockerClient = containerPool.getDockerClient();
try {
//获取执行命令
List<String[]> executeCommands = createExecuteCommands(inputs, methodName, containerDirName);
// 拼接用户程序执行错误信息
StringBuilder errorMessage = new StringBuilder();
// 拼接用户程序执行成功信息
StringBuilder message = new StringBuilder();
ExecutionInformation executionInformation = new ExecutionInformation();
executeCommands.forEach(item -> {
ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
.withCmd(item)
.withAttachStderr(true)
.withAttachStdin(true)
.withAttachStdout(true)
.exec();
//标记代码是否运行超时
final boolean[] hasItTimedOut = {true};
try {
dockerClient.execStartCmd(execCreateCmdResponse.getId())
.exec(new ResultCallback.Adapter<>() {
//若程序在超时时间内,运行结束,则调用此代码
@Override
public void onComplete() {
hasItTimedOut[0] = false;
super.onComplete();
}
//每个阶段结束就会回调
@Override
public void onNext(Frame frame) {
StreamType streamType = frame.getStreamType();
if (StreamType.STDERR.equals(streamType)) {
errorMessage.append(new String(frame.getPayload()));
} else {
message.append(new String(frame.getPayload()));
}
super.onNext(frame);
}
})
//阻塞调用
.awaitCompletion(5000, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (hasItTimedOut[0]) {
throw new RuntimeException("执行超时");
}
executionInformation.setMessage(message.toString());
executionInformation.setErrorMessage(errorMessage.toString());
executionInformationArrayList.add(executionInformation);
//清空字符串
message.setLength(0);
errorMessage.setLength(0);
});
} catch (Exception e) {
log.error("docker容器执行异常:{}", e.getLocalizedMessage());
throw new RuntimeException(e);
} finally {
containerPool.returnContainer(containerId);
}
return executionInformationArrayList;
}
public ExecutiveOutcome collectResults(List<ExecutionInformation> executionInformations) {
ExecutionInformation executionInformation = executionInformations.get(0);
String errorMessage = executionInformation.getErrorMessage();
StringBuilder stringBuilder = new StringBuilder();
if (StringUtils.isNotBlank(errorMessage)) {
executionInformations.forEach(item -> stringBuilder.append(item.getErrorMessage()));
return exceptionProcessor(ProblemStatusEnum.FAIL_THE_TEST, TestResultEnum.RUNTIME_ERROR,
"运行异常", stringBuilder.toString());
}
ArrayList<String> output = new ArrayList<>();
final Long[] maximumMemory = {0L};
final Long[] maximumTimer = {0L};
executionInformations.forEach(item -> {
String message = item.getMessage();
//TODO 如果用户代码包含了输出语句会导致这里出BUG 待优化
String[] split = message.split("\n");
maximumMemory[0] = Math.max(maximumMemory[0], Long.parseLong(split[0]));
maximumTimer[0] = Math.max(maximumTimer[0], Long.parseLong(split[1]));
output.add(split[2]);
});
JudgeInfo judgeInfo = new JudgeInfo();
judgeInfo.setStatus(TestResultEnum.SUCCEED.getStatus());
judgeInfo.setMessage(TestResultEnum.SUCCEED.getMessage());
judgeInfo.setTime(maximumTimer[0]);
judgeInfo.setMemory(maximumMemory[0]);
ExecutiveOutcome executiveOutcome = new ExecutiveOutcome();
executiveOutcome.setResultOfEnforcement(output);
executiveOutcome.setStatus(ProblemStatusEnum.SUCCESSFUL_JUDGMENT.getStatus());
executiveOutcome.setMessage(ProblemStatusEnum.SUCCESSFUL_JUDGMENT.getMessage());
executiveOutcome.setJudgeInfo(judgeInfo);
return executiveOutcome;
}
public boolean clearFiles(String path) {
return FileUtil.del(path);
}
@Override
public ExecutiveOutcome executeCode(ExecutionParameter executionParameter) {
ExecutiveOutcome executiveOutcome;
//1. 校验代码
String code = executionParameter.getCode();
boolean verifyCodeRes = verifyCode(code);
if (!verifyCodeRes) {
return exceptionProcessor(ProblemStatusEnum.FAIL_THE_TEST,
TestResultEnum.DANGEROUS_OPERATION, "代码不合法", "代码不合法");
}
ContainerPool.ContainerInfo availableContainer = null;
try {
availableContainer = containerPool.getAvailableContainer();
} catch (Exception e) {
exceptionProcessor(ProblemStatusEnum.FAIL_THE_TEST, TestResultEnum.EXECUTION_EXCEPTION,
"任务被异常中断",
"失败");
}
String containerId = availableContainer.getId();
log.info("使用的容器id:{}", containerId);
//2.保存用户代码
String filePath = saveUserCodeFile(code, availableContainer.getContainerDirName());
//3. 运行代码
List<ExecutionInformation> executionInformations = null;
try {
executionInformations = runFile(containerId, executionParameter.getInputList(), availableContainer.getContainerDirName(),
executionParameter.getMethodName());
} catch (Exception e) {
log.error("代码运行异常");
return exceptionProcessor(ProblemStatusEnum.FAIL_THE_TEST, TestResultEnum.RUNTIME_ERROR,
"运行异常", "执行异常");
}
//4. 收集结果
executiveOutcome = collectResults(executionInformations);
//5. 文件清理
boolean result = clearFiles(filePath);
if (!result) {
log.error("清除文件异常");
//TODO 通知管理员
}
return executiveOutcome;
}
}
感觉有点乱,但是懒得改了
本地方式
。。。。。。