增强 SSH/SFTP 稳定性并完善安全校验与前端交互
This commit is contained in:
@@ -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.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.ConnectionDto;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
@@ -67,4 +68,27 @@ public class ConnectionController {
|
||||
result.put("message", "Deleted");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
public ResponseEntity<Map<String, Object>> connectivity(@RequestBody Connection connection,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
Connection fullConn = connectionService.getConnectionForSsh(connection.getId(), userId);
|
||||
String password = connectionService.getDecryptedPassword(fullConn);
|
||||
String privateKey = connectionService.getDecryptedPrivateKey(fullConn);
|
||||
String passphrase = connectionService.getDecryptedPassphrase(fullConn);
|
||||
|
||||
connectionService.testConnection(fullConn, password, privateKey, passphrase);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "Connection test successful");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", false);
|
||||
result.put("message", "Connection failed: " + e.getMessage());
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,61 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.jcraft.jsch.SftpException;
|
||||
import com.sshmanager.dto.SftpFileInfo;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.SftpService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.jcraft.jsch.SftpException;
|
||||
import com.sshmanager.dto.SftpFileInfo;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.SftpService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sftp")
|
||||
public class SftpController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SftpController.class);
|
||||
|
||||
private final ConnectionService connectionService;
|
||||
private final UserRepository userRepository;
|
||||
private final SftpService sftpService;
|
||||
|
||||
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* JSch ChannelSftp is not thread-safe. If the frontend triggers concurrent requests (e.g. rapid ".." navigation),
|
||||
* sharing one ChannelSftp can crash with internal stream exceptions. We serialize all SFTP ops per (user, connection).
|
||||
*/
|
||||
private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();
|
||||
|
||||
public SftpController(ConnectionService connectionService,
|
||||
UserRepository userRepository,
|
||||
SftpService sftpService) {
|
||||
this.connectionService = connectionService;
|
||||
this.userRepository = userRepository;
|
||||
this.sftpService = sftpService;
|
||||
}
|
||||
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
private String sessionKey(Long userId, Long connectionId) {
|
||||
return userId + ":" + connectionId;
|
||||
}
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sftp")
|
||||
public class SftpController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SftpController.class);
|
||||
|
||||
private final ConnectionService connectionService;
|
||||
private final UserRepository userRepository;
|
||||
private final SftpService sftpService;
|
||||
|
||||
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();
|
||||
|
||||
public SftpController(ConnectionService connectionService,
|
||||
UserRepository userRepository,
|
||||
SftpService sftpService) {
|
||||
this.connectionService = connectionService;
|
||||
this.userRepository = userRepository;
|
||||
this.sftpService = sftpService;
|
||||
}
|
||||
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
private String sessionKey(Long userId, Long connectionId) {
|
||||
return userId + ":" + connectionId;
|
||||
}
|
||||
|
||||
private <T> T withSessionLock(String key, Supplier<T> action) {
|
||||
Object lock = sessionLocks.computeIfAbsent(key, k -> new Object());
|
||||
synchronized (lock) {
|
||||
@@ -79,103 +77,102 @@ public class SftpController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception {
|
||||
String key = sessionKey(userId, connectionId);
|
||||
SftpService.SftpSession session = sessions.get(key);
|
||||
if (session == null || !session.isConnected()) {
|
||||
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
|
||||
String password = connectionService.getDecryptedPassword(conn);
|
||||
String privateKey = connectionService.getDecryptedPrivateKey(conn);
|
||||
String passphrase = connectionService.getDecryptedPassphrase(conn);
|
||||
session = sftpService.connect(conn, password, privateKey, passphrase);
|
||||
sessions.put(key, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<?> list(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam(required = false, defaultValue = ".") String path,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
List<SftpService.FileInfo> files = sftpService.listFiles(session, path);
|
||||
List<SftpFileInfo> dtos = files.stream()
|
||||
.map(f -> new SftpFileInfo(f.name, f.directory, f.size, f.mtime))
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
} catch (Exception e) {
|
||||
// If the underlying SFTP channel got into a bad state, force reconnect on next request.
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
String errorMsg = toSftpErrorMessage(e, path, "list");
|
||||
log.warn("SFTP list failed: connectionId={}, path={}, error={}", connectionId, path, errorMsg, e);
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", errorMsg);
|
||||
return ResponseEntity.status(500).body(err);
|
||||
}
|
||||
}
|
||||
|
||||
private String toSftpErrorMessage(Exception e, String path, String operation) {
|
||||
if (e.getMessage() != null && !e.getMessage().trim().isEmpty()) {
|
||||
return e.getMessage();
|
||||
}
|
||||
// Unwrap nested RuntimeExceptions to find the underlying SftpException (if any).
|
||||
Throwable cur = e;
|
||||
for (int i = 0; i < 10 && cur != null; i++) {
|
||||
if (cur instanceof SftpException) {
|
||||
return SftpService.formatSftpExceptionMessage((SftpException) cur, path, operation);
|
||||
}
|
||||
if (cur.getMessage() != null && !cur.getMessage().trim().isEmpty()) {
|
||||
return cur.getMessage();
|
||||
}
|
||||
cur = cur.getCause();
|
||||
}
|
||||
return operation + " failed";
|
||||
}
|
||||
|
||||
@GetMapping("/pwd")
|
||||
public ResponseEntity<Map<String, String>> pwd(
|
||||
@RequestParam Long connectionId,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
String pwd = sftpService.pwd(session);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("path", pwd);
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.warn("SFTP pwd failed: connectionId={}", connectionId, e);
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", e.getMessage() != null ? e.getMessage() : "pwd failed");
|
||||
return ResponseEntity.status(500).body(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception {
|
||||
String key = sessionKey(userId, connectionId);
|
||||
SftpService.SftpSession session = sessions.get(key);
|
||||
if (session == null || !session.isConnected()) {
|
||||
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
|
||||
String password = connectionService.getDecryptedPassword(conn);
|
||||
String privateKey = connectionService.getDecryptedPrivateKey(conn);
|
||||
String passphrase = connectionService.getDecryptedPassphrase(conn);
|
||||
session = sftpService.connect(conn, password, privateKey, passphrase);
|
||||
sessions.put(key, session);
|
||||
}
|
||||
cleanupTask.recordAccess(key);
|
||||
return session;
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<?> list(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam(required = false, defaultValue = ".") String path,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
List<SftpService.FileInfo> files = sftpService.listFiles(session, path);
|
||||
List<SftpFileInfo> dtos = files.stream()
|
||||
.map(f -> new SftpFileInfo(f.name, f.directory, f.size, f.mtime))
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
String errorMsg = toSftpErrorMessage(e, path, "list");
|
||||
log.warn("SFTP list failed: connectionId={}, path={}, error={}", connectionId, path, errorMsg, e);
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", errorMsg);
|
||||
return ResponseEntity.status(500).body(err);
|
||||
}
|
||||
}
|
||||
|
||||
private String toSftpErrorMessage(Exception e, String path, String operation) {
|
||||
if (e.getMessage() != null && !e.getMessage().trim().isEmpty()) {
|
||||
return e.getMessage();
|
||||
}
|
||||
Throwable cur = e;
|
||||
for (int i = 0; i < 10 && cur != null; i++) {
|
||||
if (cur instanceof SftpException) {
|
||||
return SftpService.formatSftpExceptionMessage((SftpException) cur, path, operation);
|
||||
}
|
||||
if (cur.getMessage() != null && !cur.getMessage().trim().isEmpty()) {
|
||||
return cur.getMessage();
|
||||
}
|
||||
cur = cur.getCause();
|
||||
}
|
||||
return operation + " failed";
|
||||
}
|
||||
|
||||
@GetMapping("/pwd")
|
||||
public ResponseEntity<Map<String, String>> pwd(
|
||||
@RequestParam Long connectionId,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
String pwd = sftpService.pwd(session);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("path", pwd);
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.warn("SFTP pwd failed: connectionId={}", connectionId, e);
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", e.getMessage() != null ? e.getMessage() : "pwd failed");
|
||||
return ResponseEntity.status(500).body(err);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/download")
|
||||
public ResponseEntity<StreamingResponseBody> download(
|
||||
@RequestParam Long connectionId,
|
||||
@@ -208,19 +205,19 @@ public class SftpController {
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
public ResponseEntity<Map<String, String>> upload(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
|
||||
@PostMapping("/upload")
|
||||
public ResponseEntity<Map<String, String>> upload(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
String remotePath = (path == null || path.isEmpty() || path.equals("/"))
|
||||
? "/" + file.getOriginalFilename()
|
||||
: (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename());
|
||||
@@ -230,114 +227,114 @@ public class SftpController {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Uploaded");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/delete")
|
||||
public ResponseEntity<Map<String, String>> delete(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
@RequestParam boolean directory,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
sftpService.delete(session, path, directory);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Deleted");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/mkdir")
|
||||
public ResponseEntity<Map<String, String>> mkdir(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
sftpService.mkdir(session, path);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Created");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/rename")
|
||||
public ResponseEntity<Map<String, String>> rename(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam String oldPath,
|
||||
@RequestParam String newPath,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
sftpService.rename(session, oldPath, newPath);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Renamed");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/transfer-remote")
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/delete")
|
||||
public ResponseEntity<Map<String, String>> delete(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
@RequestParam boolean directory,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
sftpService.delete(session, path, directory);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Deleted");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/mkdir")
|
||||
public ResponseEntity<Map<String, String>> mkdir(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
sftpService.mkdir(session, path);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Created");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/rename")
|
||||
public ResponseEntity<Map<String, String>> rename(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam String oldPath,
|
||||
@RequestParam String newPath,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
return withSessionLock(key, () -> {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
sftpService.rename(session, oldPath, newPath);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Renamed");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
SftpService.SftpSession existing = sessions.remove(key);
|
||||
if (existing != null) {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/transfer-remote")
|
||||
public ResponseEntity<Map<String, String>> transferRemote(
|
||||
@RequestParam Long sourceConnectionId,
|
||||
@RequestParam String sourcePath,
|
||||
@@ -346,11 +343,11 @@ public class SftpController {
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
if (sourcePath == null || sourcePath.trim().isEmpty()) {
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", "sourcePath is required");
|
||||
return ResponseEntity.badRequest().body(err);
|
||||
}
|
||||
if (sourcePath == null || sourcePath.trim().isEmpty()) {
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", "sourcePath is required");
|
||||
return ResponseEntity.badRequest().body(err);
|
||||
}
|
||||
if (targetPath == null || targetPath.trim().isEmpty()) {
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", "targetPath is required");
|
||||
@@ -387,24 +384,61 @@ public class SftpController {
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed");
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/disconnect")
|
||||
public ResponseEntity<Map<String, String>> disconnect(
|
||||
@RequestParam Long connectionId,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
SftpService.SftpSession session = sessions.remove(key);
|
||||
if (session != null) {
|
||||
session.disconnect();
|
||||
}
|
||||
sessionLocks.remove(key);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Disconnected");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed");
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/disconnect")
|
||||
public ResponseEntity<Map<String, String>> disconnect(
|
||||
@RequestParam Long connectionId,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String key = sessionKey(userId, connectionId);
|
||||
SftpService.SftpSession session = sessions.remove(key);
|
||||
if (session != null) {
|
||||
session.disconnect();
|
||||
}
|
||||
sessionLocks.remove(key);
|
||||
cleanupTask.removeSession(key);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Disconnected");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
public void cleanupExpiredSessions(int timeoutMinutes) {
|
||||
List<String> expired = cleanupTask.getExpiredSessions(timeoutMinutes);
|
||||
for (String key : expired) {
|
||||
SftpService.SftpSession session = sessions.remove(key);
|
||||
if (session != null) {
|
||||
session.disconnect();
|
||||
}
|
||||
sessionLocks.remove(key);
|
||||
cleanupTask.removeSession(key);
|
||||
log.info("Cleaned up expired SFTP session: {}", key);
|
||||
}
|
||||
}
|
||||
|
||||
private final SftpSessionExpiryCleanup cleanupTask = new SftpSessionExpiryCleanup();
|
||||
|
||||
public static class SftpSessionExpiryCleanup {
|
||||
private final Map<String, Long> lastAccessTime = new ConcurrentHashMap<>();
|
||||
|
||||
public void recordAccess(String key) {
|
||||
lastAccessTime.put(key, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public void removeSession(String key) {
|
||||
lastAccessTime.remove(key);
|
||||
}
|
||||
|
||||
public List<String> getExpiredSessions(long timeoutMinutes) {
|
||||
long now = System.currentTimeMillis();
|
||||
long timeoutMillis = timeoutMinutes * 60 * 1000;
|
||||
return lastAccessTime.entrySet().stream()
|
||||
.filter(entry -> now - entry.getValue() > timeoutMillis)
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.sshmanager.repository.ConnectionRepository;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.SshService;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
@@ -17,7 +18,7 @@ import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Component
|
||||
public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
@@ -26,19 +27,23 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
private final UserRepository userRepository;
|
||||
private final ConnectionService connectionService;
|
||||
private final SshService sshService;
|
||||
private final ExecutorService executor;
|
||||
|
||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||
private final AtomicInteger sessionCount = new AtomicInteger(0);
|
||||
private final Map<String, SshService.SshSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> lastActivity = new ConcurrentHashMap<>();
|
||||
|
||||
public TerminalWebSocketHandler(ConnectionRepository connectionRepository,
|
||||
UserRepository userRepository,
|
||||
ConnectionService connectionService,
|
||||
SshService sshService) {
|
||||
UserRepository userRepository,
|
||||
ConnectionService connectionService,
|
||||
SshService sshService,
|
||||
@Qualifier("terminalWebSocketExecutor") ExecutorService executor) {
|
||||
this.connectionRepository = connectionRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.connectionService = connectionService;
|
||||
this.sshService = sshService;
|
||||
}
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception {
|
||||
@@ -69,6 +74,8 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
try {
|
||||
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||
sessions.put(webSocketSession.getId(), sshSession);
|
||||
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||
sessionCount.incrementAndGet();
|
||||
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
@@ -97,6 +104,7 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
|
||||
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
|
||||
if (sshSession != null && sshSession.isConnected()) {
|
||||
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||
sshSession.getInputStream().write(message.asBytes());
|
||||
sshSession.getInputStream().flush();
|
||||
}
|
||||
@@ -105,8 +113,10 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {
|
||||
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
|
||||
lastActivity.remove(webSocketSession.getId());
|
||||
if (sshSession != null) {
|
||||
sshSession.disconnect();
|
||||
sessionCount.decrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", "上传失败:表单解析异常");
|
||||
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 EncryptionService encryptionService;
|
||||
private final SshService sshService;
|
||||
|
||||
public ConnectionService(ConnectionRepository connectionRepository,
|
||||
EncryptionService encryptionService) {
|
||||
EncryptionService encryptionService,
|
||||
SshService sshService) {
|
||||
this.connectionRepository = connectionRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
this.sshService = sshService;
|
||||
}
|
||||
|
||||
public List<ConnectionDto> listByUserId(Long userId) {
|
||||
@@ -130,4 +133,18 @@ public class ConnectionService {
|
||||
return conn.getPassphrase() != null ?
|
||||
encryptionService.decrypt(conn.getPassphrase()) : null;
|
||||
}
|
||||
|
||||
public Connection testConnection(Connection conn, String password, String privateKey, String passphrase) {
|
||||
SshService.SshSession session = null;
|
||||
try {
|
||||
session = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||
return conn;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Connection test failed: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (session != null) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user