Initial commit: SSH Manager (backend + frontend)

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

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# Backend
backend/target/
backend/data/*.db
backend/data/*.mv.db
# Frontend
frontend/node_modules/
frontend/dist/
# Logs & IDE
*.log
.idea
.DS_Store
*.local
# Keep frontend .gitignore for frontend-specific rules
!frontend/.gitignore

91
README.md Normal file
View File

@@ -0,0 +1,91 @@
# SSH 管理器
基于 Web 的 SSH 连接管理工具,支持终端与 SFTP 文件传输。技术栈Vue 3、Spring BootJDK 8、JSch、xterm.js。
## 功能
- **认证**本地用户登录JWT
- **连接管理**SSH 连接的增删改查(密码或私钥)
- **Web 终端**:通过 WebSocket + xterm.js 实现实时 SSH 终端
- **SFTP**:文件浏览,支持上传、下载、删除、创建目录
## 环境要求
- JDK 8+
- Node.js 18+
- Maven 3.6+
## 快速开始
### 后端
```bash
cd backend
mvn spring-boot:run
```
后端运行在 http://localhost:8080
默认登录:`admin` / `admin123`
### 前端
```bash
cd frontend
npm install
npm run dev
```
前端运行在 http://localhost:5173API 与 WebSocket 会代理到后端)
### 生产构建
```bash
# 后端
cd backend && mvn package
# 前端
cd frontend && npm run build
```
`frontend/dist` 目录内容复制到后端的静态资源目录,或单独部署前端。
## 项目结构
```
ssh-manager/
├── backend/ # Spring BootJDK 8
│ └── src/main/java/com/sshmanager/
│ ├── config/ # 安全、WebSocket、CORS
│ ├── controller/
│ ├── service/
│ ├── entity/
│ └── repository/
├── frontend/ # Vue 3 + Vite + Tailwind
│ └── src/
│ ├── views/
│ ├── components/
│ ├── stores/
│ └── api/
└── design-system/ # UI/UX 规范
```
## 配置
### 后端application.yml
- `sshmanager.encryption-key`:用于加密连接密码的 Base64 32 字节密钥
- `sshmanager.jwt-secret`JWT 签名密钥
- `spring.datasource.url`H2 数据库路径(默认:`./data/sshmanager`
### 环境变量
- `SSHMANAGER_ENCRYPTION_KEY`:覆盖加密密钥
- `SSHMANAGER_JWT_SECRET`:覆盖 JWT 密钥
## 安全说明
- 连接密码与私钥均以 AES-256-GCM 加密存储
- 所有 API 接口需 JWT 认证
- WebSocket 连接在握手时校验 JWT
- CORS 仅允许前端来源

92
backend/pom.xml Normal file
View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.sshmanager</groupId>
<artifactId>ssh-manager-backend</artifactId>
<version>1.0.0</version>
<name>ssh-manager-backend</name>
<description>Web SSH Manager Backend</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,12 @@
package com.sshmanager;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SshManagerApplication {
public static void main(String[] args) {
SpringApplication.run(SshManagerApplication.class, args);
}
}

View File

@@ -0,0 +1,30 @@
package com.sshmanager.config;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Component
public class DataInitializer implements CommandLineRunner {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public DataInitializer(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public void run(String... args) {
if (userRepository.count() == 0) {
User admin = new User();
admin.setUsername("admin");
admin.setPasswordHash(passwordEncoder.encode("admin123"));
admin.setDisplayName("Administrator");
userRepository.save(admin);
}
}
}

View File

@@ -0,0 +1,15 @@
package com.sshmanager.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
@Configuration
public class SecurityBeanConfig {
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}

View File

@@ -0,0 +1,74 @@
package com.sshmanager.config;
import com.sshmanager.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired(required = false)
private SecurityExceptionHandler securityExceptionHandler;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors().and()
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.antMatchers("/ws/**").authenticated()
.antMatchers("/api/**").authenticated()
.anyRequest().permitAll()
.and()
.exceptionHandling(e -> {
if (securityExceptionHandler != null) {
e.authenticationEntryPoint(securityExceptionHandler);
}
})
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList("http://localhost:5173", "http://127.0.0.1:5173"));
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(Arrays.asList("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@@ -0,0 +1,30 @@
package com.sshmanager.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Component
public class SecurityExceptionHandler implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Map<String, String> body = new HashMap<>();
body.put("message", "Unauthorized");
objectMapper.writeValue(response.getOutputStream(), body);
}
}

View File

@@ -0,0 +1,56 @@
package com.sshmanager.config;
import com.sshmanager.security.JwtTokenProvider;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
@Component
public class TerminalHandshakeInterceptor implements HandshakeInterceptor {
private final JwtTokenProvider jwtTokenProvider;
public TerminalHandshakeInterceptor(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
if (!(request instanceof ServletServerHttpRequest)) {
return false;
}
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
String token = servletRequest.getParameter("token");
String connectionIdStr = servletRequest.getParameter("connectionId");
if (token == null || token.isEmpty() || connectionIdStr == null || connectionIdStr.isEmpty()) {
return false;
}
if (!jwtTokenProvider.validateToken(token)) {
return false;
}
try {
long connectionId = Long.parseLong(connectionIdStr);
String username = jwtTokenProvider.getUsernameFromToken(token);
attributes.put("connectionId", connectionId);
attributes.put("username", username);
return true;
} catch (NumberFormatException e) {
return false;
}
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
}
}

View File

@@ -0,0 +1,28 @@
package com.sshmanager.config;
import com.sshmanager.controller.TerminalWebSocketHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final TerminalWebSocketHandler terminalWebSocketHandler;
private final TerminalHandshakeInterceptor terminalHandshakeInterceptor;
public WebSocketConfig(TerminalWebSocketHandler terminalWebSocketHandler,
TerminalHandshakeInterceptor terminalHandshakeInterceptor) {
this.terminalWebSocketHandler = terminalWebSocketHandler;
this.terminalHandshakeInterceptor = terminalHandshakeInterceptor;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
.addInterceptors(terminalHandshakeInterceptor)
.setAllowedOrigins("http://localhost:5173", "http://127.0.0.1:5173");
}
}

View File

@@ -0,0 +1,69 @@
package com.sshmanager.controller;
import com.sshmanager.dto.LoginRequest;
import com.sshmanager.dto.LoginResponse;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.security.JwtTokenProvider;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider tokenProvider;
private final UserRepository userRepository;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider tokenProvider,
UserRepository userRepository) {
this.authenticationManager = authenticationManager;
this.tokenProvider = tokenProvider;
this.userRepository = userRepository;
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
String token = tokenProvider.generateToken(authentication);
User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found"));
LoginResponse response = new LoginResponse(token, user.getUsername(),
user.getDisplayName() != null ? user.getDisplayName() : user.getUsername());
return ResponseEntity.ok(response);
} catch (BadCredentialsException e) {
Map<String, String> error = new HashMap<>();
error.put("message", "Invalid username or password");
return ResponseEntity.status(401).body(error);
}
}
@GetMapping("/me")
public ResponseEntity<?> me(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
Map<String, String> error = new HashMap<>();
error.put("error", "Unauthorized");
return ResponseEntity.status(401).body(error);
}
User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
Map<String, Object> data = new HashMap<>();
data.put("username", user.getUsername());
data.put("displayName", user.getDisplayName());
return ResponseEntity.ok(data);
}
}

View File

@@ -0,0 +1,70 @@
package com.sshmanager.controller;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.dto.ConnectionDto;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.ConnectionService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/connections")
public class ConnectionController {
private final ConnectionService connectionService;
private final UserRepository userRepository;
public ConnectionController(ConnectionService connectionService,
UserRepository userRepository) {
this.connectionService = connectionService;
this.userRepository = userRepository;
}
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
return user.getId();
}
@GetMapping
public ResponseEntity<List<ConnectionDto>> list(Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(connectionService.listByUserId(userId));
}
@GetMapping("/{id}")
public ResponseEntity<ConnectionDto> get(@PathVariable Long id, Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(connectionService.getById(id, userId));
}
@PostMapping
public ResponseEntity<ConnectionDto> create(@RequestBody ConnectionCreateRequest request,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(connectionService.create(request, userId));
}
@PutMapping("/{id}")
public ResponseEntity<ConnectionDto> update(@PathVariable Long id,
@RequestBody ConnectionCreateRequest request,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(connectionService.update(id, request, userId));
}
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, String>> delete(@PathVariable Long id,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
connectionService.delete(id, userId);
Map<String, String> result = new HashMap<>();
result.put("message", "Deleted");
return ResponseEntity.ok(result);
}
}

View File

@@ -0,0 +1,248 @@
package com.sshmanager.controller;
import com.sshmanager.dto.SftpFileInfo;
import com.sshmanager.entity.Connection;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.ConnectionService;
import com.sshmanager.service.SftpService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/sftp")
public class SftpController {
private final ConnectionService connectionService;
private final UserRepository userRepository;
private final SftpService sftpService;
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
public SftpController(ConnectionService connectionService,
UserRepository userRepository,
SftpService sftpService) {
this.connectionService = connectionService;
this.userRepository = userRepository;
this.sftpService = sftpService;
}
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
return user.getId();
}
private String sessionKey(Long userId, Long connectionId) {
return userId + ":" + connectionId;
}
private SftpService.SftpSession getOrCreateSession(Long connectionId, Long userId) throws Exception {
String key = sessionKey(userId, connectionId);
SftpService.SftpSession session = sessions.get(key);
if (session == null || !session.isConnected()) {
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
String password = connectionService.getDecryptedPassword(conn);
String privateKey = connectionService.getDecryptedPrivateKey(conn);
String passphrase = connectionService.getDecryptedPassphrase(conn);
session = sftpService.connect(conn, password, privateKey, passphrase);
sessions.put(key, session);
}
return session;
}
@GetMapping("/list")
public ResponseEntity<List<SftpFileInfo>> list(
@RequestParam Long connectionId,
@RequestParam(required = false, defaultValue = ".") String path,
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
List<SftpService.FileInfo> files = sftpService.listFiles(session, path);
List<SftpFileInfo> dtos = files.stream()
.map(f -> new SftpFileInfo(f.name, f.directory, f.size, f.mtime))
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
} catch (Exception e) {
return ResponseEntity.status(500).build();
}
}
@GetMapping("/pwd")
public ResponseEntity<Map<String, String>> pwd(
@RequestParam Long connectionId,
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
String pwd = sftpService.pwd(session);
Map<String, String> result = new HashMap<>();
result.put("path", pwd);
return ResponseEntity.ok(result);
} catch (Exception e) {
return ResponseEntity.status(500).build();
}
}
@GetMapping("/download")
public ResponseEntity<byte[]> download(
@RequestParam Long connectionId,
@RequestParam String path,
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
byte[] data = sftpService.download(session, path);
String filename = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path;
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(data);
} catch (Exception e) {
return ResponseEntity.status(500).build();
}
}
@PostMapping("/upload")
public ResponseEntity<Map<String, String>> upload(
@RequestParam Long connectionId,
@RequestParam String path,
@RequestParam("file") MultipartFile file,
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
String remotePath = (path == null || path.isEmpty() || path.equals("/"))
? "/" + file.getOriginalFilename()
: (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename());
sftpService.upload(session, remotePath, file.getBytes());
Map<String, String> result = new HashMap<>();
result.put("message", "Uploaded");
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.status(500).body(error);
}
}
@DeleteMapping("/delete")
public ResponseEntity<Map<String, String>> delete(
@RequestParam Long connectionId,
@RequestParam String path,
@RequestParam boolean directory,
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
sftpService.delete(session, path, directory);
Map<String, String> result = new HashMap<>();
result.put("message", "Deleted");
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/mkdir")
public ResponseEntity<Map<String, String>> mkdir(
@RequestParam Long connectionId,
@RequestParam String path,
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
sftpService.mkdir(session, path);
Map<String, String> result = new HashMap<>();
result.put("message", "Created");
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/rename")
public ResponseEntity<Map<String, String>> rename(
@RequestParam Long connectionId,
@RequestParam String oldPath,
@RequestParam String newPath,
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
sftpService.rename(session, oldPath, newPath);
Map<String, String> result = new HashMap<>();
result.put("message", "Renamed");
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/transfer-remote")
public ResponseEntity<Map<String, String>> transferRemote(
@RequestParam Long sourceConnectionId,
@RequestParam String sourcePath,
@RequestParam Long targetConnectionId,
@RequestParam String targetPath,
Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
if (sourcePath == null || sourcePath.trim().isEmpty()) {
Map<String, String> err = new HashMap<>();
err.put("error", "sourcePath is required");
return ResponseEntity.badRequest().body(err);
}
if (targetPath == null || targetPath.trim().isEmpty()) {
Map<String, String> err = new HashMap<>();
err.put("error", "targetPath is required");
return ResponseEntity.badRequest().body(err);
}
SftpService.SftpSession sourceSession = getOrCreateSession(sourceConnectionId, userId);
SftpService.SftpSession targetSession = getOrCreateSession(targetConnectionId, userId);
if (sourceConnectionId.equals(targetConnectionId)) {
sftpService.rename(sourceSession, sourcePath.trim(), targetPath.trim());
} else {
sftpService.transferRemote(sourceSession, sourcePath.trim(), targetSession, targetPath.trim());
}
Map<String, String> result = new HashMap<>();
result.put("message", "Transferred");
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed");
return ResponseEntity.status(500).body(error);
}
}
@PostMapping("/disconnect")
public ResponseEntity<Map<String, String>> disconnect(
@RequestParam Long connectionId,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
String key = sessionKey(userId, connectionId);
SftpService.SftpSession session = sessions.remove(key);
if (session != null) {
session.disconnect();
}
Map<String, String> result = new HashMap<>();
result.put("message", "Disconnected");
return ResponseEntity.ok(result);
}
}

View File

@@ -0,0 +1,112 @@
package com.sshmanager.controller;
import com.sshmanager.entity.Connection;
import com.sshmanager.entity.User;
import com.sshmanager.repository.ConnectionRepository;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.ConnectionService;
import com.sshmanager.service.SshService;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Component
public class TerminalWebSocketHandler extends TextWebSocketHandler {
private final ConnectionRepository connectionRepository;
private final UserRepository userRepository;
private final ConnectionService connectionService;
private final SshService sshService;
private final ExecutorService executor = Executors.newCachedThreadPool();
private final Map<String, SshService.SshSession> sessions = new ConcurrentHashMap<>();
public TerminalWebSocketHandler(ConnectionRepository connectionRepository,
UserRepository userRepository,
ConnectionService connectionService,
SshService sshService) {
this.connectionRepository = connectionRepository;
this.userRepository = userRepository;
this.connectionService = connectionService;
this.sshService = sshService;
}
@Override
public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception {
Long connectionId = (Long) webSocketSession.getAttributes().get("connectionId");
String username = (String) webSocketSession.getAttributes().get("username");
if (connectionId == null || username == null) {
webSocketSession.close(CloseStatus.BAD_DATA);
return;
}
User user = userRepository.findByUsername(username).orElse(null);
if (user == null) {
webSocketSession.close(CloseStatus.BAD_DATA);
return;
}
Connection conn = connectionRepository.findById(connectionId).orElse(null);
if (conn == null || !conn.getUserId().equals(user.getId())) {
webSocketSession.close(CloseStatus.BAD_DATA);
return;
}
String password = connectionService.getDecryptedPassword(conn);
String privateKey = connectionService.getDecryptedPrivateKey(conn);
String passphrase = connectionService.getDecryptedPassphrase(conn);
try {
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
sessions.put(webSocketSession.getId(), sshSession);
executor.submit(() -> {
try {
InputStream in = sshSession.getOutputStream();
byte[] buf = new byte[4096];
int n;
while (webSocketSession.isOpen() && sshSession.isConnected() && (n = in.read(buf)) >= 0) {
String text = new String(buf, 0, n, "UTF-8");
webSocketSession.sendMessage(new TextMessage(text));
}
} catch (Exception e) {
if (webSocketSession.isOpen()) {
try {
webSocketSession.sendMessage(new TextMessage("\r\n[Connection closed]\r\n"));
} catch (IOException ignored) {
}
}
}
});
} catch (Exception e) {
webSocketSession.sendMessage(new TextMessage("\r\n[SSH Error: " + e.getMessage() + "]\r\n"));
}
}
@Override
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
if (sshSession != null && sshSession.isConnected()) {
sshSession.getInputStream().write(message.asBytes());
sshSession.getInputStream().flush();
}
}
@Override
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
if (sshSession != null) {
sshSession.disconnect();
}
}
}

View File

@@ -0,0 +1,16 @@
package com.sshmanager.dto;
import com.sshmanager.entity.Connection;
import lombok.Data;
@Data
public class ConnectionCreateRequest {
private String name;
private String host;
private Integer port = 22;
private String username;
private Connection.AuthType authType = Connection.AuthType.PASSWORD;
private String password;
private String privateKey;
private String passphrase;
}

View File

@@ -0,0 +1,29 @@
package com.sshmanager.dto;
import com.sshmanager.entity.Connection;
import lombok.Data;
@Data
public class ConnectionDto {
private Long id;
private String name;
private String host;
private Integer port;
private String username;
private Connection.AuthType authType;
private String createdAt;
private String updatedAt;
public static ConnectionDto fromEntity(Connection conn) {
ConnectionDto dto = new ConnectionDto();
dto.setId(conn.getId());
dto.setName(conn.getName());
dto.setHost(conn.getHost());
dto.setPort(conn.getPort());
dto.setUsername(conn.getUsername());
dto.setAuthType(conn.getAuthType());
dto.setCreatedAt(conn.getCreatedAt().toString());
dto.setUpdatedAt(conn.getUpdatedAt().toString());
return dto;
}
}

View File

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

View File

@@ -0,0 +1,14 @@
package com.sshmanager.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
private String token;
private String username;
private String displayName;
}

View File

@@ -0,0 +1,15 @@
package com.sshmanager.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SftpFileInfo {
private String name;
private boolean directory;
private long size;
private long mtime;
}

View File

@@ -0,0 +1,59 @@
package com.sshmanager.entity;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import javax.persistence.*;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "connections")
public class Connection {
public enum AuthType {
PASSWORD,
PRIVATE_KEY
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, length = 255)
private String host;
@Column(nullable = false)
private Integer port = 22;
@Column(nullable = false, length = 100)
private String username;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthType authType = AuthType.PASSWORD;
@Column(columnDefinition = "TEXT")
private String encryptedPassword;
@Column(columnDefinition = "TEXT")
private String encryptedPrivateKey;
@Column(length = 255)
private String passphrase;
@Column(nullable = false)
private Instant createdAt = Instant.now();
@Column(nullable = false)
private Instant updatedAt = Instant.now();
}

View File

@@ -0,0 +1,35 @@
package com.sshmanager.entity;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import javax.persistence.*;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, length = 255)
private String passwordHash;
@Column(length = 100)
private String displayName;
@Column(nullable = false)
private Instant createdAt = Instant.now();
@Column(nullable = false)
private Instant updatedAt = Instant.now();
}

View File

@@ -0,0 +1,11 @@
package com.sshmanager.repository;
import com.sshmanager.entity.Connection;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ConnectionRepository extends JpaRepository<Connection, Long> {
List<Connection> findByUserIdOrderByUpdatedAtDesc(Long userId);
}

View File

@@ -0,0 +1,13 @@
package com.sshmanager.repository;
import com.sshmanager.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}

View File

@@ -0,0 +1,32 @@
package com.sshmanager.security;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Collections;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPasswordHash(),
Collections.emptyList()
);
}
}

View File

@@ -0,0 +1,57 @@
package com.sshmanager.security;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) {
this.tokenProvider = tokenProvider;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
String username = tokenProvider.getUsernameFromToken(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
// Log but don't fail - let controller handle 401
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}

View File

@@ -0,0 +1,65 @@
package com.sshmanager.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.security.Key;
import java.util.Date;
@Component
public class JwtTokenProvider {
@Value("${sshmanager.jwt-secret}")
private String jwtSecret;
@Value("${sshmanager.jwt-expiration-ms}")
private long jwtExpirationMs;
private Key key;
@PostConstruct
public void init() {
byte[] keyBytes = jwtSecret.getBytes();
if (keyBytes.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(keyBytes, 0, padded, 0, Math.min(keyBytes.length, 32));
keyBytes = padded;
}
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(Authentication authentication) {
String username = authentication.getName();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpirationMs);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(key)
.compact();
}
public String getUsernameFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public boolean validateToken(String authToken) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(authToken);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}

View File

@@ -0,0 +1,127 @@
package com.sshmanager.service;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.dto.ConnectionDto;
import com.sshmanager.entity.Connection;
import com.sshmanager.repository.ConnectionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class ConnectionService {
private final ConnectionRepository connectionRepository;
private final EncryptionService encryptionService;
public ConnectionService(ConnectionRepository connectionRepository,
EncryptionService encryptionService) {
this.connectionRepository = connectionRepository;
this.encryptionService = encryptionService;
}
public List<ConnectionDto> listByUserId(Long userId) {
return connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId).stream()
.map(ConnectionDto::fromEntity)
.collect(Collectors.toList());
}
public ConnectionDto getById(Long id, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow(
() -> new RuntimeException("Connection not found: " + id));
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
}
return ConnectionDto.fromEntity(conn);
}
@Transactional
public ConnectionDto create(ConnectionCreateRequest request, Long userId) {
Connection conn = new Connection();
conn.setUserId(userId);
conn.setName(request.getName());
conn.setHost(request.getHost());
conn.setPort(request.getPort() != null ? request.getPort() : 22);
conn.setUsername(request.getUsername());
conn.setAuthType(request.getAuthType() != null ? request.getAuthType() : Connection.AuthType.PASSWORD);
if (conn.getAuthType() == Connection.AuthType.PASSWORD && request.getPassword() != null) {
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
} else if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && request.getPrivateKey() != null) {
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
if (request.getPassphrase() != null && !request.getPassphrase().isEmpty()) {
conn.setPassphrase(encryptionService.encrypt(request.getPassphrase()));
}
}
conn = connectionRepository.save(conn);
return ConnectionDto.fromEntity(conn);
}
@Transactional
public ConnectionDto update(Long id, ConnectionCreateRequest request, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow(
() -> new RuntimeException("Connection not found: " + id));
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
}
if (request.getName() != null) conn.setName(request.getName());
if (request.getHost() != null) conn.setHost(request.getHost());
if (request.getPort() != null) conn.setPort(request.getPort());
if (request.getUsername() != null) conn.setUsername(request.getUsername());
if (request.getAuthType() != null) conn.setAuthType(request.getAuthType());
if (request.getPassword() != null) {
conn.setEncryptedPassword(encryptionService.encrypt(request.getPassword()));
}
if (request.getPrivateKey() != null) {
conn.setEncryptedPrivateKey(encryptionService.encrypt(request.getPrivateKey()));
}
if (request.getPassphrase() != null) {
conn.setPassphrase(request.getPassphrase().isEmpty() ? null :
encryptionService.encrypt(request.getPassphrase()));
}
conn.setUpdatedAt(Instant.now());
conn = connectionRepository.save(conn);
return ConnectionDto.fromEntity(conn);
}
@Transactional
public void delete(Long id, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow(
() -> new RuntimeException("Connection not found: " + id));
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
}
connectionRepository.delete(conn);
}
public Connection getConnectionForSsh(Long id, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow(
() -> new RuntimeException("Connection not found: " + id));
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
}
return conn;
}
public String getDecryptedPassword(Connection conn) {
return conn.getEncryptedPassword() != null ?
encryptionService.decrypt(conn.getEncryptedPassword()) : null;
}
public String getDecryptedPrivateKey(Connection conn) {
return conn.getEncryptedPrivateKey() != null ?
encryptionService.decrypt(conn.getEncryptedPrivateKey()) : null;
}
public String getDecryptedPassphrase(Connection conn) {
return conn.getPassphrase() != null ?
encryptionService.decrypt(conn.getPassphrase()) : null;
}
}

View File

@@ -0,0 +1,77 @@
package com.sshmanager.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
@Service
public class EncryptionService {
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
private static final String ALGORITHM = "AES/GCM/NoPadding";
private final byte[] keyBytes;
public EncryptionService(@Value("${sshmanager.encryption-key}") String base64Key) {
this.keyBytes = Base64.getDecoder().decode(base64Key);
if (keyBytes.length != 32) {
throw new IllegalArgumentException("Encryption key must be 32 bytes (256 bits)");
}
}
public String encrypt(String plainText) {
if (plainText == null || plainText.isEmpty()) {
return null;
}
try {
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new RuntimeException("Encryption failed", e);
}
}
public String decrypt(String encryptedText) {
if (encryptedText == null || encryptedText.isEmpty()) {
return null;
}
try {
byte[] combined = Base64.getDecoder().decode(encryptedText);
byte[] iv = new byte[GCM_IV_LENGTH];
byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, iv.length);
System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
byte[] decrypted = cipher.doFinal(encrypted);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("Decryption failed", e);
}
}
}

View File

@@ -0,0 +1,188 @@
package com.sshmanager.service;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.sshmanager.entity.Connection;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Vector;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@Service
public class SftpService {
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
throws Exception {
JSch jsch = new JSch();
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
}
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
session.setConfig("StrictHostKeyChecking", "no");
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
session.setPassword(password);
}
session.connect(10000);
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
channel.connect(5000);
return new SftpSession(session, channel);
}
public static class SftpSession {
private final Session session;
private final ChannelSftp channel;
public SftpSession(Session session, ChannelSftp channel) {
this.session = session;
this.channel = channel;
}
public ChannelSftp getChannel() {
return channel;
}
public void disconnect() {
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
if (session != null && session.isConnected()) {
session.disconnect();
}
}
public boolean isConnected() {
return channel != null && channel.isConnected();
}
}
public static class FileInfo {
public String name;
public boolean directory;
public long size;
public long mtime;
public FileInfo(String name, boolean directory, long size, long mtime) {
this.name = name;
this.directory = directory;
this.size = size;
this.mtime = mtime;
}
}
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
Vector<?> entries = sftpSession.getChannel().ls(path);
List<FileInfo> result = new ArrayList<>();
for (Object obj : entries) {
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
String name = entry.getFilename();
if (".".equals(name) || "..".equals(name)) continue;
result.add(new FileInfo(
name,
entry.getAttrs().isDir(),
entry.getAttrs().getSize(),
entry.getAttrs().getMTime() * 1000L
));
}
return result;
}
public byte[] download(SftpSession sftpSession, String remotePath) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
sftpSession.getChannel().get(remotePath, out);
return out.toByteArray();
}
public void upload(SftpSession sftpSession, String remotePath, byte[] data) throws Exception {
sftpSession.getChannel().put(new ByteArrayInputStream(data), remotePath);
}
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
if (isDir) {
sftpSession.getChannel().rmdir(remotePath);
} else {
sftpSession.getChannel().rm(remotePath);
}
}
public void mkdir(SftpSession sftpSession, String remotePath) throws Exception {
sftpSession.getChannel().mkdir(remotePath);
}
public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception {
sftpSession.getChannel().rename(oldPath, newPath);
}
public String pwd(SftpSession sftpSession) throws Exception {
return sftpSession.getChannel().pwd();
}
public void cd(SftpSession sftpSession, String path) throws Exception {
sftpSession.getChannel().cd(path);
}
/**
* Transfer a single file from source session to target session (streaming, no full file in memory).
* Fails if sourcePath is a directory.
*/
public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath)
throws Exception {
if (source.getChannel().stat(sourcePath).isDir()) {
throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported");
}
final int pipeBufferSize = 65536;
PipedOutputStream pos = new PipedOutputStream();
PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize);
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<?> putFuture = executor.submit(() -> {
try {
target.getChannel().put(pis, targetPath);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
source.getChannel().get(sourcePath, pos);
pos.close();
putFuture.get(5, TimeUnit.MINUTES);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException && cause.getCause() instanceof Exception) {
throw (Exception) cause.getCause();
}
if (cause instanceof Exception) {
throw (Exception) cause;
}
throw new RuntimeException(cause);
} catch (TimeoutException e) {
throw new RuntimeException("Transfer timeout", e);
} finally {
executor.shutdownNow();
try {
pis.close();
} catch (Exception ignored) {
}
}
}
}

View File

@@ -0,0 +1,97 @@
package com.sshmanager.service;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.sshmanager.entity.Connection;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
@Service
public class SshService {
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
throws Exception {
JSch jsch = new JSch();
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
}
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
session.setConfig("StrictHostKeyChecking", "no");
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
session.setPassword(password);
}
session.connect(10000);
ChannelShell channel = (ChannelShell) session.openChannel("shell");
channel.setPtyType("xterm");
channel.connect(5000);
PipedInputStream pipedIn = new PipedInputStream();
PipedOutputStream pipeToChannel = new PipedOutputStream(pipedIn);
OutputStream channelIn = channel.getOutputStream();
InputStream channelOut = channel.getInputStream();
new Thread(() -> {
try {
byte[] buf = new byte[1024];
int n;
while ((n = pipedIn.read(buf)) > 0) {
channelIn.write(buf, 0, n);
channelIn.flush();
}
} catch (Exception e) {
// Channel closed
}
}).start();
return new SshSession(session, channel, channelOut, pipeToChannel);
}
public static class SshSession {
private final Session session;
private final ChannelShell channel;
private final InputStream outputStream;
private final OutputStream inputStream;
public SshSession(Session session, ChannelShell channel, InputStream outputStream, OutputStream inputStream) {
this.session = session;
this.channel = channel;
this.outputStream = outputStream;
this.inputStream = inputStream;
}
public InputStream getOutputStream() {
return outputStream;
}
public OutputStream getInputStream() {
return inputStream;
}
public void disconnect() {
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
if (session != null && session.isConnected()) {
session.disconnect();
}
}
public boolean isConnected() {
return channel != null && channel.isConnected();
}
}
}

View File

@@ -0,0 +1,27 @@
server:
port: 8080
spring:
datasource:
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: false
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.H2Dialect
open-in-view: false
# Encryption key for connection passwords (base64, 32 bytes for AES-256)
sshmanager:
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=}
jwt-secret: ${SSHMANAGER_JWT_SECRET:ssh-manager-jwt-secret-change-in-production}
jwt-expiration-ms: 86400000

38
design-system/MASTER.md Normal file
View File

@@ -0,0 +1,38 @@
# SSH 管理器设计系统
## 风格
- **产品类型**:管理后台 / 开发工具仪表盘
- **主题**:深色、专业、终端风格
- **布局**:侧边栏 + 主内容区
## 色彩
- 背景slate-900 (#0f172a)、slate-800
- 表面slate-800、slate-700
- 主文字slate-100 (#f1f5f9)
- 次要文字slate-400
- 强调(成功/连接emerald-500、cyan-500
- 边框slate-600、slate-700
## 字体
- 字体Inter 或 system-ui
- 正文:最小 16px行高 1.5
## 图标
- 仅使用 Lucide 图标,不使用 emoji
- 尺寸:统一 20px 或 24px
## 交互
- 所有可点击元素使用 cursor-pointer
- transition-colors duration-200
- 最小触控区域 44×44px
## 无障碍
- 对比度 4.5:1
- 可见焦点环
- 仅图标按钮需设置 aria-label

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
本模板用于在 Vite 中基于 Vue 3 与 TypeScript 进行开发。模板使用 Vue 3 的 `<script setup>` 单文件组件,可参阅 [script setup 文档](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) 了解更多。
推荐的项目配置与 IDE 支持请参考 [Vue 文档 TypeScript 指南](https://vuejs.org/guide/typescript/overview.html#project-setup)。

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSH 管理器</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3384
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.10.0",
"axios": "^1.13.4",
"lucide-vue-next": "^0.563.0",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^5.0.2",
"xterm": "^5.3.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

6
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
</script>
<template>
<RouterView />
</template>

20
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import client from './client'
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
username: string
displayName: string
}
export function login(data: LoginRequest) {
return client.post<LoginResponse>('/auth/login', data)
}
export function getMe() {
return client.get<{ username: string; displayName: string }>('/auth/me')
}

View File

@@ -0,0 +1,29 @@
import axios from 'axios'
const client = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default client

View File

@@ -0,0 +1,45 @@
import client from './client'
export type AuthType = 'PASSWORD' | 'PRIVATE_KEY'
export interface Connection {
id: number
name: string
host: string
port: number
username: string
authType: AuthType
createdAt: string
updatedAt: string
}
export interface ConnectionCreateRequest {
name: string
host: string
port?: number
username: string
authType?: AuthType
password?: string
privateKey?: string
passphrase?: string
}
export function listConnections() {
return client.get<Connection[]>('/connections')
}
export function getConnection(id: number) {
return client.get<Connection>(`/connections/${id}`)
}
export function createConnection(data: ConnectionCreateRequest) {
return client.post<Connection>('/connections', data)
}
export function updateConnection(id: number, data: ConnectionCreateRequest) {
return client.put<Connection>(`/connections/${id}`, data)
}
export function deleteConnection(id: number) {
return client.delete(`/connections/${id}`)
}

81
frontend/src/api/sftp.ts Normal file
View File

@@ -0,0 +1,81 @@
import client from './client'
export interface SftpFileInfo {
name: string
directory: boolean
size: number
mtime: number
}
export function listFiles(connectionId: number, path: string) {
return client.get<SftpFileInfo[]>('/sftp/list', {
params: { connectionId, path: path || '.' },
})
}
export function getPwd(connectionId: number) {
return client.get<{ path: string }>('/sftp/pwd', {
params: { connectionId },
})
}
export async function downloadFile(connectionId: number, path: string) {
const token = localStorage.getItem('token')
const params = new URLSearchParams({ connectionId: String(connectionId), path })
const res = await fetch(`/api/sftp/download?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = path.split('/').pop() || 'download'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
export function uploadFile(connectionId: number, path: string, file: File) {
const form = new FormData()
form.append('file', file)
return client.post('/sftp/upload', form, {
params: { connectionId, path },
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export function deleteFile(connectionId: number, path: string, directory: boolean) {
return client.delete('/sftp/delete', {
params: { connectionId, path, directory },
})
}
export function createDir(connectionId: number, path: string) {
return client.post('/sftp/mkdir', null, {
params: { connectionId, path },
})
}
export function renameFile(connectionId: number, oldPath: string, newPath: string) {
return client.post('/sftp/rename', null, {
params: { connectionId, oldPath, newPath },
})
}
export function transferRemote(
sourceConnectionId: number,
sourcePath: string,
targetConnectionId: number,
targetPath: string
) {
return client.post<{ message: string }>('/sftp/transfer-remote', null, {
params: {
sourceConnectionId,
sourcePath,
targetConnectionId,
targetPath,
},
})
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { Connection, ConnectionCreateRequest, AuthType } from '../api/connections'
import { X } from 'lucide-vue-next'
const props = defineProps<{
connection: Connection | null
onSave?: (data: ConnectionCreateRequest) => Promise<void>
}>()
const emit = defineEmits<{
save: [data: ConnectionCreateRequest]
close: []
}>()
const name = ref('')
const host = ref('')
const port = ref(22)
const username = ref('')
const authType = ref<AuthType>('PASSWORD')
const password = ref('')
const privateKey = ref('')
const passphrase = ref('')
const isEdit = computed(() => !!props.connection)
watch(
() => props.connection,
(c) => {
if (c) {
name.value = c.name
host.value = c.host
port.value = c.port
username.value = c.username
authType.value = c.authType
password.value = ''
privateKey.value = ''
passphrase.value = ''
} else {
name.value = ''
host.value = ''
port.value = 22
username.value = ''
authType.value = 'PASSWORD'
password.value = ''
privateKey.value = ''
passphrase.value = ''
}
},
{ immediate: true }
)
const error = ref('')
const loading = ref(false)
async function handleSubmit() {
error.value = ''
if (!name.value.trim()) {
error.value = '请填写名称'
return
}
if (!host.value.trim()) {
error.value = '请填写主机'
return
}
if (!username.value.trim()) {
error.value = '请填写用户名'
return
}
if (authType.value === 'PASSWORD' && !isEdit.value && !password.value) {
error.value = '请填写密码'
return
}
if (authType.value === 'PRIVATE_KEY' && !isEdit.value && !privateKey.value.trim()) {
error.value = '请填写私钥'
return
}
loading.value = true
error.value = ''
const data: ConnectionCreateRequest = {
name: name.value.trim(),
host: host.value.trim(),
port: port.value,
username: username.value.trim(),
authType: authType.value,
}
if (authType.value === 'PASSWORD' && password.value) {
data.password = password.value
}
if (authType.value === 'PRIVATE_KEY') {
if (privateKey.value.trim()) data.privateKey = privateKey.value.trim()
if (passphrase.value) data.passphrase = passphrase.value
}
try {
if (props.onSave) {
await props.onSave(data)
} else {
emit('save', data)
}
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
error.value = err.response?.data?.message || '保存失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div
class="w-full max-w-lg bg-slate-800 rounded-xl border border-slate-700 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="form-title"
>
<div class="flex items-center justify-between p-4 border-b border-slate-700">
<h2 id="form-title" class="text-lg font-semibold text-slate-100">
{{ isEdit ? '编辑连接' : '新建连接' }}
</h2>
<button
@click="emit('close')"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="关闭"
>
<X class="w-5 h-5" aria-hidden="true" />
</button>
</div>
<form @submit.prevent="handleSubmit" class="p-4 space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-slate-300 mb-1">名称</label>
<input
id="name"
v-model="name"
type="text"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="我的服务器"
/>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="col-span-2">
<label for="host" class="block text-sm font-medium text-slate-300 mb-1">主机</label>
<input
id="host"
v-model="host"
type="text"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="192.168.1.1"
/>
</div>
<div>
<label for="port" class="block text-sm font-medium text-slate-300 mb-1">端口</label>
<input
id="port"
v-model.number="port"
type="number"
min="1"
max="65535"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
</div>
<div>
<label for="username" class="block text-sm font-medium text-slate-300 mb-1">用户名</label>
<input
id="username"
v-model="username"
type="text"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="root"
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">认证方式</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="authType" type="radio" value="PASSWORD" class="rounded" />
<span class="text-slate-300">密码</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="authType" type="radio" value="PRIVATE_KEY" class="rounded" />
<span class="text-slate-300">私钥</span>
</label>
</div>
</div>
<div v-if="authType === 'PASSWORD'">
<label for="password" class="block text-sm font-medium text-slate-300 mb-1">
密码 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
<input
id="password"
v-model="password"
type="password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
:placeholder="isEdit ? '••••••••' : ''"
/>
</div>
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
</label>
<textarea
id="privateKey"
v-model="privateKey"
rows="6"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
></textarea>
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令可选</label>
<input
id="passphrase"
v-model="passphrase"
type="password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
@click="emit('close')"
class="px-4 py-2 rounded-lg text-slate-300 hover:bg-slate-700 transition-colors duration-200 cursor-pointer"
>
取消
</button>
<button
type="submit"
:disabled="loading"
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 disabled:opacity-50 cursor-pointer"
>
{{ isEdit ? '更新' : '创建' }}
</button>
</div>
</form>
</div>
</div>
</template>

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Terminal } from 'xterm'
import { AttachAddon } from '@xterm/addon-attach'
import { FitAddon } from '@xterm/addon-fit'
import { useAuthStore } from '../stores/auth'
import 'xterm/css/xterm.css'
const props = defineProps<{
connectionId: number
}>()
const containerRef = ref<HTMLElement | null>(null)
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
const errorMessage = ref('')
let term: Terminal | null = null
let fitAddon: FitAddon | null = null
let ws: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null
function getWsUrl(): string {
const authStore = useAuthStore()
const token = authStore.token
if (!token) {
throw new Error('Not authenticated')
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
}
function cleanup() {
if (resizeObserver && containerRef.value) {
resizeObserver.unobserve(containerRef.value)
}
resizeObserver = null
if (ws) {
ws.close()
ws = null
}
if (term) {
term.dispose()
term = null
}
fitAddon = null
}
onMounted(() => {
const authStore = useAuthStore()
if (!authStore.token) {
status.value = 'error'
errorMessage.value = '未登录'
return
}
if (!containerRef.value) return
term = new Terminal({
cursorBlink: true,
theme: {
background: '#0f172a',
foreground: '#f1f5f9',
cursor: '#22d3ee',
cursorAccent: '#0f172a',
selectionBackground: 'rgba(34, 211, 238, 0.3)',
},
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(containerRef.value)
fitAddon.fit()
try {
ws = new WebSocket(getWsUrl())
} catch (e) {
status.value = 'error'
errorMessage.value = e instanceof Error ? e.message : '连接失败'
return
}
ws.onopen = () => {
status.value = 'connected'
const attachAddon = new AttachAddon(ws!)
term!.loadAddon(attachAddon)
fitAddon?.fit()
}
ws.onerror = () => {
if (status.value === 'connecting') {
status.value = 'error'
errorMessage.value = 'WebSocket 连接失败'
}
}
ws.onclose = () => {
if (status.value === 'connected') {
term?.writeln('\r\n[连接已关闭]')
}
}
resizeObserver = new ResizeObserver(() => {
fitAddon?.fit()
})
resizeObserver.observe(containerRef.value)
})
onUnmounted(() => {
cleanup()
})
</script>
<template>
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
<div
ref="containerRef"
class="flex-1 min-h-0 p-4 xterm-container"
:class="{ 'flex items-center justify-center': status !== 'connected' }"
style="min-height: 300px"
>
<div v-if="status === 'connecting'" class="text-slate-400">
正在连接 SSH...
</div>
<div v-else-if="status === 'error'" class="text-red-400">
{{ errorMessage }}
</div>
</div>
</div>
</template>
<style>
.xterm-container .xterm {
padding: 0.5rem;
}
.xterm-container .xterm-viewport {
overflow-y: auto !important;
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useConnectionsStore } from '../stores/connections'
import { Server, LogOut, Menu, X } from 'lucide-vue-next'
const route = useRoute()
const authStore = useAuthStore()
const connectionsStore = useConnectionsStore()
const sidebarOpen = ref(false)
connectionsStore.fetchConnections().catch(() => {})
function closeSidebar() {
sidebarOpen.value = false
}
</script>
<template>
<div class="flex h-screen bg-slate-900">
<button
@click="sidebarOpen = !sidebarOpen"
class="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 cursor-pointer"
aria-label="切换侧边栏"
>
<Menu v-if="!sidebarOpen" class="w-6 h-6" aria-hidden="true" />
<X v-else class="w-6 h-6" aria-hidden="true" />
</button>
<aside
:class="[
'w-64 bg-slate-800 border-r border-slate-700 flex flex-col transition-transform duration-200 z-20',
'fixed lg:static inset-y-0 left-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
]"
>
<div class="p-4 border-b border-slate-700">
<h1 class="text-lg font-semibold text-slate-100">SSH 管理器</h1>
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
</div>
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4">
<RouterLink
to="/connections"
@click="closeSidebar"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
aria-label="连接列表"
>
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
<span>连接列表</span>
</RouterLink>
</nav>
<div class="p-4 border-t border-slate-700">
<button
@click="authStore.logout(); $router.push('/login')"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
aria-label="退出登录"
>
<LogOut class="w-5 h-5" aria-hidden="true" />
<span>退出登录</span>
</button>
</div>
</aside>
<div
v-if="sidebarOpen"
class="lg:hidden fixed inset-0 bg-black/50 z-10"
aria-hidden="true"
@click="sidebarOpen = false"
/>
<main class="flex-1 overflow-auto min-w-0">
<RouterView />
</main>
</div>
</template>

10
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,63 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('../views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
redirect: '/connections',
},
{
path: 'connections',
name: 'Connections',
component: () => import('../views/ConnectionsView.vue'),
},
{
path: 'terminal/:id',
name: 'Terminal',
component: () => import('../views/TerminalView.vue'),
},
{
path: 'sftp/:id',
name: 'Sftp',
component: () => import('../views/SftpView.vue'),
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore()
if (to.meta.public) {
if (authStore.isAuthenticated && to.path === '/login') {
next('/connections')
} else {
next()
}
return
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
return
}
next()
})
export default router

View File

@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as authApi from '../api/auth'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const username = ref<string | null>(null)
const displayName = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
function setAuth(t: string, u: string, d: string) {
token.value = t
username.value = u
displayName.value = d || u
localStorage.setItem('token', t)
}
function logout() {
token.value = null
username.value = null
displayName.value = null
localStorage.removeItem('token')
}
async function fetchMe() {
const res = await authApi.getMe()
username.value = res.data.username
displayName.value = res.data.displayName || res.data.username
return res.data
}
return {
token,
username,
displayName,
isAuthenticated,
setAuth,
logout,
fetchMe,
}
})

View File

@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Connection, ConnectionCreateRequest } from '../api/connections'
import * as connectionsApi from '../api/connections'
export const useConnectionsStore = defineStore('connections', () => {
const connections = ref<Connection[]>([])
async function fetchConnections() {
const res = await connectionsApi.listConnections()
connections.value = res.data
return res.data
}
async function createConnection(data: ConnectionCreateRequest) {
const res = await connectionsApi.createConnection(data)
connections.value.unshift(res.data)
return res.data
}
async function updateConnection(id: number, data: ConnectionCreateRequest) {
const res = await connectionsApi.updateConnection(id, data)
const idx = connections.value.findIndex((c) => c.id === id)
if (idx >= 0) connections.value[idx] = res.data
return res.data
}
async function deleteConnection(id: number) {
await connectionsApi.deleteConnection(id)
connections.value = connections.value.filter((c) => c.id !== id)
}
function getConnection(id: number): Connection | undefined {
return connections.value.find((c) => c.id === id)
}
return {
connections,
fetchConnections,
createConnection,
updateConnection,
deleteConnection,
getConnection,
}
})

19
frontend/src/style.css Normal file
View File

@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-900 text-slate-100 antialiased;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import type { Connection, ConnectionCreateRequest } from '../api/connections'
import ConnectionForm from '../components/ConnectionForm.vue'
import {
Server,
Plus,
Terminal,
FolderOpen,
Pencil,
Trash2,
Key,
Lock,
} from 'lucide-vue-next'
const router = useRouter()
const store = useConnectionsStore()
const showForm = ref(false)
const editingConn = ref<Connection | null>(null)
onMounted(() => store.fetchConnections())
function openCreate() {
editingConn.value = null
showForm.value = true
}
function openEdit(conn: Connection) {
editingConn.value = conn
showForm.value = true
}
function closeForm() {
showForm.value = false
editingConn.value = null
}
async function handleSave(data: ConnectionCreateRequest) {
if (editingConn.value) {
await store.updateConnection(editingConn.value.id, data)
} else {
await store.createConnection(data)
}
closeForm()
}
async function handleDelete(conn: Connection) {
if (!confirm(`确定删除连接「${conn.name}」?`)) return
await store.deleteConnection(conn.id)
}
function openTerminal(conn: Connection) {
router.push(`/terminal/${conn.id}`)
}
function openSftp(conn: Connection) {
router.push(`/sftp/${conn.id}`)
}
</script>
<template>
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-slate-100">连接列表</h2>
<button
@click="openCreate"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 cursor-pointer"
aria-label="添加连接"
>
<Plus class="w-5 h-5" aria-hidden="true" />
添加连接
</button>
</div>
<div v-if="store.connections.length === 0" class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700">
<Server class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
<p class="text-slate-400 mb-4">暂无连接</p>
<button
@click="openCreate"
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white transition-colors duration-200 cursor-pointer"
>
添加第一个连接
</button>
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="conn in store.connections"
:key="conn.id"
class="bg-slate-800 rounded-xl border border-slate-700 p-4 hover:border-slate-600 transition-colors duration-200"
>
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<div class="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
<Server class="w-5 h-5 text-cyan-400" aria-hidden="true" />
</div>
<div>
<h3 class="font-medium text-slate-100">{{ conn.name }}</h3>
<p class="text-sm text-slate-400">{{ conn.username }}@{{ conn.host }}:{{ conn.port }}</p>
</div>
</div>
<div class="flex items-center gap-1">
<button
@click="openEdit(conn)"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="编辑"
>
<Pencil class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="handleDelete(conn)"
class="p-2 rounded-lg text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
aria-label="删除"
>
<Trash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
<div class="flex items-center gap-2 mb-3">
<component
:is="conn.authType === 'PRIVATE_KEY' ? Key : Lock"
class="w-4 h-4 text-slate-500"
aria-hidden="true"
/>
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
</div>
<div class="flex gap-2">
<button
@click="openTerminal(conn)"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
>
<Terminal class="w-4 h-4" aria-hidden="true" />
终端
</button>
<button
@click="openSftp(conn)"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
>
<FolderOpen class="w-4 h-4" aria-hidden="true" />
文件
</button>
</div>
</div>
</div>
<ConnectionForm
v-if="showForm"
:connection="editingConn"
:on-save="handleSave"
@close="closeForm"
/>
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import * as authApi from '../api/auth'
import { LogIn } from 'lucide-vue-next'
const router = useRouter()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleSubmit() {
error.value = ''
loading.value = true
try {
const res = await authApi.login({ username: username.value, password: password.value })
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
router.push('/connections')
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string }; status?: number } }
error.value = err.response?.data?.message || (err.response?.status === 401 ? '用户名或密码错误' : '登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-slate-900 px-4">
<div class="w-full max-w-md">
<div class="bg-slate-800 rounded-xl shadow-xl border border-slate-700 p-8">
<div class="flex items-center gap-2 mb-6">
<div class="w-10 h-10 rounded-lg bg-cyan-600 flex items-center justify-center">
<LogIn class="w-6 h-6 text-white" aria-hidden="true" />
</div>
<h1 class="text-xl font-semibold text-slate-100">SSH 管理器</h1>
</div>
<h2 class="text-lg text-slate-300 mb-6">登录您的账户</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-slate-300 mb-1">
用户名
</label>
<input
id="username"
v-model="username"
type="text"
required
autocomplete="username"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-colors duration-200"
placeholder="admin"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-300 mb-1">
密码
</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="current-password"
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-colors duration-200"
placeholder="••••••••"
/>
</div>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="w-full py-2.5 px-4 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-slate-800"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<p class="mt-4 text-sm text-slate-500">
默认admin / admin123
</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,436 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import * as sftpApi from '../api/sftp'
import type { SftpFileInfo } from '../api/sftp'
import {
ArrowLeft,
FolderOpen,
File,
Upload,
FolderPlus,
RefreshCw,
Download,
Trash2,
ChevronRight,
Copy,
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const store = useConnectionsStore()
const connectionId = computed(() => Number(route.params.id))
const conn = ref(store.getConnection(connectionId.value))
const currentPath = ref('.')
const pathParts = ref<string[]>([])
const files = ref<SftpFileInfo[]>([])
const loading = ref(false)
const error = ref('')
const uploading = ref(false)
const selectedFile = ref<string | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const showTransferModal = ref(false)
const transferFile = ref<SftpFileInfo | null>(null)
const transferTargetConnectionId = ref<number | null>(null)
const transferTargetPath = ref('')
const transferring = ref(false)
const transferError = ref('')
onMounted(() => {
conn.value = store.getConnection(connectionId.value)
if (!conn.value) {
store.fetchConnections().then(() => {
conn.value = store.getConnection(connectionId.value)
initPath()
})
} else {
initPath()
}
})
function initPath() {
sftpApi.getPwd(connectionId.value).then((res) => {
const p = res.data.path || '/'
currentPath.value = p || '.'
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
loadPath()
}).catch(() => {
currentPath.value = '.'
pathParts.value = []
loadPath()
})
}
function loadPath() {
loading.value = true
error.value = ''
sftpApi
.listFiles(connectionId.value, currentPath.value)
.then((res) => {
files.value = res.data.sort((a, b) => {
if (a.directory !== b.directory) return a.directory ? -1 : 1
return a.name.localeCompare(b.name)
})
})
.catch(() => {
error.value = '获取文件列表失败'
})
.finally(() => {
loading.value = false
})
}
function navigateToDir(name: string) {
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
loadPath()
}
function navigateToIndex(i: number) {
if (i < 0) {
currentPath.value = '.'
} else {
currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
}
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
loadPath()
}
function goUp() {
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
return
}
const parts = currentPath.value.split('/').filter(Boolean)
if (parts.length <= 1) {
currentPath.value = '/'
pathParts.value = ['']
} else {
parts.pop()
currentPath.value = '/' + parts.join('/')
pathParts.value = parts
}
loadPath()
}
function handleFileClick(file: SftpFileInfo) {
if (file.directory) {
navigateToDir(file.name)
} else {
selectedFile.value = file.name
}
}
function handleDownload(file: SftpFileInfo) {
if (file.directory) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const path = base ? base + '/' + file.name : file.name
sftpApi.downloadFile(connectionId.value, path).catch(() => {
error.value = '下载失败'
})
}
function triggerUpload() {
fileInputRef.value?.click()
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const selected = input.files
if (!selected?.length) return
uploading.value = true
error.value = ''
const path = currentPath.value === '.' ? '' : currentPath.value
try {
for (let i = 0; i < selected.length; i++) {
await sftpApi.uploadFile(connectionId.value, path, selected[i])
}
loadPath()
} catch {
error.value = '上传失败'
} finally {
uploading.value = false
input.value = ''
}
}
function handleMkdir() {
const name = prompt('文件夹名称:')
if (!name?.trim()) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const path = base ? base + '/' + name : name
sftpApi.createDir(connectionId.value, path).then(() => loadPath()).catch(() => {
error.value = '创建文件夹失败'
})
}
function handleDelete(file: SftpFileInfo) {
if (!confirm(`确定删除「${file.name}」?`)) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const path = base ? base + '/' + file.name : file.name
sftpApi.deleteFile(connectionId.value, path, file.directory).then(() => loadPath()).catch(() => {
error.value = '删除失败'
})
}
const targetConnectionOptions = computed(() => {
const list = store.connections.filter((c) => c.id !== connectionId.value)
return list
})
async function openTransferModal(file: SftpFileInfo) {
if (file.directory) return
if (store.connections.length === 0) await store.fetchConnections()
transferFile.value = file
transferTargetConnectionId.value = targetConnectionOptions.value[0]?.id ?? null
transferTargetPath.value = currentPath.value === '.' || !currentPath.value ? '/' : currentPath.value
if (!transferTargetPath.value.endsWith('/')) transferTargetPath.value += '/'
transferError.value = ''
showTransferModal.value = true
}
function closeTransferModal() {
showTransferModal.value = false
transferFile.value = null
transferTargetConnectionId.value = null
transferTargetPath.value = ''
transferError.value = ''
}
async function submitTransfer() {
const file = transferFile.value
const targetId = transferTargetConnectionId.value
if (!file || targetId == null) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const sourcePath = base ? base + '/' + file.name : file.name
let targetPath = transferTargetPath.value.trim()
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
transferring.value = true
transferError.value = ''
try {
await sftpApi.transferRemote(connectionId.value, sourcePath, targetId, targetPath)
loadPath()
closeTransferModal()
} catch (err: unknown) {
const res = err as { response?: { data?: { error?: string } } }
transferError.value = res?.response?.data?.error ?? '传输失败'
} finally {
transferring.value = false
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleString()
}
</script>
<template>
<div class="h-full flex flex-col">
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
<button
@click="router.push('/connections')"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="返回"
>
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
</button>
<h2 class="text-lg font-semibold text-slate-100">
{{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }}
</h2>
</div>
<div class="flex-1 overflow-auto p-4">
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<div class="flex items-center gap-2 p-3 border-b border-slate-700 bg-slate-800/80">
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 flex-1">
<button
@click="navigateToIndex(-1)"
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
>
/
</button>
<template v-for="(part, i) in pathParts" :key="i">
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" aria-hidden="true" />
<button
@click="navigateToIndex(i)"
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[120px]"
>
{{ part || '/' }}
</button>
</template>
</nav>
<div class="flex items-center gap-1 flex-shrink-0">
<button
@click="triggerUpload"
:disabled="uploading"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
aria-label="上传"
>
<Upload class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="handleMkdir"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="新建文件夹"
>
<FolderPlus class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="loadPath"
:disabled="loading"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
aria-label="刷新"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
</button>
</div>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFileSelect"
/>
</div>
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
<div v-if="loading" class="p-8 text-center text-slate-400">
加载中...
</div>
<div v-else class="divide-y divide-slate-700">
<button
v-if="currentPath !== '.' && pathParts.length > 1"
@click="goUp"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left"
>
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
<span class="text-slate-400">..</span>
</button>
<button
v-for="file in files"
:key="file.name"
@click="handleFileClick(file)"
@dblclick="file.directory ? navigateToDir(file.name) : handleDownload(file)"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left group"
>
<component
:is="file.directory ? FolderOpen : File"
class="w-5 h-5 flex-shrink-0 text-slate-400"
aria-hidden="true"
/>
<span class="flex-1 truncate text-slate-200">{{ file.name }}</span>
<span v-if="!file.directory" class="text-sm text-slate-500 flex-shrink-0">
{{ formatSize(file.size) }}
</span>
<span class="text-sm text-slate-500 flex-shrink-0">{{ formatDate(file.mtime) }}</span>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
v-if="!file.directory"
@click.stop="handleDownload(file)"
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
aria-label="下载"
>
<Download class="w-4 h-4" aria-hidden="true" />
</button>
<button
v-if="!file.directory"
@click.stop="openTransferModal(file)"
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
aria-label="复制到远程"
>
<Copy class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click.stop="handleDelete(file)"
class="p-1.5 rounded text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
aria-label="删除"
>
<Trash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</button>
<div v-if="files.length === 0 && !loading" class="p-8 text-center text-slate-500">
空目录
</div>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="showTransferModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="transfer-modal-title"
>
<div class="w-full max-w-md rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
<h3 id="transfer-modal-title" class="text-lg font-semibold text-slate-100 mb-4">
复制到远程
</h3>
<p v-if="transferFile" class="text-sm text-slate-400 mb-3">
文件{{ transferFile.name }}
</p>
<div class="space-y-3">
<div>
<label for="transfer-target-conn" class="block text-sm text-slate-400 mb-1">目标连接</label>
<select
id="transfer-target-conn"
v-model.number="transferTargetConnectionId"
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
>
<option v-for="c in targetConnectionOptions" :key="c.id" :value="c.id">
{{ c.name }} ({{ c.username }}@{{ c.host }})
</option>
</select>
<p v-if="targetConnectionOptions.length === 0" class="mt-1 text-xs text-amber-400">
暂无其他连接请先在连接管理中添加
</p>
</div>
<div>
<label for="transfer-target-path" class="block text-sm text-slate-400 mb-1">目标路径目录以 / 结尾</label>
<input
id="transfer-target-path"
v-model="transferTargetPath"
type="text"
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
placeholder="/"
/>
</div>
</div>
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
@click="closeTransferModal"
:disabled="transferring"
class="rounded-lg border border-slate-600 px-4 py-2 text-slate-300 hover:bg-slate-700 disabled:opacity-50 cursor-pointer"
>
取消
</button>
<button
type="button"
@click="submitTransfer"
:disabled="transferring || transferTargetConnectionId == null"
class="rounded-lg bg-cyan-600 px-4 py-2 text-white hover:bg-cyan-500 disabled:opacity-50 cursor-pointer"
>
{{ transferring ? '传输中...' : '确定' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import TerminalWidget from '../components/TerminalWidget.vue'
import { ArrowLeft } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const store = useConnectionsStore()
const connectionId = computed(() => Number(route.params.id))
const conn = ref(store.getConnection(connectionId.value))
onMounted(() => {
conn.value = store.getConnection(connectionId.value)
if (!conn.value) {
store.fetchConnections().then(() => {
conn.value = store.getConnection(connectionId.value)
})
}
})
</script>
<template>
<div class="h-full flex flex-col">
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
<button
@click="router.push('/connections')"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="返回"
>
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
</button>
<h2 class="text-lg font-semibold text-slate-100">
{{ conn?.name || '终端' }} - {{ conn?.username }}@{{ conn?.host }}
</h2>
</div>
<div class="flex-1 min-h-0 p-4">
<TerminalWidget v-if="conn" :connection-id="conn.id" />
<div v-else class="flex items-center justify-center h-64 text-slate-400">
加载中...
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
slate: {
850: '#172033',
}
}
},
},
plugins: [],
}

View File

@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
})