Initial release

Signed-off-by: Marcus Noble <github@marcusnoble.co.uk>
This commit is contained in:
Marcus Noble 2023-07-29 21:48:11 +01:00
commit c0163bd9af
Signed by: AverageMarcus
GPG Key ID: B8F2DB8A7AEBAF78
10 changed files with 505 additions and 0 deletions

71
.github/workflows/docker.yaml vendored Normal file
View File

@ -0,0 +1,71 @@
name: Docker Image CI
on:
push:
branches: [ main ]
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Prepare
id: prepare
run: |
DOCKER_IMAGE=averagemarcus/tailscale-exporter
DOCKER_PLATFORMS=linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/386,linux/ppc64le,linux/s390x
VERSION=latest
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
fi
TAGS="--tag ${DOCKER_IMAGE}:${VERSION}"
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
TAGS="$TAGS --tag ${DOCKER_IMAGE}:latest"
fi
echo ::set-output name=tags::${TAGS}
echo ::set-output name=platforms::${DOCKER_PLATFORMS}
- name: Set up Docker Buildx
uses: crazy-max/ghaction-docker-buildx@v3
- name: Cache Docker layers
uses: actions/cache@v2
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Docker Buildx (build)
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform ${{ steps.prepare.outputs.platforms }} \
--output "type=image,push=false" \
${{ steps.prepare.outputs.tags }} \
.
- name: Docker Login
env:
DOCKER_USERNAME: averagemarcus
DOCKER_PASSWORD: ${{ secrets.DOCKER_TOKEN }}
run: |
echo "${DOCKER_PASSWORD}" | docker login --username "${DOCKER_USERNAME}" --password-stdin
- name: Docker Buildx (push)
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform ${{ steps.prepare.outputs.platforms }} \
--output "type=image,push=true" \
${{ steps.prepare.outputs.tags }} \
.

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.20-alpine as builder
ARG TARGETPLATFORM
ARG BUILDPLATFORM
ARG TARGETOS
ARG TARGETARCH
RUN apk update && apk add -U --no-cache ca-certificates
WORKDIR /app/
ADD go.mod go.sum ./
RUN go mod download
ADD main.go .
ADD pkg/ ./pkg
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o tailscale-exporter main.go
FROM --platform=${TARGETPLATFORM:-linux/amd64} scratch
WORKDIR /app/
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/tailscale-exporter /app/tailscale-exporter
ENTRYPOINT ["/app/tailscale-exporter"]

88
README.md Normal file
View File

@ -0,0 +1,88 @@
# tailscale-exporter
Exports Prometheus metrics for Tailscale devices and keys.
## Metrics
* `tailscale_devices_expiry_seconds_remaining` - The number of seconds remaining until the device authentication expires
* `tailscale_devices_expiry_time` - The timestamp (as Unix timestamp) that the device expires
* `tailscale_devices_update_available` - Whether the device can be updated to a newer version of Tailscale or not
* `tailscale_keys_expiry_seconds_remaining` - The number of seconds remaining until the key expires
* `tailscale_keys_expiry_time` - The timestamp (as Unix timestamp) that the key expires
## Configuration
The following environment variable can be used to configure the exporter:
* `TAILSCALE_API_KEY` - A valid Tailscale API key [Required]
* `TAILSCALE_TAILNET` - The Tailnet to export metrics for [Required]
* `PORT` - The port to run the exporter on (Defaults to `8080`)
## Running with Docker
```shell
export TAILSCALE_API_KEY="my-tailscale-api-key"
export TAILSCALE_TAILNET="my-tailnet.github"
docker run --rm -it -p 8080:8080 -e TAILSCALE_API_KEY -e TAILSCALE_TAILNET averagemarcus/tailscale-exporter:latest
```
Then visit: [http://localhost:8080/metrics](http://localhost:8080/metrics)
## Deploying to Kubernetes
```yaml
apiVersion: v1
kind: Secret
metadata:
name: tailscale-exporter
labels:
app.kubernetes.io/name: tailscale-exporter
stringData:
TAILSCALE_API_KEY: xxxx
TAILSCALE_TAILNET: xxxx
---
apiVersion: v1
kind: Service
metadata:
name: tailscale-exporter
labels:
app.kubernetes.io/name: tailscale-exporter
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: 8080
selector:
app: tailscale-exporter
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tailscale-exporter
labels:
app.kubernetes.io/name: tailscale-exporter
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: tailscale-exporter
template:
metadata:
labels:
app.kubernetes.io/name: tailscale-exporter
spec:
containers:
- name: tailscale-exporter
image: averagemarcus/tailscale-exporter:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
name: metrics
envFrom:
- secretRef:
name: tailscale-exporter
```

26
go.mod Normal file
View File

@ -0,0 +1,26 @@
module tailscale-exporter
go 1.20
replace github.com/tailscale/tailscale-client-go => github.com/AverageMarcus/tailscale-client-go v0.0.0-20230729201523-e4a8f131596d
require (
github.com/prometheus/client_golang v1.16.0
github.com/tailscale/tailscale-client-go v1.9.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.10.1 // indirect
github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/oauth2 v0.8.0 // indirect
golang.org/x/sys v0.8.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.30.0 // indirect
)

50
go.sum Normal file
View File

@ -0,0 +1,50 @@
github.com/AverageMarcus/tailscale-client-go v0.0.0-20230729201523-e4a8f131596d h1:D+4y8aN+WvqPW+5hbAB0rioM6rL3+A19aoXwKP3/6Wk=
github.com/AverageMarcus/tailscale-client-go v0.0.0-20230729201523-e4a8f131596d/go.mod h1:Sipqv1kTdwxy38walczihMQQm5uWIHeizasQ3Z3tHNU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg=
github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 h1:erxeiTyq+nw4Cz5+hLDkOwNF5/9IQWCQPv0gpb3+QHU=
github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

38
main.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"log"
"net/http"
"os"
"tailscale-exporter/pkg/metrics"
"tailscale-exporter/pkg/tailscale"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var addr string
func init() {
port := os.Getenv("PORT")
if port != "" {
addr = ":" + port
} else {
addr = ":8080"
}
}
func main() {
client, err := tailscale.New()
if err != nil {
log.Fatal(err)
}
reg := prometheus.NewRegistry()
metrics.Collect(client, reg)
http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{Registry: reg}))
log.Printf("tailscale-exporter")
log.Printf("Listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}

72
pkg/metrics/devices.go Normal file
View File

@ -0,0 +1,72 @@
package metrics
import (
"fmt"
"tailscale-exporter/pkg/tailscale"
"time"
"github.com/prometheus/client_golang/prometheus"
)
func collectDevices(client *tailscale.Client) []prometheus.Collector {
deviceExpiry := prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "tailscale_devices_expiry_time",
Help: "The expiry time of devices authentication",
ConstLabels: prometheus.Labels{
"tailnet": client.GetTailnet(),
},
},
[]string{"id", "created", "name"},
)
deviceSecondsRemaining := prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "tailscale_devices_expiry_seconds_remaining",
Help: "The number of seconds remaining until a device expires",
ConstLabels: prometheus.Labels{
"tailnet": client.GetTailnet(),
},
},
[]string{"id", "created", "name"},
)
deviceUpdateAvailable := prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "tailscale_devices_update_available",
Help: "If the device can be updated (1) or is running the latest version of Tailscale (0)",
ConstLabels: prometheus.Labels{
"tailnet": client.GetTailnet(),
},
},
[]string{"id", "created", "name", "version"},
)
go func() {
for {
devices, err := client.GetDevices()
if err != nil {
fmt.Println("Failed to get devices: ", err)
} else {
for _, device := range devices {
if !device.KeyExpiryDisabled {
remainingSeconds := time.Until(device.Expires.Time).Seconds()
deviceExpiry.With(prometheus.Labels{"id": device.ID, "created": device.Created.String(), "name": device.Name}).Set(float64(device.Expires.Unix()))
deviceSecondsRemaining.With(prometheus.Labels{"id": device.ID, "created": device.Created.String(), "name": device.Name}).Set(remainingSeconds)
}
updateAvailable := 0.0
if device.UpdateAvailable {
updateAvailable = 1.0
}
deviceUpdateAvailable.With(prometheus.Labels{"id": device.ID, "created": device.Created.String(), "name": device.Name, "version": device.ClientVersion}).Set(updateAvailable)
}
}
time.Sleep(60 * time.Second)
}
}()
return []prometheus.Collector{deviceExpiry, deviceSecondsRemaining, deviceUpdateAvailable}
}

56
pkg/metrics/keys.go Normal file
View File

@ -0,0 +1,56 @@
package metrics
import (
"fmt"
"tailscale-exporter/pkg/tailscale"
"time"
"github.com/prometheus/client_golang/prometheus"
)
func collectKeys(client *tailscale.Client) []prometheus.Collector {
keyExpiry := prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "tailscale_keys_expiry_time",
Help: "The expiry time of auth keys",
ConstLabels: prometheus.Labels{
"tailnet": client.GetTailnet(),
},
},
[]string{"id", "created", "description", "type"},
)
keySecondsRemaining := prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "tailscale_keys_expiry_seconds_remaining",
Help: "The number of seconds remaining until a key expires",
ConstLabels: prometheus.Labels{
"tailnet": client.GetTailnet(),
},
},
[]string{"id", "created", "description", "type"},
)
go func() {
for {
keys, err := client.GetKeys()
if err != nil {
fmt.Println("Failed to get keys: ", err)
} else {
for _, key := range keys {
remainingSeconds := time.Until(key.Expires).Seconds()
keyType := "auth_key"
if key.Capabilities == nil {
keyType = "api_access_token"
}
keyExpiry.With(prometheus.Labels{"id": key.ID, "created": key.Created.String(), "description": key.Description, "type": keyType}).Set(float64(key.Expires.Unix()))
keySecondsRemaining.With(prometheus.Labels{"id": key.ID, "created": key.Created.String(), "description": key.Description, "type": keyType}).Set(remainingSeconds)
}
}
time.Sleep(60 * time.Second)
}
}()
return []prometheus.Collector{keyExpiry, keySecondsRemaining}
}

19
pkg/metrics/metrics.go Normal file
View File

@ -0,0 +1,19 @@
package metrics
import (
"tailscale-exporter/pkg/tailscale"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
)
var defaultCollectors = []prometheus.Collector{
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
}
func Collect(client *tailscale.Client, reg *prometheus.Registry) {
reg.MustRegister(defaultCollectors...)
reg.MustRegister(collectKeys(client)...)
reg.MustRegister(collectDevices(client)...)
}

64
pkg/tailscale/client.go Normal file
View File

@ -0,0 +1,64 @@
package tailscale
import (
"context"
"fmt"
"os"
ts "github.com/tailscale/tailscale-client-go/tailscale"
)
type Client struct {
tsClient *ts.Client
tailnet string
ctx context.Context
}
func New() (*Client, error) {
apiKey := os.Getenv("TAILSCALE_API_KEY")
tailnet := os.Getenv("TAILSCALE_TAILNET")
if apiKey == "" {
return nil, fmt.Errorf("TAILSCALE_API_KEY must be set")
}
if tailnet == "" {
return nil, fmt.Errorf("TAILSCALE_TAILNET must be set")
}
client, err := ts.NewClient(apiKey, tailnet)
if err != nil {
return nil, err
}
return &Client{
tsClient: client,
tailnet: tailnet,
ctx: context.Background(),
}, nil
}
func (c *Client) GetTailnet() string {
return c.tailnet
}
func (c *Client) GetKeys() ([]ts.Key, error) {
allKeys := []ts.Key{}
keys, err := c.tsClient.Keys(c.ctx)
if err != nil {
return nil, err
} else {
for _, k := range keys {
key, err := c.tsClient.GetKey(c.ctx, k.ID)
if err != nil {
return nil, err
}
allKeys = append(allKeys, key)
}
}
return allKeys, nil
}
func (c *Client) GetDevices() ([]ts.Device, error) {
return c.tsClient.Devices(c.ctx)
}