Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,12 @@ Managing multiple servers means juggling different passwords and repeatedly ente
## Key Features

1. Cross-platform SSH/SFTP operations (supports sudo auto-fill).
2. Password management (Keychain / Secret Service / Credential Manager).
3. Host configuration management with per-host SSH keys.
4. Dry-run execution plan preview for humans and agents.
5. Local structured audit trail with safe default redaction.
6. Script execution and command security validation.
2. Direct server-to-server file transfer (`--transfer=<host>:<path> --to=<host>:<path>`), streamed through the local machine without touching local disk.
3. Password management (Keychain / Secret Service / Credential Manager).
4. Host configuration management with per-host SSH keys.
5. Dry-run execution plan preview for humans and agents.
6. Local structured audit trail with safe default redaction.
7. Script execution and command security validation.

## Installation

Expand Down Expand Up @@ -209,6 +210,9 @@ sshx --password-set=192.168.1.100-root

# Use the saved password for sudo auto-fill
sshx -h=192.168.1.100 -u=root "sudo df -h"

# Transfer a file directly from one server to another (streamed, no local copy)
sshx --transfer=192.168.1.100:/var/log/app.log --to=192.168.1.101:/backup/app.log
```

## Agent / Scripting Mode
Expand Down
14 changes: 9 additions & 5 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,12 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\
## 核心特性

1. 跨平台 SSH/SFTP 操作(支持 sudo 自动填充)。
2. 密码管理(Keychain / Secret Service / Credential Manager)。
3. 主机配置管理,支持为每台主机配置独立的 SSH 密钥。
4. 面向人和 agent 的 dry-run 执行计划预览。
5. 本地结构化审计日志,并默认做安全脱敏。
6. 脚本执行和命令安全验证。
2. 服务器到服务器直接文件传输(`--transfer=<host>:<path> --to=<host>:<path>`),数据经本机中转流式传输,不落本地磁盘。
3. 密码管理(Keychain / Secret Service / Credential Manager)。
4. 主机配置管理,支持为每台主机配置独立的 SSH 密钥。
5. 面向人和 agent 的 dry-run 执行计划预览。
6. 本地结构化审计日志,并默认做安全脱敏。
7. 脚本执行和命令安全验证。

## 安装

Expand Down Expand Up @@ -210,6 +211,9 @@ sshx -h=192.168.1.100 -u=root "sudo df -h"

# 一次性测试所有已配置的主机(每台主机 10 秒拨号超时),并在报告中标注认证方式
sshx --host-test-all

# 服务器到服务器直接传输文件(流式中转,不落本地磁盘)
sshx --transfer=192.168.1.100:/var/log/app.log --to=192.168.1.101:/backup/app.log
```

## Agent / 脚本模式
Expand Down
8 changes: 8 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ func Run(args []string) (err error) {
return nil
}

// Handle server-to-server transfer mode
if config.Mode == "transfer" {
if transferErr := HandleTransfer(config); transferErr != nil {
return fmt.Errorf("transfer failed: %w", transferErr)
}
return nil
}

// Validate flags that only apply to command execution.
if config.Mode == "ssh" {
if config.Timeout < 0 {
Expand Down
18 changes: 18 additions & 0 deletions internal/app/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ type auditEvent struct {
LocalPath string `json:"local_path,omitempty"`
RemotePath string `json:"remote_path,omitempty"`

TransferSource string `json:"transfer_source,omitempty"`
TransferDest string `json:"transfer_destination,omitempty"`

UseKeyAuth bool `json:"use_key_auth"`
KeyPath string `json:"key_path,omitempty"`
PasswordProvided bool `json:"password_provided"`
Expand Down Expand Up @@ -230,6 +233,10 @@ func (r *auditRecorder) refresh(config *sshclient.Config) {
r.event.SftpAction = config.SftpAction
r.event.LocalPath = config.LocalPath
r.event.RemotePath = config.RemotePath
if config.Mode == "transfer" {
r.event.TransferSource = formatTransferEndpoint(config.TransferSrcHost, config.TransferSrcPath)
r.event.TransferDest = formatTransferEndpoint(config.TransferDstHost, config.TransferDstPath)
}
r.event.UseKeyAuth = config.UseKeyAuth
r.event.KeyPath = config.KeyPath
r.event.PasswordProvided = config.Password != ""
Expand Down Expand Up @@ -317,11 +324,20 @@ func auditAction(config *sshclient.Config) string {
return config.PasswordAction
case "host":
return config.HostAction
case "transfer":
return "transfer"
default:
return ""
}
}

func formatTransferEndpoint(host, path string) string {
if host == "" && path == "" {
return ""
}
return host + ":" + path
}

func auditWouldReadSecret(config *sshclient.Config) bool {
switch config.Mode {
case "ssh":
Expand Down Expand Up @@ -352,6 +368,8 @@ func auditWouldMutateRemote(config *sshclient.Config) bool {
return config.Command != ""
case "sftp":
return config.SftpAction == "upload" || config.SftpAction == "mkdir" || config.SftpAction == "remove"
case "transfer":
return true
case "host":
return config.HostAction == "test" || config.HostAction == "test-all"
default:
Expand Down
21 changes: 18 additions & 3 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ func parseTimeout(value string) (time.Duration, error) {
return 0, fmt.Errorf("invalid timeout %q (use e.g. 30s, 2m, or 30)", value)
}

// splitHostPath splits a "host:path" transfer spec at the first colon.
// If there is no colon, the whole value is treated as a host with an empty path.
func splitHostPath(spec string) (host, path string) {
parts := strings.SplitN(spec, ":", 2)
if len(parts) == 2 {
return parts[0], parts[1]
}
return spec, ""
}

// ParseArgs parses command-line arguments and returns a Config.
func ParseArgs(args []string) *sshclient.Config {
config := &sshclient.Config{
Expand Down Expand Up @@ -152,11 +162,16 @@ func ParseArgs(args []string) *sshclient.Config {
config.Mode = "sftp"
config.SftpAction = "download"
config.RemotePath = strings.SplitN(arg, "=", 2)[1]
case strings.HasPrefix(arg, "--transfer="):
config.Mode = "transfer"
config.TransferSrcHost, config.TransferSrcPath = splitHostPath(strings.SplitN(arg, "=", 2)[1])
case strings.HasPrefix(arg, "--to="):
switch config.SftpAction {
case "upload":
switch {
case config.Mode == "transfer":
config.TransferDstHost, config.TransferDstPath = splitHostPath(strings.SplitN(arg, "=", 2)[1])
case config.SftpAction == "upload":
config.RemotePath = strings.SplitN(arg, "=", 2)[1]
case "download":
case config.SftpAction == "download":
config.LocalPath = strings.SplitN(arg, "=", 2)[1]
}
case strings.HasPrefix(arg, "--list="), strings.HasPrefix(arg, "--ls="):
Expand Down
37 changes: 35 additions & 2 deletions internal/app/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ type dryRunPlan struct {
LocalPath string `json:"local_path,omitempty"`
RemotePath string `json:"remote_path,omitempty"`

TransferSource string `json:"transfer_source,omitempty"`
TransferDest string `json:"transfer_destination,omitempty"`

UseKeyAuth bool `json:"use_key_auth"`
KeyPath string `json:"key_path,omitempty"`
PasswordProvided bool `json:"password_provided"`
Expand Down Expand Up @@ -137,6 +140,10 @@ func fillDryRunAction(config *sshclient.Config, plan *dryRunPlan) {
plan.Action = config.PasswordAction
case "host":
plan.Action = config.HostAction
case "transfer":
plan.Action = "transfer"
plan.TransferSource = formatTransferEndpoint(config.TransferSrcHost, config.TransferSrcPath)
plan.TransferDest = formatTransferEndpoint(config.TransferDstHost, config.TransferDstPath)
}
}

Expand Down Expand Up @@ -277,6 +284,26 @@ func fillDryRunSudo(config *sshclient.Config, plan *dryRunPlan) {
}

func fillDryRunValidation(config *sshclient.Config, plan *dryRunPlan) {
if config.Mode == "transfer" {
if config.TransferSrcHost == "" || config.TransferSrcPath == "" {
plan.ConfigCheck = dryRunStatus{
Status: "error",
ErrorKind: "config",
Message: "source must be specified as --transfer=<host>:<path>",
}
plan.Valid = false
return
}
if config.TransferDstHost == "" || config.TransferDstPath == "" {
plan.ConfigCheck = dryRunStatus{
Status: "error",
ErrorKind: "config",
Message: "destination must be specified as --to=<host>:<path>",
}
plan.Valid = false
}
return
}
if config.Mode == "ssh" {
if config.Timeout < 0 {
plan.ConfigCheck = dryRunStatus{
Expand Down Expand Up @@ -329,6 +356,9 @@ func fillDryRunEffects(config *sshclient.Config, plan *dryRunPlan) {
case "sftp":
plan.WouldConnect = canProceed
plan.WouldMutateRemote = plan.WouldConnect && (config.SftpAction == "upload" || config.SftpAction == "mkdir" || config.SftpAction == "remove")
case "transfer":
plan.WouldConnect = canProceed
plan.WouldMutateRemote = canProceed
case "password":
plan.WouldReadSecret = canProceed && (config.PasswordAction == "get" || config.PasswordAction == "check" || config.PasswordAction == "delete" || config.PasswordAction == "list")
plan.WouldWriteLocalState = canProceed && (config.PasswordAction == "set" || config.PasswordAction == "delete")
Expand All @@ -347,7 +377,7 @@ func fillDryRunEffects(config *sshclient.Config, plan *dryRunPlan) {
}

func modeUsesSSHConnection(config *sshclient.Config) bool {
if config.Mode == "ssh" || config.Mode == "sftp" {
if config.Mode == "ssh" || config.Mode == "sftp" || config.Mode == "transfer" {
return true
}
return config.Mode == "host" && (config.HostAction == "test" || config.HostAction == "test-all")
Expand Down Expand Up @@ -375,7 +405,7 @@ func printDryRunPlan(plan dryRunPlan) {
}
fmt.Println()
}
if plan.User != "" || plan.Port != "" {
if plan.Mode != "transfer" && (plan.User != "" || plan.Port != "") {
fmt.Printf("Target: %s@%s:%s\n", firstNonEmpty(plan.User, "-"), firstNonEmpty(plan.HostResolved, "-"), firstNonEmpty(plan.Port, "-"))
}
if plan.Command != "" {
Expand All @@ -384,6 +414,9 @@ func printDryRunPlan(plan dryRunPlan) {
if plan.SftpAction != "" {
fmt.Printf("SFTP: %s local=%q remote=%q\n", plan.SftpAction, plan.LocalPath, plan.RemotePath)
}
if plan.TransferSource != "" || plan.TransferDest != "" {
fmt.Printf("Transfer: %s → %s\n", plan.TransferSource, plan.TransferDest)
}
fmt.Printf("Config check: %s\n", statusText(plan.ConfigCheck))
fmt.Printf("Safety check: %s\n", statusText(plan.SafetyCheck))
fmt.Printf("Uses sudo: %t", plan.UsesSudo)
Expand Down
89 changes: 89 additions & 0 deletions internal/app/transfer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package app

import (
"fmt"

"github.com/talkincode/sshx/internal/sshclient"
"github.com/talkincode/sshx/pkg/logger"
)

// HandleTransfer performs a direct server-to-server file transfer. It opens
// SSH connections to both the source and destination hosts and streams data
// between them through the local machine without touching local disk.
func HandleTransfer(config *sshclient.Config) (err error) {
lg := logger.GetLogger()

if config.TransferSrcHost == "" || config.TransferSrcPath == "" {
return fmt.Errorf("source must be specified as --transfer=<host>:<path>")
}
if config.TransferDstHost == "" || config.TransferDstPath == "" {
return fmt.Errorf("destination must be specified as --to=<host>:<path>")
}

srcClient, err := connectTransferEndpoint(config, config.TransferSrcHost, "source")
if err != nil {
return err
}
defer func() {
if closeErr := srcClient.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()

dstClient, err := connectTransferEndpoint(config, config.TransferDstHost, "destination")
if err != nil {
return err
}
defer func() {
if closeErr := dstClient.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()

lg.Info("Transfer: %s:%s → %s:%s",
config.TransferSrcHost, config.TransferSrcPath,
config.TransferDstHost, config.TransferDstPath)

if err = srcClient.TransferTo(dstClient, config.TransferSrcPath, config.TransferDstPath); err != nil {
return err
}

lg.Success("Transfer completed: %s:%s → %s:%s",
config.TransferSrcHost, config.TransferSrcPath,
config.TransferDstHost, config.TransferDstPath)
return nil
}

// connectTransferEndpoint builds a per-endpoint config derived from the base
// invocation, resolves the host from settings when applicable, and returns a
// connected SSH client.
func connectTransferEndpoint(base *sshclient.Config, host, role string) (*sshclient.SSHClient, error) {
endpoint := &sshclient.Config{
Host: host,
Port: base.Port,
User: base.User,
Password: base.Password,
KeyPath: base.KeyPath,
UseKeyAuth: base.UseKeyAuth,
SudoKey: base.SudoKey,
DialTimeout: base.DialTimeout,
AcceptUnknownHost: base.AcceptUnknownHost,
AllowInsecureHostKey: base.AllowInsecureHostKey,
KnownHostsPath: base.KnownHostsPath,
}

if !isIPAddress(endpoint.Host) {
if resolveErr := resolveHostFromSettings(endpoint); resolveErr != nil {
logger.GetLogger().Info("Note: Could not find %s host '%s' in settings, using as hostname directly", role, endpoint.Host)
}
}

client, err := sshclient.NewSSHClient(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to create SSH client for %s host %s: %w", role, host, err)
}
if err := client.ConnectDirect(); err != nil {
return nil, fmt.Errorf("failed to connect to %s host %s: %w", role, host, err)
}
return client, nil
}
Loading
Loading