新增:SFTP直连+连接池+autoConnect+文件服务器端口自动回退
- SFTP模块:连接/断开/文件CRUD/系统信息采集/base64二进制写入 - 连接池:多服务器同时在线,瞬间切换profile - autoConnect:启动时自动连接所有非本地服务器 - 端口自动回退:listenWithFallback消除TOCTOU,解决端口冲突崩溃 - 文件服务器URL集中管理:file-server.ts消除8+处硬编码端口 - Sidebar设置面板:添加服务器/自动连接/自动刷新开关 - 修复:validateFilePath越界panic、正则预编译 - 修复:注释准确性(RemoveAll/端口8073/动态端口文档)
This commit is contained in:
260
internal/sftp/client.go
Normal file
260
internal/sftp/client.go
Normal 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
22
internal/sftp/config.go
Normal 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
69
internal/sftp/errors.go
Normal 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
499
internal/sftp/service.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user