Skip to content

Commit e077e52

Browse files
committed
fix(artifact): preserve localhost listener family
A daemon configured with the localhost hostname may bind only IPv6, so forcing every client request onto IPv4 makes an otherwise supported listener unreachable. Resolve localhost at dial time, reject non-loopback results, and try the validated numeric addresses without exposing the request through a proxy.
1 parent 9334805 commit e077e52

2 files changed

Lines changed: 99 additions & 18 deletions

File tree

cmd/agentsview/artifact_sync.go

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,65 @@ var runArtifactSyncCLI = artifact.Sync
2424

2525
const daemonArtifactExchangeResponseLimit = 1 << 20
2626

27-
var daemonArtifactExchangeHTTPClient = &http.Client{
28-
CheckRedirect: func(
29-
*http.Request,
30-
[]*http.Request,
31-
) error {
32-
return http.ErrUseLastResponse
33-
},
27+
var daemonArtifactExchangeHTTPClient = newDaemonArtifactExchangeHTTPClient()
28+
29+
func newDaemonArtifactExchangeHTTPClient() *http.Client {
30+
transport := http.DefaultTransport.(*http.Transport).Clone()
31+
transport.Proxy = nil
32+
dialer := &net.Dialer{}
33+
transport.DialContext = func(
34+
ctx context.Context,
35+
network string,
36+
address string,
37+
) (net.Conn, error) {
38+
return dialLoopbackDaemon(ctx, dialer, network, address)
39+
}
40+
return &http.Client{
41+
Transport: transport,
42+
CheckRedirect: func(
43+
*http.Request,
44+
[]*http.Request,
45+
) error {
46+
return http.ErrUseLastResponse
47+
},
48+
}
49+
}
50+
51+
func dialLoopbackDaemon(
52+
ctx context.Context,
53+
dialer *net.Dialer,
54+
network string,
55+
address string,
56+
) (net.Conn, error) {
57+
host, port, err := net.SplitHostPort(address)
58+
if err != nil || !strings.EqualFold(host, "localhost") {
59+
return dialer.DialContext(ctx, network, address)
60+
}
61+
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
62+
if err != nil {
63+
return nil, err
64+
}
65+
if len(addresses) == 0 {
66+
return nil, errors.New("localhost did not resolve to a loopback address")
67+
}
68+
for _, candidate := range addresses {
69+
if !candidate.IP.IsLoopback() {
70+
return nil, errors.New("localhost resolved to a non-loopback address")
71+
}
72+
}
73+
var dialErr error
74+
for _, candidate := range addresses {
75+
connection, candidateErr := dialer.DialContext(
76+
ctx,
77+
network,
78+
net.JoinHostPort(candidate.String(), port),
79+
)
80+
if candidateErr == nil {
81+
return connection, nil
82+
}
83+
dialErr = errors.Join(dialErr, candidateErr)
84+
}
85+
return nil, dialErr
3486
}
3587

3688
func validateArtifactSyncConfig(cfg SyncConfig) error {
@@ -168,16 +220,10 @@ func validatedLoopbackDaemonURL(rawURL string) (string, error) {
168220
parsed.Fragment != "" {
169221
return "", errors.New("unsafe daemon endpoint")
170222
}
171-
ip := net.ParseIP(parsed.Hostname())
172-
if strings.EqualFold(parsed.Hostname(), "localhost") {
173-
host := "127.0.0.1"
174-
if parsed.Port() != "" {
175-
host = net.JoinHostPort(host, parsed.Port())
176-
}
177-
parsed.Host = host
178-
ip = net.ParseIP("127.0.0.1")
179-
}
180-
if ip == nil || !ip.IsLoopback() {
223+
hostname := parsed.Hostname()
224+
ip := net.ParseIP(hostname)
225+
if !strings.EqualFold(hostname, "localhost") &&
226+
(ip == nil || !ip.IsLoopback()) {
181227
return "", errors.New("daemon endpoint is not loopback")
182228
}
183229
return strings.TrimSuffix(parsed.String(), "/"), nil

cmd/agentsview/artifact_sync_test.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"fmt"
99
"io"
10+
"net"
1011
"net/http"
1112
"net/http/httptest"
1213
"net/url"
@@ -425,7 +426,41 @@ func TestRunDaemonArtifactExchangeAcceptsLocalhostEndpoint(t *testing.T) {
425426
)
426427

427428
require.NoError(t, err)
428-
assert.Equal(t, "127.0.0.1:"+endpoint.Port(), gotHost)
429+
assert.Equal(t, "localhost:"+endpoint.Port(), gotHost)
430+
}
431+
432+
func TestRunDaemonArtifactExchangeConnectsToIPv6LocalhostListener(t *testing.T) {
433+
listener, err := net.Listen("tcp6", "[::1]:0")
434+
if err != nil {
435+
t.Skipf("IPv6 loopback is unavailable: %v", err)
436+
}
437+
var gotHost string
438+
ts := httptest.NewUnstartedServer(http.HandlerFunc(func(
439+
w http.ResponseWriter,
440+
r *http.Request,
441+
) {
442+
gotHost = r.Host
443+
w.Header().Set("Content-Type", "application/json")
444+
_, writeErr := io.WriteString(w, `{}`)
445+
require.NoError(t, writeErr)
446+
}))
447+
ts.Listener = listener
448+
ts.Start()
449+
t.Cleanup(ts.Close)
450+
endpoint, err := url.Parse(ts.URL)
451+
require.NoError(t, err)
452+
endpoint.Host = "localhost:" + endpoint.Port()
453+
454+
_, err = runDaemonArtifactExchange(
455+
t.Context(),
456+
transport{Mode: transportHTTP, URL: endpoint.String()},
457+
"",
458+
t.TempDir(),
459+
false,
460+
)
461+
462+
require.NoError(t, err)
463+
assert.Equal(t, "localhost:"+endpoint.Port(), gotHost)
429464
}
430465

431466
func TestRunLocalAndArtifactFolderSyncStopsAfterLocalFailure(t *testing.T) {

0 commit comments

Comments
 (0)