Compare commits
10 Commits
7e6ebd18a5
...
a61a88f36b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a61a88f36b | ||
|
|
e792fb919d | ||
|
|
7f57d69756 | ||
|
|
a1b8a4af8c | ||
|
|
ea38d1c026 | ||
|
|
a67562bfea | ||
|
|
b82ea1919e | ||
|
|
1aefc14e42 | ||
|
|
669dc11064 | ||
|
|
4558ef20c0 |
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Shell scripts: always LF so ./start.sh works on Linux/WSL
|
||||
*.sh text eol=lf
|
||||
@@ -42,10 +42,11 @@
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
</dependency>
|
||||
<!-- Fork with modern algorithm support (ed25519, current kex/ciphers); drop-in for com.jcraft.jsch -->
|
||||
<dependency>
|
||||
<groupId>com.jcraft</groupId>
|
||||
<groupId>com.github.mwiede</groupId>
|
||||
<artifactId>jsch</artifactId>
|
||||
<version>0.1.55</version>
|
||||
<version>2.27.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
|
||||
@@ -62,7 +62,10 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(Arrays.asList("http://localhost:5173", "http://127.0.0.1:5173"));
|
||||
config.setAllowedOrigins(Arrays.asList(
|
||||
"http://localhost:5173", "http://127.0.0.1:5173",
|
||||
"http://localhost:48080", "http://127.0.0.1:48080"
|
||||
));
|
||||
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(Arrays.asList("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.sshmanager.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* SPA 前端路由回退:未匹配到静态资源时返回 index.html,供 Vue Router history 模式使用。
|
||||
*/
|
||||
@Configuration
|
||||
public class SpaForwardConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String path, Resource location) throws IOException {
|
||||
Resource resource = location.createRelative(path);
|
||||
if (resource.exists() && resource.isReadable()) {
|
||||
return resource;
|
||||
}
|
||||
return location.createRelative("index.html");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ public class WebSocketConfig implements WebSocketConfigurer {
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
|
||||
.addInterceptors(terminalHandshakeInterceptor)
|
||||
.setAllowedOrigins("http://localhost:5173", "http://127.0.0.1:5173");
|
||||
.setAllowedOrigins(
|
||||
"http://localhost:5173", "http://127.0.0.1:5173",
|
||||
"http://localhost:48080", "http://127.0.0.1:48080"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.jcraft.jsch.SftpException;
|
||||
import com.sshmanager.dto.SftpFileInfo;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.SftpService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -17,17 +20,25 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sftp")
|
||||
public class SftpController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SftpController.class);
|
||||
|
||||
private final ConnectionService connectionService;
|
||||
private final UserRepository userRepository;
|
||||
private final SftpService sftpService;
|
||||
|
||||
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* JSch ChannelSftp is not thread-safe. If the frontend triggers concurrent requests (e.g. rapid ".." navigation),
|
||||
* sharing one ChannelSftp can crash with internal stream exceptions. We serialize all SFTP ops per (user, connection).
|
||||
*/
|
||||
private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();
|
||||
|
||||
public SftpController(ConnectionService connectionService,
|
||||
UserRepository userRepository,
|
||||
@@ -46,6 +57,13 @@ public class SftpController {
|
||||
return userId + ":" + connectionId;
|
||||
}
|
||||
|
||||
private <T> T withSessionLock(String key, Supplier<T> action) {
|
||||
Object lock = sessionLocks.computeIfAbsent(key, k -> new Object());
|
||||
synchronized (lock) {
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
|
||||
private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception {
|
||||
String key = sessionKey(userId, connectionId);
|
||||
SftpService.SftpSession session = sessions.get(key);
|
||||
@@ -61,12 +79,15 @@ public class SftpController {
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<List<SftpFileInfo>> list(
|
||||
public ResponseEntity<?> list(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam(required = false, defaultValue = ".") String path,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
List<SftpService.FileInfo> files = sftpService.listFiles(session, path);
|
||||
List<SftpFileInfo> dtos = files.stream()
|
||||
@@ -74,8 +95,39 @@ public class SftpController {
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).build();
|
||||
// If the underlying SFTP channel got into a bad state, force reconnect on next request.
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
String errorMsg = toSftpErrorMessage(e, path, "list");
|
||||
log.warn("SFTP list failed: connectionId={}, path={}, error={}", connectionId, path, errorMsg, e);
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", errorMsg);
|
||||
return ResponseEntity.status(500).body(err);
|
||||
}
|
||||
}
|
||||
|
||||
private String toSftpErrorMessage(Exception e, String path, String operation) {
|
||||
if (e.getMessage() != null && !e.getMessage().trim().isEmpty()) {
|
||||
return e.getMessage();
|
||||
}
|
||||
// Unwrap nested RuntimeExceptions to find the underlying SftpException (if any).
|
||||
Throwable cur = e;
|
||||
for (int i = 0; i < 10 && cur != null; i++) {
|
||||
if (cur instanceof SftpException) {
|
||||
return SftpService.formatSftpExceptionMessage((SftpException) cur, path, operation);
|
||||
}
|
||||
if (cur.getMessage() != null && !cur.getMessage().trim().isEmpty()) {
|
||||
return cur.getMessage();
|
||||
}
|
||||
cur = cur.getCause();
|
||||
}
|
||||
return operation + " failed";
|
||||
}
|
||||
|
||||
@GetMapping("/pwd")
|
||||
@@ -84,13 +136,27 @@ public class SftpController {
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
String pwd = sftpService.pwd(session);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("path", pwd);
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).build();
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.warn("SFTP pwd failed: connectionId={}", connectionId, e);
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", e.getMessage() != null ? e.getMessage() : "pwd failed");
|
||||
return ResponseEntity.status(500).body(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +167,9 @@ public class SftpController {
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
byte[] data = sftpService.download(session, path);
|
||||
String filename = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path;
|
||||
@@ -108,6 +177,14 @@ public class SftpController {
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(data);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
@@ -121,6 +198,9 @@ public class SftpController {
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
String remotePath = (path == null || path.isEmpty() || path.equals("/"))
|
||||
? "/" + file.getOriginalFilename()
|
||||
@@ -129,6 +209,14 @@ public class SftpController {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Uploaded");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
@@ -144,11 +232,22 @@ public class SftpController {
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
sftpService.delete(session, path, directory);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Deleted");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
@@ -163,11 +262,22 @@ public class SftpController {
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
sftpService.mkdir(session, path);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Created");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
@@ -183,11 +293,22 @@ public class SftpController {
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
sftpService.rename(session, oldPath, newPath);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Renamed");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
@@ -241,6 +362,7 @@ public class SftpController {
|
||||
if (session != null) {
|
||||
session.disconnect();
|
||||
}
|
||||
sessionLocks.remove(key);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Disconnected");
|
||||
return ResponseEntity.ok(result);
|
||||
|
||||
@@ -52,6 +52,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
// WebSocket handshake sends token as query param
|
||||
if (request.getRequestURI() != null && request.getRequestURI().startsWith("/ws/")) {
|
||||
String token = request.getParameter("token");
|
||||
if (StringUtils.hasText(token)) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.sshmanager.service;
|
||||
import com.jcraft.jsch.ChannelSftp;
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.Session;
|
||||
import com.jcraft.jsch.SftpException;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -38,6 +39,8 @@ public class SftpService {
|
||||
|
||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
||||
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
||||
session.setPassword(password);
|
||||
}
|
||||
@@ -91,7 +94,9 @@ public class SftpService {
|
||||
}
|
||||
|
||||
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
|
||||
Vector<?> entries = sftpSession.getChannel().ls(path);
|
||||
String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim();
|
||||
try {
|
||||
Vector<?> entries = sftpSession.getChannel().ls(listPath);
|
||||
List<FileInfo> result = new ArrayList<>();
|
||||
for (Object obj : entries) {
|
||||
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
||||
@@ -105,6 +110,43 @@ public class SftpService {
|
||||
));
|
||||
}
|
||||
return result;
|
||||
} catch (SftpException e) {
|
||||
String msg = formatSftpExceptionMessage(e, listPath, "list");
|
||||
throw new RuntimeException(msg, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a user-visible message from JSch SftpException (getMessage() is often null).
|
||||
*/
|
||||
public static String formatSftpExceptionMessage(SftpException e, String path, String operation) {
|
||||
int id = e.id;
|
||||
String serverMsg = e.getMessage();
|
||||
String reason = sftpErrorCodeToMessage(id);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(reason);
|
||||
if (path != null && !path.isEmpty()) {
|
||||
sb.append(": ").append(path);
|
||||
}
|
||||
if (serverMsg != null && !serverMsg.trim().isEmpty()) {
|
||||
sb.append(" (").append(serverMsg).append(")");
|
||||
} else {
|
||||
sb.append(" [SFTP status ").append(id).append("]");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String sftpErrorCodeToMessage(int id) {
|
||||
switch (id) {
|
||||
case 2: return "No such file or directory";
|
||||
case 3: return "Permission denied";
|
||||
case 4: return "Operation failed";
|
||||
case 5: return "Bad message";
|
||||
case 6: return "No connection";
|
||||
case 7: return "Connection lost";
|
||||
case 8: return "Operation not supported";
|
||||
default: return "SFTP error";
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] download(SftpSession sftpSession, String remotePath) throws Exception {
|
||||
|
||||
@@ -28,6 +28,8 @@ public class SshService {
|
||||
|
||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
||||
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
||||
session.setPassword(password);
|
||||
|
||||
@@ -2,6 +2,9 @@ server:
|
||||
port: 48080
|
||||
|
||||
spring:
|
||||
web:
|
||||
resources:
|
||||
add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退
|
||||
datasource:
|
||||
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
||||
driver-class-name: org.h2.Driver
|
||||
|
||||
6
docker/.npmrc
Normal file
6
docker/.npmrc
Normal file
@@ -0,0 +1,6 @@
|
||||
# 使用国内 npm 镜像(npmmirror 淘宝镜像)
|
||||
registry=https://registry.npmmirror.com
|
||||
sass_binary_site=https://npmmirror.com/mirrors/node-sass
|
||||
phantomjs_cdnurl=https://npmmirror.com/mirrors/phantomjs
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
chromedriver_cdnurl=https://npmmirror.com/mirrors/chromedriver
|
||||
48
docker/Dockerfile
Normal file
48
docker/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
# ========== 阶段一:前端构建(国内 npm 源) ==========
|
||||
FROM node:20-alpine AS frontend
|
||||
|
||||
# 使用国内 npm 镜像(npmmirror)
|
||||
COPY docker/.npmrc /root/.npmrc
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --prefer-offline --no-audit
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ========== 阶段二:后端构建(国内 Maven 源) ==========
|
||||
FROM maven:3.9-eclipse-temurin-8-alpine AS backend
|
||||
|
||||
COPY docker/maven-settings.xml /root/.m2/settings.xml
|
||||
WORKDIR /build
|
||||
|
||||
# 先复制 pom,利用层缓存
|
||||
COPY backend/pom.xml ./
|
||||
RUN mvn dependency:go-offline -B -q
|
||||
|
||||
# 复制后端源码
|
||||
COPY backend/src ./src
|
||||
|
||||
# 将前端打包结果放入 Spring Boot 静态资源目录
|
||||
COPY --from=frontend /app/dist ./src/main/resources/static
|
||||
|
||||
RUN mvn package -DskipTests -B -q
|
||||
|
||||
# ========== 阶段三:运行(单容器,仅 Java) ==========
|
||||
FROM eclipse-temurin:8-jre-alpine
|
||||
|
||||
RUN apk add --no-cache libgcc tzdata \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=backend /build/target/*.jar app.jar
|
||||
|
||||
ENV DATA_DIR=/app/data
|
||||
RUN mkdir -p ${DATA_DIR}
|
||||
|
||||
EXPOSE 48080
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
35
docker/README.md
Normal file
35
docker/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Docker 单容器部署
|
||||
|
||||
前端打包后放入 Spring Boot `static`,与 Java 一起在同一个容器内启动,不使用 Nginx。
|
||||
|
||||
## 国内源
|
||||
|
||||
- **npm**:`docker/.npmrc` 使用 npmmirror(淘宝镜像)
|
||||
- **Maven**:`docker/maven-settings.xml` 使用阿里云仓库
|
||||
|
||||
## 构建与运行
|
||||
|
||||
在**项目根目录**执行:
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
|
||||
# 前台运行
|
||||
docker compose -f docker/docker-compose.yml up
|
||||
|
||||
# 后台运行
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
访问:http://localhost:48080
|
||||
|
||||
## 环境变量(可选)
|
||||
|
||||
- `SSHMANAGER_ENCRYPTION_KEY`:连接密码加密密钥(生产务必修改)
|
||||
- `SSHMANAGER_JWT_SECRET`:JWT 密钥(生产务必修改)
|
||||
- `TZ`:时区,默认 `Asia/Shanghai`
|
||||
|
||||
## 数据持久化
|
||||
|
||||
H2 数据目录通过 volume `app-data` 挂载到 `/app/data`,重启容器数据保留。
|
||||
33
docker/backend.Dockerfile
Normal file
33
docker/backend.Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# Backend: Maven 使用阿里云镜像,多阶段构建
|
||||
FROM maven:3.9-eclipse-temurin-8-alpine AS builder
|
||||
|
||||
# 使用国内 Maven 配置(阿里云)
|
||||
COPY docker/maven-settings.xml /root/.m2/settings.xml
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# 先复制 pom,利用 Docker 层缓存
|
||||
COPY backend/pom.xml .
|
||||
RUN mvn dependency:go-offline -B -q
|
||||
|
||||
COPY backend/src ./src
|
||||
RUN mvn package -DskipTests -B -q
|
||||
|
||||
# 运行阶段
|
||||
FROM eclipse-temurin:8-jre-alpine
|
||||
|
||||
RUN apk add --no-cache libgcc tzdata \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/target/*.jar app.jar
|
||||
|
||||
# 数据目录(H2 数据库文件)
|
||||
ENV DATA_DIR=/app/data
|
||||
RUN mkdir -p ${DATA_DIR}
|
||||
|
||||
EXPOSE 48080
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
24
docker/docker-compose.yml
Normal file
24
docker/docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# 单容器运行:前端已打包进 JAR,由 Spring Boot 统一提供静态资源与 API
|
||||
# 构建:在项目根目录执行 docker compose -f docker/docker-compose.yml build
|
||||
# 运行:docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
image: ssh-manager:latest
|
||||
container_name: ssh-manager
|
||||
ports:
|
||||
- "48080:48080"
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
# 生产环境建议设置并挂载密钥
|
||||
# - SSHMANAGER_ENCRYPTION_KEY=...
|
||||
# - SSHMANAGER_JWT_SECRET=...
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
61
docker/maven-settings.xml
Normal file
61
docker/maven-settings.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>aliyun-central</id>
|
||||
<name>Aliyun Maven Central</name>
|
||||
<url>https://maven.aliyun.com/repository/central</url>
|
||||
<mirrorOf>central</mirrorOf>
|
||||
</mirror>
|
||||
<mirror>
|
||||
<id>aliyun-public</id>
|
||||
<name>Aliyun Public</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<mirrorOf>*</mirrorOf>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>aliyun</id>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>central</id>
|
||||
<url>https://maven.aliyun.com/repository/central</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>spring</id>
|
||||
<url>https://maven.aliyun.com/repository/spring</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>spring-plugin</id>
|
||||
<url>https://maven.aliyun.com/repository/spring-plugin</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
<pluginRepositories>
|
||||
<pluginRepository>
|
||||
<id>central</id>
|
||||
<url>https://maven.aliyun.com/repository/central</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</pluginRepository>
|
||||
<pluginRepository>
|
||||
<id>spring-plugin</id>
|
||||
<url>https://maven.aliyun.com/repository/spring-plugin</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</pluginRepository>
|
||||
</pluginRepositories>
|
||||
</profile>
|
||||
</profiles>
|
||||
<activeProfiles>
|
||||
<activeProfile>aliyun</activeProfile>
|
||||
</activeProfiles>
|
||||
</settings>
|
||||
15
docker/start.sh
Normal file
15
docker/start.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# 脚本所在目录为 docker/,项目根目录为其上级
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
echo ">>> 项目根目录: $ROOT"
|
||||
echo ">>> 构建并启动..."
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
echo ""
|
||||
echo ">>> 已启动。访问: http://localhost:48080"
|
||||
echo ">>> 查看日志: docker compose -f docker/docker-compose.yml logs -f"
|
||||
@@ -58,7 +58,8 @@ function initPath() {
|
||||
currentPath.value = p || '.'
|
||||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||
loadPath()
|
||||
}).catch(() => {
|
||||
}).catch((err: { response?: { data?: { error?: string } } }) => {
|
||||
error.value = err?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
|
||||
currentPath.value = '.'
|
||||
pathParts.value = []
|
||||
loadPath()
|
||||
@@ -76,8 +77,8 @@ function loadPath() {
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
error.value = '获取文件列表失败'
|
||||
.catch((err: { response?: { data?: { error?: string } } }) => {
|
||||
error.value = err?.response?.data?.error ?? '获取文件列表失败'
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
@@ -147,7 +148,9 @@ async function handleFileSelect(e: Event) {
|
||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||
try {
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
await sftpApi.uploadFile(connectionId.value, path, selected[i])
|
||||
const file = selected[i]
|
||||
if (!file) continue
|
||||
await sftpApi.uploadFile(connectionId.value, path, file)
|
||||
}
|
||||
loadPath()
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user