Skip to content

Commit

Permalink
Added --resolve arg support (#9)
Browse files Browse the repository at this point in the history
Closes #5

Added --resolve arg support
  • Loading branch information
ameshkov authored Sep 21, 2023
1 parent 9b80909 commit 0674bb9
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 29 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ adheres to [Semantic Versioning][semver].
### Added

* `gocurl` now supports Encrypted Client Hello. Added `--ech` and `--echconfig`
command-line arguments, see examples in README.md to learn more. ([#3](#3))
command-line arguments, see examples in README.md to learn more. ([#3][#3])
* Added `--resolve` command-line argument support. It works similarly to the one
in `curl` with one important difference: `gocurl` ignores `port` there and
simply returns specified IP addresses for the host. ([#5][#5])

[#3]: https://github.com/ameshkov/gocurl/issues/3

[#5]: https://github.com/ameshkov/gocurl/issues/5

[unreleased]: https://github.com/ameshkov/gocurl/compare/v1.0.5...HEAD

## [1.0.6] - 2023-09-17
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ Use it the same way you use original curl.
* `gocurl -x socks5://user:pass@host:port https://httpbin.agrd.workers.dev/get`
use a proxy server.
* `gocurl -I --tlsv1.3 https://tls-v1-2.badssl.com:1012/` force use TLS v1.3.
* `gocurl -I --connect-to="httpbin.agrd.workers.dev:443:172.67.152.85:443"
https://httpbin.agrd.workers.dev/head` connect to the specified IP addresses.
* `gocurl -I --resolve="httpbin.agrd.workers.dev:443:172.67.152.85"
https://httpbin.agrd.workers.dev/head` resolve the hostname to the specified
IP address. Note, that unlike `curl`, `gocurl` ignores port in this option.

<a id="newstuff"></a>

Expand Down Expand Up @@ -132,8 +137,6 @@ Here's what happens now:
## All command-line arguments
```shell
% gocurl --help
Usage:
gocurl [OPTIONS]
Expand All @@ -148,7 +151,7 @@ Application Options:
-x, --proxy=[protocol://username:password@]host[:port] Use the specified proxy. The proxy string can be
specified with a protocol:// prefix.
--connect-to=<HOST1:PORT1:HOST2:PORT2> For a request to the given HOST1:PORT1 pair, connect to
HOST2:PORT2 instead.
HOST2:PORT2 instead. Can be specified multiple times.
-I, --head Fetch the headers only.
-k, --insecure Disables TLS verification of the connection.
--tlsv1.3 Forces gocurl to use TLS v1.3.
Expand All @@ -159,6 +162,9 @@ Application Options:
--ech Enables ECH support for the request.
--echconfig=<base64-encoded data> ECH configuration to use for this request. Implicitly
enables --ech when specified.
--resolve=<[+]host:port:addr[,addr]...> Provide a custom address for a specific host. port is
ignored by gocurl. '*' can be used instead of the host
name. Can be specified multiple times.
--tls-split-hello=<CHUNKSIZE:DELAY> An option that allows splitting TLS ClientHello in two
parts in order to avoid common DPI systems detecting
TLS. CHUNKSIZE is the size of the first bytes before
Expand Down
5 changes: 5 additions & 0 deletions internal/client/dialer/direct.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ func (d *Direct) Dial(network, addr string) (conn net.Conn, err error) {
}

ipAddr := ipAddrs[0]
connectAddr := net.JoinHostPort(ipAddr.String(), port)

if connectAddr != addr {
d.out.Debug("Connecting to %s://%s", network, connectAddr)
}

conn, err = net.Dial(network, net.JoinHostPort(ipAddr.String(), port))
if err != nil {
Expand Down
110 changes: 88 additions & 22 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/tls"
"encoding/base64"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
Expand Down Expand Up @@ -66,6 +67,11 @@ type Config struct {
// an encrypted connection.
ECHConfigs []ctls.ECHConfig

// Resolve is a map of host:ips pairs. It allows specifying custom IP
// addresses for a specific host or all hosts (if '*' is used instead of
// the host name).
Resolve map[string][]net.IP

// TLSSplitChunkSize is a size of the first chunk of ClientHello that is
// sent to the server.
TLSSplitChunkSize int
Expand Down Expand Up @@ -125,12 +131,19 @@ func ParseConfig() (cfg *Config, err error) {
}

if len(opts.ConnectTo) > 0 {
cfg.ConnectTo, err = createConnectTo(opts.ConnectTo)
cfg.ConnectTo, err = parseConnectTo(opts.ConnectTo)
if err != nil {
return nil, fmt.Errorf("invalid connect-to specified %v: %w", opts.ConnectTo, err)
}
}

if len(opts.Resolve) > 0 {
cfg.Resolve, err = parseResolve(opts.Resolve)
if err != nil {
return nil, fmt.Errorf("invalid resolve specified %s: %w", opts.Resolve, err)
}
}

if len(opts.Headers) > 0 {
cfg.Headers = createHeaders(opts.Headers)
}
Expand All @@ -144,30 +157,14 @@ func ParseConfig() (cfg *Config, err error) {
}

if opts.TLSSplitHello != "" {
parts := strings.SplitN(opts.TLSSplitHello, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid tls-split-hello format: %s", opts.TLSSplitHello)
}

cfg.TLSSplitChunkSize, err = strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid tls-split-hello: %w", err)
}

cfg.TLSSplitDelay, err = strconv.Atoi(parts[1])
cfg.TLSSplitChunkSize, cfg.TLSSplitDelay, err = parseTLSSplitHello(opts.TLSSplitHello)
if err != nil {
return nil, fmt.Errorf("invalid tls-split-hello: %w", err)
}
}

if opts.ECHConfig != "" {
var b []byte
b, err = base64.StdEncoding.DecodeString(opts.ECHConfig)
if err != nil {
return nil, fmt.Errorf("invalid echconfig encoding: %w", err)
}

cfg.ECHConfigs, err = ctls.UnmarshalECHConfigs(b)
cfg.ECHConfigs, err = unmarshalECHConfigs(opts.ECHConfig)
if err != nil {
return nil, fmt.Errorf("invalid echconfig: %w", err)
}
Expand All @@ -179,13 +176,13 @@ func ParseConfig() (cfg *Config, err error) {
return cfg, nil
}

// createConnectTo creates a "connect-to" map from the string representation.
func createConnectTo(connectTo []string) (m map[string]string, err error) {
// parseConnectTo creates a "connect-to" map from the string representation.
func parseConnectTo(connectTo []string) (m map[string]string, err error) {
m = map[string]string{}
for _, ct := range connectTo {
parts := strings.SplitN(ct, ":", 4)
if len(parts) != 4 {
return nil, fmt.Errorf("invalid connect-to format %s. Expected HOST1:PORT1:HOST2:PORT2", ct)
return nil, fmt.Errorf("invalid connect-to format %s, expected HOST1:PORT1:HOST2:PORT2", ct)
}

oldHost := parts[0] + ":" + parts[1]
Expand All @@ -196,6 +193,44 @@ func createConnectTo(connectTo []string) (m map[string]string, err error) {
return m, nil
}

// parseResolve creates a "resolve" map from the string representation.
func parseResolve(resolve []string) (m map[string][]net.IP, err error) {
m = map[string][]net.IP{}

for _, r := range resolve {
parts := strings.SplitN(r, ":", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("invalid resolve format %s, expected HOST:PORT:ADDRS", r)
}

host := parts[0]
addrs := parts[2]
var ipAddresses []net.IP

for _, a := range strings.Split(addrs, ",") {
ipAddr := net.ParseIP(a)
if ipAddr == nil {
return nil, fmt.Errorf("invalid addr %s", a)
}

// Trim zero bytes.
if ipAddr.To4() != nil {
ipAddr = ipAddr.To4()
}

ipAddresses = append(ipAddresses, ipAddr)
}

if len(ipAddresses) == 0 {
return nil, fmt.Errorf("no addrs for %s", host)
}

m[host] = ipAddresses
}

return m, nil
}

// createHeaders creates HTTP headers map from the string array.
func createHeaders(headers []string) (h http.Header) {
h = http.Header{}
Expand All @@ -214,3 +249,34 @@ func createHeaders(headers []string) (h http.Header) {

return h
}

// parseTLSSplitHello parses --tls-split-hello, returns error if it's invalid.
func parseTLSSplitHello(tlsSplitHello string) (chunkSize int, delay int, err error) {
parts := strings.SplitN(tlsSplitHello, ":", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid tls-split-hello format: %s", tlsSplitHello)
}

chunkSize, err = strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("invalid tls-split-hello: %w", err)
}

delay, err = strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("invalid tls-split-hello: %w", err)
}

return chunkSize, delay, nil
}

// unmarshalECHConfigs parses the base64-encoded ECH config.
func unmarshalECHConfigs(echConfig string) (echConfigs []ctls.ECHConfig, err error) {
var b []byte
b, err = base64.StdEncoding.DecodeString(echConfig)
if err != nil {
return nil, err
}

return ctls.UnmarshalECHConfigs(b)
}
6 changes: 5 additions & 1 deletion internal/config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type Options struct {

// ConnectTo allows to override the connection target, i.e. for a request
// to the given HOST1:PORT1 pair, connect to HOST2:PORT2 instead.
ConnectTo []string `long:"connect-to" description:"For a request to the given HOST1:PORT1 pair, connect to HOST2:PORT2 instead." value-name:"<HOST1:PORT1:HOST2:PORT2>"`
ConnectTo []string `long:"connect-to" description:"For a request to the given HOST1:PORT1 pair, connect to HOST2:PORT2 instead. Can be specified multiple times." value-name:"<HOST1:PORT1:HOST2:PORT2>"`

// Head signals that the tool should only fetch headers. If specified,
// headers will be written to the output.
Expand Down Expand Up @@ -63,6 +63,10 @@ type Options struct {
// configuration using DNS.
ECHConfig string `long:"echconfig" description:"ECH configuration to use for this request. Implicitly enables --ech when specified." value-name:"<base64-encoded data>"`

// Resolve allows to provide a custom address for a specific host and port
// pair. Supports '*' instead of the host name to cover all hosts.
Resolve []string `long:"resolve" description:"Provide a custom address for a specific host. port is ignored by gocurl. '*' can be used instead of the host name. Can be specified multiple times." value-name:"<[+]host:port:addr[,addr]...>"`

// TLSSplitHello is an option that allows splitting TLS ClientHello in two
// parts in order to avoid common DPI systems detecting TLS. CHUNKSIZE is
// the size of the first bytes before ClientHello is split, DELAY is delay
Expand Down
26 changes: 24 additions & 2 deletions internal/resolve/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ func (r *Resolver) LookupHost(hostname string) (ipAddresses []net.IP, err error)
return ipAddresses, nil
}

if addrs, ok := r.lookupFromCfg(hostname); ok {
r.out.Debug("Resolved IP addresses for %s from the configuration", hostname)

return addrs, nil
}

var errs []error

for _, qType := range []uint16{dns.TypeA, dns.TypeAAAA} {
Expand Down Expand Up @@ -148,6 +154,24 @@ func (r *Resolver) LookupECHConfigs(hostname string) (echConfigs []ctls.ECHConfi
return echConfigs, nil
}

// lookupFromCfg checks if IP address for hostname are specified in the
// configuration.
func (r *Resolver) lookupFromCfg(hostname string) (addrs []net.IP, ok bool) {
if len(r.cfg.Resolve) == 0 {
return nil, false
}

if addrs, ok = r.cfg.Resolve[hostname]; ok {
return addrs, ok
}

if addrs, ok = r.cfg.Resolve["*"]; ok {
return addrs, ok
}

return nil, false
}

// dnsLookupAll sends the query m to each DNS resolver until it gets
// a successful non-empty response. If all attempts are unsuccessful, returns
// an error.
Expand All @@ -170,8 +194,6 @@ func dnsLookupAll(m *dns.Msg, addrs []string) (resp *dns.Msg, err error) {
// dnsLookup sends the query m over to DNS resolver addr and returns the
// response. Adds additional logic on top of it: returns an error when the
// response code is not success or when there're no resource records.
//
// TODO(ameshkov): --resolve logic should be added here.
func dnsLookup(m *dns.Msg, addr string) (resp *dns.Msg, err error) {
resp, err = dns.Exchange(m, net.JoinHostPort(addr, "53"))
qTypeStr := dns.Type(m.Question[0].Qtype).String()
Expand Down
23 changes: 23 additions & 0 deletions internal/resolve/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ func TestResolver_LookupHost_ipAddr(t *testing.T) {
require.Equal(t, []net.IP{{127, 0, 0, 1}}, addrs)
}

func TestResolver_LookupHost_preConfigured(t *testing.T) {
out, err := output.NewOutput("", false)
require.NoError(t, err)

r, err := resolve.NewResolver(&config.Config{
Resolve: map[string][]net.IP{
"example.org": {{127, 0, 0, 1}},
"*": {{127, 0, 0, 2}},
},
}, out)
require.NoError(t, err)

addrs, err := r.LookupHost("example.org")
require.NoError(t, err)
require.NotEmpty(t, addrs)
require.Equal(t, []net.IP{{127, 0, 0, 1}}, addrs)

addrs, err = r.LookupHost("example.net")
require.NoError(t, err)
require.NotEmpty(t, addrs)
require.Equal(t, []net.IP{{127, 0, 0, 2}}, addrs)
}

func TestResolver_LookupECHConfigs(t *testing.T) {
out, err := output.NewOutput("", false)
require.NoError(t, err)
Expand Down

0 comments on commit 0674bb9

Please sign in to comment.