Private
Public Access
1
0

新增:SFTP直连+连接池+autoConnect+文件服务器端口自动回退

- SFTP模块:连接/断开/文件CRUD/系统信息采集/base64二进制写入
- 连接池:多服务器同时在线,瞬间切换profile
- autoConnect:启动时自动连接所有非本地服务器
- 端口自动回退:listenWithFallback消除TOCTOU,解决端口冲突崩溃
- 文件服务器URL集中管理:file-server.ts消除8+处硬编码端口
- Sidebar设置面板:添加服务器/自动连接/自动刷新开关
- 修复:validateFilePath越界panic、正则预编译
- 修复:注释准确性(RemoveAll/端口8073/动态端口文档)
This commit is contained in:
2026-05-04 15:33:19 +08:00
parent 6eaaa56eb6
commit 6bee55b96f
41 changed files with 2620 additions and 458 deletions

View File

@@ -97,7 +97,7 @@ func Default() *Config {
Format: "json",
},
FileServer: FileServerConfig{
Port: 8073,
Port: 2652,
MaxFileSize: 500 * 1024 * 1024,
},
Security: SecurityConfig{

View File

@@ -46,8 +46,8 @@ func (h *Handler) HTMLPreviewProxy(c echo.Context) error {
}
theme := c.QueryParam("theme")
targetURL := fmt.Sprintf("http://localhost:8073/localfs/html-preview?path=%s&theme=%s",
url.QueryEscape(clean), url.QueryEscape(theme))
targetURL := fmt.Sprintf("%s/localfs/html-preview?path=%s&theme=%s",
h.cfg.FileServerAddr(), url.QueryEscape(clean), url.QueryEscape(theme))
resp, err := http.Get(targetURL)
if err != nil {

View File

@@ -1,6 +1,7 @@
package handler
import (
"fmt"
"net/http"
"os"
"runtime"
@@ -9,6 +10,9 @@ import (
"u-desk/internal/agent/model"
"github.com/labstack/echo/v4"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/mem"
)
// Ping 健康检查
@@ -111,3 +115,39 @@ func (h *Handler) Drives(c echo.Context) error {
return c.JSON(http.StatusOK, model.OK(drives))
}
// Stats 返回系统资源使用统计CPU/内存/磁盘)
func (h *Handler) Stats(c echo.Context) error {
info := make(map[string]interface{})
// CPU
if cores, err := cpu.Counts(true); err == nil {
info["cpu_cores"] = cores
}
if percents, err := cpu.Percent(0, false); err == nil && len(percents) > 0 {
info["cpu_usage"] = fmt.Sprintf("%.0f%%", percents[0])
}
// 内存
if memInfo, err := mem.VirtualMemory(); err == nil {
info["mem_total"] = memInfo.Total
info["mem_used"] = memInfo.Used
info["mem_usage"] = fmt.Sprintf("%.0f%%", memInfo.UsedPercent)
}
// 磁盘(取根分区)
if partitions, err := disk.Partitions(false); err == nil {
for _, p := range partitions {
if p.Mountpoint == "/" || (runtime.GOOS == "windows" && len(p.Mountpoint) == 3 && p.Mountpoint[1] == ':') {
if usage, err := disk.Usage(p.Mountpoint); err == nil {
info["disk_total"] = usage.Total
info["disk_used"] = usage.Used
info["disk_usage"] = fmt.Sprintf("%.0f%%", usage.UsedPercent)
}
break
}
}
}
return c.JSON(http.StatusOK, model.OK(info))
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
@@ -15,6 +16,8 @@ import (
"time"
)
const DefaultFileServerPort = 2652
// 预编译正则表达式(避免每次调用重复编译)
var (
// CSS 相关
@@ -44,6 +47,7 @@ var (
// HTML 预览路径修复
locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`)
winDriveRegex = regexp.MustCompile(`^[A-Za-z]:`)
)
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
@@ -80,7 +84,7 @@ func validateFilePath(rawPath string, logPrefix string) (string, error) {
filePath = filepath.Clean(filePath)
// 确保绝对路径Linux 以 / 开头Windows 以盘符开头)
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && (len(filePath) < 2 || filePath[1] != ':') {
filePath = "/" + filePath
}
@@ -103,40 +107,22 @@ var (
localFileServerOnce sync.Once
)
// StartLocalFileServer 启动本地文件服务器
// StartLocalFileServer 启动本地文件服务器(端口被占用时自动递增)
func StartLocalFileServer() (string, error) {
var initErr error
localFileServerOnce.Do(func() {
// 创建多路复用器
mux := http.NewServeMux()
// 注册 /localfs/ 路由
mux.HandleFunc("/localfs/", handleLocalFileRequest)
// 注册 HTML 预览专用路由
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
// 创建服务器(固定端口)
server := &http.Server{
Addr: "localhost:8073",
Handler: mux,
addr, srv, err := listenWithFallback(DefaultFileServerPort, mux)
if err != nil {
initErr = fmt.Errorf("无法绑定端口(%d起始): %w", DefaultFileServerPort, err)
return
}
// 启动服务器
go func() {
log.Printf("[LocalFileServer] 正在启动...")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[LocalFileServer] 启动失败: %v", err)
initErr = err
}
}()
localFileServer = &LocalFileServer{
server: server,
addr: "localhost:8073",
}
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
localFileServer = &LocalFileServer{server: srv, addr: addr}
log.Printf("[LocalFileServer] 已启动,监听: %s", addr)
})
if localFileServer == nil {
@@ -145,6 +131,33 @@ func StartLocalFileServer() (string, error) {
return localFileServer.addr, initErr
}
// listenWithFallback 从 basePort 开始尝试绑定,递增直到成功(最多试 10 次)
// 返回的 server 已在 goroutine 中 Serve(l),调用方无需再启动
func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *http.Server, err error) {
for offset := 0; offset < 10; offset++ {
port := basePort + offset
addr = fmt.Sprintf("localhost:%d", port)
l, e := net.Listen("tcp", addr)
if e == nil {
srv = &http.Server{Handler: handler}
go func() {
if se := srv.Serve(l); se != http.ErrServerClosed {
log.Printf("[LocalFileServer] 异常退出: %v", se)
}
}()
return addr, srv, nil
}
log.Printf("[LocalFileServer] 端口 %d 被占用,尝试 %d...", port, port+1)
}
return "", nil, fmt.Errorf("端口 %d-%d 均不可用", basePort, basePort+9)
}
// GetLocalFileServerAddr 返回实际绑定的地址(含动态分配的端口)
func GetLocalFileServerAddr() string {
if localFileServer == nil { return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort) }
return localFileServer.addr
}
// handleLocalFileRequest 处理本地文件请求
func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
// CORS 头:允许所有源访问(因为这是本地文件服务器)
@@ -177,7 +190,7 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
return
}
// 仅对非绝对路径添加前导 /Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /
if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) {
if !strings.HasPrefix(pathPart, "/") && !winDriveRegex.MatchString(pathPart) {
pathPart = "/" + pathPart
}

View File

@@ -53,8 +53,8 @@ func OpenPath(path string) error {
// ========== 工具函数 ==========
// formatBytes 格式化字节大小为人类可读格式
func formatBytes(bytes int64) string {
// FormatBytes 格式化字节大小为人类可读格式(导出供 sftp 等外部包使用)
func FormatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)

View File

@@ -258,7 +258,7 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
Path: filepath.ToSlash(path), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: formatBytes(info.Size()),
SizeStr: FormatBytes(info.Size()),
IsDir: info.IsDir(),
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -341,7 +341,7 @@ func (s *FileSystemService) CreateDir(path string) (*FileOperationResult, error)
Path: filepath.ToSlash(path), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: formatBytes(info.Size()),
SizeStr: FormatBytes(info.Size()),
IsDir: true,
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -389,7 +389,7 @@ func (s *FileSystemService) CreateFile(path string) (*FileOperationResult, error
Path: filepath.ToSlash(path), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: formatBytes(info.Size()),
SizeStr: FormatBytes(info.Size()),
IsDir: false,
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -414,7 +414,7 @@ func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, er
"name": info.Name(),
"path": filepath.ToSlash(path), // 统一使用正斜杠
"size": info.Size(),
"size_str": formatBytes(info.Size()),
"size_str": FormatBytes(info.Size()),
"is_dir": info.IsDir(),
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
"mode": info.Mode().String(),
@@ -470,7 +470,7 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
Path: filepath.ToSlash(newPath), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: formatBytes(info.Size()),
SizeStr: FormatBytes(info.Size()),
IsDir: info.IsDir(),
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),

View File

@@ -0,0 +1,41 @@
package service
import (
"u-desk/internal/storage"
"u-desk/internal/storage/models"
)
// ProfileService 连接配置 CRUD
type ProfileService struct{}
func NewProfileService() *ProfileService { return &ProfileService{} }
func (s *ProfileService) ListProfiles() ([]models.ConnectionProfile, error) {
db := storage.GetDB()
var list []models.ConnectionProfile
err := db.Order("sort_order asc, id asc").Find(&list).Error
return list, err
}
func (s *ProfileService) GetProfile(id uint) (*models.ConnectionProfile, error) {
db := storage.GetDB()
var p models.ConnectionProfile
err := db.First(&p, id).Error
if err != nil {
return nil, err
}
return &p, nil
}
func (s *ProfileService) SaveProfile(p *models.ConnectionProfile) error {
db := storage.GetDB()
if p.ID == 0 {
return db.Create(p).Error
}
return db.Save(p).Error
}
func (s *ProfileService) DeleteProfile(id uint) error {
db := storage.GetDB()
return db.Delete(&models.ConnectionProfile{}, id).Error
}

260
internal/sftp/client.go Normal file
View File

@@ -0,0 +1,260 @@
package sftp
import (
"fmt"
"net"
"os"
"sync"
"time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// Client SFTP 客户端封装(单连接)
type Client struct {
config *Config
client *sftp.Client
sshClient *ssh.Client
mu sync.Mutex
}
// Manager 全局 SFTP 连接管理器(以 host:port 为 key 的连接池)
type Manager struct {
clients sync.Map // map[string]*Client
}
var globalManager = &Manager{}
// GetManager 获取全局连接管理器
func GetManager() *Manager {
return globalManager
}
// Connect 创建或复用 SFTP 连接
func (m *Manager) Connect(config *Config) (*Client, error) {
key := fmt.Sprintf("%s:%d", config.Host, config.Port)
if existing, ok := m.clients.Load(key); ok {
c := existing.(*Client)
if c.IsHealthy() {
return c, nil
}
c.Close()
m.clients.Delete(key)
}
c, err := newClient(config)
if err != nil {
return nil, err
}
m.clients.Store(key, c)
return c, nil
}
// GetClient 获取已有连接(不复用也不新建)
func (m *Manager) GetClient(connID string) *Client {
if val, ok := m.clients.Load(connID); ok {
return val.(*Client)
}
return nil
}
// Disconnect 关闭并移除指定连接
func (m *Manager) Disconnect(host string, port int) {
key := fmt.Sprintf("%s:%d", host, port)
if val, ok := m.clients.LoadAndDelete(key); ok {
val.(*Client).Close()
}
}
// Shutdown 关闭所有连接
func (m *Manager) Shutdown() {
m.clients.Range(func(key, value any) bool {
value.(*Client).Close()
m.clients.Delete(key)
return true
})
}
// --- 内部 ---
func newClient(config *Config) (*Client, error) {
sshConfig := &ssh.ClientConfig{
Config: ssh.Config{
KeyExchanges: []string{
"curve25519-sha256", "curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256", "ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1",
},
},
User: config.Username,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: config.Timeout,
}
// 认证方式选择
if config.KeyPath != "" {
key, err := os.ReadFile(config.KeyPath)
if err != nil {
return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("读取密钥文件失败: %w", err)}
}
var signer ssh.Signer
if config.KeyPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(config.KeyPassphrase))
} else {
signer, err = ssh.ParsePrivateKey(key)
}
if err != nil {
return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("解析密钥失败: %w", err)}
}
sshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
} else if config.Password != "" {
pw := config.Password
sshConfig.Auth = []ssh.AuthMethod{
ssh.Password(pw),
ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
answers[i] = pw
}
return answers, nil
}),
}
} else {
return nil, &ConnectionError{Op: "auth", Err: fmt.Errorf("必须提供密码或密钥文件")}
}
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
sshConn, err := net.DialTimeout("tcp", addr, config.Timeout)
if err != nil {
return nil, &ConnectionError{Op: "dial", Err: err}
}
sshConnConn, chans, reqs, err := ssh.NewClientConn(sshConn, addr, sshConfig)
if err != nil {
sshConn.Close()
return nil, &ConnectionError{Op: "handshake", Err: err}
}
sshClient := ssh.NewClient(sshConnConn, chans, reqs)
sftpClient, err := sftp.NewClient(sshClient)
if err != nil {
sshClient.Close()
return nil, &ConnectionError{Op: "sftp_init", Err: err}
}
return &Client{
config: config,
client: sftpClient,
sshClient: sshClient,
}, nil
}
// IsHealthy 检查连接是否健康(先取引用再解锁,避免持锁做 I/O
func (c *Client) IsHealthy() bool {
c.mu.Lock()
client := c.client
c.mu.Unlock()
if client == nil {
return false
}
_, err := client.Stat("/")
return err == nil
}
// WithRetry 带重试的操作执行(自动处理断线重连)
func (c *Client) WithRetry(fn func(*sftp.Client) error) error {
const maxRetries = 3
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration((attempt+1)*2) * time.Second)
if reconnectErr := c.reconnect(); reconnectErr != nil {
lastErr = reconnectErr
continue
}
}
c.mu.Lock()
client := c.client
c.mu.Unlock()
if client == nil {
lastErr = fmt.Errorf("SFTP 客户端未初始化")
continue
}
if err := fn(client); err != nil {
if isNetworkError(err) {
lastErr = err
continue
}
return err
}
return nil
}
return fmt.Errorf("操作失败(已重试 %d 次): %w", maxRetries, lastErr)
}
func (c *Client) reconnect() error {
nc, err := newClient(c.config)
if err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
c.closeLocked()
c.client = nc.client
c.sshClient = nc.sshClient
return nil
}
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
c.closeLocked()
}
func (c *Client) closeLocked() {
if c.client != nil {
c.client.Close()
c.client = nil
}
if c.sshClient != nil {
c.sshClient.Close()
c.sshClient = nil
}
}
// RunCommand 通过 SSH Session 执行远程命令,返回 stdout
func (c *Client) RunCommand(cmd string) (string, error) {
c.mu.Lock()
sshClient := c.sshClient
c.mu.Unlock()
if sshClient == nil {
return "", fmt.Errorf("SSH 客户端未初始化")
}
session, err := sshClient.NewSession()
if err != nil {
return "", fmt.Errorf("创建 SSH 会话失败: %w", err)
}
defer session.Close()
out, err := session.CombinedOutput(cmd)
if err != nil {
return "", fmt.Errorf("执行命令失败 [%s]: %w", cmd, err)
}
return string(out), nil
}
// RawClient 获取底层 sftp.Client高级用法
func (c *Client) RawClient() *sftp.Client {
c.mu.Lock()
defer c.mu.Unlock()
return c.client
}

22
internal/sftp/config.go Normal file
View File

@@ -0,0 +1,22 @@
package sftp
import "time"
// Config SFTP 连接配置
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
KeyPath string `json:"key_path"`
KeyPassphrase string `json:"key_passphrase"`
Timeout time.Duration `json:"timeout"`
}
// DefaultConfig 返回默认配置
func DefaultConfig() *Config {
return &Config{
Port: 22,
Timeout: 15 * time.Second,
}
}

69
internal/sftp/errors.go Normal file
View File

@@ -0,0 +1,69 @@
package sftp
import (
"errors"
"fmt"
"strings"
)
// ConnectionError SFTP 连接错误(区分阶段)
type ConnectionError struct {
Op string // dial / handshake / auth / sftp_init
Err error
}
func (e *ConnectionError) Error() string {
return fmt.Sprintf("SFTP 连接失败 [%s]: %v", e.Op, e.Err)
}
func (e *ConnectionError) Unwrap() error { return e.Err }
// ToUserMessage 将底层错误转换为用户友好的中文错误(返回 error 类型)
func ToUserMessage(err error) error {
if err == nil {
return nil
}
var connErr *ConnectionError
if errors.As(err, &connErr) {
switch connErr.Op {
case "dial":
return fmt.Errorf("无法连接到服务器 (%v)。请检查地址和端口是否正确", connErr.Err)
case "handshake":
return fmt.Errorf("SSH 握手失败,请检查服务是否正常运行")
case "auth":
return fmt.Errorf("认证失败,请检查用户名和密码/密钥是否正确")
case "sftp_init":
return fmt.Errorf("SFTP 子系统初始化失败,请确认远程服务器支持 SFTP")
}
}
msg := err.Error()
switch {
case strings.Contains(msg, "unable authenticate"), strings.Contains(msg, "no auth method"):
return fmt.Errorf("认证失败:用户名或密码/密钥不正确")
case strings.Contains(msg, "connection refused"):
return fmt.Errorf("连接被拒绝:目标机器可能未开启 SSH 服务")
case strings.Contains(msg, "timeout"), strings.Contains(msg, "i/o timeout"):
return fmt.Errorf("连接超时:请检查网络连通性或防火墙设置")
case strings.Contains(msg, "host key mismatch"):
return fmt.Errorf("主机密钥不匹配:可能存在中间人攻击风险")
case strings.Contains(msg, "permission denied"):
return fmt.Errorf("权限不足:当前用户无权访问该资源")
case strings.Contains(msg, "no such file"), strings.Contains(msg, "not found"):
return fmt.Errorf("文件或目录不存在")
default:
return err
}
}
// isNetworkError 判断是否为网络层错误(需要重连)
func isNetworkError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "broken pipe") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "timeout")
}

499
internal/sftp/service.go Normal file
View File

@@ -0,0 +1,499 @@
package sftp
import (
"encoding/base64"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"u-desk/internal/filesystem"
sftpclient "github.com/pkg/sftp"
)
// Service SFTP 文件操作服务
type Service struct {
manager *Manager
}
// NewService 创建 SFTP 服务实例
func NewService() *Service {
return &Service{manager: GetManager()}
}
// GetManager 获取底层连接管理器(供 App 层调用)
func (s *Service) GetManager() *Manager {
return s.manager
}
// ConnID 从配置生成连接标识符
func ConnID(host string, port int) string {
return fmt.Sprintf("%s:%d", host, port)
}
// --- 核心文件操作 ---
func (s *Service) ListDir(connID string, dirPath string) ([]map[string]interface{}, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
var entries []fs.FileInfo
err = c.WithRetry(func(sc *sftpclient.Client) error {
var e error
entries, e = sc.ReadDir(dirPath)
return e
})
if err != nil {
return nil, fmt.Errorf("读取目录失败: %w", err)
}
result := make([]map[string]interface{}, 0, len(entries))
for _, info := range entries {
fullPath := path.Join(dirPath, info.Name())
result = append(result, map[string]interface{}{
"name": info.Name(),
"path": toUnixPath(fullPath),
"is_dir": info.IsDir(),
"size": info.Size(),
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
})
}
return result, nil
}
func (s *Service) ReadFile(connID string, filePath string) (string, error) {
c, err := s.getClient(connID)
if err != nil {
return "", err
}
// 大小限制(与本地模式 ReadFile 的 10MB 上限对齐)
const maxSize int64 = 10 << 20
err = c.WithRetry(func(sc *sftpclient.Client) error {
fi, e := sc.Stat(filePath)
if e != nil { return e }
if fi.Size() > maxSize {
return fmt.Errorf("文件过大 (%s),超过 %d 限制", filesystem.FormatBytes(fi.Size()), maxSize)
}
return nil
})
if err != nil {
return "", err
}
var data []byte
err = c.WithRetry(func(sc *sftpclient.Client) error {
f, e := sc.Open(filePath)
if e != nil {
return e
}
defer f.Close()
data, e = io.ReadAll(f)
return e
})
if err != nil {
return "", fmt.Errorf("读取文件失败: %w", err)
}
return string(data), nil
}
func (s *Service) WriteFile(connID string, filePath string, content string) error {
c, err := s.getClient(connID)
if err != nil {
return err
}
return c.WithRetry(func(sc *sftpclient.Client) error {
f, e := sc.Create(filePath)
if e != nil {
return fmt.Errorf("创建文件失败: %w", e)
}
defer f.Close()
_, e = f.Write([]byte(content))
return e
})
}
// WriteBase64File 将 base64 编码的二进制内容写入远程文件(用于粘贴图片等场景)
func (s *Service) WriteBase64File(connID string, filePath string, base64Content string) error {
c, err := s.getClient(connID)
if err != nil {
return err
}
data, err := base64.StdEncoding.DecodeString(base64Content)
if err != nil {
return fmt.Errorf("base64 解码失败: %w", err)
}
return c.WithRetry(func(sc *sftpclient.Client) error {
f, e := sc.Create(filePath)
if e != nil {
return fmt.Errorf("创建文件失败: %w", e)
}
defer f.Close()
_, e = f.Write(data)
return e
})
}
func (s *Service) GetFileInfo(connID string, filePath string) (map[string]interface{}, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
var info fs.FileInfo
err = c.WithRetry(func(sc *sftpclient.Client) error {
var e error
info, e = sc.Stat(filePath)
return e
})
if err != nil {
return nil, fmt.Errorf("获取文件信息失败: %w", err)
}
return map[string]interface{}{
"name": info.Name(),
"path": toUnixPath(filePath),
"size": info.Size(),
"size_str": filesystem.FormatBytes(info.Size()),
"is_dir": info.IsDir(),
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
"mode": info.Mode().String(),
}, nil
}
func (s *Service) CreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
err = c.WithRetry(func(sc *sftpclient.Client) error {
return sc.MkdirAll(dirPath)
})
if err != nil {
return nil, fmt.Errorf("创建目录失败: %w", err)
}
infoMap, _ := s.GetFileInfo(connID, dirPath)
return toFileOperationResult(infoMap, true), nil
}
func (s *Service) CreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
err = c.WithRetry(func(sc *sftpclient.Client) error {
f, e := sc.Create(filePath)
if e != nil {
return e
}
return f.Close()
})
if err != nil {
return nil, fmt.Errorf("创建文件失败: %w", err)
}
infoMap, _ := s.GetFileInfo(connID, filePath)
return toFileOperationResult(infoMap, false), nil
}
func (s *Service) DeletePath(connID string, filePath string) (*filesystem.FileOperationResult, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
infoMap, _ := s.GetFileInfo(connID, filePath)
err = c.WithRetry(func(sc *sftpclient.Client) error {
fi, e := sc.Stat(filePath)
if e != nil {
return e
}
if fi.IsDir() {
// 递归删除目录
return sc.RemoveAll(filePath)
}
return sc.Remove(filePath)
})
if err != nil {
return nil, fmt.Errorf("删除失败: %w", err)
}
result := toFileOperationResult(infoMap, false)
result.Deleted = true
return result, nil
}
func (s *Service) RenamePath(connID string, oldPath, newPath string) (*filesystem.FileOperationResult, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
err = c.WithRetry(func(sc *sftpclient.Client) error {
return sc.Rename(oldPath, newPath)
})
if err != nil {
return nil, fmt.Errorf("重命名失败: %w", err)
}
infoMap, _ := s.GetFileInfo(connID, newPath)
result := toFileOperationResult(infoMap, false)
result.OldPath = oldPath
return result, nil
}
// DownloadToTemp 下载远程文件到本地临时目录(用于预览)
func (s *Service) DownloadToTemp(connID string, remotePath string) (string, error) {
c, err := s.getClient(connID)
if err != nil {
return "", err
}
// 预览文件大小上限 50MB比编辑模式宽松
const maxPreviewSize int64 = 50 << 20
err = c.WithRetry(func(sc *sftpclient.Client) error {
fi, e := sc.Stat(remotePath)
if e != nil { return e }
if fi.Size() > maxPreviewSize {
return fmt.Errorf("预览文件过大: %s", filesystem.FormatBytes(fi.Size()))
}
return nil
})
if err != nil {
return "", err
}
tmpDir := os.TempDir()
// 用时间戳+随机数避免同名文件覆盖
localPath := filepath.Join(tmpDir, fmt.Sprintf("udesk-sftp-preview-%d-%s", time.Now().UnixNano(), filepath.Base(remotePath)))
err = c.WithRetry(func(sc *sftpclient.Client) error {
src, e := sc.Open(remotePath)
if e != nil {
return e
}
defer src.Close()
dst, e := os.Create(localPath)
if e != nil {
return e
}
defer dst.Close()
_, e = io.Copy(dst, src)
return e
})
if err != nil {
return "", fmt.Errorf("下载文件失败: %w", err)
}
return localPath, nil
}
// GetCommonPaths 返回 SFTP 远程主机常用路径
func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
c := s.manager.GetClient(connID)
username := "root"
if c != nil {
c.mu.Lock()
username = c.config.Username
c.mu.Unlock()
}
home := "/root"
if username != "root" && username != "" {
home = fmt.Sprintf("/home/%s", username)
}
return map[string]string{
"home": home,
"tmp": "/tmp",
"root": "/",
}, nil
}
// CleanupTempFiles 清理遗留的临时预览文件
func CleanupTempFiles() {
tmpDir := os.TempDir()
entries, err := os.ReadDir(tmpDir)
if err != nil {
return
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "udesk-sftp-preview-") {
os.Remove(filepath.Join(tmpDir, entry.Name()))
}
}
}
// GetSystemInfo 通过 SSH 命令采集远程系统信息(磁盘/CPU/内存)
func (s *Service) GetSystemInfo(connID string) (map[string]interface{}, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
type cmdResult struct {
key string // "df" | "mem" | "cpu"
output string
err error
}
// 并发执行三条命令(每条独立超时)
results := make(chan cmdResult, 3)
cmdTimeout := 6 * time.Second
runCmd := func(key, cmd string) {
out, e := c.RunCommand(cmd)
results <- cmdResult{key, out, e}
}
go runCmd("df", "df -B1 / 2>/dev/null | tail -1")
go runCmd("mem", "free -b 2>/dev/null | grep -E '^Mem:' || cat /proc/meminfo 2>/dev/null | head -2")
go runCmd("cpu", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
var dfOut, memOut, cpuOut string
hasErr := false
for i := 0; i < 3; i++ {
select {
case r := <-results:
switch r.key {
case "df":
dfOut = r.output
case "mem":
memOut = r.output
case "cpu":
cpuOut = r.output
}
if r.err != nil {
hasErr = true
}
case <-time.After(cmdTimeout):
hasErr = true
}
}
info := make(map[string]interface{})
// 解析 CPU 使用率
cpuOut = strings.TrimSpace(cpuOut)
if usage, err := strconv.ParseFloat(cpuOut, 64); err == nil && usage >= 0 {
info["cpu_usage"] = fmt.Sprintf("%.0f%%", usage)
}
// 解析磁盘信息: df -B1 / → Filesystem 1M-blocks Used Available Use% Mounted on
dfOut = strings.TrimSpace(dfOut)
if dfFields := strings.Fields(dfOut); len(dfFields) >= 5 {
var diskTotal, diskUsed uint64
if v, err := strconv.ParseUint(dfFields[1], 10, 64); err == nil {
diskTotal = v
info["disk_total"] = v
}
if v, err := strconv.ParseUint(dfFields[2], 10, 64); err == nil {
diskUsed = v
info["disk_used"] = v
}
if diskTotal > 0 {
info["disk_usage"] = fmt.Sprintf("%.0f%%", float64(diskUsed)/float64(diskTotal)*100)
}
}
// 解析内存信息: free -b | grep Mem: 或 /proc/meminfo
memOut = strings.TrimSpace(memOut)
if strings.Contains(memOut, "MemTotal:") {
parseProcMeminfo(memOut, info)
} else if fields := strings.Fields(memOut); len(fields) >= 3 {
var memTotal, memUsed uint64
if v, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
memTotal = v
info["mem_total"] = v
}
if v, err := strconv.ParseUint(fields[2], 10, 64); err == nil {
memUsed = v
info["mem_used"] = v
}
if memTotal > 0 {
info["mem_usage"] = fmt.Sprintf("%.0f%%", float64(memUsed)/float64(memTotal)*100)
}
}
if hasErr && len(info) == 0 {
return nil, fmt.Errorf("采集远程系统信息失败")
}
return info, nil
}
func parseProcMeminfo(output string, info map[string]interface{}) {
lines := strings.Split(output, "\n")
memMap := make(map[string]uint64)
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) >= 2 {
key := strings.TrimSuffix(fields[0], ":")
if val, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
memMap[key] = val
}
}
}
total := memMap["MemTotal"] * 1024 // kB → bytes
// 可用内存 ≈ MemAvailable (较新内核) 或 MemFree + Buffers + Cached
free := memMap["MemFree"]
if avail, ok := memMap["MemAvailable"]; ok {
free = avail
} else {
free += memMap["Buffers"] + memMap["Cached"]
}
used := total - free*1024
info["mem_total"] = total
info["mem_used"] = used
if total > 0 {
info["mem_usage"] = fmt.Sprintf("%.0f%%", float64(used)/float64(total)*100)
}
}
// --- 内部辅助 ---
func (s *Service) getClient(connID string) (*Client, error) {
c := s.manager.GetClient(connID)
if c == nil {
return nil, fmt.Errorf("SFTP 连接不存在: %s", connID)
}
return c, nil
}
func toUnixPath(p string) string {
return strings.ReplaceAll(p, "\\", "/")
}
func toFileOperationResult(m map[string]interface{}, isDir bool) *filesystem.FileOperationResult {
name, _ := m["name"].(string)
p, _ := m["path"].(string)
size, _ := m["size"].(int64)
modTime, _ := m["mod_time"].(string)
mode, _ := m["mode"].(string)
return &filesystem.FileOperationResult{
Path: p,
Name: name,
Size: size,
SizeStr: filesystem.FormatBytes(size),
IsDir: isDir,
ModTime: modTime,
Mode: mode,
}
}

View File

@@ -0,0 +1,22 @@
package models
import "time"
// ConnectionProfile 连接配置模型SQLite 持久化)
type ConnectionProfile struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
Host string `gorm:"type:varchar(255)" json:"host"`
Port int `gorm:"default:22" json:"port"`
Username string `gorm:"type:varchar(100);default:root" json:"username"`
Password string `gorm:"type:text" json:"password"`
KeyPath string `gorm:"type:text" json:"key_path"`
Type string `gorm:"type:varchar(20);not null;index" json:"type"` // local|remote|sftp
Token string `gorm:"type:text" json:"token"`
LastConnected *time.Time `json:"last_connected"`
SortOrder int `gorm:"default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ConnectionProfile) TableName() string { return "connection_profiles" }

View File

@@ -63,6 +63,7 @@ func InitFast() (*gorm.DB, error) {
// SQLite 的 AutoMigrate 很快,不会造成明显延迟
if err := db.AutoMigrate(
&models.AppConfig{},
&models.ConnectionProfile{},
); err != nil {
return nil, err
}