Initial commit: SSH Manager (backend + frontend)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
package com.sshmanager;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class SshManagerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(SshManagerApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sshmanager.config;
|
||||
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class DataInitializer implements CommandLineRunner {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public DataInitializer(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (userRepository.count() == 0) {
|
||||
User admin = new User();
|
||||
admin.setUsername("admin");
|
||||
admin.setPasswordHash(passwordEncoder.encode("admin123"));
|
||||
admin.setDisplayName("Administrator");
|
||||
userRepository.save(admin);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sshmanager.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
|
||||
@Configuration
|
||||
public class SecurityBeanConfig {
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.sshmanager.config;
|
||||
|
||||
import com.sshmanager.security.JwtAuthenticationFilter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
@Autowired(required = false)
|
||||
private SecurityExceptionHandler securityExceptionHandler;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors().and()
|
||||
.csrf().disable()
|
||||
.sessionManagement()
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
.and()
|
||||
.authorizeRequests()
|
||||
.antMatchers("/api/auth/**").permitAll()
|
||||
.antMatchers("/ws/**").authenticated()
|
||||
.antMatchers("/api/**").authenticated()
|
||||
.anyRequest().permitAll()
|
||||
.and()
|
||||
.exceptionHandling(e -> {
|
||||
if (securityExceptionHandler != null) {
|
||||
e.authenticationEntryPoint(securityExceptionHandler);
|
||||
}
|
||||
})
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(Arrays.asList("http://localhost:5173", "http://127.0.0.1:5173"));
|
||||
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(Arrays.asList("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sshmanager.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class SecurityExceptionHandler implements AuthenticationEntryPoint {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException authException) throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
|
||||
Map<String, String> body = new HashMap<>();
|
||||
body.put("message", "Unauthorized");
|
||||
objectMapper.writeValue(response.getOutputStream(), body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.sshmanager.config;
|
||||
|
||||
import com.sshmanager.security.JwtTokenProvider;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.http.server.ServletServerHttpRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.WebSocketHandler;
|
||||
import org.springframework.web.socket.server.HandshakeInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class TerminalHandshakeInterceptor implements HandshakeInterceptor {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public TerminalHandshakeInterceptor(JwtTokenProvider jwtTokenProvider) {
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
|
||||
if (!(request instanceof ServletServerHttpRequest)) {
|
||||
return false;
|
||||
}
|
||||
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
|
||||
|
||||
String token = servletRequest.getParameter("token");
|
||||
String connectionIdStr = servletRequest.getParameter("connectionId");
|
||||
|
||||
if (token == null || token.isEmpty() || connectionIdStr == null || connectionIdStr.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (!jwtTokenProvider.validateToken(token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
long connectionId = Long.parseLong(connectionIdStr);
|
||||
String username = jwtTokenProvider.getUsernameFromToken(token);
|
||||
attributes.put("connectionId", connectionId);
|
||||
attributes.put("username", username);
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler, Exception exception) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
|
||||
.addInterceptors(terminalHandshakeInterceptor)
|
||||
.setAllowedOrigins("http://localhost:5173", "http://127.0.0.1:5173");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.LoginRequest;
|
||||
import com.sshmanager.dto.LoginResponse;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.security.JwtTokenProvider;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public AuthController(AuthenticationManager authenticationManager,
|
||||
JwtTokenProvider tokenProvider,
|
||||
UserRepository userRepository) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.tokenProvider = tokenProvider;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||
try {
|
||||
Authentication authentication = authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
String token = tokenProvider.generateToken(authentication);
|
||||
|
||||
User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
LoginResponse response = new LoginResponse(token, user.getUsername(),
|
||||
user.getDisplayName() != null ? user.getDisplayName() : user.getUsername());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (BadCredentialsException e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "Invalid username or password");
|
||||
return ResponseEntity.status(401).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<?> me(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", "Unauthorized");
|
||||
return ResponseEntity.status(401).body(error);
|
||||
}
|
||||
User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("username", user.getUsername());
|
||||
data.put("displayName", user.getDisplayName());
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.ConnectionCreateRequest;
|
||||
import com.sshmanager.dto.ConnectionDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/connections")
|
||||
public class ConnectionController {
|
||||
|
||||
private final ConnectionService connectionService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public ConnectionController(ConnectionService connectionService,
|
||||
UserRepository userRepository) {
|
||||
this.connectionService = connectionService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<ConnectionDto>> list(Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(connectionService.listByUserId(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ConnectionDto> get(@PathVariable Long id, Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(connectionService.getById(id, userId));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ConnectionDto> create(@RequestBody ConnectionCreateRequest request,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(connectionService.create(request, userId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ConnectionDto> update(@PathVariable Long id,
|
||||
@RequestBody ConnectionCreateRequest request,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(connectionService.update(id, request, userId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Map<String, String>> delete(@PathVariable Long id,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
connectionService.delete(id, userId);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Deleted");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
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.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sftp")
|
||||
public class SftpController {
|
||||
|
||||
private final ConnectionService connectionService;
|
||||
private final UserRepository userRepository;
|
||||
private final SftpService sftpService;
|
||||
|
||||
private final Map<String, SftpService.SftpSession> sessions = 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 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<SftpFileInfo>> list(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam(required = false, defaultValue = ".") String path,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
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) {
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/pwd")
|
||||
public ResponseEntity<Map<String, String>> pwd(
|
||||
@RequestParam Long connectionId,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
String pwd = sftpService.pwd(session);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("path", pwd);
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/download")
|
||||
public ResponseEntity<byte[]> download(
|
||||
@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
byte[] data = sftpService.download(session, path);
|
||||
String filename = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path;
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(data);
|
||||
} catch (Exception e) {
|
||||
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);
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
String remotePath = (path == null || path.isEmpty() || path.equals("/"))
|
||||
? "/" + file.getOriginalFilename()
|
||||
: (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename());
|
||||
sftpService.upload(session, remotePath, file.getBytes());
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Uploaded");
|
||||
return ResponseEntity.ok(result);
|
||||
} 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);
|
||||
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) {
|
||||
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);
|
||||
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) {
|
||||
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);
|
||||
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) {
|
||||
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,
|
||||
@RequestParam Long targetConnectionId,
|
||||
@RequestParam String targetPath,
|
||||
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 (targetPath == null || targetPath.trim().isEmpty()) {
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", "targetPath is required");
|
||||
return ResponseEntity.badRequest().body(err);
|
||||
}
|
||||
SftpService.SftpSession sourceSession = getOrCreateSession(sourceConnectionId, userId);
|
||||
SftpService.SftpSession targetSession = getOrCreateSession(targetConnectionId, userId);
|
||||
if (sourceConnectionId.equals(targetConnectionId)) {
|
||||
sftpService.rename(sourceSession, sourcePath.trim(), targetPath.trim());
|
||||
} else {
|
||||
sftpService.transferRemote(sourceSession, sourcePath.trim(), targetSession, targetPath.trim());
|
||||
}
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Transferred");
|
||||
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();
|
||||
}
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Disconnected");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.ConnectionRepository;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.SshService;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@Component
|
||||
public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
private final ConnectionRepository connectionRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ConnectionService connectionService;
|
||||
private final SshService sshService;
|
||||
|
||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||
private final Map<String, SshService.SshSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
public TerminalWebSocketHandler(ConnectionRepository connectionRepository,
|
||||
UserRepository userRepository,
|
||||
ConnectionService connectionService,
|
||||
SshService sshService) {
|
||||
this.connectionRepository = connectionRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.connectionService = connectionService;
|
||||
this.sshService = sshService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception {
|
||||
Long connectionId = (Long) webSocketSession.getAttributes().get("connectionId");
|
||||
String username = (String) webSocketSession.getAttributes().get("username");
|
||||
|
||||
if (connectionId == null || username == null) {
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
User user = userRepository.findByUsername(username).orElse(null);
|
||||
if (user == null) {
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
Connection conn = connectionRepository.findById(connectionId).orElse(null);
|
||||
if (conn == null || !conn.getUserId().equals(user.getId())) {
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
String password = connectionService.getDecryptedPassword(conn);
|
||||
String privateKey = connectionService.getDecryptedPrivateKey(conn);
|
||||
String passphrase = connectionService.getDecryptedPassphrase(conn);
|
||||
|
||||
try {
|
||||
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||
sessions.put(webSocketSession.getId(), sshSession);
|
||||
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
InputStream in = sshSession.getOutputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while (webSocketSession.isOpen() && sshSession.isConnected() && (n = in.read(buf)) >= 0) {
|
||||
String text = new String(buf, 0, n, "UTF-8");
|
||||
webSocketSession.sendMessage(new TextMessage(text));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (webSocketSession.isOpen()) {
|
||||
try {
|
||||
webSocketSession.sendMessage(new TextMessage("\r\n[Connection closed]\r\n"));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
webSocketSession.sendMessage(new TextMessage("\r\n[SSH Error: " + e.getMessage() + "]\r\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
|
||||
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
|
||||
if (sshSession != null && sshSession.isConnected()) {
|
||||
sshSession.getInputStream().write(message.asBytes());
|
||||
sshSession.getInputStream().flush();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {
|
||||
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
|
||||
if (sshSession != null) {
|
||||
sshSession.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import com.sshmanager.entity.Connection;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ConnectionCreateRequest {
|
||||
private String name;
|
||||
private String host;
|
||||
private Integer port = 22;
|
||||
private String username;
|
||||
private Connection.AuthType authType = Connection.AuthType.PASSWORD;
|
||||
private String password;
|
||||
private String privateKey;
|
||||
private String passphrase;
|
||||
}
|
||||
29
backend/src/main/java/com/sshmanager/dto/ConnectionDto.java
Normal file
29
backend/src/main/java/com/sshmanager/dto/ConnectionDto.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import com.sshmanager.entity.Connection;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ConnectionDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String host;
|
||||
private Integer port;
|
||||
private String username;
|
||||
private Connection.AuthType authType;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
|
||||
public static ConnectionDto fromEntity(Connection conn) {
|
||||
ConnectionDto dto = new ConnectionDto();
|
||||
dto.setId(conn.getId());
|
||||
dto.setName(conn.getName());
|
||||
dto.setHost(conn.getHost());
|
||||
dto.setPort(conn.getPort());
|
||||
dto.setUsername(conn.getUsername());
|
||||
dto.setAuthType(conn.getAuthType());
|
||||
dto.setCreatedAt(conn.getCreatedAt().toString());
|
||||
dto.setUpdatedAt(conn.getUpdatedAt().toString());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LoginRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
}
|
||||
14
backend/src/main/java/com/sshmanager/dto/LoginResponse.java
Normal file
14
backend/src/main/java/com/sshmanager/dto/LoginResponse.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LoginResponse {
|
||||
private String token;
|
||||
private String username;
|
||||
private String displayName;
|
||||
}
|
||||
15
backend/src/main/java/com/sshmanager/dto/SftpFileInfo.java
Normal file
15
backend/src/main/java/com/sshmanager/dto/SftpFileInfo.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SftpFileInfo {
|
||||
private String name;
|
||||
private boolean directory;
|
||||
private long size;
|
||||
private long mtime;
|
||||
}
|
||||
59
backend/src/main/java/com/sshmanager/entity/Connection.java
Normal file
59
backend/src/main/java/com/sshmanager/entity/Connection.java
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.sshmanager.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "connections")
|
||||
public class Connection {
|
||||
|
||||
public enum AuthType {
|
||||
PASSWORD,
|
||||
PRIVATE_KEY
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String host;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer port = 22;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String username;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private AuthType authType = AuthType.PASSWORD;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String encryptedPassword;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String encryptedPrivateKey;
|
||||
|
||||
@Column(length = 255)
|
||||
private String passphrase;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant updatedAt = Instant.now();
|
||||
}
|
||||
35
backend/src/main/java/com/sshmanager/entity/User.java
Normal file
35
backend/src/main/java/com/sshmanager/entity/User.java
Normal file
@@ -0,0 +1,35 @@
|
||||
package com.sshmanager.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(length = 100)
|
||||
private String displayName;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant updatedAt = Instant.now();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sshmanager.repository;
|
||||
|
||||
import com.sshmanager.entity.Connection;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ConnectionRepository extends JpaRepository<Connection, Long> {
|
||||
|
||||
List<Connection> findByUserIdOrderByUpdatedAtDesc(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sshmanager.repository;
|
||||
|
||||
import com.sshmanager.entity.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
boolean existsByUsername(String username);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.sshmanager.security;
|
||||
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
@Service
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public CustomUserDetailsService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||
|
||||
return new org.springframework.security.core.userdetails.User(
|
||||
user.getUsername(),
|
||||
user.getPasswordHash(),
|
||||
Collections.emptyList()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.sshmanager.security;
|
||||
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
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;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final UserDetailsService userDetailsService;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) {
|
||||
this.tokenProvider = tokenProvider;
|
||||
this.userDetailsService = userDetailsService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
try {
|
||||
String jwt = getJwtFromRequest(request);
|
||||
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
|
||||
String username = tokenProvider.getUsernameFromToken(jwt);
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// Log but don't fail - let controller handle 401
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String getJwtFromRequest(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader("Authorization");
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.sshmanager.security;
|
||||
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.security.Key;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
@Value("${sshmanager.jwt-secret}")
|
||||
private String jwtSecret;
|
||||
|
||||
@Value("${sshmanager.jwt-expiration-ms}")
|
||||
private long jwtExpirationMs;
|
||||
|
||||
private Key key;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
byte[] keyBytes = jwtSecret.getBytes();
|
||||
if (keyBytes.length < 32) {
|
||||
byte[] padded = new byte[32];
|
||||
System.arraycopy(keyBytes, 0, padded, 0, Math.min(keyBytes.length, 32));
|
||||
keyBytes = padded;
|
||||
}
|
||||
this.key = Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
|
||||
public String generateToken(Authentication authentication) {
|
||||
String username = authentication.getName();
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + jwtExpirationMs);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(username)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String getUsernameFromToken(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(key)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
|
||||
public boolean validateToken(String authToken) {
|
||||
try {
|
||||
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(authToken);
|
||||
return true;
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.sshmanager.dto.ConnectionCreateRequest;
|
||||
import com.sshmanager.dto.ConnectionDto;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.repository.ConnectionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ConnectionService {
|
||||
|
||||
private final ConnectionRepository connectionRepository;
|
||||
private final EncryptionService encryptionService;
|
||||
|
||||
public ConnectionService(ConnectionRepository connectionRepository,
|
||||
EncryptionService encryptionService) {
|
||||
this.connectionRepository = connectionRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
}
|
||||
|
||||
public List<ConnectionDto> listByUserId(Long userId) {
|
||||
return connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId).stream()
|
||||
.map(ConnectionDto::fromEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public ConnectionDto getById(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConnectionDto create(ConnectionCreateRequest request, Long userId) {
|
||||
Connection conn = new Connection();
|
||||
conn.setUserId(userId);
|
||||
conn.setName(request.getName());
|
||||
conn.setHost(request.getHost());
|
||||
conn.setPort(request.getPort() != null ? request.getPort() : 22);
|
||||
conn.setUsername(request.getUsername());
|
||||
conn.setAuthType(request.getAuthType() != null ? request.getAuthType() : Connection.AuthType.PASSWORD);
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && request.getPassword() != null) {
|
||||
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
|
||||
} else if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && request.getPrivateKey() != null) {
|
||||
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
|
||||
if (request.getPassphrase() != null && !request.getPassphrase().isEmpty()) {
|
||||
conn.setPassphrase(encryptionService.encrypt(request.getPassphrase()));
|
||||
}
|
||||
}
|
||||
|
||||
conn = connectionRepository.save(conn);
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConnectionDto update(Long id, ConnectionCreateRequest request, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
|
||||
if (request.getName() != null) conn.setName(request.getName());
|
||||
if (request.getHost() != null) conn.setHost(request.getHost());
|
||||
if (request.getPort() != null) conn.setPort(request.getPort());
|
||||
if (request.getUsername() != null) conn.setUsername(request.getUsername());
|
||||
if (request.getAuthType() != null) conn.setAuthType(request.getAuthType());
|
||||
|
||||
if (request.getPassword() != null) {
|
||||
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
|
||||
}
|
||||
if (request.getPrivateKey() != null) {
|
||||
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
|
||||
}
|
||||
if (request.getPassphrase() != null) {
|
||||
conn.setPassphrase(request.getPassphrase().isEmpty() ? null :
|
||||
encryptionService.encrypt(request.getPassphrase()));
|
||||
}
|
||||
|
||||
conn.setUpdatedAt(Instant.now());
|
||||
conn = connectionRepository.save(conn);
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
connectionRepository.delete(conn);
|
||||
}
|
||||
|
||||
public Connection getConnectionForSsh(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
public String getDecryptedPassword(Connection conn) {
|
||||
return conn.getEncryptedPassword() != null ?
|
||||
encryptionService.decrypt(conn.getEncryptedPassword()) : null;
|
||||
}
|
||||
|
||||
public String getDecryptedPrivateKey(Connection conn) {
|
||||
return conn.getEncryptedPrivateKey() != null ?
|
||||
encryptionService.decrypt(conn.getEncryptedPrivateKey()) : null;
|
||||
}
|
||||
|
||||
public String getDecryptedPassphrase(Connection conn) {
|
||||
return conn.getPassphrase() != null ?
|
||||
encryptionService.decrypt(conn.getPassphrase()) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Service
|
||||
public class EncryptionService {
|
||||
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
|
||||
private final byte[] keyBytes;
|
||||
|
||||
public EncryptionService(@Value("${sshmanager.encryption-key}") String base64Key) {
|
||||
this.keyBytes = Base64.getDecoder().decode(base64Key);
|
||||
if (keyBytes.length != 32) {
|
||||
throw new IllegalArgumentException("Encryption key must be 32 bytes (256 bits)");
|
||||
}
|
||||
}
|
||||
|
||||
public String encrypt(String plainText) {
|
||||
if (plainText == null || plainText.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
new SecureRandom().nextBytes(iv);
|
||||
|
||||
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
|
||||
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
|
||||
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
byte[] combined = new byte[iv.length + encrypted.length];
|
||||
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
|
||||
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String encryptedText) {
|
||||
if (encryptedText == null || encryptedText.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] combined = Base64.getDecoder().decode(encryptedText);
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH];
|
||||
System.arraycopy(combined, 0, iv, 0, iv.length);
|
||||
System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);
|
||||
|
||||
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
|
||||
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
|
||||
byte[] decrypted = cipher.doFinal(encrypted);
|
||||
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Decryption failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
188
backend/src/main/java/com/sshmanager/service/SftpService.java
Normal file
188
backend/src/main/java/com/sshmanager/service/SftpService.java
Normal file
@@ -0,0 +1,188 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.jcraft.jsch.ChannelSftp;
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.Session;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
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;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
@Service
|
||||
public class SftpService {
|
||||
|
||||
public SftpSession connect(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()) {
|
||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||
}
|
||||
|
||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
||||
session.setPassword(password);
|
||||
}
|
||||
session.connect(10000);
|
||||
|
||||
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(5000);
|
||||
|
||||
return new SftpSession(session, channel);
|
||||
}
|
||||
|
||||
public static class SftpSession {
|
||||
private final Session session;
|
||||
private final ChannelSftp channel;
|
||||
|
||||
public SftpSession(Session session, ChannelSftp channel) {
|
||||
this.session = session;
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
public ChannelSftp getChannel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return channel != null && channel.isConnected();
|
||||
}
|
||||
}
|
||||
|
||||
public static class FileInfo {
|
||||
public String name;
|
||||
public boolean directory;
|
||||
public long size;
|
||||
public long mtime;
|
||||
|
||||
public FileInfo(String name, boolean directory, long size, long mtime) {
|
||||
this.name = name;
|
||||
this.directory = directory;
|
||||
this.size = size;
|
||||
this.mtime = mtime;
|
||||
}
|
||||
}
|
||||
|
||||
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
|
||||
Vector<?> entries = sftpSession.getChannel().ls(path);
|
||||
List<FileInfo> result = new ArrayList<>();
|
||||
for (Object obj : entries) {
|
||||
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
||||
String name = entry.getFilename();
|
||||
if (".".equals(name) || "..".equals(name)) continue;
|
||||
result.add(new FileInfo(
|
||||
name,
|
||||
entry.getAttrs().isDir(),
|
||||
entry.getAttrs().getSize(),
|
||||
entry.getAttrs().getMTime() * 1000L
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public byte[] download(SftpSession sftpSession, String remotePath) throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
sftpSession.getChannel().get(remotePath, out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
public void upload(SftpSession sftpSession, String remotePath, byte[] data) throws Exception {
|
||||
sftpSession.getChannel().put(new ByteArrayInputStream(data), remotePath);
|
||||
}
|
||||
|
||||
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
|
||||
if (isDir) {
|
||||
sftpSession.getChannel().rmdir(remotePath);
|
||||
} else {
|
||||
sftpSession.getChannel().rm(remotePath);
|
||||
}
|
||||
}
|
||||
|
||||
public void mkdir(SftpSession sftpSession, String remotePath) throws Exception {
|
||||
sftpSession.getChannel().mkdir(remotePath);
|
||||
}
|
||||
|
||||
public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception {
|
||||
sftpSession.getChannel().rename(oldPath, newPath);
|
||||
}
|
||||
|
||||
public String pwd(SftpSession sftpSession) throws Exception {
|
||||
return sftpSession.getChannel().pwd();
|
||||
}
|
||||
|
||||
public void cd(SftpSession sftpSession, String path) throws Exception {
|
||||
sftpSession.getChannel().cd(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer a single file from source session to target session (streaming, no full file in memory).
|
||||
* Fails if sourcePath is a directory.
|
||||
*/
|
||||
public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath)
|
||||
throws Exception {
|
||||
if (source.getChannel().stat(sourcePath).isDir()) {
|
||||
throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported");
|
||||
}
|
||||
final int pipeBufferSize = 65536;
|
||||
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();
|
||||
try {
|
||||
pis.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
backend/src/main/java/com/sshmanager/service/SshService.java
Normal file
97
backend/src/main/java/com/sshmanager/service/SshService.java
Normal file
@@ -0,0 +1,97 @@
|
||||
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 java.nio.charset.StandardCharsets;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
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 {
|
||||
JSch jsch = new JSch();
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||
}
|
||||
|
||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
||||
session.setPassword(password);
|
||||
}
|
||||
|
||||
session.connect(10000);
|
||||
|
||||
ChannelShell channel = (ChannelShell) session.openChannel("shell");
|
||||
channel.setPtyType("xterm");
|
||||
channel.connect(5000);
|
||||
|
||||
PipedInputStream pipedIn = new PipedInputStream();
|
||||
PipedOutputStream pipeToChannel = new PipedOutputStream(pipedIn);
|
||||
OutputStream channelIn = channel.getOutputStream();
|
||||
InputStream channelOut = channel.getInputStream();
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
byte[] buf = new byte[1024];
|
||||
int n;
|
||||
while ((n = pipedIn.read(buf)) > 0) {
|
||||
channelIn.write(buf, 0, n);
|
||||
channelIn.flush();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Channel closed
|
||||
}
|
||||
}).start();
|
||||
|
||||
return new SshSession(session, channel, channelOut, pipeToChannel);
|
||||
}
|
||||
|
||||
public static class SshSession {
|
||||
private final Session session;
|
||||
private final ChannelShell channel;
|
||||
private final InputStream outputStream;
|
||||
private final OutputStream inputStream;
|
||||
|
||||
public SshSession(Session session, ChannelShell channel, InputStream outputStream, OutputStream inputStream) {
|
||||
this.session = session;
|
||||
this.channel = channel;
|
||||
this.outputStream = outputStream;
|
||||
this.inputStream = inputStream;
|
||||
}
|
||||
|
||||
public InputStream getOutputStream() {
|
||||
return outputStream;
|
||||
}
|
||||
|
||||
public OutputStream getInputStream() {
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return channel != null && channel.isConnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
27
backend/src/main/resources/application.yml
Normal file
27
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
h2:
|
||||
console:
|
||||
enabled: false
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.H2Dialect
|
||||
open-in-view: false
|
||||
|
||||
# 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}
|
||||
jwt-expiration-ms: 86400000
|
||||
Reference in New Issue
Block a user