From 7463ec9723677456e8987d136b14d5d1f29430d5 Mon Sep 17 00:00:00 2001 From: leechenghsiu Date: Fri, 10 Jul 2026 15:40:54 +0800 Subject: [PATCH 1/2] feat(server): add `zeabur server exec` to run a command over SSH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running a command on a dedicated server previously meant `server ssh-info` + a hand-rolled ssh2/sshpass invocation. That embeds the password into a multi-layer-escaped shell script, so passwords with special characters (]/@/…) get corrupted and auth fails; it also needs an external ssh/sshpass client. `server exec [server-id] -- ` fetches the credentials and opens the connection entirely in-process via golang.org/x/crypto/ssh: - Password is never printed nor passed through a shell → special chars are safe. - Streams stdout/stderr live (no buffering for long output). - Propagates the remote command's exit code. - StrictHostKeyChecking=no equivalent (dedicated servers rotate host keys). - Non-interactive: stdin left unset so the remote sees EOF (no hang). `ssh-info` stays as the primitive for users who want the raw credentials to drive their own client / take over manually. DES-802 Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 1 + go.sum | 2 + internal/cmd/server/exec/exec.go | 155 +++++++++++++++++++++++++++++++ internal/cmd/server/server.go | 2 + 4 files changed, 160 insertions(+) create mode 100644 internal/cmd/server/exec/exec.go diff --git a/go.mod b/go.mod index 1595916..42577c7 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.50.0 golang.org/x/oauth2 v0.36.0 ) diff --git a/go.sum b/go.sum index 1cfb18e..4c5bf43 100644 --- a/go.sum +++ b/go.sum @@ -149,6 +149,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= diff --git a/internal/cmd/server/exec/exec.go b/internal/cmd/server/exec/exec.go new file mode 100644 index 0000000..a6600d6 --- /dev/null +++ b/internal/cmd/server/exec/exec.go @@ -0,0 +1,155 @@ +// Package exec implements `zeabur server exec`: run a command on a dedicated +// server over SSH and stream its output. +// +// Unlike the two-step `server ssh-info` + hand-rolled ssh2/sshpass flow (which +// embeds the password into a multi-layer-escaped shell script and corrupts +// passwords containing special characters), this fetches the credentials and +// opens the connection entirely in-process via golang.org/x/crypto/ssh. The +// password is never printed nor passed through a shell, so special characters +// are safe and no external ssh/sshpass client is required (DES-802). +package exec + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + + "github.com/zeabur/cli/internal/cmdutil" +) + +type Options struct { + id string +} + +func NewCmdExec(f *cmdutil.Factory) *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "exec [server-id] -- [args...]", + Short: "Run a command on a dedicated server over SSH", + Long: `Run a command on a dedicated server over SSH and stream its output. + +Credentials are fetched and used inside the CLI — never printed and never passed +through a shell — so passwords with special characters work and no external ssh +or sshpass client is required. + +The command after '--' is joined with spaces and run by the remote login shell, +matching 'ssh host ' semantics. Quote a compound command as a single +argument to keep it intact.`, + Example: ` zeabur server exec --id -- uname -a + zeabur server exec -- sudo kubectl get pods -A + zeabur server exec -- 'echo start && sudo systemctl status k3s'`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // Everything after '--' is the remote command; anything before it is + // the optional positional server id. cobra reports the '--' position. + dash := cmd.ArgsLenAtDash() + if dash < 0 { + return fmt.Errorf("missing command; use: zeabur server exec [server-id] -- ") + } + + positional := args[:dash] + remoteArgs := args[dash:] + if len(remoteArgs) == 0 { + return fmt.Errorf("no command given after '--'") + } + if len(positional) > 1 { + return fmt.Errorf("unexpected arguments before '--': %v", positional[1:]) + } + if len(positional) == 1 { + if opts.id != "" && opts.id != positional[0] { + return fmt.Errorf("conflicting server IDs: arg=%q, --id=%q", positional[0], opts.id) + } + opts.id = positional[0] + } + + return runExec(f, opts, strings.Join(remoteArgs, " ")) + }, + } + + cmd.Flags().StringVar(&opts.id, "id", "", "Server ID") + + return cmd +} + +func runExec(f *cmdutil.Factory, opts *Options, remoteCmd string) error { + if opts.id == "" { + return fmt.Errorf("--id is required") + } + + ctx := context.Background() + + server, err := f.ApiClient.GetServer(ctx, opts.id) + if err != nil { + return fmt.Errorf("get server failed: %w", err) + } + + username := "root" + if server.SSHUsername != nil && *server.SSHUsername != "" { + username = *server.SSHUsername + } + + var auth []ssh.AuthMethod + if server.IsManaged { + pw, err := f.ApiClient.RevealServerPassword(ctx, opts.id) + if err == nil && pw != "" { + auth = append(auth, ssh.Password(pw)) + } + } + if len(auth) == 0 { + return fmt.Errorf( + "no password available for server %s; `server exec` uses password auth for managed servers — for key-based access use `zeabur server ssh`", + opts.id, + ) + } + + config := &ssh.ClientConfig{ + User: username, + Auth: auth, + // Equivalent to `-o StrictHostKeyChecking=no`: dedicated servers are + // reached by IP with rotating host keys, so pinning would only wedge. + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 15 * time.Second, + } + addr := fmt.Sprintf("%s:%d", server.IP, server.SSHPort) + + f.Log.Infof("Connecting to %s@%s ...", username, addr) + + client, err := ssh.Dial("tcp", addr, config) + if err != nil { + return fmt.Errorf("ssh connection failed: %w", err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("ssh session failed: %w", err) + } + defer session.Close() + + // Stream output live (long output like `kubectl logs` shouldn't buffer). + // Stdin is intentionally left unset: exec is non-interactive, so the remote + // command sees EOF immediately — attaching a TTY stdin here could hang. + session.Stdout = os.Stdout + session.Stderr = os.Stderr + + runErr := session.Run(remoteCmd) + if runErr != nil { + // Propagate the remote command's exit code as the CLI's exit code. + var exitErr *ssh.ExitError + if errors.As(runErr, &exitErr) { + session.Close() + client.Close() + os.Exit(exitErr.ExitStatus()) + } + return fmt.Errorf("command execution failed: %w", runErr) + } + + return nil +} diff --git a/internal/cmd/server/server.go b/internal/cmd/server/server.go index b96a117..dc25d09 100644 --- a/internal/cmd/server/server.go +++ b/internal/cmd/server/server.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" serverCatalogCmd "github.com/zeabur/cli/internal/cmd/server/catalog" + serverExecCmd "github.com/zeabur/cli/internal/cmd/server/exec" serverGetCmd "github.com/zeabur/cli/internal/cmd/server/get" serverListCmd "github.com/zeabur/cli/internal/cmd/server/list" serverPlanCmd "github.com/zeabur/cli/internal/cmd/server/plan" @@ -33,6 +34,7 @@ func NewCmdServer(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(serverRegionCmd.NewCmdRegion(f)) cmd.AddCommand(serverPlanCmd.NewCmdPlan(f)) cmd.AddCommand(serverSSHCmd.NewCmdSSH(f)) + cmd.AddCommand(serverExecCmd.NewCmdExec(f)) cmd.AddCommand(serverSSHInfoCmd.NewCmdSSHInfo(f)) return cmd From 921e99814338f6386c1f53208caa0db137cba567 Mon Sep 17 00:00:00 2001 From: leechenghsiu Date: Fri, 10 Jul 2026 15:57:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(server):=20address=20exec=20review=20?= =?UTF-8?q?=E2=80=94=20crypto=20bump,=20interactive=20select,=20password?= =?UTF-8?q?=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #263: - Bump golang.org/x/crypto to v0.52.0 (fixes GO-2026-5013 in ssh.Dial). - Prompt for a server when no id is given in interactive mode (mirrors `server ssh`); the remote command stays non-interactive. - Return RevealServerPassword errors instead of swallowing them as "no password available", so an API/authz failure surfaces its real cause. HostKeyCallback stays ssh.InsecureIgnoreHostKey() intentionally: it's the StrictHostKeyChecking=no equivalent already used by `server ssh`, since dedicated servers are reached by IP with rotating host keys and can't be pinned. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 10 +++---- go.sum | 20 ++++++------- internal/cmd/server/exec/exec.go | 49 +++++++++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 42577c7..24acd81 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.28.0 - golang.org/x/crypto v0.50.0 + golang.org/x/crypto v0.52.0 golang.org/x/oauth2 v0.36.0 ) @@ -60,10 +60,10 @@ require ( github.com/spf13/pflag v1.0.10 github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/term v0.42.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.44.0 // indirect gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 4c5bf43..7864952 100644 --- a/go.sum +++ b/go.sum @@ -149,16 +149,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -172,18 +172,18 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/cmd/server/exec/exec.go b/internal/cmd/server/exec/exec.go index a6600d6..545c0cf 100644 --- a/internal/cmd/server/exec/exec.go +++ b/internal/cmd/server/exec/exec.go @@ -78,13 +78,49 @@ argument to keep it intact.`, return cmd } -func runExec(f *cmdutil.Factory, opts *Options, remoteCmd string) error { - if opts.id == "" { - return fmt.Errorf("--id is required") +// selectServer prompts the user to pick a server when no id was given (mirrors +// `server ssh`). The remote command itself stays non-interactive. +func selectServer(ctx context.Context, f *cmdutil.Factory) (string, error) { + servers, err := f.ApiClient.ListServers(ctx, f.CurrentOwnerID()) + if err != nil { + return "", fmt.Errorf("list servers failed: %w", err) + } + if len(servers) == 0 { + return "", fmt.Errorf("no servers found") + } + + options := make([]string, len(servers)) + for i, s := range servers { + location := s.IP + if s.City != nil && s.Country != nil { + location = fmt.Sprintf("%s, %s", *s.City, *s.Country) + } else if s.Country != nil { + location = *s.Country + } + options[i] = fmt.Sprintf("%s (%s)", s.Name, location) + } + + idx, err := f.Prompter.Select("Select a server", "", options) + if err != nil { + return "", err } + return servers[idx].ID, nil +} +func runExec(f *cmdutil.Factory, opts *Options, remoteCmd string) error { ctx := context.Background() + if opts.id == "" { + if !f.Interactive { + return fmt.Errorf("--id is required") + } + id, err := selectServer(ctx, f) + if err != nil { + return err + } + opts.id = id + } + server, err := f.ApiClient.GetServer(ctx, opts.id) if err != nil { return fmt.Errorf("get server failed: %w", err) @@ -98,7 +134,12 @@ func runExec(f *cmdutil.Factory, opts *Options, remoteCmd string) error { var auth []ssh.AuthMethod if server.IsManaged { pw, err := f.ApiClient.RevealServerPassword(ctx, opts.id) - if err == nil && pw != "" { + if err != nil { + // Don't swallow an API/authorization failure as "no password" — that + // hides the real cause. Only an actually-empty password is unavailable. + return fmt.Errorf("reveal server password failed: %w", err) + } + if pw != "" { auth = append(auth, ssh.Password(pw)) } }