diff --git a/CHANGELOG.md b/CHANGELOG.md index a45122c..e561df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ CHANGELOG fzf --preview='fzf-preview.sh {}' ``` - (Experimental) Sixel and Kitty image support now also available on Windows +- HTTP server can be configured to accept remote connections + ```sh + # FZF_API_KEY is required for a non-localhost listen address + export FZF_API_KEY="$(head -c 32 /dev/urandom | base64)" + fzf --listen 0.0.0.0:6266 + ``` - Bug fixes 0.43.0 diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 309be90..66bc678 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -793,14 +793,14 @@ ncurses finder only after the input stream is complete. e.g. \fBfzf --multi | fzf --sync\fR .RE .TP -.B "--listen[=HTTP_PORT]" -Start HTTP server on the given port. It allows external processes to send -actions to perform via POST method. If the port number is omitted or given as -0, fzf will choose the port automatically and export it as \fBFZF_PORT\fR -environment variable to the child processes started via \fBexecute\fR and -\fBexecute-silent\fR actions. If \fBFZF_API_KEY\fR environment variable is -set, the server would require sending an API key with the same value in the -\fBx-api-key\fR HTTP header. +.B "--listen[=[ADDR:]PORT]" +Start HTTP server and listen on the given address. It allows external processes +to send actions to perform via POST method. If the port number is omitted or +given as 0, fzf will automatically choose a port and export it as +\fBFZF_PORT\fR environment variable to the child processes. If +\fBFZF_API_KEY\fR environment variable is set, the server would require sending +an API key with the same value in the \fBx-api-key\fR HTTP header. +\fBFZF_API_KEY\fR is required for a non-localhost listen address. e.g. \fB# Start HTTP server on port 6266 @@ -812,8 +812,12 @@ e.g. # Send action to the server curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )' - # Start HTTP server on port 6266 and send an authenticated action + # Start HTTP server on port 6266 with remote connections allowed + # * Listening on non-localhost address requires using an API key export FZF_API_KEY="$(head -c 32 /dev/urandom | base64)" + fzf --listen 0.0.0.0:6266 + + # Send an authenticated action curl -XPOST localhost:6266 -H "x-api-key: $FZF_API_KEY" -d 'change-query(yo)' # Choose port automatically and export it as $FZF_PORT to the child process diff --git a/src/options.go b/src/options.go index b4f74e9..4176292 100644 --- a/src/options.go +++ b/src/options.go @@ -118,7 +118,7 @@ const usage = `usage: fzf [options] --read0 Read input delimited by ASCII NUL characters --print0 Print output delimited by ASCII NUL characters --sync Synchronous search for multi-staged filtering - --listen[=HTTP_PORT] Start HTTP server to receive actions (POST /) + --listen[=[ADDR:]PORT] Start HTTP server to receive actions (POST /) --version Display version information and exit Environment variables @@ -334,7 +334,7 @@ type Options struct { PreviewLabel labelOpts Unicode bool Tabstop int - ListenPort *int + ListenAddr *string ClearOnExit bool Version bool } @@ -1833,10 +1833,13 @@ func parseOptions(opts *Options, allArgs []string) { case "--tabstop": opts.Tabstop = nextInt(allArgs, &i, "tab stop required") case "--listen": - port := optionalNumeric(allArgs, &i, 0) - opts.ListenPort = &port + given, addr := optionalNextString(allArgs, &i) + if !given { + addr = defaultListenAddr + } + opts.ListenAddr = &addr case "--no-listen": - opts.ListenPort = nil + opts.ListenAddr = nil case "--clear": opts.ClearOnExit = true case "--no-clear": @@ -1927,8 +1930,7 @@ func parseOptions(opts *Options, allArgs []string) { } else if match, value := optString(arg, "--tabstop="); match { opts.Tabstop = atoi(value) } else if match, value := optString(arg, "--listen="); match { - port := atoi(value) - opts.ListenPort = &port + opts.ListenAddr = &value } else if match, value := optString(arg, "--hscroll-off="); match { opts.HscrollOff = atoi(value) } else if match, value := optString(arg, "--scroll-off="); match { @@ -1958,10 +1960,6 @@ func parseOptions(opts *Options, allArgs []string) { errorExit("tab stop must be a positive integer") } - if opts.ListenPort != nil && (*opts.ListenPort < 0 || *opts.ListenPort > 65535) { - errorExit("invalid listen port") - } - if len(opts.JumpLabels) == 0 { errorExit("empty jump labels") } diff --git a/src/server.go b/src/server.go index 8fc605a..a89c5e0 100644 --- a/src/server.go +++ b/src/server.go @@ -26,12 +26,13 @@ type getParams struct { } const ( - crlf = "\r\n" - httpOk = "HTTP/1.1 200 OK" + crlf - httpBadRequest = "HTTP/1.1 400 Bad Request" + crlf - httpUnauthorized = "HTTP/1.1 401 Unauthorized" + crlf - httpReadTimeout = 10 * time.Second - maxContentLength = 1024 * 1024 + crlf = "\r\n" + httpOk = "HTTP/1.1 200 OK" + crlf + httpBadRequest = "HTTP/1.1 400 Bad Request" + crlf + httpUnauthorized = "HTTP/1.1 401 Unauthorized" + crlf + httpReadTimeout = 10 * time.Second + maxContentLength = 1024 * 1024 + defaultListenAddr = "localhost:0" ) type httpServer struct { @@ -40,30 +41,52 @@ type httpServer struct { responseChannel chan string } -func startHttpServer(port int, actionChannel chan []*action, responseChannel chan string) (error, int) { - if port < 0 { - return nil, port +func parseListenAddress(address string) (error, string, int) { + parts := strings.SplitN(address, ":", 3) + if len(parts) == 1 { + parts = []string{"localhost", parts[0]} + } + if len(parts) != 2 { + return fmt.Errorf("invalid listen address: %s", address), "", 0 + } + portStr := parts[len(parts)-1] + port, err := strconv.Atoi(portStr) + if err != nil || port < 0 || port > 65535 { + return fmt.Errorf("invalid listen port: %s", portStr), "", 0 + } + if len(parts[0]) == 0 { + parts[0] = "localhost" + } + return nil, parts[0], port +} + +func startHttpServer(address string, actionChannel chan []*action, responseChannel chan string) (error, int) { + err, host, port := parseListenAddress(address) + if err != nil { + return err, port } - listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port)) + apiKey := os.Getenv("FZF_API_KEY") + if host != "localhost" && host != "127.0.0.1" && len(apiKey) == 0 { + return fmt.Errorf("FZF_API_KEY is required for remote access"), port + } + listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, port)) if err != nil { - return fmt.Errorf("port not available: %d", port), port + return fmt.Errorf("failed to listen on %s", address), port } if port == 0 { addr := listener.Addr().String() - parts := strings.SplitN(addr, ":", 2) + parts := strings.Split(addr, ":") if len(parts) < 2 { return fmt.Errorf("cannot extract port: %s", addr), port } - var err error - port, err = strconv.Atoi(parts[1]) - if err != nil { + if port, err := strconv.Atoi(parts[len(parts)-1]); err != nil { return err, port } } server := httpServer{ - apiKey: []byte(os.Getenv("FZF_API_KEY")), + apiKey: []byte(apiKey), actionChannel: actionChannel, responseChannel: responseChannel, } diff --git a/src/terminal.go b/src/terminal.go index e4ff304..326c07c 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -235,6 +235,7 @@ type Terminal struct { margin [4]sizeSpec padding [4]sizeSpec unicode bool + listenAddr *string listenPort *int borderShape tui.BorderShape cleanExit bool @@ -586,7 +587,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { } var previewBox *util.EventBox // We need to start previewer if HTTP server is enabled even when --preview option is not specified - if len(opts.Preview.command) > 0 || hasPreviewAction(opts) || opts.ListenPort != nil { + if len(opts.Preview.command) > 0 || hasPreviewAction(opts) || opts.ListenAddr != nil { previewBox = util.NewEventBox() } var renderer tui.Renderer @@ -659,7 +660,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { margin: opts.Margin, padding: opts.Padding, unicode: opts.Unicode, - listenPort: opts.ListenPort, + listenAddr: opts.ListenAddr, borderShape: opts.BorderShape, borderWidth: 1, borderLabel: nil, @@ -748,8 +749,8 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { _, t.hasLoadActions = t.keymap[tui.Load.AsEvent()] - if t.listenPort != nil { - err, port := startHttpServer(*t.listenPort, t.serverInputChan, t.serverOutputChan) + if t.listenAddr != nil { + err, port := startHttpServer(*t.listenAddr, t.serverInputChan, t.serverOutputChan) if err != nil { errorExit(err.Error()) }