feat: save to remote can be authed by Kerberos
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// kerberos_uploader.go — an SSHUploader implementation that delegates to
|
||||
// the system /usr/bin/ssh + /usr/bin/scp binaries so an existing Kerberos
|
||||
// (GSSAPI) credential cache populated by `kinit` is reused transparently.
|
||||
//
|
||||
// 设计理由 (为什么不用 golang.org/x/crypto/ssh 做 Kerberos):
|
||||
// - macOS 的 Kerberos 票据默认存放在 API: 类型的 credential cache 中,
|
||||
// 由系统 GSSD (Heimdal) 守护进程托管. Go 生态的 gokrb5 只能读取
|
||||
// FILE: 类型 ccache 与 keytab, 无法访问 API: ccache, 因此在 macOS 上
|
||||
// 用纯 Go 实现 GSSAPI 认证基本不可行.
|
||||
// - 系统自带的 /usr/bin/ssh 原生集成 Heimdal GSSAPI, 用户 `kinit` 之后
|
||||
// 的票据可被直接复用. 把上传委托给系统 ssh/scp 是最稳妥的方案, 也与
|
||||
// 用户"命令行能免密登录即可"的预期完全一致.
|
||||
// - 仅在 AuthMethod == kerberos 时启用此实现; builtin 路径不受影响.
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mmmy/snapgo/internal/domain"
|
||||
)
|
||||
|
||||
// systemSSHPath / systemSCPPath are the absolute paths to the macOS system
|
||||
// binaries. We use absolute paths rather than relying on $PATH so a hijacked
|
||||
// PATH cannot redirect us to an arbitrary binary, and because GUI apps on
|
||||
// macOS often launch with a minimal PATH that omits /usr/bin entirely.
|
||||
const (
|
||||
systemSSHPath = "/usr/bin/ssh"
|
||||
systemSCPPath = "/usr/bin/scp"
|
||||
)
|
||||
|
||||
// KerberosUploader satisfies application.SSHUploader by shelling out to the
|
||||
// system scp binary with GSSAPI authentication enabled.
|
||||
type KerberosUploader struct {
|
||||
cfg domain.SSHConfig
|
||||
}
|
||||
|
||||
// NewKerberosUploader returns an uploader that delegates to system scp.
|
||||
func NewKerberosUploader(cfg domain.SSHConfig) *KerberosUploader {
|
||||
return &KerberosUploader{cfg: cfg}
|
||||
}
|
||||
|
||||
// Upload writes data to remoteRelPath (relative to the remote $HOME) by
|
||||
// staging it in a local temp file and invoking system scp once.
|
||||
//
|
||||
// We stage to a temp file because scp transfers files, not stdin; piping a
|
||||
// stream would require the more fragile `scp -t` sink protocol that we only
|
||||
// use for the in-process client. A temp file is simpler and robust.
|
||||
func (u *KerberosUploader) Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error {
|
||||
start := time.Now()
|
||||
cleaned := normaliseRemotePath(remoteRelPath)
|
||||
if cleaned == "" {
|
||||
return fmt.Errorf("ssh(kerberos): remote path is empty")
|
||||
}
|
||||
sshLog().Info("kerberos uploader start",
|
||||
"host", u.cfg.Host, "user", u.cfg.User, "port", u.cfg.Port,
|
||||
"remote_path", cleaned, "size", len(data))
|
||||
|
||||
// 1. Ensure the remote directory tree exists. scp itself will not create
|
||||
// intermediate directories, so we run a remote `mkdir -p` first.
|
||||
dir, _ := path.Split(cleaned)
|
||||
dir = strings.Trim(dir, "/")
|
||||
if dir != "" {
|
||||
if err := u.runRemoteMkdir(ctx, dir); err != nil {
|
||||
sshLog().Error("kerberos uploader mkdir failed",
|
||||
"dir", dir, "elapsed", time.Since(start), "err", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Stage the payload to a local temp file for scp to read.
|
||||
tmp, err := os.CreateTemp("", "snapgo-scp-*.png")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh(kerberos): create temp: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("ssh(kerberos): write temp: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("ssh(kerberos): close temp: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpPath, mode.Perm()); err != nil {
|
||||
sshLog().Debug("kerberos uploader chmod temp failed", "err", err)
|
||||
}
|
||||
|
||||
// 3. scp the temp file to "user@host:cleaned" (cleaned is relative to
|
||||
// $HOME, which scp interprets correctly for a remote login shell).
|
||||
remoteTarget := fmt.Sprintf("%s@%s:%s", u.cfg.User, u.cfg.Host, cleaned)
|
||||
args := append(u.commonSCPArgs(), tmpPath, remoteTarget)
|
||||
if err := u.runCommand(ctx, systemSCPPath, args); err != nil {
|
||||
sshLog().Error("kerberos uploader scp failed",
|
||||
"remote_path", cleaned, "elapsed", time.Since(start), "err", err)
|
||||
return err
|
||||
}
|
||||
sshLog().Info("kerberos uploader done",
|
||||
"remote_path", cleaned, "size", len(data), "total_elapsed", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
// runRemoteMkdir runs `mkdir -p ~/<dir>` on the remote host via system ssh.
|
||||
func (u *KerberosUploader) runRemoteMkdir(ctx context.Context, dir string) error {
|
||||
remote := fmt.Sprintf("%s@%s", u.cfg.User, u.cfg.Host)
|
||||
// Quote the directory so spaces / metacharacters cannot break the shell
|
||||
// command. dir is already normalised (no leading ~ or /).
|
||||
cmd := "mkdir -p " + shellQuote("./"+dir)
|
||||
args := append(u.commonSSHArgs(), remote, cmd)
|
||||
return u.runCommand(ctx, systemSSHPath, args)
|
||||
}
|
||||
|
||||
// commonSSHArgs builds the shared option set that enables GSSAPI auth and
|
||||
// disables interactive prompts (we want a hard failure, never a hang waiting
|
||||
// for a password the GUI cannot answer).
|
||||
func (u *KerberosUploader) commonSSHArgs() []string {
|
||||
port := u.cfg.Port
|
||||
if port <= 0 {
|
||||
port = 22
|
||||
}
|
||||
timeout := u.cfg.ConnectTimeoutSecs
|
||||
if timeout <= 0 {
|
||||
timeout = 10
|
||||
}
|
||||
args := []string{
|
||||
"-p", strconv.Itoa(port),
|
||||
"-o", "GSSAPIAuthentication=yes",
|
||||
"-o", "GSSAPIDelegateCredentials=no",
|
||||
"-o", "PreferredAuthentications=gssapi-with-mic,gssapi-keyex",
|
||||
"-o", "PubkeyAuthentication=no",
|
||||
"-o", "PasswordAuthentication=no",
|
||||
"-o", "KbdInteractiveAuthentication=no",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=" + strconv.Itoa(timeout),
|
||||
}
|
||||
args = append(args, u.hostKeyArgs()...)
|
||||
return args
|
||||
}
|
||||
|
||||
// commonSCPArgs mirrors commonSSHArgs but uses scp's uppercase -P port flag.
|
||||
func (u *KerberosUploader) commonSCPArgs() []string {
|
||||
port := u.cfg.Port
|
||||
if port <= 0 {
|
||||
port = 22
|
||||
}
|
||||
timeout := u.cfg.ConnectTimeoutSecs
|
||||
if timeout <= 0 {
|
||||
timeout = 10
|
||||
}
|
||||
args := []string{
|
||||
"-P", strconv.Itoa(port),
|
||||
"-o", "GSSAPIAuthentication=yes",
|
||||
"-o", "GSSAPIDelegateCredentials=no",
|
||||
"-o", "PreferredAuthentications=gssapi-with-mic,gssapi-keyex",
|
||||
"-o", "PubkeyAuthentication=no",
|
||||
"-o", "PasswordAuthentication=no",
|
||||
"-o", "KbdInteractiveAuthentication=no",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=" + strconv.Itoa(timeout),
|
||||
}
|
||||
args = append(args, u.hostKeyArgs()...)
|
||||
return args
|
||||
}
|
||||
|
||||
// hostKeyArgs maps StrictHostKey onto OpenSSH's StrictHostKeyChecking option,
|
||||
// keeping behaviour consistent with the in-process client.
|
||||
func (u *KerberosUploader) hostKeyArgs() []string {
|
||||
if u.cfg.StrictHostKey {
|
||||
if u.cfg.KnownHostsPath != "" {
|
||||
return []string{
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "UserKnownHostsFile=" + u.cfg.KnownHostsPath,
|
||||
}
|
||||
}
|
||||
return []string{"-o", "StrictHostKeyChecking=yes"}
|
||||
}
|
||||
// Non-strict: accept new keys automatically but still record them. This
|
||||
// mirrors the in-process InsecureIgnoreHostKey trade-off the UI warns of.
|
||||
return []string{"-o", "StrictHostKeyChecking=accept-new"}
|
||||
}
|
||||
|
||||
// runCommand executes the given binary, capturing combined stderr so a
|
||||
// failure surfaces the remote/OpenSSH diagnostic instead of a bare exit code.
|
||||
func (u *KerberosUploader) runCommand(ctx context.Context, bin string, args []string) error {
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
sshLog().Debug("kerberos exec", "bin", bin, "args", strings.Join(args, " "))
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return fmt.Errorf("%s failed: %s", path.Base(bin), msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestKerberosConnection probes the remote host with system ssh, surfacing a
|
||||
// clear hint when no valid ticket is present.
|
||||
func TestKerberosConnection(ctx context.Context, cfg domain.SSHConfig) error {
|
||||
sshLog().Info("kerberos test connection start",
|
||||
"host", cfg.Host, "user", cfg.User, "port", cfg.Port)
|
||||
u := &KerberosUploader{cfg: cfg}
|
||||
remote := fmt.Sprintf("%s@%s", cfg.User, cfg.Host)
|
||||
args := append(u.commonSSHArgs(), remote, "true")
|
||||
if err := u.runCommand(ctx, systemSSHPath, args); err != nil {
|
||||
// Detect the most common cause: no/expired Kerberos ticket.
|
||||
if !hasKerberosTicket(ctx) {
|
||||
return fmt.Errorf("no valid Kerberos ticket found — run `kinit %s@BYTEDANCE.COM` first (%w)", cfg.User, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
sshLog().Info("kerberos test connection ok")
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasKerberosTicket reports whether `klist -s` indicates a valid ticket.
|
||||
// `klist -s` exits 0 when a valid TGT exists, non-zero otherwise.
|
||||
func hasKerberosTicket(ctx context.Context) bool {
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/klist", "-s")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
Reference in New Issue
Block a user