| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "flag" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "os/signal" |
| 11 | "path" |
| 12 | "path/filepath" |
| 13 | "runtime" |
| 14 | "strings" |
| 15 | "syscall" |
| 16 | "time" |
| 17 | |
| 18 | "golang.org/x/term" |
| 19 | |
| 20 | "reasonix/internal/config" |
| 21 | "reasonix/internal/i18n" |
| 22 | "reasonix/internal/netclient" |
| 23 | "reasonix/internal/releaseasset" |
| 24 | "reasonix/internal/remote" |
| 25 | "reasonix/internal/remote/bootstrap" |
| 26 | "reasonix/internal/remote/forward" |
| 27 | ) |
| 28 | |
| 29 | func newFlagSet(name string) *flag.FlagSet { |
| 30 | fs := flag.NewFlagSet(name, flag.ContinueOnError) |
| 31 | return fs |
| 32 | } |
| 33 | |
| 34 | // buildRemoteClient resolves nameOrTarget against config + ~/.ssh/config and |
| 35 | // returns a not-yet-started remote.Client with terminal-based secret and |
| 36 | // host-key prompts. cleanup releases transient resources. |
| 37 | func buildRemoteClient(nameOrTarget string) (*remote.Client, func(), error) { |
| 38 | cfg, err := config.Load() |
| 39 | if err != nil { |
| 40 | return nil, nil, err |
| 41 | } |
| 42 | sshCfg, err := remote.LoadUserSSHConfig() |
| 43 | if err != nil { |
| 44 | return nil, nil, fmt.Errorf("load SSH config: %w", err) |
| 45 | } |
| 46 | host, err := remote.ResolveHost(cfg, nameOrTarget, sshCfg) |
| 47 | if err != nil { |
| 48 | return nil, nil, err |
| 49 | } |
| 50 | |
| 51 | auth := remoteAuthForHost(host, terminalSecretPrompt) |
| 52 | resolvedJumps, err := remote.ResolveJumpHosts(cfg, host.ProxyJump, sshCfg) |
| 53 | if err != nil { |
| 54 | return nil, nil, err |
| 55 | } |
| 56 | jumpHosts := make([]remote.JumpHostOptions, 0, len(resolvedJumps)) |
| 57 | for _, jump := range resolvedJumps { |
| 58 | jumpHosts = append(jumpHosts, remote.JumpHostOptions{ |
| 59 | Host: jump, |
| 60 | Auth: remoteAuthForHost(jump, terminalSecretPrompt), |
| 61 | }) |
| 62 | } |
| 63 | |
| 64 | policy := &remote.HostKeyPolicy{Prompt: terminalHostKeyPrompt} |
| 65 | |
| 66 | // A misconfigured proxy is surfaced, not silently bypassed: a proxy is often |
| 67 | // a policy requirement, and quietly dialing direct could exfiltrate the |
| 68 | // connection around it. |
| 69 | dialer, derr := netclient.NewStreamDialer(cfg.NetworkProxySpec()) |
| 70 | if derr != nil { |
| 71 | return nil, nil, fmt.Errorf("remote: network proxy is misconfigured: %w", derr) |
| 72 | } |
| 73 | client, err := remote.New(remote.Options{ |
| 74 | Host: host, |
| 75 | Auth: auth, |
| 76 | JumpHosts: jumpHosts, |
| 77 | HostKeys: policy, |
| 78 | Dialer: dialer, |
| 79 | }) |
| 80 | if err != nil { |
| 81 | return nil, nil, err |
| 82 | } |
| 83 | return client, func() {}, nil |
| 84 | } |
| 85 | |
| 86 | func remoteAuthForHost(host remote.ResolvedHost, prompt remote.SecretPrompt) remote.AuthOptions { |
| 87 | auth := remote.AuthOptions{SecretPrompt: prompt} |
| 88 | if host.PassphraseEnv != "" { |
| 89 | env := host.PassphraseEnv |
| 90 | auth.Passphrase = func() (string, error) { return config.ResolveCredential(env).Value, nil } |
| 91 | } |
| 92 | if host.PasswordEnv != "" { |
| 93 | env := host.PasswordEnv |
| 94 | auth.Password = func() (string, error) { return config.ResolveCredential(env).Value, nil } |
| 95 | } |
| 96 | return auth |
| 97 | } |
| 98 | |
| 99 | func terminalSecretPrompt(_ context.Context, kind remote.SecretKind, host, identityFile string) (string, error) { |
| 100 | var label string |
| 101 | switch kind { |
| 102 | case remote.SecretPassword: |
| 103 | label = fmt.Sprintf(i18n.M.RemotePasswordPromptFmt, host) |
| 104 | default: |
| 105 | label = fmt.Sprintf(i18n.M.RemotePassphrasePromptFmt, host) |
| 106 | if identityFile != "" { |
| 107 | label += " (" + filepath.Base(identityFile) + ")" |
| 108 | } |
| 109 | } |
| 110 | fmt.Fprint(os.Stderr, label+" ") |
| 111 | if !term.IsTerminal(int(os.Stdin.Fd())) { |
| 112 | return "", fmt.Errorf("cannot prompt for %s: not a terminal", kind) |
| 113 | } |
| 114 | b, err := term.ReadPassword(int(os.Stdin.Fd())) |
| 115 | fmt.Fprintln(os.Stderr) |
| 116 | if err != nil { |
| 117 | return "", err |
| 118 | } |
| 119 | return string(b), nil |
| 120 | } |
| 121 | |
| 122 | func terminalHostKeyPrompt(_ context.Context, q remote.HostKeyQuestion) (bool, error) { |
| 123 | fmt.Fprintf(os.Stderr, i18n.M.RemoteHostKeyPromptFmt+"\n", q.Host, q.KeyType, q.Fingerprint) |
| 124 | fmt.Fprint(os.Stderr, "Accept and continue? [y/N] ") |
| 125 | var answer string |
| 126 | _, _ = fmt.Fscanln(os.Stdin, &answer) |
| 127 | answer = strings.ToLower(strings.TrimSpace(answer)) |
| 128 | return answer == "y" || answer == "yes", nil |
| 129 | } |
| 130 | |
| 131 | // remoteConnectSyntax is the parsed form of `reasonix remote connect|open …`. |
| 132 | type remoteConnectSyntax struct { |
| 133 | name string |
| 134 | workspace string |
| 135 | localPort int |
| 136 | noServe bool |
| 137 | open bool |
| 138 | forwardOnly bool |
| 139 | } |
| 140 | |
| 141 | const remoteConnectUsage = "usage: reasonix remote connect <name> [flags]" |
| 142 | |
| 143 | // parseRemoteConnectSyntax accepts both documented orders: |
| 144 | // |
| 145 | // reasonix remote connect <name> [flags] |
| 146 | // reasonix remote connect [flags] <name> |
| 147 | // |
| 148 | // Go's flag package stops at the first positional, so `<name> --open` used to |
| 149 | // fail even though help/GUIDE show that form. We keep stdlib flags (so `-open` |
| 150 | // still works) and only special-case a leading host name. |
| 151 | func parseRemoteConnectSyntax(args []string, openAlias bool) (remoteConnectSyntax, error) { |
| 152 | fs := newFlagSet("remote connect") |
| 153 | workspace := fs.String("workspace", "", "remote workspace directory") |
| 154 | localPort := fs.Int("local-port", 0, "local port to bind for the serve tunnel (0 = auto)") |
| 155 | noServe := fs.Bool("no-serve", false, "only establish forwards; do not bootstrap serve") |
| 156 | open := fs.Bool("open", openAlias, "print/open the serve URL") |
| 157 | forwardOnly := fs.Bool("forward-only", false, "apply configured forwards only; no serve") |
| 158 | |
| 159 | var name string |
| 160 | flagArgs := args |
| 161 | if len(args) > 0 && !strings.HasPrefix(args[0], "-") { |
| 162 | name = args[0] |
| 163 | flagArgs = args[1:] |
| 164 | } |
| 165 | if err := parseCommandFlagSet(fs, flagArgs); err != nil { |
| 166 | return remoteConnectSyntax{}, err |
| 167 | } |
| 168 | rest := fs.Args() |
| 169 | switch { |
| 170 | case name != "" && len(rest) == 0: |
| 171 | // name-first: connect <name> [flags] |
| 172 | case name == "" && len(rest) == 1: |
| 173 | // flags-first: connect [flags] <name> |
| 174 | name = rest[0] |
| 175 | default: |
| 176 | return remoteConnectSyntax{}, errors.New(remoteConnectUsage) |
| 177 | } |
| 178 | return remoteConnectSyntax{ |
| 179 | name: name, |
| 180 | workspace: *workspace, |
| 181 | localPort: *localPort, |
| 182 | noServe: *noServe, |
| 183 | open: *open, |
| 184 | forwardOnly: *forwardOnly, |
| 185 | }, nil |
| 186 | } |
| 187 | |
| 188 | // remoteConnectCLI runs a foreground supervisor: connect, bootstrap serve, |
| 189 | // forward the serve port and configured forwards, and hold the tunnel until |
| 190 | // Ctrl-C. The remote serve keeps running after disconnect. |
| 191 | func remoteConnectCLI(args []string, version string) int { |
| 192 | // args[0] is "connect" or "open". |
| 193 | openAlias := args[0] == "open" |
| 194 | syntax, err := parseRemoteConnectSyntax(args[1:], openAlias) |
| 195 | if err != nil { |
| 196 | if code, ok := reportCommandFlagError(err); ok { |
| 197 | return code |
| 198 | } |
| 199 | fmt.Fprintln(os.Stderr, err) |
| 200 | return 2 |
| 201 | } |
| 202 | name := syntax.name |
| 203 | |
| 204 | cfg, err := config.Load() |
| 205 | if err != nil { |
| 206 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 207 | return 1 |
| 208 | } |
| 209 | entry, _ := cfg.RemoteHost(name) |
| 210 | ws := syntax.workspace |
| 211 | if ws == "" { |
| 212 | ws = entry.Workspace |
| 213 | } |
| 214 | |
| 215 | client, cleanup, err := buildRemoteClient(name) |
| 216 | if err != nil { |
| 217 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 218 | return 1 |
| 219 | } |
| 220 | defer cleanup() |
| 221 | |
| 222 | // Live status line. |
| 223 | client.Subscribe(func(ev remote.StatusEvent) { |
| 224 | switch ev.Status { |
| 225 | case remote.StatusConnecting: |
| 226 | fmt.Fprintf(os.Stderr, i18n.M.RemoteConnectingFmt+"\n", name) |
| 227 | case remote.StatusConnected: |
| 228 | fmt.Fprintf(os.Stderr, i18n.M.RemoteConnectedFmt+"\n", name) |
| 229 | case remote.StatusReconnecting: |
| 230 | fmt.Fprintf(os.Stderr, i18n.M.RemoteReconnectingFmt+"\n", name, ev.Attempt) |
| 231 | case remote.StatusDegraded: |
| 232 | fmt.Fprintf(os.Stderr, i18n.M.RemoteDegradedFmt+"\n", name) |
| 233 | case remote.StatusStopped: |
| 234 | if ev.Err != nil { |
| 235 | fmt.Fprintf(os.Stderr, "%s %v\n", i18n.M.ErrorPrefix, ev.Err) |
| 236 | } |
| 237 | } |
| 238 | }) |
| 239 | |
| 240 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
| 241 | defer stop() |
| 242 | if err := client.Start(ctx); err != nil { |
| 243 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 244 | return 1 |
| 245 | } |
| 246 | defer client.Close() |
| 247 | |
| 248 | // Apply configured forwards. |
| 249 | if err := applyConfiguredForwards(client, entry); err != nil { |
| 250 | fmt.Fprintf(os.Stderr, "%s %v\n", i18n.M.ErrorPrefix, err) |
| 251 | } |
| 252 | |
| 253 | if !syntax.noServe && !syntax.forwardOnly { |
| 254 | res, err := bootstrap.EnsureServe(ctx, client, bootstrap.Options{ |
| 255 | Workspace: ws, |
| 256 | Install: entry.ServeInstallMode(), |
| 257 | LocalBinary: currentExecutable(), |
| 258 | LocalGOOS: runtime.GOOS, |
| 259 | LocalGOARCH: runtime.GOARCH, |
| 260 | ProductVersion: version, |
| 261 | FetchBinary: fetchRemoteCLIBinary, |
| 262 | MinVersion: bootstrap.MinServeVersion, |
| 263 | Progress: func(step, detail string) { |
| 264 | fmt.Fprintf(os.Stderr, i18n.M.RemoteBootstrapStepFmt+"\n", step, detail) |
| 265 | }, |
| 266 | }) |
| 267 | if err != nil { |
| 268 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 269 | return 1 |
| 270 | } |
| 271 | localURL, ferr := forwardServe(client, res.State.Addr, syntax.localPort, res.Token) |
| 272 | if ferr != nil { |
| 273 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, ferr) |
| 274 | return 1 |
| 275 | } |
| 276 | fmt.Printf(i18n.M.RemoteServeReadyFmt+"\n", localURL) |
| 277 | if syntax.open { |
| 278 | _ = openInBrowser(localURL) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | fmt.Fprintln(os.Stderr, "Press Ctrl-C to disconnect (remote serve keeps running).") |
| 283 | <-ctx.Done() |
| 284 | fmt.Fprintln(os.Stderr, i18n.M.RemoteDisconnected) |
| 285 | return 0 |
| 286 | } |
| 287 | |
| 288 | func applyConfiguredForwards(client *remote.Client, entry config.RemoteHostEntry) error { |
| 289 | set := client.Forwards() |
| 290 | var firstErr error |
| 291 | for _, f := range entry.Forwards { |
| 292 | dir := forward.Local |
| 293 | if strings.EqualFold(f.Type, "remote") { |
| 294 | dir = forward.Remote |
| 295 | } |
| 296 | spec := forward.Spec{Direction: dir, BindAddr: normalizeBind(f.Bind), TargetAddr: f.Target} |
| 297 | if _, err := set.Add(spec); err != nil && firstErr == nil { |
| 298 | firstErr = err |
| 299 | } |
| 300 | } |
| 301 | return firstErr |
| 302 | } |
| 303 | |
| 304 | // forwardServe adds the reserved "serve" local forward to the remote serve |
| 305 | // address and returns the local URL (with token). |
| 306 | func forwardServe(client *remote.Client, remoteAddr string, localPort int, token string) (string, error) { |
| 307 | bind := "127.0.0.1:0" |
| 308 | if localPort > 0 { |
| 309 | bind = fmt.Sprintf("127.0.0.1:%d", localPort) |
| 310 | } |
| 311 | bound, err := client.Forwards().Add(forward.Spec{ |
| 312 | Name: "serve", |
| 313 | Direction: forward.Local, |
| 314 | BindAddr: bind, |
| 315 | TargetAddr: remoteAddr, |
| 316 | }) |
| 317 | if err != nil { |
| 318 | return "", err |
| 319 | } |
| 320 | return fmt.Sprintf("http://%s/?token=%s", bound, token), nil |
| 321 | } |
| 322 | |
| 323 | func normalizeBind(bind string) string { |
| 324 | if !strings.Contains(bind, ":") { |
| 325 | return "127.0.0.1:" + bind |
| 326 | } |
| 327 | return bind |
| 328 | } |
| 329 | |
| 330 | func currentExecutable() string { |
| 331 | if p, err := os.Executable(); err == nil { |
| 332 | return p |
| 333 | } |
| 334 | return "" |
| 335 | } |
| 336 | |
| 337 | func fetchRemoteCLIBinary(ctx context.Context, version, goos, goarch string) ([]byte, error) { |
| 338 | cfg, err := config.Load() |
| 339 | if err != nil { |
| 340 | return nil, err |
| 341 | } |
| 342 | client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{ |
| 343 | ResponseHeaderTimeout: 30 * time.Second, |
| 344 | }) |
| 345 | if err != nil { |
| 346 | return nil, err |
| 347 | } |
| 348 | client.Timeout = 2 * time.Minute |
| 349 | return releaseasset.DownloadCLI(ctx, client, version, goos, goarch) |
| 350 | } |
| 351 | |
| 352 | const remoteServeUsage = "usage: reasonix remote serve start|stop|status|logs <name> [--workspace PATH] [-n N]" |
| 353 | |
| 354 | // remoteServeCLI: serve start|stop|status|logs <name>. |
| 355 | func remoteServeCLI(args []string, version string) int { |
| 356 | if commandHelpRequested(args, 2) { |
| 357 | fmt.Fprintln(os.Stdout, remoteServeUsage) |
| 358 | return 0 |
| 359 | } |
| 360 | if len(args) < 2 { |
| 361 | fmt.Fprintln(os.Stderr, remoteServeUsage) |
| 362 | return 2 |
| 363 | } |
| 364 | action := args[0] |
| 365 | fs := newFlagSet("remote serve") |
| 366 | workspace := fs.String("workspace", "", "remote workspace directory") |
| 367 | n := fs.Int("n", 200, "log lines to show (logs)") |
| 368 | if code, ok := parseCommandFlags(fs, args[2:]); !ok { |
| 369 | return code |
| 370 | } |
| 371 | name := args[1] |
| 372 | cfg, err := config.Load() |
| 373 | if err != nil { |
| 374 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 375 | return 1 |
| 376 | } |
| 377 | entry, _ := cfg.RemoteHost(name) |
| 378 | ws := *workspace |
| 379 | if ws == "" { |
| 380 | ws = entry.Workspace |
| 381 | } |
| 382 | |
| 383 | client, cleanup, err := buildRemoteClient(name) |
| 384 | if err != nil { |
| 385 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 386 | return 1 |
| 387 | } |
| 388 | defer cleanup() |
| 389 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| 390 | defer cancel() |
| 391 | if err := client.Start(ctx); err != nil { |
| 392 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 393 | return 1 |
| 394 | } |
| 395 | defer client.Close() |
| 396 | |
| 397 | switch action { |
| 398 | case "start": |
| 399 | res, err := bootstrap.EnsureServe(ctx, client, bootstrap.Options{ |
| 400 | Workspace: ws, Install: entry.ServeInstallMode(), |
| 401 | LocalBinary: currentExecutable(), LocalGOOS: runtime.GOOS, LocalGOARCH: runtime.GOARCH, |
| 402 | ProductVersion: version, FetchBinary: fetchRemoteCLIBinary, MinVersion: bootstrap.MinServeVersion, |
| 403 | }) |
| 404 | if err != nil { |
| 405 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 406 | return 1 |
| 407 | } |
| 408 | fmt.Printf("serve running on remote %s (pid %d)\n", res.State.Addr, res.State.PID) |
| 409 | return 0 |
| 410 | case "stop": |
| 411 | if err := bootstrap.Stop(ctx, client, ws); err != nil { |
| 412 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 413 | return 1 |
| 414 | } |
| 415 | fmt.Println("serve stopped") |
| 416 | return 0 |
| 417 | case "status": |
| 418 | st, alive, err := bootstrap.Status(ctx, client, ws) |
| 419 | if err != nil { |
| 420 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 421 | return 1 |
| 422 | } |
| 423 | if st.PID == 0 { |
| 424 | fmt.Println("no serve recorded for this workspace") |
| 425 | return 0 |
| 426 | } |
| 427 | fmt.Printf("pid=%d addr=%s alive=%v workspace=%s\n", st.PID, st.Addr, alive, st.Workspace) |
| 428 | return 0 |
| 429 | case "logs": |
| 430 | if err := bootstrap.Logs(ctx, client, ws, *n, os.Stdout); err != nil { |
| 431 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 432 | return 1 |
| 433 | } |
| 434 | return 0 |
| 435 | default: |
| 436 | fmt.Fprintf(os.Stderr, "unknown serve action %q\n", action) |
| 437 | return 2 |
| 438 | } |
| 439 | } |
| 440 | |
| 441 | // remoteStatusCLI prints configured host summaries; with a name it also does a |
| 442 | // brief liveness probe. |
| 443 | func remoteStatusCLI(args []string) int { |
| 444 | cfg, err := config.Load() |
| 445 | if err != nil { |
| 446 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 447 | return 1 |
| 448 | } |
| 449 | if len(args) == 0 { |
| 450 | return remoteListCLI() |
| 451 | } |
| 452 | name := args[0] |
| 453 | entry, ok := cfg.RemoteHost(name) |
| 454 | if !ok { |
| 455 | fmt.Fprintf(os.Stderr, "no remote host named %q\n", name) |
| 456 | return 1 |
| 457 | } |
| 458 | fmt.Printf("host %s: %s@%s workspace=%s\n", entry.Name, entry.User, entry.Host, entry.Workspace) |
| 459 | return 0 |
| 460 | } |
| 461 | |
| 462 | // remoteForwardCLI manages persisted forward rules (applied on next connect). |
| 463 | func remoteForwardCLI(args []string) int { |
| 464 | if len(args) < 2 { |
| 465 | fmt.Fprintln(os.Stderr, "usage: reasonix remote forward add <host> (-L|-R) <spec> | rm <host> <name> | ls <host>") |
| 466 | return 2 |
| 467 | } |
| 468 | switch args[0] { |
| 469 | case "ls": |
| 470 | return remoteForwardLs(args[1]) |
| 471 | case "add": |
| 472 | return remoteForwardAdd(args[1:]) |
| 473 | case "rm": |
| 474 | return remoteForwardRm(args[1:]) |
| 475 | default: |
| 476 | fmt.Fprintf(os.Stderr, "unknown forward action %q\n", args[0]) |
| 477 | return 2 |
| 478 | } |
| 479 | } |
| 480 | |
| 481 | func remoteForwardLs(host string) int { |
| 482 | cfg, err := config.Load() |
| 483 | if err != nil { |
| 484 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 485 | return 1 |
| 486 | } |
| 487 | entry, ok := cfg.RemoteHost(host) |
| 488 | if !ok { |
| 489 | fmt.Fprintf(os.Stderr, "no remote host named %q\n", host) |
| 490 | return 1 |
| 491 | } |
| 492 | if len(entry.Forwards) == 0 { |
| 493 | fmt.Println("no forwards configured") |
| 494 | return 0 |
| 495 | } |
| 496 | for _, f := range entry.Forwards { |
| 497 | fmt.Printf("%s\t%s -> %s\n", f.Type, f.Bind, f.Target) |
| 498 | } |
| 499 | return 0 |
| 500 | } |
| 501 | |
| 502 | func remoteForwardAdd(args []string) int { |
| 503 | if len(args) != 3 { |
| 504 | fmt.Fprintln(os.Stderr, "usage: reasonix remote forward add <host> (-L|-R) <spec>") |
| 505 | return 2 |
| 506 | } |
| 507 | host := args[0] |
| 508 | dir, err := forward.ParseDirection(args[1]) |
| 509 | if err != nil { |
| 510 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 511 | return 2 |
| 512 | } |
| 513 | spec, err := forward.ParseShorthand(dir, args[2]) |
| 514 | if err != nil { |
| 515 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 516 | return 2 |
| 517 | } |
| 518 | if spec.NonLoopbackBind() { |
| 519 | fmt.Fprintln(os.Stderr, "warning: bind address is not loopback; the forward will be reachable off-machine") |
| 520 | } |
| 521 | ftype := "local" |
| 522 | if dir == forward.Remote { |
| 523 | ftype = "remote" |
| 524 | } |
| 525 | found := false |
| 526 | err = editUserConfig(func(c *config.Config) error { |
| 527 | entry, ok := c.RemoteHost(host) |
| 528 | if !ok { |
| 529 | return fmt.Errorf("no remote host named %q", host) |
| 530 | } |
| 531 | entry.Forwards = append(entry.Forwards, config.RemoteForwardEntry{Type: ftype, Bind: spec.BindAddr, Target: spec.TargetAddr}) |
| 532 | found = true |
| 533 | return c.UpsertRemoteHost(entry) |
| 534 | }) |
| 535 | if err != nil { |
| 536 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 537 | return 1 |
| 538 | } |
| 539 | _ = found |
| 540 | fmt.Printf("added %s forward %s -> %s to %q (takes effect on next connect)\n", ftype, spec.BindAddr, spec.TargetAddr, host) |
| 541 | return 0 |
| 542 | } |
| 543 | |
| 544 | func remoteForwardRm(args []string) int { |
| 545 | if len(args) != 2 { |
| 546 | fmt.Fprintln(os.Stderr, "usage: reasonix remote forward rm <host> <bind>") |
| 547 | return 2 |
| 548 | } |
| 549 | host, bind := args[0], args[1] |
| 550 | removed := false |
| 551 | err := editUserConfig(func(c *config.Config) error { |
| 552 | entry, ok := c.RemoteHost(host) |
| 553 | if !ok { |
| 554 | return fmt.Errorf("no remote host named %q", host) |
| 555 | } |
| 556 | kept := entry.Forwards[:0] |
| 557 | for _, f := range entry.Forwards { |
| 558 | if f.Bind == bind || normalizeBind(f.Bind) == normalizeBind(bind) { |
| 559 | removed = true |
| 560 | continue |
| 561 | } |
| 562 | kept = append(kept, f) |
| 563 | } |
| 564 | entry.Forwards = kept |
| 565 | return c.UpsertRemoteHost(entry) |
| 566 | }) |
| 567 | if err != nil { |
| 568 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 569 | return 1 |
| 570 | } |
| 571 | if !removed { |
| 572 | fmt.Fprintf(os.Stderr, "no forward bound to %q on %q\n", bind, host) |
| 573 | return 1 |
| 574 | } |
| 575 | fmt.Printf("removed forward %s from %q\n", bind, host) |
| 576 | return 0 |
| 577 | } |
| 578 | |
| 579 | // remoteFSCLI: fs ls|get|put with <name>:<path> operands. |
| 580 | func remoteFSCLI(args []string) int { |
| 581 | if len(args) < 2 { |
| 582 | fmt.Fprintln(os.Stderr, "usage: reasonix remote fs ls <name>:<path> | get <name>:<remote> [local] | put <local> <name>:<remote>") |
| 583 | return 2 |
| 584 | } |
| 585 | switch args[0] { |
| 586 | case "ls": |
| 587 | return remoteFSLs(args[1]) |
| 588 | case "get": |
| 589 | return remoteFSGet(args[1:]) |
| 590 | case "put": |
| 591 | return remoteFSPut(args[1:]) |
| 592 | default: |
| 593 | fmt.Fprintf(os.Stderr, "unknown fs action %q\n", args[0]) |
| 594 | return 2 |
| 595 | } |
| 596 | } |
| 597 | |
| 598 | func splitHostPath(s string) (host, p string, ok bool) { |
| 599 | i := strings.Index(s, ":") |
| 600 | if i <= 0 || i == len(s)-1 { |
| 601 | return "", "", false |
| 602 | } |
| 603 | return s[:i], s[i+1:], true |
| 604 | } |
| 605 | |
| 606 | func withRemoteFS(name string, fn func(ctx context.Context, client *remote.Client) int) int { |
| 607 | client, cleanup, err := buildRemoteClient(name) |
| 608 | if err != nil { |
| 609 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 610 | return 1 |
| 611 | } |
| 612 | defer cleanup() |
| 613 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| 614 | defer cancel() |
| 615 | if err := client.Start(ctx); err != nil { |
| 616 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 617 | return 1 |
| 618 | } |
| 619 | defer client.Close() |
| 620 | return fn(ctx, client) |
| 621 | } |
| 622 | |
| 623 | func remoteFSLs(operand string) int { |
| 624 | host, p, ok := splitHostPath(operand) |
| 625 | if !ok { |
| 626 | fmt.Fprintln(os.Stderr, "usage: reasonix remote fs ls <name>:<path>") |
| 627 | return 2 |
| 628 | } |
| 629 | return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int { |
| 630 | fsys, err := client.SFTP() |
| 631 | if err != nil { |
| 632 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 633 | return 1 |
| 634 | } |
| 635 | entries, err := fsys.List(ctx, p) |
| 636 | if err != nil { |
| 637 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 638 | return 1 |
| 639 | } |
| 640 | for _, e := range entries { |
| 641 | suffix := "" |
| 642 | if e.IsDir { |
| 643 | suffix = "/" |
| 644 | } |
| 645 | fmt.Printf("%s%s\n", e.Name, suffix) |
| 646 | } |
| 647 | return 0 |
| 648 | }) |
| 649 | } |
| 650 | |
| 651 | func remoteFSGet(args []string) int { |
| 652 | if len(args) < 1 || len(args) > 2 { |
| 653 | fmt.Fprintln(os.Stderr, "usage: reasonix remote fs get <name>:<remote> [local]") |
| 654 | return 2 |
| 655 | } |
| 656 | host, remotePath, ok := splitHostPath(args[0]) |
| 657 | if !ok { |
| 658 | fmt.Fprintln(os.Stderr, "usage: reasonix remote fs get <name>:<remote> [local]") |
| 659 | return 2 |
| 660 | } |
| 661 | localPath := path.Base(remotePath) |
| 662 | if len(args) >= 2 { |
| 663 | localPath = args[1] |
| 664 | } |
| 665 | return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int { |
| 666 | fsys, err := client.SFTP() |
| 667 | if err != nil { |
| 668 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 669 | return 1 |
| 670 | } |
| 671 | // Stream the full file to disk — never the capped preview reader, which |
| 672 | // would silently truncate large downloads. |
| 673 | out, err := os.Create(localPath) |
| 674 | if err != nil { |
| 675 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 676 | return 1 |
| 677 | } |
| 678 | n, err := fsys.Download(ctx, remotePath, out) |
| 679 | if cerr := out.Close(); cerr != nil && err == nil { |
| 680 | err = cerr |
| 681 | } |
| 682 | if err != nil { |
| 683 | _ = os.Remove(localPath) |
| 684 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 685 | return 1 |
| 686 | } |
| 687 | fmt.Printf("wrote %s (%d bytes)\n", localPath, n) |
| 688 | return 0 |
| 689 | }) |
| 690 | } |
| 691 | |
| 692 | func remoteFSPut(args []string) int { |
| 693 | if len(args) != 2 { |
| 694 | fmt.Fprintln(os.Stderr, "usage: reasonix remote fs put <local> <name>:<remote>") |
| 695 | return 2 |
| 696 | } |
| 697 | localPath := args[0] |
| 698 | host, remotePath, ok := splitHostPath(args[1]) |
| 699 | if !ok { |
| 700 | fmt.Fprintln(os.Stderr, "usage: reasonix remote fs put <local> <name>:<remote>") |
| 701 | return 2 |
| 702 | } |
| 703 | in, err := os.Open(localPath) |
| 704 | if err != nil { |
| 705 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 706 | return 1 |
| 707 | } |
| 708 | defer in.Close() |
| 709 | return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int { |
| 710 | fsys, err := client.SFTP() |
| 711 | if err != nil { |
| 712 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 713 | return 1 |
| 714 | } |
| 715 | n, err := fsys.UploadAtomic(ctx, remotePath, in, 0o644) |
| 716 | if err != nil { |
| 717 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 718 | return 1 |
| 719 | } |
| 720 | fmt.Printf("uploaded %s -> %s (%d bytes)\n", localPath, remotePath, n) |
| 721 | return 0 |
| 722 | }) |
| 723 | } |
| 724 | |
| 725 | func openInBrowser(url string) error { |
| 726 | var cmd string |
| 727 | var args []string |
| 728 | switch runtime.GOOS { |
| 729 | case "darwin": |
| 730 | cmd, args = "open", []string{url} |
| 731 | case "windows": |
| 732 | cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url} |
| 733 | default: |
| 734 | cmd, args = "xdg-open", []string{url} |
| 735 | } |
| 736 | c := exec.Command(cmd, args...) |
| 737 | return c.Start() |
| 738 | } |
| 739 |