feat: 主题切换 + 浅色模式适配,SFTP/批量命令/Webhook/仪表盘全面升级
This commit is contained in:
@@ -2,8 +2,10 @@ package com.sshmanager;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class SshManagerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -1,58 +1,62 @@
|
||||
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 org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
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(@NonNull ServerHttpRequest request, @NonNull ServerHttpResponse response,
|
||||
@NonNull WebSocketHandler wsHandler, @NonNull 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(@NonNull ServerHttpRequest request, @NonNull ServerHttpResponse response,
|
||||
@NonNull WebSocketHandler wsHandler, @Nullable Exception exception) {
|
||||
}
|
||||
}
|
||||
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 org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
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(@NonNull ServerHttpRequest request, @NonNull ServerHttpResponse response,
|
||||
@NonNull WebSocketHandler wsHandler, @NonNull 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");
|
||||
String credentialToken = servletRequest.getParameter("credentialToken");
|
||||
|
||||
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);
|
||||
if (credentialToken != null && !credentialToken.isEmpty()) {
|
||||
attributes.put("credentialToken", credentialToken);
|
||||
}
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterHandshake(@NonNull ServerHttpRequest request, @NonNull ServerHttpResponse response,
|
||||
@NonNull WebSocketHandler wsHandler, @Nullable Exception exception) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.CommandSnippetDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.CommandSnippetService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/snippets")
|
||||
public class CommandSnippetController {
|
||||
|
||||
private final CommandSnippetService snippetService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public CommandSnippetController(CommandSnippetService snippetService, UserRepository userRepository) {
|
||||
this.snippetService = snippetService;
|
||||
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<CommandSnippetDto>> list(Authentication auth) {
|
||||
return ResponseEntity.ok(snippetService.listByUserId(getCurrentUserId(auth)));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CommandSnippetDto> create(@RequestBody Map<String, String> body, Authentication auth) {
|
||||
return ResponseEntity.ok(snippetService.create(
|
||||
getCurrentUserId(auth),
|
||||
body.get("name"),
|
||||
body.get("command"),
|
||||
body.get("description")));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<CommandSnippetDto> update(@PathVariable Long id,
|
||||
@RequestBody Map<String, String> body,
|
||||
Authentication auth) {
|
||||
return ResponseEntity.ok(snippetService.update(
|
||||
id, getCurrentUserId(auth),
|
||||
body.get("name"),
|
||||
body.get("command"),
|
||||
body.get("description")));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Map<String, String>> delete(@PathVariable Long id, Authentication auth) {
|
||||
snippetService.delete(id, getCurrentUserId(auth));
|
||||
Map<String, String> result = new java.util.HashMap<>();
|
||||
result.put("message", "Deleted");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.ConnectionCreateRequest;
|
||||
import com.sshmanager.dto.ConnectionDto;
|
||||
import com.sshmanager.dto.BackupImportResponseDto;
|
||||
import com.sshmanager.dto.BackupPackageDto;
|
||||
import com.sshmanager.dto.BatchCommandRequest;
|
||||
import com.sshmanager.dto.BatchCommandResponseDto;
|
||||
import com.sshmanager.dto.ConnectionStatusCheckRequest;
|
||||
import com.sshmanager.dto.ConnectionStatusResponseDto;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.BackupService;
|
||||
import com.sshmanager.service.BatchCommandService;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.ConnectionStatusService;
|
||||
import com.sshmanager.dto.ConnectionCreateRequest;
|
||||
import com.sshmanager.dto.ConnectionDto;
|
||||
import com.sshmanager.dto.BackupImportResponseDto;
|
||||
import com.sshmanager.dto.BackupPackageDto;
|
||||
import com.sshmanager.dto.BatchCommandRequest;
|
||||
import com.sshmanager.dto.BatchCommandResponseDto;
|
||||
import com.sshmanager.dto.ConnectionStatusCheckRequest;
|
||||
import com.sshmanager.dto.ConnectionStatusResponseDto;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.BackupService;
|
||||
import com.sshmanager.service.BatchCommandService;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.ConnectionStatusService;
|
||||
import com.sshmanager.service.QuickCredentialRegistry;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -27,23 +28,27 @@ import java.util.Map;
|
||||
@RequestMapping("/api/connections")
|
||||
public class ConnectionController {
|
||||
|
||||
private final ConnectionService connectionService;
|
||||
private final BackupService backupService;
|
||||
private final BatchCommandService batchCommandService;
|
||||
private final ConnectionStatusService connectionStatusService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public ConnectionController(ConnectionService connectionService,
|
||||
BackupService backupService,
|
||||
BatchCommandService batchCommandService,
|
||||
ConnectionStatusService connectionStatusService,
|
||||
UserRepository userRepository) {
|
||||
this.connectionService = connectionService;
|
||||
this.backupService = backupService;
|
||||
this.batchCommandService = batchCommandService;
|
||||
this.connectionStatusService = connectionStatusService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
private final ConnectionService connectionService;
|
||||
private final BackupService backupService;
|
||||
private final BatchCommandService batchCommandService;
|
||||
private final ConnectionStatusService connectionStatusService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
private final QuickCredentialRegistry quickCredentials;
|
||||
|
||||
public ConnectionController(ConnectionService connectionService,
|
||||
BackupService backupService,
|
||||
BatchCommandService batchCommandService,
|
||||
ConnectionStatusService connectionStatusService,
|
||||
UserRepository userRepository,
|
||||
QuickCredentialRegistry quickCredentials) {
|
||||
this.connectionService = connectionService;
|
||||
this.backupService = backupService;
|
||||
this.batchCommandService = batchCommandService;
|
||||
this.connectionStatusService = connectionStatusService;
|
||||
this.userRepository = userRepository;
|
||||
this.quickCredentials = quickCredentials;
|
||||
}
|
||||
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
@@ -77,46 +82,53 @@ public class ConnectionController {
|
||||
return ResponseEntity.ok(connectionService.update(id, request, userId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Map<String, String>> delete(@PathVariable Long id,
|
||||
Authentication authentication) {
|
||||
@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);
|
||||
}
|
||||
|
||||
@GetMapping("/backup/export")
|
||||
public ResponseEntity<BackupPackageDto> exportBackup(Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(backupService.exportBackup(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/backup/import")
|
||||
public ResponseEntity<BackupImportResponseDto> importBackup(@RequestBody BackupPackageDto request,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(backupService.importBackup(userId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/batch-command")
|
||||
public ResponseEntity<BatchCommandResponseDto> executeBatchCommand(@RequestBody BatchCommandRequest request,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(batchCommandService.execute(userId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/status")
|
||||
public ResponseEntity<ConnectionStatusResponseDto> checkStatuses(@RequestBody ConnectionStatusCheckRequest request,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(connectionStatusService.checkStatuses(userId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
public ResponseEntity<Map<String, Object>> connectivity(@RequestBody Connection connection,
|
||||
Authentication authentication) {
|
||||
result.put("message", "Deleted");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/pin")
|
||||
public ResponseEntity<ConnectionDto> togglePin(@PathVariable Long id,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(connectionService.togglePin(id, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/backup/export")
|
||||
public ResponseEntity<BackupPackageDto> exportBackup(Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(backupService.exportBackup(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/backup/import")
|
||||
public ResponseEntity<BackupImportResponseDto> importBackup(@RequestBody BackupPackageDto request,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(backupService.importBackup(userId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/batch-command")
|
||||
public ResponseEntity<BatchCommandResponseDto> executeBatchCommand(@RequestBody BatchCommandRequest request,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(batchCommandService.execute(userId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/status")
|
||||
public ResponseEntity<ConnectionStatusResponseDto> checkStatuses(@RequestBody ConnectionStatusCheckRequest request,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(connectionStatusService.checkStatuses(userId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
public ResponseEntity<Map<String, Object>> connectivity(@RequestBody Connection connection,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
Connection fullConn = connectionService.getConnectionForSsh(connection.getId(), userId);
|
||||
@@ -136,4 +148,44 @@ public class ConnectionController {
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/export/config")
|
||||
public ResponseEntity<String> exportSshConfig(Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String config = connectionService.exportSshConfig(userId);
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", "text/plain;charset=UTF-8")
|
||||
.header("Content-Disposition", "attachment; filename=ssh-config.txt")
|
||||
.body(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-connect: ephemeral SSH connection for `ssh user@host` in terminal.
|
||||
* Returns a credentialToken (one-time, TTL 60s) instead of passing password directly.
|
||||
* The connection is stored in memory only, never persisted to the database.
|
||||
*/
|
||||
@PostMapping("/quick-connect")
|
||||
public ResponseEntity<Map<String, Object>> quickConnect(@RequestBody Map<String, Object> body,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String host = (String) body.getOrDefault("host", "");
|
||||
String username = (String) body.getOrDefault("username", "root");
|
||||
String password = (String) body.getOrDefault("password", "");
|
||||
int port = body.containsKey("port") ? Integer.parseInt(body.get("port").toString()) : 22;
|
||||
|
||||
ConnectionCreateRequest req = new ConnectionCreateRequest();
|
||||
req.setName(host + "-quick");
|
||||
req.setHost(host);
|
||||
req.setPort(port);
|
||||
req.setUsername(username);
|
||||
req.setAuthType(Connection.AuthType.PASSWORD);
|
||||
|
||||
ConnectionDto conn = connectionService.quickCreate(req, userId);
|
||||
String credentialToken = quickCredentials.create(userId, conn.getId(), password);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("connection", conn);
|
||||
result.put("credentialToken", credentialToken);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,4 +1087,116 @@ public class SftpController {
|
||||
return now - endTime > timeoutMillis;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Online file editor: read file content ──
|
||||
@GetMapping("/read")
|
||||
public ResponseEntity<?> readFile(@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
Authentication auth) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(auth);
|
||||
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
|
||||
|
||||
// Limit file size to 5MB
|
||||
long maxSize = 5 * 1024 * 1024L;
|
||||
String content = sftpService.readFileContent(conn,
|
||||
connectionService.getDecryptedPassword(conn),
|
||||
connectionService.getDecryptedPrivateKey(conn),
|
||||
connectionService.getDecryptedPassphrase(conn),
|
||||
path, maxSize);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("content", content);
|
||||
result.put("path", path);
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", "READ_ERROR");
|
||||
err.put("message", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Online file editor: save file ──
|
||||
@PostMapping("/write")
|
||||
public ResponseEntity<?> writeFile(@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
@RequestBody Map<String, String> body,
|
||||
Authentication auth) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(auth);
|
||||
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
|
||||
String content = body.get("content");
|
||||
if (content == null) {
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("message", "Content is required");
|
||||
return ResponseEntity.badRequest().body(err);
|
||||
}
|
||||
sftpService.writeFileContent(conn,
|
||||
connectionService.getDecryptedPassword(conn),
|
||||
connectionService.getDecryptedPrivateKey(conn),
|
||||
connectionService.getDecryptedPassphrase(conn),
|
||||
path, content);
|
||||
Map<String, String> ok = new HashMap<>();
|
||||
ok.put("message", "File saved");
|
||||
return ResponseEntity.ok(ok);
|
||||
} catch (Exception e) {
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", "WRITE_ERROR");
|
||||
err.put("message", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(err);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> makeResult(String message) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("message", message);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── Remote compress ──
|
||||
@PostMapping("/compress")
|
||||
public ResponseEntity<?> compress(@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
@RequestParam(defaultValue = "tar.gz") String format,
|
||||
Authentication auth) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(auth);
|
||||
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
|
||||
String result = sftpService.compressRemote(conn,
|
||||
connectionService.getDecryptedPassword(conn),
|
||||
connectionService.getDecryptedPrivateKey(conn),
|
||||
connectionService.getDecryptedPassphrase(conn),
|
||||
path, format);
|
||||
return ResponseEntity.ok(makeResult(result));
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> err = new HashMap<>();
|
||||
err.put("error", "COMPRESS_ERROR");
|
||||
err.put("message", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remote decompress ──
|
||||
@PostMapping("/decompress")
|
||||
public ResponseEntity<?> decompress(@RequestParam Long connectionId,
|
||||
@RequestParam String path,
|
||||
@RequestParam(defaultValue = "/tmp") String targetDir,
|
||||
Authentication auth) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(auth);
|
||||
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
|
||||
String result = sftpService.decompressRemote(conn,
|
||||
connectionService.getDecryptedPassword(conn),
|
||||
connectionService.getDecryptedPrivateKey(conn),
|
||||
connectionService.getDecryptedPassphrase(conn),
|
||||
path, targetDir);
|
||||
return ResponseEntity.ok(makeResult(result));
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> err = new HashMap<>();
|
||||
err.put("error", "DECOMPRESS_ERROR");
|
||||
err.put("message", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,30 +13,19 @@ public class TerminalControlMessage {
|
||||
private String type;
|
||||
private Integer cols;
|
||||
private Integer rows;
|
||||
private String token;
|
||||
private String password;
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Integer getCols() {
|
||||
return cols;
|
||||
}
|
||||
|
||||
public void setCols(Integer cols) {
|
||||
this.cols = cols;
|
||||
}
|
||||
|
||||
public Integer getRows() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
public void setRows(Integer rows) {
|
||||
this.rows = rows;
|
||||
}
|
||||
public String getType() { return type; }
|
||||
public void setType(String type) { this.type = type; }
|
||||
public Integer getCols() { return cols; }
|
||||
public void setCols(Integer cols) { this.cols = cols; }
|
||||
public Integer getRows() { return rows; }
|
||||
public void setRows(Integer rows) { this.rows = rows; }
|
||||
public String getToken() { return token; }
|
||||
public void setToken(String token) { this.token = token; }
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
|
||||
public static Optional<TerminalControlMessage> parse(String payload) {
|
||||
if (payload == null || !payload.startsWith(CONTROL_PREFIX)) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.ConnectionRepository;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.QuickConnectionRegistry;
|
||||
import com.sshmanager.service.QuickCredentialRegistry;
|
||||
import com.sshmanager.service.SshService;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -28,6 +30,8 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
private final UserRepository userRepository;
|
||||
private final ConnectionService connectionService;
|
||||
private final SshService sshService;
|
||||
private final QuickConnectionRegistry quickConnections;
|
||||
private final QuickCredentialRegistry quickCredentials;
|
||||
private final ExecutorService executor;
|
||||
|
||||
private final AtomicInteger sessionCount = new AtomicInteger(0);
|
||||
@@ -38,11 +42,15 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
UserRepository userRepository,
|
||||
ConnectionService connectionService,
|
||||
SshService sshService,
|
||||
QuickConnectionRegistry quickConnections,
|
||||
QuickCredentialRegistry quickCredentials,
|
||||
@Qualifier("terminalWebSocketExecutor") ExecutorService executor) {
|
||||
this.connectionRepository = connectionRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.connectionService = connectionService;
|
||||
this.sshService = sshService;
|
||||
this.quickConnections = quickConnections;
|
||||
this.quickCredentials = quickCredentials;
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
@@ -63,13 +71,29 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check persistent DB first, then in-memory quick-connect registry
|
||||
Connection conn = connectionRepository.findById(connectionId).orElse(null);
|
||||
if (conn == null) {
|
||||
conn = quickConnections.get(connectionId);
|
||||
}
|
||||
if (conn == null || !conn.getUserId().equals(user.getId())) {
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
String password = connectionService.getDecryptedPassword(conn);
|
||||
// Resolve password: credentialToken (one-time) > stored DB password
|
||||
String credentialToken = (String) webSocketSession.getAttributes().get("credentialToken");
|
||||
String password;
|
||||
if (credentialToken != null) {
|
||||
password = quickCredentials.consume(credentialToken, user.getId(), connectionId);
|
||||
if (password == null) {
|
||||
webSocketSession.sendMessage(new TextMessage("\r\n[Credential token expired or invalid]\r\n"));
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
password = connectionService.getDecryptedPassword(conn);
|
||||
}
|
||||
String privateKey = connectionService.getDecryptedPrivateKey(conn);
|
||||
String passphrase = connectionService.getDecryptedPassphrase(conn);
|
||||
|
||||
@@ -130,6 +154,15 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
if (sshSession != null) {
|
||||
sshSession.disconnect();
|
||||
sessionCount.decrementAndGet();
|
||||
// Clean up ephemeral quick-connect connections + credential tokens
|
||||
Long connectionId = (Long) webSocketSession.getAttributes().get("connectionId");
|
||||
if (connectionId != null && quickConnections.get(connectionId) != null) {
|
||||
quickConnections.remove(connectionId);
|
||||
}
|
||||
String credentialToken = (String) webSocketSession.getAttributes().get("credentialToken");
|
||||
if (credentialToken != null) {
|
||||
quickCredentials.invalidate(credentialToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.entity.WebhookConfig;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.WebhookService;
|
||||
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/webhooks")
|
||||
public class WebhookController {
|
||||
|
||||
private final WebhookService webhookService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public WebhookController(WebhookService webhookService, UserRepository userRepository) {
|
||||
this.webhookService = webhookService;
|
||||
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<WebhookConfig>> list(Authentication auth) {
|
||||
return ResponseEntity.ok(webhookService.listByUser(getCurrentUserId(auth)));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<WebhookConfig> create(@RequestBody Map<String, String> body, Authentication auth) {
|
||||
return ResponseEntity.ok(webhookService.create(
|
||||
getCurrentUserId(auth), body.get("url"), body.get("eventType")));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Map<String, String>> delete(@PathVariable Long id, Authentication auth) {
|
||||
webhookService.delete(id, getCurrentUserId(auth));
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Deleted");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
public ResponseEntity<Map<String, String>> test(@RequestBody Map<String, String> body) {
|
||||
webhookService.fireEvent("test", "测试通知", body.getOrDefault("message", "这是一条测试消息"));
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("message", "Test event fired");
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import com.sshmanager.entity.CommandSnippet;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CommandSnippetDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String command;
|
||||
private String description;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
|
||||
public static CommandSnippetDto fromEntity(CommandSnippet snippet) {
|
||||
CommandSnippetDto dto = new CommandSnippetDto();
|
||||
dto.setId(snippet.getId());
|
||||
dto.setName(snippet.getName());
|
||||
dto.setCommand(snippet.getCommand());
|
||||
dto.setDescription(snippet.getDescription());
|
||||
dto.setCreatedAt(snippet.getCreatedAt().toString());
|
||||
dto.setUpdatedAt(snippet.getUpdatedAt().toString());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import com.sshmanager.entity.Connection;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import com.sshmanager.entity.Connection;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ConnectionCreateRequest {
|
||||
public enum SetupMode {
|
||||
NONE,
|
||||
@@ -20,4 +20,5 @@ public class ConnectionCreateRequest {
|
||||
private String passphrase;
|
||||
private SetupMode setupMode = SetupMode.NONE;
|
||||
private String bootstrapPassword;
|
||||
private Boolean pinned;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public class ConnectionDto {
|
||||
private Integer port;
|
||||
private String username;
|
||||
private Connection.AuthType authType;
|
||||
private Boolean pinned;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
|
||||
@@ -22,6 +23,7 @@ public class ConnectionDto {
|
||||
dto.setPort(conn.getPort());
|
||||
dto.setUsername(conn.getUsername());
|
||||
dto.setAuthType(conn.getAuthType());
|
||||
dto.setPinned(conn.getIsPinned());
|
||||
dto.setCreatedAt(conn.getCreatedAt().toString());
|
||||
dto.setUpdatedAt(conn.getUpdatedAt().toString());
|
||||
return dto;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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 = "command_snippets")
|
||||
public class CommandSnippet {
|
||||
|
||||
@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, columnDefinition = "TEXT")
|
||||
private String command;
|
||||
|
||||
@Column(length = 255)
|
||||
private String description;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant updatedAt = Instant.now();
|
||||
}
|
||||
@@ -56,4 +56,7 @@ public class Connection {
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant updatedAt = Instant.now();
|
||||
|
||||
@Column(columnDefinition = "BOOLEAN DEFAULT FALSE")
|
||||
private Boolean isPinned = false;
|
||||
}
|
||||
|
||||
@@ -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 = "webhook_configs")
|
||||
public class WebhookConfig {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String url;
|
||||
|
||||
@Column(length = 50)
|
||||
private String eventType;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Boolean enabled = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.sshmanager.repository;
|
||||
|
||||
import com.sshmanager.entity.CommandSnippet;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface CommandSnippetRepository extends JpaRepository<CommandSnippet, Long> {
|
||||
List<CommandSnippet> findByUserIdOrderByUpdatedAtDesc(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.sshmanager.repository;
|
||||
|
||||
import com.sshmanager.entity.WebhookConfig;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.List;
|
||||
|
||||
public interface WebhookConfigRepository extends JpaRepository<WebhookConfig, Long> {
|
||||
List<WebhookConfig> findByUserIdAndEnabledTrue(Long userId);
|
||||
List<WebhookConfig> findByEventTypeAndEnabledTrue(String eventType);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.sshmanager.dto.CommandSnippetDto;
|
||||
import com.sshmanager.entity.CommandSnippet;
|
||||
import com.sshmanager.exception.AccessDeniedException;
|
||||
import com.sshmanager.exception.NotFoundException;
|
||||
import com.sshmanager.repository.CommandSnippetRepository;
|
||||
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 CommandSnippetService {
|
||||
|
||||
private final CommandSnippetRepository repository;
|
||||
|
||||
public CommandSnippetService(CommandSnippetRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public List<CommandSnippetDto> listByUserId(Long userId) {
|
||||
return repository.findByUserIdOrderByUpdatedAtDesc(userId).stream()
|
||||
.map(CommandSnippetDto::fromEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CommandSnippetDto create(Long userId, String name, String command, String description) {
|
||||
CommandSnippet snippet = new CommandSnippet();
|
||||
snippet.setUserId(userId);
|
||||
snippet.setName(name.trim());
|
||||
snippet.setCommand(command.trim());
|
||||
snippet.setDescription(description != null ? description.trim() : null);
|
||||
snippet.setCreatedAt(Instant.now());
|
||||
snippet.setUpdatedAt(Instant.now());
|
||||
return CommandSnippetDto.fromEntity(repository.save(snippet));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CommandSnippetDto update(Long id, Long userId, String name, String command, String description) {
|
||||
CommandSnippet snippet = repository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("Snippet not found: " + id));
|
||||
if (!snippet.getUserId().equals(userId)) {
|
||||
throw new AccessDeniedException("Access denied");
|
||||
}
|
||||
snippet.setName(name.trim());
|
||||
snippet.setCommand(command.trim());
|
||||
snippet.setDescription(description != null ? description.trim() : null);
|
||||
snippet.setUpdatedAt(Instant.now());
|
||||
return CommandSnippetDto.fromEntity(repository.save(snippet));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long userId) {
|
||||
CommandSnippet snippet = repository.findById(id).orElse(null);
|
||||
if (snippet == null) return;
|
||||
if (!snippet.getUserId().equals(userId)) {
|
||||
throw new AccessDeniedException("Access denied");
|
||||
}
|
||||
repository.delete(snippet);
|
||||
}
|
||||
}
|
||||
@@ -22,15 +22,21 @@ public class ConnectionService {
|
||||
private final EncryptionService encryptionService;
|
||||
private final SshService sshService;
|
||||
private final SshBootstrapService sshBootstrapService;
|
||||
private final WebhookService webhookService;
|
||||
private final QuickConnectionRegistry quickConnections;
|
||||
|
||||
public ConnectionService(ConnectionRepository connectionRepository,
|
||||
EncryptionService encryptionService,
|
||||
SshService sshService,
|
||||
SshBootstrapService sshBootstrapService) {
|
||||
SshBootstrapService sshBootstrapService,
|
||||
WebhookService webhookService,
|
||||
QuickConnectionRegistry quickConnections) {
|
||||
this.connectionRepository = connectionRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
this.sshService = sshService;
|
||||
this.sshBootstrapService = sshBootstrapService;
|
||||
this.webhookService = webhookService;
|
||||
this.quickConnections = quickConnections;
|
||||
}
|
||||
|
||||
public List<ConnectionDto> listByUserId(Long userId) {
|
||||
@@ -58,6 +64,7 @@ public class ConnectionService {
|
||||
conn.setHost(trimToNull(request.getHost()));
|
||||
conn.setPort(request.getPort() != null ? request.getPort() : 22);
|
||||
conn.setUsername(trimToNull(request.getUsername()));
|
||||
if (request.getPinned() != null) conn.setIsPinned(request.getPinned());
|
||||
|
||||
if (getSetupMode(request) == ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP) {
|
||||
SshBootstrapService.BootstrapResult bootstrapResult = sshBootstrapService.bootstrapWithPassword(request, userId);
|
||||
@@ -71,6 +78,19 @@ public class ConnectionService {
|
||||
}
|
||||
|
||||
conn = connectionRepository.save(conn);
|
||||
webhookService.fireEventForUser(userId, "connection.create", "连接已创建", conn.getName() + "@" + conn.getHost());
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
public ConnectionDto quickCreate(ConnectionCreateRequest request, Long userId) {
|
||||
// Ephemeral — stored in memory, NOT persisted to database.
|
||||
// Cleans up automatically when the WebSocket session closes.
|
||||
Connection conn = quickConnections.create(
|
||||
trimToNull(request.getHost()),
|
||||
trimToNull(request.getUsername()),
|
||||
request.getPort() != null ? request.getPort() : 22,
|
||||
userId
|
||||
);
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
@@ -93,6 +113,7 @@ public class ConnectionService {
|
||||
}
|
||||
if (request.getUsername() != null) conn.setUsername(trimToNull(request.getUsername()));
|
||||
if (request.getAuthType() != null) conn.setAuthType(request.getAuthType());
|
||||
if (request.getPinned() != null) conn.setIsPinned(request.getPinned());
|
||||
|
||||
validatePersistedFields(conn);
|
||||
applyCredentialUpdate(conn, request);
|
||||
@@ -112,7 +133,41 @@ public class ConnectionService {
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new AccessDeniedException("Access denied");
|
||||
}
|
||||
String connName = conn.getName();
|
||||
String connHost = conn.getHost();
|
||||
connectionRepository.delete(conn);
|
||||
webhookService.fireEventForUser(userId, "connection.delete", "连接已删除", connName + "@" + connHost);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConnectionDto togglePin(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new NotFoundException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new AccessDeniedException("Access denied");
|
||||
}
|
||||
conn.setIsPinned(!Boolean.TRUE.equals(conn.getIsPinned()));
|
||||
conn.setUpdatedAt(Instant.now());
|
||||
conn = connectionRepository.save(conn);
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
public String exportSshConfig(Long userId) {
|
||||
List<Connection> conns = connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# SSH Manager - Exported Config\n");
|
||||
sb.append("# Generated: ").append(java.time.Instant.now()).append("\n\n");
|
||||
for (Connection c : conns) {
|
||||
sb.append("Host ").append(c.getName()).append("\n");
|
||||
sb.append(" HostName ").append(c.getHost()).append("\n");
|
||||
sb.append(" Port ").append(c.getPort()).append("\n");
|
||||
sb.append(" User ").append(c.getUsername()).append("\n");
|
||||
if (c.getAuthType() == Connection.AuthType.PRIVATE_KEY) {
|
||||
sb.append(" IdentityFile ~/.ssh/id_rsa\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public Connection getConnectionForSsh(Long id, Long userId) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.sshmanager.entity.Connection;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* In-memory registry for quick-connect (ephemeral) SSH connections.
|
||||
* Entries are NOT persisted to the database and are cleaned up
|
||||
* when the WebSocket session closes.
|
||||
*/
|
||||
@Component
|
||||
public class QuickConnectionRegistry {
|
||||
|
||||
private final AtomicLong idGen = new AtomicLong(10_000_000);
|
||||
private final Map<Long, Entry> entries = new ConcurrentHashMap<>();
|
||||
|
||||
public Connection create(String host, String username, int port, Long userId) {
|
||||
long id = idGen.incrementAndGet();
|
||||
Connection conn = new Connection();
|
||||
conn.setId(id);
|
||||
conn.setUserId(userId);
|
||||
conn.setName(host + "-quick");
|
||||
conn.setHost(host);
|
||||
conn.setPort(port);
|
||||
conn.setUsername(username);
|
||||
conn.setAuthType(Connection.AuthType.PASSWORD);
|
||||
conn.setEncryptedPassword(null);
|
||||
conn.setCreatedAt(Instant.now());
|
||||
conn.setUpdatedAt(Instant.now());
|
||||
entries.put(id, new Entry(conn));
|
||||
return conn;
|
||||
}
|
||||
|
||||
public Connection get(Long id) {
|
||||
Entry entry = entries.get(id);
|
||||
return entry != null ? entry.connection : null;
|
||||
}
|
||||
|
||||
public void remove(Long id) {
|
||||
entries.remove(id);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return entries.size();
|
||||
}
|
||||
|
||||
private static class Entry {
|
||||
final Connection connection;
|
||||
final long createdAt = System.currentTimeMillis();
|
||||
|
||||
Entry(Connection connection) {
|
||||
this.connection = connection;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* One-time credential tokens for quick-connect SSH passwords.
|
||||
*
|
||||
* A token is generated per quick-connect request, bound to a specific
|
||||
* (userId, connectionId) pair, and can be consumed exactly once.
|
||||
* Tokens expire after TTL seconds to limit the window of exposure.
|
||||
*/
|
||||
@Component
|
||||
public class QuickCredentialRegistry {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(QuickCredentialRegistry.class);
|
||||
private static final long TTL_MILLIS = 60_000; // 60 seconds
|
||||
|
||||
private final Map<String, Entry> tokens = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Create a token for the given credentials.
|
||||
* Returns the token string.
|
||||
*/
|
||||
public String create(Long userId, Long connectionId, String password) {
|
||||
String token = UUID.randomUUID().toString().replace("-", "");
|
||||
tokens.put(token, new Entry(userId, connectionId, password));
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume (read-once + delete) a credential token.
|
||||
* Returns null if token is invalid, expired, or already consumed.
|
||||
* Validates that userId and connectionId match.
|
||||
*/
|
||||
public String consume(String token, Long userId, Long connectionId) {
|
||||
Entry entry = tokens.remove(token);
|
||||
if (entry == null) {
|
||||
log.warn("Credential token not found or already consumed");
|
||||
return null;
|
||||
}
|
||||
if (!entry.userId.equals(userId) || !entry.connectionId.equals(connectionId)) {
|
||||
log.warn("Credential token userId/connectionId mismatch");
|
||||
return null;
|
||||
}
|
||||
if (System.currentTimeMillis() - entry.createdAt > TTL_MILLIS) {
|
||||
log.warn("Credential token expired");
|
||||
return null;
|
||||
}
|
||||
return entry.password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-invalidate a token (used on connection failure).
|
||||
*/
|
||||
public void invalidate(String token) {
|
||||
tokens.remove(token);
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 30_000)
|
||||
public void evictExpired() {
|
||||
long now = System.currentTimeMillis();
|
||||
tokens.entrySet().removeIf(e -> now - e.getValue().createdAt > TTL_MILLIS);
|
||||
if (!tokens.isEmpty()) {
|
||||
log.debug("Credential pool: {} active tokens", tokens.size());
|
||||
}
|
||||
}
|
||||
|
||||
private static class Entry {
|
||||
final Long userId;
|
||||
final Long connectionId;
|
||||
final String password;
|
||||
final long createdAt = System.currentTimeMillis();
|
||||
|
||||
Entry(Long userId, Long connectionId, String password) {
|
||||
this.userId = userId;
|
||||
this.connectionId = connectionId;
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.jcraft.jsch.ChannelExec;
|
||||
import com.jcraft.jsch.ChannelSftp;
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.Session;
|
||||
@@ -10,7 +11,10 @@ import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.util.JschUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedOutputStream;
|
||||
@@ -439,4 +443,108 @@ public class SftpService {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Online file editor: read file content (maxSize limit) ──
|
||||
public String readFileContent(Connection conn, String password, String privateKey,
|
||||
String passphrase, String path, long maxSize) throws Exception {
|
||||
SftpSession sftpSession = connect(conn, password, privateKey, passphrase);
|
||||
try {
|
||||
SftpATTRS attrs = sftpSession.getChannel().stat(path);
|
||||
if (attrs.getSize() > maxSize) {
|
||||
throw new IllegalArgumentException("File too large (max " + (maxSize / 1024 / 1024) + "MB)");
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
sftpSession.getChannel().get(path, baos);
|
||||
return baos.toString("UTF-8");
|
||||
} finally {
|
||||
sftpSession.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Online file editor: write file content ──
|
||||
public void writeFileContent(Connection conn, String password, String privateKey,
|
||||
String passphrase, String path, String content) throws Exception {
|
||||
SftpSession sftpSession = connect(conn, password, privateKey, passphrase);
|
||||
try {
|
||||
sftpSession.getChannel().put(
|
||||
new java.io.ByteArrayInputStream(content.getBytes("UTF-8")), path, ChannelSftp.OVERWRITE);
|
||||
} finally {
|
||||
sftpSession.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remote compress via SSH exec ──
|
||||
public String compressRemote(Connection conn, String password, String privateKey,
|
||||
String passphrase, String path, String format) throws Exception {
|
||||
String outputName = path.replaceAll("/+$", "") + (".zip".equalsIgnoreCase(format) ? ".zip" : ".tar.gz");
|
||||
String cmd;
|
||||
if ("zip".equalsIgnoreCase(format)) {
|
||||
cmd = "cd /tmp && zip -r " + shellQuote(outputName) + " " + shellQuote(path) + " 2>&1";
|
||||
} else {
|
||||
cmd = "tar -czf " + shellQuote(outputName) + " -C $(dirname " + shellQuote(path) + ") $(basename " + shellQuote(path) + ") 2>&1";
|
||||
}
|
||||
Session session = JschUtil.createSession(conn, password, privateKey, passphrase);
|
||||
try {
|
||||
ChannelExec channel = (ChannelExec) session.openChannel("exec");
|
||||
channel.setCommand(cmd);
|
||||
InputStream in = channel.getInputStream();
|
||||
ByteArrayOutputStream err = new ByteArrayOutputStream();
|
||||
channel.setErrStream(err, true);
|
||||
channel.connect(10000);
|
||||
String output = readStreamFully(in);
|
||||
channel.disconnect();
|
||||
if (channel.getExitStatus() != 0) {
|
||||
throw new RuntimeException("Compression failed: " + err.toString("UTF-8"));
|
||||
}
|
||||
return output.trim();
|
||||
} finally {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remote decompress via SSH exec ──
|
||||
public String decompressRemote(Connection conn, String password, String privateKey,
|
||||
String passphrase, String path, String targetDir) throws Exception {
|
||||
String lower = path.toLowerCase();
|
||||
String cmd;
|
||||
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) {
|
||||
cmd = "mkdir -p " + shellQuote(targetDir) + " && tar -xzf " + shellQuote(path) + " -C " + shellQuote(targetDir) + " 2>&1";
|
||||
} else if (lower.endsWith(".zip")) {
|
||||
cmd = "mkdir -p " + shellQuote(targetDir) + " && unzip -o " + shellQuote(path) + " -d " + shellQuote(targetDir) + " 2>&1";
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported format (supported: .tar.gz, .tgz, .zip)");
|
||||
}
|
||||
Session session = JschUtil.createSession(conn, password, privateKey, passphrase);
|
||||
try {
|
||||
ChannelExec channel = (ChannelExec) session.openChannel("exec");
|
||||
channel.setCommand(cmd);
|
||||
InputStream in = channel.getInputStream();
|
||||
ByteArrayOutputStream err = new ByteArrayOutputStream();
|
||||
channel.setErrStream(err, true);
|
||||
channel.connect(10000);
|
||||
String output = readStreamFully(in);
|
||||
channel.disconnect();
|
||||
if (channel.getExitStatus() != 0) {
|
||||
throw new RuntimeException("Decompression failed: " + err.toString("UTF-8"));
|
||||
}
|
||||
return output.trim();
|
||||
} finally {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private String readStreamFully(InputStream in) throws Exception {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(in, "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (sb.length() > 0) sb.append("\n");
|
||||
sb.append(line);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String shellQuote(String value) {
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.sshmanager.entity.WebhookConfig;
|
||||
import com.sshmanager.repository.WebhookConfigRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class WebhookService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebhookService.class);
|
||||
private final WebhookConfigRepository repository;
|
||||
|
||||
public WebhookService(WebhookConfigRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public List<WebhookConfig> listByUser(Long userId) {
|
||||
return repository.findByUserIdAndEnabledTrue(userId);
|
||||
}
|
||||
|
||||
public WebhookConfig create(Long userId, String url, String eventType) {
|
||||
WebhookConfig wc = new WebhookConfig();
|
||||
wc.setUserId(userId);
|
||||
wc.setUrl(url);
|
||||
wc.setEventType(eventType);
|
||||
wc.setEnabled(true);
|
||||
return repository.save(wc);
|
||||
}
|
||||
|
||||
public void delete(Long id, Long userId) {
|
||||
repository.findById(id).ifPresent(wc -> {
|
||||
if (wc.getUserId().equals(userId)) repository.delete(wc);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a webhook's configured event type matches the fired event.
|
||||
* Supports "*" for all events, or exact match.
|
||||
*/
|
||||
static boolean matchesEventType(String configType, String firedType) {
|
||||
if (configType == null || firedType == null) return false;
|
||||
if ("*".equals(configType.trim())) return true;
|
||||
return configType.trim().equals(firedType);
|
||||
}
|
||||
|
||||
@Async
|
||||
public void fireEvent(String eventType, String title, String message) {
|
||||
fireEventForUser(null, eventType, title, message);
|
||||
}
|
||||
|
||||
@Async
|
||||
public void fireEventForUser(Long userId, String eventType, String title, String message) {
|
||||
List<WebhookConfig> hooks = repository.findAll().stream()
|
||||
.filter(w -> Boolean.TRUE.equals(w.getEnabled()))
|
||||
.filter(w -> matchesEventType(w.getEventType(), eventType))
|
||||
.filter(w -> userId == null || userId.equals(w.getUserId()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (hooks.isEmpty()) {
|
||||
log.debug("No webhooks configured for event type: {}", eventType);
|
||||
return;
|
||||
}
|
||||
|
||||
for (WebhookConfig hook : hooks) {
|
||||
try {
|
||||
URL url = new URL(hook.getUrl());
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setDoOutput(true);
|
||||
|
||||
String json = String.format(
|
||||
"{\"msgtype\":\"text\",\"text\":{\"content\":\"[SSH Manager] %s\\n%s\"}}",
|
||||
title, message
|
||||
);
|
||||
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(json.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
conn.getResponseCode();
|
||||
conn.disconnect();
|
||||
} catch (Exception e) {
|
||||
log.warn("Webhook call failed for {}: {}", hook.getUrl(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,13 +62,15 @@ public final class JschUtil {
|
||||
+ "diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||
if (password == null || password.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"SSH authentication failed: password is empty. Please check the connection password configuration.");
|
||||
if (password == null || password.isEmpty() || "__quick__".equals(password)) {
|
||||
// No stored password — use keyboard-interactive so the user is prompted
|
||||
session.setConfig("PreferredAuthentications", "keyboard-interactive");
|
||||
session.setUserInfo(new PasswordAuth(""));
|
||||
} else {
|
||||
session.setConfig("PreferredAuthentications", "password,keyboard-interactive");
|
||||
session.setPassword(password);
|
||||
session.setUserInfo(new PasswordAuth(password));
|
||||
}
|
||||
session.setConfig("PreferredAuthentications", "password,keyboard-interactive");
|
||||
session.setPassword(password);
|
||||
session.setUserInfo(new PasswordAuth(password));
|
||||
} else {
|
||||
session.setConfig("PreferredAuthentications", "publickey");
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{c as r,j as e}from"./index-Z2D8CQl5.js";const l=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]],m=r("circle-alert",l);const o=[["path",{d:"M10 11v6",key:"nco0om"}],["path",{d:"M14 11v6",key:"outv1u"}],["path",{d:"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",key:"miytrc"}],["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",key:"e791ji"}]],b=r("trash-2",o);const i=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],x=r("x",i);function h({title:c,onClose:t,children:d,footer:a,maxWidth:s="max-w-3xl",open:n=!0}){return n?e.jsx("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm",children:e.jsxs("div",{className:`flex max-h-[92vh] w-full flex-col overflow-hidden rounded-3xl border border-border-main bg-surface-card ${s}`,children:[e.jsxs("div",{className:"flex items-center justify-between border-b border-border-subtle bg-surface-card/90 px-5 py-4",children:[e.jsx("h3",{className:"text-lg font-medium text-content-main",children:c}),t?e.jsx("button",{onClick:t,className:"rounded-xl border border-border-main bg-surface-muted p-2 text-content-muted transition hover:text-content-main",children:e.jsx(x,{size:18})}):null]}),e.jsx("div",{className:"flex-1 overflow-y-auto p-6",children:d}),a?e.jsx("div",{className:"flex justify-end gap-3 border-t border-border-subtle bg-surface-card/90 px-5 py-4",children:a}):null]})}):null}export{m as C,h as M,b as T,x as X};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.xterm{cursor:text;position:relative;-moz-user-select:none;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;inset:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility,.xterm .xterm-message{position:absolute;inset:0;z-index:10;color:transparent;pointer-events:none}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:double underline;text-decoration:double underline}.xterm-underline-3{-webkit-text-decoration:wavy underline;text-decoration:wavy underline}.xterm-underline-4{-webkit-text-decoration:dotted underline;text-decoration:dotted underline}.xterm-underline-5{-webkit-text-decoration:dashed underline;text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{c as o,h as t}from"./index-Z2D8CQl5.js";const s=[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]],y=o("activity",s);const r=[["rect",{width:"20",height:"14",x:"2",y:"3",rx:"2",key:"48i651"}],["line",{x1:"8",x2:"16",y1:"21",y2:"21",key:"1svkeh"}],["line",{x1:"12",x2:"12",y1:"17",y2:"21",key:"vw1qmm"}]],h=o("monitor",r);const a=[["rect",{width:"20",height:"8",x:"2",y:"2",rx:"2",ry:"2",key:"ngkwjq"}],["rect",{width:"20",height:"8",x:"2",y:"14",rx:"2",ry:"2",key:"iecqi9"}],["line",{x1:"6",x2:"6.01",y1:"6",y2:"6",key:"16zg32"}],["line",{x1:"6",x2:"6.01",y1:"18",y2:"18",key:"nzw8ys"}]],x=o("server",a);function d(){return t.get("/connections")}function k(n){return t.post("/connections",n)}function p(n,e){return t.put(`/connections/${n}`,e)}function g(n){return t.delete(`/connections/${n}`)}function l(n,e){return t.post("/connections/batch-command",{connectionIds:n,command:e})}function f(n){return t.post("/connections/status",{connectionIds:n})}function m(n){return t.put(`/connections/${n}/pin`)}function C(n,e,c,i){return t.post("/connections/quick-connect",{host:n,username:e,port:c,password:i})}function q(n){return t.get(`/monitor/${n}`)}export{y as A,h as M,x as S,k as a,f as c,g as d,l as e,q as g,d as l,C as q,m as t,p as u};
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ssh-manager.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>SSH Manager</title>
|
||||
<script type="module" crossorigin src="/assets/index-Z2D8CQl5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B4Duc4SL.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<!-- 背景层:深色圆角矩形与发光效果 -->
|
||||
<defs>
|
||||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0f172a" />
|
||||
<stop offset="100%" stop-color="#020617" />
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="15" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
<linearGradient id="primaryGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.6" />
|
||||
<stop offset="100%" stop-color="#10b981" stop-opacity="0.3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 外部圆角底框 -->
|
||||
<rect x="32" y="32" width="448" height="448" rx="100" ry="100" fill="url(#bgGradient)" stroke="#1e293b" stroke-width="8" />
|
||||
|
||||
<!-- 内部光晕点缀 -->
|
||||
<circle cx="256" cy="256" r="180" fill="url(#primaryGlow)" filter="url(#glow)" />
|
||||
<rect x="64" y="64" width="384" height="384" rx="80" ry="80" fill="#0f172a" opacity="0.85" />
|
||||
|
||||
<!-- 终端符号: >_ -->
|
||||
<g transform="translate(130, 160)" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- 箭头 > -->
|
||||
<path d="M 20 20 L 120 90 L 20 160" fill="none" stroke="#3b82f6" stroke-width="40" />
|
||||
|
||||
<!-- 下划线 _ -->
|
||||
<line x1="140" y1="180" x2="240" y2="180" stroke="#10b981" stroke-width="36" />
|
||||
</g>
|
||||
|
||||
<!-- 顶部状态指示灯 (红黄绿) -->
|
||||
<circle cx="110" cy="110" r="12" fill="#ef4444" />
|
||||
<circle cx="150" cy="110" r="12" fill="#eab308" />
|
||||
<circle cx="190" cy="110" r="12" fill="#10b981" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -3,6 +3,8 @@ package com.sshmanager.controller;
|
||||
import com.sshmanager.repository.ConnectionRepository;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.QuickConnectionRegistry;
|
||||
import com.sshmanager.service.QuickCredentialRegistry;
|
||||
import com.sshmanager.service.SshService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -28,6 +30,8 @@ class TerminalWebSocketHandlerTest {
|
||||
UserRepository userRepository = mock(UserRepository.class);
|
||||
ConnectionService connectionService = mock(ConnectionService.class);
|
||||
SshService sshService = mock(SshService.class);
|
||||
QuickConnectionRegistry quickConnections = mock(QuickConnectionRegistry.class);
|
||||
QuickCredentialRegistry quickCredentials = mock(QuickCredentialRegistry.class);
|
||||
ExecutorService executor = mock(ExecutorService.class);
|
||||
|
||||
TerminalWebSocketHandler handler = new TerminalWebSocketHandler(
|
||||
@@ -35,6 +39,8 @@ class TerminalWebSocketHandlerTest {
|
||||
userRepository,
|
||||
connectionService,
|
||||
sshService,
|
||||
quickConnections,
|
||||
quickCredentials,
|
||||
executor
|
||||
);
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ class ConnectionServiceTest {
|
||||
@Mock
|
||||
private SshBootstrapService sshBootstrapService;
|
||||
|
||||
@Mock
|
||||
private WebhookService webhookService;
|
||||
|
||||
@Mock
|
||||
private QuickConnectionRegistry quickConnectionRegistry;
|
||||
|
||||
@InjectMocks
|
||||
private ConnectionService connectionService;
|
||||
|
||||
|
||||
@@ -28,17 +28,21 @@ class SftpServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPasswordAuthenticationRequiredWithValidConnection() {
|
||||
void testQuickConnectWithEmptyPasswordUsesKeyboardInteractive() {
|
||||
// Quick-connect connections have empty password — should NOT throw
|
||||
// (they fall back to keyboard-interactive auth)
|
||||
Connection conn = new Connection();
|
||||
conn.setHost("127.0.0.1");
|
||||
conn.setPort(22);
|
||||
conn.setUsername("test");
|
||||
conn.setAuthType(Connection.AuthType.PASSWORD);
|
||||
// This will try to connect to 127.0.0.1 and fail with connection refused,
|
||||
// but it should NOT fail with "password is empty" anymore
|
||||
Exception exception = assertThrows(Exception.class, () -> {
|
||||
Connection conn = new Connection();
|
||||
conn.setHost("127.0.0.1");
|
||||
conn.setPort(22);
|
||||
conn.setUsername("test");
|
||||
conn.setAuthType(Connection.AuthType.PASSWORD);
|
||||
sftpService.connect(conn, "", null, null);
|
||||
sftpService.connect(conn, "__quick__", null, null);
|
||||
});
|
||||
assertTrue(exception.getMessage().contains("Password is required") ||
|
||||
exception instanceof IllegalArgumentException);
|
||||
// Should fail with connection/timeout, not password validation
|
||||
assertFalse(exception.getMessage().contains("password is empty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sshmanager.util;
|
||||
|
||||
import com.sshmanager.entity.Connection;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -130,6 +131,34 @@ class JschUtilTest {
|
||||
assertEquals(password, auth.getPassword());
|
||||
}
|
||||
|
||||
// ── Empty password (quick-connect) ──
|
||||
|
||||
@Test
|
||||
void emptyPasswordUsesKeyboardInteractive() {
|
||||
Connection conn = new Connection();
|
||||
conn.setHost("127.0.0.2");
|
||||
conn.setPort(22);
|
||||
conn.setUsername("test");
|
||||
conn.setAuthType(Connection.AuthType.PASSWORD);
|
||||
// Empty password should not throw "password is empty" — falls through to keyboard-interactive
|
||||
Exception ex = assertThrows(Exception.class, () -> JschUtil.createSession(conn, "", null, null));
|
||||
assertFalse(ex.getMessage().contains("password is empty"),
|
||||
"Empty password should fall back to keyboard-interactive, not reject");
|
||||
}
|
||||
|
||||
@Test
|
||||
void quickConnectPasswordUsesKeyboardInteractive() {
|
||||
Connection conn = new Connection();
|
||||
conn.setHost("127.0.0.2");
|
||||
conn.setPort(22);
|
||||
conn.setUsername("test");
|
||||
conn.setAuthType(Connection.AuthType.PASSWORD);
|
||||
// "__quick__" marker password should not throw "password is empty"
|
||||
Exception ex = assertThrows(Exception.class, () -> JschUtil.createSession(conn, "__quick__", null, null));
|
||||
assertFalse(ex.getMessage().contains("password is empty"),
|
||||
"Quick-connect marker should use keyboard-interactive, not reject");
|
||||
}
|
||||
|
||||
@Test
|
||||
void passwordAuthEchoPromptsAreLeftEmpty() {
|
||||
String password = "test-password";
|
||||
|
||||
Reference in New Issue
Block a user