增强 SSH/SFTP 稳定性并完善安全校验与前端交互
This commit is contained in:
46
AGENTS.md
46
AGENTS.md
@@ -157,3 +157,49 @@
|
|||||||
- Copilot 规则:未发现 `.github/copilot-instructions.md`
|
- Copilot 规则:未发现 `.github/copilot-instructions.md`
|
||||||
|
|
||||||
若未来新增上述规则文件,agents 必须先读取并将其视为高优先级约束。
|
若未来新增上述规则文件,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 数据库文件
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import org.springframework.boot.CommandLineRunner;
|
|||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class DataInitializer implements CommandLineRunner {
|
public class DataInitializer implements CommandLineRunner {
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ public class DataInitializer implements CommandLineRunner {
|
|||||||
admin.setUsername("admin");
|
admin.setUsername("admin");
|
||||||
admin.setPasswordHash(passwordEncoder.encode("admin123"));
|
admin.setPasswordHash(passwordEncoder.encode("admin123"));
|
||||||
admin.setDisplayName("Administrator");
|
admin.setDisplayName("Administrator");
|
||||||
|
admin.setPasswordChangedAt(Instant.now());
|
||||||
userRepository.save(admin);
|
userRepository.save(admin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.sshmanager.config;
|
package com.sshmanager.config;
|
||||||
|
|
||||||
import com.sshmanager.security.JwtAuthenticationFilter;
|
import com.sshmanager.security.JwtAuthenticationFilter;
|
||||||
|
import com.sshmanager.security.PasswordExpirationFilter;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -16,6 +17,8 @@ import org.springframework.web.cors.CorsConfigurationSource;
|
|||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -26,10 +29,14 @@ public class SecurityConfig {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private SecurityExceptionHandler securityExceptionHandler;
|
private SecurityExceptionHandler securityExceptionHandler;
|
||||||
|
|
||||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
|
||||||
|
PasswordExpirationFilter passwordExpirationFilter) {
|
||||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||||
|
this.passwordExpirationFilter = passwordExpirationFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final PasswordExpirationFilter passwordExpirationFilter;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
@@ -49,7 +56,8 @@ public class SecurityConfig {
|
|||||||
e.authenticationEntryPoint(securityExceptionHandler);
|
e.authenticationEntryPoint(securityExceptionHandler);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.addFilterBefore(passwordExpirationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
@@ -62,10 +70,9 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
config.setAllowedOrigins(Arrays.asList(
|
// Docker/remote deployments may be accessed via IP/hostname.
|
||||||
"http://localhost:5173", "http://127.0.0.1:5173",
|
// API and WS are still protected by JWT.
|
||||||
"http://localhost:48080", "http://127.0.0.1:48080"
|
config.addAllowedOriginPattern("*");
|
||||||
));
|
|
||||||
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
config.setAllowedHeaders(Arrays.asList("*"));
|
config.setAllowedHeaders(Arrays.asList("*"));
|
||||||
config.setAllowCredentials(true);
|
config.setAllowCredentials(true);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,9 +23,8 @@ public class WebSocketConfig implements WebSocketConfigurer {
|
|||||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
|
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
|
||||||
.addInterceptors(terminalHandshakeInterceptor)
|
.addInterceptors(terminalHandshakeInterceptor)
|
||||||
.setAllowedOrigins(
|
// Docker/remote deployments often use non-localhost origins.
|
||||||
"http://localhost:5173", "http://127.0.0.1:5173",
|
// WebSocket access is still protected by JWT in the handshake.
|
||||||
"http://localhost:48080", "http://127.0.0.1:48080"
|
.setAllowedOrigins("*");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.sshmanager.controller;
|
|||||||
|
|
||||||
import com.sshmanager.dto.ConnectionCreateRequest;
|
import com.sshmanager.dto.ConnectionCreateRequest;
|
||||||
import com.sshmanager.dto.ConnectionDto;
|
import com.sshmanager.dto.ConnectionDto;
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
import com.sshmanager.entity.User;
|
import com.sshmanager.entity.User;
|
||||||
import com.sshmanager.repository.UserRepository;
|
import com.sshmanager.repository.UserRepository;
|
||||||
import com.sshmanager.service.ConnectionService;
|
import com.sshmanager.service.ConnectionService;
|
||||||
@@ -67,4 +68,27 @@ public class ConnectionController {
|
|||||||
result.put("message", "Deleted");
|
result.put("message", "Deleted");
|
||||||
return ResponseEntity.ok(result);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.springframework.http.HttpHeaders;
|
|||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||||
@@ -23,6 +24,7 @@ import java.util.Map;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/sftp")
|
@RequestMapping("/api/sftp")
|
||||||
@@ -35,10 +37,6 @@ public class SftpController {
|
|||||||
private final SftpService sftpService;
|
private final SftpService sftpService;
|
||||||
|
|
||||||
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
|
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<>();
|
private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public SftpController(ConnectionService connectionService,
|
public SftpController(ConnectionService connectionService,
|
||||||
@@ -91,6 +89,7 @@ public class SftpController {
|
|||||||
session = sftpService.connect(conn, password, privateKey, passphrase);
|
session = sftpService.connect(conn, password, privateKey, passphrase);
|
||||||
sessions.put(key, session);
|
sessions.put(key, session);
|
||||||
}
|
}
|
||||||
|
cleanupTask.recordAccess(key);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +110,6 @@ public class SftpController {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
return ResponseEntity.ok(dtos);
|
return ResponseEntity.ok(dtos);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// If the underlying SFTP channel got into a bad state, force reconnect on next request.
|
|
||||||
SftpService.SftpSession existing = sessions.remove(key);
|
SftpService.SftpSession existing = sessions.remove(key);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
existing.disconnect();
|
existing.disconnect();
|
||||||
@@ -132,7 +130,6 @@ public class SftpController {
|
|||||||
if (e.getMessage() != null && !e.getMessage().trim().isEmpty()) {
|
if (e.getMessage() != null && !e.getMessage().trim().isEmpty()) {
|
||||||
return e.getMessage();
|
return e.getMessage();
|
||||||
}
|
}
|
||||||
// Unwrap nested RuntimeExceptions to find the underlying SftpException (if any).
|
|
||||||
Throwable cur = e;
|
Throwable cur = e;
|
||||||
for (int i = 0; i < 10 && cur != null; i++) {
|
for (int i = 0; i < 10 && cur != null; i++) {
|
||||||
if (cur instanceof SftpException) {
|
if (cur instanceof SftpException) {
|
||||||
@@ -403,8 +400,45 @@ public class SftpController {
|
|||||||
session.disconnect();
|
session.disconnect();
|
||||||
}
|
}
|
||||||
sessionLocks.remove(key);
|
sessionLocks.remove(key);
|
||||||
|
cleanupTask.removeSession(key);
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
result.put("message", "Disconnected");
|
result.put("message", "Disconnected");
|
||||||
return ResponseEntity.ok(result);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.sshmanager.repository.ConnectionRepository;
|
|||||||
import com.sshmanager.repository.UserRepository;
|
import com.sshmanager.repository.UserRepository;
|
||||||
import com.sshmanager.service.ConnectionService;
|
import com.sshmanager.service.ConnectionService;
|
||||||
import com.sshmanager.service.SshService;
|
import com.sshmanager.service.SshService;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.socket.CloseStatus;
|
import org.springframework.web.socket.CloseStatus;
|
||||||
import org.springframework.web.socket.TextMessage;
|
import org.springframework.web.socket.TextMessage;
|
||||||
@@ -17,7 +18,7 @@ import java.io.InputStream;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||||
@@ -26,18 +27,22 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final ConnectionService connectionService;
|
private final ConnectionService connectionService;
|
||||||
private final SshService sshService;
|
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, SshService.SshSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Long> lastActivity = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public TerminalWebSocketHandler(ConnectionRepository connectionRepository,
|
public TerminalWebSocketHandler(ConnectionRepository connectionRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
ConnectionService connectionService,
|
ConnectionService connectionService,
|
||||||
SshService sshService) {
|
SshService sshService,
|
||||||
|
@Qualifier("terminalWebSocketExecutor") ExecutorService executor) {
|
||||||
this.connectionRepository = connectionRepository;
|
this.connectionRepository = connectionRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.connectionService = connectionService;
|
this.connectionService = connectionService;
|
||||||
this.sshService = sshService;
|
this.sshService = sshService;
|
||||||
|
this.executor = executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -69,6 +74,8 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
try {
|
try {
|
||||||
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
|
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||||
sessions.put(webSocketSession.getId(), sshSession);
|
sessions.put(webSocketSession.getId(), sshSession);
|
||||||
|
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||||
|
sessionCount.incrementAndGet();
|
||||||
|
|
||||||
executor.submit(() -> {
|
executor.submit(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -97,6 +104,7 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
|
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
|
||||||
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
|
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
|
||||||
if (sshSession != null && sshSession.isConnected()) {
|
if (sshSession != null && sshSession.isConnected()) {
|
||||||
|
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||||
sshSession.getInputStream().write(message.asBytes());
|
sshSession.getInputStream().write(message.asBytes());
|
||||||
sshSession.getInputStream().flush();
|
sshSession.getInputStream().flush();
|
||||||
}
|
}
|
||||||
@@ -105,8 +113,10 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
@Override
|
@Override
|
||||||
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {
|
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {
|
||||||
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
|
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
|
||||||
|
lastActivity.remove(webSocketSession.getId());
|
||||||
if (sshSession != null) {
|
if (sshSession != null) {
|
||||||
sshSession.disconnect();
|
sshSession.disconnect();
|
||||||
|
sessionCount.decrementAndGet();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import lombok.AllArgsConstructor;
|
|||||||
|
|
||||||
import javax.persistence.*;
|
import javax.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@@ -32,4 +33,7 @@ public class User {
|
|||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Instant updatedAt = Instant.now();
|
private Instant updatedAt = Instant.now();
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant passwordChangedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
public class AccessDeniedException extends SshManagerException {
|
||||||
|
public AccessDeniedException(String message) {
|
||||||
|
super(403, "ACCESS_DENIED", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,4 +39,20 @@ public class GlobalExceptionHandler {
|
|||||||
err.put("error", "上传失败:表单解析异常");
|
err.put("error", "上传失败:表单解析异常");
|
||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(err);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
public class InvalidOperationException extends SshManagerException {
|
||||||
|
public InvalidOperationException(String message) {
|
||||||
|
super(400, "INVALID_OPERATION", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
public class NotFoundException extends SshManagerException {
|
||||||
|
public NotFoundException(String message) {
|
||||||
|
super(404, "NOT_FOUND", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
public class UnauthorizedException extends SshManagerException {
|
||||||
|
public UnauthorizedException(String message) {
|
||||||
|
super(401, "UNAUTHORIZED", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,14 @@ public class ConnectionService {
|
|||||||
|
|
||||||
private final ConnectionRepository connectionRepository;
|
private final ConnectionRepository connectionRepository;
|
||||||
private final EncryptionService encryptionService;
|
private final EncryptionService encryptionService;
|
||||||
|
private final SshService sshService;
|
||||||
|
|
||||||
public ConnectionService(ConnectionRepository connectionRepository,
|
public ConnectionService(ConnectionRepository connectionRepository,
|
||||||
EncryptionService encryptionService) {
|
EncryptionService encryptionService,
|
||||||
|
SshService sshService) {
|
||||||
this.connectionRepository = connectionRepository;
|
this.connectionRepository = connectionRepository;
|
||||||
this.encryptionService = encryptionService;
|
this.encryptionService = encryptionService;
|
||||||
|
this.sshService = sshService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ConnectionDto> listByUserId(Long userId) {
|
public List<ConnectionDto> listByUserId(Long userId) {
|
||||||
@@ -130,4 +133,18 @@ public class ConnectionService {
|
|||||||
return conn.getPassphrase() != null ?
|
return conn.getPassphrase() != null ?
|
||||||
encryptionService.decrypt(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ import java.util.concurrent.TimeoutException;
|
|||||||
@Service
|
@Service
|
||||||
public class SftpService {
|
public class SftpService {
|
||||||
|
|
||||||
|
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)
|
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
JSch jsch = new JSch();
|
JSch jsch = new JSch();
|
||||||
@@ -40,8 +46,14 @@ public class SftpService {
|
|||||||
session.setConfig("StrictHostKeyChecking", "no");
|
session.setConfig("StrictHostKeyChecking", "no");
|
||||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
// 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");
|
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);
|
session.setPassword(password);
|
||||||
|
} else {
|
||||||
|
session.setConfig("PreferredAuthentications", "publickey");
|
||||||
}
|
}
|
||||||
session.connect(10000);
|
session.connect(10000);
|
||||||
|
|
||||||
@@ -193,9 +205,7 @@ public class SftpService {
|
|||||||
PipedOutputStream pos = new PipedOutputStream();
|
PipedOutputStream pos = new PipedOutputStream();
|
||||||
PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize);
|
PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize);
|
||||||
|
|
||||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
Future<?> putFuture = executorService.submit(() -> {
|
||||||
try {
|
|
||||||
Future<?> putFuture = executor.submit(() -> {
|
|
||||||
try {
|
try {
|
||||||
target.getChannel().put(pis, targetPath);
|
target.getChannel().put(pis, targetPath);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -205,23 +215,9 @@ public class SftpService {
|
|||||||
source.getChannel().get(sourcePath, pos);
|
source.getChannel().get(sourcePath, pos);
|
||||||
pos.close();
|
pos.close();
|
||||||
putFuture.get(5, TimeUnit.MINUTES);
|
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();
|
|
||||||
try {
|
try {
|
||||||
pis.close();
|
pis.close();
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,8 +31,14 @@ public class SshService {
|
|||||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
// 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");
|
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);
|
session.setPassword(password);
|
||||||
|
} else {
|
||||||
|
session.setConfig("PreferredAuthentications", "publickey");
|
||||||
}
|
}
|
||||||
|
|
||||||
session.connect(10000);
|
session.connect(10000);
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ spring:
|
|||||||
add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退
|
add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 200MB
|
max-file-size: 2048MB
|
||||||
max-request-size: 200MB
|
max-request-size: 2048MB
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
||||||
driver-class-name: org.h2.Driver
|
driver-class-name: org.h2.Driver
|
||||||
@@ -17,6 +17,9 @@ spring:
|
|||||||
h2:
|
h2:
|
||||||
console:
|
console:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
path: /h2
|
||||||
|
settings:
|
||||||
|
web-allow-others: true
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: update
|
ddl-auto: update
|
||||||
@@ -29,6 +32,13 @@ spring:
|
|||||||
|
|
||||||
# Encryption key for connection passwords (base64, 32 bytes for AES-256)
|
# Encryption key for connection passwords (base64, 32 bytes for AES-256)
|
||||||
sshmanager:
|
sshmanager:
|
||||||
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=}
|
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY ""}
|
||||||
jwt-secret: ${SSHMANAGER_JWT_SECRET:ssh-manager-jwt-secret-change-in-production}
|
jwt-secret: ${SSHMANAGER_JWT_SECRET ""}
|
||||||
jwt-expiration-ms: 86400000
|
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
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@ services:
|
|||||||
- "48080:48080"
|
- "48080:48080"
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# 生产环境建议设置并挂载密钥
|
# JWT Secret (change in production!)
|
||||||
# - SSHMANAGER_ENCRYPTION_KEY=...
|
- SSHMANAGER_JWT_SECRET=ssh-manager-prod-jwt-secret-20240311
|
||||||
# - SSHMANAGER_JWT_SECRET=...
|
# Encryption Key (base64, 32 bytes; change in production!)
|
||||||
|
- SSHMANAGER_ENCRYPTION_KEY=MLVt7pE35KULIppEiit0doUMvSjozZJ037oNGeXjhVA=
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^5.0.2",
|
"vue-router": "^5.0.2",
|
||||||
|
"vue-toast-notification": "^3.1.3",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3327,6 +3328,18 @@
|
|||||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vue-tsc": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
|
||||||
|
|||||||
@@ -16,15 +16,16 @@
|
|||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^5.0.2",
|
"vue-router": "^5.0.2",
|
||||||
|
"vue-toast-notification": "^3.1.3",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.14",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
|
||||||
"@vue/tsconfig": "^0.8.1",
|
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vue-tsc": "^3.1.4"
|
"vue-tsc": "^3.1.4"
|
||||||
|
|||||||
@@ -37,6 +37,51 @@ export async function downloadFile(connectionId: number, path: string) {
|
|||||||
URL.revokeObjectURL(url)
|
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) {
|
export function uploadFile(connectionId: number, path: string, file: File) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file, file.name)
|
form.append('file', file, file.name)
|
||||||
|
|||||||
@@ -20,10 +20,32 @@ const username = ref('')
|
|||||||
const authType = ref<AuthType>('PASSWORD')
|
const authType = ref<AuthType>('PASSWORD')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const privateKey = ref('')
|
const privateKey = ref('')
|
||||||
|
const privateKeyFileName = ref('')
|
||||||
|
const privateKeyInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const passphrase = ref('')
|
const passphrase = ref('')
|
||||||
|
|
||||||
const isEdit = computed(() => !!props.connection)
|
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(
|
watch(
|
||||||
() => props.connection,
|
() => props.connection,
|
||||||
(c) => {
|
(c) => {
|
||||||
@@ -35,6 +57,7 @@ watch(
|
|||||||
authType.value = c.authType
|
authType.value = c.authType
|
||||||
password.value = ''
|
password.value = ''
|
||||||
privateKey.value = ''
|
privateKey.value = ''
|
||||||
|
privateKeyFileName.value = ''
|
||||||
passphrase.value = ''
|
passphrase.value = ''
|
||||||
} else {
|
} else {
|
||||||
name.value = ''
|
name.value = ''
|
||||||
@@ -44,6 +67,7 @@ watch(
|
|||||||
authType.value = 'PASSWORD'
|
authType.value = 'PASSWORD'
|
||||||
password.value = ''
|
password.value = ''
|
||||||
privateKey.value = ''
|
privateKey.value = ''
|
||||||
|
privateKeyFileName.value = ''
|
||||||
passphrase.value = ''
|
passphrase.value = ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -69,8 +93,52 @@ function handleDialogMouseDown() {
|
|||||||
backdropPressed.value = false
|
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() {
|
async function handleSubmit() {
|
||||||
error.value = ''
|
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()) {
|
if (!name.value.trim()) {
|
||||||
error.value = '请填写名称'
|
error.value = '请填写名称'
|
||||||
return
|
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"
|
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"
|
placeholder="192.168.1.1"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="hostError" class="mt-1 text-xs text-red-400">{{ hostError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="port" class="block text-sm font-medium text-slate-300 mb-1">端口</label>
|
<label for="port" class="block text-sm font-medium text-slate-300 mb-1">端口</label>
|
||||||
@@ -180,6 +249,7 @@ async function handleSubmit() {
|
|||||||
max="65535"
|
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"
|
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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -221,13 +291,24 @@ async function handleSubmit() {
|
|||||||
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
|
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
|
||||||
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
|
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<input
|
||||||
|
ref="privateKeyInputRef"
|
||||||
id="privateKey"
|
id="privateKey"
|
||||||
v-model="privateKey"
|
type="file"
|
||||||
rows="6"
|
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 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
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"
|
||||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
@change="handlePrivateKeyFileChange"
|
||||||
></textarea>
|
/>
|
||||||
|
<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>
|
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令(可选)</label>
|
||||||
<input
|
<input
|
||||||
id="passphrase"
|
id="passphrase"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
import Toast from 'vue-toast-notification'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
@@ -7,4 +8,9 @@ import './style.css'
|
|||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(Toast, {
|
||||||
|
position: 'top-right',
|
||||||
|
duration: 3000,
|
||||||
|
dismissible: true,
|
||||||
|
})
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useToast } from 'vue-toast-notification'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import * as sftpApi from '../api/sftp'
|
import * as sftpApi from '../api/sftp'
|
||||||
import type { SftpFileInfo } from '../api/sftp'
|
import type { SftpFileInfo } from '../api/sftp'
|
||||||
@@ -17,10 +18,14 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Loader,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
const store = useConnectionsStore()
|
const store = useConnectionsStore()
|
||||||
|
|
||||||
const connectionId = computed(() => Number(route.params.id))
|
const connectionId = computed(() => Number(route.params.id))
|
||||||
@@ -37,13 +42,55 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
|
|||||||
|
|
||||||
const showHiddenFiles = ref(false)
|
const showHiddenFiles = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const filteredFiles = computed(() => {
|
let searchDebounceTimer = 0
|
||||||
|
const filteredFiles = ref<SftpFileInfo[]>([])
|
||||||
|
|
||||||
|
function applyFileFilters() {
|
||||||
const q = searchQuery.value.trim().toLowerCase()
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
const base = showHiddenFiles.value ? files.value : files.value.filter((f) => !f.name.startsWith('.'))
|
const base = showHiddenFiles.value ? files.value : files.value.filter((f) => !f.name.startsWith('.'))
|
||||||
if (!q) return base
|
filteredFiles.value = q ? base.filter((f) => f.name.toLowerCase().includes(q)) : base
|
||||||
return base.filter((f) => f.name.toLowerCase().includes(q))
|
}
|
||||||
|
|
||||||
|
watch([searchQuery, showHiddenFiles, files], () => {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
applyFileFilters()
|
||||||
|
}, 300)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 showTransferModal = ref(false)
|
||||||
const transferFile = ref<SftpFileInfo | null>(null)
|
const transferFile = ref<SftpFileInfo | null>(null)
|
||||||
const transferTargetConnectionId = ref<number | null>(null)
|
const transferTargetConnectionId = ref<number | null>(null)
|
||||||
@@ -66,7 +113,7 @@ onMounted(() => {
|
|||||||
function initPath() {
|
function initPath() {
|
||||||
sftpApi.getPwd(connectionId.value).then((res) => {
|
sftpApi.getPwd(connectionId.value).then((res) => {
|
||||||
const p = res.data.path || '/'
|
const p = res.data.path || '/'
|
||||||
currentPath.value = p || '.'
|
currentPath.value = p === '/' ? '/' : p
|
||||||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||||
loadPath()
|
loadPath()
|
||||||
}).catch((err: { response?: { data?: { error?: string } } }) => {
|
}).catch((err: { response?: { data?: { error?: string } } }) => {
|
||||||
@@ -160,20 +207,72 @@ async function handleFileSelect(e: Event) {
|
|||||||
uploading.value = true
|
uploading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||||
try {
|
|
||||||
|
const uploadTasks: { id: string; file: File }[] = []
|
||||||
for (let i = 0; i < selected.length; i++) {
|
for (let i = 0; i < selected.length; i++) {
|
||||||
const file = selected[i]
|
const file = selected[i]
|
||||||
if (!file) continue
|
if (!file) continue
|
||||||
await sftpApi.uploadFile(connectionId.value, path, file)
|
uploadTasks.push({ id: `${Date.now()}-${i}`, file })
|
||||||
}
|
}
|
||||||
loadPath()
|
|
||||||
} catch (err: unknown) {
|
uploadProgressList.value = uploadTasks.map(({ id, file }) => ({
|
||||||
const res = err as { response?: { data?: { error?: string } } }
|
id,
|
||||||
error.value = res?.response?.data?.error ?? '上传失败'
|
name: file.name,
|
||||||
} finally {
|
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
|
uploading.value = false
|
||||||
input.value = ''
|
fileInputRef.value!.value = ''
|
||||||
}
|
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
|
||||||
|
toast.success(`成功上传 ${successCount} 个文件`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMkdir() {
|
function handleMkdir() {
|
||||||
@@ -240,16 +339,6 @@ async function submitTransfer() {
|
|||||||
transferring.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -338,6 +427,41 @@ function formatDate(ts: number): string {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<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">
|
<div v-if="loading" class="p-8 text-center text-slate-400">
|
||||||
@@ -397,7 +521,7 @@ function formatDate(ts: number): string {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="filteredFiles.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user