diff --git a/backend/src/main/java/com/sshmanager/controller/TerminalControlMessage.java b/backend/src/main/java/com/sshmanager/controller/TerminalControlMessage.java new file mode 100644 index 0000000..7457aa2 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/controller/TerminalControlMessage.java @@ -0,0 +1,53 @@ +package com.sshmanager.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.Optional; + +public class TerminalControlMessage { + + public static final String CONTROL_PREFIX = "__SSHMANAGER__:"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private String type; + private Integer cols; + private Integer rows; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Integer getCols() { + return cols; + } + + public void setCols(Integer cols) { + this.cols = cols; + } + + public Integer getRows() { + return rows; + } + + public void setRows(Integer rows) { + this.rows = rows; + } + + public static Optional parse(String payload) { + if (payload == null || !payload.startsWith(CONTROL_PREFIX)) { + return Optional.empty(); + } + + String json = payload.substring(CONTROL_PREFIX.length()); + try { + return Optional.of(OBJECT_MAPPER.readValue(json, TerminalControlMessage.class)); + } catch (Exception e) { + return Optional.empty(); + } + } +} diff --git a/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java b/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java index 209fe61..97a5c8b 100644 --- a/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java +++ b/backend/src/main/java/com/sshmanager/controller/TerminalWebSocketHandler.java @@ -100,15 +100,26 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler { } } - @Override - protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception { - SshService.SshSession sshSession = sessions.get(webSocketSession.getId()); - if (sshSession != null && sshSession.isConnected()) { - lastActivity.put(webSocketSession.getId(), System.currentTimeMillis()); - sshSession.getInputStream().write(message.asBytes()); - sshSession.getInputStream().flush(); - } - } + @Override + protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception { + SshService.SshSession sshSession = sessions.get(webSocketSession.getId()); + if (sshSession != null && sshSession.isConnected()) { + lastActivity.put(webSocketSession.getId(), System.currentTimeMillis()); + + String payload = message.getPayload(); + TerminalControlMessage.parse(payload).ifPresent(ctrl -> { + if ("resize".equals(ctrl.getType()) && ctrl.getCols() != null && ctrl.getRows() != null) { + sshSession.resize(ctrl.getCols(), ctrl.getRows()); + } + }); + if (payload != null && payload.startsWith(TerminalControlMessage.CONTROL_PREFIX)) { + return; + } + + sshSession.getInputStream().write(message.asBytes()); + sshSession.getInputStream().flush(); + } + } @Override public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception { diff --git a/backend/src/main/java/com/sshmanager/service/SshService.java b/backend/src/main/java/com/sshmanager/service/SshService.java index 5ef51af..b3b5177 100644 --- a/backend/src/main/java/com/sshmanager/service/SshService.java +++ b/backend/src/main/java/com/sshmanager/service/SshService.java @@ -85,14 +85,27 @@ public class SshService { return outputStream; } - public OutputStream getInputStream() { - return inputStream; - } + public OutputStream getInputStream() { + return inputStream; + } + + public void resize(int cols, int rows) { + if (cols <= 0 || rows <= 0) { + return; + } + if (channel == null) { + return; + } + try { + channel.setPtySize(cols, rows, 0, 0); + } catch (Exception ignored) { + } + } - public void disconnect() { - if (channel != null && channel.isConnected()) { - channel.disconnect(); - } + public void disconnect() { + if (channel != null && channel.isConnected()) { + channel.disconnect(); + } if (session != null && session.isConnected()) { session.disconnect(); } diff --git a/backend/src/test/java/com/sshmanager/controller/SftpControllerTest.java b/backend/src/test/java/com/sshmanager/controller/SftpControllerTest.java index 496d858..374e461 100644 --- a/backend/src/test/java/com/sshmanager/controller/SftpControllerTest.java +++ b/backend/src/test/java/com/sshmanager/controller/SftpControllerTest.java @@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.doThrow; @@ -86,8 +87,8 @@ class SftpControllerTest { assertEquals(HttpStatus.OK, response.getStatusCode()); assertEquals("Transferred", response.getBody().get("message")); - verify(sftpService).rename(session, "/src/file.txt", "/dst/file.txt"); - verify(sftpService, never()).transferRemote(any(), any(), any(), any()); + verify(sftpService).transferRemote(eq(session), eq("/src/file.txt"), eq(session), eq("/dst/file.txt"), any(SftpService.TransferProgressListener.class)); + verify(sftpService, never()).rename(any(), any(), any()); } @Test @@ -109,7 +110,7 @@ class SftpControllerTest { assertEquals(HttpStatus.OK, response.getStatusCode()); assertEquals("Transferred", response.getBody().get("message")); - verify(sftpService).transferRemote(sourceSession, "/src/file.txt", targetSession, "/dst/file.txt"); + verify(sftpService).transferRemote(eq(sourceSession), eq("/src/file.txt"), eq(targetSession), eq("/dst/file.txt"), any(SftpService.TransferProgressListener.class)); verify(sftpService, never()).rename(any(), any(), any()); } @@ -118,7 +119,7 @@ class SftpControllerTest { when(connectionService.getConnectionForSsh(anyLong(), eq(1L))).thenReturn(new Connection()); SftpService.SftpSession session = connectedSession(true); when(sftpService.connect(any(Connection.class), any(), any(), any())).thenReturn(session); - doThrow(new RuntimeException("boom")).when(sftpService).rename(any(), any(), any()); + doThrow(new RuntimeException("boom")).when(sftpService).transferRemote(any(), anyString(), any(), anyString(), any(SftpService.TransferProgressListener.class)); ResponseEntity> response = sftpController.transferRemote( 3L, diff --git a/backend/src/test/java/com/sshmanager/controller/TerminalControlMessageTest.java b/backend/src/test/java/com/sshmanager/controller/TerminalControlMessageTest.java new file mode 100644 index 0000000..b944453 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/controller/TerminalControlMessageTest.java @@ -0,0 +1,38 @@ +package com.sshmanager.controller; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TerminalControlMessageTest { + + @Test + void parseReturnsResizeMessageWhenPayloadHasControlPrefix() { + String payload = TerminalControlMessage.CONTROL_PREFIX + + "{\"type\":\"resize\",\"cols\":120,\"rows\":40}"; + + Optional parsed = TerminalControlMessage.parse(payload); + + assertTrue(parsed.isPresent()); + assertEquals("resize", parsed.get().getType()); + assertEquals(120, parsed.get().getCols()); + assertEquals(40, parsed.get().getRows()); + } + + @Test + void parseReturnsEmptyForNonControlPayload() { + Optional parsed = TerminalControlMessage.parse("ls -la\n"); + assertFalse(parsed.isPresent()); + } + + @Test + void parseReturnsEmptyForInvalidJson() { + String payload = TerminalControlMessage.CONTROL_PREFIX + "not-json"; + Optional parsed = TerminalControlMessage.parse(payload); + assertFalse(parsed.isPresent()); + } +} diff --git a/backend/src/test/java/com/sshmanager/controller/TerminalWebSocketHandlerTest.java b/backend/src/test/java/com/sshmanager/controller/TerminalWebSocketHandlerTest.java new file mode 100644 index 0000000..00c6214 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/controller/TerminalWebSocketHandlerTest.java @@ -0,0 +1,63 @@ +package com.sshmanager.controller; + +import com.sshmanager.repository.ConnectionRepository; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.service.ConnectionService; +import com.sshmanager.service.SshService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.lang.reflect.Field; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TerminalWebSocketHandlerTest { + + @Test + void handleTextMessageAppliesResizeControlMessageWithoutForwardingToShell() throws Exception { + ConnectionRepository connectionRepository = mock(ConnectionRepository.class); + UserRepository userRepository = mock(UserRepository.class); + ConnectionService connectionService = mock(ConnectionService.class); + SshService sshService = mock(SshService.class); + ExecutorService executor = mock(ExecutorService.class); + + TerminalWebSocketHandler handler = new TerminalWebSocketHandler( + connectionRepository, + userRepository, + connectionService, + sshService, + executor + ); + + WebSocketSession ws = mock(WebSocketSession.class); + when(ws.getId()).thenReturn("s1"); + + SshService.SshSession sshSession = mock(SshService.SshSession.class); + when(sshSession.isConnected()).thenReturn(true); + Map sessions = sessionsMap(handler); + sessions.put("s1", sshSession); + + String payload = TerminalControlMessage.CONTROL_PREFIX + + "{\"type\":\"resize\",\"cols\":120,\"rows\":40}"; + handler.handleTextMessage(ws, new TextMessage(payload)); + + verify(sshSession).resize(120, 40); + verify(sshSession, never()).getInputStream(); + } + + @SuppressWarnings("unchecked") + private Map sessionsMap(TerminalWebSocketHandler handler) throws Exception { + Field f = TerminalWebSocketHandler.class.getDeclaredField("sessions"); + f.setAccessible(true); + return (Map) f.get(handler); + } +} diff --git a/backend/src/test/java/com/sshmanager/service/SshSessionResizeTest.java b/backend/src/test/java/com/sshmanager/service/SshSessionResizeTest.java new file mode 100644 index 0000000..b9882fe --- /dev/null +++ b/backend/src/test/java/com/sshmanager/service/SshSessionResizeTest.java @@ -0,0 +1,27 @@ +package com.sshmanager.service; + +import com.jcraft.jsch.ChannelShell; +import com.jcraft.jsch.Session; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.io.OutputStream; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class SshSessionResizeTest { + + @Test + void resizeSetsPtySizeOnChannel() { + Session session = mock(Session.class); + ChannelShell channel = mock(ChannelShell.class); + InputStream out = mock(InputStream.class); + OutputStream in = mock(OutputStream.class); + + SshService.SshSession sshSession = new SshService.SshSession(session, channel, out, in); + sshSession.resize(120, 40); + + verify(channel).setPtySize(120, 40, 0, 0); + } +} diff --git a/frontend/src/components/TerminalWidget.vue b/frontend/src/components/TerminalWidget.vue index 635da97..41ba406 100644 --- a/frontend/src/components/TerminalWidget.vue +++ b/frontend/src/components/TerminalWidget.vue @@ -1,11 +1,12 @@