Initial commit: SSH Manager (backend + frontend)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
liu
2026-02-03 09:10:06 +08:00
commit 1c5a44ff71
63 changed files with 6946 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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) {
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}

View 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;
}
}

View File

@@ -0,0 +1,9 @@
package com.sshmanager.dto;
import lombok.Data;
@Data
public class LoginRequest {
private String username;
private String password;
}

View 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;
}

View 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;
}

View 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();
}

View 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();
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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()
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View 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) {
}
}
}
}

View 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();
}
}
}

View 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