Skip to content

Commit e5ce382

Browse files
authored
feat: add IsCoderConnectRunning to workspacesdk (#17361)
Adds `IsCoderConnectRunning()` to the workspacesdk. This will support the `coder` CLI being able to use CoderConnect when it's running. part of #16828
1 parent 39b9d23 commit e5ce382

File tree

2 files changed

+137
-1
lines changed

2 files changed

+137
-1
lines changed

codersdk/workspacesdk/workspacesdk.go

Lines changed: 51 additions & 1 deletion
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+
}
Lines changed: 86 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy