Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,18 @@ fetch --proxy http://localhost:8080 example.com
fetch --proxy socks5://localhost:1080 example.com
```

### `--resolve [+]HOST:PORT:IP[,IP]`

Connect to the supplied IP address for a matching host and port while keeping
the URL host as the HTTP Host header and TLS SNI. Repeat the option or provide
comma-separated addresses to provide multiple candidates. A `*` host acts as
a fallback for any host. A leading `+` is accepted for curl compatibility.

```sh
fetch --resolve example.com:443:127.0.0.1 https://example.com
fetch --resolve '*:443:192.0.2.10' https://example.com
```

### `--unix PATH`

Make request over a Unix domain socket. Unix-like systems only.
Expand Down Expand Up @@ -804,7 +816,7 @@ fetch --from-curl 'https://example.com'
| Auth | `-u`, `--digest`, `--aws-sigv4`, `--oauth2-bearer` |
| TLS | `-k`, `--cacert`, `-E`/`--cert`, `--key`, `--tlsv1.x`, `--tls-max`, `--ech hard | true | auto | false` |
| Output | `-o`, `-O`, `-J` |
| Network | `-L`, `--max-redirs`, `-m`/`--max-time`, `--connect-timeout`, `-x`, `--unix-socket`, `--doh-url`, `--retry`, `--retry-delay`, `--retry-unsafe`, `-r` |
| Network | `-L`, `--max-redirs`, `-m`/`--max-time`, `--connect-timeout`, `-x`, `--unix-socket`, `--doh-url`, `--resolve`, `--retry`, `--retry-delay`, `--retry-unsafe`, `-r` |
| HTTP version | `-0`, `--http1.1`, `--http2`, `--http3` |
| Headers | `-A`, `-e`, `-b` |
| Verbosity | `-v`, `-s` |
Expand Down
58 changes: 58 additions & 0 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,64 @@ func TestMain(t *testing.T) {
}
})

t.Run("resolve connects to the selected IP and preserves the URL host", func(t *testing.T) {
t.Parallel()
chHost := make(chan string, 1)
server := startServer(func(w http.ResponseWriter, r *http.Request) {
chHost <- r.Host
io.WriteString(w, "resolved")
})
defer server.Close()

_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
if err != nil {
t.Fatal(err)
}
host := "resolve.example.test"
target := "http://" + host + ":" + port
res := runFetchOpts(t, fetchPath, fetchOpts{env: []string{
"HTTP_PROXY=", "http_proxy=", "HTTPS_PROXY=", "https_proxy=", "ALL_PROXY=", "all_proxy=",
"NO_PROXY=*", "no_proxy=*",
}}, target, "--resolve", host+":"+port+":127.0.0.1")
assertExitCode(t, 0, res)
assertBufEquals(t, res.stdout, "resolved")
if got := <-chHost; got != host+":"+port {
t.Fatalf("request Host = %q, want %q", got, host+":"+port)
}
})

t.Run("resolve preserves HTTPS Host and SNI", func(t *testing.T) {
t.Parallel()
info := make(chan [2]string, 1)
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sni := ""
if r.TLS != nil {
sni = r.TLS.ServerName
}
info <- [2]string{r.Host, sni}
io.WriteString(w, "secure-resolved")
}))
defer server.Close()

_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "https://"))
if err != nil {
t.Fatal(err)
}
host := "tls-resolve.example.test"
target := "https://" + host + ":" + port
res := runFetchOpts(t, fetchPath, fetchOpts{env: []string{
"HTTP_PROXY=", "http_proxy=", "HTTPS_PROXY=", "https_proxy=", "ALL_PROXY=", "all_proxy=",
"NO_PROXY=*", "no_proxy=*",
}}, target, "--insecure", "--resolve", host+":"+port+":127.0.0.1")
assertExitCode(t, 0, res)
assertBufEquals(t, res.stdout, "secure-resolved")
got := <-info
wantHost := host + ":" + port
if got[0] != wantHost || got[1] != host {
t.Fatalf("request Host/SNI = %q/%q, want %q/%q", got[0], got[1], wantHost, host)
}
})

t.Run("dns over https", func(t *testing.T) {
t.Parallel()
server := startServer(func(w http.ResponseWriter, r *http.Request) {
Expand Down
19 changes: 19 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/ryanfowler/fetch/internal/aws"
"github.com/ryanfowler/fetch/internal/config"
"github.com/ryanfowler/fetch/internal/core"
"github.com/ryanfowler/fetch/internal/resolver"
)

// App represents the full configuration for a fetch invocation.
Expand Down Expand Up @@ -60,6 +61,7 @@ type App struct {
Range []string
RemoteHeaderName bool
RemoteName bool
Resolve []resolver.ResolveEntry
UnixSocket string
Update bool
CheckUpdate bool
Expand Down Expand Up @@ -489,6 +491,14 @@ func (a *App) CLI() *CLI {
boolFlag(&a.RemoteName, "remote-name", "O", "Use URL path component as output filename").
WithAliases("output-current-dir"),

{
Long: "resolve",
Args: "HOST:PORT:IP",
Description: "Connect to IP while preserving Host/SNI",
IsSet: func() bool { return len(a.Resolve) > 0 },
Fn: a.parseResolveFlag,
},

cfgFlag("retry", "", "NUM", "Maximum number of retries",
func() bool { return a.Cfg.Retry != nil }, a.Cfg.ParseRetry).
WithDefault("0"),
Expand Down Expand Up @@ -635,6 +645,15 @@ func (a *App) parseBasicFlag(value string) error {
return nil
}

func (a *App) parseResolveFlag(value string) error {
entries, err := resolver.ParseResolveEntries(value)
if err != nil {
return core.NewValueError("resolve", value, err.Error(), false)
}
a.Resolve = append(a.Resolve, entries...)
return nil
}

func (a *App) parseDigestFlag(value string) error {
user, pass, ok := strings.Cut(value, ":")
if !ok {
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,11 @@ func (a *App) applyFromCurl(r *curl.Result) error {
return err
}
}
for _, value := range r.Resolve {
if err := a.parseResolveFlag(value); err != nil {
return err
}
}
if r.RetrySet {
if err := a.Cfg.ParseRetry(strconv.Itoa(r.Retry)); err != nil {
return err
Expand Down
36 changes: 36 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,42 @@ func TestCLI002Validation(t *testing.T) {
}
}

func TestResolveFlag(t *testing.T) {
app, err := Parse([]string{
"--resolve", "Example.com:443:192.0.2.10",
"--resolve=*:80:192.0.2.11,192.0.2.12",
"https://example.com",
})
if err != nil {
t.Fatal(err)
}
if len(app.Resolve) != 3 || app.Resolve[0].Host != "example.com" || app.Resolve[0].Port != "443" || app.Resolve[0].IP.String() != "192.0.2.10" || app.Resolve[1].Host != "*" || app.Resolve[2].IP.String() != "192.0.2.12" {
t.Fatalf("Resolve = %+v, want three parsed mappings", app.Resolve)
}
if !app.OptionProvenance("resolve").Has(SourceCLI) {
t.Fatal("resolve did not record explicit CLI provenance")
}

for _, value := range []string{"example.com:443", "example.com:443:not-an-ip"} {
if _, err := Parse([]string{"--resolve", value, "https://example.com"}); err == nil || !strings.Contains(err.Error(), "resolve") {
t.Fatalf("Parse(--resolve %q) error = %v, want resolve validation error", value, err)
}
}
}

func TestFromCurlResolve(t *testing.T) {
app, err := Parse([]string{"--from-curl", "curl --resolve +example.com:443:192.0.2.10,192.0.2.11 https://example.com"})
if err != nil {
t.Fatal(err)
}
if len(app.Resolve) != 2 || app.Resolve[0].Host != "example.com" || app.Resolve[0].IP.String() != "192.0.2.10" || app.Resolve[1].IP.String() != "192.0.2.11" {
t.Fatalf("Resolve = %+v, want imported mapping", app.Resolve)
}
if !app.OptionProvenance("resolve").Has(SourceCurl) {
t.Fatal("imported resolve did not record curl provenance")
}
}

func TestCLIProxyParseErrorRedactsURLCredentials(t *testing.T) {
value := "http://proxy-user:proxy-password@example.test/bad%zz?access_token=proxy-query-secret&safe=ok"
_, err := Parse([]string{"--proxy", value, "https://example.com"})
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/provenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ func (a *App) markCurlOptions(r *curl.Result) {
if r.DoHURL != "" {
a.markCurlOption("dns-server")
}
if len(r.Resolve) > 0 {
a.markCurlOption("resolve")
}
if r.RetrySet {
a.markCurlOption("retry")
}
Expand Down
8 changes: 5 additions & 3 deletions internal/cli/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ func (r *OptionRegistry) Ignored(mode OptionMode, explicit func(string) bool) []
for i, label := range []string{
"--data/--json/--xml", "--form", "--multipart", "--grpc", "--grpc-describe", "--grpc-list",
"--output", "--remote-name", "--remote-header-name", "--copy", "--method", "--header", "--query",
"--edit", "--session", "--retry", "--retry-unsafe", "--range", "--timing", "--proxy", "--discard", "--unix",
"--edit", "--session", "--retry", "--retry-unsafe", "--range", "--timing", "--proxy", "--resolve", "--discard", "--unix",
"--inspect-tls", "--bearer", "--basic", "--digest", "--aws-sigv4", "--ca-cert", "--cert", "--key",
"--tls", "--max-tls", "--insecure", "--format", "--dry-run",
} {
Expand All @@ -279,7 +279,7 @@ func applyFlagDefinition(flag *Flag) {
if flag.ConfigKey == "" {
flag.ConfigKey = flag.Long
}
if flag.Long == "header" || flag.Long == "query" || flag.Long == "ca-cert" {
if flag.Long == "header" || flag.Long == "query" || flag.Long == "ca-cert" || flag.Long == "resolve" {
flag.Repeatable = true
}
if flag.Long == "form" || flag.Long == "multipart" || flag.Long == "range" || flag.Long == "verbose" {
Expand Down Expand Up @@ -346,7 +346,8 @@ var fromCurlOptions = map[string]bool{
"range": true, "unix": true, "timeout": true, "connect-timeout": true,
"redirects": true, "proxy": true, "insecure": true, "max-tls": true, "min-tls": true,
"http": true, "ech": true, "cert": true, "key": true, "ca-cert": true, "dns-server": true,
"retry": true, "retry-delay": true, "grpc": true, "grpc-describe": true,
"resolve": true,
"retry": true, "retry-delay": true, "grpc": true, "grpc-describe": true,
"grpc-list": true, "query": true,
}

Expand Down Expand Up @@ -408,6 +409,7 @@ var flagDefinitions = map[string]Flag{

"header": {IgnoredIn: []OptionMode{ModeDNSInspection, ModeTLSInspection}},
"query": {IgnoredIn: []OptionMode{ModeDNSInspection, ModeTLSInspection}},
"resolve": {IgnoredIn: []OptionMode{ModeDNSInspection}},
"grpc": {Conflicts: []string{"grpc-list", "grpc-describe"}, IgnoredIn: []OptionMode{ModeDNSInspection, ModeTLSInspection}},
"grpc-describe": {Conflicts: []string{"grpc", "grpc-list"}, IgnoredIn: []OptionMode{ModeDNSInspection, ModeTLSInspection}},
"grpc-list": {Conflicts: []string{"grpc", "grpc-describe"}, IgnoredIn: []OptionMode{ModeDNSInspection, ModeTLSInspection}},
Expand Down
1 change: 1 addition & 0 deletions internal/client/automatic_ech.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func (t *automaticHTTP3Transport) dialAutomaticECHTCP(ctx context.Context, origi
Host: host,
Port: port,
OriginHost: origin.Hostname(),
OriginPort: originPort(origin),
Resolver: t.resolver,
Candidates: addresses,
}, cfg, t.ech)
Expand Down
Loading
Loading