├── .envrc ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ ├── release.yml │ └── tag.yml ├── .gitignore ├── .vscode └── copyright.code-snippets ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config.go ├── config_test.go ├── default.nix ├── deploy ├── configmap.yaml ├── deployment.yaml ├── kustomization.yaml ├── namespace.yaml ├── secret.yaml └── serviceaccount.yaml ├── deque ├── deque.go └── deque_test.go ├── docker.nix ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── http.go ├── http_test.go ├── infra ├── .envrc ├── .terraform.lock.hcl ├── main.tf ├── moved.tf └── outputs.tf ├── load_balancer.go ├── load_balancer_test.go ├── main.go ├── shell.nix ├── signals_fallback.go ├── signals_unix.go └── tailscale-lb.nix /.envrc: -------------------------------------------------------------------------------- 1 | #shellcheck shell=sh 2 | use flake 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: zombiezen 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | paths: 7 | - '**' 8 | - '!.github/**' 9 | - '!.gitignore' 10 | - '!infra/**' 11 | - '.github/workflows/build.yml' 12 | workflow_dispatch: 13 | 14 | jobs: 15 | nix-build: 16 | name: nix build 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Check out code 20 | uses: actions/checkout@v4 21 | - name: Authenticate to Google Cloud Platform 22 | uses: google-github-actions/auth@v1 23 | with: 24 | workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} 25 | service_account: ${{ vars.GOOGLE_SERVICE_ACCOUNT }} 26 | token_format: access_token 27 | - name: Install Nix 28 | uses: cachix/install-nix-action@v26 29 | - name: Set up cache 30 | uses: zombiezen/setup-nix-cache-action@v0.3.2 31 | with: 32 | substituters: ${{ vars.NIX_SUBSTITUTER }} 33 | secret_keys: ${{ secrets.NIX_PRIVATE_KEY }} 34 | use_nixcached: true 35 | - id: build 36 | name: Build and test 37 | run: | 38 | nix build --print-build-logs '.#ci' 39 | echo "result=$(readlink -f result)" >> "$GITHUB_OUTPUT" 40 | - name: Upload Docker images 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: docker-images 44 | path: ${{ steps.build.outputs.result }}/docker-image-*.tar.gz 45 | 46 | permissions: 47 | contents: read 48 | id-token: write 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]*' 6 | 7 | env: 8 | IMAGE_NAME: ghcr.io/zombiezen/tailscale-lb 9 | 10 | jobs: 11 | docker: 12 | name: Docker Push 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | arch: ["amd64", "arm64"] 17 | steps: 18 | - name: Check out code 19 | uses: actions/checkout@v4 20 | - name: Authenticate to Google Cloud Platform 21 | uses: google-github-actions/auth@v1 22 | with: 23 | workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} 24 | service_account: ${{ vars.GOOGLE_SERVICE_ACCOUNT }} 25 | token_format: access_token 26 | - name: Log into GitHub Container Registry 27 | run: echo "$GITHUB_TOKEN" | docker login ghcr.io -u zombiezen --password-stdin 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | - name: Install Nix 31 | uses: cachix/install-nix-action@v26 32 | - name: Set up cache 33 | uses: zombiezen/setup-nix-cache-action@v0.3.2 34 | with: 35 | substituters: ${{ vars.NIX_SUBSTITUTER }} 36 | secret_keys: ${{ secrets.NIX_PRIVATE_KEY }} 37 | use_nixcached: true 38 | - id: build 39 | name: Build 40 | run: | 41 | IMAGE_TAG="$(echo ${{ github.ref_name }} | sed -e 's/^v//')-${{ matrix.arch }}" 42 | echo "tag=$IMAGE_TAG" >> $GITHUB_OUTPUT 43 | nix build \ 44 | --print-build-logs \ 45 | --file docker.nix \ 46 | --argstr name "$IMAGE_NAME" \ 47 | --argstr tag "$IMAGE_TAG" \ 48 | --argstr rev ${{ github.sha }} \ 49 | --argstr system x86_64-linux 50 | - name: Load into Docker 51 | run: docker load < result 52 | - name: Push to GitHub Container Registry 53 | run: docker push "${IMAGE_NAME}:${IMAGE_TAG}" 54 | env: 55 | IMAGE_TAG: ${{ steps.build.outputs.tag }} 56 | docker-manifest: 57 | name: Create Multi-Arch Image 58 | needs: [docker] 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Log into GitHub Container Registry 62 | run: echo "$GITHUB_TOKEN" | docker login ghcr.io -u zombiezen --password-stdin 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | - id: manifest 66 | name: Create manifest 67 | run: | 68 | IMAGE_TAG="$(echo ${{ github.ref_name }} | sed -e 's/^v//')" 69 | echo "tag=$IMAGE_TAG" >> $GITHUB_OUTPUT 70 | docker manifest create \ 71 | "${IMAGE_NAME}:${IMAGE_TAG}" \ 72 | "${IMAGE_NAME}:${IMAGE_TAG}-amd64" \ 73 | "${IMAGE_NAME}:${IMAGE_TAG}-arm64" 74 | - name: Push to GitHub Container Registry 75 | run: docker manifest push "${IMAGE_NAME}:${IMAGE_TAG}" 76 | env: 77 | IMAGE_TAG: ${{ steps.manifest.outputs.tag }} 78 | 79 | permissions: 80 | contents: read 81 | packages: write 82 | id-token: write 83 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Ross Light 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: Tag Image 18 | on: 19 | workflow_dispatch: 20 | inputs: 21 | src: 22 | description: Source tag 23 | required: true 24 | dst: 25 | description: Destination tags (space-separated) 26 | required: true 27 | permissions: 28 | packages: write 29 | jobs: 30 | tag: 31 | name: Tag 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Install crane 35 | env: 36 | VERSION: "0.14.0" 37 | run: | 38 | curl -fsSL "https://github.com/google/go-containerregistry/releases/download/v${VERSION}/go-containerregistry_Linux_x86_64.tar.gz" | tar zxf - crane 39 | - name: Log into GitHub Container Registry 40 | uses: docker/login-action@v2 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.repository_owner }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | - name: Tag 46 | env: 47 | SRC: "ghcr.io/${{ github.repository_owner }}/tailscale-lb:${{ inputs.src }}" 48 | DST: "${{ inputs.dst }}" 49 | run: | 50 | IFS=' ' read -r -a dst_tags <<< "$DST" 51 | for t in "${dst_tags[@]}"; do 52 | ./crane tag "$SRC" "$t" 53 | done 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /tailscale-lb 2 | *.ini 3 | -------------------------------------------------------------------------------- /.vscode/copyright.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Copyright": { 3 | "prefix": "copyright", 4 | "body": [ 5 | "$LINE_COMMENT Copyright $CURRENT_YEAR Ross Light", 6 | "$LINE_COMMENT", 7 | "$LINE_COMMENT Licensed under the Apache License, Version 2.0 (the \"License\");", 8 | "$LINE_COMMENT you may not use this file except in compliance with the License.", 9 | "$LINE_COMMENT You may obtain a copy of the License at", 10 | "$LINE_COMMENT", 11 | "$LINE_COMMENT https://www.apache.org/licenses/LICENSE-2.0", 12 | "$LINE_COMMENT", 13 | "$LINE_COMMENT Unless required by applicable law or agreed to in writing, software", 14 | "$LINE_COMMENT distributed under the License is distributed on an \"AS IS\" BASIS,", 15 | "$LINE_COMMENT WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.", 16 | "$LINE_COMMENT See the License for the specific language governing permissions and", 17 | "$LINE_COMMENT limitations under the License.", 18 | "$LINE_COMMENT", 19 | "$LINE_COMMENT SPDX-License-Identifier: Apache-2.0" 20 | ], 21 | "description": "Apache license header" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # tailscale-lb Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | [Unreleased]: https://github.com/zombiezen/tailscale-lb/compare/v0.4.0...main 9 | 10 | ## [0.4.0][] - 2024-03-19 11 | 12 | Version 0.4 adds the ability to use a self-hosted coordination server. 13 | 14 | [0.4.0]: https://github.com/zombiezen/tailscale-lb/releases/tag/v0.4.0 15 | 16 | ### Added 17 | 18 | - New `control-url` configuration setting permits using a self-hosted coordination server 19 | ([#5](https://github.com/zombiezen/tailscale-lb/pull/5)). 20 | 21 | ### Changed 22 | 23 | - Upgraded `tsnet` to 1.60.1. 24 | 25 | ## [0.3.0][] - 2023-03-18 26 | 27 | Version 0.3 adds HTTP-aware reverse-proxying. 28 | 29 | [0.3.0]: https://github.com/zombiezen/tailscale-lb/releases/tag/v0.3.0 30 | 31 | ### Added 32 | 33 | - HTTP reverse-proxying with `X-Forwarded-For` and Tailscale-specific features 34 | like optional identity headers and automatic TLS certificates. 35 | - Nix packaging has now been converted into a flake. 36 | 37 | ### Changed 38 | 39 | - `default.nix` now just evaluates to the tailscale-lb derivation. 40 | - Upgraded `tsnet` to 1.38.1. 41 | 42 | ## [0.2.1][] - 2022-09-21 43 | 44 | [0.2.1]: https://github.com/zombiezen/tailscale-lb/releases/tag/v0.2.1 45 | 46 | ### Fixed 47 | 48 | - No longer treating absolute paths in `state-directory` configuration as relative. 49 | 50 | ## [0.2.0][] - 2022-09-21 51 | 52 | Version 0.2 adds the ability to persist the Tailscale IP address between runs. 53 | 54 | [0.2.0]: https://github.com/zombiezen/tailscale-lb/releases/tag/v0.2.0 55 | 56 | ### Added 57 | 58 | - Add option to allow storing Tailscale state between runs. 59 | This allows for non-ephemeral use cases. 60 | 61 | ### Changed 62 | 63 | - Automatically log out from Tailscale on graceful exit 64 | if running without a state directory. 65 | - Configuration order has been flipped so that 66 | the last configuration file passed on the command-line 67 | has the highest precedence. 68 | 69 | ## [0.1.1][] - 2022-09-19 70 | 71 | [0.1.1]: https://github.com/zombiezen/tailscale-lb/releases/tag/v0.1.1 72 | 73 | ### Fixed 74 | 75 | - Fixed IPv4 DNS lookups 76 | ([#1](https://github.com/zombiezen/tailscale-lb/issues/1)). 77 | 78 | ## [0.1.0][] - 2022-09-19 79 | 80 | Initial release 81 | 82 | [0.1.0]: https://github.com/zombiezen/tailscale-lb/releases/tag/v0.1.0 83 | -------------------------------------------------------------------------------- /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 Load Balancer 2 | 3 | This project is a basic load-balancer for forwarding [Tailscale][] TCP traffic. 4 | This is useful for setting up [virtual IPs for services on Tailscale][]. 5 | 6 | [Tailscale]: https://tailscale.com/ 7 | [virtual IPs for services on Tailscale]: https://github.com/tailscale/tailscale/issues/465 8 | 9 | ## Status 10 | 11 | **This project is largely a proof-of-concept/prototype.** 12 | Having [virtual IPs for services on Tailscale][] has been discussed upstream 13 | and may land eventually, 14 | but I had an immediate need for this on my own Tailnet. 15 | 16 | I'm sharing the code for this in the interest of 17 | sharing the results of my experimentation, 18 | but I don't have a ton of time to spare for this particular project. 19 | Bugfixes welcome, but don't expect huge feature development or production-readiness. 20 | If you find this program useful, consider [sponsoring me][]! 21 | 22 | [sponsoring me]: https://github.com/sponsors/zombiezen 23 | 24 | ## Installation 25 | 26 | tailscale-lb is distributed as a [Docker][] image: 27 | 28 | ```shell 29 | docker pull ghcr.io/zombiezen/tailscale-lb 30 | ``` 31 | 32 | You can check out the available tags on [GitHub Container Registry][]. 33 | 34 | Alternatively, if you're using [Nix][], you can install the binary 35 | by checking out the repository and running the following: 36 | 37 | ```shell 38 | nix-env --file . --install 39 | ``` 40 | 41 | Or if you're using Nix flakes: 42 | 43 | ```shell 44 | nix profile install github:zombiezen/tailscale-lb 45 | ``` 46 | 47 | If you are deploying to Kubernetes, example manifests are provided in the `deploy` folder that can also be built with `kustomize`. Make sure to update the value of `TAILSCALE_AUTH_KEY` in secret.yaml to be an authentication key that you have generated from your [Tailscale Console][]. 48 | 49 | ```shell 50 | kubectl apply -k deploy 51 | ``` 52 | 53 | [Tailscale Console]: https://login.tailscale.com/admin/settings/keys 54 | [Docker]: https://www.docker.com/ 55 | [GitHub Container Registry]: https://github.com/zombiezen/tailscale-lb/pkgs/container/tailscale-lb 56 | [Nix]: https://nixos.org/ 57 | 58 | ## Usage 59 | 60 | Create a configuration file: 61 | 62 | ```ini 63 | # This is the hostname that will show up in the Tailscale console 64 | # and be used by MagicDNS. 65 | hostname = example 66 | # (Optional) Use an authentication key from https://login.tailscale.com/admin/settings/keys 67 | # If you don't provide an auth key, 68 | # tailscale-lb will log a URL to visit in your browser to authenticate it. 69 | auth-key = tskey-foo 70 | # (Optional) If given, the load balancer will be non-ephemeral 71 | # and persist state in the given directory. 72 | # If the path is relative, it resolved relative to 73 | # the directory the configuration file is located in. 74 | state-directory = /var/lib/tailscale-lb 75 | 76 | # (Optional) Specify the coordination server URL 77 | # control-url = https://headscale.example 78 | 79 | # For each port you want to listen on, 80 | # add a section like this: 81 | [tcp 22] 82 | # ... and then add one or more backends. 83 | # tailscale-lb will round-robin TCP connections 84 | # among the various IP addresses it discovers. 85 | # A backend can be one of: 86 | 87 | # a) An IPv4 address. If the port is omitted, then the section's port is used. 88 | backend = 127.0.0.1:22 89 | 90 | # b) An IPv6 address. If the port is omitted, then the section's port is used. 91 | backend = [2001:db8::1234]:22 92 | 93 | # c) A DNS name. If the port is omitted, then the section's port is used. 94 | backend = example.com:22 95 | 96 | # d) SRV records. The port is obtained from the SRV record. 97 | # Priority and weight are ignored. 98 | backend = srv _ssh._tcp.example.com 99 | 100 | # For each HTTP port you want to listen on, 101 | # add a section like this: 102 | [http 80] 103 | 104 | # Backends are specified the same as above. 105 | backend = 127.0.0.1:80 106 | 107 | # Add the following request headers (default true): 108 | # Tailscale-User: The connecting user's email address 109 | # Tailscale-Name: The connecting user's display name 110 | # Tailscale-Profile-Picture: A URL to the connecting user's profile picture 111 | whois = true 112 | # Use the MagicDNS HTTPS Certificates described in https://tailscale.com/kb/1153/enabling-https/ 113 | # (default false) 114 | tls = false 115 | # Whether to use the request-supplied X-Forwarded-For (default false). 116 | trust-x-forwarded-for = false 117 | ``` 118 | 119 | Then run tailscale-lb with the configuration file as its argument. 120 | If you're using Docker: 121 | 122 | ```shell 123 | docker run --rm \ 124 | --volume "$(pwd)/foo.ini":/etc/tailscale-lb.ini \ 125 | ghcr.io/zombiezen/tailscale-lb /etc/tailscale-lb.ini 126 | ``` 127 | 128 | Or if you're using a standalone binary: 129 | 130 | ```shell 131 | tailscale-lb foo.ini 132 | ``` 133 | 134 | You can then see the load balancer's IP address in the logs 135 | or in the Tailscale admin console. 136 | 137 | ## License 138 | 139 | [Apache 2.0](LICENSE) 140 | 141 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "net" 23 | "net/netip" 24 | "path/filepath" 25 | "strconv" 26 | "strings" 27 | "unicode" 28 | "unicode/utf8" 29 | 30 | "zombiezen.com/go/ini" 31 | "zombiezen.com/go/log" 32 | ) 33 | 34 | type configuration struct { 35 | hostname string 36 | authKey string 37 | controlURL string 38 | stateDir string 39 | ports map[uint16]portConfig 40 | } 41 | 42 | type portConfig struct { 43 | tcp *tcpConfig 44 | http *httpConfig 45 | } 46 | 47 | func (pc portConfig) isEmpty() bool { 48 | return pc.tcp == nil && pc.http == nil 49 | } 50 | 51 | type tcpConfig struct { 52 | backends []*backend 53 | } 54 | 55 | type httpConfig struct { 56 | backends []*backend 57 | whois bool 58 | trustXFF bool 59 | tls bool 60 | } 61 | 62 | func (cfg *configuration) fill(source configer) error { 63 | if cfg.hostname == "" { 64 | cfg.hostname = source.Get("", "hostname") 65 | } 66 | if cfg.authKey == "" { 67 | cfg.authKey = source.Get("", "auth-key") 68 | } 69 | if cfg.controlURL == "" { 70 | cfg.controlURL = source.Get("", "control-url") 71 | } 72 | if cfg.stateDir == "" { 73 | if v := source.Value("", "state-directory"); v != nil { 74 | if v.Filename == "" { 75 | return fmt.Errorf("configuration value for state-directory (line %d) has no file", v.Line) 76 | } 77 | if filepath.IsAbs(v.Value) { 78 | cfg.stateDir = v.Value 79 | } else { 80 | cfg.stateDir = filepath.Join(filepath.Dir(v.Filename), v.Value) 81 | } 82 | } 83 | } 84 | 85 | for sectionName := range source.Sections() { 86 | switch { 87 | case strings.HasPrefix(sectionName, "tcp "): 88 | n, err := strconv.ParseUint(sectionName[len("tcp "):], 10, 16) 89 | if err != nil { 90 | log.Warnf(context.TODO(), "Unknown config section %q", sectionName) 91 | continue 92 | } 93 | portNumber := uint16(n) 94 | if portNumber == 0 { 95 | return fmt.Errorf("read config: cannot configure port 0") 96 | } 97 | if cfg.ports == nil { 98 | cfg.ports = make(map[uint16]portConfig) 99 | } else if !cfg.ports[portNumber].isEmpty() { 100 | return fmt.Errorf("read config: conflicting definition of port %d", portNumber) 101 | } 102 | tc := new(tcpConfig) 103 | cfg.ports[portNumber] = portConfig{tcp: tc} 104 | 105 | for _, backendAddr := range source.Find(sectionName, "backend") { 106 | b, err := parseBackend(backendAddr, portNumber) 107 | if err != nil { 108 | return fmt.Errorf("read config: tcp %d: %v", portNumber, err) 109 | } 110 | tc.backends = append(tc.backends, b) 111 | } 112 | case strings.HasPrefix(sectionName, "http "): 113 | n, err := strconv.ParseUint(sectionName[len("http "):], 10, 16) 114 | if err != nil { 115 | log.Warnf(context.TODO(), "Unknown config section %q", sectionName) 116 | continue 117 | } 118 | portNumber := uint16(n) 119 | if portNumber == 0 { 120 | return fmt.Errorf("read config: cannot configure port 0") 121 | } 122 | if cfg.ports == nil { 123 | cfg.ports = make(map[uint16]portConfig) 124 | } else if !cfg.ports[portNumber].isEmpty() { 125 | return fmt.Errorf("read config: conflicting definition of port %d", portNumber) 126 | } 127 | hc := new(httpConfig) 128 | cfg.ports[portNumber] = portConfig{http: hc} 129 | 130 | if s := source.Get(sectionName, "tls"); s != "" { 131 | var err error 132 | hc.tls, err = strconv.ParseBool(s) 133 | if err != nil { 134 | return fmt.Errorf("read config: http %d: tls: %v", portNumber, err) 135 | } 136 | } 137 | if s := source.Get(sectionName, "whois"); s != "" { 138 | var err error 139 | hc.whois, err = strconv.ParseBool(s) 140 | if err != nil { 141 | return fmt.Errorf("read config: http %d: whois: %v", portNumber, err) 142 | } 143 | } 144 | if s := source.Get(sectionName, "trust-x-forwarded-for"); s != "" { 145 | var err error 146 | hc.trustXFF, err = strconv.ParseBool(s) 147 | if err != nil { 148 | return fmt.Errorf("read config: http %d: trust-x-forwarded-for: %v", portNumber, err) 149 | } 150 | } 151 | for _, backendAddr := range source.Find(sectionName, "backend") { 152 | b, err := parseBackend(backendAddr, portNumber) 153 | if err != nil { 154 | return fmt.Errorf("read config: http %d: %v", portNumber, err) 155 | } 156 | hc.backends = append(hc.backends, b) 157 | } 158 | default: 159 | if sectionName != "" { 160 | log.Warnf(context.TODO(), "Unknown config section %q", sectionName) 161 | } 162 | continue 163 | } 164 | } 165 | return nil 166 | } 167 | 168 | type backend struct { 169 | addr netip.Addr 170 | hostname string 171 | port uint16 172 | srv bool 173 | } 174 | 175 | func parseBackend(s string, implicitPort uint16) (*backend, error) { 176 | const srvPrefix = "srv" 177 | if len(s) >= len(srvPrefix)+1 && s[:len(srvPrefix)] == srvPrefix { 178 | if c, size := utf8.DecodeRuneInString(s[len(srvPrefix):]); unicode.IsSpace(c) { 179 | return &backend{ 180 | hostname: strings.TrimLeftFunc(s[len(srvPrefix)+size:], unicode.IsSpace), 181 | srv: true, 182 | }, nil 183 | } 184 | } 185 | 186 | b := new(backend) 187 | host, portString, err := net.SplitHostPort(s) 188 | if err != nil { 189 | host = s 190 | b.port = implicitPort 191 | } else { 192 | port, err := strconv.ParseUint(portString, 10, 16) 193 | if err != nil { 194 | return nil, fmt.Errorf("parse backend %q: invalid port", s) 195 | } 196 | b.port = uint16(port) 197 | } 198 | if addr, err := netip.ParseAddr(host); err == nil { 199 | b.addr = addr 200 | } else { 201 | b.hostname = host 202 | } 203 | return b, nil 204 | } 205 | 206 | func (b *backend) String() string { 207 | if b.srv { 208 | return "srv " + b.hostname 209 | } 210 | host := b.hostname 211 | if b.addr.IsValid() { 212 | host = b.addr.String() 213 | } 214 | return net.JoinHostPort(host, strconv.Itoa(int(b.port))) 215 | } 216 | 217 | type configer interface { 218 | Get(section, key string) string 219 | Value(section, key string) *ini.Value 220 | Find(section, key string) []string 221 | Sections() map[string]struct{} 222 | } 223 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package main 18 | 19 | import ( 20 | "net/netip" 21 | "testing" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | ) 25 | 26 | func TestParseBackend(t *testing.T) { 27 | tests := []struct { 28 | s string 29 | implicitPort uint16 30 | want *backend 31 | }{ 32 | {"127.0.0.1", 80, &backend{ 33 | addr: netip.MustParseAddr("127.0.0.1"), 34 | port: 80, 35 | }}, 36 | {"127.0.0.1:8080", 80, &backend{ 37 | addr: netip.MustParseAddr("127.0.0.1"), 38 | port: 8080, 39 | }}, 40 | {"example.com", 80, &backend{ 41 | hostname: "example.com", 42 | port: 80, 43 | }}, 44 | {"example.com:8080", 80, &backend{ 45 | hostname: "example.com", 46 | port: 8080, 47 | }}, 48 | {"srv example.com", 80, &backend{ 49 | hostname: "example.com", 50 | srv: true, 51 | }}, 52 | {"srv example.com", 80, &backend{ 53 | hostname: "example.com", 54 | srv: true, 55 | }}, 56 | {"srv.example.com", 80, &backend{ 57 | hostname: "srv.example.com", 58 | port: 80, 59 | }}, 60 | } 61 | for _, test := range tests { 62 | got, err := parseBackend(test.s, test.implicitPort) 63 | if err != nil { 64 | t.Errorf("parseBackend(%q, %d) = _, %v; want %#v", test.s, test.implicitPort, got, test.want) 65 | continue 66 | } 67 | diff := cmp.Diff( 68 | test.want, got, 69 | cmp.AllowUnexported(backend{}), 70 | cmp.Comparer(func(a1, a2 netip.Addr) bool { return a1 == a2 }), 71 | ) 72 | if diff != "" { 73 | t.Errorf("parseBackend(%q, %d) (-want +got):\n%s", test.s, test.implicitPort, diff) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Ross Light 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | let 18 | lock = builtins.fromJSON (builtins.readFile ./flake.lock); 19 | flakeCompatSource = fetchTarball { 20 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 21 | sha256 = lock.nodes.flake-compat.locked.narHash; 22 | }; 23 | flakeCompat = import flakeCompatSource { src = ./.; }; 24 | in 25 | flakeCompat.defaultNix 26 | -------------------------------------------------------------------------------- /deploy/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | data: 3 | tailscale-lb.ini.tpl: | 4 | # This is the hostname that will show up in the Tailscale console 5 | # and be used by MagicDNS. 6 | hostname = tailscale-lb 7 | 8 | # (Optional) Use an authentication key from https://login.tailscale.com/admin/settings/keys 9 | auth-key = ${TAILSCALE_AUTH_KEY} 10 | 11 | [tcp 443] 12 | backend = example.com 13 | kind: ConfigMap 14 | metadata: 15 | labels: 16 | app.kubernetes.io/name: tailscale-lb 17 | app.kubernetes.io/instance: tailscale-lb 18 | name: tailscale-lb 19 | namespace: tailscale-lb 20 | -------------------------------------------------------------------------------- /deploy/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: tailscale-lb 6 | app.kubernetes.io/instance: tailscale-lb 7 | name: tailscale-lb 8 | namespace: tailscale-lb 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app.kubernetes.io/name: tailscale-lb 14 | app.kubernetes.io/instance: tailscale-lb 15 | strategy: 16 | rollingUpdate: 17 | maxSurge: 100% 18 | maxUnavailable: 0% 19 | type: RollingUpdate 20 | template: 21 | metadata: 22 | labels: 23 | app.kubernetes.io/name: tailscale-lb 24 | app.kubernetes.io/instance: tailscale-lb 25 | spec: 26 | containers: 27 | - image: ghcr.io/zombiezen/tailscale-lb:latest 28 | imagePullPolicy: IfNotPresent 29 | name: tailscale-lb 30 | args: 31 | - /etc/tailscale/tailscale-lb.ini 32 | resources: {} 33 | volumeMounts: 34 | - name: config 35 | mountPath: /etc/tailscale 36 | initContainers: 37 | - image: alpine:latest 38 | imagePullPolicy: IfNotPresent 39 | name: config 40 | command: 41 | - /bin/sh 42 | - -c 43 | - | 44 | apk -q update && apk -q add gettext 45 | envsubst < /tmp/config-template/tailscale-lb.ini.tpl | cat > /tmp/config/tailscale-lb.ini 46 | envFrom: 47 | - secretRef: 48 | name: tailscale-lb 49 | volumeMounts: 50 | - name: config 51 | mountPath: /tmp/config 52 | - name: config-template 53 | mountPath: /tmp/config-template 54 | volumes: 55 | - name: config 56 | emptyDir: {} 57 | - name: config-template 58 | configMap: 59 | name: tailscale-lb 60 | restartPolicy: Always 61 | -------------------------------------------------------------------------------- /deploy/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - configmap.yaml 5 | - deployment.yaml 6 | - namespace.yaml 7 | - secret.yaml 8 | - serviceaccount.yaml 9 | -------------------------------------------------------------------------------- /deploy/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | kubernetes.io/metadata.name: tailscale-lb 6 | name: tailscale-lb 7 | name: tailscale-lb 8 | -------------------------------------------------------------------------------- /deploy/secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | data: 3 | TAILSCALE_AUTH_KEY: Q0hBTkdFTUUK 4 | kind: Secret 5 | metadata: 6 | labels: 7 | app.kubernetes.io/name: tailscale-lb 8 | app.kubernetes.io/instance: tailscale-lb 9 | name: tailscale-lb 10 | namespace: tailscale-lb 11 | type: Opaque 12 | -------------------------------------------------------------------------------- /deploy/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: default 5 | namespace: tailscale-lb 6 | automountServiceAccountToken: false 7 | -------------------------------------------------------------------------------- /deque/deque.go: -------------------------------------------------------------------------------- 1 | // Package deque provides a double-ended queue. 2 | package deque 3 | 4 | import ( 5 | "fmt" 6 | "math/bits" 7 | ) 8 | 9 | const initialCapacity = 8 10 | 11 | // Deque is a double-ended queue. 12 | // The zero value is an empty deque. 13 | type Deque[T any] struct { 14 | array []T 15 | front int 16 | back int // exclusive 17 | } 18 | 19 | // Len returns the number of elements in the deque. 20 | func (d *Deque[T]) Len() int { 21 | return count(d.front, d.back, len(d.array)) 22 | } 23 | 24 | func (d *Deque[T]) isFull() bool { 25 | return len(d.array)-d.Len() <= 1 26 | } 27 | 28 | // At returns the element at the given index, 29 | // with 0 being the front of the queue. 30 | // Panics if i is negative or greater than or equal to d.Len(). 31 | func (d *Deque[T]) At(i int) T { 32 | if n := d.Len(); i < 0 || i >= n { 33 | panic(fmt.Errorf("deque index %d out of range %d", i, n)) 34 | } 35 | return d.array[wrapIndex(d.front+i, len(d.array))] 36 | } 37 | 38 | // Front returns the element at the front of the queue. 39 | func (d *Deque[T]) Front() (_ T, ok bool) { 40 | var x T 41 | if d.front == d.back { 42 | return x, false 43 | } 44 | x = d.array[d.front] 45 | return x, true 46 | } 47 | 48 | // PopFront removes the element at the front of the queue and returns it. 49 | func (d *Deque[T]) PopFront() (_ T, ok bool) { 50 | x, ok := d.Front() 51 | if ok { 52 | var zero T 53 | d.array[d.front] = zero 54 | d.front = wrapIndex(d.front+1, len(d.array)) 55 | } 56 | return x, ok 57 | } 58 | 59 | // Append inserts an element at the back of the queue. 60 | func (d *Deque[T]) Append(x T) { 61 | if d.isFull() { 62 | d.grow() 63 | } 64 | d.array[d.back] = x 65 | d.back = wrapIndex(d.back+1, len(d.array)) 66 | } 67 | 68 | func (d *Deque[T]) Filter(pred func(T) bool) { 69 | if d.front == d.back { 70 | return 71 | } 72 | oldLen := d.Len() 73 | n := 0 74 | for i, end := d.front, d.front+oldLen; i < end; i++ { 75 | x := d.array[wrapIndex(i, len(d.array))] 76 | if pred(x) { 77 | d.array[wrapIndex(d.front+n, len(d.array))] = x 78 | n++ 79 | } 80 | } 81 | clearStart := wrapIndex(d.front+n, len(d.array)) 82 | if clearStart <= d.back { 83 | clear(d.array[clearStart:d.back]) 84 | } else { 85 | clear(d.array[clearStart:]) 86 | clear(d.array[:d.back]) 87 | } 88 | d.back = wrapIndex(d.back+n-oldLen, len(d.array)) 89 | } 90 | 91 | // Rotate rotates the deque n places to the left, 92 | // such that the n'th item will be at the front of the deque. 93 | // Negative values rotate the deque to the right. 94 | func (d *Deque[T]) Rotate(n int) { 95 | if d.front == d.back { 96 | return 97 | } 98 | len := d.Len() 99 | n = wrapIndex(n, len) 100 | if n == 0 { 101 | return 102 | } 103 | k := len - n 104 | if n <= k { 105 | d.rotateLeft(n) 106 | } else { 107 | d.rotateRight(k) 108 | } 109 | } 110 | 111 | func (d *Deque[T]) rotateLeft(mid int) { 112 | wrapCopy(d.array, d.back, d.front, mid) 113 | d.front = wrapIndex(d.front+mid, len(d.array)) 114 | d.back = wrapIndex(d.back+mid, len(d.array)) 115 | } 116 | 117 | func (d *Deque[T]) rotateRight(k int) { 118 | d.front = wrapIndex(d.front-k, len(d.array)) 119 | d.back = wrapIndex(d.back-k, len(d.array)) 120 | wrapCopy(d.array, d.front, d.back, k) 121 | } 122 | 123 | // wrapCopy copies a potentially wrapping block of memory n long from src to dst. 124 | // abs(dst - src) + n must be no larger than len(array) 125 | // (i.e. there must be at most one continous overlapping region between src and dst). 126 | func wrapCopy[T any](array []T, dst, src, n int) { 127 | if src == dst || n == 0 { 128 | return 129 | } 130 | dstAfterSrc := wrapIndex(dst-src, len(array)) < n 131 | srcPreWrapLen := len(array) - src 132 | dstPreWrapLen := len(array) - dst 133 | srcWraps := srcPreWrapLen < n 134 | dstWraps := dstPreWrapLen < n 135 | 136 | switch { 137 | case !srcWraps && !dstWraps: 138 | copy(array[dst:], array[src:src+n]) 139 | case !dstAfterSrc && !srcWraps && dstWraps: 140 | copy(array[dst:], array[src:src+dstPreWrapLen]) 141 | copy(array, array[src+dstPreWrapLen:src+n]) 142 | case dstAfterSrc && !srcWraps && dstWraps: 143 | copy(array, array[src+dstPreWrapLen:src+n]) 144 | copy(array[dst:], array[src:src+dstPreWrapLen]) 145 | case !dstAfterSrc && srcWraps && !dstWraps: 146 | copy(array[dst:], array[src:src+srcPreWrapLen]) 147 | copy(array[dst+srcPreWrapLen:], array[:n-srcPreWrapLen]) 148 | case dstAfterSrc && srcWraps && !dstWraps: 149 | copy(array[dst+srcPreWrapLen:], array[:n-srcPreWrapLen]) 150 | copy(array[dst:], array[src:src+srcPreWrapLen]) 151 | case !dstAfterSrc && srcWraps && dstWraps: 152 | delta := dstPreWrapLen - srcPreWrapLen 153 | copy(array[dst:], array[src:src+srcPreWrapLen]) 154 | copy(array[dst+srcPreWrapLen:], array[:delta]) 155 | copy(array, array[delta:delta+n-dstPreWrapLen]) 156 | default: 157 | delta := srcPreWrapLen - dstPreWrapLen 158 | copy(array[delta:], array[:n-srcPreWrapLen]) 159 | copy(array, array[len(array)-delta:]) 160 | copy(array[dst:], array[src:src+dstPreWrapLen]) 161 | } 162 | } 163 | 164 | func (d *Deque[T]) grow() { 165 | var newCap int 166 | if len(d.array) < initialCapacity { 167 | newCap = initialCapacity 168 | } else { 169 | newCap = len(d.array) * 2 170 | } 171 | newArray := make([]T, newCap) 172 | oldLen := d.Len() 173 | if d.front <= d.back { 174 | copy(newArray, d.array[d.front:d.back]) 175 | } else { 176 | split := copy(newArray, d.array[d.front:]) 177 | copy(newArray[split:], d.array[:d.back]) 178 | } 179 | d.array = newArray 180 | d.front = 0 181 | d.back = oldLen 182 | } 183 | 184 | func clear[T any](s []T) { 185 | var zero T 186 | for i := range s { 187 | s[i] = zero 188 | } 189 | } 190 | 191 | func count(front, back, size int) int { 192 | return int((uint(back) - uint(front)) & uint(size-1)) 193 | } 194 | 195 | func wrapIndex(i, size int) int { 196 | if !isPowerOfTwo(size) { 197 | for i < 0 { 198 | i += size 199 | } 200 | return i % size 201 | } 202 | return i & (size - 1) 203 | } 204 | 205 | func isPowerOfTwo(n int) bool { 206 | return n > 0 && bits.OnesCount(uint(n)) == 1 207 | } 208 | -------------------------------------------------------------------------------- /deque/deque_test.go: -------------------------------------------------------------------------------- 1 | package deque 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/google/go-cmp/cmp/cmpopts" 8 | ) 9 | 10 | func TestZero(t *testing.T) { 11 | d := new(Deque[int]) 12 | if got := d.Len(); got != 0 { 13 | t.Errorf("d.Len() = %d; want 0", got) 14 | } 15 | if got, ok := d.Front(); ok { 16 | t.Errorf("d.Front() = %v, true; want _, false", got) 17 | } 18 | if got, ok := d.PopFront(); ok { 19 | t.Errorf("d.PopFront() = %v, true; want _, false", got) 20 | } 21 | } 22 | 23 | func TestAtPanic(t *testing.T) { 24 | t.Run("Negative", func(t *testing.T) { 25 | d := new(Deque[int]) 26 | d.Append(1) 27 | 28 | defer func() { 29 | if err := recover(); err == nil { 30 | t.Error("At did not panic") 31 | } 32 | }() 33 | d.At(-1) 34 | }) 35 | 36 | t.Run("PastLen", func(t *testing.T) { 37 | d := new(Deque[int]) 38 | d.Append(1) 39 | 40 | defer func() { 41 | if err := recover(); err == nil { 42 | t.Error("At did not panic") 43 | } 44 | }() 45 | d.At(1) 46 | }) 47 | } 48 | 49 | func TestAppend(t *testing.T) { 50 | d := new(Deque[int]) 51 | 52 | d.Append(10) 53 | if diff := cmp.Diff([]int{10}, toSlice(d)); diff != "" { 54 | t.Errorf("after first append (-want +got):\n%s", diff) 55 | } 56 | if got, ok := d.Front(); !ok || got != 10 { 57 | t.Errorf("d.Front() = %d, %t; want 10, true", got, ok) 58 | } 59 | 60 | d.Append(11) 61 | if diff := cmp.Diff([]int{10, 11}, toSlice(d)); diff != "" { 62 | t.Errorf("after second append (-want +got):\n%s", diff) 63 | } 64 | if got, ok := d.Front(); !ok || got != 10 { 65 | t.Errorf("d.Front() = %d, %t; want 10, true", got, ok) 66 | } 67 | 68 | d.Append(12) 69 | if diff := cmp.Diff([]int{10, 11, 12}, toSlice(d)); diff != "" { 70 | t.Errorf("after third append (-want +got):\n%s", diff) 71 | } 72 | if got, ok := d.Front(); !ok || got != 10 { 73 | t.Errorf("d.Front() = %d, %t; want 10, true", got, ok) 74 | } 75 | } 76 | 77 | func TestPopFront(t *testing.T) { 78 | d := new(Deque[int]) 79 | d.Append(10) 80 | d.Append(11) 81 | d.Append(12) 82 | 83 | if got, ok := d.PopFront(); !ok || got != 10 { 84 | t.Errorf("first d.PopFront() = %d, %t; want 10, true", got, ok) 85 | } 86 | if got, want := d.Len(), 2; got != want { 87 | t.Errorf("after first pop, d.Len() = %d; want %d", got, want) 88 | } 89 | 90 | if got, ok := d.PopFront(); !ok || got != 11 { 91 | t.Errorf("second d.PopFront() = %d, %t; want 11, true", got, ok) 92 | } 93 | if got, want := d.Len(), 1; got != want { 94 | t.Errorf("after second pop, d.Len() = %d; want %d", got, want) 95 | } 96 | 97 | if got, ok := d.PopFront(); !ok || got != 12 { 98 | t.Errorf("third d.PopFront() = %d, %t; want 12, true", got, ok) 99 | } 100 | if got, want := d.Len(), 0; got != want { 101 | t.Errorf("after third pop, d.Len() = %d; want %d", got, want) 102 | } 103 | 104 | if got, ok := d.PopFront(); ok { 105 | t.Errorf("fourth d.PopFront() = %d, %t; want _, false", got, ok) 106 | } 107 | if got, want := d.Len(), 0; got != want { 108 | t.Errorf("after fourth pop, d.Len() = %d; want %d", got, want) 109 | } 110 | } 111 | 112 | func TestGrowContiguous(t *testing.T) { 113 | d := new(Deque[int]) 114 | counter := 0 115 | for i := 0; i < initialCapacity; i++ { 116 | d.Append(counter) 117 | counter++ 118 | } 119 | if len(d.array) <= initialCapacity { 120 | t.Errorf("len(d.array) = %d; want >%d", len(d.array), initialCapacity) 121 | } 122 | if diff := cmp.Diff(seq(0, initialCapacity), toSlice(d), cmpopts.EquateEmpty()); diff != "" { 123 | t.Errorf("after growing (-want +got):\n%s", diff) 124 | } 125 | } 126 | 127 | func TestGrowSplit(t *testing.T) { 128 | d := new(Deque[int]) 129 | counter := 0 130 | for i := 0; i < initialCapacity-1; i++ { 131 | d.Append(counter) 132 | counter++ 133 | } 134 | for i := 0; i < 3; i++ { 135 | d.PopFront() 136 | } 137 | for i := 0; i < 3; i++ { 138 | d.Append(counter) 139 | counter++ 140 | } 141 | if diff := cmp.Diff(seq(3, initialCapacity+2), toSlice(d), cmpopts.EquateEmpty()); diff != "" { 142 | t.Errorf("before growing (-want +got):\n%s", diff) 143 | } 144 | 145 | for i := 0; i < 3; i++ { 146 | d.Append(counter) 147 | counter++ 148 | } 149 | if diff := cmp.Diff(seq(3, initialCapacity+5), toSlice(d), cmpopts.EquateEmpty()); diff != "" { 150 | t.Errorf("after growing (-want +got):\n%s", diff) 151 | } 152 | } 153 | 154 | func TestFilter(t *testing.T) { 155 | predicates := map[string]func(int) bool{ 156 | "isEven": func(i int) bool { return i%2 == 0 }, 157 | "lessThanThree": func(i int) bool { return i < 3 }, 158 | "isZero": func(i int) bool { return i == 0 }, 159 | } 160 | tests := []struct { 161 | offset int 162 | init []int 163 | pred string 164 | want []int 165 | }{ 166 | { 167 | pred: "isEven", 168 | want: []int{}, 169 | }, 170 | { 171 | init: seq(0, 10), 172 | pred: "isEven", 173 | want: []int{0, 2, 4, 6, 8}, 174 | }, 175 | { 176 | init: seq(0, 10), 177 | pred: "lessThanThree", 178 | want: []int{0, 1, 2}, 179 | }, 180 | { 181 | offset: initialCapacity - 2, 182 | init: seq(0, initialCapacity-3), 183 | pred: "lessThanThree", 184 | want: []int{0, 1, 2}, 185 | }, 186 | { 187 | offset: initialCapacity - 3, 188 | init: seq(0, initialCapacity-3), 189 | pred: "isZero", 190 | want: []int{0}, 191 | }, 192 | } 193 | for _, test := range tests { 194 | d := new(Deque[int]) 195 | if test.offset != 0 { 196 | d.Append(0) 197 | for i := 0; i < test.offset; i++ { 198 | d.Append(0) 199 | d.PopFront() 200 | } 201 | } 202 | for _, x := range test.init { 203 | d.Append(x) 204 | } 205 | if test.offset != 0 { 206 | d.PopFront() 207 | } 208 | 209 | d.Filter(predicates[test.pred]) 210 | if diff := cmp.Diff(test.want, toSlice(d), cmpopts.EquateEmpty()); diff != "" { 211 | t.Errorf(".Filter(%s) (-want +got):\n%s", test.init, test.offset, test.pred, diff) 212 | } 213 | } 214 | } 215 | 216 | func TestRotate(t *testing.T) { 217 | tests := []struct { 218 | init []int 219 | rotate int 220 | want []int 221 | }{ 222 | { 223 | rotate: 0, 224 | want: []int{}, 225 | }, 226 | { 227 | rotate: 1, 228 | want: []int{}, 229 | }, 230 | { 231 | rotate: -1, 232 | want: []int{}, 233 | }, 234 | { 235 | init: seq(0, 10), 236 | rotate: 0, 237 | want: seq(0, 10), 238 | }, 239 | { 240 | init: seq(0, 10), 241 | rotate: 10, 242 | want: seq(0, 10), 243 | }, 244 | { 245 | init: seq(0, 10), 246 | rotate: 3, 247 | want: append(seq(3, 10), seq(0, 3)...), 248 | }, 249 | { 250 | init: seq(0, 10), 251 | rotate: -7, 252 | want: append(seq(3, 10), seq(0, 3)...), 253 | }, 254 | { 255 | init: seq(0, 10), 256 | rotate: -3, 257 | want: append(seq(7, 10), seq(0, 7)...), 258 | }, 259 | { 260 | init: seq(0, 10), 261 | rotate: 7, 262 | want: append(seq(7, 10), seq(0, 7)...), 263 | }, 264 | } 265 | for _, test := range tests { 266 | d := new(Deque[int]) 267 | for _, x := range test.init { 268 | d.Append(x) 269 | } 270 | d.Rotate(test.rotate) 271 | got := toSlice(d) 272 | if !cmp.Equal(test.want, got, cmpopts.EquateEmpty()) { 273 | t.Errorf("Deque%v.Rotate(%d) = %v; want %v", test.init, test.rotate, got, test.want) 274 | } 275 | } 276 | } 277 | 278 | func TestWrapCopy(t *testing.T) { 279 | tests := []struct { 280 | array []int 281 | dst int 282 | src int 283 | n int 284 | want []int 285 | }{ 286 | { 287 | array: seq(0, 5), 288 | src: 0, 289 | dst: 0, 290 | n: 0, 291 | want: seq(0, 5), 292 | }, 293 | { 294 | array: seq(0, 9), 295 | src: 2, 296 | dst: 4, 297 | n: 4, 298 | want: []int{0, 1, 2, 3, 2, 3, 4, 5, 8}, 299 | }, 300 | { 301 | array: seq(0, 9), 302 | src: 0, 303 | dst: 7, 304 | n: 4, 305 | want: []int{2, 3, 2, 3, 4, 5, 6, 0, 1}, 306 | }, 307 | { 308 | array: seq(0, 9), 309 | src: 5, 310 | dst: 7, 311 | n: 4, 312 | want: []int{7, 8, 2, 3, 4, 5, 6, 5, 6}, 313 | }, 314 | { 315 | array: seq(0, 9), 316 | src: 7, 317 | dst: 5, 318 | n: 4, 319 | want: []int{0, 1, 2, 3, 4, 7, 8, 0, 1}, 320 | }, 321 | { 322 | array: seq(0, 9), 323 | src: 7, 324 | dst: 0, 325 | n: 4, 326 | want: []int{7, 8, 0, 1, 4, 5, 6, 7, 8}, 327 | }, 328 | { 329 | array: seq(0, 9), 330 | src: 7, 331 | dst: 6, 332 | n: 5, 333 | want: []int{1, 2, 2, 3, 4, 5, 7, 8, 0}, 334 | }, 335 | { 336 | array: seq(0, 9), 337 | src: 6, 338 | dst: 7, 339 | n: 5, 340 | want: []int{8, 0, 1, 3, 4, 5, 6, 6, 7}, 341 | }, 342 | } 343 | for _, test := range tests { 344 | array := append([]int(nil), test.array...) 345 | wrapCopy(array, test.dst, test.src, test.n) 346 | if !cmp.Equal(test.want, array) { 347 | t.Errorf("wrapCopy(%v, %d, %d, %d) = %v; want %v", test.array, test.dst, test.src, test.n, array, test.want) 348 | } 349 | } 350 | } 351 | 352 | func TestCount(t *testing.T) { 353 | tests := []struct { 354 | front int 355 | back int 356 | size int 357 | want int 358 | }{ 359 | {0, 0, 8, 0}, 360 | {0, 1, 8, 1}, 361 | {7, 0, 8, 1}, 362 | } 363 | for _, test := range tests { 364 | if got := count(test.front, test.back, test.size); got != test.want { 365 | t.Errorf("count(%d, %d, %d) = %d; want %d", test.front, test.back, test.size, got, test.want) 366 | } 367 | } 368 | } 369 | 370 | func TestWrapIndex(t *testing.T) { 371 | tests := []struct { 372 | i int 373 | size int 374 | want int 375 | }{ 376 | {-4, 4, 0}, 377 | {-3, 4, 1}, 378 | {-2, 4, 2}, 379 | {-1, 4, 3}, 380 | {0, 4, 0}, 381 | {1, 4, 1}, 382 | {2, 4, 2}, 383 | {3, 4, 3}, 384 | {4, 4, 0}, 385 | {5, 4, 1}, 386 | {6, 4, 2}, 387 | {7, 4, 3}, 388 | 389 | // Non-power-of-two 390 | {-5, 5, 0}, 391 | {-4, 5, 1}, 392 | {-3, 5, 2}, 393 | {-2, 5, 3}, 394 | {-1, 5, 4}, 395 | {0, 5, 0}, 396 | {1, 5, 1}, 397 | {2, 5, 2}, 398 | {3, 5, 3}, 399 | {4, 5, 4}, 400 | {5, 5, 0}, 401 | {6, 5, 1}, 402 | {7, 5, 2}, 403 | {8, 5, 3}, 404 | {9, 5, 4}, 405 | {10, 5, 0}, 406 | } 407 | for _, test := range tests { 408 | if got := wrapIndex(test.i, test.size); got != test.want { 409 | t.Errorf("wrapIndex(%d, %d) = %d; want %d", test.i, test.size, got, test.want) 410 | } 411 | } 412 | } 413 | 414 | func TestIsPowerOfTwo(t *testing.T) { 415 | tests := []struct { 416 | n int 417 | want bool 418 | }{ 419 | {0, false}, 420 | {1, true}, 421 | {2, true}, 422 | {3, false}, 423 | {4, true}, 424 | {5, false}, 425 | {6, false}, 426 | {7, false}, 427 | {8, true}, 428 | } 429 | 430 | for _, test := range tests { 431 | if got := isPowerOfTwo(test.n); got != test.want { 432 | t.Errorf("isPowerOfTwo(%d) = %t; want %t", test.n, got, test.want) 433 | } 434 | } 435 | } 436 | 437 | func toSlice[T any](d *Deque[T]) []T { 438 | s := make([]T, d.Len()) 439 | for i := range s { 440 | s[i] = d.At(i) 441 | } 442 | return s 443 | } 444 | 445 | func seq(start, end int) []int { 446 | s := make([]int, end-start) 447 | for i := range s { 448 | s[i] = start + i 449 | } 450 | return s 451 | } 452 | 453 | func TestSeq(t *testing.T) { 454 | tests := []struct { 455 | start int 456 | end int 457 | want []int 458 | }{ 459 | {0, 0, []int{}}, 460 | {0, 5, []int{0, 1, 2, 3, 4}}, 461 | {5, 10, []int{5, 6, 7, 8, 9}}, 462 | } 463 | for _, test := range tests { 464 | got := seq(test.start, test.end) 465 | if diff := cmp.Diff(test.want, got); diff != "" { 466 | t.Errorf("seq(%d, %d) (-want +got):\n%s", test.start, test.end, diff) 467 | } 468 | } 469 | } 470 | -------------------------------------------------------------------------------- /docker.nix: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Ross Light 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | { name 18 | , tag 19 | , rev ? null 20 | , system ? builtins.currentSystem 21 | }: 22 | 23 | let 24 | flake = builtins.getFlake ("git+file:${builtins.toString ./.}" + (if !(builtins.isNull rev) then "?rev=${rev}&shallow=1" else "")); 25 | in 26 | flake.lib.mkDocker { 27 | pkgs = flake.lib.nixpkgs system; 28 | inherit name tag; 29 | } 30 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1696426674, 7 | "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 8 | "owner": "edolstra", 9 | "repo": "flake-compat", 10 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "edolstra", 15 | "repo": "flake-compat", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-utils": { 20 | "inputs": { 21 | "systems": "systems" 22 | }, 23 | "locked": { 24 | "lastModified": 1709126324, 25 | "narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=", 26 | "owner": "numtide", 27 | "repo": "flake-utils", 28 | "rev": "d465f4819400de7c8d874d50b982301f28a84605", 29 | "type": "github" 30 | }, 31 | "original": { 32 | "id": "flake-utils", 33 | "type": "indirect" 34 | } 35 | }, 36 | "nixpkgs": { 37 | "locked": { 38 | "lastModified": 1709386671, 39 | "narHash": "sha256-VPqfBnIJ+cfa78pd4Y5Cr6sOWVW8GYHRVucxJGmRf8Q=", 40 | "owner": "NixOS", 41 | "repo": "nixpkgs", 42 | "rev": "fa9a51752f1b5de583ad5213eb621be071806663", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "id": "nixpkgs", 47 | "type": "indirect" 48 | } 49 | }, 50 | "root": { 51 | "inputs": { 52 | "flake-compat": "flake-compat", 53 | "flake-utils": "flake-utils", 54 | "nixpkgs": "nixpkgs" 55 | } 56 | }, 57 | "systems": { 58 | "locked": { 59 | "lastModified": 1681028828, 60 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 61 | "owner": "nix-systems", 62 | "repo": "default", 63 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "nix-systems", 68 | "repo": "default", 69 | "type": "github" 70 | } 71 | } 72 | }, 73 | "root": "root", 74 | "version": 7 75 | } 76 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Ross Light 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | { 18 | description = "A basic load-balancer for forwarding Tailscale TCP traffic"; 19 | 20 | inputs = { 21 | nixpkgs.url = "nixpkgs"; 22 | flake-utils.url = "flake-utils"; 23 | flake-compat = { 24 | url = "github:edolstra/flake-compat"; 25 | flake = false; 26 | }; 27 | }; 28 | 29 | outputs = { self, nixpkgs, flake-utils, ... }: 30 | let 31 | supportedSystems = [ 32 | flake-utils.lib.system.x86_64-linux 33 | flake-utils.lib.system.aarch64-linux 34 | flake-utils.lib.system.x86_64-darwin 35 | flake-utils.lib.system.aarch64-darwin 36 | ]; 37 | in 38 | flake-utils.lib.eachSystem supportedSystems (system: 39 | let 40 | pkgs = self.lib.nixpkgs system; 41 | terraform = pkgs.terraform_1.withPlugins (p: [ 42 | p.github 43 | p.google 44 | p.time 45 | ]); 46 | in 47 | { 48 | packages = { 49 | default = self.lib.mkTailscaleLB pkgs; 50 | 51 | ci = pkgs.linkFarm "tailscale-lb-ci" ([ 52 | { name = "tailscale-lb"; path = self.packages.${system}.default; } 53 | ] ++ pkgs.lib.lists.optional (self.packages.${system} ? docker-amd64) { 54 | name = "docker-image-tailscale-lb-amd64.tar.gz"; 55 | path = self.packages.${system}.docker-amd64; 56 | } ++ pkgs.lib.lists.optional (self.packages.${system} ? docker-arm64) { 57 | name = "docker-image-tailscale-lb-arm64.tar.gz"; 58 | path = self.packages.${system}.docker-arm64; 59 | }); 60 | } // pkgs.lib.optionalAttrs pkgs.hostPlatform.isLinux { 61 | docker-amd64 = self.lib.mkDocker { 62 | pkgs = if pkgs.targetPlatform.isx86_64 then pkgs else pkgs.pkgsCross.musl64; 63 | }; 64 | docker-arm64 = self.lib.mkDocker { 65 | pkgs = if pkgs.targetPlatform.isAarch64 then pkgs else pkgs.pkgsCross.aarch64-multiplatform-musl; 66 | }; 67 | }; 68 | 69 | apps.default = { 70 | type = "app"; 71 | program = "${self.packages.${system}.default}/bin/tailscale-lb"; 72 | }; 73 | 74 | devShells.default = pkgs.mkShell { 75 | inputsFrom = [ 76 | self.packages.${system}.default 77 | ]; 78 | }; 79 | 80 | devShells.infra = pkgs.mkShell { 81 | packages = [ 82 | terraform 83 | ]; 84 | }; 85 | }) // { 86 | lib = { 87 | nixpkgs = system: import nixpkgs { 88 | inherit system; 89 | config.allowUnfreePredicate = pkg: builtins.elem (nixpkgs.lib.strings.getName pkg) [ 90 | "terraform" 91 | ]; 92 | }; 93 | 94 | mkTailscaleLB = pkgs: pkgs.callPackage ./tailscale-lb.nix { 95 | buildGoModule = pkgs.buildGo122Module; 96 | }; 97 | 98 | mkDocker = 99 | { pkgs 100 | , name ? "ghcr.io/zombiezen/tailscale-lb" 101 | , tag ? null 102 | }: 103 | let 104 | tailscale-lb = self.lib.mkTailscaleLB pkgs; 105 | in 106 | pkgs.dockerTools.buildImage { 107 | inherit name; 108 | tag = if builtins.isNull tag then tailscale-lb.version else tag; 109 | 110 | copyToRoot = pkgs.buildEnv { 111 | name = "tailscale-lb"; 112 | paths = [ 113 | tailscale-lb 114 | pkgs.cacert 115 | ]; 116 | }; 117 | 118 | config = { 119 | Entrypoint = [ "/bin/tailscale-lb" ]; 120 | 121 | Labels = { 122 | "org.opencontainers.image.source" = "https://github.com/zombiezen/tailscale-lb"; 123 | "org.opencontainers.image.licenses" = "Apache-2.0"; 124 | "org.opencontainers.image.version" = tailscale-lb.version; 125 | }; 126 | }; 127 | }; 128 | }; 129 | }; 130 | } 131 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module zombiezen.com/go/tailscale-lb 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/google/go-cmp v0.6.0 7 | golang.org/x/sync v0.6.0 8 | golang.org/x/sys v0.18.0 9 | tailscale.com v1.60.1 10 | zombiezen.com/go/ini v0.0.0-20220922030607-23a6472a8275 11 | zombiezen.com/go/log v1.1.0-beta1 12 | zombiezen.com/go/xcontext v1.0.0 13 | ) 14 | 15 | require ( 16 | filippo.io/edwards25519 v1.1.0 // indirect 17 | github.com/akutz/memconn v0.1.0 // indirect 18 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 19 | github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect 20 | github.com/aws/aws-sdk-go-v2/config v1.26.5 // indirect 21 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16 // indirect 22 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect 23 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect 24 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect 25 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect 27 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect 29 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect 30 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect 31 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect 32 | github.com/aws/smithy-go v1.19.0 // indirect 33 | github.com/coreos/go-iptables v0.7.0 // indirect 34 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 35 | github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0 // indirect 36 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect 37 | github.com/fxamacker/cbor/v2 v2.6.0 // indirect 38 | github.com/go-ole/go-ole v1.3.0 // indirect 39 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 40 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 41 | github.com/google/btree v1.1.2 // indirect 42 | github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c // indirect 43 | github.com/google/uuid v1.5.0 // indirect 44 | github.com/gorilla/csrf v1.7.2 // indirect 45 | github.com/gorilla/securecookie v1.1.2 // indirect 46 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect 47 | github.com/illarion/gonotify v1.0.1 // indirect 48 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect 49 | github.com/jmespath/go-jmespath v0.4.0 // indirect 50 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect 51 | github.com/jsimonetti/rtnetlink v1.4.1 // indirect 52 | github.com/klauspost/compress v1.17.4 // indirect 53 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect 54 | github.com/mdlayher/genetlink v1.3.2 // indirect 55 | github.com/mdlayher/netlink v1.7.2 // indirect 56 | github.com/mdlayher/sdnotify v1.0.0 // indirect 57 | github.com/mdlayher/socket v0.5.0 // indirect 58 | github.com/miekg/dns v1.1.58 // indirect 59 | github.com/mitchellh/go-ps v1.0.0 // indirect 60 | github.com/pierrec/lz4/v4 v4.1.21 // indirect 61 | github.com/safchain/ethtool v0.3.0 // indirect 62 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect 63 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 64 | github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780 // indirect 65 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 66 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect 67 | github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect 68 | github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61 // indirect 69 | github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 // indirect 70 | github.com/tcnksm/go-httpstat v0.2.0 // indirect 71 | github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect 72 | github.com/vishvananda/netlink v1.2.1-beta.2 // indirect 73 | github.com/vishvananda/netns v0.0.4 // indirect 74 | github.com/x448/float16 v0.8.4 // indirect 75 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect 76 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 77 | golang.org/x/crypto v0.21.0 // indirect 78 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect 79 | golang.org/x/mod v0.14.0 // indirect 80 | golang.org/x/net v0.22.0 // indirect 81 | golang.org/x/term v0.18.0 // indirect 82 | golang.org/x/text v0.14.0 // indirect 83 | golang.org/x/time v0.5.0 // indirect 84 | golang.org/x/tools v0.17.0 // indirect 85 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 86 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 87 | gvisor.dev/gvisor v0.0.0-20240119233241-c9c1d4f9b186 // indirect 88 | inet.af/peercred v0.0.0-20210906144145-0893ea02156a // indirect 89 | nhooyr.io/websocket v1.8.10 // indirect 90 | ) 91 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= 4 | filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= 5 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 6 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 7 | github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= 8 | github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= 9 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 10 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 11 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 12 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 13 | github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= 14 | github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= 15 | github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw= 16 | github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU= 17 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= 18 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= 19 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= 20 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= 21 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= 22 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= 23 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= 24 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= 25 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= 26 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= 27 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= 28 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= 29 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= 30 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= 31 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= 32 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= 33 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= 34 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= 35 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= 36 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= 37 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= 38 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= 39 | github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= 40 | github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= 41 | github.com/cilium/ebpf v0.12.3 h1:8ht6F9MquybnY97at+VDZb3eQQr8ev79RueWeVaEcG4= 42 | github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1UVgM= 43 | github.com/coreos/go-iptables v0.7.0 h1:XWM3V+MPRr5/q51NuWSgU0fqMad64Zyxs8ZUoMsamr8= 44 | github.com/coreos/go-iptables v0.7.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 45 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 46 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 47 | github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= 48 | github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 49 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 50 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 51 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 52 | github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0 h1:vrC07UZcgPzu/OjWsmQKMGg3LoPSz9jh/pQXIrHjUj4= 53 | github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= 54 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= 55 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= 56 | github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= 57 | github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= 58 | github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= 59 | github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= 60 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 61 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 62 | github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= 63 | github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 64 | github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= 65 | github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= 66 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 67 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 68 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 69 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= 70 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= 71 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 72 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 73 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 74 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 75 | github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 76 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 77 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 78 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 79 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 80 | github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c h1:06RMfw+TMMHtRuUOroMeatRCCgSMWXCJQeABvHU69YQ= 81 | github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c/go.mod h1:BVIYo3cdnT4qSylnYqcd5YtmXhr51cJPGtnLBe/uLBU= 82 | github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= 83 | github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 84 | github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= 85 | github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= 86 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 87 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 88 | github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= 89 | github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 90 | github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= 91 | github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= 92 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= 93 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= 94 | github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= 95 | github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= 96 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 97 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 98 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 99 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 100 | github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= 101 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= 102 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= 103 | github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE= 104 | github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= 105 | github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= 106 | github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 107 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= 108 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= 109 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 110 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 111 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 112 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 113 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 114 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 115 | github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= 116 | github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= 117 | github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= 118 | github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= 119 | github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= 120 | github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= 121 | github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= 122 | github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= 123 | github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= 124 | github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= 125 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 126 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 127 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 128 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 129 | github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 130 | github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 131 | github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 132 | github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= 133 | github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= 134 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 135 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 136 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 137 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 138 | github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= 139 | github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= 140 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 141 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 142 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 143 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= 144 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= 145 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= 146 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= 147 | github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780 h1:U0J2CUrrTcc2wmr9tSLYEo+USfwNikRRsmxVLD4eZ7E= 148 | github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= 149 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= 150 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= 151 | github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126 h1:EBLH+PeC3efXmUi82yEMxjlcKhDwAUZTi0tIT4Q8oTg= 152 | github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126/go.mod h1:UCbnLJ2ebWLs28V9ubpXbq4Qx3e0q1TVoM1AC3Z2b40= 153 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= 154 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 155 | github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk= 156 | github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= 157 | github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61 h1:G6/VUGQkHbBffO0s3f51DThcHCWrShlWklcS4Zxh5BU= 158 | github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= 159 | github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 h1:zwsem4CaamMdC3tFoTpzrsUSMDPV0K6rhnQdF7kXekQ= 160 | github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= 161 | github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9 h1:81P7rjnikHKTJ75EkjppvbwUfKHDHYk6LJpO5PZy8pA= 162 | github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= 163 | github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= 164 | github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= 165 | github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= 166 | github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= 167 | github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs= 168 | github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI= 169 | github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw= 170 | github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= 171 | github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= 172 | github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= 173 | github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= 174 | github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= 175 | github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 176 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 177 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 178 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= 179 | go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 180 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 181 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 182 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 183 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 184 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= 185 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= 186 | golang.org/x/exp/typeparams v0.0.0-20240119083558-1b970713d09a h1:8qmSSA8Gz/1kTrCe0nqR0R3Gb/NDhykzWw2q2mWZydM= 187 | golang.org/x/exp/typeparams v0.0.0-20240119083558-1b970713d09a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 188 | golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= 189 | golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= 190 | golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= 191 | golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 192 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 193 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 194 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 195 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 196 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 197 | golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 198 | golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 199 | golang.org/x/sys v0.0.0-20210301091718-77cc2087c03b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 200 | golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 201 | golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 202 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 203 | golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 204 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 205 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 206 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 207 | golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= 208 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 209 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 210 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 211 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 212 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 213 | golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= 214 | golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= 215 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 216 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= 217 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 218 | golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 219 | golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 220 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 221 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 222 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 223 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 224 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 225 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 226 | gvisor.dev/gvisor v0.0.0-20240119233241-c9c1d4f9b186 h1:VWRSJX9ghfqsRSZGMAILL6QpYRKWnHcYPi24SCubQRs= 227 | gvisor.dev/gvisor v0.0.0-20240119233241-c9c1d4f9b186/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk= 228 | honnef.co/go/tools v0.4.6 h1:oFEHCKeID7to/3autwsWfnuv69j3NsfcXbvJKuIcep8= 229 | honnef.co/go/tools v0.4.6/go.mod h1:+rnGS1THNh8zMwnd2oVOTL9QF6vmfyG6ZXBULae2uc0= 230 | howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= 231 | howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 232 | inet.af/peercred v0.0.0-20210906144145-0893ea02156a h1:qdkS8Q5/i10xU2ArJMKYhVa1DORzBfYS/qA2UK2jheg= 233 | inet.af/peercred v0.0.0-20210906144145-0893ea02156a/go.mod h1:FjawnflS/udxX+SvpsMgZfdqx2aykOlkISeAsADi5IU= 234 | inet.af/wf v0.0.0-20221017222439-36129f591884 h1:zg9snq3Cpy50lWuVqDYM7AIRVTtU50y5WXETMFohW/Q= 235 | inet.af/wf v0.0.0-20221017222439-36129f591884/go.mod h1:bSAQ38BYbY68uwpasXOTZo22dKGy9SNvI6PZFeKomZE= 236 | nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= 237 | nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= 238 | software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= 239 | software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= 240 | tailscale.com v1.60.1 h1:RNNIuSWE0HeUS7c5i7rAZTCmoDzsAxy5yc5LF4K7pFs= 241 | tailscale.com v1.60.1/go.mod h1:qgxvJUlfOWeURBEORdcX4EhoCduFHeBW3FNIZBpmIHY= 242 | zombiezen.com/go/ini v0.0.0-20220922030607-23a6472a8275 h1:qNmoOM4otFdCdDt4Qyf5vUGlAJTrr20Ce9ijO4g29CE= 243 | zombiezen.com/go/ini v0.0.0-20220922030607-23a6472a8275/go.mod h1:+TD85cq5iHXlTnhdJbaD4sBijR0y4ZnF5zkYjP8hRpE= 244 | zombiezen.com/go/log v1.1.0-beta1 h1:W9/YvlR//MHUPbZJC9tl7+GiryHLuGv/S8Z5SUTXjXY= 245 | zombiezen.com/go/log v1.1.0-beta1/go.mod h1:Eos1rXF8JpgK+h6NYITdTJslqFJJA3SaIJHMU75Sqfg= 246 | zombiezen.com/go/xcontext v1.0.0 h1:ggjoy/K+SKliNkvLszlTEQbajk1te/4AjzeBVTJcF4U= 247 | zombiezen.com/go/xcontext v1.0.0/go.mod h1:bXPuLPkAwZShCRDueoyymB/Hjn9qt+S+rfkXqGIKcSc= 248 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "net/http" 22 | "net/http/httputil" 23 | "net/url" 24 | "strings" 25 | 26 | "tailscale.com/client/tailscale" 27 | "tailscale.com/client/tailscale/apitype" 28 | "zombiezen.com/go/log" 29 | "zombiezen.com/go/log/zstdlog" 30 | ) 31 | 32 | type httpLoadBalancer struct { 33 | lb *loadBalancer 34 | tailscale *tailscale.LocalClient 35 | whoisHeaders bool 36 | trustXFF bool 37 | } 38 | 39 | func (hlb *httpLoadBalancer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 40 | ctx, cancel := context.WithCancel(r.Context()) 41 | defer cancel() 42 | 43 | whoisChan := make(chan *apitype.WhoIsResponse, 1) 44 | if hlb.whoisHeaders { 45 | go func() { 46 | defer close(whoisChan) 47 | whois, err := hlb.tailscale.WhoIs(ctx, r.RemoteAddr) 48 | if err != nil { 49 | log.Errorf(ctx, "Tailscale whois: %v", err) 50 | return 51 | } 52 | whoisChan <- whois 53 | }() 54 | } else { 55 | close(whoisChan) 56 | } 57 | 58 | addr, err := hlb.lb.pick(ctx) 59 | if err != nil { 60 | log.Errorf(ctx, "Finding backend for %s %s: %v", r.Method, r.URL.Path, err) 61 | http.Error(w, "Could not find suitable backend for request.", http.StatusServiceUnavailable) 62 | return 63 | } 64 | 65 | proxy := &httputil.ReverseProxy{ 66 | Rewrite: func(r *httputil.ProxyRequest) { 67 | // Strip any Tailscale headers out, 68 | // so proxied servers can know to trust the headers. 69 | for key := range r.Out.Header { 70 | if strings.HasPrefix(key, "Tailscale-") { 71 | delete(r.Out.Header, key) 72 | } 73 | } 74 | 75 | r.SetURL(&url.URL{ 76 | Scheme: "http", 77 | Host: addr.String(), 78 | }) 79 | r.Out.Host = r.In.Host 80 | if hlb.trustXFF { 81 | r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] 82 | } 83 | r.SetXForwarded() 84 | 85 | if hlb.whoisHeaders { 86 | whois := <-whoisChan 87 | if whois != nil { 88 | // Reference: https://github.com/tailscale/tailscale/tree/10b20fd1c725f1627d2fad43acbd727b13cb9dbf/cmd/nginx-auth#headers 89 | 90 | // TODO(soon): ID? 91 | r.Out.Header.Set("Tailscale-User", whois.UserProfile.LoginName) 92 | r.Out.Header.Set("Tailscale-Name", whois.UserProfile.DisplayName) 93 | r.Out.Header.Set("Tailscale-Profile-Picture", whois.UserProfile.ProfilePicURL) 94 | } 95 | } 96 | }, 97 | ErrorLog: zstdlog.New(log.Default(), &zstdlog.Options{ 98 | Context: ctx, 99 | Level: log.Warn, 100 | }), 101 | } 102 | proxy.ServeHTTP(w, r) 103 | } 104 | -------------------------------------------------------------------------------- /http_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "encoding/json" 22 | "io" 23 | "net" 24 | "net/http" 25 | "net/http/httptest" 26 | "net/netip" 27 | "net/url" 28 | "strconv" 29 | "testing" 30 | 31 | "tailscale.com/client/tailscale" 32 | "tailscale.com/client/tailscale/apitype" 33 | "tailscale.com/tailcfg" 34 | ) 35 | 36 | func TestHTTPLoadBalancer(t *testing.T) { 37 | const ( 38 | wantPath = "/foo" 39 | wantHost = "ts-service.example.com" 40 | wantUser = "foo@example.com" 41 | wantDisplayName = "Foo Bar" 42 | wantProfilePictureURL = "https://www.example.com/user/foo/profile.png" 43 | 44 | wantContentType = "text/plain; charset=utf-8" 45 | wantResponse = "Hello, World!\n" 46 | 47 | userProvidedHeader = "Tailscale-Evil-Header" 48 | ) 49 | 50 | backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 | if got, want := r.Host, wantHost; got != want { 52 | t.Errorf("Host = %q; want %q", got, want) 53 | } 54 | if got, want := r.URL.Path, wantPath; got != want { 55 | t.Errorf(":path = %q; want %q", got, want) 56 | } 57 | if got, want := r.Header.Get("Tailscale-User"), wantUser; got != want { 58 | t.Errorf("Tailscale-User = %q; want %q", got, want) 59 | } 60 | if got, want := r.Header.Get("Tailscale-Name"), wantDisplayName; got != want { 61 | t.Errorf("Tailscale-Name = %q; want %q", got, want) 62 | } 63 | if got, want := r.Header.Get("Tailscale-Profile-Picture"), wantProfilePictureURL; got != want { 64 | t.Errorf("Tailscale-Profile-Picture = %q; want %q", got, want) 65 | } 66 | if got := r.Header.Values(userProvidedHeader); len(got) > 0 { 67 | t.Errorf("%s = %q; want []", userProvidedHeader, got) 68 | } 69 | if got, want1, want2 := r.Header.Get("X-Forwarded-For"), "127.0.0.1", "::1"; got != want1 && got != want2 { 70 | t.Errorf("X-Forwarded-For = %q; want %q or %q", got, want1, want2) 71 | } 72 | w.Header().Set("Content-Type", wantContentType) 73 | w.Header().Set("Content-Length", strconv.Itoa(len(wantResponse))) 74 | io.WriteString(w, wantResponse) 75 | })) 76 | defer backendSrv.Close() 77 | backendURL, err := url.Parse(backendSrv.URL) 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | backendAddr, err := netip.ParseAddrPort(backendURL.Host) 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | tailscaleLocalAPISrv := httptest.NewServer(fakeWhoIsHandler( 87 | func(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) { 88 | return &apitype.WhoIsResponse{ 89 | UserProfile: &tailcfg.UserProfile{ 90 | LoginName: wantUser, 91 | DisplayName: wantDisplayName, 92 | ProfilePicURL: wantProfilePictureURL, 93 | }, 94 | }, nil 95 | }, 96 | )) 97 | defer tailscaleLocalAPISrv.Close() 98 | tailscaleLocalAPIURL, err := url.Parse(tailscaleLocalAPISrv.URL) 99 | if err != nil { 100 | t.Fatal(err) 101 | } 102 | tailscaleLocalAPIAddr, err := netip.ParseAddrPort(tailscaleLocalAPIURL.Host) 103 | if err != nil { 104 | t.Fatal(err) 105 | } 106 | 107 | proxySrv := httptest.NewServer(&httpLoadBalancer{ 108 | lb: newLoadBalancer(nil, []*backend{{ 109 | addr: backendAddr.Addr(), 110 | port: backendAddr.Port(), 111 | }}), 112 | tailscale: &tailscale.LocalClient{ 113 | Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { 114 | return net.Dial("tcp", tailscaleLocalAPIAddr.String()) 115 | }, 116 | }, 117 | whoisHeaders: true, 118 | }) 119 | defer proxySrv.Close() 120 | 121 | req, err := http.NewRequest(http.MethodGet, proxySrv.URL+wantPath, nil) 122 | if err != nil { 123 | t.Fatal(err) 124 | } 125 | req.Host = wantHost 126 | req.Header.Set(userProvidedHeader, "xyzzy") 127 | resp, err := proxySrv.Client().Do(req) 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | defer resp.Body.Close() 132 | got, err := io.ReadAll(resp.Body) 133 | if string(got) != wantResponse || err != nil { 134 | t.Errorf("io.ReadAll(Body) = %q, %v; want %q, ", got, err, wantResponse) 135 | } 136 | } 137 | 138 | // fakeWhoIsHandler returns a fake of the Tailscale Local API 139 | // that implements the "WhoIs" endpoint. 140 | func fakeWhoIsHandler(f func(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error)) http.Handler { 141 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 142 | if r.URL.Path != "/localapi/v0/whois" { 143 | http.NotFound(w, r) 144 | return 145 | } 146 | remoteAddr := r.URL.Query().Get("addr") 147 | resp, err := f(r.Context(), remoteAddr) 148 | if err != nil { 149 | http.Error(w, err.Error(), http.StatusInternalServerError) 150 | return 151 | } 152 | respJSON, err := json.Marshal(resp) 153 | if err != nil { 154 | http.Error(w, err.Error(), http.StatusInternalServerError) 155 | return 156 | } 157 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 158 | w.Header().Set("Content-Length", strconv.Itoa(len(respJSON))) 159 | w.Write(respJSON) 160 | }) 161 | } 162 | -------------------------------------------------------------------------------- /infra/.envrc: -------------------------------------------------------------------------------- 1 | #shellcheck shell=bash 2 | use flake '.#infra' 3 | dotenv_if_exists 4 | -------------------------------------------------------------------------------- /infra/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/google" { 5 | version = "5.13.0" 6 | constraints = ">= 4.0.0, < 6.0.0" 7 | hashes = [ 8 | "h1:PIeYPTPtzSqBQR8f3yz1RsNYx1ocIE/uoGZjYcGaxas=", 9 | ] 10 | } 11 | 12 | provider "registry.terraform.io/integrations/github" { 13 | version = "5.43.0" 14 | hashes = [ 15 | "h1:VPRM4zIFhq7vzNwkCozr+srIXe1jjCcDf2XN0fSPmU4=", 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /infra/main.tf: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Ross Light 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | terraform { 18 | required_version = "1.7.4" 19 | 20 | backend "remote" { 21 | organization = "zombiezen" 22 | 23 | workspaces { 24 | name = "tailscale-lb" 25 | } 26 | } 27 | 28 | required_providers { 29 | google = { 30 | version = "5.13.0" 31 | } 32 | github = { 33 | source = "integrations/github" 34 | version = "5.43.0" 35 | } 36 | } 37 | } 38 | 39 | provider "google" { 40 | project = "zombiezen-tailscale-lb" 41 | } 42 | 43 | provider "github" { 44 | owner = local.github_repository_owner 45 | } 46 | 47 | data "google_project" "project" { 48 | } 49 | 50 | locals { 51 | github_repository_owner = "zombiezen" 52 | github_repository_name = "tailscale-lb" 53 | } 54 | 55 | resource "google_project_service" "cloudresourcemanager" { 56 | service = "cloudresourcemanager.googleapis.com" 57 | disable_on_destroy = false 58 | } 59 | 60 | resource "google_project_service" "iam" { 61 | service = "iam.googleapis.com" 62 | disable_on_destroy = false 63 | } 64 | 65 | resource "google_service_account" "github_actions" { 66 | account_id = "github" 67 | display_name = "GitHub Actions" 68 | description = "GitHub Actions runners" 69 | depends_on = [ 70 | google_project_service.iam, 71 | ] 72 | } 73 | 74 | module "nix_cache" { 75 | source = "zombiezen/nix-cache/google" 76 | version = "0.2.1" 77 | 78 | bucket_name = "${data.google_project.project.project_id}-nixcache" 79 | bucket_location = "us" 80 | 81 | service_account_email = google_service_account.github_actions.email 82 | hmac_key = false 83 | } 84 | 85 | module "github_identity_pool" { 86 | source = "zombiezen/github-identity/google" 87 | version = "0.1.2" 88 | 89 | attribute_condition = "assertion.repository=='${local.github_repository_owner}/${local.github_repository_name}'" 90 | 91 | service_accounts = { 92 | main = { 93 | subject = "${local.github_repository_owner}/${local.github_repository_name}" 94 | service_account_name = google_service_account.github_actions.name 95 | } 96 | } 97 | } 98 | 99 | resource "github_actions_variable" "nix_substituter" { 100 | repository = local.github_repository_name 101 | variable_name = "NIX_SUBSTITUTER" 102 | value = module.nix_cache.nixcached_substituter 103 | } 104 | 105 | resource "github_actions_variable" "workload_identity_provider" { 106 | repository = local.github_repository_name 107 | variable_name = "GOOGLE_WORKLOAD_IDENTITY_PROVIDER" 108 | value = module.github_identity_pool.pool_provider_name 109 | } 110 | 111 | resource "github_actions_variable" "service_account" { 112 | repository = local.github_repository_name 113 | variable_name = "GOOGLE_SERVICE_ACCOUNT" 114 | value = google_service_account.github_actions.email 115 | } 116 | -------------------------------------------------------------------------------- /infra/moved.tf: -------------------------------------------------------------------------------- 1 | moved { 2 | from = google_storage_bucket.nixcache 3 | to = module.nix_cache.google_storage_bucket.cache 4 | } 5 | 6 | moved { 7 | from = google_project_service.storage 8 | to = module.nix_cache.google_project_service.storage 9 | } 10 | -------------------------------------------------------------------------------- /infra/outputs.tf: -------------------------------------------------------------------------------- 1 | output "cache_bucket" { 2 | value = module.nix_cache.nixcached_substituter 3 | } 4 | -------------------------------------------------------------------------------- /load_balancer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "net" 23 | "net/netip" 24 | "strings" 25 | "sync" 26 | 27 | "golang.org/x/sync/errgroup" 28 | "zombiezen.com/go/log" 29 | "zombiezen.com/go/tailscale-lb/deque" 30 | ) 31 | 32 | type resolver interface { 33 | LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) 34 | LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) 35 | } 36 | 37 | type loadBalancer struct { 38 | resolver resolver 39 | backends []*backend 40 | refreshSem chan struct{} 41 | 42 | mu sync.Mutex 43 | queue deque.Deque[netip.AddrPort] 44 | } 45 | 46 | func newLoadBalancer(r resolver, backends []*backend) *loadBalancer { 47 | return &loadBalancer{ 48 | resolver: r, 49 | backends: backends, 50 | refreshSem: make(chan struct{}, 1), 51 | } 52 | } 53 | 54 | // pick chooses one of the available backends 55 | // or returns an error if none are available. 56 | func (lb *loadBalancer) pick(ctx context.Context) (netip.AddrPort, error) { 57 | refreshErr := lb.refresh(ctx) 58 | 59 | lb.mu.Lock() 60 | defer lb.mu.Unlock() 61 | addr, ok := lb.queue.Front() 62 | if !ok { 63 | if refreshErr != nil { 64 | return netip.AddrPort{}, fmt.Errorf("pick address: %w", refreshErr) 65 | } 66 | return netip.AddrPort{}, fmt.Errorf("pick address: no backend available") 67 | } 68 | lb.queue.Rotate(1) 69 | return addr, nil 70 | } 71 | 72 | // refresh updates the addresses in the queue. 73 | // It only returns errors if the Context is canceled or exceeds its deadline 74 | // before the DNS resolution is complete. 75 | func (lb *loadBalancer) refresh(ctx context.Context) error { 76 | // Only allow one refresh call at a time. 77 | select { 78 | case lb.refreshSem <- struct{}{}: 79 | // Release the semaphore on return. 80 | defer func() { <-lb.refreshSem }() 81 | case <-ctx.Done(): 82 | return fmt.Errorf("refresh backends: start: %w", ctx.Err()) 83 | } 84 | 85 | ctx, cancel := context.WithCancel(ctx) 86 | grp, grpCtx := errgroup.WithContext(ctx) 87 | grp.SetLimit(10) 88 | addrChan := make(chan netip.AddrPort) 89 | addrSetChan := make(chan map[netip.AddrPort]struct{}, 1) 90 | defer func() { 91 | cancel() 92 | if err := grp.Wait(); err != nil { 93 | log.Debugf(ctx, "Load balance refresh abort: %v", err) 94 | } 95 | <-addrSetChan 96 | }() 97 | 98 | go func() { 99 | defer close(addrSetChan) 100 | addrs := make(map[netip.AddrPort]struct{}) 101 | for { 102 | select { 103 | case a, ok := <-addrChan: 104 | if !ok { 105 | addrSetChan <- addrs 106 | return 107 | } 108 | addrs[a] = struct{}{} 109 | case <-ctx.Done(): 110 | return 111 | } 112 | } 113 | }() 114 | 115 | // Start the name resolution. 116 | const maxConcurrency = 10 117 | goFunc := func(ctx context.Context, f func() error) error { 118 | grp.Go(f) 119 | return nil 120 | } 121 | for _, b := range lb.backends { 122 | if b.addr.IsValid() { 123 | addrChan <- netip.AddrPortFrom(b.addr, b.port) 124 | continue 125 | } 126 | 127 | b := b 128 | err := goFunc(ctx, func() error { 129 | return lookup(grpCtx, addrChan, lb.resolver, goFunc, b) 130 | }) 131 | if err != nil { 132 | return fmt.Errorf("refresh backends: %w", err) 133 | } 134 | } 135 | 136 | // Wait until all workers have settled, then collect the set. 137 | if err := grp.Wait(); err != nil { 138 | return fmt.Errorf("refresh backends: %w", err) 139 | } 140 | close(addrChan) 141 | var addrSet map[netip.AddrPort]struct{} 142 | select { 143 | case addrSet = <-addrSetChan: 144 | case <-ctx.Done(): 145 | return fmt.Errorf("refresh backends: %w", ctx.Err()) 146 | } 147 | 148 | // Update the queue. 149 | lb.mu.Lock() 150 | defer lb.mu.Unlock() 151 | lb.queue.Filter(func(a netip.AddrPort) bool { _, ok := addrSet[a]; return ok }) 152 | for i, n := 0, lb.queue.Len(); i < n; i++ { 153 | delete(addrSet, lb.queue.At(i)) 154 | } 155 | for newAddr := range addrSet { 156 | lb.queue.Append(newAddr) 157 | } 158 | return nil 159 | } 160 | 161 | func lookup(ctx context.Context, out chan<- netip.AddrPort, resolver resolver, goFunc func(context.Context, func() error) error, b *backend) error { 162 | if b.srv { 163 | _, records, err := resolver.LookupSRV(ctx, "", "", b.hostname) 164 | if err != nil { 165 | log.Warnf(ctx, "%v", err) 166 | return nil 167 | } 168 | if log.IsEnabled(log.Debug) { 169 | recordsString := new(strings.Builder) 170 | for i, r := range records { 171 | if i > 0 { 172 | recordsString.WriteString(" ") 173 | } 174 | fmt.Fprintf(recordsString, "%s:%d", r.Target, r.Port) 175 | } 176 | log.Debugf(ctx, "Resolved SRV %s -> %s", b.hostname, recordsString) 177 | } 178 | if len(records) == 0 { 179 | log.Warnf(ctx, "No SRV records found for %s", b.hostname) 180 | return nil 181 | } 182 | for _, r := range records[:len(records)-1] { 183 | r := r 184 | err := goFunc(ctx, func() error { 185 | return lookup(ctx, out, resolver, goFunc, &backend{ 186 | hostname: r.Target, 187 | port: r.Port, 188 | }) 189 | }) 190 | if err != nil { 191 | return err 192 | } 193 | } 194 | 195 | // Don't acquire the semaphore for the last record: 196 | // just reuse the one already grabbed for this goroutine. 197 | lastRecord := records[len(records)-1] 198 | b = &backend{ 199 | hostname: lastRecord.Target, 200 | port: lastRecord.Port, 201 | } 202 | } 203 | 204 | addrs, err := resolver.LookupNetIP(ctx, "ip", b.hostname) 205 | if err != nil { 206 | log.Warnf(ctx, "%v", err) 207 | return nil 208 | } 209 | // Workaround for upstream Go weirdness: https://go.dev/issue/53554 210 | // LookupNetIP can return IPv4-mapped IPv6 addresses, 211 | // which can't directly be dialed. 212 | for i, a := range addrs { 213 | addrs[i] = a.Unmap() 214 | } 215 | if log.IsEnabled(log.Debug) { 216 | addrsString := new(strings.Builder) 217 | for i, a := range addrs { 218 | if i > 0 { 219 | addrsString.WriteString(" ") 220 | } 221 | addrsString.WriteString(a.String()) 222 | } 223 | log.Debugf(ctx, "Resolved A/AAAA %s -> %s", b.hostname, addrsString) 224 | } 225 | for _, a := range addrs { 226 | select { 227 | case out <- netip.AddrPortFrom(a, b.port): 228 | case <-ctx.Done(): 229 | return ctx.Err() 230 | } 231 | } 232 | return nil 233 | } 234 | -------------------------------------------------------------------------------- /load_balancer_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "net" 23 | "net/netip" 24 | "os" 25 | "testing" 26 | 27 | "github.com/google/go-cmp/cmp" 28 | "zombiezen.com/go/log/testlog" 29 | ) 30 | 31 | func TestSingleAddress(t *testing.T) { 32 | ctx := testlog.WithTB(context.Background(), t) 33 | lb := newLoadBalancer(fakeResolver{}, []*backend{ 34 | {addr: netip.MustParseAddr("127.0.0.1"), port: 80}, 35 | }) 36 | got, err := lb.pick(ctx) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | if want := netip.MustParseAddrPort("127.0.0.1:80"); got != want { 41 | t.Errorf("lb.pick(ctx) = %v; want %v", got, want) 42 | } 43 | } 44 | 45 | func TestMultipleAddresses(t *testing.T) { 46 | ctx := testlog.WithTB(context.Background(), t) 47 | lb := newLoadBalancer(fakeResolver{}, []*backend{ 48 | {addr: netip.MustParseAddr("127.0.0.1"), port: 80}, 49 | {addr: netip.MustParseAddr("127.0.0.1"), port: 81}, 50 | {addr: netip.MustParseAddr("127.0.0.1"), port: 82}, 51 | }) 52 | 53 | got := make(map[netip.AddrPort]struct{}) 54 | for i := 0; i < 3; i++ { 55 | addrPort, err := lb.pick(ctx) 56 | if err != nil { 57 | t.Error(err) 58 | break 59 | } 60 | got[addrPort] = struct{}{} 61 | } 62 | want := map[netip.AddrPort]struct{}{ 63 | netip.MustParseAddrPort("127.0.0.1:80"): {}, 64 | netip.MustParseAddrPort("127.0.0.1:81"): {}, 65 | netip.MustParseAddrPort("127.0.0.1:82"): {}, 66 | } 67 | if diff := cmp.Diff(want, got); diff != "" { 68 | t.Errorf("picked (-want +got):\n%s", diff) 69 | } 70 | } 71 | 72 | func TestHostName(t *testing.T) { 73 | ctx := testlog.WithTB(context.Background(), t) 74 | rslv := fakeResolver{a: map[string][]netip.Addr{ 75 | "example.com": { 76 | netip.MustParseAddr("192.0.2.1"), 77 | netip.MustParseAddr("192.0.2.2"), 78 | }, 79 | }} 80 | lb := newLoadBalancer(rslv, []*backend{ 81 | {hostname: "example.com", port: 80}, 82 | }) 83 | 84 | got := make(map[netip.AddrPort]struct{}) 85 | for i := 0; i < 2; i++ { 86 | addrPort, err := lb.pick(ctx) 87 | if err != nil { 88 | t.Error(err) 89 | break 90 | } 91 | got[addrPort] = struct{}{} 92 | } 93 | want := map[netip.AddrPort]struct{}{ 94 | netip.MustParseAddrPort("192.0.2.1:80"): {}, 95 | netip.MustParseAddrPort("192.0.2.2:80"): {}, 96 | } 97 | if diff := cmp.Diff(want, got); diff != "" { 98 | t.Errorf("picked (-want +got):\n%s", diff) 99 | } 100 | } 101 | 102 | func TestHostName4In6(t *testing.T) { 103 | // Replicate buggy behavior from https://go.dev/issue/53554 104 | 105 | ctx := testlog.WithTB(context.Background(), t) 106 | rslv := fakeResolver{a: map[string][]netip.Addr{ 107 | "example.com": { 108 | netip.MustParseAddr("::ffff:192.0.2.1"), 109 | }, 110 | }} 111 | lb := newLoadBalancer(rslv, []*backend{ 112 | {hostname: "example.com", port: 80}, 113 | }) 114 | 115 | got := make(map[netip.AddrPort]struct{}) 116 | for i := 0; i < 2; i++ { 117 | addrPort, err := lb.pick(ctx) 118 | if err != nil { 119 | t.Error(err) 120 | break 121 | } 122 | got[addrPort] = struct{}{} 123 | } 124 | want := map[netip.AddrPort]struct{}{ 125 | netip.MustParseAddrPort("192.0.2.1:80"): {}, 126 | } 127 | if diff := cmp.Diff(want, got); diff != "" { 128 | t.Errorf("picked (-want +got):\n%s", diff) 129 | } 130 | } 131 | 132 | func TestSRV(t *testing.T) { 133 | ctx := testlog.WithTB(context.Background(), t) 134 | rslv := fakeResolver{ 135 | a: map[string][]netip.Addr{ 136 | "example.com.": { 137 | netip.MustParseAddr("192.0.2.1"), 138 | netip.MustParseAddr("192.0.2.2"), 139 | }, 140 | }, 141 | srv: map[string][]*net.SRV{ 142 | "_http._tcp.example.com": { 143 | { 144 | Target: "example.com.", 145 | Port: 80, 146 | Priority: 10, 147 | Weight: 0, 148 | }, 149 | { 150 | Target: "example.com.", 151 | Port: 8080, 152 | Priority: 20, 153 | Weight: 0, 154 | }, 155 | }, 156 | }, 157 | } 158 | lb := newLoadBalancer(rslv, []*backend{ 159 | {hostname: "_http._tcp.example.com", srv: true}, 160 | }) 161 | 162 | got := make(map[netip.AddrPort]struct{}) 163 | for i := 0; i < 4; i++ { 164 | addrPort, err := lb.pick(ctx) 165 | if err != nil { 166 | t.Error(err) 167 | break 168 | } 169 | got[addrPort] = struct{}{} 170 | } 171 | want := map[netip.AddrPort]struct{}{ 172 | netip.MustParseAddrPort("192.0.2.1:80"): {}, 173 | netip.MustParseAddrPort("192.0.2.2:80"): {}, 174 | netip.MustParseAddrPort("192.0.2.1:8080"): {}, 175 | netip.MustParseAddrPort("192.0.2.2:8080"): {}, 176 | } 177 | if diff := cmp.Diff(want, got); diff != "" { 178 | t.Errorf("picked (-want +got):\n%s", diff) 179 | } 180 | } 181 | 182 | type fakeResolver struct { 183 | a map[string][]netip.Addr 184 | srv map[string][]*net.SRV 185 | } 186 | 187 | func (r fakeResolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) { 188 | if network != "ip" { 189 | return nil, fmt.Errorf("lookup ip: only \"ip\" network supported (got %q)", network) 190 | } 191 | return append([]netip.Addr(nil), r.a[host]...), nil 192 | } 193 | 194 | func (r fakeResolver) LookupSRV(ctx context.Context, service, proto, name string) (cname string, srv []*net.SRV, err error) { 195 | if service != "" || proto != "" { 196 | cname = fmt.Sprintf("_%s._%s.%s", service, proto, name) 197 | } else { 198 | cname = name 199 | } 200 | records := r.srv[cname] 201 | if len(records) == 0 { 202 | return cname, nil, nil 203 | } 204 | srv = make([]*net.SRV, 0, len(records)) 205 | for _, r := range records { 206 | r2 := new(net.SRV) 207 | *r2 = *r 208 | srv = append(srv, r2) 209 | } 210 | return cname, srv, nil 211 | } 212 | 213 | func TestMain(m *testing.M) { 214 | testlog.Main(nil) 215 | os.Exit(m.Run()) 216 | } 217 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "crypto/tls" 22 | "errors" 23 | "flag" 24 | "fmt" 25 | "io" 26 | "net" 27 | "net/http" 28 | "os" 29 | "os/signal" 30 | "path/filepath" 31 | "runtime" 32 | "strings" 33 | "sync" 34 | "time" 35 | 36 | "golang.org/x/sync/errgroup" 37 | "tailscale.com/client/tailscale" 38 | "tailscale.com/ipn" 39 | "tailscale.com/ipn/store" 40 | "tailscale.com/ipn/store/mem" 41 | "tailscale.com/tsnet" 42 | "tailscale.com/types/logger" 43 | "zombiezen.com/go/ini" 44 | "zombiezen.com/go/log" 45 | "zombiezen.com/go/log/zstdlog" 46 | "zombiezen.com/go/xcontext" 47 | ) 48 | 49 | const programName = "tailscale-lb" 50 | 51 | const tailscaleLogLevel = log.Debug - 1 52 | 53 | func main() { 54 | flagSet := flag.NewFlagSet(programName, flag.ContinueOnError) 55 | flagSet.Usage = func() { 56 | fmt.Fprintf(flagSet.Output(), "usage: %s CONFIG [...]\n", programName) 57 | flagSet.PrintDefaults() 58 | } 59 | var cfg configuration 60 | flagSet.StringVar(&cfg.hostname, "hostname", "", "host`name` to send to Tailscale") 61 | flagSet.StringVar(&cfg.stateDir, "state-directory", "", "`path` to directory to store Tailscale state in") 62 | debug := flagSet.Bool("debug", false, "show debugging output") 63 | debugTailscale := flagSet.Bool("debug-tailscale", false, "show all debugging output, including Tailscale") 64 | 65 | const exitUsage = 64 66 | if err := flagSet.Parse(os.Args[1:]); err == flag.ErrHelp { 67 | os.Exit(exitUsage) 68 | } else if err != nil { 69 | os.Exit(1) 70 | } 71 | 72 | const baseLogFlags = log.ShowDate | log.ShowTime 73 | if *debugTailscale { 74 | log.SetDefault(log.New(os.Stderr, "", baseLogFlags|log.ShowLevel, nil)) 75 | } else if *debug { 76 | log.SetDefault(&log.LevelFilter{ 77 | Min: log.Debug, 78 | Output: log.New(os.Stderr, "", baseLogFlags|log.ShowLevel, nil), 79 | }) 80 | } else { 81 | log.SetDefault(&log.LevelFilter{ 82 | Min: log.Info, 83 | Output: log.New(os.Stderr, "", baseLogFlags, nil), 84 | }) 85 | } 86 | 87 | ctx, cancel := signal.NotifyContext(context.Background(), interruptSignals...) 88 | if flagSet.NArg() == 0 { 89 | log.Errorf(ctx, "No configuration files given") 90 | flagSet.PrintDefaults() 91 | os.Exit(exitUsage) 92 | } 93 | // Later INI arguments should take precedence over earlier arguments. 94 | // Reverse the arguments to ParseFiles so the FileSet matches precedence. 95 | iniPaths := append([]string(nil), flagSet.Args()...) 96 | reverseSlice(iniPaths) 97 | iniFiles, err := ini.ParseFiles(nil, iniPaths...) 98 | if err != nil { 99 | log.Errorf(ctx, "%v", err) 100 | os.Exit(1) 101 | } 102 | if err := cfg.fill(iniFiles); err != nil { 103 | log.Errorf(ctx, "%v", err) 104 | os.Exit(1) 105 | } 106 | 107 | err = run(ctx, &cfg) 108 | cancel() 109 | if err != nil { 110 | log.Errorf(ctx, "%v", err) 111 | os.Exit(1) 112 | } 113 | } 114 | 115 | func run(ctx context.Context, cfg *configuration) error { 116 | if cfg.hostname == "" { 117 | return fmt.Errorf("hostname not set in configuration") 118 | } 119 | 120 | srv := tsnet.Server{ 121 | Store: new(mem.Store), 122 | Ephemeral: true, 123 | 124 | Hostname: cfg.hostname, 125 | AuthKey: cfg.authKey, 126 | Logf: tailscaleLogf(ctx), 127 | } 128 | if cfg.controlURL != "" { 129 | srv.ControlURL = cfg.controlURL 130 | } 131 | if cfg.stateDir != "" { 132 | srv.Ephemeral = false 133 | srv.Dir = cfg.stateDir 134 | // NewFileStore is responsible for creating its directory. 135 | var err error 136 | srv.Store, err = store.NewFileStore(tailscaleLogf(ctx), filepath.Join(cfg.stateDir, "tailscale-lb.state")) 137 | if err != nil { 138 | return err 139 | } 140 | } 141 | if err := srv.Start(); err != nil { 142 | return err 143 | } 144 | log.Infof(ctx, "Host %s connected to Tailscale", cfg.hostname) 145 | ctx, cancel := context.WithCancel(ctx) 146 | var wg sync.WaitGroup 147 | defer func() { 148 | log.Infof(ctx, "Shutting down...") 149 | cancel() 150 | if err := srv.Close(); err != nil { 151 | log.Errorf(ctx, "While shutting down: %v", err) 152 | } 153 | log.Debugf(ctx, "Waiting for handlers to stop...") 154 | wg.Wait() 155 | }() 156 | 157 | wg.Add(1) 158 | client, err := srv.LocalClient() 159 | if err != nil { 160 | // LocalClient should not return an error if server successfully started. 161 | return err 162 | } 163 | if cfg.stateDir == "" { 164 | // If this is an ephemeral Tailscale node, 165 | // then log out on exit if we can 166 | // so the node doesn't linger in the admin console. 167 | // Otherwise, don't log out so we can reuse credentials between runs. 168 | defer func() { 169 | log.Debugf(ctx, "Logging out...") 170 | logoutCtx, cancelLogout := xcontext.KeepAlive(ctx, 10*time.Second) 171 | defer cancelLogout() 172 | if err := client.Logout(logoutCtx); err != nil { 173 | log.Errorf(ctx, "Failed to log out: %v", err) 174 | } 175 | }() 176 | } 177 | go func() { 178 | defer wg.Done() 179 | logStartupInfo(ctx, client) 180 | }() 181 | 182 | systemResolver := new(net.Resolver) 183 | for port, pc := range cfg.ports { 184 | pc := pc 185 | log.Infof(ctx, "Listening for TCP port %d", port) 186 | l, err := srv.Listen("tcp", fmt.Sprintf(":%d", port)) 187 | if err != nil { 188 | return err 189 | } 190 | switch { 191 | case pc.tcp != nil: 192 | lb := newLoadBalancer(systemResolver, pc.tcp.backends) 193 | wg.Add(1) 194 | go func() { 195 | defer wg.Done() 196 | listenTCPPort(ctx, l, lb) 197 | }() 198 | case pc.http != nil: 199 | httpServer := &http.Server{ 200 | Handler: &httpLoadBalancer{ 201 | lb: newLoadBalancer(systemResolver, pc.http.backends), 202 | tailscale: client, 203 | trustXFF: pc.http.trustXFF, 204 | }, 205 | BaseContext: func(net.Listener) context.Context { return ctx }, 206 | ErrorLog: zstdlog.New(log.Default(), &zstdlog.Options{ 207 | Context: ctx, 208 | Level: log.Error, 209 | }), 210 | ReadTimeout: 5 * time.Second, 211 | WriteTimeout: 5 * time.Second, 212 | } 213 | if pc.http.tls { 214 | httpServer.TLSConfig = &tls.Config{ 215 | GetCertificate: client.GetCertificate, 216 | } 217 | } 218 | wg.Add(2) 219 | go func() { 220 | defer wg.Done() 221 | <-ctx.Done() 222 | httpServer.Shutdown(context.Background()) 223 | }() 224 | go func() { 225 | defer wg.Done() 226 | if pc.http.tls { 227 | httpServer.ServeTLS(l, "", "") 228 | } else { 229 | httpServer.Serve(l) 230 | } 231 | }() 232 | default: 233 | panic("unreachable") 234 | } 235 | } 236 | <-ctx.Done() 237 | return nil 238 | } 239 | 240 | func logStartupInfo(ctx context.Context, client *tailscale.LocalClient) { 241 | tick := time.NewTicker(2 * time.Second) 242 | defer tick.Stop() 243 | var prevAuthURL string 244 | for { 245 | if err := ctx.Err(); err != nil { 246 | log.Debugf(ctx, "Stopping startup info poll: %v", err) 247 | return 248 | } 249 | status, err := client.Status(ctx) 250 | if err != nil { 251 | log.Errorf(ctx, "Unable to query Tailscale status (will retry): %v", err) 252 | goto wait 253 | } 254 | if status.BackendState == ipn.NeedsLogin.String() { 255 | if status.AuthURL != prevAuthURL { 256 | log.Infof(ctx, "To start this load balancer, restart with TS_AUTHKEY set, or go to: %s", status.AuthURL) 257 | prevAuthURL = status.AuthURL 258 | } 259 | } else if len(status.TailscaleIPs) > 0 { 260 | sb := new(strings.Builder) 261 | for i, addr := range status.TailscaleIPs { 262 | if i > 0 { 263 | sb.WriteString(", ") 264 | } 265 | sb.WriteString(addr.String()) 266 | } 267 | log.Infof(ctx, "Listening on Tailscale addresses: %s", sb) 268 | return 269 | } else { 270 | log.Debugf(ctx, "Backend state = %q and has no addresses", status.BackendState) 271 | } 272 | 273 | wait: 274 | select { 275 | case <-tick.C: 276 | case <-ctx.Done(): 277 | } 278 | } 279 | } 280 | 281 | func listenTCPPort(ctx context.Context, l net.Listener, lb *loadBalancer) { 282 | var closeOnce sync.Once 283 | closeListener := func() { 284 | closeOnce.Do(func() { 285 | if err := l.Close(); err != nil { 286 | log.Errorf(ctx, "Closing listener: %v", err) 287 | } 288 | }) 289 | } 290 | 291 | ctx, cancel := context.WithCancel(ctx) 292 | var wg sync.WaitGroup 293 | wg.Add(1) 294 | go func() { 295 | defer wg.Done() 296 | <-ctx.Done() 297 | closeListener() 298 | }() 299 | defer func() { 300 | cancel() 301 | closeListener() 302 | wg.Wait() 303 | }() 304 | 305 | for { 306 | log.Debugf(ctx, "Waiting for connection on %v", l.Addr()) 307 | conn, err := l.Accept() 308 | if err != nil { 309 | log.Debugf(ctx, "Accept on %v returned error (stopping listener): %v", l.Addr(), err) 310 | return 311 | } 312 | log.Debugf(ctx, "Accepted connection from %v on %v", conn.RemoteAddr(), conn.LocalAddr()) 313 | wg.Add(1) 314 | go func() { 315 | defer wg.Done() 316 | handleTCPConn(ctx, conn, lb) 317 | }() 318 | } 319 | } 320 | 321 | func handleTCPConn(ctx context.Context, clientConn net.Conn, lb *loadBalancer) { 322 | defer func() { 323 | if err := clientConn.Close(); err != nil { 324 | log.Errorf(ctx, "%v", err) 325 | } 326 | }() 327 | 328 | pickCtx, cancelPick := context.WithTimeout(ctx, 30*time.Second) 329 | backendAddr, err := lb.pick(pickCtx) 330 | cancelPick() 331 | if err != nil { 332 | log.Warnf(ctx, "Unable to find suitable backend for %v on %v: %v", clientConn.RemoteAddr(), clientConn.LocalAddr(), err) 333 | return 334 | } 335 | log.Debugf(ctx, "Picked backend %v for %v on %v", backendAddr, clientConn.RemoteAddr(), clientConn.LocalAddr()) 336 | backendConn, err := new(net.Dialer).DialContext(ctx, "tcp", backendAddr.String()) 337 | if err != nil { 338 | log.Warnf(ctx, "Connect to backend for %v on %v: %v", clientConn.RemoteAddr(), clientConn.LocalAddr(), err) 339 | return 340 | } 341 | 342 | grp, ctx := errgroup.WithContext(ctx) 343 | grp.Go(func() error { 344 | <-ctx.Done() 345 | clientConn.SetDeadline(time.Now()) 346 | backendConn.SetDeadline(time.Now()) 347 | return nil 348 | }) 349 | grp.Go(func() error { 350 | if _, err := io.Copy(backendConn, clientConn); err != nil { 351 | log.Warnf(ctx, "Connection for %v on %v (backend %v): %v", clientConn.RemoteAddr(), clientConn.LocalAddr(), backendAddr, err) 352 | } 353 | return errConnDone 354 | }) 355 | grp.Go(func() error { 356 | if _, err := io.Copy(clientConn, backendConn); err != nil { 357 | log.Warnf(ctx, "Connection for %v on %v (backend %v): %v", clientConn.RemoteAddr(), clientConn.LocalAddr(), backendAddr, err) 358 | } 359 | return errConnDone 360 | }) 361 | grp.Wait() 362 | } 363 | 364 | func tailscaleLogf(ctx context.Context) logger.Logf { 365 | return func(format string, args ...any) { 366 | ent := log.Entry{Time: time.Now(), Level: tailscaleLogLevel} 367 | if _, file, line, ok := runtime.Caller(2); ok { 368 | ent.File = file 369 | ent.Line = line 370 | } 371 | logger := log.Default() 372 | if !logger.LogEnabled(ent) { 373 | return 374 | } 375 | ent.Msg = fmt.Sprintf(format, args...) 376 | if n := len(ent.Msg); n > 0 && ent.Msg[n-1] == '\n' { 377 | ent.Msg = ent.Msg[:n-1] 378 | } 379 | logger.Log(ctx, ent) 380 | } 381 | } 382 | 383 | func reverseSlice[T any](s []T) { 384 | for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { 385 | s[i], s[j] = s[j], s[i] 386 | } 387 | } 388 | 389 | var errConnDone = errors.New("connection finished") 390 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Ross Light 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | let 18 | lock = builtins.fromJSON (builtins.readFile ./flake.lock); 19 | flakeCompatSource = fetchTarball { 20 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 21 | sha256 = lock.nodes.flake-compat.locked.narHash; 22 | }; 23 | flakeCompat = import flakeCompatSource { src = ./.; }; 24 | in 25 | flakeCompat.shellNix 26 | -------------------------------------------------------------------------------- /signals_fallback.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | //go:build !unix 18 | 19 | package main 20 | 21 | import ( 22 | "os" 23 | ) 24 | 25 | var interruptSignals = []os.Signal{ 26 | os.Interrupt, 27 | } 28 | -------------------------------------------------------------------------------- /signals_unix.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package main 18 | 19 | import ( 20 | "os" 21 | 22 | "golang.org/x/sys/unix" 23 | ) 24 | 25 | var interruptSignals = []os.Signal{ 26 | unix.SIGINT, 27 | unix.SIGTERM, 28 | } 29 | -------------------------------------------------------------------------------- /tailscale-lb.nix: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Ross Light 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | { lib 18 | , buildGoModule 19 | , nix-gitignore 20 | }: 21 | 22 | let 23 | src = 24 | let 25 | root = ./.; 26 | patterns = nix-gitignore.withGitignoreFile extraIgnores root; 27 | extraIgnores = [ 28 | "LICENSE" 29 | "README.md" 30 | "CHANGELOG.md" 31 | ".envrc" 32 | "*.nix" 33 | "/.github/" 34 | ".vscode/" 35 | "/infra/" 36 | "result" 37 | "result-*" 38 | ]; 39 | in builtins.path { 40 | name = "tailscale-lb-source"; 41 | path = root; 42 | filter = nix-gitignore.gitignoreFilterPure (_: _: true) patterns root; 43 | }; 44 | in 45 | 46 | buildGoModule { 47 | pname = "tailscale-lb"; 48 | version = "0.4.0"; 49 | 50 | inherit src; 51 | 52 | vendorHash = "sha256-GH0qRZ/XAYmCCSCG240V6huHTbr4sdiM9KYtGGV+lKY="; 53 | 54 | ldflags = [ "-s" "-w" ]; 55 | 56 | subPackages = [ "." ]; 57 | 58 | meta = { 59 | description = "Basic load-balancer for forwarding Tailscale TCP traffic"; 60 | homepage = "https://github.com/zombiezen/tailscale-lb"; 61 | license = lib.licenses.asl20; 62 | maintainers = [ lib.maintainers.zombiezen ]; 63 | }; 64 | } 65 | --------------------------------------------------------------------------------