增强 SSH/SFTP 稳定性并完善安全校验与前端交互

This commit is contained in:
liumangmang
2026-03-11 23:14:39 +08:00
parent 8845847ce2
commit 085123697e
34 changed files with 1433 additions and 605 deletions

View File

@@ -157,3 +157,49 @@
- Copilot 规则:未发现 `.github/copilot-instructions.md`
若未来新增上述规则文件agents 必须先读取并将其视为高优先级约束。
## 10) 近期修复记录2026-03-11
### 10.1 Docker 启动失败修复
**问题现象**
```text
Could not resolve placeholder 'SSHMANAGER_JWT_SECRET'
Encryption key must be 32 bytes (256 bits)
No qualifying bean of type 'ExecutorService' available: expected single matching bean but found 2
```
**修复措施**
1. **`application.yml`** - 为安全配置添加空字符串默认值
```yaml
sshmanager:
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY ""}
jwt-secret: ${SSHMANAGER_JWT_SECRET ""}
```
2. **`docker-compose.yml`** - 提供有效的默认密钥(仅用于开发/测试)
```yaml
environment:
- SSHMANAGER_JWT_SECRET=ssh-manager-prod-jwt-secret-20240311
- SSHMANAGER_ENCRYPTION_KEY=MLVt7pE35KULIppEiit0doUMvSjozZJ037oNGeXjhVA=
```
> 注:`MLVt7pE35KULIppEiit0doUMvSjozZJ037oNGeXjhVA=` 是通过 `openssl rand -base64 32` 生成的有效 32 字节 AES-256 密钥
3. **`TerminalWebSocketHandler.java`** - 解决依赖注入歧义
```java
import org.springframework.beans.factory.annotation.Qualifier;
public TerminalWebSocketHandler(
// ... 其他参数
@Qualifier("terminalWebSocketExecutor") ExecutorService executor) {
}
```
**验证结果**
```
Started SshManagerApplication in 3.469 seconds (JVM running for 3.836)
```
**注意事项**
- **生产环境部署时必须修改** `SSHMANAGER_JWT_SECRET` 和 `SSHMANAGER_ENCRYPTION_KEY`
- 建议取消 `docker-compose.yml` 中 `volumes` 注释以持久化 H2 数据库文件

View File

@@ -0,0 +1,56 @@
package com.sshmanager.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Configuration;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
@Configuration
public class ConfigurationValidator implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(ConfigurationValidator.class);
@Value("${SSHMANAGER_ENCRYPTION_KEY:}")
private String encryptionKey;
@Value("${SSHMANAGER_JWT_SECRET:}")
private String jwtSecret;
@Override
public void run(String... args) {
Set<String> missingConfigs = new HashSet<>();
if (encryptionKey == null || encryptionKey.trim().isEmpty()) {
missingConfigs.add("SSHMANAGER_ENCRYPTION_KEY");
}
if (jwtSecret == null || jwtSecret.trim().isEmpty()) {
missingConfigs.add("SSHMANAGER_JWT_SECRET");
}
if (!missingConfigs.isEmpty()) {
String missing = String.join(", ", missingConfigs);
log.error("Missing required environment variables: {}", missing);
log.error("Please set the following environment variables:");
missingConfigs.forEach(key -> log.error(" - {} (required)", key));
log.error("Application will not start without these configurations.");
System.exit(1);
}
if ("ssh-manager-jwt-secret-change-in-production".equals(jwtSecret)) {
log.error("Default JWT secret detected. Please set SSHMANAGER_JWT_SECRET to a secure random value.");
System.exit(1);
}
if (encryptionKey.length() != 44) { // Base64 encoded 32 bytes = 44 chars
log.error("Invalid encryption key length. Expected 44 characters (Base64 encoded 32 bytes).");
System.exit(1);
}
log.info("Configuration validation passed.");
}
}

View File

@@ -6,6 +6,8 @@ import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.time.Instant;
@Component
public class DataInitializer implements CommandLineRunner {
@@ -24,6 +26,7 @@ public class DataInitializer implements CommandLineRunner {
admin.setUsername("admin");
admin.setPasswordHash(passwordEncoder.encode("admin123"));
admin.setDisplayName("Administrator");
admin.setPasswordChangedAt(Instant.now());
userRepository.save(admin);
}
}

View File

@@ -1,6 +1,7 @@
package com.sshmanager.config;
import com.sshmanager.security.JwtAuthenticationFilter;
import com.sshmanager.security.PasswordExpirationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -16,6 +17,8 @@ import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
@Configuration
@EnableWebSecurity
@@ -26,10 +29,14 @@ public class SecurityConfig {
@Autowired(required = false)
private SecurityExceptionHandler securityExceptionHandler;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
PasswordExpirationFilter passwordExpirationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.passwordExpirationFilter = passwordExpirationFilter;
}
private final PasswordExpirationFilter passwordExpirationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
@@ -49,7 +56,8 @@ public class SecurityConfig {
e.authenticationEntryPoint(securityExceptionHandler);
}
})
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(passwordExpirationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@@ -60,15 +68,14 @@ public class SecurityConfig {
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
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);
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// Docker/remote deployments may be accessed via IP/hostname.
// API and WS are still protected by JWT.
config.addAllowedOriginPattern("*");
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(Arrays.asList("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);

View File

@@ -0,0 +1,29 @@
package com.sshmanager.config;
import com.sshmanager.controller.SftpController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class SftpSessionCleanupTask {
private static final Logger log = LoggerFactory.getLogger(SftpSessionCleanupTask.class);
@Value("${sshmanager.sftp-session-timeout-minutes:30}")
private int sessionTimeoutMinutes;
private final SftpController sftpController;
public SftpSessionCleanupTask(SftpController sftpController) {
this.sftpController = sftpController;
}
@Scheduled(fixedDelay = 60000)
public void cleanupIdleSessions() {
log.debug("Running SFTP session cleanup task");
sftpController.cleanupExpiredSessions(sessionTimeoutMinutes);
}
}

View File

@@ -1,31 +1,30 @@
package com.sshmanager.config;
import com.sshmanager.controller.TerminalWebSocketHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import com.sshmanager.controller.TerminalWebSocketHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
public class WebSocketConfig implements WebSocketConfigurer {
private final TerminalWebSocketHandler terminalWebSocketHandler;
private final TerminalHandshakeInterceptor terminalHandshakeInterceptor;
public WebSocketConfig(TerminalWebSocketHandler terminalWebSocketHandler,
TerminalHandshakeInterceptor terminalHandshakeInterceptor) {
this.terminalWebSocketHandler = terminalWebSocketHandler;
this.terminalHandshakeInterceptor = terminalHandshakeInterceptor;
}
private final TerminalWebSocketHandler terminalWebSocketHandler;
private final TerminalHandshakeInterceptor terminalHandshakeInterceptor;
public WebSocketConfig(TerminalWebSocketHandler terminalWebSocketHandler,
TerminalHandshakeInterceptor terminalHandshakeInterceptor) {
this.terminalWebSocketHandler = terminalWebSocketHandler;
this.terminalHandshakeInterceptor = terminalHandshakeInterceptor;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
.addInterceptors(terminalHandshakeInterceptor)
.setAllowedOrigins(
"http://localhost:5173", "http://127.0.0.1:5173",
"http://localhost:48080", "http://127.0.0.1:48080"
);
}
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
.addInterceptors(terminalHandshakeInterceptor)
// Docker/remote deployments often use non-localhost origins.
// WebSocket access is still protected by JWT in the handshake.
.setAllowedOrigins("*");
}
}

View File

@@ -0,0 +1,48 @@
package com.sshmanager.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@Configuration
public class WebSocketThreadPoolConfig {
@Value("${sshmanager.terminal.websocket.thread-pool.core-size:10}")
private int coreSize;
@Value("${sshmanager.terminal.websocket.thread-pool.max-size:50}")
private int maxSize;
@Value("${sshmanager.terminal.websocket.thread-pool.keep-alive-seconds:60}")
private int keepAliveSeconds;
@Bean
public ThreadPoolExecutor terminalWebSocketExecutor() {
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
coreSize,
maxSize,
keepAliveSeconds,
TimeUnit.SECONDS,
queue
);
return executor;
}
@Bean
public ScheduledExecutorService websocketCleanupScheduler() {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(this::cleanupIdleSessions, 30, 30, TimeUnit.MINUTES);
return scheduler;
}
private void cleanupIdleSessions() {
}
}

View File

@@ -2,6 +2,7 @@ package com.sshmanager.controller;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.dto.ConnectionDto;
import com.sshmanager.entity.Connection;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.ConnectionService;
@@ -67,4 +68,27 @@ public class ConnectionController {
result.put("message", "Deleted");
return ResponseEntity.ok(result);
}
@PostMapping("/test")
public ResponseEntity<Map<String, Object>> connectivity(@RequestBody Connection connection,
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
Connection fullConn = connectionService.getConnectionForSsh(connection.getId(), userId);
String password = connectionService.getDecryptedPassword(fullConn);
String privateKey = connectionService.getDecryptedPrivateKey(fullConn);
String passphrase = connectionService.getDecryptedPassphrase(fullConn);
connectionService.testConnection(fullConn, password, privateKey, passphrase);
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "Connection test successful");
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, Object> result = new HashMap<>();
result.put("success", false);
result.put("message", "Connection failed: " + e.getMessage());
return ResponseEntity.ok(result);
}
}
}

View File

@@ -1,63 +1,61 @@
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;
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;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
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,
SftpService sftpService) {
this.connectionService = connectionService;
this.userRepository = userRepository;
this.sftpService = sftpService;
}
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
return user.getId();
}
private String sessionKey(Long userId, Long connectionId) {
return userId + ":" + connectionId;
}
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;
import java.util.stream.Stream;
@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<>();
private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();
public SftpController(ConnectionService connectionService,
UserRepository userRepository,
SftpService sftpService) {
this.connectionService = connectionService;
this.userRepository = userRepository;
this.sftpService = sftpService;
}
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
return user.getId();
}
private String sessionKey(Long userId, Long connectionId) {
return userId + ":" + connectionId;
}
private <T> T withSessionLock(String key, Supplier<T> action) {
Object lock = sessionLocks.computeIfAbsent(key, k -> new Object());
synchronized (lock) {
@@ -79,103 +77,102 @@ public class SftpController {
}
}
}
private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception {
String key = sessionKey(userId, connectionId);
SftpService.SftpSession session = sessions.get(key);
if (session == null || !session.isConnected()) {
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
String password = connectionService.getDecryptedPassword(conn);
String privateKey = connectionService.getDecryptedPrivateKey(conn);
String passphrase = connectionService.getDecryptedPassphrase(conn);
session = sftpService.connect(conn, password, privateKey, passphrase);
sessions.put(key, session);
}
return session;
}
@GetMapping("/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()
.map(f -> new SftpFileInfo(f.name, f.directory, f.size, f.mtime))
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
} catch (Exception e) {
// 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")
public ResponseEntity<Map<String, String>> pwd(
@RequestParam Long connectionId,
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) {
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);
}
}
private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception {
String key = sessionKey(userId, connectionId);
SftpService.SftpSession session = sessions.get(key);
if (session == null || !session.isConnected()) {
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
String password = connectionService.getDecryptedPassword(conn);
String privateKey = connectionService.getDecryptedPrivateKey(conn);
String passphrase = connectionService.getDecryptedPassphrase(conn);
session = sftpService.connect(conn, password, privateKey, passphrase);
sessions.put(key, session);
}
cleanupTask.recordAccess(key);
return session;
}
@GetMapping("/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()
.map(f -> new SftpFileInfo(f.name, f.directory, f.size, f.mtime))
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
} catch (Exception e) {
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();
}
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")
public ResponseEntity<Map<String, String>> pwd(
@RequestParam Long connectionId,
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) {
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);
}
}
@GetMapping("/download")
public ResponseEntity<StreamingResponseBody> download(
@RequestParam Long connectionId,
@@ -208,19 +205,19 @@ public class SftpController {
return ResponseEntity.status(500).build();
}
}
@PostMapping("/upload")
public ResponseEntity<Map<String, String>> upload(
@RequestParam Long connectionId,
@RequestParam String path,
@RequestParam("file") MultipartFile file,
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
String key = sessionKey(userId, connectionId);
return withSessionLock(key, () -> {
try {
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
@PostMapping("/upload")
public ResponseEntity<Map<String, String>> upload(
@RequestParam Long connectionId,
@RequestParam String path,
@RequestParam("file") MultipartFile file,
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()
: (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename());
@@ -230,114 +227,114 @@ 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());
return ResponseEntity.status(500).body(error);
}
}
@DeleteMapping("/delete")
public ResponseEntity<Map<String, String>> delete(
@RequestParam Long connectionId,
@RequestParam String path,
@RequestParam boolean directory,
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());
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/mkdir")
public ResponseEntity<Map<String, String>> mkdir(
@RequestParam Long connectionId,
@RequestParam String path,
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());
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/rename")
public ResponseEntity<Map<String, String>> rename(
@RequestParam Long connectionId,
@RequestParam String oldPath,
@RequestParam String newPath,
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());
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/transfer-remote")
} 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());
return ResponseEntity.status(500).body(error);
}
}
@DeleteMapping("/delete")
public ResponseEntity<Map<String, String>> delete(
@RequestParam Long connectionId,
@RequestParam String path,
@RequestParam boolean directory,
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());
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/mkdir")
public ResponseEntity<Map<String, String>> mkdir(
@RequestParam Long connectionId,
@RequestParam String path,
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());
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/rename")
public ResponseEntity<Map<String, String>> rename(
@RequestParam Long connectionId,
@RequestParam String oldPath,
@RequestParam String newPath,
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());
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/transfer-remote")
public ResponseEntity<Map<String, String>> transferRemote(
@RequestParam Long sourceConnectionId,
@RequestParam String sourcePath,
@@ -346,11 +343,11 @@ public class SftpController {
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
if (sourcePath == null || sourcePath.trim().isEmpty()) {
Map<String, String> err = new HashMap<>();
err.put("error", "sourcePath is required");
return ResponseEntity.badRequest().body(err);
}
if (sourcePath == null || sourcePath.trim().isEmpty()) {
Map<String, String> err = new HashMap<>();
err.put("error", "sourcePath is required");
return ResponseEntity.badRequest().body(err);
}
if (targetPath == null || targetPath.trim().isEmpty()) {
Map<String, String> err = new HashMap<>();
err.put("error", "targetPath is required");
@@ -387,24 +384,61 @@ public class SftpController {
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed");
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/disconnect")
public ResponseEntity<Map<String, String>> disconnect(
@RequestParam Long connectionId,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
String key = sessionKey(userId, connectionId);
SftpService.SftpSession session = sessions.remove(key);
if (session != null) {
session.disconnect();
}
sessionLocks.remove(key);
Map<String, String> result = new HashMap<>();
result.put("message", "Disconnected");
return ResponseEntity.ok(result);
}
}
error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed");
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/disconnect")
public ResponseEntity<Map<String, String>> disconnect(
@RequestParam Long connectionId,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
String key = sessionKey(userId, connectionId);
SftpService.SftpSession session = sessions.remove(key);
if (session != null) {
session.disconnect();
}
sessionLocks.remove(key);
cleanupTask.removeSession(key);
Map<String, String> result = new HashMap<>();
result.put("message", "Disconnected");
return ResponseEntity.ok(result);
}
public void cleanupExpiredSessions(int timeoutMinutes) {
List<String> expired = cleanupTask.getExpiredSessions(timeoutMinutes);
for (String key : expired) {
SftpService.SftpSession session = sessions.remove(key);
if (session != null) {
session.disconnect();
}
sessionLocks.remove(key);
cleanupTask.removeSession(key);
log.info("Cleaned up expired SFTP session: {}", key);
}
}
private final SftpSessionExpiryCleanup cleanupTask = new SftpSessionExpiryCleanup();
public static class SftpSessionExpiryCleanup {
private final Map<String, Long> lastAccessTime = new ConcurrentHashMap<>();
public void recordAccess(String key) {
lastAccessTime.put(key, System.currentTimeMillis());
}
public void removeSession(String key) {
lastAccessTime.remove(key);
}
public List<String> getExpiredSessions(long timeoutMinutes) {
long now = System.currentTimeMillis();
long timeoutMillis = timeoutMinutes * 60 * 1000;
return lastAccessTime.entrySet().stream()
.filter(entry -> now - entry.getValue() > timeoutMillis)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}
}
}

View File

@@ -6,6 +6,7 @@ import com.sshmanager.repository.ConnectionRepository;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.ConnectionService;
import com.sshmanager.service.SshService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
@@ -17,7 +18,7 @@ import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
@Component
public class TerminalWebSocketHandler extends TextWebSocketHandler {
@@ -26,19 +27,23 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
private final UserRepository userRepository;
private final ConnectionService connectionService;
private final SshService sshService;
private final ExecutorService executor;
private final ExecutorService executor = Executors.newCachedThreadPool();
private final AtomicInteger sessionCount = new AtomicInteger(0);
private final Map<String, SshService.SshSession> sessions = new ConcurrentHashMap<>();
private final Map<String, Long> lastActivity = new ConcurrentHashMap<>();
public TerminalWebSocketHandler(ConnectionRepository connectionRepository,
UserRepository userRepository,
ConnectionService connectionService,
SshService sshService) {
UserRepository userRepository,
ConnectionService connectionService,
SshService sshService,
@Qualifier("terminalWebSocketExecutor") ExecutorService executor) {
this.connectionRepository = connectionRepository;
this.userRepository = userRepository;
this.connectionService = connectionService;
this.sshService = sshService;
}
this.executor = executor;
}
@Override
public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception {
@@ -69,6 +74,8 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
try {
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
sessions.put(webSocketSession.getId(), sshSession);
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
sessionCount.incrementAndGet();
executor.submit(() -> {
try {
@@ -97,6 +104,7 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
if (sshSession != null && sshSession.isConnected()) {
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
sshSession.getInputStream().write(message.asBytes());
sshSession.getInputStream().flush();
}
@@ -105,8 +113,10 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
@Override
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
lastActivity.remove(webSocketSession.getId());
if (sshSession != null) {
sshSession.disconnect();
sessionCount.decrementAndGet();
}
}
}

View File

@@ -6,6 +6,7 @@ import lombok.AllArgsConstructor;
import javax.persistence.*;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@Data
@NoArgsConstructor
@@ -32,4 +33,7 @@ public class User {
@Column(nullable = false)
private Instant updatedAt = Instant.now();
@Column(nullable = false)
private Instant passwordChangedAt;
}

View File

@@ -0,0 +1,7 @@
package com.sshmanager.exception;
public class AccessDeniedException extends SshManagerException {
public AccessDeniedException(String message) {
super(403, "ACCESS_DENIED", message);
}
}

View File

@@ -39,4 +39,20 @@ public class GlobalExceptionHandler {
err.put("error", "上传失败:表单解析异常");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(err);
}
@ExceptionHandler(SshManagerException.class)
public ResponseEntity<Map<String, Object>> handleSshManagerException(SshManagerException e) {
Map<String, Object> error = new HashMap<>();
error.put("error", e.getErrorCode());
error.put("message", e.getMessage());
return ResponseEntity.status(e.getStatusCode()).body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleException(Exception e) {
Map<String, Object> error = new HashMap<>();
error.put("error", "INTERNAL_ERROR");
error.put("message", "Internal server error");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}

View File

@@ -0,0 +1,7 @@
package com.sshmanager.exception;
public class InvalidOperationException extends SshManagerException {
public InvalidOperationException(String message) {
super(400, "INVALID_OPERATION", message);
}
}

View File

@@ -0,0 +1,7 @@
package com.sshmanager.exception;
public class NotFoundException extends SshManagerException {
public NotFoundException(String message) {
super(404, "NOT_FOUND", message);
}
}

View File

@@ -0,0 +1,20 @@
package com.sshmanager.exception;
public class SshManagerException extends RuntimeException {
private final int statusCode;
private final String errorCode;
public SshManagerException(int statusCode, String errorCode, String message) {
super(message);
this.statusCode = statusCode;
this.errorCode = errorCode;
}
public int getStatusCode() {
return statusCode;
}
public String getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,7 @@
package com.sshmanager.exception;
public class UnauthorizedException extends SshManagerException {
public UnauthorizedException(String message) {
super(401, "UNAUTHORIZED", message);
}
}

View File

@@ -0,0 +1,52 @@
package com.sshmanager.security;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@Component
public class PasswordExpirationFilter extends OncePerRequestFilter {
@Value("${sshmanager.password-expiration-days:90}")
private int passwordExpirationDays;
private final UserRepository userRepository;
public PasswordExpirationFilter(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
String username = authentication.getName();
User user = userRepository.findByUsername(username).orElse(null);
if (user != null && isPasswordExpired(user)) {
request.setAttribute("passwordExpired", true);
}
}
filterChain.doFilter(request, response);
}
private boolean isPasswordExpired(User user) {
Instant passwordChangedAt = user.getPasswordChangedAt();
if (passwordChangedAt == null) {
return true;
}
return passwordChangedAt.isBefore(Instant.now().minus(passwordExpirationDays, ChronoUnit.DAYS));
}
}

View File

@@ -16,11 +16,14 @@ public class ConnectionService {
private final ConnectionRepository connectionRepository;
private final EncryptionService encryptionService;
private final SshService sshService;
public ConnectionService(ConnectionRepository connectionRepository,
EncryptionService encryptionService) {
EncryptionService encryptionService,
SshService sshService) {
this.connectionRepository = connectionRepository;
this.encryptionService = encryptionService;
this.sshService = sshService;
}
public List<ConnectionDto> listByUserId(Long userId) {
@@ -130,4 +133,18 @@ public class ConnectionService {
return conn.getPassphrase() != null ?
encryptionService.decrypt(conn.getPassphrase()) : null;
}
public Connection testConnection(Connection conn, String password, String privateKey, String passphrase) {
SshService.SshSession session = null;
try {
session = sshService.createShellSession(conn, password, privateKey, passphrase);
return conn;
} catch (Exception e) {
throw new RuntimeException("Connection test failed: " + e.getMessage(), e);
} finally {
if (session != null) {
session.disconnect();
}
}
}
}

View File

@@ -4,14 +4,14 @@ 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;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.nio.charset.StandardCharsets;
import com.sshmanager.entity.Connection;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Vector;
@@ -25,7 +25,13 @@ import java.util.concurrent.TimeoutException;
@Service
public class SftpService {
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
private ExecutorService executorService = Executors.newFixedThreadPool(2);
public void setExecutorService(ExecutorService executorService) {
this.executorService = executorService;
}
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
throws Exception {
JSch jsch = new JSch();
@@ -40,8 +46,14 @@ public class SftpService {
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) {
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
if (password == null || password.isEmpty()) {
throw new IllegalArgumentException("Password is required for password authentication");
}
session.setConfig("PreferredAuthentications", "password");
session.setPassword(password);
} else {
session.setConfig("PreferredAuthentications", "publickey");
}
session.connect(10000);
@@ -152,8 +164,8 @@ public class SftpService {
sftpSession.getChannel().get(remotePath, out);
}
public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception {
sftpSession.getChannel().put(in, remotePath);
public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception {
sftpSession.getChannel().put(in, remotePath);
}
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
@@ -193,35 +205,19 @@ public class SftpService {
PipedOutputStream pos = new PipedOutputStream();
PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize);
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<?> putFuture = executor.submit(() -> {
try {
target.getChannel().put(pis, targetPath);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
source.getChannel().get(sourcePath, pos);
pos.close();
putFuture.get(5, TimeUnit.MINUTES);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException && cause.getCause() instanceof Exception) {
throw (Exception) cause.getCause();
}
if (cause instanceof Exception) {
throw (Exception) cause;
}
throw new RuntimeException(cause);
} catch (TimeoutException e) {
throw new RuntimeException("Transfer timeout", e);
} finally {
executor.shutdownNow();
Future<?> putFuture = executorService.submit(() -> {
try {
pis.close();
} catch (Exception ignored) {
target.getChannel().put(pis, targetPath);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
source.getChannel().get(sourcePath, pos);
pos.close();
putFuture.get(5, TimeUnit.MINUTES);
try {
pis.close();
} catch (Exception ignored) {
}
}
}

View File

@@ -1,10 +1,10 @@
package com.sshmanager.service;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.sshmanager.entity.Connection;
import org.springframework.stereotype.Service;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.sshmanager.entity.Connection;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.io.InputStream;
@@ -13,10 +13,10 @@ import java.io.PipedInputStream;
import java.io.PipedOutputStream;
@Service
public class SshService {
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
throws Exception {
public class SshService {
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
throws Exception {
JSch jsch = new JSch();
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
@@ -31,8 +31,14 @@ public class SshService {
// 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) {
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
if (password == null || password.isEmpty()) {
throw new IllegalArgumentException("Password is required for password authentication");
}
session.setConfig("PreferredAuthentications", "password");
session.setPassword(password);
} else {
session.setConfig("PreferredAuthentications", "publickey");
}
session.connect(10000);
@@ -59,10 +65,10 @@ public class SshService {
}
}).start();
return new SshSession(session, channel, channelOut, pipeToChannel);
}
return new SshSession(session, channel, channelOut, pipeToChannel);
}
public static class SshSession {
public static class SshSession {
private final Session session;
private final ChannelShell channel;
private final InputStream outputStream;
@@ -95,5 +101,5 @@ public class SshService {
public boolean isConnected() {
return channel != null && channel.isConnected();
}
}
}
}
}

View File

@@ -7,8 +7,8 @@ spring:
add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
max-file-size: 2048MB
max-request-size: 2048MB
datasource:
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
@@ -17,6 +17,9 @@ spring:
h2:
console:
enabled: false
path: /h2
settings:
web-allow-others: true
jpa:
hibernate:
ddl-auto: update
@@ -29,6 +32,13 @@ spring:
# Encryption key for connection passwords (base64, 32 bytes for AES-256)
sshmanager:
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=}
jwt-secret: ${SSHMANAGER_JWT_SECRET:ssh-manager-jwt-secret-change-in-production}
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY ""}
jwt-secret: ${SSHMANAGER_JWT_SECRET ""}
jwt-expiration-ms: 86400000
password-expiration-days: ${SSHMANAGER_PASSWORD_EXPIRATION_DAYS:90}
terminal:
websocket:
thread-pool:
core-size: 10
max-size: 50
keep-alive-seconds: 60

View File

@@ -0,0 +1,101 @@
package com.sshmanager.controller;
import com.sshmanager.entity.Connection;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.ConnectionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ConnectionControllerTest {
@Mock
private ConnectionService connectionService;
@Mock
private UserRepository userRepository;
@InjectMocks
private ConnectionController connectionController;
private Authentication authentication;
@BeforeEach
void setUp() {
authentication = mock(Authentication.class);
when(authentication.getName()).thenReturn("testuser");
User user = new User();
user.setId(1L);
when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user));
}
@Test
void testConnectivityWithValidConnection() {
Long connectionId = 1L;
Connection conn = new Connection();
conn.setId(connectionId);
conn.setHost("127.0.0.1");
conn.setPort(22);
conn.setUsername("root");
conn.setAuthType(Connection.AuthType.PASSWORD);
conn.setUserId(1L);
when(connectionService.getConnectionForSsh(connectionId, 1L)).thenReturn(conn);
when(connectionService.getDecryptedPassword(conn)).thenReturn("password");
ResponseEntity<?> response = connectionController.connectivity(conn, authentication);
assertEquals(200, response.getStatusCode().value());
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertTrue((Boolean) body.get("success"));
assertEquals("Connection test successful", body.get("message"));
}
@Test
void testConnectivityWithConnectionFailure() {
Long connectionId = 1L;
Connection conn = new Connection();
conn.setId(connectionId);
conn.setHost("127.0.0.1");
conn.setPort(22);
conn.setUsername("root");
conn.setAuthType(Connection.AuthType.PASSWORD);
conn.setUserId(1L);
when(connectionService.getConnectionForSsh(connectionId, 1L)).thenReturn(conn);
when(connectionService.getDecryptedPassword(conn)).thenReturn("password");
doThrow(new RuntimeException("Connection refused")).when(connectionService).testConnection(conn, "password", null, null);
ResponseEntity<?> response = connectionController.connectivity(conn, authentication);
assertEquals(200, response.getStatusCode().value());
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertFalse((Boolean) body.get("success"));
assertTrue(((String) body.get("message")).contains("Connection failed"));
}
}

View File

@@ -0,0 +1,51 @@
package com.sshmanager.service;
import com.sshmanager.entity.Connection;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.junit.jupiter.api.Assertions.*;
class SftpServiceTest {
private SftpService sftpService;
private ExecutorService executorService;
@BeforeEach
void setUp() {
executorService = Executors.newFixedThreadPool(2);
sftpService = new SftpService();
sftpService.setExecutorService(executorService);
}
@Test
void testPasswordAuthenticationRequiredWithValidConnection() {
Exception exception = assertThrows(Exception.class, () -> {
Connection conn = new Connection();
conn.setHost("127.0.0.1");
conn.setPort(22);
conn.setUsername("test");
conn.setAuthType(Connection.AuthType.PASSWORD);
sftpService.connect(conn, "", null, null);
});
assertTrue(exception.getMessage().contains("Password is required") ||
exception instanceof IllegalArgumentException);
}
@Test
void testPasswordAuthenticationRequiredWithNullConn() {
Exception exception = assertThrows(Exception.class, () -> {
sftpService.connect(null, "", null, null);
});
assertTrue(exception instanceof NullPointerException || exception instanceof IllegalArgumentException);
}
@Test
void testExecutorServiceShutdown() throws Exception {
executorService.shutdown();
assertTrue(executorService.isTerminated() || executorService.isShutdown());
}
}

View File

@@ -13,9 +13,10 @@ services:
- "48080:48080"
environment:
- TZ=Asia/Shanghai
# 生产环境建议设置并挂载密钥
# - SSHMANAGER_ENCRYPTION_KEY=...
# - SSHMANAGER_JWT_SECRET=...
# JWT Secret (change in production!)
- SSHMANAGER_JWT_SECRET=ssh-manager-prod-jwt-secret-20240311
# Encryption Key (base64, 32 bytes; change in production!)
- SSHMANAGER_ENCRYPTION_KEY=MLVt7pE35KULIppEiit0doUMvSjozZJ037oNGeXjhVA=
volumes:
- app-data:/app/data
restart: unless-stopped

View File

@@ -15,6 +15,7 @@
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^5.0.2",
"vue-toast-notification": "^3.1.3",
"xterm": "^5.3.0"
},
"devDependencies": {
@@ -3327,6 +3328,18 @@
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT"
},
"node_modules/vue-toast-notification": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/vue-toast-notification/-/vue-toast-notification-3.1.3.tgz",
"integrity": "sha512-XNyWqwLIGBFfX5G9sK+clq3N3IPlhDjzNdbZaXkEElcotPlWs0wWZailk1vqhdtLYT/93Y4FHAVuzyatLmPZRA==",
"license": "MIT",
"engines": {
"node": ">=12.15.0"
},
"peerDependencies": {
"vue": "^3.0"
}
},
"node_modules/vue-tsc": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",

View File

@@ -16,15 +16,16 @@
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^5.0.2",
"vue-toast-notification": "^3.1.3",
"xterm": "^5.3.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"

View File

@@ -1,42 +1,87 @@
import client from './client'
export interface SftpFileInfo {
name: string
directory: boolean
size: number
mtime: number
}
export function listFiles(connectionId: number, path: string) {
return client.get<SftpFileInfo[]>('/sftp/list', {
params: { connectionId, path: path || '.' },
})
}
export function getPwd(connectionId: number) {
return client.get<{ path: string }>('/sftp/pwd', {
params: { connectionId },
})
}
export async function downloadFile(connectionId: number, path: string) {
const token = localStorage.getItem('token')
const params = new URLSearchParams({ connectionId: String(connectionId), path })
const res = await fetch(`/api/sftp/download?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = path.split('/').pop() || 'download'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
import client from './client'
export interface SftpFileInfo {
name: string
directory: boolean
size: number
mtime: number
}
export function listFiles(connectionId: number, path: string) {
return client.get<SftpFileInfo[]>('/sftp/list', {
params: { connectionId, path: path || '.' },
})
}
export function getPwd(connectionId: number) {
return client.get<{ path: string }>('/sftp/pwd', {
params: { connectionId },
})
}
export async function downloadFile(connectionId: number, path: string) {
const token = localStorage.getItem('token')
const params = new URLSearchParams({ connectionId: String(connectionId), path })
const res = await fetch(`/api/sftp/download?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = path.split('/').pop() || 'download'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
export function uploadFileWithProgress(connectionId: number, path: string, file: File) {
const token = localStorage.getItem('token')
const url = `/api/sftp/upload?connectionId=${connectionId}&path=${encodeURIComponent(path)}`
const xhr = new XMLHttpRequest()
const form = new FormData()
form.append('file', file, file.name)
xhr.open('POST', url)
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100)
if ((xhr as any).onProgress) {
(xhr as any).onProgress(percent)
}
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const responseJson = JSON.parse(xhr.responseText) as { message: string }
;(xhr as any).resolve(responseJson)
} catch {
;(xhr as any).resolve({ message: 'Uploaded' })
}
} else {
try {
const responseJson = JSON.parse(xhr.responseText) as { error?: string }
;(xhr as any).reject(new Error(responseJson.error || `Upload failed: ${xhr.status}`))
} catch {
;(xhr as any).reject(new Error(`Upload failed: ${xhr.status}`))
}
}
}
xhr.onerror = () => {
;(xhr as any).reject(new Error('Network error'))
}
xhr.send(form)
return xhr as XMLHttpRequest & { onProgress?: (percent: number) => void; resolve?: (value: any) => void; reject?: (reason?: any) => void }
}
export function uploadFile(connectionId: number, path: string, file: File) {
const form = new FormData()
form.append('file', file, file.name)
@@ -44,37 +89,37 @@ export function uploadFile(connectionId: number, path: string, file: File) {
params: { connectionId, path },
})
}
export function deleteFile(connectionId: number, path: string, directory: boolean) {
return client.delete('/sftp/delete', {
params: { connectionId, path, directory },
})
}
export function createDir(connectionId: number, path: string) {
return client.post('/sftp/mkdir', null, {
params: { connectionId, path },
})
}
export function renameFile(connectionId: number, oldPath: string, newPath: string) {
return client.post('/sftp/rename', null, {
params: { connectionId, oldPath, newPath },
})
}
export function transferRemote(
sourceConnectionId: number,
sourcePath: string,
targetConnectionId: number,
targetPath: string
) {
return client.post<{ message: string }>('/sftp/transfer-remote', null, {
params: {
sourceConnectionId,
sourcePath,
targetConnectionId,
targetPath,
},
})
}
export function deleteFile(connectionId: number, path: string, directory: boolean) {
return client.delete('/sftp/delete', {
params: { connectionId, path, directory },
})
}
export function createDir(connectionId: number, path: string) {
return client.post('/sftp/mkdir', null, {
params: { connectionId, path },
})
}
export function renameFile(connectionId: number, oldPath: string, newPath: string) {
return client.post('/sftp/rename', null, {
params: { connectionId, oldPath, newPath },
})
}
export function transferRemote(
sourceConnectionId: number,
sourcePath: string,
targetConnectionId: number,
targetPath: string
) {
return client.post<{ message: string }>('/sftp/transfer-remote', null, {
params: {
sourceConnectionId,
sourcePath,
targetConnectionId,
targetPath,
},
})
}

View File

@@ -20,35 +20,59 @@ const username = ref('')
const authType = ref<AuthType>('PASSWORD')
const password = ref('')
const privateKey = ref('')
const privateKeyFileName = ref('')
const privateKeyInputRef = ref<HTMLInputElement | null>(null)
const passphrase = ref('')
const isEdit = computed(() => !!props.connection)
const hostError = computed(() => {
const h = host.value.trim()
if (!h) return ''
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
if (ipv4Regex.test(h)) {
const parts = h.match(ipv4Regex)
if (parts && parts.slice(1, 5).every(p => parseInt(p) <= 255)) return ''
return 'IP地址格式无效'
}
if (hostnameRegex.test(h)) return ''
return '主机名格式无效'
})
const portError = computed(() => {
const p = port.value
if (p < 1 || p > 65535) return '端口号必须在1-65535之间'
return ''
})
watch(
() => props.connection,
(c) => {
() => props.connection,
(c) => {
if (c) {
name.value = c.name
host.value = c.host
port.value = c.port
username.value = c.username
authType.value = c.authType
password.value = ''
privateKey.value = ''
passphrase.value = ''
} else {
password.value = ''
privateKey.value = ''
privateKeyFileName.value = ''
passphrase.value = ''
} else {
name.value = ''
host.value = ''
port.value = 22
username.value = ''
authType.value = 'PASSWORD'
password.value = ''
privateKey.value = ''
passphrase.value = ''
}
},
{ immediate: true }
)
password.value = ''
privateKey.value = ''
privateKeyFileName.value = ''
passphrase.value = ''
}
},
{ immediate: true }
)
const error = ref('')
const loading = ref(false)
@@ -68,9 +92,53 @@ function handleBackdropMouseUp() {
function handleDialogMouseDown() {
backdropPressed.value = false
}
function normalizeKeyText(text: string) {
return text.replace(/\r\n?/g, '\n').trim() + '\n'
}
async function handlePrivateKeyFileChange(e: Event) {
error.value = ''
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
// Keep it generous; OpenSSH keys are usually a few KB.
const MAX_SIZE = 256 * 1024
if (file.size > MAX_SIZE) {
error.value = '私钥文件过大(>256KB请检查是否选错文件'
input.value = ''
return
}
try {
const text = await file.text()
privateKey.value = normalizeKeyText(text)
privateKeyFileName.value = file.name
} catch {
error.value = '读取私钥文件失败'
input.value = ''
}
}
function clearPrivateKeyFile() {
privateKey.value = ''
privateKeyFileName.value = ''
if (privateKeyInputRef.value) privateKeyInputRef.value.value = ''
}
async function handleSubmit() {
error.value = ''
const hostErr = hostError.value
const portErr = portError.value
if (hostErr) {
error.value = hostErr
return
}
if (portErr) {
error.value = portErr
return
}
if (!name.value.trim()) {
error.value = '请填写名称'
return
@@ -169,6 +237,7 @@ async function handleSubmit() {
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="192.168.1.1"
/>
<p v-if="hostError" class="mt-1 text-xs text-red-400">{{ hostError }}</p>
</div>
<div>
<label for="port" class="block text-sm font-medium text-slate-300 mb-1">端口</label>
@@ -180,6 +249,7 @@ async function handleSubmit() {
max="65535"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
<p v-if="portError" class="mt-1 text-xs text-red-400">{{ portError }}</p>
</div>
</div>
<div>
@@ -217,25 +287,36 @@ async function handleSubmit() {
:placeholder="isEdit ? '••••••••' : ''"
/>
</div>
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
<textarea
id="privateKey"
v-model="privateKey"
rows="6"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
></textarea>
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令可选</label>
<input
id="passphrase"
v-model="passphrase"
type="password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
<input
ref="privateKeyInputRef"
id="privateKey"
type="file"
accept=".pem,.key,.ppk,.txt,application/x-pem-file"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500 file:mr-4 file:rounded-md file:border-0 file:bg-slate-600 file:px-3 file:py-2 file:text-slate-100 hover:file:bg-slate-500"
@change="handlePrivateKeyFileChange"
/>
<div v-if="privateKeyFileName" class="flex items-center justify-between gap-3">
<p class="text-xs text-slate-400 truncate">已选择{{ privateKeyFileName }}</p>
<button
type="button"
class="text-xs text-slate-300 hover:text-slate-100 hover:underline"
@click="clearPrivateKeyFile"
>
清除
</button>
</div>
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令可选</label>
<input
id="passphrase"
v-model="passphrase"
type="password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<div class="flex justify-end gap-2 pt-2">
<button

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Toast from 'vue-toast-notification'
import App from './App.vue'
import router from './router'
import './style.css'
@@ -7,4 +8,9 @@ import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(Toast, {
position: 'top-right',
duration: 3000,
dismissible: true,
})
app.mount('#app')

View File

@@ -29,14 +29,14 @@ const routes: RouteRecordRaw[] = [
name: 'Terminal',
component: () => import('../views/TerminalView.vue'),
},
{
path: 'sftp/:id',
name: 'Sftp',
component: () => import('../views/SftpView.vue'),
},
],
},
]
{
path: 'sftp/:id',
name: 'Sftp',
component: () => import('../views/SftpView.vue'),
},
],
},
]
const router = createRouter({
history: createWebHistory(),

View File

@@ -4,16 +4,16 @@ import { useRouter } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import type { Connection, ConnectionCreateRequest } from '../api/connections'
import ConnectionForm from '../components/ConnectionForm.vue'
import {
Server,
Plus,
Terminal,
FolderOpen,
Pencil,
Trash2,
Key,
Lock,
} from 'lucide-vue-next'
import {
Server,
Plus,
Terminal,
FolderOpen,
Pencil,
Trash2,
Key,
Lock,
} from 'lucide-vue-next'
const router = useRouter()
const store = useConnectionsStore()
@@ -56,10 +56,10 @@ function openTerminal(conn: Connection) {
router.push(`/terminal/${conn.id}`)
}
function openSftp(conn: Connection) {
router.push(`/sftp/${conn.id}`)
}
</script>
function openSftp(conn: Connection) {
router.push(`/sftp/${conn.id}`)
}
</script>
<template>
<div class="p-6">
@@ -127,24 +127,24 @@ function openSftp(conn: Connection) {
/>
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
</div>
<div class="flex gap-2">
<button
@click="openTerminal(conn)"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
>
<Terminal class="w-4 h-4" aria-hidden="true" />
终端
</button>
<button
@click="openSftp(conn)"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
>
<FolderOpen class="w-4 h-4" aria-hidden="true" />
文件
</button>
</div>
</div>
</div>
<div class="flex gap-2">
<button
@click="openTerminal(conn)"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
>
<Terminal class="w-4 h-4" aria-hidden="true" />
终端
</button>
<button
@click="openSftp(conn)"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
>
<FolderOpen class="w-4 h-4" aria-hidden="true" />
文件
</button>
</div>
</div>
</div>
<ConnectionForm
v-if="showForm"

View File

@@ -1,26 +1,31 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'vue-toast-notification'
import { useConnectionsStore } from '../stores/connections'
import * as sftpApi from '../api/sftp'
import type { SftpFileInfo } from '../api/sftp'
import {
ArrowLeft,
FolderOpen,
File,
Upload,
FolderPlus,
RefreshCw,
Eye,
EyeOff,
Download,
Trash2,
ChevronRight,
Copy,
import {
ArrowLeft,
FolderOpen,
File,
Upload,
FolderPlus,
RefreshCw,
Eye,
EyeOff,
Download,
Trash2,
ChevronRight,
Copy,
CheckCircle,
AlertCircle,
Loader,
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useConnectionsStore()
const connectionId = computed(() => Number(route.params.id))
@@ -31,20 +36,62 @@ const pathParts = ref<string[]>([])
const files = ref<SftpFileInfo[]>([])
const loading = ref(false)
const error = ref('')
const uploading = ref(false)
const selectedFile = ref<string | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const selectedFile = ref<string | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const showHiddenFiles = ref(false)
const searchQuery = ref('')
const filteredFiles = computed(() => {
let searchDebounceTimer = 0
const filteredFiles = ref<SftpFileInfo[]>([])
function applyFileFilters() {
const q = searchQuery.value.trim().toLowerCase()
const base = showHiddenFiles.value ? files.value : files.value.filter((f) => !f.name.startsWith('.'))
if (!q) return base
return base.filter((f) => f.name.toLowerCase().includes(q))
filteredFiles.value = q ? base.filter((f) => f.name.toLowerCase().includes(q)) : base
}
watch([searchQuery, showHiddenFiles, files], () => {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => {
applyFileFilters()
}, 300)
}, { immediate: true })
onBeforeUnmount(() => {
clearTimeout(searchDebounceTimer)
})
const showTransferModal = ref(false)
const showUploadProgress = ref(false)
const uploadProgressList = ref<{ id: string; name: string; size: number; uploaded: number; total: number; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string }[]>([])
const lastUpdate = ref(0)
const totalProgress = computed(() => {
if (uploadProgressList.value.length === 0) return 0
const totalSize = uploadProgressList.value.reduce((sum, item) => sum + item.size, 0)
const uploadedSize = uploadProgressList.value.reduce((sum, item) => {
if (item.status === 'success') return sum + item.size
if (item.status === 'uploading') return sum + item.uploaded
return sum
}, 0)
return totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
})
const currentUploadingFile = computed(() => {
return uploadProgressList.value.find(item => item.status === 'uploading')?.name || ''
})
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleString()
}
const showTransferModal = ref(false)
const transferFile = ref<SftpFileInfo | null>(null)
const transferTargetConnectionId = ref<number | null>(null)
const transferTargetPath = ref('')
@@ -66,7 +113,7 @@ onMounted(() => {
function initPath() {
sftpApi.getPwd(connectionId.value).then((res) => {
const p = res.data.path || '/'
currentPath.value = p || '.'
currentPath.value = p === '/' ? '/' : p
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
loadPath()
}).catch((err: { response?: { data?: { error?: string } } }) => {
@@ -153,27 +200,79 @@ function triggerUpload() {
fileInputRef.value?.click()
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const selected = input.files
if (!selected?.length) return
uploading.value = true
error.value = ''
const path = currentPath.value === '.' ? '' : currentPath.value
try {
for (let i = 0; i < selected.length; i++) {
const file = selected[i]
if (!file) continue
await sftpApi.uploadFile(connectionId.value, path, file)
}
loadPath()
} catch (err: unknown) {
const res = err as { response?: { data?: { error?: string } } }
error.value = res?.response?.data?.error ?? '上传失败'
} finally {
uploading.value = false
input.value = ''
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const selected = input.files
if (!selected?.length) return
uploading.value = true
error.value = ''
const path = currentPath.value === '.' ? '' : currentPath.value
const uploadTasks: { id: string; file: File }[] = []
for (let i = 0; i < selected.length; i++) {
const file = selected[i]
if (!file) continue
uploadTasks.push({ id: `${Date.now()}-${i}`, file })
}
uploadProgressList.value = uploadTasks.map(({ id, file }) => ({
id,
name: file.name,
size: file.size,
uploaded: 0,
total: file.size,
status: 'pending',
}))
showUploadProgress.value = true
const MAX_PARALLEL = 5
const results: Promise<void>[] = []
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
const batchPromises = batch.map(task => {
if (!task) return Promise.resolve()
const { id, file } = task
const item = uploadProgressList.value.find(item => item.id === id)
if (!item) return Promise.resolve()
item.status = 'uploading'
return new Promise<void>((resolve, reject) => {
const onProgress = (percent: number) => {
const now = Date.now()
if (now - (lastUpdate.value || 0) > 100) {
item.uploaded = Math.round((file.size * percent) / 100)
item.total = file.size
lastUpdate.value = now
}
}
const xhr = sftpApi.uploadFileWithProgress(connectionId.value, path, file)
xhr.onProgress = onProgress
xhr.onload = () => {
item.status = 'success'
resolve()
}
xhr.onerror = () => {
item.status = 'error'
item.message = 'Network error'
reject(new Error('Network error'))
}
})
})
results.push(...batchPromises)
await Promise.allSettled(batchPromises)
}
await Promise.allSettled(results)
await loadPath()
showUploadProgress.value = false
uploadProgressList.value = []
uploading.value = false
fileInputRef.value!.value = ''
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
toast.success(`成功上传 ${successCount} 个文件`)
}
function handleMkdir() {
@@ -240,16 +339,6 @@ async function submitTransfer() {
transferring.value = false
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleString()
}
</script>
<template>
@@ -338,6 +427,41 @@ function formatDate(ts: number): string {
/>
</div>
<div v-if="showUploadProgress" class="bg-slate-800/50 border-b border-slate-700 p-4 space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-slate-300">上传进度: {{ totalProgress }}%</span>
<span class="text-sm text-slate-400">{{ currentUploadingFile || '准备上传...' }}</span>
</div>
<div class="w-full bg-slate-700 rounded-full h-2 overflow-hidden">
<div
class="bg-cyan-600 h-full transition-all duration-300"
:style="{ width: totalProgress + '%' }"
></div>
</div>
<div class="grid grid-cols-1 gap-2 max-h-40 overflow-y-auto">
<div
v-for="item in uploadProgressList"
:key="item.id"
class="flex items-center gap-3 text-sm"
>
<CheckCircle v-if="item.status === 'success'" class="w-4 h-4 flex-shrink-0 text-green-500" aria-hidden="true" />
<AlertCircle v-else-if="item.status === 'error'" class="w-4 h-4 flex-shrink-0 text-red-500" aria-hidden="true" />
<Loader v-else-if="item.status === 'uploading'" class="w-4 h-4 flex-shrink-0 text-cyan-500 animate-spin" aria-hidden="true" />
<File v-else class="w-4 h-4 flex-shrink-0 text-slate-500" aria-hidden="true" />
<span class="flex-1 truncate text-slate-300">{{ item.name }}</span>
<span class="text-slate-400 text-xs">
{{ formatSize(item.size) }}
<template v-if="item.status === 'uploading'">
({{ Math.round((item.uploaded / item.total) * 100) }}%)
</template>
<template v-else-if="item.status === 'success'">
</template>
</span>
</div>
</div>
</div>
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
<div v-if="loading" class="p-8 text-center text-slate-400">
@@ -397,7 +521,7 @@ function formatDate(ts: number): string {
</div>
</button>
<div v-if="filteredFiles.length === 0 && !loading" class="p-8 text-center text-slate-500">
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件已隐藏隐藏文件') }}
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件已隐藏文件') }}
</div>
</div>
</div>

View File

@@ -12,5 +12,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"]
}