Files

231 lines
8.4 KiB
Go

// 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
}