├── .dockerignore ├── .envrc ├── .github └── workflows │ ├── docker-build.yml │ └── nix-github-actions.yml ├── .gitignore ├── .gitlab-ci.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── app └── Main.hs ├── flake.lock ├── flake.nix ├── nix ├── nixos-module.nix └── vm-test.nix ├── src ├── TailscaleManager.hs └── TailscaleManager │ ├── Config.hs │ ├── Discovery │ ├── AWSManagedPrefixList.hs │ └── DNS.hs │ └── Logging.hs ├── tailscale-manager.cabal └── test └── Main.hs /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | tmp/ 5 | 6 | .git 7 | .gitignore 8 | 9 | .direnv/ 10 | 11 | dist-newstyle/ 12 | result 13 | result-* 14 | out/ 15 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake; 2 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | # Based on https://docs.docker.com/build/ci/github-actions/multi-platform/ 2 | name: Docker 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | push: 9 | branches: 10 | - main 11 | tags: 12 | - "v*" 13 | 14 | env: 15 | REGISTRY: ghcr.io 16 | ORGANIZATION: singlestore-labs 17 | 18 | permissions: 19 | contents: read 20 | packages: write 21 | attestations: write 22 | id-token: write 23 | 24 | jobs: 25 | build: 26 | runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | platform: 31 | - linux/amd64 32 | - linux/arm64 33 | 34 | steps: 35 | - name: Prepare 36 | run: | 37 | platform=${{ matrix.platform }} 38 | # Store image name in lowercase and platform pair for Docker push 39 | echo "IMAGE_NAME=${GITHUB_REPOSITORY@L}" >> $GITHUB_ENV 40 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 41 | 42 | - name: Docker meta 43 | id: meta 44 | uses: docker/metadata-action@v5 45 | with: 46 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 47 | tags: | 48 | type=raw,value=latest,enable={{is_default_branch}} 49 | type=ref,event=branch 50 | type=ref,event=tag 51 | type=ref,event=pr 52 | type=semver,pattern={{version}} 53 | type=semver,pattern={{major}}.{{minor}} 54 | type=sha,format=long 55 | 56 | - name: Set up Docker Buildx 57 | uses: docker/setup-buildx-action@v3 58 | 59 | - name: Log in to the Container registry 60 | uses: docker/login-action@v3 61 | with: 62 | registry: ${{ env.REGISTRY }} 63 | username: ${{ github.actor }} 64 | password: ${{ secrets.GITHUB_TOKEN }} 65 | 66 | - name: Build and push by digest 67 | id: build 68 | uses: docker/build-push-action@v6 69 | with: 70 | cache-from: type=gha,scope=${{ matrix.platform }} 71 | cache-to: type=gha,scope=${{ matrix.platform }},mode=max 72 | labels: ${{ steps.meta.outputs.labels }} 73 | outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.ref == 'refs/heads/main' || github.repository_owner != env.ORGANIZATION }} 74 | 75 | - name: Export digest 76 | if: github.ref == 'refs/heads/main' || github.repository_owner != env.ORGANIZATION 77 | run: | 78 | mkdir -p /tmp/digests 79 | digest="${{ steps.build.outputs.digest }}" 80 | touch "/tmp/digests/${digest#sha256:}" 81 | 82 | - name: Upload digest 83 | if: github.ref == 'refs/heads/main' || github.repository_owner != env.ORGANIZATION 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: digests-${{ env.PLATFORM_PAIR }} 87 | path: /tmp/digests/* 88 | if-no-files-found: error 89 | retention-days: 1 90 | 91 | merge: 92 | runs-on: ubuntu-latest 93 | if: github.ref == 'refs/heads/main' || github.repository_owner != 'singlestore-labs' 94 | needs: 95 | - build 96 | steps: 97 | - name: Prepare 98 | run: | 99 | echo "IMAGE_NAME=${GITHUB_REPOSITORY@L}" >> $GITHUB_ENV 100 | 101 | - name: Download digests 102 | uses: actions/download-artifact@v4 103 | with: 104 | path: /tmp/digests 105 | pattern: digests-* 106 | merge-multiple: true 107 | 108 | - name: Set up Docker Buildx 109 | uses: docker/setup-buildx-action@v3 110 | 111 | - name: Docker meta 112 | id: meta 113 | uses: docker/metadata-action@v5 114 | with: 115 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 116 | tags: | 117 | type=raw,value=latest,enable={{is_default_branch}} 118 | type=ref,event=branch 119 | type=ref,event=tag 120 | type=semver,pattern={{version}} 121 | type=semver,pattern={{major}}.{{minor}} 122 | type=sha,format=long 123 | 124 | - name: Log in to the Container registry 125 | uses: docker/login-action@v3 126 | with: 127 | registry: ${{ env.REGISTRY }} 128 | username: ${{ github.actor }} 129 | password: ${{ secrets.GITHUB_TOKEN }} 130 | 131 | - name: Create manifest list and push 132 | working-directory: /tmp/digests 133 | run: | 134 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 135 | $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) 136 | 137 | - name: Inspect image 138 | run: | 139 | docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} 140 | -------------------------------------------------------------------------------- /.github/workflows/nix-github-actions.yml: -------------------------------------------------------------------------------- 1 | name: Nix Flake actions 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | nix-matrix: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | matrix: ${{ steps.set-matrix.outputs.matrix }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: cachix/install-nix-action@v27 17 | - id: set-matrix 18 | name: Generate Nix Matrix 19 | run: | 20 | set -Eeu 21 | matrix="$(nix eval --json '.#githubActions.matrix')" 22 | echo "matrix=$matrix" >> "$GITHUB_OUTPUT" 23 | 24 | nix-build: 25 | needs: nix-matrix 26 | runs-on: ${{ matrix.os }} 27 | strategy: 28 | matrix: ${{fromJSON(needs.nix-matrix.outputs.matrix)}} 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: cachix/install-nix-action@v27 32 | - run: nix build -L '.#${{ matrix.attr }}' 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | tmp/ 5 | 6 | .direnv/ 7 | 8 | dist-newstyle/ 9 | result 10 | result-* 11 | out/ 12 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | build: 2 | stage: build 3 | image: nixos/nix:2.18.5 4 | artifacts: 5 | when: always 6 | paths: 7 | - junit.xml 8 | - public/ 9 | reports: 10 | junit: junit.xml 11 | before_script: 12 | - mkdir -p ~/.config/nix; echo "extra-experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf 13 | script: 14 | - nix build .#default.testreport --print-build-logs 15 | - nix build .#default.doc 16 | after_script: 17 | - cp result-testreport/junit.xml . 18 | - cp -r result-doc/share/doc/tailscale-manager-*/html/ public/ 19 | 20 | pages: 21 | stage: deploy 22 | only: 23 | variables: ["$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"] 24 | environment: 25 | name: docs 26 | url: "$CI_PAGES_URL" 27 | image: busybox 28 | variables: 29 | GIT_STRATEGY: "none" 30 | dependencies: 31 | - build 32 | script: 33 | - echo "Pages accessible through $CI_PAGES_URL" 34 | artifacts: 35 | paths: 36 | - public/ 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision history for tailscale-manager 2 | 3 | ## 0.1.0.0 -- YYYY-mm-dd 4 | 5 | * First version. Released on an unsuspecting world. 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # We can't use the official haskell image, because it's based on Debian, 2 | # which uses glibc, and we need musl to build a static binary. 3 | FROM benz0li/ghc-musl:9.6 AS builder 4 | 5 | # Install dependencies first, to cache them 6 | WORKDIR /app 7 | COPY tailscale-manager.cabal . 8 | RUN cabal update && cabal build -j --only-dependencies 9 | 10 | # Copy source code 11 | COPY . . 12 | 13 | # Build static binary, code from https://hasufell.github.io/posts/2024-04-21-static-linking.html 14 | RUN cabal build -j --enable-executable-static exe:tailscale-manager 15 | RUN mkdir out/ && cp $(cabal -v0 list-bin exe:tailscale-manager) out/ 16 | 17 | # Copy the binary to a new image, to keep the final image lightweight 18 | FROM alpine:3.20 AS runtime 19 | 20 | COPY --from=builder /app/out/tailscale-manager /bin/ 21 | CMD ["tailscale-manager"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tailscale routes manager 2 | 3 | **tailscale-manager** dynamically manages Tailscale subnet route advertisements 4 | based on user-configurable discovery sources. It runs alongside tailscaled on 5 | the node(s) where you want to advertise routes. 6 | 7 | ## Supported discovery methods 8 | 9 | | config keyword | example | description | 10 | | :---------------------- | :---------------------------- | :------------------------- | 11 | | `routes` | `["192.168.0.0/24"]` | Static routes | 12 | | `hostRoutes` | `["private-app.example.com"]` | DNS hostname lookup | 13 | | `awsManagedPrefixLists` | `["pl-02761f4a40454a3c9"]` | [AWS Managed Prefix Lists] | 14 | 15 | [AWS Managed Prefix Lists]: https://docs.aws.amazon.com/vpc/latest/userguide/managed-prefix-lists.html 16 | 17 | `hostRoutes` can be used to emulate [Tailscale App Connectors] by advertising a 18 | set of individual IP address routes that are kept in sync with DNS lookups of a 19 | set of hostnames. This is most useful when using [Headscale], which doesn't 20 | normally support App Connectors. 21 | 22 | [Tailscale App Connectors]: https://tailscale.com/kb/1281/app-connectors 23 | [Headscale]: https://headscale.net/ 24 | 25 | ### Possible future discovery methods 26 | 27 | - DNS SRV records 28 | - Extra JSON files on disk 29 | - Generic HTTP service discovery, similar to [Prometheus `http_sd`](https://prometheus.io/docs/prometheus/2.54/http_sd/) 30 | - [NetBox lists](https://github.com/devon-mar/netbox-lists) 31 | - Google Cloud [public-advertised-prefixes](https://cloud.google.com/sdk/gcloud/reference/compute/public-advertised-prefixes) 32 | - Other cloud providers? 33 | 34 | ## Example 35 | 36 | Create a configuration file in either JSON or YAML format. Here’s an example: 37 | 38 | `config.json` 39 | ```json 40 | { 41 | "routes": [ 42 | "172.16.0.0/22", 43 | "192.168.0.0/24" 44 | ], 45 | "hostRoutes": [ 46 | "github.com", 47 | "private-app.example.com" 48 | ], 49 | "awsManagedPrefixLists": [ 50 | "pl-02761f4a40454a3c9" 51 | ] 52 | } 53 | ``` 54 | or `config.yaml` 55 | ```yaml 56 | routes: 57 | - "172.16.0.0/22" 58 | - "192.168.0.0/24" 59 | hostRoutes: 60 | - "special-hostname1.example" 61 | - "special-hostname2.example" 62 | awsManagedPrefixLists: 63 | - "pl-02761f4a40454a3c9" 64 | extraArgs: 65 | - "--webclient" 66 | ``` 67 | 68 | Run tailscale-manager: 69 | 70 | ```sh 71 | tailscale-manager your-config-file.json --interval 300 72 | ``` 73 | 74 | The above will result in individual /32 (or /128 for ipv6) route advertisements 75 | on your tailnet for the IP addresses that `github.com` and 76 | `private-app.example.com` resolve to, plus any routes found in the named AWS 77 | managed prefix lists, and any static routes provided in the `routes` list. 78 | Every 300 seconds, tailscale-manager will refresh its route discovery sources 79 | and update tailscale route advertisements accordingly. 80 | 81 | ## Commandline options 82 | 83 | ``` 84 | Usage: tailscale-manager [--dryrun] [--tailscale PATH] 85 | [--socket SOCKET_PATH] [--interval INT] 86 | [--max-shrink-ratio RATIO] 87 | 88 | Tailscale routes manager 89 | 90 | Dynamically resolves a list of hostRoutes to IP addresses, then tells tailscale 91 | to advertise them as /32 routes along with any normal CIDR routes. 92 | 93 | Config file example: 94 | 95 | { 96 | "routes": [ 97 | "172.16.0.0/22", 98 | "192.168.0.0/24" 99 | ], 100 | "hostRoutes": [ 101 | "special-hostname1.example", 102 | "special-hostname2.example", 103 | ], 104 | "awsManagedPrefixLists": [ 105 | "pl-02761f4a40454a3c9" 106 | ], 107 | "extraArgs": ["--webclient"] 108 | } 109 | 110 | Available options: 111 | --dryrun Dryrun mode 112 | --tailscale PATH Path to the tailscale executable 113 | (default: "tailscale") 114 | --socket SOCKET_PATH Path to the tailscaled socket 115 | (default: "/var/run/tailscale/tailscaled.sock") 116 | --interval INT Interval (in seconds) between runs. 0 means exit 117 | after running once. (default: 0) 118 | --max-shrink-ratio RATIO Max allowed route shrinkage between consecutive runs, 119 | as a ratio between 0 and 1. 1 means no limit. 120 | (default: 0.33) 121 | -h,--help Show this help text 122 | ``` 123 | 124 | ## Docker image 125 | 126 | A Docker image is built and pushed to GitHub Container Registry on each version, commit, and pull request. The image is built based on Alpine images and contains a statically-linked `tailscale-manager` binary. 127 | 128 | You can use it to build your own custom Tailscale Docker images using the following Dockerfile and `entrypoint.sh` script. 129 | 130 | ```dockerfile 131 | # Dockerfile 132 | FROM ghcr.io/singlestore-labs/tailscale-manager AS tailscale-manager 133 | FROM tailscale/tailscale AS tailscale 134 | 135 | COPY --from=tailscale-manager /bin/tailscale-manager /bin/tailscale-manager 136 | 137 | COPY config.json 138 | 139 | COPY entrypoint.sh /usr/local/bin/entrypoint 140 | CMD ["entrypoint"] 141 | ``` 142 | 143 | ```sh 144 | # entrypoint.sh 145 | #!/bin/sh 146 | tailscale-manager --interval 300 & 147 | containerboot 148 | ``` 149 | 150 | This will allow you to use Tailscale as a Docker container while having `tailscale-manager` running in the background, periodically updating routes. 151 | 152 | ## NixOS module 153 | 154 | If you use NixOS, this repository provides a flake with a NixOS module to install and run tailscale-manager as a systemd service. You can incorporate it into your flake.nix like so: 155 | 156 | ```nix 157 | { 158 | description = "my nixos config"; 159 | 160 | inputs = { 161 | nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11"; 162 | tailscale-manager = { 163 | url = "github:singlestore-labs/tailscale-manager"; 164 | inputs.nixpkgs.follows = "nixpkgs"; 165 | }; 166 | }; 167 | 168 | outputs = { self, nixpkgs, tailscale-manager }: 169 | { 170 | nixosConfigurations.default = nixpkgs.lib.nixosSystem { 171 | system = "x86_64-linux"; 172 | modules = [ 173 | tailscale-manager.nixosModules.default 174 | ({ config, lib, pkgs, ... }: 175 | { 176 | nixpkgs.overlays = [ tailscale-manager.overlays.default ]; 177 | 178 | services.tailscale.enable = true; 179 | 180 | services.tailscale-manager = { 181 | enable = true; 182 | routes = [ 183 | "172.16.0.0/22" 184 | "192.168.0.0/24" 185 | ]; 186 | hostRoutes = [ 187 | "app1.example.com" 188 | "app2.example.com" 189 | ]; 190 | interval = 300; 191 | maxShrinkRatio = 0.25; 192 | }; 193 | }) 194 | ]; 195 | }; 196 | }; 197 | } 198 | ``` 199 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | -- | TailscaleManager entrypoint 2 | 3 | module Main where 4 | 5 | import TailscaleManager 6 | 7 | main :: IO () 8 | main = TailscaleManager.main 9 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nix-github-actions": { 22 | "inputs": { 23 | "nixpkgs": [ 24 | "nixpkgs" 25 | ] 26 | }, 27 | "locked": { 28 | "lastModified": 1737420293, 29 | "narHash": "sha256-F1G5ifvqTpJq7fdkT34e/Jy9VCyzd5XfJ9TO8fHhJWE=", 30 | "owner": "nix-community", 31 | "repo": "nix-github-actions", 32 | "rev": "f4158fa080ef4503c8f4c820967d946c2af31ec9", 33 | "type": "github" 34 | }, 35 | "original": { 36 | "owner": "nix-community", 37 | "repo": "nix-github-actions", 38 | "type": "github" 39 | } 40 | }, 41 | "nixpkgs": { 42 | "locked": { 43 | "lastModified": 1737569578, 44 | "narHash": "sha256-6qY0pk2QmUtBT9Mywdvif0i/CLVgpCjMUn6g9vB+f3M=", 45 | "owner": "NixOS", 46 | "repo": "nixpkgs", 47 | "rev": "47addd76727f42d351590c905d9d1905ca895b82", 48 | "type": "github" 49 | }, 50 | "original": { 51 | "owner": "NixOS", 52 | "ref": "nixos-24.11", 53 | "repo": "nixpkgs", 54 | "type": "github" 55 | } 56 | }, 57 | "root": { 58 | "inputs": { 59 | "flake-utils": "flake-utils", 60 | "nix-github-actions": "nix-github-actions", 61 | "nixpkgs": "nixpkgs" 62 | } 63 | }, 64 | "systems": { 65 | "locked": { 66 | "lastModified": 1681028828, 67 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 68 | "owner": "nix-systems", 69 | "repo": "default", 70 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 71 | "type": "github" 72 | }, 73 | "original": { 74 | "owner": "nix-systems", 75 | "repo": "default", 76 | "type": "github" 77 | } 78 | } 79 | }, 80 | "root": "root", 81 | "version": 7 82 | } 83 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "tailscale-manager"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | nix-github-actions.url = "github:nix-community/nix-github-actions"; 8 | nix-github-actions.inputs.nixpkgs.follows = "nixpkgs"; 9 | }; 10 | 11 | outputs = { self, nixpkgs, flake-utils, nix-github-actions }: 12 | { 13 | githubActions = nix-github-actions.lib.mkGithubMatrix { 14 | checks = nixpkgs.lib.getAttrs [ "x86_64-linux" ] self.checks; 15 | }; 16 | 17 | nixosModules.default = self.nixosModules.tailscale-manager; 18 | nixosModules.tailscale-manager = import ./nix/nixos-module.nix; 19 | 20 | overlays.default = final: prev: { 21 | tailscale-manager = self.packages.${prev.system}.tailscale-manager; 22 | }; 23 | 24 | } // flake-utils.lib.eachDefaultSystem (system: 25 | let 26 | pkgs = nixpkgs.legacyPackages.${system}.extend self.overlays.default; 27 | 28 | haskellPackages = pkgs.haskellPackages; 29 | 30 | jailbreakUnbreak = pkg: 31 | pkgs.haskell.lib.doJailbreak (pkg.overrideAttrs (_: { meta = { }; })); 32 | in { 33 | packages.tailscale-manager = ( 34 | haskellPackages.callCabal2nix "tailscale-manager" self rec { 35 | # Dependency overrides go here 36 | }).overrideAttrs (x: { 37 | outputs = x.outputs ++ ["testreport"]; 38 | preCheck = '' 39 | checkFlagsArray+=("--test-options=--xml=$testreport/junit.xml") 40 | ''; 41 | }); 42 | 43 | packages.default = self.packages.${system}.tailscale-manager; 44 | 45 | checks.tailscale-manager = self.packages.${system}.tailscale-manager; 46 | 47 | checks.vm-test = pkgs.callPackage ./nix/vm-test.nix { inherit self; }; 48 | 49 | devShells.default = pkgs.mkShell { 50 | buildInputs = with pkgs; [ 51 | haskellPackages.haskell-language-server # you must build it with your ghc to work 52 | ghcid 53 | cabal-install 54 | ]; 55 | inputsFrom = map (__getAttr "env") (__attrValues self.packages.${system}); 56 | }; 57 | 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /nix/nixos-module.nix: -------------------------------------------------------------------------------- 1 | { config, lib, pkgs, ... }: 2 | 3 | with lib; 4 | let 5 | cfg = config.services.tailscale-manager; 6 | configFile = pkgs.writeTextFile { 7 | name = "tailscale-manager.json"; 8 | text = generators.toJSON {} { 9 | routes = cfg.routes; 10 | hostRoutes = cfg.hostRoutes; 11 | extraArgs = cfg.extraArgs; 12 | awsManagedPrefixLists = cfg.awsManagedPrefixLists; 13 | }; 14 | }; 15 | in { 16 | options.services.tailscale-manager = { 17 | enable = mkEnableOption "tailscale-manager"; 18 | package = mkPackageOption pkgs "tailscale-manager" {}; 19 | interval = mkOption { 20 | type = types.int; 21 | default = 300; 22 | description = "Interval between runs, in seconds"; 23 | }; 24 | routes = mkOption { 25 | type = types.listOf types.str; 26 | default = []; 27 | description = "List of CIDR prefix routes to advertise"; 28 | }; 29 | hostRoutes = mkOption { 30 | type = types.listOf types.str; 31 | default = []; 32 | description = "List of hostnames and IP addresses to add as /32 routes"; 33 | }; 34 | awsManagedPrefixLists = mkOption { 35 | type = types.listOf types.str; 36 | default = []; 37 | description = "AWS prefix list IDs for route discovery"; 38 | }; 39 | extraArgs = mkOption { 40 | type = types.listOf types.str; 41 | default = []; 42 | description = "Extra arguments for `tailscale set`"; 43 | }; 44 | dryRun = mkOption { 45 | type = types.bool; 46 | default = false; 47 | description = "Enable dry-run mode, don't actually apply changes."; 48 | }; 49 | maxShrinkRatio = mkOption { 50 | type = types.float; 51 | default = 0.5; 52 | description = "How much route shrinkage is allowed between subsequent runs (between 0 and 1)"; 53 | }; 54 | socketPath = mkOption { 55 | type = types.path; 56 | default = "/var/run/tailscale/tailscaled.sock"; 57 | description = "Path to the tailscaled socket"; 58 | }; 59 | }; 60 | config = mkIf cfg.enable { 61 | systemd.services.tailscale-manager = { 62 | after = ["tailscaled.service"]; 63 | wants = ["tailscaled.service"]; 64 | wantedBy = ["multi-user.target"]; 65 | # Never give up on trying to restart 66 | startLimitIntervalSec = 0; 67 | serviceConfig = { 68 | Type = "exec"; 69 | Restart = "always"; 70 | # Restart at increasing intervals to avoid things like EC2 71 | # metadata service rate limits 72 | RestartSec = 1; 73 | RestartSteps = 30; 74 | RestartMaxDelaySec = 60; 75 | ExecStart = lib.escapeShellArgs ( 76 | [ "${cfg.package}/bin/tailscale-manager" configFile 77 | "--tailscale=${config.services.tailscale.package}/bin/tailscale" 78 | "--socket=${cfg.socketPath}" 79 | "--interval=${toString cfg.interval}" 80 | "--max-shrink-ratio=${toString cfg.maxShrinkRatio}" 81 | ] ++ lib.optional cfg.dryRun "--dryrun" 82 | ); 83 | }; 84 | }; 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /nix/vm-test.nix: -------------------------------------------------------------------------------- 1 | { self, lib, pkgs, ... }: 2 | 3 | let fakeTailscale = pkgs.writeScriptBin "tailscale" '' 4 | #!/bin/sh 5 | echo "Fake tailscale invoked with args: $*" 1>&2 6 | ''; 7 | in 8 | pkgs.nixosTest { 9 | name = "tailscale-manager"; 10 | nodes.machine1 = { config, pkgs, ... }: { 11 | imports = [ self.nixosModules.tailscale-manager ]; 12 | services.tailscale.package = fakeTailscale; 13 | services.tailscale-manager = { 14 | enable = true; 15 | routes = ["192.168.254.0/24"]; 16 | }; 17 | system.stateVersion = "24.11"; 18 | }; 19 | testScript = '' 20 | machine1.wait_for_unit("tailscale-manager.service") 21 | machine1.wait_for_console_text("Fake tailscale invoked with args:.*--advertise-routes=192.168.254.0/24") 22 | ''; 23 | } 24 | -------------------------------------------------------------------------------- /src/TailscaleManager.hs: -------------------------------------------------------------------------------- 1 | -- | Tailscale Routes Manager 2 | 3 | module TailscaleManager where 4 | 5 | import Control.Concurrent (threadDelay) 6 | import Control.Monad (unless, void, when) 7 | import Control.Monad.Loops (iterateM_) 8 | import Data.IP (IPRange) 9 | import Data.List (intercalate) 10 | import Data.Set (Set) 11 | import Data.Set qualified as S 12 | import Options.Applicative 13 | import Prettyprinter (Doc) 14 | import System.Log.Logger 15 | import System.Process (callProcess, showCommandForUser) 16 | import Text.RawString.QQ (r) 17 | 18 | import TailscaleManager.Config 19 | import TailscaleManager.Discovery.AWSManagedPrefixList (resolveAllPrefixLists) 20 | import TailscaleManager.Discovery.DNS (resolveHostnamesToRoutes) 21 | import TailscaleManager.Logging (myLogger) 22 | 23 | type Seconds = Int 24 | 25 | -- | Commandline option types 26 | data TailscaleManagerOptions 27 | = TailscaleManagerOptions 28 | { configFile :: FilePath 29 | , dryRun :: Bool 30 | , tailscaleCmd :: FilePath 31 | , socketPath :: FilePath 32 | , interval :: Seconds 33 | , maxShrinkRatio :: Double 34 | } 35 | 36 | -- This is just a multiline string. 37 | -- Don't be fooled by gitlab trying to syntax highlight it. 38 | helpText :: Doc a 39 | helpText = [r|Tailscale routes manager 40 | 41 | Dynamically resolves a list of hostRoutes to IP addresses, then tells tailscale 42 | to advertise them as /32 routes along with any normal CIDR routes. 43 | 44 | Config file example (JSON): 45 | 46 | { 47 | "routes": [ 48 | "172.16.0.0/22", 49 | "192.168.0.0/24" 50 | ], 51 | "hostRoutes": [ 52 | "special-hostname1.example", 53 | "special-hostname2.example" 54 | ], 55 | "awsManagedPrefixLists": [ 56 | "pl-02761f4a40454a3c9" 57 | ], 58 | "extraArgs": ["--webclient"] 59 | }|] 60 | 61 | tsManagerOptions :: Parser TailscaleManagerOptions 62 | tsManagerOptions = 63 | TailscaleManagerOptions 64 | <$> argument str (metavar "") 65 | <*> switch (long "dryrun" 66 | <> help "Dryrun mode") 67 | <*> strOption (long "tailscale" 68 | <> metavar "PATH" 69 | <> help "Path to the tailscale executable" 70 | <> value "tailscale" 71 | <> showDefault) 72 | <*> strOption (long "socket" 73 | <> metavar "SOCKET_PATH" 74 | <> help "Path to the tailscaled socket" 75 | <> value "/var/run/tailscale/tailscaled.sock" 76 | <> showDefault) 77 | <*> option auto (long "interval" 78 | <> metavar "INT" 79 | <> help "Interval (in seconds) between runs. 0 means exit after running once." 80 | <> value 0 81 | <> showDefault) 82 | <*> option auto (long "max-shrink-ratio" 83 | <> metavar "RATIO" 84 | <> help "Max allowed route shrinkage between consecutive runs, as a ratio between 0 and 1. 1 means no limit." 85 | <> value 0.33 86 | <> showDefault) 87 | 88 | main :: IO () 89 | main = run =<< execParser opts 90 | where 91 | opts = info (tsManagerOptions <**> helper) 92 | (fullDesc 93 | <> progDescDoc (Just helpText) 94 | <> header "tailscale-manager") 95 | 96 | run :: TailscaleManagerOptions -> IO () 97 | run options = do 98 | -- Clear the default root log handler to prevent duplicate messages 99 | updateGlobalLogger rootLoggerName removeHandler 100 | logger <- myLogger 101 | when (dryRun options) $ 102 | logL logger WARNING "Dry-run mode enabled. Will not actually apply changes." 103 | if interval options > 0 104 | then do 105 | logL logger INFO $ "Running every " ++ show (interval options) ++ " seconds" 106 | iterateM_ (runOnce options) S.empty 107 | else void (runOnce options S.empty) 108 | 109 | runOnce :: TailscaleManagerOptions -- ^ Commandline options 110 | -> Set IPRange -- ^ Result of the previous run 111 | -> IO (Set IPRange) -- ^ New generated routes 112 | runOnce options prevRoutes = do 113 | logger <- myLogger 114 | 115 | let invokeTailscale args = 116 | if dryRun options 117 | then 118 | logL logger DEBUG $ "(not actually) Running: " ++ escapedArgs 119 | else do 120 | logL logger DEBUG $ "Running: " ++ escapedArgs 121 | callProcess (tailscaleCmd options) args 122 | where escapedArgs = showCommandForUser (tailscaleCmd options) args 123 | 124 | let logDelay = do 125 | when (interval options > 0) $ 126 | logL logger DEBUG ("Sleeping for " ++ show (interval options) ++ " seconds") 127 | threadDelay (interval options * 1000000) -- microseconds 128 | 129 | config <- loadConfig (configFile options) 130 | newRoutes <- generateRoutes config 131 | 132 | logDiff prevRoutes newRoutes 133 | 134 | let shrinkage = shrinkRatio prevRoutes newRoutes 135 | if shrinkage < maxShrinkRatio options 136 | then do 137 | invokeTailscale $ ["--socket=" ++ socketPath options, "set", "--advertise-routes=" ++ intercalate "," (map show $ S.toList newRoutes)] ++ tsExtraArgs config 138 | logDelay 139 | return newRoutes 140 | else do 141 | logL logger ERROR "Sanity check failed! Refusing to apply changes!" 142 | logL logger ERROR ("Shrink ratio: " ++ show shrinkage) 143 | logDelay 144 | return prevRoutes 145 | 146 | -- | Emit a log message describing the difference between old and new route sets. 147 | logDiff :: Set IPRange -> Set IPRange -> IO () 148 | logDiff prevRoutes newRoutes = do 149 | logger <- myLogger 150 | 151 | logL logger INFO (show (length routesToAdd) ++ " to add, " ++ 152 | show (length routesToRemove) ++ " to remove, " ++ 153 | show (length routesUnchanged) ++ " routes unchanged") 154 | unless (null routesToAdd) $ 155 | logL logger INFO ("Routes to add: " ++ unwords (map show (S.toList routesToAdd))) 156 | unless (null routesToRemove) $ 157 | logL logger INFO ("Routes to remove: " ++ unwords (map show (S.toList routesToRemove))) 158 | where 159 | routesToAdd = S.difference newRoutes prevRoutes 160 | routesToRemove = S.difference prevRoutes newRoutes 161 | routesUnchanged = S.intersection prevRoutes newRoutes 162 | 163 | -- |Compute how much the smaller the new set is vs old. 164 | -- 165 | -- >>> shrinkRatio ["1.1.1.1/32", "2.2.2.2/32"] ["1.1.1.1/32"] 166 | -- 0.5 167 | shrinkRatio :: Foldable t 168 | => t a -- ^ Old set 169 | -> t a -- ^ New set 170 | -> Double -- ^ Shrink ratio 171 | shrinkRatio old new = 1 - (1 / (fromIntegral (length old) / fromIntegral (length new))) 172 | 173 | -- | Generate routes based on config, resolving hostnames and AWS-managed prefix lists. 174 | generateRoutes :: TSConfig -> IO (Set IPRange) 175 | generateRoutes config = do 176 | hostRoutes <- resolveHostnamesToRoutes (tsHostRoutes config) 177 | managedPrefixRoutes <- resolveAllPrefixLists (tsAWSManagedPrefixLists config) 178 | return $ S.fromList (tsRoutes config <> hostRoutes <> managedPrefixRoutes) 179 | -------------------------------------------------------------------------------- /src/TailscaleManager/Config.hs: -------------------------------------------------------------------------------- 1 | -- | Config loader for Tailscale Routes Manager 2 | 3 | module TailscaleManager.Config where 4 | 5 | import Data.Aeson 6 | import Data.Aeson.IP () 7 | import Data.IP (IPRange) 8 | import Data.Maybe (fromMaybe) 9 | import Data.Text 10 | import System.FilePath (takeExtension) 11 | import Data.Yaml (decodeFileEither) 12 | 13 | -- |Config file schema 14 | data TSConfig 15 | = TSConfig 16 | { tsRoutes :: [IPRange] 17 | , tsHostRoutes :: [String] 18 | , tsExtraArgs :: [String] 19 | , tsAWSManagedPrefixLists :: [Text] 20 | } 21 | deriving Show 22 | 23 | -- |Config file JSON parser 24 | instance FromJSON TSConfig where 25 | parseJSON = withObject "TSConfig" $ \obj -> do 26 | routes <- obj .:? "routes" 27 | hostRoutes <- obj .:? "hostRoutes" 28 | extraArgs <- obj .:? "extraArgs" 29 | awsManagedPrefixLists <- obj .:? "awsManagedPrefixLists" 30 | return (TSConfig { tsRoutes = fromMaybe [] routes 31 | , tsHostRoutes = fromMaybe [] hostRoutes 32 | , tsExtraArgs = fromMaybe [] extraArgs 33 | , tsAWSManagedPrefixLists = fromMaybe [] awsManagedPrefixLists 34 | }) 35 | 36 | -- | Load configuration from a file, detecting format based on file extension. 37 | loadConfig :: FilePath -> IO TSConfig 38 | loadConfig path = do 39 | case takeExtension path of 40 | ".json" -> loadConfigFromJSON path 41 | ".yaml" -> loadConfigFromYAML path 42 | _ -> error "Unsupported file format. Please use .json or .yaml." 43 | 44 | -- | Load configuration from a JSON file. 45 | loadConfigFromJSON :: FilePath -> IO TSConfig 46 | loadConfigFromJSON path = do 47 | result <- eitherDecodeFileStrict path 48 | case result of 49 | Left err -> error $ "Failed to parse JSON config: " ++ err 50 | Right config -> return config 51 | 52 | -- | Load configuration from a YAML file. 53 | loadConfigFromYAML :: FilePath -> IO TSConfig 54 | loadConfigFromYAML path = do 55 | result <- decodeFileEither path 56 | case result of 57 | Left err -> error $ "Failed to parse YAML config: " ++ show err 58 | Right config -> return config 59 | -------------------------------------------------------------------------------- /src/TailscaleManager/Discovery/AWSManagedPrefixList.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DataKinds #-} 2 | {-# LANGUAGE FlexibleContexts #-} 3 | {-# LANGUAGE OverloadedLabels #-} 4 | -- | AWS Managed Prefix List route discovery 5 | 6 | module TailscaleManager.Discovery.AWSManagedPrefixList where 7 | 8 | import Amazonka 9 | import Amazonka.EC2 10 | import Amazonka.Prelude (Text, mapMaybe, catMaybes) 11 | import Control.Lens 12 | import Data.Conduit 13 | import Data.Text (unpack) 14 | import qualified Data.Conduit.List as CL 15 | import Data.Generics.Labels () 16 | import Data.IP (IPRange) 17 | import System.IO (stderr) 18 | import System.Log.Logger 19 | import Text.Read (readMaybe) 20 | 21 | import TailscaleManager.Logging 22 | 23 | getPrefixListPaginated :: Text -- ^ prefix list ID 24 | -> IO [PrefixListEntry] 25 | getPrefixListPaginated pl = do 26 | -- TODO: figure out how to use my own HSLogger instance here instead of 27 | -- amazonka's built-in logger 28 | lgr <- newLogger Info stderr 29 | env <- newEnv discover <&> set #logger lgr 30 | 31 | runResourceT . runConduit $ 32 | paginate env (newGetManagedPrefixListEntries pl) 33 | .| CL.concatMap (view $ #entries . _Just) 34 | .| CL.consume 35 | 36 | -- | Look up a AWS Managed Prefix List by ID, returning parsed subnet prefixes. 37 | -- Silently discards any unparsable prefixes, because it seems safe to 38 | -- assume that AWS will return valid CIDR strings. 39 | resolvePrefixListEntries :: Text -- ^ Prefix list ID 40 | -> IO [IPRange] 41 | resolvePrefixListEntries pl = 42 | getPrefixListPaginated pl 43 | <&> mapMaybe (readMaybe . unpack . view (#cidr . _Just)) 44 | 45 | -- | Like 'resolvePrefixListEntries', but logs and discards errors. 46 | resolvePrefixListEntries' :: Text -- ^ Prefix list ID 47 | -> IO (Maybe [IPRange]) 48 | resolvePrefixListEntries' pl = do 49 | lgr <- myLogger 50 | result <- trying _ServiceError $ resolvePrefixListEntries pl 51 | case result of 52 | Left err -> do 53 | -- TODO: better error messages 54 | logL lgr WARNING ("Failed to resolve a prefix list! " <> show err) 55 | return Nothing 56 | Right ipranges -> 57 | return $ Just ipranges 58 | 59 | -- | Look up entries from multiple prefix lists, ignoring any errors 60 | resolveAllPrefixLists :: [Text] -- ^ Prefix list IDs 61 | -> IO [IPRange] 62 | resolveAllPrefixLists pls = 63 | mapM resolvePrefixListEntries' pls <&> concat . catMaybes 64 | -------------------------------------------------------------------------------- /src/TailscaleManager/Discovery/DNS.hs: -------------------------------------------------------------------------------- 1 | -- | DNS hostname route discovery 2 | 3 | module TailscaleManager.Discovery.DNS where 4 | 5 | import Data.Functor ((<&>)) 6 | import Data.Maybe (catMaybes, mapMaybe) 7 | import Control.Exception (IOException, try) 8 | import Data.IP 9 | import Network.Socket 10 | import System.Log.Logger 11 | 12 | import TailscaleManager.Logging 13 | 14 | -- |Resolve a list of hostnames to a concatenated list of IPs. 15 | resolveHostnames :: [HostName] -> IO [IP] 16 | resolveHostnames hs = mapM resolveOne hs <&> concat . catMaybes 17 | 18 | -- |Resolve one hostname to a list of IPs. 19 | -- Logs a warning and returns Nothing if the lookup fails. 20 | resolveOne :: HostName -> IO (Maybe [IP]) 21 | resolveOne hostname = do 22 | logger <- myLogger 23 | let hints = defaultHints { addrSocketType = Stream } 24 | result <- try @IOException $ getAddrInfo (Just hints) (Just hostname) Nothing 25 | case result of 26 | Left err -> do 27 | logL logger WARNING (show err) 28 | return Nothing 29 | Right addrInfos -> 30 | return $ Just (map fst (mapMaybe (fromSockAddr . addrAddress) addrInfos)) 31 | 32 | -- |Given a ipv4 or ipv6 IP address, return a /32 or /128 CIDR route for it. 33 | -- 34 | -- >>> ipToHostRoute (read "1.1.1.1" :: IP) 35 | -- 1.1.1.1/32 36 | -- 37 | -- >>> ipToHostRoute (read "fd00::1" :: IP) 38 | -- fd00::1/128 39 | ipToHostRoute :: IP -> IPRange 40 | ipToHostRoute (IPv4 ip) = IPv4Range (makeAddrRange ip 32) 41 | ipToHostRoute (IPv6 ip) = IPv6Range (makeAddrRange ip 128) 42 | 43 | -- | Resolve hostnames to static routes 44 | resolveHostnamesToRoutes :: [HostName] -> IO [IPRange] 45 | resolveHostnamesToRoutes hs = resolveHostnames hs <&> map ipToHostRoute 46 | -------------------------------------------------------------------------------- /src/TailscaleManager/Logging.hs: -------------------------------------------------------------------------------- 1 | -- | Logging setup 2 | 3 | module TailscaleManager.Logging where 4 | 5 | import Data.Functor ((<&>)) 6 | import System.IO (stderr) 7 | import System.Log.Formatter (simpleLogFormatter) 8 | import System.Log.Logger 9 | import System.Log.Handler (LogHandler (setFormatter)) 10 | import System.Log.Handler.Simple (streamHandler) 11 | 12 | myLogger :: IO Logger 13 | myLogger = do 14 | let myFormatter = simpleLogFormatter "[$time $prio $loggername] $msg" 15 | handler <- streamHandler stderr INFO <&> flip setFormatter myFormatter 16 | getLogger "tailscale-manager" <&> setLevel INFO . setHandlers [handler] 17 | -------------------------------------------------------------------------------- /tailscale-manager.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 3.0 2 | name: tailscale-manager 3 | -- The package version. 4 | -- See the Haskell package versioning policy (PVP) for standards 5 | -- guiding when and how versions should be incremented. 6 | -- https://pvp.haskell.org 7 | -- PVP summary: +-+------- breaking API changes 8 | -- | | +----- non-breaking API additions 9 | -- | | | +--- code changes with no API change 10 | version: 0.1.2.0 11 | -- A short (one-line) description of the package. 12 | -- synopsis: 13 | -- A longer description of the package. 14 | -- description: 15 | license: Apache-2.0 16 | license-file: LICENSE 17 | author: Benjamin Staffin 18 | maintainer: benley@gmail.com 19 | -- copyright: 20 | category: System 21 | build-type: Simple 22 | extra-doc-files: CHANGELOG.md 23 | -- Extra source files to be distributed with the package, such as examples, or a tutorial module. 24 | -- extra-source-files: 25 | 26 | common warnings 27 | ghc-options: -Wall 28 | 29 | common deps 30 | default-language: Haskell2010 31 | build-depends: 32 | base ^>=4.18.2.1 33 | , aeson ^>= 2.1.2 34 | , aeson-iproute ^>= 0.3.0 35 | , amazonka ^>= 2.0 36 | , amazonka-core ^>= 2.0 37 | , amazonka-ec2 ^>= 2.0 38 | , bytestring ^>= 0.11.5 39 | , conduit ^>= 1.3.5 40 | , containers ^>= 0.6.7 41 | , generic-lens ^>= 2.2.2 42 | , hslogger ^>= 1.3.1 43 | , network ^>= 3.1.4 44 | , iproute ^>= 1.7.12 45 | , lens ^>= 5.2.3 46 | , monad-loops ^>= 0.4.3 47 | , optparse-applicative ^>= 0.18.1 48 | , prettyprinter ^>= 1.7.1 49 | , process ^>= 1.6.19 50 | , protolude ^>= 0.3.4 51 | , raw-strings-qq ^>= 1.1 52 | , text ^>= 2.0.2 53 | , filepath ^>= 1.4.2 54 | , yaml ^>= 0.11.8 55 | 56 | executable tailscale-manager 57 | import: warnings, deps 58 | main-is: Main.hs 59 | -- Modules included in this executable, other than Main. 60 | -- other-modules: 61 | -- LANGUAGE extensions used by modules in this package. 62 | -- other-extensions: 63 | build-depends: tailscale-manager 64 | hs-source-dirs: app 65 | 66 | library 67 | import: warnings, deps 68 | default-extensions: 69 | ImportQualifiedPost 70 | , OverloadedStrings 71 | , QuasiQuotes 72 | , StrictData 73 | , TypeApplications 74 | exposed-modules: 75 | TailscaleManager 76 | , TailscaleManager.Config 77 | , TailscaleManager.Discovery.AWSManagedPrefixList 78 | , TailscaleManager.Discovery.DNS 79 | , TailscaleManager.Logging 80 | hs-source-dirs: src 81 | 82 | test-suite tailscale-manager-tests 83 | import: warnings, deps 84 | type: exitcode-stdio-1.0 85 | hs-source-dirs: test 86 | main-is: Main.hs 87 | build-depends: 88 | tailscale-manager 89 | , HUnit ^>= 1.6.2 90 | , HUnit-approx ^>= 1.1 91 | , tasty ^>= 1.4.3 92 | , tasty-ant-xml ^>= 1.1.9 93 | , tasty-hunit ^>= 0.10.1 94 | -------------------------------------------------------------------------------- /test/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE ImportQualifiedPost #-} 2 | 3 | -- | Unit tests for TailscaleManager 4 | 5 | module Main where 6 | 7 | import Control.Monad (unless) 8 | import Data.IP (IPRange) 9 | import Data.Set qualified as S 10 | import TailscaleManager as T 11 | import TailscaleManager.Discovery.DNS 12 | import Test.Tasty 13 | import Test.Tasty.HUnit 14 | import Test.Tasty.Runners.AntXML (antXMLRunner) 15 | 16 | -- | Asserts that the specified actual value is approximately equal to the 17 | -- expected value. The output message will contain the prefix, the expected 18 | -- value, the actual value, and the maximum margin of error. 19 | -- 20 | -- If the prefix is the empty string (i.e., @\"\"@), then the prefix is omitted 21 | -- and only the expected and actual values are output. 22 | -- 23 | -- (copied verbatim from HUnit-approx, but this version works with Tasty) 24 | assertApproxEqual :: (HasCallStack, Ord a, Num a, Show a) 25 | => String -- ^ The message prefix 26 | -> a -- ^ Maximum allowable margin of error 27 | -> a -- ^ The expected value 28 | -> a -- ^ The actual value 29 | -> Assertion 30 | assertApproxEqual preface epsilon expected actual = 31 | unless (abs (actual - expected) <= epsilon) (assertFailure msg) 32 | where msg = (if null preface then "" else preface ++ "\n") ++ 33 | "expected: " ++ show expected ++ "\n but got: " ++ show actual ++ 34 | "\n (maximum margin of error: " ++ show epsilon ++ ")" 35 | 36 | toIPRanges :: [String] -> S.Set IPRange 37 | toIPRanges = S.fromList . map read 38 | 39 | testShrinkage :: TestTree 40 | testShrinkage = testGroup "shrinkRatio" 41 | [ testCase "0 shrink" $ assertEqual "" 0.0 (T.shrinkRatio set1 set1) 42 | , testCase "25% shrinkage" $ assertEqual "" 0.25 (T.shrinkRatio set1 set2) 43 | , testCase "100% shrinkage" $ assertEqual "" 1.0 (T.shrinkRatio set1 S.empty) 44 | , testCase "negative shrink" $ assertApproxEqual "" epsilon (-0.333) (T.shrinkRatio set2 set1) 45 | ] 46 | where epsilon = 0.001 47 | set1 = toIPRanges [ "192.168.0.0/24" 48 | , "192.168.1.0/24" 49 | , "192.168.2.0/24" 50 | , "192.168.3.0/24" ] 51 | set2 = toIPRanges [ "192.168.0.0/24" 52 | , "192.168.1.0/24" 53 | , "192.168.2.0/24" ] 54 | 55 | -- | This may look redundant but I got it wrong the first time, so... 56 | testIpToHostRoute :: TestTree 57 | testIpToHostRoute = testGroup "ipToHostRoute" 58 | [ testCase "ipToHostRoute (ipv4)" $ 59 | assertEqual "" (read "192.168.0.1/32") (ipToHostRoute $ read "192.168.0.1") 60 | , testCase "ipToHostRoute (ipv6)" $ 61 | assertEqual "" (read "fd00::1/128") (ipToHostRoute $ read "fd00::1") 62 | ] 63 | 64 | tests :: TestTree 65 | tests = testGroup "TailscaleManager" [testShrinkage, testIpToHostRoute] 66 | 67 | main :: IO () 68 | main = defaultMainWithIngredients ([antXMLRunner] <> defaultIngredients) 69 | tests 70 | --------------------------------------------------------------------------------