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
48 changes: 47 additions & 1 deletion devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ func (t *Time) UnmarshalJSON(data []byte) error {
return nil
}

type DERPRegion struct {
Preferred bool `json:"preferred,omitempty"`
LatencyMilliseconds float64 `json:"latencyMs"`
}

type ClientSupports struct {
HairPinning bool `json:"hairPinning"`
IPV6 bool `json:"ipv6"`
PCP bool `json:"pcp"`
PMP bool `json:"pmp"`
UDP bool `json:"udp"`
UPNP bool `json:"upnp"`
}

type ClientConnectivity struct {
Endpoints []string `json:"endpoints"`
DERP string `json:"derp"`
MappingVariesByDestIP bool `json:"mappingVariesByDestIP"`
// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
DERPLatency map[string]DERPRegion `json:"latency"`
ClientSupports ClientSupports `json:"clientSupports"`
}

type Device struct {
Addresses []string `json:"addresses"`
Name string `json:"name"`
Expand All @@ -66,6 +89,11 @@ type Device struct {
TailnetLockError string `json:"tailnetLockError"`
TailnetLockKey string `json:"tailnetLockKey"`
UpdateAvailable bool `json:"updateAvailable"`

// The below are only included in listings when querying `all` fields.
AdvertisedRoutes []string `json:"AdvertisedRoutes"`
EnabledRoutes []string `json:"enabledRoutes"`
ClientConnectivity *ClientConnectivity `json:"clientConnectivity"`
}

type DevicePostureAttributes struct {
Expand Down Expand Up @@ -115,13 +143,31 @@ func (dr *DevicesResource) SetPostureAttribute(ctx context.Context, deviceID, at
return dr.do(req, nil)
}

// List lists every [Device] in the tailnet.
// ListWithAllFields lists every [Device] in the tailnet. Each [Device] in
// the response will have all fields populated.
func (dr *DevicesResource) ListWithAllFields(ctx context.Context) ([]Device, error) {
return dr.list(ctx, true)
}

// List lists every [Device] in the tailnet. The fields `EnabledRoutes`,
// `AdvertisedRoutes` and `ClientConnectivity` will be omitted from the resulting
// [Devices]. To get these fields, use `ListWithAllFields`.
func (dr *DevicesResource) List(ctx context.Context) ([]Device, error) {
return dr.list(ctx, false)
}

func (dr *DevicesResource) list(ctx context.Context, all bool) ([]Device, error) {
req, err := dr.buildRequest(ctx, http.MethodGet, dr.buildTailnetURL("devices"))
if err != nil {
return nil, err
}

if all {
q := req.URL.Query()
q.Set("fields", "all")
req.URL.RawQuery = q.Encode()
}

m := make(map[string][]Device)
err = dr.do(req, &m)
if err != nil {
Expand Down
51 changes: 50 additions & 1 deletion devices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,30 @@ func TestClient_Devices_Get(t *testing.T) {
TailnetLockError: "test error",
TailnetLockKey: "tlpub:test",
UpdateAvailable: true,
AdvertisedRoutes: []string{"127.0.0.1", "127.0.0.2"},
EnabledRoutes: []string{"127.0.0.1"},
ClientConnectivity: &ClientConnectivity{
Endpoints: []string{"199.9.14.201:59128", "192.68.0.21:59128"},
DERP: "New York City",
DERPLatency: map[string]DERPRegion{
"Dallas": {
LatencyMilliseconds: 60.463043,
},
"New York City": {
Preferred: true,
LatencyMilliseconds: 31.323811,
},
},
MappingVariesByDestIP: true,
ClientSupports: ClientSupports{
HairPinning: false,
IPV6: false,
PCP: false,
PMP: false,
UDP: false,
UPNP: false,
},
},
}

client, server := NewTestHarness(t)
Expand Down Expand Up @@ -142,6 +166,30 @@ func TestClient_Devices_List(t *testing.T) {
NodeKey: "nodekey:test",
OS: "windows",
UpdateAvailable: true,
AdvertisedRoutes: []string{"127.0.0.1", "127.0.0.2"},
EnabledRoutes: []string{"127.0.0.1"},
ClientConnectivity: &ClientConnectivity{
Endpoints: []string{"199.9.14.201:59128", "192.68.0.21:59128"},
DERP: "New York City",
DERPLatency: map[string]DERPRegion{
"Dallas": {
LatencyMilliseconds: 60.463043,
},
"New York City": {
Preferred: true,
LatencyMilliseconds: 31.323811,
},
},
MappingVariesByDestIP: true,
ClientSupports: ClientSupports{
HairPinning: false,
IPV6: false,
PCP: false,
PMP: false,
UDP: false,
UPNP: false,
},
},
},
},
}
Expand All @@ -150,10 +198,11 @@ func TestClient_Devices_List(t *testing.T) {
server.ResponseCode = http.StatusOK
server.ResponseBody = expectedDevices

actualDevices, err := client.Devices().List(context.Background())
actualDevices, err := client.Devices().ListWithAllFields(context.Background())
assert.NoError(t, err)
assert.Equal(t, http.MethodGet, server.Method)
assert.Equal(t, "/api/v2/tailnet/example.com/devices", server.Path)
assert.Equal(t, "all", server.Query.Get("fields"))
assert.EqualValues(t, expectedDevices["devices"], actualDevices)
}

Expand Down
26 changes: 26 additions & 0 deletions policyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,32 @@ func (pr *PolicyFileResource) Set(ctx context.Context, acl any, etag string) err
return pr.do(req, nil)
}

// Set sets the [ACL] for the tailnet and returns the resulting [ACL].
// etag is an optional value that, if supplied, will be used in the "If-Match" HTTP request header.
func (pr *PolicyFileResource) SetAndGet(ctx context.Context, acl ACL, etag string) (*ACL, error) {
headers := make(map[string]string)
if etag != "" {
headers["If-Match"] = fmt.Sprintf("%q", etag)
}

reqOpts := []requestOption{
requestHeaders(headers),
requestBody(acl),
}

req, err := pr.buildRequest(ctx, http.MethodPost, pr.buildTailnetURL("acl"), reqOpts...)
if err != nil {
return nil, err
}

out, header, err := bodyWithResponseHeader[ACL](pr, req)
if err != nil {
return nil, err
}
out.ETag = header.Get("Etag")
return out, nil
}

// Validate validates the provided ACL via the API. acl can either be an [ACL], or a HuJSON string.
func (pr *PolicyFileResource) Validate(ctx context.Context, acl any) error {
reqOpts := []requestOption{
Expand Down
56 changes: 56 additions & 0 deletions policyfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,62 @@ func TestClient_SetACL(t *testing.T) {
assert.EqualValues(t, expectedACL, actualACL)
}

func TestClient_SetAndGetACL(t *testing.T) {
t.Parallel()

client, server := NewTestHarness(t)
server.ResponseCode = http.StatusOK
server.ResponseHeader.Set("ETag", "abcdefg")
in := ACL{
ACLs: []ACLEntry{
{
Action: "accept",
Ports: []string{"*:*"},
Users: []string{"*"},
},
},
TagOwners: map[string][]string{
"tag:example": {"group:example"},
},
Hosts: map[string]string{
"example-host-1": "100.100.100.100",
"example-host-2": "100.100.101.100/24",
},
Groups: map[string][]string{
"group:example": {
"[email protected]",
"[email protected]",
},
},
Tests: []ACLTest{
{
User: "[email protected]",
Allow: []string{"example-host-1:22", "example-host-2:80"},
Deny: []string{"exapmle-host-2:100"},
},
{
User: "[email protected]",
Allow: []string{"100.60.3.4:22"},
},
},
ETag: "abcdefg",
}
server.ResponseBody = in

out, err := client.PolicyFile().SetAndGet(context.Background(), in, "abcdefg")
assert.NoError(t, err)
assert.Equal(t, http.MethodPost, server.Method)
assert.Equal(t, "/api/v2/tailnet/example.com/acl", server.Path)
assert.Equal(t, `"abcdefg"`, server.Header.Get("If-Match"))
assert.EqualValues(t, "application/json", server.Header.Get("Content-Type"))
assert.EqualValues(t, &in, out)

var actualACL ACL
assert.NoError(t, json.Unmarshal(server.Body.Bytes(), &actualACL))
in.ETag = ""
assert.EqualValues(t, in, actualACL)
}

func TestClient_SetACL_HuJSON(t *testing.T) {
t.Parallel()

Expand Down
Loading