feat: 主题切换 + 浅色模式适配,SFTP/批量命令/Webhook/仪表盘全面升级

This commit is contained in:
liumangmang
2026-06-10 14:33:47 +08:00
parent 507d59d633
commit 4a17f0106e
69 changed files with 3105 additions and 673 deletions
@@ -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";