├── .devcontainer └── devcontainer.json ├── .github ├── dependabot.yml └── workflows │ └── post-go-dependabot.yml ├── README.md ├── go.mod ├── go.sum └── main.go /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "ghcr.io/hellodword/devcontainers-go" 3 | } 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/post-go-dependabot.yml: -------------------------------------------------------------------------------- 1 | name: post-go-dependabot 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: 7 | - closed 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | on_merged: 15 | if: > 16 | (github.event.pull_request.merged == true && 17 | github.repository_owner == 'hellodword' && 18 | startsWith(github.event.pull_request.head.ref, 'dependabot/')) || 19 | (github.event_name == 'workflow_dispatch' && 20 | github.repository_owner == 'hellodword') 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | ref: "master" 26 | 27 | - name: Set up Go 28 | uses: actions/setup-go@v4 29 | 30 | - run: go mod tidy 31 | - run: go build -x -v -trimpath -ldflags "-s -w" -buildvcs=false -o tailscale-derp-client-verifier . 32 | 33 | - name: config git 34 | if: github.event.pull_request.merged == true 35 | run: | 36 | git config --global user.name github-actions[bot] 37 | git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com 38 | 39 | - name: create pr 40 | if: github.event.pull_request.merged == true 41 | run: | 42 | git add go.mod 43 | git add go.sum 44 | if git commit -m "chore: [bot] go mod tidy"; then 45 | git checkout -b "post/${{ github.event.pull_request.head.ref }}" 46 | git push -f origin "post/${{ github.event.pull_request.head.ref }}" 47 | gh pr create --fill 48 | fi 49 | env: 50 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tailscale-derp-client-verifier 2 | 3 | ## Why 4 | 5 | Because I want to run a Tailscale DERP server without having to trust it. 6 | 7 | 1. `Tailscale` is `end-to-end encrypted` by design[^1] 8 | 9 | 2. An insecure DERP server or connection is still secure for DERP users; there are no MITM issues[^2] 10 | 11 | 3. `--verify-clients` requires adding this DERP server as one of your Tailscale nodes. In my opinion, this is risky, even with ACLs. 12 | 13 | 4. `--verify-client-url` allows derper to check an external URL to permit access[^3] 14 | 15 | ## Build 16 | 17 | ```sh 18 | # with latest Go 19 | go build -x -v -trimpath -ldflags "-s -w" -buildvcs=false -o tailscale-derp-client-verifier . 20 | ``` 21 | 22 | ## Usage 23 | 24 | 1. **On the trusted Tailscale node machine**: get the nodes list from your trusted homelab server or laptop, and sync the `nodes.json` to the DERP server machine: 25 | 26 | You can achieve this in many ways. I do this with rclone, systemd timer, and S3 object storage:: 27 | 28 | ```sh 29 | #! /usr/bin/env bash 30 | set -e 31 | set -x 32 | 33 | [ -f "$RCLONE_CONFIG_FILE" ] 34 | [ -d "$WORK_DIR" ] 35 | [ -n "$TAILSCALE_S3_REMOTE_NAME" ] 36 | [ -n "$TAILSCALE_S3_BUCKET" ] 37 | 38 | cd $WORK_DIR 39 | 40 | tailscale status --json | jq '[recurse | objects | with_entries(select(.key == "PublicKey")) | .[]] | sort' > "temp.nodes.json" 41 | 42 | jq -e . "temp.nodes.json" 43 | 44 | if [ "$(cat "nodes.json" || true)" != "$(cat "temp.nodes.json")" ]; then 45 | rclone --config "$RCLONE_CONFIG_FILE" --contimeout=3m --timeout=10m --checksum copyto \ 46 | "temp.nodes.json" "$TAILSCALE_S3_REMOTE_NAME:$TAILSCALE_S3_BUCKET/nodes.json" 47 | mv "temp.nodes.json" "nodes.json" 48 | fi 49 | ``` 50 | 51 | 2. **On the DERP server machine**: run `tailscale-derp-client-verifier` 52 | 53 | If you're not syncing the `nodes.json` with S3 object storage, use the `-path` argument: 54 | 55 | ```sh 56 | /path/to/tailscale-derp-client-verifier -path /path/to/nodes.json 57 | ``` 58 | 59 | I use S3, so I: 60 | 61 | ```sh 62 | . .env 63 | 64 | /path/to/tailscale-derp-client-verifier 65 | ``` 66 | 67 | ```ini 68 | # .env file 69 | S3_ACCESS_KEY_ID= 70 | S3_SECRET_ACCESS_KEY= 71 | S3_ENDPOINT= 72 | S3_REGION= 73 | S3_BUCKET= 74 | S3_FILE=nodes.json 75 | S3_FORCE_PATH_STYLE= 76 | ``` 77 | 78 | 3. **On the DERP server machine**: deploy the derper 79 | 80 | ```sh 81 | derper <... other args> --verify-clients=false --verify-client-url-fail-open=false --verify-client-url=http://127.0.0.1:3000 82 | ``` 83 | 84 | [^1]: https://tailscale.com/security 85 | [^2]: https://github.com/tailscale/tailscale/issues/12107#issuecomment-2106233579 86 | [^3]: https://github.com/tailscale/tailscale/pull/11193 87 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hellodword/tailscale-derp-client-verifier 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/aws/aws-sdk-go v1.55.7 9 | tailscale.com v1.84.1 10 | ) 11 | 12 | require ( 13 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect 14 | github.com/jmespath/go-jmespath v0.4.0 // indirect 15 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 16 | golang.org/x/crypto v0.37.0 // indirect 17 | golang.org/x/net v0.38.0 // indirect 18 | golang.org/x/sys v0.32.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= 2 | github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 5 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 7 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 8 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= 9 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 10 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 11 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 13 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 14 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 15 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 16 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 17 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 18 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 19 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 22 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 24 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 25 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 26 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= 27 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 28 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 29 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 30 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= 31 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= 32 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 33 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 34 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 35 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 36 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 37 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 38 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 39 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 40 | tailscale.com v1.84.1 h1:xtuiYeAIUR+dRztPzzqUsjj+Fv/06vz28zoFaP1k/Os= 41 | tailscale.com v1.84.1/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= 42 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "flag" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "sync" 13 | "sync/atomic" 14 | "time" 15 | 16 | "github.com/aws/aws-sdk-go/aws" 17 | "github.com/aws/aws-sdk-go/aws/credentials" 18 | "github.com/aws/aws-sdk-go/aws/session" 19 | "github.com/aws/aws-sdk-go/service/s3" 20 | "tailscale.com/tailcfg" 21 | "tailscale.com/types/key" 22 | ) 23 | 24 | const ( 25 | defaultAddr = "localhost:3000" 26 | ) 27 | 28 | type Fetcher func() ([]key.NodePublic, error) 29 | type Searcher func(n key.NodePublic) bool 30 | 31 | func main() { 32 | addr := flag.String("addr", defaultAddr, "") 33 | nodesFile := flag.String("path", "", "/path/to/nodes.json") 34 | flag.Parse() 35 | 36 | var interval time.Duration 37 | var fetcher Fetcher 38 | var searcher Searcher 39 | 40 | if *nodesFile == "" { 41 | interval = time.Minute * 10 42 | fetcher = setupS3Fetcher() 43 | _, err := fetcher() 44 | if err != nil { 45 | panic(err) 46 | } 47 | } else { 48 | interval = time.Minute 49 | fetcher = func() ([]key.NodePublic, error) { 50 | var nodes []key.NodePublic 51 | err := readJSONFile(*nodesFile, &nodes) 52 | return nodes, err 53 | } 54 | } 55 | 56 | var lock sync.RWMutex 57 | var nodes []key.NodePublic 58 | var lastUpdate uint32 59 | 60 | searcher = func(n key.NodePublic) bool { 61 | now := uint32(time.Now().Unix()) 62 | 63 | if now > atomic.LoadUint32(&lastUpdate)+uint32(interval.Seconds()) { 64 | atomic.StoreUint32(&lastUpdate, now) 65 | log.Println("fetcher", "fetching") 66 | _nodes, err := fetcher() 67 | if err != nil { 68 | log.Println("fetcher", err) 69 | } else { 70 | lock.Lock() 71 | nodes = _nodes 72 | lock.Unlock() 73 | log.Println("fetcher", "updated") 74 | } 75 | } 76 | 77 | lock.RLock() 78 | defer lock.RUnlock() 79 | for i := range nodes { 80 | if n.Compare(nodes[i]) == 0 { 81 | return true 82 | } 83 | } 84 | return false 85 | } 86 | 87 | http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 88 | if r.Method != http.MethodPost { 89 | w.WriteHeader(http.StatusMethodNotAllowed) 90 | return 91 | } 92 | 93 | var buf = bytes.NewBuffer(nil) 94 | var req tailcfg.DERPAdmitClientRequest 95 | err := json.NewDecoder(io.TeeReader(io.LimitReader(r.Body, 1<<13), buf)).Decode(&req) 96 | if err != nil { 97 | w.WriteHeader(http.StatusBadRequest) 98 | return 99 | } 100 | 101 | var resp tailcfg.DERPAdmitClientResponse 102 | 103 | resp.Allow = searcher(req.NodePublic) 104 | 105 | if resp.Allow { 106 | log.Println("allowed", req.NodePublic, req.Source) 107 | } 108 | 109 | b, err := json.Marshal(resp) 110 | if err != nil { 111 | w.WriteHeader(http.StatusInternalServerError) 112 | return 113 | } 114 | 115 | w.WriteHeader(http.StatusOK) 116 | w.Write(b) 117 | })) 118 | 119 | log.Println("serving", *addr) 120 | err := http.ListenAndServe(*addr, nil) 121 | if err != nil { 122 | os.Exit(1) 123 | } 124 | } 125 | 126 | func setupS3Fetcher() Fetcher { 127 | s3AccessKey := os.Getenv("S3_ACCESS_KEY_ID") 128 | s3SecretKey := os.Getenv("S3_SECRET_ACCESS_KEY") 129 | s3Endpoint := os.Getenv("S3_ENDPOINT") 130 | s3Region := os.Getenv("S3_REGION") 131 | s3Bucket := os.Getenv("S3_BUCKET") 132 | s3File := os.Getenv("S3_FILE") 133 | s3ForcePathStyle := strings.ToLower(os.Getenv("S3_FORCE_PATH_STYLE")) == "true" 134 | s3Object := &s3.GetObjectInput{ 135 | Bucket: aws.String(s3Bucket), 136 | Key: aws.String(s3File), 137 | } 138 | 139 | cfg := &aws.Config{ 140 | Endpoint: aws.String(s3Endpoint), 141 | Region: aws.String(s3Region), 142 | S3ForcePathStyle: aws.Bool(s3ForcePathStyle), 143 | Credentials: credentials.NewStaticCredentials(s3AccessKey, s3SecretKey, ""), 144 | } 145 | sess := session.Must(session.NewSession(cfg)) 146 | 147 | s3Instance := s3.New(sess) 148 | 149 | return func() ([]key.NodePublic, error) { 150 | var nodes []key.NodePublic 151 | err := readJSONS3(s3Instance, s3Object, &nodes) 152 | return nodes, err 153 | } 154 | } 155 | 156 | func readJSONS3(instance *s3.S3, object *s3.GetObjectInput, v interface{}) error { 157 | r, err := instance.GetObject(object) 158 | if err != nil { 159 | return err 160 | } 161 | defer r.Body.Close() 162 | return json.NewDecoder(r.Body).Decode(v) 163 | } 164 | 165 | func readJSONFile(path string, v interface{}) error { 166 | f, err := os.Open(path) 167 | if err != nil { 168 | return err 169 | } 170 | defer f.Close() 171 | return json.NewDecoder(f).Decode(v) 172 | } 173 | --------------------------------------------------------------------------------