Skip to content

Commit 59a61f6

Browse files
authored
fix: Expose all Tailscale tags as target labels. (#31)
Tags are converted to target labels keys with the following logic: - "tag:" prefix is removed - tag value is lower-cased - '-' and ':' characters are converted to '_' - if empty at this stage, tag value set to "EMPTY" (very unlikely to happen) - prepended with "__meta_tailscale_device_tag_" To illustrate, the tag "tag:foo-someThing:1234" will result in the label key "__meta_tailscale_device_tag_foo_something_1234". All target labels will always have the static value "1". This also changes the behavior of TailscaleSD so that each machine results in a single target descriptor. The previous behavior resulted in a target for each tag applied to a machine. Note that this approach leaves the possibility of similarly-named tags to collide after conversion. For example, both "tag:foo-bar:baz" and "tag:foo_bar_baz" will result in the label key "__meta_tailscale_device_tag_foo_bar_baz". Additionally, the handling of unicode characters is also poorly understood, and warrants more testing.
1 parent 9be2fef commit 59a61f6

File tree

2 files changed

+97
-43
lines changed

2 files changed

+97
-43
lines changed

tailscalesd.go

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"log/slog"
1515
"net"
1616
"net/http"
17+
"regexp"
18+
"strings"
1719
)
1820

1921
const (
@@ -51,6 +53,10 @@ const (
5153
// LabelMetaTailnet is the name of the Tailnet from which this target
5254
// information was retrieved. Not reported when using the local API.
5355
LabelMetaTailnet = "__meta_tailscale_tailnet"
56+
57+
// LabelMetaDeviceTagPrefix is the prefix for all labels representing device
58+
// tags. The tag name will be appended.
59+
LabelMetaDeviceTagPrefix = "__meta_tailscale_device_tag_"
5460
)
5561

5662
// Device in a Tailnet, as reported by one of the various Tailscale APIs.
@@ -129,10 +135,32 @@ func filterEmptyLabels(td TargetDescriptor) TargetDescriptor {
129135
}
130136
}
131137

138+
var tagReplaceRe = regexp.MustCompile(`[:-]`)
139+
140+
// tagToLabelKey translates a Tailscale tag to a Prometheus target label key.
141+
func tagToLabelKey(tag string) string {
142+
t := strings.TrimPrefix(tag, "tag:")
143+
t = strings.ToLower(t)
144+
t = tagReplaceRe.ReplaceAllString(t, "_")
145+
146+
if t == "" {
147+
t = "EMPTY"
148+
}
149+
150+
return LabelMetaDeviceTagPrefix + t
151+
}
152+
132153
// translate Devices to Prometheus TargetDescriptor, filtering empty labels.
133-
func translate(devices []Device, filters ...TargetFilter) (found []TargetDescriptor) {
134-
for _, d := range devices {
135-
target := TargetDescriptor{
154+
func translate(devices []Device, filters ...TargetFilter) []TargetDescriptor {
155+
n := len(devices)
156+
if n == 0 {
157+
return nil
158+
}
159+
160+
// Preallocate the output slice.
161+
found := make([]TargetDescriptor, n)
162+
for i, d := range devices {
163+
found[i] = TargetDescriptor{
136164
Targets: d.Addresses,
137165
// All labels added here, except for tags.
138166
Labels: map[string]string{
@@ -146,24 +174,19 @@ func translate(devices []Device, filters ...TargetFilter) (found []TargetDescrip
146174
LabelMetaTailnet: d.Tailnet,
147175
},
148176
}
149-
for _, filter := range filters {
150-
target = filter(target)
151-
}
152-
if l := len(d.Tags); l == 0 {
153-
found = append(found, target)
154-
continue
177+
178+
// Add tags as individual labels.
179+
for _, tag := range d.Tags {
180+
found[i].Labels[tagToLabelKey(tag)] = "1"
155181
}
156-
for _, t := range d.Tags {
157-
lt := target
158-
lt.Labels = make(map[string]string)
159-
for k, v := range target.Labels {
160-
lt.Labels[k] = v
161-
}
162-
lt.Labels[LabelMetaDeviceTag] = t
163-
found = append(found, lt)
182+
183+
// Apply filters.
184+
for _, filter := range filters {
185+
found[i] = filter(found[i])
164186
}
165187
}
166-
return
188+
189+
return found
167190
}
168191

169192
type discoveryHandler struct {

tailscalesd_test.go

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ package tailscalesd
33
import (
44
"context"
55
"errors"
6-
"io"
7-
"log"
6+
"log/slog"
87
"net/http"
98
"net/http/httptest"
109
"os"
@@ -15,7 +14,7 @@ import (
1514

1615
func TestMain(m *testing.M) {
1716
// No log output during test runs.
18-
log.SetOutput(io.Discard)
17+
slog.SetDefault(slog.New(slog.DiscardHandler))
1918
os.Exit(m.Run())
2019
}
2120

@@ -111,6 +110,29 @@ func TestFilterIPv6Addresses(t *testing.T) {
111110
}
112111
}
113112

113+
func TestTagToLabelKey(t *testing.T) {
114+
for _, tc := range []struct {
115+
tag string
116+
want string
117+
}{
118+
{"", "__meta_tailscale_device_tag_EMPTY"},
119+
{"tag:", "__meta_tailscale_device_tag_EMPTY"},
120+
{"tag:foo", "__meta_tailscale_device_tag_foo"},
121+
{"tag:foo-bar", "__meta_tailscale_device_tag_foo_bar"},
122+
{"tag:foo_bar", "__meta_tailscale_device_tag_foo_bar"},
123+
{"tag:ж", "__meta_tailscale_device_tag_ж"},
124+
{"tag:foo:1234", "__meta_tailscale_device_tag_foo_1234"},
125+
{"tag:camelCase", "__meta_tailscale_device_tag_camelcase"},
126+
} {
127+
t.Run(tc.tag, func(t *testing.T) {
128+
got := tagToLabelKey(tc.tag)
129+
if got != tc.want {
130+
t.Errorf("tagToLabelKey(%q): got: %v want: %v", tc.tag, got, tc.want)
131+
}
132+
})
133+
}
134+
}
135+
114136
func TestTranslate(t *testing.T) {
115137
for tn, tc := range map[string]struct {
116138
devices []Device
@@ -150,7 +172,7 @@ func TestTranslate(t *testing.T) {
150172
},
151173
},
152174
},
153-
"single device with two tags expands to two descriptors": {
175+
"single device with single tag expands to single descriptor": {
154176
devices: []Device{
155177
{
156178
Addresses: []string{
@@ -166,7 +188,6 @@ func TestTranslate(t *testing.T) {
166188
Tailnet: "[email protected]",
167189
Tags: []string{
168190
"tag:foo",
169-
"tag:bar",
170191
},
171192
},
172193
},
@@ -181,10 +202,33 @@ func TestTranslate(t *testing.T) {
181202
"__meta_tailscale_device_id": "id",
182203
"__meta_tailscale_device_name": "somethingclever",
183204
"__meta_tailscale_device_os": "beos",
184-
"__meta_tailscale_device_tag": "tag:foo",
205+
"__meta_tailscale_device_tag_foo": "1",
185206
"__meta_tailscale_tailnet": "[email protected]",
186207
},
187208
},
209+
},
210+
},
211+
"single device with two tags expands to single descriptor": {
212+
devices: []Device{
213+
{
214+
Addresses: []string{
215+
"100.2.3.4",
216+
"fd7a::1234",
217+
},
218+
API: "foo.example.com",
219+
ClientVersion: "420.69",
220+
Hostname: "somethingclever",
221+
ID: "id",
222+
Name: "somethingclever",
223+
OS: "beos",
224+
Tailnet: "[email protected]",
225+
Tags: []string{
226+
"tag:foo",
227+
"tag:bar",
228+
},
229+
},
230+
},
231+
want: []TargetDescriptor{
188232
{
189233
Targets: []string{"100.2.3.4", "fd7a::1234"},
190234
Labels: map[string]string{
@@ -195,7 +239,8 @@ func TestTranslate(t *testing.T) {
195239
"__meta_tailscale_device_id": "id",
196240
"__meta_tailscale_device_name": "somethingclever",
197241
"__meta_tailscale_device_os": "beos",
198-
"__meta_tailscale_device_tag": "tag:bar",
242+
"__meta_tailscale_device_tag_foo": "1",
243+
"__meta_tailscale_device_tag_bar": "1",
199244
"__meta_tailscale_tailnet": "[email protected]",
200245
},
201246
},
@@ -238,22 +283,8 @@ func TestTranslate(t *testing.T) {
238283
"__meta_tailscale_device_id": "id",
239284
"__meta_tailscale_device_name": "somethingclever",
240285
"__meta_tailscale_device_os": "beos",
241-
"__meta_tailscale_device_tag": "tag:foo",
242-
"__meta_tailscale_tailnet": "[email protected]",
243-
"test_label": "IT WORKED",
244-
},
245-
},
246-
{
247-
Targets: []string{"100.2.3.4", "fd7a::1234"},
248-
Labels: map[string]string{
249-
"__meta_tailscale_api": "foo.example.com",
250-
"__meta_tailscale_device_authorized": "false",
251-
"__meta_tailscale_device_client_version": "420.69",
252-
"__meta_tailscale_device_hostname": "somethingclever",
253-
"__meta_tailscale_device_id": "id",
254-
"__meta_tailscale_device_name": "somethingclever",
255-
"__meta_tailscale_device_os": "beos",
256-
"__meta_tailscale_device_tag": "tag:bar",
286+
"__meta_tailscale_device_tag_foo": "1",
287+
"__meta_tailscale_device_tag_bar": "1",
257288
"__meta_tailscale_tailnet": "[email protected]",
258289
"test_label": "IT WORKED",
259290
},
@@ -333,7 +364,7 @@ func TestDiscoveryHandler(t *testing.T) {
333364
want: httpWant{
334365
code: http.StatusOK,
335366
contentType: "application/json; charset=utf-8",
336-
body: `[{"targets":["100.2.3.4","fd7a::1234"],"labels":{"__meta_tailscale_api":"foo.example.com","__meta_tailscale_device_authorized":"false","__meta_tailscale_device_client_version":"420.69","__meta_tailscale_device_hostname":"somethingclever","__meta_tailscale_device_id":"id","__meta_tailscale_device_name":"somethingclever","__meta_tailscale_device_os":"beos","__meta_tailscale_device_tag":"tag:foo","__meta_tailscale_tailnet":"[email protected]"}},{"targets":["100.2.3.4","fd7a::1234"],"labels":{"__meta_tailscale_api":"foo.example.com","__meta_tailscale_device_authorized":"false","__meta_tailscale_device_client_version":"420.69","__meta_tailscale_device_hostname":"somethingclever","__meta_tailscale_device_id":"id","__meta_tailscale_device_name":"somethingclever","__meta_tailscale_device_os":"beos","__meta_tailscale_device_tag":"tag:bar","__meta_tailscale_tailnet":"[email protected]"}}]` + "\n",
367+
body: `[{"targets":["100.2.3.4","fd7a::1234"],"labels":{"__meta_tailscale_api":"foo.example.com","__meta_tailscale_device_authorized":"false","__meta_tailscale_device_client_version":"420.69","__meta_tailscale_device_hostname":"somethingclever","__meta_tailscale_device_id":"id","__meta_tailscale_device_name":"somethingclever","__meta_tailscale_device_os":"beos","__meta_tailscale_device_tag_bar":"1","__meta_tailscale_device_tag_foo":"1","__meta_tailscale_tailnet":"[email protected]"}}]` + "\n",
337368
},
338369
},
339370
"results with no errors are served": {
@@ -361,7 +392,7 @@ func TestDiscoveryHandler(t *testing.T) {
361392
want: httpWant{
362393
code: http.StatusOK,
363394
contentType: "application/json; charset=utf-8",
364-
body: `[{"targets":["100.2.3.4","fd7a::1234"],"labels":{"__meta_tailscale_api":"foo.example.com","__meta_tailscale_device_authorized":"false","__meta_tailscale_device_client_version":"420.69","__meta_tailscale_device_hostname":"somethingclever","__meta_tailscale_device_id":"id","__meta_tailscale_device_name":"somethingclever","__meta_tailscale_device_os":"beos","__meta_tailscale_device_tag":"tag:foo","__meta_tailscale_tailnet":"[email protected]"}},{"targets":["100.2.3.4","fd7a::1234"],"labels":{"__meta_tailscale_api":"foo.example.com","__meta_tailscale_device_authorized":"false","__meta_tailscale_device_client_version":"420.69","__meta_tailscale_device_hostname":"somethingclever","__meta_tailscale_device_id":"id","__meta_tailscale_device_name":"somethingclever","__meta_tailscale_device_os":"beos","__meta_tailscale_device_tag":"tag:bar","__meta_tailscale_tailnet":"[email protected]"}}]` + "\n",
395+
body: `[{"targets":["100.2.3.4","fd7a::1234"],"labels":{"__meta_tailscale_api":"foo.example.com","__meta_tailscale_device_authorized":"false","__meta_tailscale_device_client_version":"420.69","__meta_tailscale_device_hostname":"somethingclever","__meta_tailscale_device_id":"id","__meta_tailscale_device_name":"somethingclever","__meta_tailscale_device_os":"beos","__meta_tailscale_device_tag_bar":"1","__meta_tailscale_device_tag_foo":"1","__meta_tailscale_tailnet":"[email protected]"}}]` + "\n",
365396
},
366397
},
367398
} {

0 commit comments

Comments
 (0)