| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "net" |
| 7 | "net/url" |
| 8 | "os" |
| 9 | "os/signal" |
| 10 | "syscall" |
| 11 | |
| 12 | "reasonix/internal/agent" |
| 13 | "reasonix/internal/config" |
| 14 | "reasonix/internal/control" |
| 15 | "reasonix/internal/i18n" |
| 16 | "reasonix/internal/serve" |
| 17 | ) |
| 18 | |
| 19 | // runServe exposes the controller's HTTP and SSE frontend. |
| 20 | func runServe(args []string) int { |
| 21 | return runServeWithOptions(args, serveRunOptions{command: "serve"}) |
| 22 | } |
| 23 | |
| 24 | // runWeb adds local browser lifecycle behavior to the Serve frontend. |
| 25 | func runWeb(args []string) int { |
| 26 | return runServeWithOptions(args, serveRunOptions{command: "web", openBrowser: true}) |
| 27 | } |
| 28 | |
| 29 | type serveRunOptions struct { |
| 30 | command string |
| 31 | openBrowser bool |
| 32 | } |
| 33 | |
| 34 | type serveFrontendOptions struct { |
| 35 | command string |
| 36 | address string |
| 37 | portFile string |
| 38 | tokenFile string |
| 39 | pidFile string |
| 40 | openBrowser bool |
| 41 | hasSession bool |
| 42 | } |
| 43 | |
| 44 | type serveFrontendResources struct { |
| 45 | listener net.Listener |
| 46 | displayAddr string |
| 47 | artifacts []string |
| 48 | registration *webInstanceRegistration |
| 49 | } |
| 50 | |
| 51 | func prepareServeFrontend(opts serveFrontendOptions) (_ *serveFrontendResources, err error) { |
| 52 | resources := &serveFrontendResources{displayAddr: opts.address} |
| 53 | defer func() { |
| 54 | if err != nil { |
| 55 | resources.release(true) |
| 56 | } |
| 57 | }() |
| 58 | |
| 59 | if opts.portFile != "" || opts.command == "web" || opts.openBrowser { |
| 60 | if opts.command == "web" { |
| 61 | resources.listener, err = listenWebWithPortRetry(opts.address) |
| 62 | } else { |
| 63 | resources.listener, err = net.Listen("tcp", opts.address) |
| 64 | } |
| 65 | if err != nil { |
| 66 | return nil, err |
| 67 | } |
| 68 | resources.displayAddr = resources.listener.Addr().String() |
| 69 | requested, selected := requestedPort(opts.address), requestedPort(resources.displayAddr) |
| 70 | if opts.command == "web" && requested != 0 && requested != selected { |
| 71 | fmt.Fprintf(os.Stderr, " port %d is in use; using %d instead\n", requested, selected) |
| 72 | } |
| 73 | if opts.portFile != "" { |
| 74 | if err = writeServeAddrFile(opts.portFile, resources.displayAddr); err != nil { |
| 75 | return nil, err |
| 76 | } |
| 77 | resources.artifacts = append(resources.artifacts, opts.portFile) |
| 78 | } |
| 79 | } |
| 80 | if opts.pidFile != "" { |
| 81 | if err = writeServePidFile(opts.pidFile); err != nil { |
| 82 | return nil, err |
| 83 | } |
| 84 | resources.artifacts = append(resources.artifacts, opts.pidFile) |
| 85 | } |
| 86 | if opts.command == "web" { |
| 87 | resources.registration, err = registerWebInstance(config.ReasonixHomeDir(), resources.displayAddr) |
| 88 | if err != nil { |
| 89 | return nil, err |
| 90 | } |
| 91 | } |
| 92 | return resources, nil |
| 93 | } |
| 94 | |
| 95 | func (r *serveFrontendResources) release(closeListener bool) { |
| 96 | if closeListener && r.listener != nil { |
| 97 | _ = r.listener.Close() |
| 98 | } |
| 99 | if r.registration != nil { |
| 100 | r.registration.Release() |
| 101 | } |
| 102 | for _, path := range r.artifacts { |
| 103 | _ = os.Remove(path) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func runServeFrontend(ctrl *control.Controller, srv *serve.Server, cfg config.ServeConfig, opts serveFrontendOptions) int { |
| 108 | resources, err := prepareServeFrontend(opts) |
| 109 | if err != nil { |
| 110 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 111 | return 1 |
| 112 | } |
| 113 | defer resources.release(false) |
| 114 | srv.EnableProviderSetupForListener(resources.displayAddr) |
| 115 | reportServeFrontend(ctrl, srv, cfg, resources.displayAddr, opts) |
| 116 | startServeBalanceDiagnostics(ctrl) |
| 117 | return serveFrontendLoop(ctrl, srv, resources, opts) |
| 118 | } |
| 119 | |
| 120 | func reportServeFrontend(ctrl *control.Controller, srv *serve.Server, cfg config.ServeConfig, address string, opts serveFrontendOptions) { |
| 121 | fmt.Printf("reasonix %s — %s on http://%s\n", opts.command, ctrl.Label(), address) |
| 122 | if srv.AuthMode() == "token" { |
| 123 | fmt.Println(" auth: token") |
| 124 | // Supervised Serve already owns the token file, so avoid logging its value. |
| 125 | if opts.portFile != "" && opts.tokenFile != "" { |
| 126 | fmt.Printf(" share: http://%s/ (token in %s)\n", address, opts.tokenFile) |
| 127 | } else { |
| 128 | fmt.Printf(" share: http://%s/#token=%s\n", address, url.QueryEscape(srv.AuthToken())) |
| 129 | } |
| 130 | } else if srv.AuthMode() == "password" { |
| 131 | fmt.Printf(" auth: password (login at http://%s/login)\n", address) |
| 132 | } |
| 133 | if warning := serve.PlainHTTPAuthWarning(cfg, address); warning != "" { |
| 134 | fmt.Fprintf(os.Stderr, " %s\n", warning) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func startServeBalanceDiagnostics(ctrl *control.Controller) { |
| 139 | go func() { |
| 140 | if balance, err := ctrl.Balance(context.Background()); err != nil { |
| 141 | fmt.Fprintf(os.Stderr, " balance: error — %v\n", err) |
| 142 | } else if balance == nil { |
| 143 | fmt.Fprintln(os.Stderr, " balance: not configured (no balance_url for this provider)") |
| 144 | } else { |
| 145 | fmt.Printf(" balance: %s\n", balance.Display()) |
| 146 | } |
| 147 | }() |
| 148 | } |
| 149 | |
| 150 | func serveFrontendLoop(ctrl *control.Controller, srv *serve.Server, resources *serveFrontendResources, opts serveFrontendOptions) int { |
| 151 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
| 152 | defer stop() |
| 153 | if resources.listener == nil { |
| 154 | if err := srv.RunGraceful(ctx, opts.address); err != nil { |
| 155 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 156 | return 1 |
| 157 | } |
| 158 | return 0 |
| 159 | } |
| 160 | var serveErr error |
| 161 | if opts.openBrowser { |
| 162 | sessionID := "" |
| 163 | if opts.hasSession { |
| 164 | sessionID = agent.BranchID(ctrl.SessionPath()) |
| 165 | } |
| 166 | serveErr = runServeListenerAfterReady(ctx, srv, resources.listener, resources.displayAddr, func() { |
| 167 | browserURL, err := launchWebBrowser(srv, resources.displayAddr, sessionID) |
| 168 | if err != nil { |
| 169 | fmt.Fprintf(os.Stderr, " browser: could not open %s — %v\n", browserURL, err) |
| 170 | } else { |
| 171 | fmt.Printf(" browser: %s\n", browserURL) |
| 172 | } |
| 173 | }) |
| 174 | } else { |
| 175 | serveErr = srv.RunGracefulListener(ctx, resources.listener) |
| 176 | } |
| 177 | if serveErr != nil { |
| 178 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, serveErr) |
| 179 | return 1 |
| 180 | } |
| 181 | return 0 |
| 182 | } |
| 183 |