diff --git a/AGENTS.md b/AGENTS.md index 2aca4f2..4cc0fad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 数据库文件 diff --git a/backend/src/main/java/com/sshmanager/config/ConfigurationValidator.java b/backend/src/main/java/com/sshmanager/config/ConfigurationValidator.java new file mode 100644 index 0000000..a6f79f2 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/config/ConfigurationValidator.java @@ -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 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."); + } +} diff --git a/backend/src/main/java/com/sshmanager/config/DataInitializer.java b/backend/src/main/java/com/sshmanager/config/DataInitializer.java index 144b9cb..16370ae 100644 --- a/backend/src/main/java/com/sshmanager/config/DataInitializer.java +++ b/backend/src/main/java/com/sshmanager/config/DataInitializer.java @@ -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); } } diff --git a/backend/src/main/java/com/sshmanager/config/SecurityConfig.java b/backend/src/main/java/com/sshmanager/config/SecurityConfig.java index b99978e..b4e8e3f 100644 --- a/backend/src/main/java/com/sshmanager/config/SecurityConfig.java +++ b/backend/src/main/java/com/sshmanager/config/SecurityConfig.java @@ -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); diff --git a/backend/src/main/java/com/sshmanager/config/SftpSessionCleanupTask.java b/backend/src/main/java/com/sshmanager/config/SftpSessionCleanupTask.java new file mode 100644 index 0000000..dcb19c3 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/config/SftpSessionCleanupTask.java @@ -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); + } +} diff --git a/backend/src/main/java/com/sshmanager/config/WebSocketConfig.java b/backend/src/main/java/com/sshmanager/config/WebSocketConfig.java index 35e4740..7285eb9 100644 --- a/backend/src/main/java/com/sshmanager/config/WebSocketConfig.java +++ b/backend/src/main/java/com/sshmanager/config/WebSocketConfig.java @@ -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("*"); + } +} diff --git a/backend/src/main/java/com/sshmanager/config/WebSocketThreadPoolConfig.java b/backend/src/main/java/com/sshmanager/config/WebSocketThreadPoolConfig.java new file mode 100644 index 0000000..a433e7a --- /dev/null +++ b/backend/src/main/java/com/sshmanager/config/WebSocketThreadPoolConfig.java @@ -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 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() { + } +} diff --git a/backend/src/main/java/com/sshmanager/controller/ConnectionController.java b/backend/src/main/java/com/sshmanager/controller/ConnectionController.java index a0dbe07..ba3935d 100644 --- a/backend/src/main/java/com/sshmanager/controller/ConnectionController.java +++ b/backend/src/main/java/com/sshmanager/controller/ConnectionController.java @@ -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> 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 result = new HashMap<>(); + result.put("success", true); + result.put("message", "Connection test successful"); + return ResponseEntity.ok(result); + } catch (Exception e) { + Map result = new HashMap<>(); + result.put("success", false); + result.put("message", "Connection failed: " + e.getMessage()); + return ResponseEntity.ok(result); + } + } } diff --git a/backend/src/main/java/com/sshmanager/controller/SftpController.java b/backend/src/main/java/com/sshmanager/controller/SftpController.java index 883bfe6..565638b 100644 --- a/backend/src/main/java/com/sshmanager/controller/SftpController.java +++ b/backend/src/main/java/com/sshmanager/controller/SftpController.java @@ -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 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 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 sessions = new ConcurrentHashMap<>(); + private final Map 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 withSessionLock(String key, Supplier 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 files = sftpService.listFiles(session, path); - List 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 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> 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 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 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 files = sftpService.listFiles(session, path); + List 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 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> 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 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 err = new HashMap<>(); + err.put("error", e.getMessage() != null ? e.getMessage() : "pwd failed"); + return ResponseEntity.status(500).body(err); + } + } + @GetMapping("/download") public ResponseEntity download( @RequestParam Long connectionId, @@ -208,19 +205,19 @@ public class SftpController { return ResponseEntity.status(500).build(); } } - - @PostMapping("/upload") - public ResponseEntity> 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> 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 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 error = new HashMap<>(); - error.put("error", e.getMessage()); - return ResponseEntity.status(500).body(error); - } - } - - @DeleteMapping("/delete") - public ResponseEntity> 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 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 error = new HashMap<>(); - error.put("error", e.getMessage()); - return ResponseEntity.status(500).body(error); - } - } - - @PostMapping("/mkdir") - public ResponseEntity> 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 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 error = new HashMap<>(); - error.put("error", e.getMessage()); - return ResponseEntity.status(500).body(error); - } - } - - @PostMapping("/rename") - public ResponseEntity> 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 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 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 error = new HashMap<>(); + error.put("error", e.getMessage()); + return ResponseEntity.status(500).body(error); + } + } + + @DeleteMapping("/delete") + public ResponseEntity> 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 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 error = new HashMap<>(); + error.put("error", e.getMessage()); + return ResponseEntity.status(500).body(error); + } + } + + @PostMapping("/mkdir") + public ResponseEntity> 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 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 error = new HashMap<>(); + error.put("error", e.getMessage()); + return ResponseEntity.status(500).body(error); + } + } + + @PostMapping("/rename") + public ResponseEntity> 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 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 error = new HashMap<>(); + error.put("error", e.getMessage()); + return ResponseEntity.status(500).body(error); + } + } + + @PostMapping("/transfer-remote") public ResponseEntity> 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 err = new HashMap<>(); - err.put("error", "sourcePath is required"); - return ResponseEntity.badRequest().body(err); - } + if (sourcePath == null || sourcePath.trim().isEmpty()) { + Map err = new HashMap<>(); + err.put("error", "sourcePath is required"); + return ResponseEntity.badRequest().body(err); + } if (targetPath == null || targetPath.trim().isEmpty()) { Map err = new HashMap<>(); err.put("error", "targetPath is required"); @@ -387,24 +384,61 @@ public class SftpController { return ResponseEntity.ok(result); } catch (Exception e) { Map error = new HashMap<>(); - error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed"); - return ResponseEntity.status(500).body(error); - } - } - - @PostMapping("/disconnect") - public ResponseEntity> 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 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> 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 result = new HashMap<>(); + result.put("message", "Disconnected"); + return ResponseEntity.ok(result); + } + + public void cleanupExpiredSessions(int timeoutMinutes) { + List 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 lastAccessTime = new ConcurrentHashMap<>(); + + public void recordAccess(String key) { + lastAccessTime.put(key, System.currentTimeMillis()); + } + + public void removeSession(String key) { + lastAccessTime.remove(key); + } + + public List 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()); + } + } +} diff --git a/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java b/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java index 276db56..209fe61 100644 --- a/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java +++ b/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java @@ -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 sessions = new ConcurrentHashMap<>(); + private final Map 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(); } } } diff --git a/backend/src/main/java/com/sshmanager/entity/User.java b/backend/src/main/java/com/sshmanager/entity/User.java index daa8676..a800d45 100644 --- a/backend/src/main/java/com/sshmanager/entity/User.java +++ b/backend/src/main/java/com/sshmanager/entity/User.java @@ -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; } diff --git a/backend/src/main/java/com/sshmanager/exception/AccessDeniedException.java b/backend/src/main/java/com/sshmanager/exception/AccessDeniedException.java new file mode 100644 index 0000000..7ca0477 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/exception/AccessDeniedException.java @@ -0,0 +1,7 @@ +package com.sshmanager.exception; + +public class AccessDeniedException extends SshManagerException { + public AccessDeniedException(String message) { + super(403, "ACCESS_DENIED", message); + } +} diff --git a/backend/src/main/java/com/sshmanager/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/sshmanager/exception/GlobalExceptionHandler.java index b118c6c..359e7f6 100644 --- a/backend/src/main/java/com/sshmanager/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/sshmanager/exception/GlobalExceptionHandler.java @@ -39,4 +39,20 @@ public class GlobalExceptionHandler { err.put("error", "上传失败:表单解析异常"); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(err); } + + @ExceptionHandler(SshManagerException.class) + public ResponseEntity> handleSshManagerException(SshManagerException e) { + Map 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> handleException(Exception e) { + Map error = new HashMap<>(); + error.put("error", "INTERNAL_ERROR"); + error.put("message", "Internal server error"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } } diff --git a/backend/src/main/java/com/sshmanager/exception/InvalidOperationException.java b/backend/src/main/java/com/sshmanager/exception/InvalidOperationException.java new file mode 100644 index 0000000..d96e2fc --- /dev/null +++ b/backend/src/main/java/com/sshmanager/exception/InvalidOperationException.java @@ -0,0 +1,7 @@ +package com.sshmanager.exception; + +public class InvalidOperationException extends SshManagerException { + public InvalidOperationException(String message) { + super(400, "INVALID_OPERATION", message); + } +} diff --git a/backend/src/main/java/com/sshmanager/exception/NotFoundException.java b/backend/src/main/java/com/sshmanager/exception/NotFoundException.java new file mode 100644 index 0000000..041227b --- /dev/null +++ b/backend/src/main/java/com/sshmanager/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package com.sshmanager.exception; + +public class NotFoundException extends SshManagerException { + public NotFoundException(String message) { + super(404, "NOT_FOUND", message); + } +} diff --git a/backend/src/main/java/com/sshmanager/exception/SshManagerException.java b/backend/src/main/java/com/sshmanager/exception/SshManagerException.java new file mode 100644 index 0000000..3b4598e --- /dev/null +++ b/backend/src/main/java/com/sshmanager/exception/SshManagerException.java @@ -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; + } +} diff --git a/backend/src/main/java/com/sshmanager/exception/UnauthorizedException.java b/backend/src/main/java/com/sshmanager/exception/UnauthorizedException.java new file mode 100644 index 0000000..f9eff0b --- /dev/null +++ b/backend/src/main/java/com/sshmanager/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package com.sshmanager.exception; + +public class UnauthorizedException extends SshManagerException { + public UnauthorizedException(String message) { + super(401, "UNAUTHORIZED", message); + } +} diff --git a/backend/src/main/java/com/sshmanager/security/PasswordExpirationFilter.java b/backend/src/main/java/com/sshmanager/security/PasswordExpirationFilter.java new file mode 100644 index 0000000..20c5e8c --- /dev/null +++ b/backend/src/main/java/com/sshmanager/security/PasswordExpirationFilter.java @@ -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)); + } +} diff --git a/backend/src/main/java/com/sshmanager/service/ConnectionService.java b/backend/src/main/java/com/sshmanager/service/ConnectionService.java index b8a387a..5827e8e 100644 --- a/backend/src/main/java/com/sshmanager/service/ConnectionService.java +++ b/backend/src/main/java/com/sshmanager/service/ConnectionService.java @@ -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 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(); + } + } + } } diff --git a/backend/src/main/java/com/sshmanager/service/SftpService.java b/backend/src/main/java/com/sshmanager/service/SftpService.java index 55f368e..40f912b 100644 --- a/backend/src/main/java/com/sshmanager/service/SftpService.java +++ b/backend/src/main/java/com/sshmanager/service/SftpService.java @@ -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) { } } } diff --git a/backend/src/main/java/com/sshmanager/service/SshService.java b/backend/src/main/java/com/sshmanager/service/SshService.java index 607fb9e..5ef51af 100644 --- a/backend/src/main/java/com/sshmanager/service/SshService.java +++ b/backend/src/main/java/com/sshmanager/service/SshService.java @@ -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(); } - } -} + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 31178d2..ad52294 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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 diff --git a/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java b/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java new file mode 100644 index 0000000..22631b8 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java @@ -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 body = (Map) 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 body = (Map) response.getBody(); + assertFalse((Boolean) body.get("success")); + assertTrue(((String) body.get("message")).contains("Connection failed")); + } +} diff --git a/backend/src/test/java/com/sshmanager/service/SftpServiceTest.java b/backend/src/test/java/com/sshmanager/service/SftpServiceTest.java new file mode 100644 index 0000000..ece1aa0 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/service/SftpServiceTest.java @@ -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()); + } +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f979ee4..0d9ca17 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1e5e574..e431ef8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index d4569ef..5d5123c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/api/sftp.ts b/frontend/src/api/sftp.ts index 4232848..8f8f2eb 100644 --- a/frontend/src/api/sftp.ts +++ b/frontend/src/api/sftp.ts @@ -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('/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('/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, + }, + }) +} diff --git a/frontend/src/components/ConnectionForm.vue b/frontend/src/components/ConnectionForm.vue index d1943b7..b88a6a4 100644 --- a/frontend/src/components/ConnectionForm.vue +++ b/frontend/src/components/ConnectionForm.vue @@ -20,35 +20,59 @@ const username = ref('') const authType = ref('PASSWORD') const password = ref('') const privateKey = ref('') +const privateKeyFileName = ref('') +const privateKeyInputRef = ref(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" /> +

{{ hostError }}

@@ -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" /> +

{{ portError }}

@@ -217,25 +287,36 @@ async function handleSubmit() { :placeholder="isEdit ? '••••••••' : ''" />
-
- - - - -
+
+ + +
+

已选择:{{ privateKeyFileName }}

+ +
+ + +

{{ error }}