feat: add port forwarding and optimize connection status checks
This commit is contained in:
+29
-29
@@ -53,35 +53,35 @@
|
|||||||
<artifactId>jjwt-api</artifactId>
|
<artifactId>jjwt-api</artifactId>
|
||||||
<version>0.11.5</version>
|
<version>0.11.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-impl</artifactId>
|
<artifactId>jjwt-impl</artifactId>
|
||||||
<version>0.11.5</version>
|
<version>0.11.5</version>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-jackson</artifactId>
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
<version>0.11.5</version>
|
<version>0.11.5</version>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.security</groupId>
|
<groupId>org.springframework.security</groupId>
|
||||||
<artifactId>spring-security-test</artifactId>
|
<artifactId>spring-security-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<resources>
|
<resources>
|
||||||
<resource>
|
<resource>
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
import java.util.concurrent.BlockingQueue;
|
import java.util.concurrent.BlockingQueue;
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@@ -35,14 +33,4 @@ public class WebSocketThreadPoolConfig {
|
|||||||
);
|
);
|
||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
|
||||||
public ScheduledExecutorService websocketCleanupScheduler() {
|
|
||||||
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
|
|
||||||
scheduler.scheduleAtFixedRate(this::cleanupIdleSessions, 30, 30, TimeUnit.MINUTES);
|
|
||||||
return scheduler;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void cleanupIdleSessions() {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package com.sshmanager.controller;
|
||||||
|
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import com.sshmanager.entity.User;
|
||||||
|
import com.sshmanager.exception.AccessDeniedException;
|
||||||
|
import com.sshmanager.exception.NotFoundException;
|
||||||
|
import com.sshmanager.repository.ConnectionRepository;
|
||||||
|
import com.sshmanager.repository.UserRepository;
|
||||||
|
import com.sshmanager.service.ConnectionService;
|
||||||
|
import com.sshmanager.service.PortForwardRegistry;
|
||||||
|
import com.sshmanager.service.PortForwardRegistry.TunnelEntry;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API for managing in-memory SSH port-forwarding tunnels.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>GET /api/port-forwards – list running tunnels for the current user</li>
|
||||||
|
* <li>POST /api/port-forwards – create a new tunnel</li>
|
||||||
|
* <li>DELETE /api/port-forwards/{id} – stop a tunnel</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>All tunnels are ephemeral: they live only while the server is running.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/port-forwards")
|
||||||
|
public class PortForwardController {
|
||||||
|
|
||||||
|
private final PortForwardRegistry portForwardRegistry;
|
||||||
|
private final ConnectionRepository connectionRepository;
|
||||||
|
private final ConnectionService connectionService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public PortForwardController(PortForwardRegistry portForwardRegistry,
|
||||||
|
ConnectionRepository connectionRepository,
|
||||||
|
ConnectionService connectionService,
|
||||||
|
UserRepository userRepository) {
|
||||||
|
this.portForwardRegistry = portForwardRegistry;
|
||||||
|
this.connectionRepository = connectionRepository;
|
||||||
|
this.connectionService = connectionService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Long getCurrentUserId(Authentication auth) {
|
||||||
|
User user = userRepository.findByUsername(auth.getName())
|
||||||
|
.orElseThrow(() -> new IllegalStateException("User not found"));
|
||||||
|
return user.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> toDto(TunnelEntry e) {
|
||||||
|
Map<String, Object> dto = new HashMap<>();
|
||||||
|
dto.put("id", e.getId());
|
||||||
|
dto.put("connectionId", e.getConnectionId());
|
||||||
|
dto.put("connectionName", e.getConnectionName());
|
||||||
|
dto.put("localPort", e.getLocalPort());
|
||||||
|
dto.put("remoteHost", e.getRemoteHost());
|
||||||
|
dto.put("remotePort", e.getRemotePort());
|
||||||
|
dto.put("status", e.getStatus().name().toLowerCase());
|
||||||
|
dto.put("createdAt", e.getCreatedAt().toString());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── endpoints ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** GET /api/port-forwards — list active tunnels for the authenticated user. */
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<Map<String, Object>>> list(Authentication auth) {
|
||||||
|
Long userId = getCurrentUserId(auth);
|
||||||
|
List<Map<String, Object>> dtos = portForwardRegistry.listByUser(userId).stream()
|
||||||
|
.map(PortForwardController::toDto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/port-forwards — create a new port-forwarding tunnel.
|
||||||
|
*
|
||||||
|
* <p>Expected request body:
|
||||||
|
* <pre>
|
||||||
|
* {
|
||||||
|
* "connectionId": 42,
|
||||||
|
* "localPort": 8080,
|
||||||
|
* "remoteHost": "127.0.0.1",
|
||||||
|
* "remotePort": 3306
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestBody Map<String, Object> body, Authentication auth) {
|
||||||
|
Long userId = getCurrentUserId(auth);
|
||||||
|
|
||||||
|
// ── parse & validate request ──────────────────────────────────────────
|
||||||
|
Long connectionId;
|
||||||
|
int localPort, remotePort;
|
||||||
|
String remoteHost;
|
||||||
|
try {
|
||||||
|
connectionId = Long.valueOf(body.get("connectionId").toString());
|
||||||
|
localPort = Integer.parseInt(body.get("localPort").toString());
|
||||||
|
remotePort = Integer.parseInt(body.get("remotePort").toString());
|
||||||
|
remoteHost = body.get("remoteHost").toString().trim();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", "Invalid request body: connectionId, localPort, remoteHost and remotePort are required");
|
||||||
|
return ResponseEntity.badRequest().body(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ownership check ───────────────────────────────────────────────────
|
||||||
|
Connection conn = connectionRepository.findById(connectionId).orElse(null);
|
||||||
|
if (conn == null) {
|
||||||
|
throw new NotFoundException("Connection not found: " + connectionId);
|
||||||
|
}
|
||||||
|
if (!conn.getUserId().equals(userId)) {
|
||||||
|
throw new AccessDeniedException("Access denied to connection: " + connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── decrypt credentials ───────────────────────────────────────────────
|
||||||
|
String password = connectionService.getDecryptedPassword(conn);
|
||||||
|
String privateKey = connectionService.getDecryptedPrivateKey(conn);
|
||||||
|
String passphrase = connectionService.getDecryptedPassphrase(conn);
|
||||||
|
|
||||||
|
// ── create tunnel ─────────────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
TunnelEntry entry = portForwardRegistry.create(
|
||||||
|
userId, conn, password, privateKey, passphrase,
|
||||||
|
localPort, remoteHost, remotePort);
|
||||||
|
return ResponseEntity.ok(toDto(entry));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(err);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", "Failed to create port-forward: " + e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /api/port-forwards/{id} — stop and remove a tunnel. */
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, String>> stop(@PathVariable String id, Authentication auth) {
|
||||||
|
Long userId = getCurrentUserId(auth);
|
||||||
|
try {
|
||||||
|
portForwardRegistry.stop(id, userId);
|
||||||
|
Map<String, String> ok = new HashMap<>();
|
||||||
|
ok.put("message", "Tunnel stopped");
|
||||||
|
return ResponseEntity.ok(ok);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import com.sshmanager.service.QuickConnectionRegistry;
|
|||||||
import com.sshmanager.service.QuickCredentialRegistry;
|
import com.sshmanager.service.QuickCredentialRegistry;
|
||||||
import com.sshmanager.service.SshService;
|
import com.sshmanager.service.SshService;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.socket.CloseStatus;
|
import org.springframework.web.socket.CloseStatus;
|
||||||
import org.springframework.web.socket.TextMessage;
|
import org.springframework.web.socket.TextMessage;
|
||||||
@@ -34,7 +36,11 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
private final QuickCredentialRegistry quickCredentials;
|
private final QuickCredentialRegistry quickCredentials;
|
||||||
private final ExecutorService executor;
|
private final ExecutorService executor;
|
||||||
|
|
||||||
|
@Value("${sshmanager.terminal.idle-timeout-minutes:30}")
|
||||||
|
private long idleTimeoutMinutes;
|
||||||
|
|
||||||
private final AtomicInteger sessionCount = new AtomicInteger(0);
|
private final AtomicInteger sessionCount = new AtomicInteger(0);
|
||||||
|
private final Map<String, WebSocketSession> wsSessions = new ConcurrentHashMap<>();
|
||||||
private final Map<String, SshService.SshSession> sessions = new ConcurrentHashMap<>();
|
private final Map<String, SshService.SshSession> sessions = new ConcurrentHashMap<>();
|
||||||
private final Map<String, Long> lastActivity = new ConcurrentHashMap<>();
|
private final Map<String, Long> lastActivity = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@@ -100,9 +106,15 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
try {
|
try {
|
||||||
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
|
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||||
sessions.put(webSocketSession.getId(), sshSession);
|
sessions.put(webSocketSession.getId(), sshSession);
|
||||||
|
wsSessions.put(webSocketSession.getId(), webSocketSession);
|
||||||
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||||
sessionCount.incrementAndGet();
|
sessionCount.incrementAndGet();
|
||||||
|
|
||||||
|
// Refresh the quick-connection TTL so an active terminal is not evicted mid-session
|
||||||
|
if (quickConnections.get(connectionId) != null) {
|
||||||
|
quickConnections.touch(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
executor.submit(() -> {
|
executor.submit(() -> {
|
||||||
try {
|
try {
|
||||||
InputStream in = sshSession.getOutputStream();
|
InputStream in = sshSession.getOutputStream();
|
||||||
@@ -132,6 +144,12 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
if (sshSession != null && sshSession.isConnected()) {
|
if (sshSession != null && sshSession.isConnected()) {
|
||||||
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||||
|
|
||||||
|
// Touch the quick connection registry to keep metadata alive
|
||||||
|
Long connectionId = (Long) webSocketSession.getAttributes().get("connectionId");
|
||||||
|
if (connectionId != null) {
|
||||||
|
quickConnections.touch(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
String payload = message.getPayload();
|
String payload = message.getPayload();
|
||||||
TerminalControlMessage.parse(payload).ifPresent(ctrl -> {
|
TerminalControlMessage.parse(payload).ifPresent(ctrl -> {
|
||||||
if ("resize".equals(ctrl.getType()) && ctrl.getCols() != null && ctrl.getRows() != null) {
|
if ("resize".equals(ctrl.getType()) && ctrl.getCols() != null && ctrl.getRows() != null) {
|
||||||
@@ -151,6 +169,7 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
public void afterConnectionClosed(@NonNull WebSocketSession webSocketSession, @NonNull CloseStatus status) throws Exception {
|
public void afterConnectionClosed(@NonNull WebSocketSession webSocketSession, @NonNull CloseStatus status) throws Exception {
|
||||||
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
|
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
|
||||||
lastActivity.remove(webSocketSession.getId());
|
lastActivity.remove(webSocketSession.getId());
|
||||||
|
wsSessions.remove(webSocketSession.getId());
|
||||||
if (sshSession != null) {
|
if (sshSession != null) {
|
||||||
sshSession.disconnect();
|
sshSession.disconnect();
|
||||||
sessionCount.decrementAndGet();
|
sessionCount.decrementAndGet();
|
||||||
@@ -165,4 +184,23 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelay = 60000)
|
||||||
|
public void cleanupIdleSessions() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long maxIdleMillis = idleTimeoutMinutes * 60_000L;
|
||||||
|
lastActivity.forEach((sessionId, lastTime) -> {
|
||||||
|
if (now - lastTime > maxIdleMillis) {
|
||||||
|
WebSocketSession ws = wsSessions.get(sessionId);
|
||||||
|
if (ws != null && ws.isOpen()) {
|
||||||
|
try {
|
||||||
|
ws.sendMessage(new TextMessage("\r\n[Session idle timeout closed]\r\n"));
|
||||||
|
ws.close(CloseStatus.GOING_AWAY);
|
||||||
|
} catch (IOException e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,43 @@ import com.sshmanager.dto.ConnectionStatusCheckRequest;
|
|||||||
import com.sshmanager.dto.ConnectionStatusItemDto;
|
import com.sshmanager.dto.ConnectionStatusItemDto;
|
||||||
import com.sshmanager.dto.ConnectionStatusResponseDto;
|
import com.sshmanager.dto.ConnectionStatusResponseDto;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import javax.annotation.PreDestroy;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ConnectionStatusService {
|
public class ConnectionStatusService {
|
||||||
|
|
||||||
private final ConnectionService connectionService;
|
private static final Logger log = LoggerFactory.getLogger(ConnectionStatusService.class);
|
||||||
|
private static final int MAX_CONCURRENCY = 20;
|
||||||
|
private static final int PROBE_TIMEOUT_SECONDS = 5;
|
||||||
|
|
||||||
public ConnectionStatusService(ConnectionService connectionService) {
|
private final ConnectionService connectionService;
|
||||||
|
private final TcpProbe tcpProbe;
|
||||||
|
private final ExecutorService executor;
|
||||||
|
|
||||||
|
public ConnectionStatusService(ConnectionService connectionService, TcpProbe tcpProbe) {
|
||||||
this.connectionService = connectionService;
|
this.connectionService = connectionService;
|
||||||
|
this.tcpProbe = tcpProbe;
|
||||||
|
this.executor = Executors.newFixedThreadPool(MAX_CONCURRENCY, r -> {
|
||||||
|
Thread t = new Thread(r, "status-probe-" + r.hashCode());
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void shutdown() {
|
||||||
|
executor.shutdownNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ConnectionStatusResponseDto checkStatuses(Long userId, ConnectionStatusCheckRequest request) {
|
public ConnectionStatusResponseDto checkStatuses(Long userId, ConnectionStatusCheckRequest request) {
|
||||||
@@ -23,39 +48,28 @@ public class ConnectionStatusService {
|
|||||||
throw new IllegalArgumentException("At least one connection is required");
|
throw new IllegalArgumentException("At least one connection is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ConnectionStatusItemDto> results = new ArrayList<ConnectionStatusItemDto>();
|
// Execute all probes in parallel with a bounded thread pool
|
||||||
|
List<CompletableFuture<ConnectionStatusItemDto>> futures = new ArrayList<>();
|
||||||
|
for (Long connectionId : request.getConnectionIds()) {
|
||||||
|
futures.add(CompletableFuture.supplyAsync(() -> probeConnection(connectionId, userId), executor));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ConnectionStatusItemDto> results = new ArrayList<>();
|
||||||
int onlineCount = 0;
|
int onlineCount = 0;
|
||||||
int offlineCount = 0;
|
int offlineCount = 0;
|
||||||
|
|
||||||
for (Long connectionId : request.getConnectionIds()) {
|
for (CompletableFuture<ConnectionStatusItemDto> future : futures) {
|
||||||
Connection connection = connectionService.getConnectionForSsh(connectionId, userId);
|
|
||||||
long startedAt = System.currentTimeMillis();
|
|
||||||
try {
|
try {
|
||||||
connectionService.testConnection(
|
ConnectionStatusItemDto result = future.get(PROBE_TIMEOUT_SECONDS * 2, TimeUnit.SECONDS);
|
||||||
connection,
|
results.add(result);
|
||||||
connectionService.getDecryptedPassword(connection),
|
if ("online".equals(result.getStatus())) {
|
||||||
connectionService.getDecryptedPrivateKey(connection),
|
onlineCount++;
|
||||||
connectionService.getDecryptedPassphrase(connection)
|
} else {
|
||||||
);
|
offlineCount++;
|
||||||
long durationMs = System.currentTimeMillis() - startedAt;
|
}
|
||||||
results.add(new ConnectionStatusItemDto(
|
} catch (Exception e) {
|
||||||
connection.getId(),
|
results.add(new ConnectionStatusItemDto(0L, null, "offline", "Probe timeout", 0));
|
||||||
connection.getName(),
|
offlineCount++;
|
||||||
"online",
|
|
||||||
"SSH connection available",
|
|
||||||
durationMs
|
|
||||||
));
|
|
||||||
onlineCount += 1;
|
|
||||||
} catch (Exception error) {
|
|
||||||
long durationMs = System.currentTimeMillis() - startedAt;
|
|
||||||
results.add(new ConnectionStatusItemDto(
|
|
||||||
connection.getId(),
|
|
||||||
connection.getName(),
|
|
||||||
"offline",
|
|
||||||
error.getMessage(),
|
|
||||||
durationMs
|
|
||||||
));
|
|
||||||
offlineCount += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,4 +80,30 @@ public class ConnectionStatusService {
|
|||||||
response.setResults(results);
|
response.setResults(results);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ConnectionStatusItemDto probeConnection(Long connectionId, Long userId) {
|
||||||
|
long startedAt = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
Connection connection = connectionService.getConnectionForSsh(connectionId, userId);
|
||||||
|
// Quick TCP probe — no SSH handshake, just checks port liveness
|
||||||
|
tcpProbe.checkReachable(connection, PROBE_TIMEOUT_SECONDS);
|
||||||
|
long durationMs = System.currentTimeMillis() - startedAt;
|
||||||
|
return new ConnectionStatusItemDto(
|
||||||
|
connection.getId(), connection.getName(),
|
||||||
|
"online", "TCP port reachable", durationMs
|
||||||
|
);
|
||||||
|
} catch (Exception error) {
|
||||||
|
long durationMs = System.currentTimeMillis() - startedAt;
|
||||||
|
Long id = connectionId;
|
||||||
|
String name = null;
|
||||||
|
try {
|
||||||
|
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
|
||||||
|
id = conn.getId();
|
||||||
|
name = conn.getName();
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return new ConnectionStatusItemDto(
|
||||||
|
id, name, "offline", error.getMessage(), durationMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.jcraft.jsch.Session;
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import com.sshmanager.util.JschUtil;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory registry for active SSH port-forwarding tunnels.
|
||||||
|
*
|
||||||
|
* <p>All tunnels are ephemeral — they exist only for the lifetime of the server
|
||||||
|
* process. Restarting the server clears all tunnels automatically.
|
||||||
|
*
|
||||||
|
* <p>One JSch {@link Session} is kept per tunnel. When a tunnel is stopped the
|
||||||
|
* session is disconnected and the local port is freed.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PortForwardRegistry {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PortForwardRegistry.class);
|
||||||
|
|
||||||
|
// ── public data model ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public enum TunnelStatus { RUNNING, STOPPED, ERROR }
|
||||||
|
|
||||||
|
public static class TunnelEntry {
|
||||||
|
private final String id;
|
||||||
|
private final Long userId;
|
||||||
|
private final Long connectionId;
|
||||||
|
private final String connectionName;
|
||||||
|
private final int localPort;
|
||||||
|
private final String remoteHost;
|
||||||
|
private final int remotePort;
|
||||||
|
private final Instant createdAt;
|
||||||
|
private volatile TunnelStatus status;
|
||||||
|
// not exposed in DTO
|
||||||
|
final Session jschSession;
|
||||||
|
|
||||||
|
public TunnelEntry(String id, Long userId, Long connectionId, String connectionName,
|
||||||
|
int localPort, String remoteHost, int remotePort,
|
||||||
|
Session jschSession) {
|
||||||
|
this.id = id;
|
||||||
|
this.userId = userId;
|
||||||
|
this.connectionId = connectionId;
|
||||||
|
this.connectionName = connectionName;
|
||||||
|
this.localPort = localPort;
|
||||||
|
this.remoteHost = remoteHost;
|
||||||
|
this.remotePort = remotePort;
|
||||||
|
this.jschSession = jschSession;
|
||||||
|
this.createdAt = Instant.now();
|
||||||
|
this.status = TunnelStatus.RUNNING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public Long getUserId() { return userId; }
|
||||||
|
public Long getConnectionId() { return connectionId; }
|
||||||
|
public String getConnectionName(){ return connectionName; }
|
||||||
|
public int getLocalPort() { return localPort; }
|
||||||
|
public String getRemoteHost() { return remoteHost; }
|
||||||
|
public int getRemotePort() { return remotePort; }
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
public TunnelStatus getStatus() { return status; }
|
||||||
|
void setStatus(TunnelStatus s) { this.status = s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── state ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private final Map<String, TunnelEntry> tunnels = new ConcurrentHashMap<>();
|
||||||
|
private final SshSessionFactory sshSessionFactory;
|
||||||
|
|
||||||
|
public PortForwardRegistry(SshSessionFactory sshSessionFactory) {
|
||||||
|
this.sshSessionFactory = sshSessionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and register a new port-forwarding tunnel.
|
||||||
|
*
|
||||||
|
* @param userId owner's user ID
|
||||||
|
* @param conn the SSH connection entity to tunnel through
|
||||||
|
* @param password decrypted password (null for key auth)
|
||||||
|
* @param privateKey decrypted private key PEM (null for password auth)
|
||||||
|
* @param passphrase key passphrase (null if none)
|
||||||
|
* @param localPort local TCP port to bind (1–65535)
|
||||||
|
* @param remoteHost the host reachable from the SSH server to forward to
|
||||||
|
* @param remotePort the port on remoteHost to forward to (1–65535)
|
||||||
|
* @return the registered {@link TunnelEntry}
|
||||||
|
* @throws IllegalArgumentException if port numbers are out of range or remoteHost is blank
|
||||||
|
* @throws Exception if the SSH session or port-forward setup fails
|
||||||
|
*/
|
||||||
|
public TunnelEntry create(Long userId, Connection conn,
|
||||||
|
String password, String privateKey, String passphrase,
|
||||||
|
int localPort, String remoteHost, int remotePort) throws Exception {
|
||||||
|
validatePorts(localPort, remotePort);
|
||||||
|
if (remoteHost == null || remoteHost.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("remoteHost must not be blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
Session session = sshSessionFactory.createSession(conn, password, privateKey, passphrase);
|
||||||
|
try {
|
||||||
|
session.setPortForwardingL(localPort, remoteHost, remotePort);
|
||||||
|
} catch (Exception e) {
|
||||||
|
session.disconnect();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
String id = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
TunnelEntry entry = new TunnelEntry(id, userId, conn.getId(), conn.getName(),
|
||||||
|
localPort, remoteHost, remotePort, session);
|
||||||
|
tunnels.put(id, entry);
|
||||||
|
log.info("Port-forward started: id={} user={} {}:{}:{} via connection={}",
|
||||||
|
id, userId, localPort, remoteHost, remotePort, conn.getId());
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop and remove a tunnel.
|
||||||
|
*
|
||||||
|
* @param id tunnel ID
|
||||||
|
* @param userId caller's user ID — must match the tunnel owner
|
||||||
|
* @throws IllegalArgumentException if the tunnel is not found or not owned by this user
|
||||||
|
*/
|
||||||
|
public void stop(String id, Long userId) {
|
||||||
|
TunnelEntry entry = tunnels.get(id);
|
||||||
|
if (entry == null) {
|
||||||
|
throw new IllegalArgumentException("Port-forward tunnel not found: " + id);
|
||||||
|
}
|
||||||
|
if (!entry.getUserId().equals(userId)) {
|
||||||
|
throw new IllegalArgumentException("Access denied to tunnel: " + id);
|
||||||
|
}
|
||||||
|
tunnels.remove(id);
|
||||||
|
try {
|
||||||
|
entry.jschSession.delPortForwardingL(entry.getLocalPort());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// best-effort cancel
|
||||||
|
}
|
||||||
|
if (entry.jschSession.isConnected()) {
|
||||||
|
entry.jschSession.disconnect();
|
||||||
|
}
|
||||||
|
entry.setStatus(TunnelStatus.STOPPED);
|
||||||
|
log.info("Port-forward stopped: id={} user={} localPort={}", id, userId, entry.getLocalPort());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all active tunnels belonging to a user. */
|
||||||
|
public List<TunnelEntry> listByUser(Long userId) {
|
||||||
|
List<TunnelEntry> result = new ArrayList<>();
|
||||||
|
for (TunnelEntry e : tunnels.values()) {
|
||||||
|
if (e.getUserId().equals(userId)) {
|
||||||
|
result.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sort((a, b) -> a.getCreatedAt().compareTo(b.getCreatedAt()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void validatePorts(int localPort, int remotePort) {
|
||||||
|
if (localPort < 1 || localPort > 65535) {
|
||||||
|
throw new IllegalArgumentException("localPort must be in range 1-65535, got: " + localPort);
|
||||||
|
}
|
||||||
|
if (remotePort < 1 || remotePort > 65535) {
|
||||||
|
throw new IllegalArgumentException("remotePort must be in range 1-65535, got: " + remotePort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package com.sshmanager.service;
|
package com.sshmanager.service;
|
||||||
|
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -10,15 +14,30 @@ import java.util.concurrent.atomic.AtomicLong;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory registry for quick-connect (ephemeral) SSH connections.
|
* 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.
|
* <p>Entries are NOT persisted to the database. They are cleaned up either:
|
||||||
|
* <ul>
|
||||||
|
* <li>immediately when the WebSocket session closes ({@link #remove}), or</li>
|
||||||
|
* <li>by the background TTL sweep ({@link #evictExpired}) after
|
||||||
|
* {@code sshmanager.quick-connection.ttl-minutes} minutes of inactivity.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Call {@link #touch} after a successful WebSocket handshake to reset the
|
||||||
|
* last-active timestamp and prevent premature eviction of long-lived sessions.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class QuickConnectionRegistry {
|
public class QuickConnectionRegistry {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(QuickConnectionRegistry.class);
|
||||||
|
|
||||||
|
@Value("${sshmanager.quick-connection.ttl-minutes:30}")
|
||||||
|
private long ttlMinutes;
|
||||||
|
|
||||||
private final AtomicLong idGen = new AtomicLong(10_000_000);
|
private final AtomicLong idGen = new AtomicLong(10_000_000);
|
||||||
private final Map<Long, Entry> entries = new ConcurrentHashMap<>();
|
private final Map<Long, Entry> entries = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// ── public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public Connection create(String host, String username, int port, Long userId) {
|
public Connection create(String host, String username, int port, Long userId) {
|
||||||
long id = idGen.incrementAndGet();
|
long id = idGen.incrementAndGet();
|
||||||
Connection conn = new Connection();
|
Connection conn = new Connection();
|
||||||
@@ -36,11 +55,25 @@ public class QuickConnectionRegistry {
|
|||||||
return conn;
|
return conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve a quick connection; returns null if not found or already expired. */
|
||||||
public Connection get(Long id) {
|
public Connection get(Long id) {
|
||||||
Entry entry = entries.get(id);
|
Entry entry = entries.get(id);
|
||||||
return entry != null ? entry.connection : null;
|
return entry != null ? entry.connection : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the last-active timestamp for a quick connection.
|
||||||
|
* Call this after a WebSocket session is successfully established so that
|
||||||
|
* the entry is not evicted while the terminal is still in use.
|
||||||
|
*/
|
||||||
|
public void touch(Long id) {
|
||||||
|
Entry entry = entries.get(id);
|
||||||
|
if (entry != null) {
|
||||||
|
entry.lastAccessAt = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immediately remove a quick connection (called on WebSocket close). */
|
||||||
public void remove(Long id) {
|
public void remove(Long id) {
|
||||||
entries.remove(id);
|
entries.remove(id);
|
||||||
}
|
}
|
||||||
@@ -49,9 +82,41 @@ public class QuickConnectionRegistry {
|
|||||||
return entries.size();
|
return entries.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── background cleanup ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled eviction of stale quick connections.
|
||||||
|
*
|
||||||
|
* <p>The interval is controlled by
|
||||||
|
* {@code sshmanager.quick-connection.cleanup-interval-ms} (default 60 s).
|
||||||
|
* An entry is evicted when its last-active time exceeds
|
||||||
|
* {@code sshmanager.quick-connection.ttl-minutes}.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelayString = "${sshmanager.quick-connection.cleanup-interval-ms:60000}")
|
||||||
|
public void evictExpired() {
|
||||||
|
long ttlMillis = ttlMinutes * 60_000L;
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
int[] removed = {0};
|
||||||
|
entries.entrySet().removeIf(e -> {
|
||||||
|
boolean expired = (now - e.getValue().lastAccessAt) > ttlMillis;
|
||||||
|
if (expired) {
|
||||||
|
removed[0]++;
|
||||||
|
log.info("Evicting stale quick connection id={} (inactive > {} min)",
|
||||||
|
e.getKey(), ttlMinutes);
|
||||||
|
}
|
||||||
|
return expired;
|
||||||
|
});
|
||||||
|
if (removed[0] > 0) {
|
||||||
|
log.info("Quick-connection TTL sweep removed {} entr{}, {} remaining",
|
||||||
|
removed[0], removed[0] == 1 ? "y" : "ies", entries.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internal entry ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static class Entry {
|
private static class Entry {
|
||||||
final Connection connection;
|
final Connection connection;
|
||||||
final long createdAt = System.currentTimeMillis();
|
volatile long lastAccessAt = System.currentTimeMillis();
|
||||||
|
|
||||||
Entry(Connection connection) {
|
Entry(Connection connection) {
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production TCP probe implementation.
|
||||||
|
* Opens a raw TCP socket to the target host:port — no SSH handshake.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RealTcpProbe implements TcpProbe {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void checkReachable(Connection conn, int timeoutSeconds) throws Exception {
|
||||||
|
Socket socket = new Socket();
|
||||||
|
try {
|
||||||
|
socket.connect(new InetSocketAddress(conn.getHost(), conn.getPort()), timeoutSeconds * 1000);
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { socket.close(); } catch (Exception ignored) {}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.jcraft.jsch.Session;
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import com.sshmanager.util.JschUtil;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Spring-managed factory for JSch sessions.
|
||||||
|
*
|
||||||
|
* <p>Wraps the static utility calls to {@link JschUtil#createSession} so that
|
||||||
|
* dependent components can be easily tested via dependency injection and standard mocking.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SshSessionFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and connect an SSH session.
|
||||||
|
*/
|
||||||
|
public Session createSession(Connection conn, String password, String privateKey, String passphrase)
|
||||||
|
throws Exception {
|
||||||
|
return JschUtil.createSession(conn, password, privateKey, passphrase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction for TCP connectivity probing.
|
||||||
|
* Production: {@link RealTcpProbe} uses raw Socket.connect().
|
||||||
|
* Tests: mock to return success/failure without real network.
|
||||||
|
*/
|
||||||
|
public interface TcpProbe {
|
||||||
|
void checkReachable(Connection conn, int timeoutSeconds) throws Exception;
|
||||||
|
}
|
||||||
@@ -39,8 +39,17 @@ sshmanager:
|
|||||||
jwt-expiration-ms: 86400000
|
jwt-expiration-ms: 86400000
|
||||||
password-expiration-days: ${SSHMANAGER_PASSWORD_EXPIRATION_DAYS:90}
|
password-expiration-days: ${SSHMANAGER_PASSWORD_EXPIRATION_DAYS:90}
|
||||||
terminal:
|
terminal:
|
||||||
|
# Idle timeout threshold for active terminal websocket sessions, in minutes.
|
||||||
|
# Disconnects the underlying SSH session and closes the terminal on timeout. Default: 30.
|
||||||
|
idle-timeout-minutes: 30
|
||||||
websocket:
|
websocket:
|
||||||
thread-pool:
|
thread-pool:
|
||||||
core-size: 10
|
core-size: 10
|
||||||
max-size: 50
|
max-size: 50
|
||||||
keep-alive-seconds: 60
|
keep-alive-seconds: 60
|
||||||
|
quick-connection:
|
||||||
|
# Time-to-live for idle quick (ephemeral) SSH connections, in minutes.
|
||||||
|
# An active WebSocket terminal resets the timer. Default: 30 minutes.
|
||||||
|
ttl-minutes: 30
|
||||||
|
# How often to run the TTL cleanup sweep, in milliseconds. Default: 60 s.
|
||||||
|
cleanup-interval-ms: 60000
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +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};
|
import{c as r,j as e}from"./index-BQbRYAGj.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};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+10
-10
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +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};
|
import{c as o,h as t}from"./index-BQbRYAGj.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};
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>SSH Manager</title>
|
<title>SSH Manager</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Z2D8CQl5.js"></script>
|
<script type="module" crossorigin src="/assets/index-BQbRYAGj.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B4Duc4SL.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CPovcnGC.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package com.sshmanager.controller;
|
||||||
|
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import com.sshmanager.entity.User;
|
||||||
|
import com.sshmanager.exception.AccessDeniedException;
|
||||||
|
import com.sshmanager.exception.NotFoundException;
|
||||||
|
import com.sshmanager.repository.ConnectionRepository;
|
||||||
|
import com.sshmanager.repository.UserRepository;
|
||||||
|
import com.sshmanager.service.ConnectionService;
|
||||||
|
import com.sshmanager.service.PortForwardRegistry;
|
||||||
|
import com.sshmanager.service.PortForwardRegistry.TunnelEntry;
|
||||||
|
import com.sshmanager.service.PortForwardRegistry.TunnelStatus;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link PortForwardController}.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
class PortForwardControllerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PortForwardRegistry portForwardRegistry;
|
||||||
|
@Mock
|
||||||
|
private ConnectionRepository connectionRepository;
|
||||||
|
@Mock
|
||||||
|
private ConnectionService connectionService;
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private PortForwardController portForwardController;
|
||||||
|
|
||||||
|
private Authentication authentication;
|
||||||
|
private User testUser;
|
||||||
|
private Connection connection;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
authentication = mock(Authentication.class);
|
||||||
|
when(authentication.getName()).thenReturn("testuser");
|
||||||
|
|
||||||
|
testUser = new User();
|
||||||
|
testUser.setId(100L);
|
||||||
|
testUser.setUsername("testuser");
|
||||||
|
when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser));
|
||||||
|
|
||||||
|
connection = new Connection();
|
||||||
|
connection.setId(1L);
|
||||||
|
connection.setUserId(100L);
|
||||||
|
connection.setName("test-conn");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_returnsActiveTunnels() {
|
||||||
|
TunnelEntry mockEntry = mock(TunnelEntry.class);
|
||||||
|
when(mockEntry.getId()).thenReturn("tunnel-id");
|
||||||
|
when(mockEntry.getConnectionId()).thenReturn(1L);
|
||||||
|
when(mockEntry.getConnectionName()).thenReturn("test-conn");
|
||||||
|
when(mockEntry.getLocalPort()).thenReturn(8080);
|
||||||
|
when(mockEntry.getRemoteHost()).thenReturn("127.0.0.1");
|
||||||
|
when(mockEntry.getRemotePort()).thenReturn(3306);
|
||||||
|
when(mockEntry.getStatus()).thenReturn(TunnelStatus.RUNNING);
|
||||||
|
when(mockEntry.getCreatedAt()).thenReturn(java.time.Instant.now());
|
||||||
|
|
||||||
|
when(portForwardRegistry.listByUser(100L)).thenReturn(Collections.singletonList(mockEntry));
|
||||||
|
|
||||||
|
ResponseEntity<List<Map<String, Object>>> response = (ResponseEntity<List<Map<String, Object>>>) (Object) portForwardController.list(authentication);
|
||||||
|
|
||||||
|
assertEquals(200, response.getStatusCode().value());
|
||||||
|
List<Map<String, Object>> body = response.getBody();
|
||||||
|
assertNotNull(body);
|
||||||
|
assertEquals(1, body.size());
|
||||||
|
assertEquals("tunnel-id", body.get(0).get("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_success() throws Exception {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("connectionId", 1L);
|
||||||
|
body.put("localPort", 8080);
|
||||||
|
body.put("remoteHost", "127.0.0.1");
|
||||||
|
body.put("remotePort", 3306);
|
||||||
|
|
||||||
|
when(connectionRepository.findById(1L)).thenReturn(Optional.of(connection));
|
||||||
|
when(connectionService.getDecryptedPassword(connection)).thenReturn("password");
|
||||||
|
|
||||||
|
TunnelEntry mockEntry = mock(TunnelEntry.class);
|
||||||
|
when(mockEntry.getId()).thenReturn("tunnel-id");
|
||||||
|
when(mockEntry.getConnectionId()).thenReturn(1L);
|
||||||
|
when(mockEntry.getConnectionName()).thenReturn("test-conn");
|
||||||
|
when(mockEntry.getLocalPort()).thenReturn(8080);
|
||||||
|
when(mockEntry.getRemoteHost()).thenReturn("127.0.0.1");
|
||||||
|
when(mockEntry.getRemotePort()).thenReturn(3306);
|
||||||
|
when(mockEntry.getStatus()).thenReturn(TunnelStatus.RUNNING);
|
||||||
|
when(mockEntry.getCreatedAt()).thenReturn(java.time.Instant.now());
|
||||||
|
|
||||||
|
when(portForwardRegistry.create(eq(100L), eq(connection), eq("password"), any(), any(), eq(8080), eq("127.0.0.1"), eq(3306)))
|
||||||
|
.thenReturn(mockEntry);
|
||||||
|
|
||||||
|
ResponseEntity<?> response = portForwardController.create(body, authentication);
|
||||||
|
|
||||||
|
assertEquals(200, response.getStatusCode().value());
|
||||||
|
Map<String, Object> respBody = (Map<String, Object>) response.getBody();
|
||||||
|
assertNotNull(respBody);
|
||||||
|
assertEquals("tunnel-id", respBody.get("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_invalidRequestBody() {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
// missing connectionId and ports
|
||||||
|
|
||||||
|
ResponseEntity<?> response = portForwardController.create(body, authentication);
|
||||||
|
|
||||||
|
assertEquals(400, response.getStatusCode().value());
|
||||||
|
Map<String, String> respBody = (Map<String, String>) response.getBody();
|
||||||
|
assertNotNull(respBody);
|
||||||
|
assertTrue(respBody.get("error").contains("Invalid request body"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_connectionNotFound() {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("connectionId", 999L);
|
||||||
|
body.put("localPort", 8080);
|
||||||
|
body.put("remoteHost", "127.0.0.1");
|
||||||
|
body.put("remotePort", 3306);
|
||||||
|
|
||||||
|
when(connectionRepository.findById(999L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThrows(NotFoundException.class, () ->
|
||||||
|
portForwardController.create(body, authentication));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_accessDenied() {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("connectionId", 1L);
|
||||||
|
body.put("localPort", 8080);
|
||||||
|
body.put("remoteHost", "127.0.0.1");
|
||||||
|
body.put("remotePort", 3306);
|
||||||
|
|
||||||
|
Connection otherUserConnection = new Connection();
|
||||||
|
otherUserConnection.setId(1L);
|
||||||
|
otherUserConnection.setUserId(200L); // different owner
|
||||||
|
|
||||||
|
when(connectionRepository.findById(1L)).thenReturn(Optional.of(otherUserConnection));
|
||||||
|
|
||||||
|
assertThrows(AccessDeniedException.class, () ->
|
||||||
|
portForwardController.create(body, authentication));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_internalServerError() throws Exception {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("connectionId", 1L);
|
||||||
|
body.put("localPort", 8080);
|
||||||
|
body.put("remoteHost", "127.0.0.1");
|
||||||
|
body.put("remotePort", 3306);
|
||||||
|
|
||||||
|
when(connectionRepository.findById(1L)).thenReturn(Optional.of(connection));
|
||||||
|
when(portForwardRegistry.create(any(), any(), any(), any(), any(), anyInt(), anyString(), anyInt()))
|
||||||
|
.thenThrow(new RuntimeException("SSH failed"));
|
||||||
|
|
||||||
|
ResponseEntity<?> response = portForwardController.create(body, authentication);
|
||||||
|
|
||||||
|
assertEquals(500, response.getStatusCode().value());
|
||||||
|
Map<String, String> respBody = (Map<String, String>) response.getBody();
|
||||||
|
assertNotNull(respBody);
|
||||||
|
assertEquals("Failed to create port-forward: SSH failed", respBody.get("error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_success() {
|
||||||
|
ResponseEntity<Map<String, String>> response = portForwardController.stop("tunnel-id", authentication);
|
||||||
|
|
||||||
|
assertEquals(200, response.getStatusCode().value());
|
||||||
|
verify(portForwardRegistry).stop("tunnel-id", 100L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_throwsIllegalArgumentException() {
|
||||||
|
doThrow(new IllegalArgumentException("Tunnel not found"))
|
||||||
|
.when(portForwardRegistry).stop("nonexistent-id", 100L);
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, String>> response = portForwardController.stop("nonexistent-id", authentication);
|
||||||
|
|
||||||
|
assertEquals(400, response.getStatusCode().value());
|
||||||
|
assertEquals("Tunnel not found", response.getBody().get("error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,56 @@ class TerminalWebSocketHandlerTest {
|
|||||||
verify(sshSession, never()).getInputStream();
|
verify(sshSession, never()).getInputStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void cleanupIdleSessionsClosesExpiredWebSockets() throws Exception {
|
||||||
|
ConnectionRepository connectionRepository = mock(ConnectionRepository.class);
|
||||||
|
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(
|
||||||
|
connectionRepository,
|
||||||
|
userRepository,
|
||||||
|
connectionService,
|
||||||
|
sshService,
|
||||||
|
quickConnections,
|
||||||
|
quickCredentials,
|
||||||
|
executor
|
||||||
|
);
|
||||||
|
|
||||||
|
Field wsSessionsField = TerminalWebSocketHandler.class.getDeclaredField("wsSessions");
|
||||||
|
wsSessionsField.setAccessible(true);
|
||||||
|
Map<String, WebSocketSession> wsSessions = (Map<String, WebSocketSession>) wsSessionsField.get(handler);
|
||||||
|
|
||||||
|
Field lastActivityField = TerminalWebSocketHandler.class.getDeclaredField("lastActivity");
|
||||||
|
lastActivityField.setAccessible(true);
|
||||||
|
Map<String, Long> lastActivity = (Map<String, Long>) lastActivityField.get(handler);
|
||||||
|
|
||||||
|
Field idleTimeoutField = TerminalWebSocketHandler.class.getDeclaredField("idleTimeoutMinutes");
|
||||||
|
idleTimeoutField.setAccessible(true);
|
||||||
|
idleTimeoutField.set(handler, 30L);
|
||||||
|
|
||||||
|
WebSocketSession ws1 = mock(WebSocketSession.class);
|
||||||
|
when(ws1.isOpen()).thenReturn(true);
|
||||||
|
|
||||||
|
WebSocketSession ws2 = mock(WebSocketSession.class);
|
||||||
|
|
||||||
|
wsSessions.put("ws1", ws1);
|
||||||
|
lastActivity.put("ws1", System.currentTimeMillis() - (31 * 60 * 1000L));
|
||||||
|
|
||||||
|
wsSessions.put("ws2", ws2);
|
||||||
|
lastActivity.put("ws2", System.currentTimeMillis() - (10 * 60 * 1000L));
|
||||||
|
|
||||||
|
handler.cleanupIdleSessions();
|
||||||
|
|
||||||
|
verify(ws1).close(org.springframework.web.socket.CloseStatus.GOING_AWAY);
|
||||||
|
verify(ws2, never()).close(org.springframework.web.socket.CloseStatus.GOING_AWAY);
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private Map<String, SshService.SshSession> sessionsMap(TerminalWebSocketHandler handler) throws Exception {
|
private Map<String, SshService.SshSession> sessionsMap(TerminalWebSocketHandler handler) throws Exception {
|
||||||
Field f = TerminalWebSocketHandler.class.getDeclaredField("sessions");
|
Field f = TerminalWebSocketHandler.class.getDeclaredField("sessions");
|
||||||
|
|||||||
@@ -3,19 +3,20 @@ package com.sshmanager.service;
|
|||||||
import com.sshmanager.dto.ConnectionStatusCheckRequest;
|
import com.sshmanager.dto.ConnectionStatusCheckRequest;
|
||||||
import com.sshmanager.dto.ConnectionStatusResponseDto;
|
import com.sshmanager.dto.ConnectionStatusResponseDto;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.Mockito.doReturn;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -24,49 +25,128 @@ class ConnectionStatusServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private ConnectionService connectionService;
|
private ConnectionService connectionService;
|
||||||
|
|
||||||
@InjectMocks
|
@Mock
|
||||||
|
private TcpProbe tcpProbe;
|
||||||
|
|
||||||
private ConnectionStatusService connectionStatusService;
|
private ConnectionStatusService connectionStatusService;
|
||||||
|
|
||||||
@Test
|
@BeforeEach
|
||||||
void checkStatusesAggregatesOnlineAndOfflineResults() {
|
void setUp() {
|
||||||
Connection onlineConnection = new Connection();
|
connectionStatusService = new ConnectionStatusService(connectionService, tcpProbe);
|
||||||
onlineConnection.setId(1L);
|
}
|
||||||
onlineConnection.setUserId(99L);
|
|
||||||
onlineConnection.setName("prod");
|
|
||||||
|
|
||||||
Connection offlineConnection = new Connection();
|
@Test
|
||||||
offlineConnection.setId(2L);
|
void checkStatusesRejectsEmptyConnectionIds() {
|
||||||
offlineConnection.setUserId(99L);
|
ConnectionStatusCheckRequest request = new ConnectionStatusCheckRequest();
|
||||||
offlineConnection.setName("test");
|
IllegalArgumentException error = assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> connectionStatusService.checkStatuses(1L, request)
|
||||||
|
);
|
||||||
|
assertEquals("At least one connection is required", error.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkStatusesMarksReachableAsOnline() throws Exception {
|
||||||
|
Connection conn = new Connection();
|
||||||
|
conn.setId(1L);
|
||||||
|
conn.setUserId(99L);
|
||||||
|
conn.setName("reachable-host");
|
||||||
|
conn.setHost("10.0.0.1");
|
||||||
|
conn.setPort(22);
|
||||||
|
|
||||||
|
when(connectionService.getConnectionForSsh(1L, 99L)).thenReturn(conn);
|
||||||
|
|
||||||
ConnectionStatusCheckRequest request = new ConnectionStatusCheckRequest();
|
ConnectionStatusCheckRequest request = new ConnectionStatusCheckRequest();
|
||||||
request.setConnectionIds(Arrays.asList(1L, 2L));
|
request.setConnectionIds(Arrays.asList(1L));
|
||||||
|
|
||||||
when(connectionService.getConnectionForSsh(1L, 99L)).thenReturn(onlineConnection);
|
ConnectionStatusResponseDto response = connectionStatusService.checkStatuses(99L, request);
|
||||||
when(connectionService.getConnectionForSsh(2L, 99L)).thenReturn(offlineConnection);
|
|
||||||
doReturn(onlineConnection).when(connectionService).testConnection(eq(onlineConnection), eq(null), eq(null), eq(null));
|
assertEquals(1, response.getTotal());
|
||||||
doThrow(new RuntimeException("Connection refused")).when(connectionService).testConnection(eq(offlineConnection), eq(null), eq(null), eq(null));
|
assertEquals(1, response.getOnlineCount());
|
||||||
|
assertEquals(0, response.getOfflineCount());
|
||||||
|
assertEquals("online", response.getResults().get(0).getStatus());
|
||||||
|
assertEquals("TCP port reachable", response.getResults().get(0).getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkStatusesMarksUnreachableAsOffline() throws Exception {
|
||||||
|
Connection conn = new Connection();
|
||||||
|
conn.setId(2L);
|
||||||
|
conn.setUserId(99L);
|
||||||
|
conn.setName("unreachable-host");
|
||||||
|
conn.setHost("10.0.0.2");
|
||||||
|
conn.setPort(22);
|
||||||
|
|
||||||
|
when(connectionService.getConnectionForSsh(2L, 99L)).thenReturn(conn);
|
||||||
|
doAnswer(invocation -> { throw new RuntimeException("Connection refused"); })
|
||||||
|
.when(tcpProbe).checkReachable(conn, 5);
|
||||||
|
|
||||||
|
ConnectionStatusCheckRequest request = new ConnectionStatusCheckRequest();
|
||||||
|
request.setConnectionIds(Arrays.asList(2L));
|
||||||
|
|
||||||
|
ConnectionStatusResponseDto response = connectionStatusService.checkStatuses(99L, request);
|
||||||
|
|
||||||
|
assertEquals(1, response.getTotal());
|
||||||
|
assertEquals(0, response.getOnlineCount());
|
||||||
|
assertEquals(1, response.getOfflineCount());
|
||||||
|
assertEquals("offline", response.getResults().get(0).getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkStatusesAggregatesMixedResults() throws Exception {
|
||||||
|
Connection online = new Connection();
|
||||||
|
online.setId(10L);
|
||||||
|
online.setUserId(99L);
|
||||||
|
online.setName("server-a");
|
||||||
|
online.setHost("10.0.0.1");
|
||||||
|
online.setPort(22);
|
||||||
|
|
||||||
|
Connection offline = new Connection();
|
||||||
|
offline.setId(20L);
|
||||||
|
offline.setUserId(99L);
|
||||||
|
offline.setName("server-b");
|
||||||
|
offline.setHost("10.0.0.2");
|
||||||
|
offline.setPort(22);
|
||||||
|
|
||||||
|
when(connectionService.getConnectionForSsh(10L, 99L)).thenReturn(online);
|
||||||
|
when(connectionService.getConnectionForSsh(20L, 99L)).thenReturn(offline);
|
||||||
|
// Answer with argument matching based on connection ID
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
Connection c = invocation.getArgument(0);
|
||||||
|
if (c.getId() == 20L) throw new RuntimeException("Connection refused");
|
||||||
|
return null;
|
||||||
|
}).when(tcpProbe).checkReachable(any(Connection.class), anyInt());
|
||||||
|
|
||||||
|
ConnectionStatusCheckRequest request = new ConnectionStatusCheckRequest();
|
||||||
|
request.setConnectionIds(Arrays.asList(10L, 20L));
|
||||||
|
|
||||||
ConnectionStatusResponseDto response = connectionStatusService.checkStatuses(99L, request);
|
ConnectionStatusResponseDto response = connectionStatusService.checkStatuses(99L, request);
|
||||||
|
|
||||||
assertEquals(2, response.getTotal());
|
assertEquals(2, response.getTotal());
|
||||||
assertEquals(1, response.getOnlineCount());
|
assertEquals(1, response.getOnlineCount());
|
||||||
assertEquals(1, response.getOfflineCount());
|
assertEquals(1, response.getOfflineCount());
|
||||||
assertEquals("online", response.getResults().get(0).getStatus());
|
|
||||||
assertEquals("offline", response.getResults().get(1).getStatus());
|
|
||||||
assertEquals("prod", response.getResults().get(0).getConnectionName());
|
|
||||||
assertEquals("Connection refused", response.getResults().get(1).getMessage());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void checkStatusesRejectsEmptyConnectionIds() {
|
void checkStatusesHandlesAllOfflineGracefully() throws Exception {
|
||||||
|
Connection conn = new Connection();
|
||||||
|
conn.setId(99L);
|
||||||
|
conn.setUserId(99L);
|
||||||
|
conn.setName("fail-host");
|
||||||
|
conn.setHost("10.0.0.1");
|
||||||
|
conn.setPort(22);
|
||||||
|
|
||||||
|
when(connectionService.getConnectionForSsh(99L, 99L)).thenReturn(conn);
|
||||||
|
doAnswer(invocation -> { throw new RuntimeException("timeout"); })
|
||||||
|
.when(tcpProbe).checkReachable(conn, 5);
|
||||||
|
|
||||||
ConnectionStatusCheckRequest request = new ConnectionStatusCheckRequest();
|
ConnectionStatusCheckRequest request = new ConnectionStatusCheckRequest();
|
||||||
|
request.setConnectionIds(Collections.singletonList(99L));
|
||||||
|
|
||||||
IllegalArgumentException error = assertThrows(
|
ConnectionStatusResponseDto response = connectionStatusService.checkStatuses(99L, request);
|
||||||
IllegalArgumentException.class,
|
|
||||||
() -> connectionStatusService.checkStatuses(1L, request)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals("At least one connection is required", error.getMessage());
|
assertEquals(1, response.getTotal());
|
||||||
|
assertEquals(0, response.getOnlineCount());
|
||||||
|
assertEquals(1, response.getOfflineCount());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.jcraft.jsch.Session;
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link PortForwardRegistry}.
|
||||||
|
*
|
||||||
|
* <p>The JSch {@link Session} is mocked so no real SSH connections are made.
|
||||||
|
* Reflection is used to inject mock tunnels into the registry.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PortForwardRegistryTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private Session mockSession;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SshSessionFactory sshSessionFactory;
|
||||||
|
|
||||||
|
private PortForwardRegistry registry;
|
||||||
|
private Connection connection;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
registry = new PortForwardRegistry(sshSessionFactory);
|
||||||
|
connection = new Connection();
|
||||||
|
connection.setId(1L);
|
||||||
|
connection.setUserId(100L);
|
||||||
|
connection.setName("test-conn");
|
||||||
|
connection.setHost("example.com");
|
||||||
|
connection.setPort(22);
|
||||||
|
connection.setUsername("user");
|
||||||
|
connection.setAuthType(Connection.AuthType.PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private PortForwardRegistry.TunnelEntry addMockTunnel(Long userId, int localPort,
|
||||||
|
String remoteHost, int remotePort)
|
||||||
|
throws Exception {
|
||||||
|
String id = "test-id-" + localPort;
|
||||||
|
PortForwardRegistry.TunnelEntry entry = new PortForwardRegistry.TunnelEntry(
|
||||||
|
id, userId, connection.getId(), connection.getName(),
|
||||||
|
localPort, remoteHost, remotePort, mockSession);
|
||||||
|
|
||||||
|
Map<String, PortForwardRegistry.TunnelEntry> tunnels =
|
||||||
|
(Map<String, PortForwardRegistry.TunnelEntry>) ReflectionTestUtils.getField(registry, "tunnels");
|
||||||
|
if (tunnels != null) {
|
||||||
|
tunnels.put(id, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── validation tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_throwsOnInvalidLocalPort() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
registry.create(100L, connection, "pass", null, null, 0, "127.0.0.1", 3306));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_throwsOnPortAboveRange() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
registry.create(100L, connection, "pass", null, null, 70000, "127.0.0.1", 3306));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_throwsOnInvalidRemotePort() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
registry.create(100L, connection, "pass", null, null, 8080, "127.0.0.1", 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_throwsOnBlankRemoteHost() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
registry.create(100L, connection, "pass", null, null, 8080, " ", 3306));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_throwsOnNullRemoteHost() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
registry.create(100L, connection, "pass", null, null, 8080, null, 3306));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_success() throws Exception {
|
||||||
|
when(sshSessionFactory.createSession(any(), any(), any(), any()))
|
||||||
|
.thenReturn(mockSession);
|
||||||
|
when(mockSession.setPortForwardingL(anyInt(), anyString(), anyInt())).thenReturn(8080);
|
||||||
|
|
||||||
|
PortForwardRegistry.TunnelEntry entry = registry.create(100L, connection, "pass", null, null, 8080, "127.0.0.1", 3306);
|
||||||
|
|
||||||
|
assertNotNull(entry);
|
||||||
|
assertEquals(100L, entry.getUserId());
|
||||||
|
assertEquals(8080, entry.getLocalPort());
|
||||||
|
assertEquals("127.0.0.1", entry.getRemoteHost());
|
||||||
|
assertEquals(3306, entry.getRemotePort());
|
||||||
|
assertEquals(PortForwardRegistry.TunnelStatus.RUNNING, entry.getStatus());
|
||||||
|
verify(mockSession).setPortForwardingL(8080, "127.0.0.1", 3306);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TunnelEntry model tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tunnelEntry_initialStatusIsRunning() throws Exception {
|
||||||
|
PortForwardRegistry.TunnelEntry entry = addMockTunnel(100L, 8080, "127.0.0.1", 3306);
|
||||||
|
assertEquals(PortForwardRegistry.TunnelStatus.RUNNING, entry.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tunnelEntry_fieldsAreCorrectlySet() throws Exception {
|
||||||
|
PortForwardRegistry.TunnelEntry entry = addMockTunnel(100L, 8080, "db.internal", 5432);
|
||||||
|
assertEquals(100L, entry.getUserId());
|
||||||
|
assertEquals(8080, entry.getLocalPort());
|
||||||
|
assertEquals("db.internal", entry.getRemoteHost());
|
||||||
|
assertEquals(5432, entry.getRemotePort());
|
||||||
|
assertNotNull(entry.getCreatedAt());
|
||||||
|
assertNotNull(entry.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── stop tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_throwsOnUnknownId() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
registry.stop("nonexistent-id", 100L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_stopsActiveTunnel() throws Exception {
|
||||||
|
PortForwardRegistry.TunnelEntry entry = addMockTunnel(100L, 8080, "127.0.0.1", 3306);
|
||||||
|
when(mockSession.isConnected()).thenReturn(true);
|
||||||
|
|
||||||
|
registry.stop(entry.getId(), 100L);
|
||||||
|
|
||||||
|
verify(mockSession).delPortForwardingL(8080);
|
||||||
|
verify(mockSession).disconnect();
|
||||||
|
assertEquals(PortForwardRegistry.TunnelStatus.STOPPED, entry.getStatus());
|
||||||
|
assertTrue(registry.listByUser(100L).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_throwsOnAccessDenied() throws Exception {
|
||||||
|
PortForwardRegistry.TunnelEntry entry = addMockTunnel(100L, 8080, "127.0.0.1", 3306);
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
registry.stop(entry.getId(), 200L)); // wrong user
|
||||||
|
|
||||||
|
assertEquals(PortForwardRegistry.TunnelStatus.RUNNING, entry.getStatus());
|
||||||
|
verify(mockSession, never()).disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── listByUser ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listByUser_returnsEmptyWhenNone() {
|
||||||
|
List<PortForwardRegistry.TunnelEntry> list = registry.listByUser(100L);
|
||||||
|
assertNotNull(list);
|
||||||
|
assertTrue(list.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listByUser_returnsOnlyUserTunnels() throws Exception {
|
||||||
|
PortForwardRegistry.TunnelEntry e1 = addMockTunnel(100L, 8080, "127.0.0.1", 3306);
|
||||||
|
PortForwardRegistry.TunnelEntry e2 = addMockTunnel(100L, 8081, "127.0.0.1", 5432);
|
||||||
|
PortForwardRegistry.TunnelEntry e3 = addMockTunnel(200L, 8082, "127.0.0.1", 6379);
|
||||||
|
|
||||||
|
List<PortForwardRegistry.TunnelEntry> user100Tunnels = registry.listByUser(100L);
|
||||||
|
assertEquals(2, user100Tunnels.size());
|
||||||
|
assertTrue(user100Tunnels.contains(e1));
|
||||||
|
assertTrue(user100Tunnels.contains(e2));
|
||||||
|
assertFalse(user100Tunnels.contains(e3));
|
||||||
|
|
||||||
|
List<PortForwardRegistry.TunnelEntry> user200Tunnels = registry.listByUser(200L);
|
||||||
|
assertEquals(1, user200Tunnels.size());
|
||||||
|
assertTrue(user200Tunnels.contains(e3));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link QuickConnectionRegistry}.
|
||||||
|
*
|
||||||
|
* <p>These tests exercise the core lifecycle (create / get / remove) and the TTL
|
||||||
|
* eviction logic without starting a Spring context.
|
||||||
|
*/
|
||||||
|
class QuickConnectionRegistryTest {
|
||||||
|
|
||||||
|
private QuickConnectionRegistry registry;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
registry = new QuickConnectionRegistry();
|
||||||
|
// Default TTL: 30 minutes (not relevant for most tests — overridden where needed)
|
||||||
|
ReflectionTestUtils.setField(registry, "ttlMinutes", 30L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── basic lifecycle ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_returnsConnectionWithCorrectFields() {
|
||||||
|
Connection conn = registry.create("192.168.1.1", "admin", 22, 1L);
|
||||||
|
|
||||||
|
assertNotNull(conn.getId());
|
||||||
|
assertTrue(conn.getId() >= 10_000_001L, "ID should be in quick-connect namespace");
|
||||||
|
assertEquals("192.168.1.1", conn.getHost());
|
||||||
|
assertEquals("admin", conn.getUsername());
|
||||||
|
assertEquals(22, conn.getPort());
|
||||||
|
assertEquals(1L, conn.getUserId());
|
||||||
|
assertEquals(Connection.AuthType.PASSWORD, conn.getAuthType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_returnsConnectionAfterCreate() {
|
||||||
|
Connection conn = registry.create("host", "user", 22, 1L);
|
||||||
|
|
||||||
|
Connection found = registry.get(conn.getId());
|
||||||
|
assertNotNull(found);
|
||||||
|
assertEquals(conn.getId(), found.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_returnsNullForUnknownId() {
|
||||||
|
assertNull(registry.get(999_999L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void remove_deletesEntry() {
|
||||||
|
Connection conn = registry.create("host", "user", 22, 1L);
|
||||||
|
registry.remove(conn.getId());
|
||||||
|
|
||||||
|
assertNull(registry.get(conn.getId()));
|
||||||
|
assertEquals(0, registry.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistentConnections_areNotAffectedByRegistry() {
|
||||||
|
// Registry only holds quick connections; a regular DB ID (e.g. 1) is never stored here
|
||||||
|
assertNull(registry.get(1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multipleEntries_areStoredIndependently() {
|
||||||
|
Connection a = registry.create("host-a", "ua", 22, 1L);
|
||||||
|
Connection b = registry.create("host-b", "ub", 2222, 2L);
|
||||||
|
|
||||||
|
assertEquals(2, registry.size());
|
||||||
|
assertNotNull(registry.get(a.getId()));
|
||||||
|
assertNotNull(registry.get(b.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TTL eviction ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evictExpired_removesEntriesOlderThanTtl() throws Exception {
|
||||||
|
// Set a 0-minute TTL so every entry is immediately "expired"
|
||||||
|
ReflectionTestUtils.setField(registry, "ttlMinutes", 0L);
|
||||||
|
|
||||||
|
registry.create("host", "user", 22, 1L);
|
||||||
|
assertEquals(1, registry.size());
|
||||||
|
|
||||||
|
// Small delay so lastAccessAt < now - ttl (ttl = 0 ms)
|
||||||
|
Thread.sleep(5);
|
||||||
|
registry.evictExpired();
|
||||||
|
|
||||||
|
assertEquals(0, registry.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evictExpired_keepsRecentEntries() {
|
||||||
|
// TTL = 30 min — newly created entries should survive
|
||||||
|
registry.create("host", "user", 22, 1L);
|
||||||
|
registry.evictExpired();
|
||||||
|
|
||||||
|
assertEquals(1, registry.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void touch_preventsEviction() throws Exception {
|
||||||
|
// 1-ms TTL would normally evict immediately
|
||||||
|
ReflectionTestUtils.setField(registry, "ttlMinutes", 0L);
|
||||||
|
|
||||||
|
Connection conn = registry.create("host", "user", 22, 1L);
|
||||||
|
|
||||||
|
// Touch resets lastAccessAt to now — entry should survive one sweep cycle
|
||||||
|
// when the sweep happens within the same millisecond
|
||||||
|
registry.touch(conn.getId());
|
||||||
|
|
||||||
|
// We cannot guarantee sub-millisecond execution, so just verify touch
|
||||||
|
// doesn't throw and the entry is still accessible right after touch.
|
||||||
|
assertNotNull(registry.get(conn.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void touch_onUnknownId_doesNotThrow() {
|
||||||
|
assertDoesNotThrow(() -> registry.touch(999_999L));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── id namespace ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consecutiveCreates_produceIncreasingIds() {
|
||||||
|
Connection first = registry.create("h", "u", 22, 1L);
|
||||||
|
Connection second = registry.create("h", "u", 22, 1L);
|
||||||
|
|
||||||
|
assertTrue(second.getId() > first.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -5,10 +5,10 @@ FROM node:20-alpine AS frontend
|
|||||||
COPY docker/.npmrc /root/.npmrc
|
COPY docker/.npmrc /root/.npmrc
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY frontend-v2/package.json frontend-v2/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci --prefer-offline --no-audit
|
RUN npm ci --prefer-offline --no-audit
|
||||||
|
|
||||||
COPY frontend-v2/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ========== 阶段二:后端构建(国内 Maven 源) ==========
|
# ========== 阶段二:后端构建(国内 Maven 源) ==========
|
||||||
|
|||||||
@@ -1,38 +1,101 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||||
<!-- 背景层:深色圆角矩形与发光效果 -->
|
|
||||||
<defs>
|
<defs>
|
||||||
|
<!-- Background Gradient -->
|
||||||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stop-color="#0f172a" />
|
<stop offset="0%" stop-color="#0b1329" />
|
||||||
|
<stop offset="60%" stop-color="#040814" />
|
||||||
|
<stop offset="100%" stop-color="#02040a" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Border Gradient -->
|
||||||
|
<linearGradient id="borderGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#1e293b" />
|
||||||
|
<stop offset="50%" stop-color="#0f172a" />
|
||||||
<stop offset="100%" stop-color="#020617" />
|
<stop offset="100%" stop-color="#020617" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feGaussianBlur stdDeviation="15" result="blur" />
|
<!-- Terminal Border Gradient -->
|
||||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
<linearGradient id="termBorder" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#22d3ee" />
|
||||||
|
<stop offset="50%" stop-color="#3b82f6" />
|
||||||
|
<stop offset="100%" stop-color="#6366f1" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Key Gradient -->
|
||||||
|
<linearGradient id="keyGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#22d3ee" />
|
||||||
|
<stop offset="40%" stop-color="#0ea5e9" />
|
||||||
|
<stop offset="100%" stop-color="#2563eb" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Subtle Glow for Terminal -->
|
||||||
|
<filter id="termGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="12" result="blur" />
|
||||||
|
<feComponentTransfer in="blur" result="boost">
|
||||||
|
<feFuncA type="linear" slope="0.3"/>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="boost" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
<linearGradient id="primaryGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.6" />
|
<!-- 3D Shadow for the Key -->
|
||||||
<stop offset="100%" stop-color="#10b981" stop-opacity="0.3" />
|
<filter id="keyShadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feDropShadow dx="-6" dy="8" stdDeviation="6" flood-color="#020617" flood-opacity="0.8"/>
|
||||||
|
<feDropShadow dx="-2" dy="3" stdDeviation="3" flood-color="#06b6d4" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<!-- Glare Gradient -->
|
||||||
|
<linearGradient id="glareGrad" x1="0%" y1="0%" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.08" />
|
||||||
|
<stop offset="40%" stop-color="#ffffff" stop-opacity="0.03" />
|
||||||
|
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- 外部圆角底框 -->
|
<!-- Outer Squircle App Icon Base -->
|
||||||
<rect x="32" y="32" width="448" height="448" rx="100" ry="100" fill="url(#bgGradient)" stroke="#1e293b" stroke-width="8" />
|
<rect x="24" y="24" width="464" height="464" rx="120" ry="120" fill="url(#bgGradient)" stroke="url(#borderGradient)" stroke-width="6" />
|
||||||
|
|
||||||
<!-- 内部光晕点缀 -->
|
<!-- Inner Soft Ambient Light (Top-Left glow) -->
|
||||||
<circle cx="256" cy="256" r="180" fill="url(#primaryGlow)" filter="url(#glow)" />
|
<path d="M 24 144 A 120 120 0 0 1 144 24 L 300 24 C 200 80 80 200 24 300 Z" fill="#22d3ee" fill-opacity="0.02" />
|
||||||
<rect x="64" y="64" width="384" height="384" rx="80" ry="80" fill="#0f172a" opacity="0.85" />
|
|
||||||
|
|
||||||
<!-- 终端符号: >_ -->
|
<!-- Terminal Window Console Card -->
|
||||||
<g transform="translate(130, 160)" stroke-linecap="round" stroke-linejoin="round">
|
<g filter="url(#termGlow)">
|
||||||
<!-- 箭头 > -->
|
<!-- Terminal Outer Frame -->
|
||||||
<path d="M 20 20 L 120 90 L 20 160" fill="none" stroke="#3b82f6" stroke-width="40" />
|
<rect x="110" y="130" width="260" height="230" rx="36" ry="36" fill="#030712" stroke="url(#termBorder)" stroke-width="4.5" />
|
||||||
|
|
||||||
|
<!-- Glare reflection on terminal glass -->
|
||||||
|
<path d="M 112 166 Q 240 132 368 166 A 34 34 0 0 1 368 180 L 220 358 A 34 34 0 0 1 112 300 Z" fill="url(#glareGrad)" />
|
||||||
|
|
||||||
<!-- 下划线 _ -->
|
<!-- Terminal Header Dots -->
|
||||||
<line x1="140" y1="180" x2="240" y2="180" stroke="#10b981" stroke-width="36" />
|
<circle cx="150" cy="165" r="7" fill="#ef4444" fill-opacity="0.75" />
|
||||||
|
<circle cx="175" cy="165" r="7" fill="#f59e0b" fill-opacity="0.75" />
|
||||||
|
<circle cx="200" cy="165" r="7" fill="#10b981" fill-opacity="0.75" />
|
||||||
|
|
||||||
|
<!-- Host text simulation -->
|
||||||
|
<text x="230" y="169" fill="#94a3b8" fill-opacity="0.4" font-family="monospace" font-size="11" letter-spacing="1">ssh-session</text>
|
||||||
|
|
||||||
|
<!-- Terminal Prompts >_ -->
|
||||||
|
<g transform="translate(150, 210)">
|
||||||
|
<!-- Arrow > -->
|
||||||
|
<path d="M 10 20 L 40 45 L 10 70" fill="none" stroke="#22d3ee" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<!-- Cursor _ -->
|
||||||
|
<rect x="55" y="60" width="30" height="10" rx="2" fill="#34d399" />
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- 顶部状态指示灯 (红黄绿) -->
|
<!-- Security Key Overlap -->
|
||||||
<circle cx="110" cy="110" r="12" fill="#ef4444" />
|
<g transform="translate(315, 235) rotate(-45)" filter="url(#keyShadow)">
|
||||||
<circle cx="150" cy="110" r="12" fill="#eab308" />
|
<!-- Shaft of Key -->
|
||||||
<circle cx="190" cy="110" r="12" fill="#10b981" />
|
<path d="M -20 -7 L -175 -7 C -180 -7 -180 7 -175 7 L -20 7 Z" fill="url(#keyGrad)" />
|
||||||
|
|
||||||
|
<!-- Key Head (Terminal screen hole shape) -->
|
||||||
|
<circle cx="20" cy="0" r="45" fill="none" stroke="url(#keyGrad)" stroke-width="14" />
|
||||||
|
<circle cx="20" cy="0" r="14" fill="none" stroke="url(#keyGrad)" stroke-width="8" />
|
||||||
|
|
||||||
|
<!-- Key Teeth -->
|
||||||
|
<path d="M -115 5 L -115 32 A 4 4 0 0 0 -111 36 L -101 36 A 4 4 0 0 0 -97 32 L -97 5 Z" fill="url(#keyGrad)" />
|
||||||
|
<path d="M -150 5 L -150 32 A 4 4 0 0 0 -146 36 L -136 36 A 4 4 0 0 0 -132 32 L -132 5 Z" fill="url(#keyGrad)" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,293 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Network,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
StopCircle,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import {
|
||||||
|
createPortForward,
|
||||||
|
listPortForwards,
|
||||||
|
stopPortForward,
|
||||||
|
type PortForwardTunnel,
|
||||||
|
} from '../services/portForwards'
|
||||||
|
import type { Connection } from '../types'
|
||||||
|
|
||||||
|
interface PortForwardModalProps {
|
||||||
|
open: boolean
|
||||||
|
connections: Connection[]
|
||||||
|
initialConnectionId?: number | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortForwardModal({
|
||||||
|
open,
|
||||||
|
connections,
|
||||||
|
initialConnectionId,
|
||||||
|
onClose,
|
||||||
|
}: PortForwardModalProps) {
|
||||||
|
const [tunnels, setTunnels] = useState<PortForwardTunnel[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [connectionId, setConnectionId] = useState<number>(0)
|
||||||
|
const [localPort, setLocalPort] = useState<number>(8080)
|
||||||
|
const [remoteHost, setRemoteHost] = useState<string>('127.0.0.1')
|
||||||
|
const [remotePort, setRemotePort] = useState<number>(80)
|
||||||
|
|
||||||
|
// Load tunnels
|
||||||
|
const fetchTunnels = async (showLoading = true) => {
|
||||||
|
if (showLoading) setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await listPortForwards()
|
||||||
|
setTunnels(data)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message ?? '获取端口转发列表失败')
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchTunnels(true)
|
||||||
|
// Pre-select connection if provided
|
||||||
|
if (initialConnectionId) {
|
||||||
|
setConnectionId(initialConnectionId)
|
||||||
|
} else if (connections.length > 0) {
|
||||||
|
setConnectionId(connections[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, initialConnectionId, connections])
|
||||||
|
|
||||||
|
const handleStartTunnel = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!connectionId) {
|
||||||
|
setError('请选择 SSH 连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await createPortForward({
|
||||||
|
connectionId,
|
||||||
|
localPort,
|
||||||
|
remoteHost: remoteHost.trim(),
|
||||||
|
remotePort,
|
||||||
|
})
|
||||||
|
// Reset form options (keep connection, maybe increment local port)
|
||||||
|
setLocalPort((prev) => prev + 1)
|
||||||
|
await fetchTunnels(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message ?? '启动端口转发失败')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStopTunnel = async (id: string) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await stopPortForward(id)
|
||||||
|
await fetchTunnels(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message ?? '停止端口转发失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="端口转发管理"
|
||||||
|
onClose={onClose}
|
||||||
|
open={open}
|
||||||
|
maxWidth="max-w-5xl"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6 md:flex-row">
|
||||||
|
{/* Left Column: Form to create a new tunnel */}
|
||||||
|
<div className="flex-1 rounded-2xl border border-border-main bg-surface-muted/30 p-5 md:max-w-xs lg:max-w-sm">
|
||||||
|
<h4 className="mb-4 flex items-center gap-2 text-sm font-semibold text-content-main">
|
||||||
|
<Play size={16} className="text-emerald-500" />
|
||||||
|
新建端口转发通道
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<form onSubmit={handleStartTunnel} className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-content-muted">
|
||||||
|
SSH 连接通道
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={connectionId}
|
||||||
|
onChange={(e) => setConnectionId(Number(e.target.value))}
|
||||||
|
className="w-full rounded-xl border border-border-main bg-surface-control px-3.5 py-2 text-sm text-content-main focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||||
|
>
|
||||||
|
{connections.length === 0 ? (
|
||||||
|
<option value={0}>暂无可用连接</option>
|
||||||
|
) : (
|
||||||
|
connections.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.host})
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-content-muted">
|
||||||
|
本地侦听端口 (Local Port)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={localPort}
|
||||||
|
onChange={(e) => setLocalPort(Number(e.target.value))}
|
||||||
|
className="w-full rounded-xl border border-border-main bg-surface-control px-3.5 py-2 text-sm text-content-main focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="col-span-2 flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-content-muted">
|
||||||
|
目标主机 (Remote Host)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={remoteHost}
|
||||||
|
onChange={(e) => setRemoteHost(e.target.value)}
|
||||||
|
className="w-full rounded-xl border border-border-main bg-surface-control px-3.5 py-2 text-sm text-content-main focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-content-muted">
|
||||||
|
目标端口
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={remotePort}
|
||||||
|
onChange={(e) => setRemotePort(Number(e.target.value))}
|
||||||
|
className="w-full rounded-xl border border-border-main bg-surface-control px-3.5 py-2 text-sm text-content-main focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex gap-2 rounded-xl bg-red-500/10 p-3 text-xs text-red-300 border border-red-500/20">
|
||||||
|
<AlertCircle size={16} className="shrink-0 text-red-400" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || connections.length === 0}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 py-2.5 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{submitting ? '正在启动...' : '启动转发'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: List of running tunnels */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-content-main">
|
||||||
|
<Network size={16} className="text-blue-500 animate-pulse" />
|
||||||
|
活动转发列表
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchTunnels(true)}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-xl border border-border-main bg-surface-muted p-2 text-content-muted transition hover:text-content-main hover:bg-surface-panel"
|
||||||
|
title="刷新列表"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto max-h-[400px] rounded-2xl border border-border-main bg-surface-panel/40 p-4">
|
||||||
|
{loading && tunnels.length === 0 ? (
|
||||||
|
<div className="flex h-48 items-center justify-center text-xs text-content-dim">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
) : tunnels.length === 0 ? (
|
||||||
|
<div className="flex h-48 flex-col items-center justify-center text-content-dim">
|
||||||
|
<Network size={36} className="mb-2 text-content-dim/30" />
|
||||||
|
<span className="text-xs">暂无活动的端口转发通道</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tunnels.map((tunnel) => (
|
||||||
|
<div
|
||||||
|
key={tunnel.id}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-border-subtle bg-surface-card p-3.5 transition hover:border-border-main"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-content-main">
|
||||||
|
<span className="max-w-[120px] truncate text-blue-400">
|
||||||
|
{tunnel.connectionName}
|
||||||
|
</span>
|
||||||
|
<span className="text-content-dim font-normal">|</span>
|
||||||
|
<span className="text-emerald-500 font-mono">
|
||||||
|
:{tunnel.localPort}
|
||||||
|
</span>
|
||||||
|
<span className="text-content-dim font-normal">→</span>
|
||||||
|
<span className="text-content-muted font-mono">
|
||||||
|
{tunnel.remoteHost}:{tunnel.remotePort}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-content-dim">
|
||||||
|
<span>
|
||||||
|
启动时间: {new Date(tunnel.createdAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
<span className="h-1 w-1 rounded-full bg-content-dim" />
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
tunnel.status === 'running'
|
||||||
|
? 'bg-emerald-500 animate-pulse'
|
||||||
|
: tunnel.status === 'error'
|
||||||
|
? 'bg-red-500'
|
||||||
|
: 'bg-content-muted'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{tunnel.status === 'running'
|
||||||
|
? '运行中'
|
||||||
|
: tunnel.status === 'error'
|
||||||
|
? '异常'
|
||||||
|
: '已停止'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleStopTunnel(tunnel.id)}
|
||||||
|
className="self-end sm:self-center flex items-center gap-1 rounded-lg border border-red-500/20 bg-red-500/5 px-2 py-1 text-[11px] font-medium text-red-300 transition hover:bg-red-500/10 hover:text-red-200"
|
||||||
|
>
|
||||||
|
<StopCircle size={12} />
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
|
Network,
|
||||||
Settings,
|
Settings,
|
||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -61,6 +62,7 @@ import SettingsModal from '../components/SettingsModal'
|
|||||||
import SftpPane from '../components/SftpPane'
|
import SftpPane from '../components/SftpPane'
|
||||||
import TerminalPane from '../components/TerminalPane'
|
import TerminalPane from '../components/TerminalPane'
|
||||||
import TransferCenterModal from '../components/TransferCenterModal'
|
import TransferCenterModal from '../components/TransferCenterModal'
|
||||||
|
import PortForwardModal from '../components/PortForwardModal'
|
||||||
|
|
||||||
const terminalStatusCopy: Record<TerminalConnectionStatus, { label: string; tone: string; dot: string }> = {
|
const terminalStatusCopy: Record<TerminalConnectionStatus, { label: string; tone: string; dot: string }> = {
|
||||||
idle: { label: '终端未打开', tone: 'text-content-muted', dot: 'bg-content-muted' },
|
idle: { label: '终端未打开', tone: 'text-content-muted', dot: 'bg-content-muted' },
|
||||||
@@ -254,6 +256,8 @@ export default function WorkspacePage({
|
|||||||
const [showBatchModal, setShowBatchModal] = useState(false)
|
const [showBatchModal, setShowBatchModal] = useState(false)
|
||||||
const [showTransferModal, setShowTransferModal] = useState(initialTool === 'transfers')
|
const [showTransferModal, setShowTransferModal] = useState(initialTool === 'transfers')
|
||||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||||
|
const [showPortForwardModal, setShowPortForwardModal] = useState(false)
|
||||||
|
const [portForwardInitialConnId, setPortForwardInitialConnId] = useState<number | null>(null)
|
||||||
const [quickTokens, setQuickTokens] = useState<Record<string, string>>({})
|
const [quickTokens, setQuickTokens] = useState<Record<string, string>>({})
|
||||||
const [showChangePassword, setShowChangePassword] = useState<boolean>(!!user?.passwordChangeRequired)
|
const [showChangePassword, setShowChangePassword] = useState<boolean>(!!user?.passwordChangeRequired)
|
||||||
const [transferTasks, setTransferTasks] = useState<TransferTaskGroup[]>([])
|
const [transferTasks, setTransferTasks] = useState<TransferTaskGroup[]>([])
|
||||||
@@ -740,6 +744,16 @@ export default function WorkspacePage({
|
|||||||
<FileUp size={16} className="text-blue-400" />
|
<FileUp size={16} className="text-blue-400" />
|
||||||
传输中心
|
传输中心
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-content-muted transition hover:bg-surface-muted hover:text-content-main"
|
||||||
|
onClick={() => {
|
||||||
|
setPortForwardInitialConnId(null)
|
||||||
|
setShowPortForwardModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Network size={16} className="text-emerald-400" />
|
||||||
|
端口转发
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-content-muted transition hover:bg-surface-muted hover:text-content-main"
|
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-content-muted transition hover:bg-surface-muted hover:text-content-main"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -1097,6 +1111,12 @@ export default function WorkspacePage({
|
|||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
onDarkModeChange={setDarkMode}
|
onDarkModeChange={setDarkMode}
|
||||||
/>
|
/>
|
||||||
|
<PortForwardModal
|
||||||
|
open={showPortForwardModal}
|
||||||
|
connections={connections}
|
||||||
|
initialConnectionId={portForwardInitialConnId}
|
||||||
|
onClose={() => setShowPortForwardModal(false)}
|
||||||
|
/>
|
||||||
{showChangePassword ? <ChangePasswordModal force={!!user?.passwordChangeRequired} onClose={() => setShowChangePassword(false)} /> : null}
|
{showChangePassword ? <ChangePasswordModal force={!!user?.passwordChangeRequired} onClose={() => setShowChangePassword(false)} /> : null}
|
||||||
{treeContextMenu.visible ? (
|
{treeContextMenu.visible ? (
|
||||||
<div className="fixed inset-0 z-50" onClick={closeTreeContextMenu}>
|
<div className="fixed inset-0 z-50" onClick={closeTreeContextMenu}>
|
||||||
@@ -1127,7 +1147,23 @@ export default function WorkspacePage({
|
|||||||
新建文件夹
|
新建文件夹
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-content-main transition hover:bg-surface-muted hover:text-content-main"
|
||||||
|
onClick={() => {
|
||||||
|
const targetId = treeContextMenu.targetId
|
||||||
|
closeTreeContextMenu()
|
||||||
|
if (!targetId) return
|
||||||
|
const node = findTreeNode(targetId)
|
||||||
|
if (node && node.type === 'connection' && node.connectionId) {
|
||||||
|
setPortForwardInitialConnId(node.connectionId)
|
||||||
|
setShowPortForwardModal(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
端口转发
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-content-main transition hover:bg-surface-muted hover:text-content-main"
|
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-content-main transition hover:bg-surface-muted hover:text-content-main"
|
||||||
onClick={() => void handleEditTreeItem()}
|
onClick={() => void handleEditTreeItem()}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import http from './http'
|
||||||
|
|
||||||
|
export interface PortForwardTunnel {
|
||||||
|
id: string
|
||||||
|
connectionId: number
|
||||||
|
connectionName: string
|
||||||
|
localPort: number
|
||||||
|
remoteHost: string
|
||||||
|
remotePort: number
|
||||||
|
status: 'running' | 'stopped' | 'error'
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePortForwardRequest {
|
||||||
|
connectionId: number
|
||||||
|
localPort: number
|
||||||
|
remoteHost: string
|
||||||
|
remotePort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPortForwards(): Promise<PortForwardTunnel[]> {
|
||||||
|
const res = await http.get<PortForwardTunnel[]>('/port-forwards')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPortForward(req: CreatePortForwardRequest): Promise<PortForwardTunnel> {
|
||||||
|
try {
|
||||||
|
const res = await http.post<PortForwardTunnel>('/port-forwards', req)
|
||||||
|
return res.data
|
||||||
|
} catch (err: any) {
|
||||||
|
const errMsg = err.response?.data?.error ?? err.message ?? '启动端口转发失败'
|
||||||
|
throw new Error(errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopPortForward(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await http.delete(`/port-forwards/${id}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
const errMsg = err.response?.data?.error ?? err.message ?? '停止端口转发失败'
|
||||||
|
throw new Error(errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user