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