feat: save short cut image to remote by ssh

- add settings for ssh config
- implement save remote function by ssh
This commit is contained in:
2026-06-11 17:10:58 +08:00
parent 3e4d071b4c
commit 050f2b74c3
17 changed files with 1463 additions and 46 deletions
+135
View File
@@ -0,0 +1,135 @@
// capture_ssh.go — application-level service that uploads a screenshot to
// a remote host via SSH/SCP.
//
// Design rationale:
// - Mirrors CaptureAndUploadService so the UI layer can call a single
// well-defined entrypoint per destination kind.
// - Depends on a small Uploader interface rather than the concrete SSH
// adapter so the service is unit-testable without a real SSH server.
// - The remote object key is generated through the same date-based
// scheme used for S3 uploads (see buildObjectKey in capture_upload.go)
// so users get a consistent layout regardless of destination.
package application
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/mmmy/snapgo/internal/domain"
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
)
// sshSvcLog returns an application-layer SSH logger bound to the CURRENT
// default handler.
//
// 为什么是函数而非包级 slog.With 变量: 包级变量会在 main() 调用
// logging.Init() 之前被初始化, 捕获到 bootstrap handler, 之后 SetDefault
// 切换 handler 时会产生双前缀日志. 惰性读取 slog.Default() 可避免该问题.
func sshSvcLog() *slog.Logger { return slog.Default().With("component", "application.ssh") }
// SSHUploader abstracts the SSH/SCP adapter so this layer does not depend
// on golang.org/x/crypto/ssh directly. The infrastructure package wires a
// concrete implementation through a small adapter below.
type SSHUploader interface {
// Upload writes `data` to remoteRelPath (relative to the user's $HOME)
// with the given file mode and returns once the remote has acknowledged
// the transfer.
Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error
}
// CaptureAndSSHService wires capture-bytes → SCP → clipboard → notify.
type CaptureAndSSHService struct {
Uploader SSHUploader
Clipboard clipboard.Writer
Notifier Notifier
// Cfg is the active SSH configuration; we keep a copy on the service
// so PathPrefix and PublicURLBase are explicitly part of the contract
// rather than reaching into a shared global.
Cfg domain.SSHConfig
}
// ExecuteWithBytes uploads previously-captured PNG bytes via SCP and copies
// either a public URL (if configured) or the remote-relative path to the
// clipboard. Either is useful: a URL for sharing, a path so the user can
// inspect / scp it manually.
func (s *CaptureAndSSHService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error {
if s.Uploader == nil {
sshSvcLog().Warn("ExecuteWithBytes aborted: uploader nil")
s.notifyFailure("SSH not configured")
return fmt.Errorf("ssh uploader is nil")
}
if len(pngBytes) == 0 {
sshSvcLog().Warn("ExecuteWithBytes aborted: empty screenshot")
s.notifyFailure("empty screenshot")
return fmt.Errorf("empty screenshot")
}
relPath := buildRemoteRelPath(s.Cfg.PathPrefix)
sshSvcLog().Info("save-remote pipeline start",
"host", s.Cfg.Host, "user", s.Cfg.User, "port", s.Cfg.Port,
"size", len(pngBytes), "remote_path", relPath,
"strict_host_key", s.Cfg.StrictHostKey)
start := time.Now()
if err := s.Uploader.Upload(ctx, relPath, pngBytes, 0o644); err != nil {
sshSvcLog().Error("save-remote upload failed",
"remote_path", relPath, "elapsed", time.Since(start), "err", err)
s.notifyFailure("ssh upload failed: " + err.Error())
return err
}
sshSvcLog().Info("save-remote upload ok",
"remote_path", relPath, "elapsed", time.Since(start))
clipText := remoteShareText(relPath)
if s.Clipboard != nil {
if err := s.Clipboard.WriteText(clipText); err != nil {
sshSvcLog().Error("save-remote clipboard write failed", "err", err)
s.notifyFailure("clipboard write failed: " + err.Error())
return err
}
sshSvcLog().Debug("save-remote clipboard write ok", "text", clipText)
}
if s.Notifier != nil {
s.Notifier.NotifySuccess(clipText)
}
sshSvcLog().Info("save-remote pipeline done",
"remote_path", relPath, "total_elapsed", time.Since(start))
return nil
}
// remoteShareText returns the clipboard string for an SSH upload.
//
// 设计理由: 用户反馈只需要远端机器上的路径 (方便登录后直接定位文件),
// 不需要 user@host 前缀, 也不再支持 Public URL Base. 这里统一返回
// "~/<relPath>", 即相对远端 $HOME 的可读路径.
func remoteShareText(relPath string) string {
return "~/" + relPath
}
// buildRemoteRelPath produces the remote-relative target path with the
// same date-grouped layout used by the S3 pipeline.
func buildRemoteRelPath(prefix string) string {
prefix = strings.TrimSpace(prefix)
prefix = strings.TrimPrefix(prefix, "~")
prefix = strings.TrimPrefix(prefix, "/")
if prefix != "" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
now := time.Now()
return fmt.Sprintf("%s%s/%s/%s-%s.png",
prefix,
now.Format("2006"),
now.Format("01"),
now.Format("20060102-150405"),
randomSuffix(6),
)
}
func (s *CaptureAndSSHService) notifyFailure(reason string) {
if s.Notifier != nil {
s.Notifier.NotifyFailure(reason)
}
}
+53 -5
View File
@@ -1,8 +1,8 @@
// Package domain — configuration types.
//
// S3Config is intentionally split into its own file so that future providers
// can introduce their own configuration types side-by-side without polluting
// the core domain types file.
// S3Config and SSHConfig are intentionally split into their own structs so
// that future providers can introduce their own configuration types side-
// by-side without polluting the core domain types file.
package domain
// S3Config describes the connection parameters for any S3-compatible
@@ -27,10 +27,41 @@ type S3Config struct {
UsePathStyle bool `json:"usePathStyle"`
}
// SSHConfig describes the parameters required to upload a screenshot via
// SSH/SCP to a remote host.
//
// Field design notes:
// - Host / Port form the destination endpoint. Port defaults to 22 when 0.
// - User is the SSH login name.
// - Password is optional; when empty the implementation falls back to the
// local SSH agent or ~/.ssh/id_* keys (i.e. the user is responsible for
// having password-less access already configured on this machine).
// - PathPrefix is interpreted *relative to the remote user's $HOME*. The
// UI presents it as "~/<PathPrefix>" so the user cannot escape the home
// directory by writing absolute paths. Leading "/" or "~" markers are
// stripped before the path is used.
// - StrictHostKey toggles host key verification. When false the client
// uses ssh.InsecureIgnoreHostKey() to mimic `scp -o StrictHostKeyChecking=no`,
// trading some security for first-launch usability on personal LANs.
// - KnownHostsPath defaults to ~/.ssh/known_hosts when empty and is only
// used while StrictHostKey is true.
// - ConnectTimeoutSecs caps the network handshake duration so a wrong
// endpoint does not hang the capture flow indefinitely.
type SSHConfig struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
PathPrefix string `json:"pathPrefix"`
StrictHostKey bool `json:"strictHostKey"`
KnownHostsPath string `json:"knownHostsPath"`
ConnectTimeoutSecs int `json:"connectTimeoutSecs"`
}
// AppConfig is the top-level on-disk configuration document.
//
// We keep S3 nested so that adding more providers later (e.g. AliyunOSS, COS)
// only requires a new sibling field rather than a schema rewrite.
// We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS,
// COS, FTP) only requires a new sibling field rather than a schema rewrite.
type AppConfig struct {
// Hotkey describes the global shortcut that triggers a capture.
// Stored as a human-readable string like "cmd+shift+a"; parsing happens
@@ -39,6 +70,10 @@ type AppConfig struct {
// S3 holds the active S3-compatible storage configuration.
S3 S3Config `json:"s3"`
// SSH holds the configuration for the optional "save to remote via scp"
// destination triggered by the save-remote toolbar button.
SSH SSHConfig `json:"ssh"`
}
// DefaultAppConfig returns sane zero-value defaults used on first launch.
@@ -49,6 +84,12 @@ func DefaultAppConfig() AppConfig {
PathPrefix: "snapgo/",
UsePathStyle: true,
},
SSH: SSHConfig{
Port: 22,
PathPrefix: "snapgo/",
ConnectTimeoutSecs: 10,
StrictHostKey: false,
},
}
}
@@ -59,3 +100,10 @@ func (c AppConfig) IsS3Configured() bool {
c.S3.AccessKeyID != "" &&
c.S3.SecretAccessKey != ""
}
// IsSSHConfigured reports whether the SSH destination has the minimum
// fields required to attempt a connection. Password is intentionally NOT
// checked because empty password means "use agent / key auth".
func (c AppConfig) IsSSHConfigured() bool {
return c.SSH.Host != "" && c.SSH.User != ""
}
+226
View File
@@ -0,0 +1,226 @@
// Package logging centralises slog configuration.
//
// 设计动机:
// - SnapGo 是 macOS GUI 应用, 由 Finder/open 启动时 stderr 会被重定向
// 到 /dev/null, slog 默认 handler 写到 stderr 因此对用户不可见.
// - 为方便排查网络 / 权限 / SCP 协议层问题, 我们将日志同时写入磁盘文件
// 和 stderr, 让 `tail -f` 与开发期 `wails dev` 都能直接看到输出.
// - 单独抽出包可避免 main / app 直接依赖文件 IO 细节, 也方便后续替换为
// os_log 或集中式日志方案.
package logging
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
)
const (
// maxLogSize is the byte threshold that triggers a rotation. 5 MiB keeps
// a useful amount of recent history while staying small enough to open
// in any editor.
maxLogSize = 5 * 1024 * 1024
// maxBackups is how many rotated files to retain (snapgo.log.1,
// snapgo.log.2). Older ones are deleted on each rotation.
maxBackups = 2
)
// teeWriter is a thin io.Writer that fans out a single slog write to many
// underlying writers. We avoid log.MultiWriter from the std library because
// io.MultiWriter already covers the use case without an extra dependency.
type teeWriter struct {
writers []io.Writer
}
// Write copies p to every underlying writer. We deliberately keep going on
// errors so a single broken writer (e.g. closed file handle) does not lose
// output to the surviving writers; the last error wins which is sufficient
// for a logger.
func (t *teeWriter) Write(p []byte) (int, error) {
var lastErr error
for _, w := range t.writers {
if w == nil {
continue
}
if _, err := w.Write(p); err != nil {
lastErr = err
}
}
return len(p), lastErr
}
// Init wires slog.Default to write to both ~/Library/Logs/SnapGo/snapgo.log
// and stderr. Returns a closer the caller (main) should defer so the file
// handle is released on shutdown.
//
// 日志级别可通过环境变量 SNAPGO_LOG_LEVEL 调整: debug|info|warn|error.
// 默认 debug 以便排查 SCP 阶段性问题, 待功能稳定后可调整为 info.
func Init() (closer func()) {
level := parseLevel(os.Getenv("SNAPGO_LOG_LEVEL"))
var fileW io.Writer
logPath, err := defaultLogPath()
if err == nil {
// Size-based rotation keeps the log directory bounded; append mode is
// preserved inside rotatingFile so prior-launch context survives.
rf, ferr := newRotatingFile(logPath, maxLogSize, maxBackups)
if ferr == nil {
fileW = rf
closer = func() { _ = rf.Close() }
}
}
if closer == nil {
closer = func() {}
}
writer := &teeWriter{writers: []io.Writer{os.Stderr}}
if fileW != nil {
writer.writers = append(writer.writers, fileW)
}
handler := slog.NewTextHandler(writer, &slog.HandlerOptions{Level: level})
slog.SetDefault(slog.New(handler))
slog.Info("logging initialised",
"level", level.String(),
"file", logPath)
return closer
}
// rotatingFile is a size-based rotating log writer.
//
// 设计理由:
// - GUI 应用长期 append 写日志, 不加限制会无限增长, 占满磁盘.
// - 标准库不提供滚动能力, 引入 lumberjack 等第三方库又会增加依赖,
// 而我们的需求很简单 (单文件 / 按大小 / 固定份数), 自实现成本更低.
// - 实现策略: 每次 Write 前检查写入后是否超过 maxLogSize, 超过则先
// rotate (snapgo.log -> snapgo.log.1 -> snapgo.log.2, 丢弃最老的),
// 再写入新建的 snapgo.log. 用互斥锁保证并发安全 (slog handler 自身
// 不保证对 io.Writer 的串行化).
type rotatingFile struct {
mu sync.Mutex
path string
maxSize int64
backups int
file *os.File
size int64
}
// newRotatingFile opens (or creates, append mode) the target log file and
// initialises the current size from the existing file so a restart does not
// reset the rotation threshold.
func newRotatingFile(path string, maxSize int64, backups int) (*rotatingFile, error) {
r := &rotatingFile{path: path, maxSize: maxSize, backups: backups}
if err := r.openExisting(); err != nil {
return nil, err
}
return r, nil
}
// openExisting opens the active log file in append mode and records its
// current size.
func (r *rotatingFile) openExisting() error {
f, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return err
}
r.file = f
r.size = info.Size()
return nil
}
// Write appends p, rotating first when the write would exceed maxSize.
func (r *rotatingFile) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.size+int64(len(p)) > r.maxSize {
if err := r.rotate(); err != nil {
// On rotation failure we keep writing to the current file rather
// than dropping logs; oversized-by-a-bit beats data loss.
r.size += int64(len(p))
n, werr := r.file.Write(p)
if werr != nil {
return n, werr
}
return n, err
}
}
n, err := r.file.Write(p)
r.size += int64(n)
return n, err
}
// rotate closes the current file, shifts backups (snapgo.log.1 ->
// snapgo.log.2, dropping the oldest), renames the active file to
// snapgo.log.1, then opens a fresh active file.
func (r *rotatingFile) rotate() error {
if err := r.file.Close(); err != nil {
return err
}
// Drop the oldest backup, then shift the rest up by one index.
oldest := fmt.Sprintf("%s.%d", r.path, r.backups)
_ = os.Remove(oldest)
for i := r.backups - 1; i >= 1; i-- {
src := fmt.Sprintf("%s.%d", r.path, i)
dst := fmt.Sprintf("%s.%d", r.path, i+1)
_ = os.Rename(src, dst)
}
if r.backups >= 1 {
_ = os.Rename(r.path, fmt.Sprintf("%s.1", r.path))
}
return r.openExisting()
}
// Close releases the underlying file handle.
func (r *rotatingFile) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.file == nil {
return nil
}
return r.file.Close()
}
// defaultLogPath returns ~/Library/Logs/SnapGo/snapgo.log, creating the
// parent directory if needed. macOS users expect app logs to live there
// (Console.app surfaces this directory under "Reports" and `open ~/Library/Logs`
// is muscle-memory).
func defaultLogPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(home, "Library", "Logs", "SnapGo")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return filepath.Join(dir, "snapgo.log"), nil
}
// parseLevel translates a free-form string into an slog.Level. We default
// to debug because the app currently has plenty of diagnostic logs and the
// user-facing impact (slightly more disk IO on uploads) is negligible.
func parseLevel(s string) slog.Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "error":
return slog.LevelError
case "warn", "warning":
return slog.LevelWarn
case "info":
return slog.LevelInfo
case "debug", "":
return slog.LevelDebug
default:
return slog.LevelDebug
}
}
+487
View File
@@ -0,0 +1,487 @@
// Package ssh provides an SCP-based remote upload adapter for SnapGo.
//
// Design rationale:
// - We deliberately implement SCP "sink mode" by hand on top of an SSH
// session instead of pulling in an extra dependency. The protocol is
// trivial (one control line + payload + null byte) and we only need
// write support, so a custom implementation keeps the dependency
// surface small and auditable.
// - The adapter is split into a Client (connection lifetime) and a
// CopyFile method (single transfer) so future use-cases such as listing
// or deleting remote files can extend the same SSH session.
// - All public methods accept a context so the application layer can
// enforce a timeout that matches the user-perceived capture latency.
package ssh
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"os"
"path"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
"github.com/mmmy/snapgo/internal/domain"
)
// logger returns a component-scoped slog logger bound to the CURRENT
// default handler.
//
// 为什么是函数而非包级 slog.With 变量:
// - 包级变量在 main() 调用 logging.Init() 之前就初始化, 那一刻
// slog.Default() 还是 bootstrap handler (它转发给标准 log 包).
// - logging.Init() 里的 slog.SetDefault() 会把标准 log 包重定向回新的
// TextHandler, 于是旧 handler 先把整行格式化成 "INFO msg component=ssh..."
// 再被新 handler 当成一个 msg 二次包裹, 产生双前缀日志.
// - 每次惰性读取 slog.Default() 即可始终拿到正确的目标 handler.
func sshLog() *slog.Logger { return slog.Default().With("component", "ssh") }
// Client is a thin wrapper around *ssh.Client whose lifetime maps 1:1
// to a single upload operation. We do not pool connections because the
// expected cadence (a few uploads per minute at most) does not justify
// the additional complexity of managing keep-alives.
type Client struct {
client *ssh.Client
cfg domain.SSHConfig
}
// Dial establishes an SSH connection using the supplied configuration.
//
// Authentication priority:
// 1. Password, if non-empty.
// 2. Local ssh-agent ($SSH_AUTH_SOCK), if present.
// 3. ~/.ssh/id_ed25519 → id_rsa fallback files.
//
// Host-key verification follows cfg.StrictHostKey. When strict, the user-
// supplied known_hosts file is honoured (defaulting to ~/.ssh/known_hosts).
// When false the call uses ssh.InsecureIgnoreHostKey, which is a deliberate
// trade-off that surfaces in the UI — the settings page warns the user.
func Dial(ctx context.Context, cfg domain.SSHConfig) (*Client, error) {
if cfg.Host == "" || cfg.User == "" {
return nil, fmt.Errorf("ssh: host and user are required")
}
port := cfg.Port
if port <= 0 {
port = 22
}
timeout := time.Duration(cfg.ConnectTimeoutSecs) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
authMethods, authSummary, err := buildAuthMethods(cfg)
if err != nil {
return nil, err
}
if len(authMethods) == 0 {
sshLog().Warn("dial aborted: no auth methods",
"host", cfg.Host, "user", cfg.User, "auth_summary", authSummary)
return nil, fmt.Errorf("ssh: no authentication methods available (set password or ensure ssh-agent / ~/.ssh/id_* exists)")
}
hostKeyCallback, hostKeyMode, err := buildHostKeyCallback(cfg)
if err != nil {
sshLog().Error("host key callback failed",
"host", cfg.Host, "strict", cfg.StrictHostKey,
"known_hosts", cfg.KnownHostsPath, "err", err)
return nil, err
}
clientCfg := &ssh.ClientConfig{
User: cfg.User,
Auth: authMethods,
HostKeyCallback: hostKeyCallback,
Timeout: timeout,
}
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", port))
sshLog().Info("dial start",
"addr", addr, "user", cfg.User,
"timeout", timeout, "auth_summary", authSummary, "host_key", hostKeyMode)
dialStart := time.Now()
// Honour ctx by dialing through net.Dialer so an early ctx cancel
// surfaces here instead of after the (long) ssh handshake.
dialer := net.Dialer{Timeout: timeout}
tcpConn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
sshLog().Error("tcp dial failed",
"addr", addr, "elapsed", time.Since(dialStart), "err", err)
return nil, fmt.Errorf("ssh: dial %s: %w", addr, err)
}
sshLog().Debug("tcp connected", "addr", addr, "elapsed", time.Since(dialStart))
hsStart := time.Now()
sshConn, chans, reqs, err := ssh.NewClientConn(tcpConn, addr, clientCfg)
if err != nil {
_ = tcpConn.Close()
sshLog().Error("ssh handshake failed",
"addr", addr, "user", cfg.User,
"elapsed", time.Since(hsStart), "err", err)
return nil, fmt.Errorf("ssh: handshake: %w", err)
}
sshLog().Info("ssh handshake ok",
"addr", addr, "user", cfg.User,
"server_version", string(sshConn.ServerVersion()),
"elapsed", time.Since(hsStart))
return &Client{
client: ssh.NewClient(sshConn, chans, reqs),
cfg: cfg,
}, nil
}
// Close shuts down the underlying SSH connection.
func (c *Client) Close() error {
if c == nil || c.client == nil {
return nil
}
return c.client.Close()
}
// CopyFile uploads `data` to remoteRelativePath under the user's home
// directory. The caller passes a path *relative to $HOME*; the adapter
// strips any leading "/" or "~" so users cannot escape their home
// directory through misconfigured PathPrefix values.
//
// The upload uses the SCP "sink mode" protocol:
//
// $ scp -t <remote-dir>
// ← we send: D0755 0 <subdir>\n (mkdir -p analogue, repeated)
// ← we send: C0644 <size> <basename>\n
// ← we send: <bytes...>\0
// ← we send: E\n (close each directory)
//
// Each line we write is acknowledged by a single 0x00 byte from the remote
// `scp -t` process; a non-zero ack indicates an error.
func (c *Client) CopyFile(ctx context.Context, remoteRelativePath string, data []byte, mode os.FileMode) error {
cleaned := normaliseRemotePath(remoteRelativePath)
if cleaned == "" {
return fmt.Errorf("ssh: remote path is empty")
}
dir, base := path.Split(cleaned)
dir = strings.Trim(dir, "/")
sshLog().Info("scp copy start",
"remote_path", cleaned, "dir", dir, "file", base,
"size", len(data), "mode", fmt.Sprintf("%#o", mode.Perm()))
session, err := c.client.NewSession()
if err != nil {
sshLog().Error("scp new session failed", "err", err)
return fmt.Errorf("ssh: new session: %w", err)
}
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
return fmt.Errorf("ssh: stdin pipe: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
return fmt.Errorf("ssh: stdout pipe: %w", err)
}
// `-t` puts the remote scp into "sink mode" rooted at $HOME. `-r`
// allows us to feed `D` directives so we can create directory trees
// in one round-trip rather than running a separate `mkdir -p`.
cmd := fmt.Sprintf("scp -tr %s", shellQuote("./"))
sshLog().Debug("scp remote command", "cmd", cmd)
if err := session.Start(cmd); err != nil {
sshLog().Error("scp session start failed", "cmd", cmd, "err", err)
return fmt.Errorf("ssh: start scp: %w", err)
}
transferStart := time.Now()
errCh := make(chan error, 1)
go func() {
errCh <- writeSCPStream(stdin, stdout, dir, base, data, mode)
}()
// Wait for either ctx, the writer goroutine, or the remote command.
select {
case <-ctx.Done():
sshLog().Warn("scp cancelled by context",
"remote_path", cleaned, "elapsed", time.Since(transferStart), "err", ctx.Err())
_ = session.Signal(ssh.SIGTERM)
_ = session.Close()
return ctx.Err()
case writeErr := <-errCh:
if writeErr != nil {
sshLog().Error("scp protocol failed",
"remote_path", cleaned, "elapsed", time.Since(transferStart), "err", writeErr)
_ = session.Close()
return writeErr
}
}
if err := session.Wait(); err != nil {
sshLog().Error("scp session wait failed",
"remote_path", cleaned, "elapsed", time.Since(transferStart), "err", err)
return fmt.Errorf("ssh: scp wait: %w", err)
}
sshLog().Info("scp copy ok",
"remote_path", cleaned, "size", len(data), "elapsed", time.Since(transferStart))
return nil
}
// writeSCPStream drives the SCP sink-mode dialogue described above. It is
// extracted from CopyFile for two reasons:
// 1. It contains the only blocking IO so we can run it in a goroutine
// and select on ctx.Done().
// 2. It makes the protocol-level steps testable in isolation (the unit
// tests pipe an in-memory bytes.Buffer into expectAck).
func writeSCPStream(stdin io.WriteCloser, stdout io.Reader, dir, base string, data []byte, mode os.FileMode) error {
defer stdin.Close()
// First ack: the remote scp -t emits an initial 0x00 once it is ready.
if err := expectAck(stdout); err != nil {
return fmt.Errorf("scp: initial ack: %w", err)
}
sshLog().Debug("scp initial ack received")
// Walk `dir` segment by segment, opening each level with `D0755 0 <name>`.
dirs := splitNonEmpty(dir, "/")
for _, segment := range dirs {
line := fmt.Sprintf("D0755 0 %s\n", segment)
if _, err := io.WriteString(stdin, line); err != nil {
return fmt.Errorf("scp: write D-line: %w", err)
}
if err := expectAck(stdout); err != nil {
return fmt.Errorf("scp: ack D-line %q: %w", segment, err)
}
sshLog().Debug("scp dir opened", "segment", segment)
}
// File header: `C<perm> <size> <name>\n`
header := fmt.Sprintf("C%04o %d %s\n", mode.Perm(), len(data), base)
if _, err := io.WriteString(stdin, header); err != nil {
return fmt.Errorf("scp: write C-line: %w", err)
}
if err := expectAck(stdout); err != nil {
return fmt.Errorf("scp: ack C-line: %w", err)
}
sshLog().Debug("scp header acked", "header", strings.TrimSpace(header))
if _, err := stdin.Write(data); err != nil {
return fmt.Errorf("scp: write payload: %w", err)
}
if _, err := stdin.Write([]byte{0}); err != nil {
return fmt.Errorf("scp: write trailing null: %w", err)
}
if err := expectAck(stdout); err != nil {
return fmt.Errorf("scp: ack payload: %w", err)
}
sshLog().Debug("scp payload acked", "bytes", len(data))
// Pop each directory we opened earlier with an `E` line so the remote
// scp finishes cleanly.
for range dirs {
if _, err := io.WriteString(stdin, "E\n"); err != nil {
return fmt.Errorf("scp: write E-line: %w", err)
}
if err := expectAck(stdout); err != nil {
return fmt.Errorf("scp: ack E-line: %w", err)
}
}
return nil
}
// expectAck reads exactly one byte from the remote scp and treats anything
// other than 0x00 as a protocol-level error. When the remote signals an
// error (0x01 = warning, 0x02 = fatal) it is followed by a textual reason
// terminated with '\n', which we surface to the caller.
func expectAck(r io.Reader) error {
buf := make([]byte, 1)
if _, err := io.ReadFull(r, buf); err != nil {
return err
}
if buf[0] == 0 {
return nil
}
// Read the rest of the message until newline so the user sees a
// meaningful "permission denied"-style reason.
msg := readUntilNewline(r)
return fmt.Errorf("scp remote error (%d): %s", buf[0], strings.TrimSpace(msg))
}
func readUntilNewline(r io.Reader) string {
var sb strings.Builder
buf := make([]byte, 1)
for i := 0; i < 1024; i++ {
if _, err := io.ReadFull(r, buf); err != nil {
break
}
if buf[0] == '\n' {
break
}
sb.WriteByte(buf[0])
}
return sb.String()
}
// TestConnection performs a minimal handshake + `pwd` round trip so the
// settings UI can confirm the credentials work without writing a file.
func TestConnection(ctx context.Context, cfg domain.SSHConfig) error {
sshLog().Info("test connection start", "host", cfg.Host, "user", cfg.User, "port", cfg.Port)
start := time.Now()
client, err := Dial(ctx, cfg)
if err != nil {
sshLog().Error("test connection: dial failed", "err", err, "elapsed", time.Since(start))
return err
}
defer client.Close()
session, err := client.client.NewSession()
if err != nil {
sshLog().Error("test connection: new session failed", "err", err)
return fmt.Errorf("ssh: new session: %w", err)
}
defer session.Close()
if err := session.Run("true"); err != nil {
sshLog().Error("test connection: probe failed", "err", err)
return fmt.Errorf("ssh: probe command failed: %w", err)
}
sshLog().Info("test connection ok", "elapsed", time.Since(start))
return nil
}
// normaliseRemotePath strips any leading "/" or "~" so the path is always
// interpreted relative to the remote $HOME, matching the UI promise.
func normaliseRemotePath(p string) string {
p = strings.TrimSpace(p)
p = strings.TrimPrefix(p, "~")
p = strings.TrimPrefix(p, "/")
p = strings.TrimPrefix(p, "./")
return path.Clean(p)
}
// splitNonEmpty splits `s` on `sep` and discards empty segments so the
// caller does not have to defend against double slashes etc.
func splitNonEmpty(s, sep string) []string {
parts := strings.Split(s, sep)
out := parts[:0]
for _, part := range parts {
if part == "" || part == "." {
continue
}
out = append(out, part)
}
return out
}
// shellQuote is a defensive single-quote escaper used when interpolating
// user-controlled strings (currently only the relative root ".") into the
// remote `scp` command line. We do NOT quote arbitrary user paths because
// SCP itself receives them through the protocol channel above.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// buildAuthMethods chooses authentication methods given the supplied
// configuration. See Dial's docstring for the priority order.
//
// Returns a human-readable summary alongside the method slice so the
// caller can include "password+agent+ed25519" (and similar) in its dial
// log without leaking secret material.
func buildAuthMethods(cfg domain.SSHConfig) ([]ssh.AuthMethod, string, error) {
var methods []ssh.AuthMethod
var sources []string
if cfg.Password != "" {
methods = append(methods, ssh.Password(cfg.Password))
sources = append(sources, "password")
}
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
if conn, err := net.Dial("unix", sock); err == nil {
// 关键: 仅在 agent 真正持有 key 时才把它加入 auth methods.
//
// macOS 的 launchd ssh-agent 即便没有任何 identity 也会响应,
// 此时若仍注册 PublicKeysCallback, x/crypto 会把它当作一次空的
// publickey 尝试. 配合服务器的 MaxAuthTries 计数, 这次"空尝试"
// 会挤占后续基于磁盘私钥的认证机会, 最终导致
// "[none publickey] no supported methods remain" —— 即便磁盘上
// 的 key 本身完全可用. 因此空 agent 必须跳过.
ag := agent.NewClient(conn)
if keys, lerr := ag.List(); lerr == nil && len(keys) > 0 {
methods = append(methods, ssh.PublicKeysCallback(ag.Signers))
sources = append(sources, fmt.Sprintf("agent(%d)", len(keys)))
} else {
sshLog().Debug("ssh-agent has no identities; skipping",
"sock", sock, "list_err", lerr)
}
} else {
sshLog().Debug("ssh-agent dial failed", "sock", sock, "err", err)
}
}
home, err := os.UserHomeDir()
if err == nil {
// Probe the common default keys; any unreadable / missing file is
// silently skipped so the user never sees noise about keys they did
// not set up.
for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} {
signer, err := loadPrivateKey(path.Join(home, ".ssh", name))
if err == nil && signer != nil {
methods = append(methods, ssh.PublicKeys(signer))
sources = append(sources, name)
}
}
} else {
sshLog().Debug("home dir unavailable for ssh keys", "err", err)
}
if len(sources) == 0 {
return methods, "none", nil
}
return methods, strings.Join(sources, "+"), nil
}
// loadPrivateKey reads and parses a single OpenSSH private key file.
// Returns (nil, nil) when the file does not exist so the caller can keep
// scanning the standard key list.
func loadPrivateKey(p string) (ssh.Signer, error) {
data, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
signer, err := ssh.ParsePrivateKey(data)
if err != nil {
// Encrypted keys without a passphrase are skipped silently for now;
// passphrase support is left to a follow-up spec.
return nil, nil
}
return signer, nil
}
// buildHostKeyCallback returns either a strict known_hosts-backed callback
// or an InsecureIgnoreHostKey fallback, depending on cfg.StrictHostKey.
//
// The returned mode string ("insecure" or "known_hosts:<path>") is logged
// at dial time so a "rejected by host key" failure is easy to diagnose.
func buildHostKeyCallback(cfg domain.SSHConfig) (ssh.HostKeyCallback, string, error) {
if !cfg.StrictHostKey {
// Deliberate trade-off: matches `scp -o StrictHostKeyChecking=no` and
// keeps the first-launch experience friction-free for personal LANs.
return ssh.InsecureIgnoreHostKey(), "insecure", nil
}
knownHosts := cfg.KnownHostsPath
if knownHosts == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, "", fmt.Errorf("ssh: resolve home for known_hosts: %w", err)
}
knownHosts = path.Join(home, ".ssh", "known_hosts")
}
cb, err := knownhosts.New(knownHosts)
if err != nil {
return nil, "", fmt.Errorf("ssh: load known_hosts %q: %w", knownHosts, err)
}
return cb, "known_hosts:" + knownHosts, nil
}
+59
View File
@@ -0,0 +1,59 @@
// uploader.go — small adapter that satisfies application.SSHUploader by
// dialing a one-shot connection per upload. This keeps the application
// layer free of any direct dependency on golang.org/x/crypto/ssh while
// still exposing a clean, unit-testable contract.
package ssh
import (
"context"
"os"
"time"
"github.com/mmmy/snapgo/internal/domain"
)
// Uploader is the production implementation of application.SSHUploader.
//
// We dial fresh per-upload because:
// - Captures happen sparsely (a few per minute at peak), so the cost of
// a TCP+SSH handshake is dwarfed by user think-time.
// - Holding a long-lived connection would force us to add keepalives,
// reconnection logic, and config-change invalidation — not worth it
// for the current usage profile.
type Uploader struct {
cfg domain.SSHConfig
}
// NewUploader returns a ready-to-use uploader. The configuration is
// captured by value so a simultaneous in-flight upload cannot be re-
// targeted by a settings save.
func NewUploader(cfg domain.SSHConfig) *Uploader {
return &Uploader{cfg: cfg}
}
// Upload satisfies application.SSHUploader. We log entry/exit at INFO so
// the operator sees a single line per upload in the normal happy path,
// and ERROR with elapsed time when something fails.
func (u *Uploader) Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error {
start := time.Now()
sshLog().Info("uploader.Upload start",
"host", u.cfg.Host, "user", u.cfg.User, "port", u.cfg.Port,
"remote_path", remoteRelPath, "size", len(data))
client, err := Dial(ctx, u.cfg)
if err != nil {
sshLog().Error("uploader.Upload dial failed",
"host", u.cfg.Host, "elapsed", time.Since(start), "err", err)
return err
}
defer client.Close()
if err := client.CopyFile(ctx, remoteRelPath, data, mode); err != nil {
sshLog().Error("uploader.Upload copy failed",
"remote_path", remoteRelPath, "elapsed", time.Since(start), "err", err)
return err
}
sshLog().Info("uploader.Upload done",
"remote_path", remoteRelPath, "size", len(data),
"total_elapsed", time.Since(start))
return nil
}