Skip to content

Commit 74fc20a

Browse files
committed
feat: add IsCoderConnectRunning to workspacesdk
1 parent 9e2af3e commit 74fc20a

File tree

2 files changed

+137
-1
lines changed

2 files changed

+137
-1
lines changed

codersdk/workspacesdk/workspacesdk.go

+51-1
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,19 @@ func init() {
128128
}
129129
}
130130

131+
type resolver interface {
132+
LookupIP(ctx context.Context, network, host string) ([]net.IP, error)
133+
}
134+
131135
type Client struct {
132136
client *codersdk.Client
137+
138+
// overridden in tests
139+
resolver resolver
133140
}
134141

135142
func New(c *codersdk.Client) *Client {
136-
return &Client{client: c}
143+
return &Client{client: c, resolver: net.DefaultResolver}
137144
}
138145

139146
// AgentConnectionInfo returns required information for establishing
@@ -384,3 +391,46 @@ func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentRe
384391
}
385392
return websocket.NetConn(context.Background(), conn, websocket.MessageBinary), nil
386393
}
394+
395+
type CoderConnectQueryOptions struct {
396+
HostnameSuffix string
397+
}
398+
399+
// IsCoderConnectRunning checks if Coder Connect (OS level tunnel to workspaces) is running on the system. If you
400+
// already know the hostname suffix your deployment uses, you can pass it in the CoderConnectQueryOptions to avoid an
401+
// API call to AgentConnectionInfoGeneric.
402+
func (c *Client) IsCoderConnectRunning(ctx context.Context, o CoderConnectQueryOptions) (bool, error) {
403+
suffix := o.HostnameSuffix
404+
if suffix == "" {
405+
info, err := c.AgentConnectionInfoGeneric(ctx)
406+
if err != nil {
407+
return false, xerrors.Errorf("get agent connection info: %w", err)
408+
}
409+
suffix = info.HostnameSuffix
410+
}
411+
domainName := fmt.Sprintf(tailnet.IsCoderConnectEnabledFmtString, suffix)
412+
var dnsError *net.DNSError
413+
ips, err := c.resolver.LookupIP(ctx, "ip6", domainName)
414+
if xerrors.As(err, &dnsError) {
415+
if dnsError.IsNotFound {
416+
return false, nil
417+
}
418+
}
419+
if err != nil {
420+
return false, xerrors.Errorf("lookup DNS %s: %w", domainName, err)
421+
}
422+
423+
// The returned IP addresses are probably from the Coder Connect DNS server, but there are sometimes weird captive
424+
// internet setups where the DNS server is configured to return an address for any IP query. So, to avoid false
425+
// positives, check if we can find an address from our service prefix.
426+
for _, ip := range ips {
427+
addr, ok := netip.AddrFromSlice(ip)
428+
if !ok {
429+
continue
430+
}
431+
if tailnet.CoderServicePrefix.AsNetip().Contains(addr) {
432+
return true, nil
433+
}
434+
}
435+
return false, nil
436+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package workspacesdk
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net"
7+
"net/http"
8+
"net/http/httptest"
9+
"net/url"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
"golang.org/x/xerrors"
15+
16+
"github.com/coder/coder/v2/coderd/httpapi"
17+
"github.com/coder/coder/v2/codersdk"
18+
"github.com/coder/coder/v2/testutil"
19+
20+
"tailscale.com/net/tsaddr"
21+
22+
"github.com/coder/coder/v2/tailnet"
23+
)
24+
25+
func TestClient_IsCoderConnectRunning(t *testing.T) {
26+
t.Parallel()
27+
ctx := testutil.Context(t, testutil.WaitShort)
28+
29+
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
30+
assert.Equal(t, "/api/v2/workspaceagents/connection", r.URL.Path)
31+
httpapi.Write(ctx, rw, http.StatusOK, AgentConnectionInfo{
32+
HostnameSuffix: "test",
33+
})
34+
}))
35+
defer srv.Close()
36+
37+
apiURL, err := url.Parse(srv.URL)
38+
require.NoError(t, err)
39+
sdkClient := codersdk.New(apiURL)
40+
client := New(sdkClient)
41+
42+
// Right name, right IP
43+
expectedName := fmt.Sprintf(tailnet.IsCoderConnectEnabledFmtString, "test")
44+
client.resolver = &fakeResolver{t: t, hostMap: map[string][]net.IP{
45+
expectedName: {net.ParseIP(tsaddr.CoderServiceIPv6().String())},
46+
}}
47+
48+
result, err := client.IsCoderConnectRunning(ctx, CoderConnectQueryOptions{})
49+
require.NoError(t, err)
50+
require.True(t, result)
51+
52+
// Wrong name
53+
result, err = client.IsCoderConnectRunning(ctx, CoderConnectQueryOptions{HostnameSuffix: "coder"})
54+
require.NoError(t, err)
55+
require.False(t, result)
56+
57+
// Not found
58+
client.resolver = &fakeResolver{t: t, err: &net.DNSError{IsNotFound: true}}
59+
result, err = client.IsCoderConnectRunning(ctx, CoderConnectQueryOptions{})
60+
require.NoError(t, err)
61+
require.False(t, result)
62+
63+
// Some other error
64+
client.resolver = &fakeResolver{t: t, err: xerrors.New("a bad thing happened")}
65+
_, err = client.IsCoderConnectRunning(ctx, CoderConnectQueryOptions{})
66+
require.Error(t, err)
67+
68+
// Right name, wrong IP
69+
client.resolver = &fakeResolver{t: t, hostMap: map[string][]net.IP{
70+
expectedName: {net.ParseIP("2001::34")},
71+
}}
72+
result, err = client.IsCoderConnectRunning(ctx, CoderConnectQueryOptions{})
73+
require.NoError(t, err)
74+
require.False(t, result)
75+
}
76+
77+
type fakeResolver struct {
78+
t testing.TB
79+
hostMap map[string][]net.IP
80+
err error
81+
}
82+
83+
func (f *fakeResolver) LookupIP(_ context.Context, network, host string) ([]net.IP, error) {
84+
assert.Equal(f.t, "ip6", network)
85+
return f.hostMap[host], f.err
86+
}

0 commit comments

Comments
 (0)