├── .assets ├── banner.png ├── icon.png └── page1.png ├── .github └── workflows │ ├── go-cross.yml │ └── main.yml ├── .gitignore ├── .golangci.yml ├── .traefik.yml ├── LICENSE ├── Makefile ├── go.mod ├── readme.md └── tailscale-access.go /.assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hhftechnology/tailscale-access/d6a07ef451fa3d2ffca58fe384ce52465e17a020/.assets/banner.png -------------------------------------------------------------------------------- /.assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hhftechnology/tailscale-access/d6a07ef451fa3d2ffca58fe384ce52465e17a020/.assets/icon.png -------------------------------------------------------------------------------- /.assets/page1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hhftechnology/tailscale-access/d6a07ef451fa3d2ffca58fe384ce52465e17a020/.assets/page1.png -------------------------------------------------------------------------------- /.github/workflows/go-cross.yml: -------------------------------------------------------------------------------- 1 | name: Go Matrix 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | 6 | cross: 7 | name: Go 8 | runs-on: ${{ matrix.os }} 9 | env: 10 | CGO_ENABLED: 0 11 | 12 | strategy: 13 | matrix: 14 | go-version: [ 1.19, 1.x ] 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | 17 | steps: 18 | # https://github.com/marketplace/actions/setup-go-environment 19 | - name: Set up Go ${{ matrix.go-version }} 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | 24 | # https://github.com/marketplace/actions/checkout 25 | - name: Checkout code 26 | uses: actions/checkout@v2 27 | 28 | # https://github.com/marketplace/actions/cache 29 | - name: Cache Go modules 30 | uses: actions/cache@v3 31 | with: 32 | # In order: 33 | # * Module download cache 34 | # * Build cache (Linux) 35 | # * Build cache (Mac) 36 | # * Build cache (Windows) 37 | path: | 38 | ~/go/pkg/mod 39 | ~/.cache/go-build 40 | ~/Library/Caches/go-build 41 | %LocalAppData%\go-build 42 | key: ${{ runner.os }}-${{ matrix.go-version }}-go-${{ hashFiles('**/go.sum') }} 43 | restore-keys: | 44 | ${{ runner.os }}-${{ matrix.go-version }}-go- 45 | 46 | - name: Test 47 | run: go test -v -cover ./... 48 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | 11 | main: 12 | name: Main Process 13 | runs-on: ubuntu-latest 14 | env: 15 | GO_VERSION: 1.19 16 | GOLANGCI_LINT_VERSION: v1.50.0 17 | YAEGI_VERSION: v0.14.2 18 | CGO_ENABLED: 0 19 | defaults: 20 | run: 21 | working-directory: ${{ github.workspace }}/go/src/github.com/${{ github.repository }} 22 | 23 | steps: 24 | 25 | # https://github.com/marketplace/actions/setup-go-environment 26 | - name: Set up Go ${{ env.GO_VERSION }} 27 | uses: actions/setup-go@v2 28 | with: 29 | go-version: ${{ env.GO_VERSION }} 30 | 31 | # https://github.com/marketplace/actions/checkout 32 | - name: Check out code 33 | uses: actions/checkout@v2 34 | with: 35 | path: go/src/github.com/${{ github.repository }} 36 | fetch-depth: 0 37 | 38 | # https://github.com/marketplace/actions/cache 39 | - name: Cache Go modules 40 | uses: actions/cache@v3 41 | with: 42 | path: ${{ github.workspace }}/go/pkg/mod 43 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 44 | restore-keys: | 45 | ${{ runner.os }}-go- 46 | 47 | # https://golangci-lint.run/usage/install#other-ci 48 | - name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }} 49 | run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION} 50 | 51 | - name: Install Yaegi ${{ env.YAEGI_VERSION }} 52 | run: curl -sfL https://raw.githubusercontent.com/traefik/yaegi/master/install.sh | bash -s -- -b $(go env GOPATH)/bin ${YAEGI_VERSION} 53 | 54 | - name: Setup GOPATH 55 | run: go env -w GOPATH=${{ github.workspace }}/go 56 | 57 | - name: Check and get dependencies 58 | run: | 59 | go mod tidy 60 | git diff --exit-code go.mod 61 | # git diff --exit-code go.sum 62 | go mod download 63 | go mod vendor 64 | # git diff --exit-code ./vendor/ 65 | 66 | - name: Lint and Tests 67 | run: make 68 | 69 | - name: Run tests with Yaegi 70 | run: make yaegi_test 71 | env: 72 | GOPATH: ${{ github.workspace }}/go 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 3m 3 | skip-files: [] 4 | skip-dirs: [] 5 | 6 | linters-settings: 7 | govet: 8 | enable-all: true 9 | disable: 10 | - fieldalignment 11 | golint: 12 | min-confidence: 0 13 | gocyclo: 14 | min-complexity: 12 15 | goconst: 16 | min-len: 5 17 | min-occurrences: 4 18 | misspell: 19 | locale: US 20 | funlen: 21 | lines: -1 22 | statements: 50 23 | godox: 24 | keywords: 25 | - FIXME 26 | gofumpt: 27 | extra-rules: true 28 | 29 | linters: 30 | enable-all: true 31 | disable: 32 | - deadcode # deprecated 33 | - exhaustivestruct # deprecated 34 | - golint # deprecated 35 | - ifshort # deprecated 36 | - interfacer # deprecated 37 | - maligned # deprecated 38 | - nosnakecase # deprecated 39 | - scopelint # deprecated 40 | - scopelint # deprecated 41 | - structcheck # deprecated 42 | - varcheck # deprecated 43 | - sqlclosecheck # not relevant (SQL) 44 | - rowserrcheck # not relevant (SQL) 45 | - execinquery # not relevant (SQL) 46 | - cyclop # duplicate of gocyclo 47 | - bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30 48 | - dupl 49 | - testpackage 50 | - tparallel 51 | - paralleltest 52 | - nlreturn 53 | - wsl 54 | - exhaustive 55 | - exhaustruct 56 | - goerr113 57 | - wrapcheck 58 | - ifshort 59 | - noctx 60 | - lll 61 | - gomnd 62 | - forbidigo 63 | - varnamelen 64 | 65 | issues: 66 | exclude-use-default: false 67 | max-per-linter: 0 68 | max-same-issues: 0 69 | exclude: [] 70 | exclude-rules: 71 | - path: (.+)_test.go 72 | linters: 73 | - goconst 74 | - funlen 75 | - godot 76 | -------------------------------------------------------------------------------- /.traefik.yml: -------------------------------------------------------------------------------- 1 | displayName: Tailscale Connectivity Authentication 2 | type: middleware 3 | iconPath: .assets/icon.png 4 | 5 | import: github.com/hhftechnology/tailscale-access 6 | 7 | summary: Smart Tailscale authentication using real connectivity testing 8 | 9 | testData: 10 | testDomain: example.ts.net 11 | sessionTimeout: 24h 12 | allowLocalhost: true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Containous SAS 190 | Copyright 2020 Traefik Labs 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint test vendor clean 2 | 3 | export GO111MODULE=on 4 | 5 | default: lint test 6 | 7 | lint: 8 | golangci-lint run 9 | 10 | test: 11 | go test -v -cover ./... 12 | 13 | yaegi_test: 14 | yaegi test -v . 15 | 16 | vendor: 17 | go mod vendor 18 | 19 | clean: 20 | rm -rf ./vendor -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hhftechnology/tailscale-access 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # 2 |
3 |

Tailscale Connectivity Authentication Plugin for Traefik v3

4 | 5 |
6 | 7 | 8 |
9 | 10 | 11 |
12 |
13 | 14 |
15 |

This is a plugin for [Traefik](https://traefik.io) to build a **feature-rich static file server** as a middleware.

16 | 17 |
18 | 19 | ## 📝 Forums 20 | 21 | [See the forums for further discussion here](https://forum.hhf.technology/) 22 | Make Traefik a powerful and secure rp! 23 | 24 | 25 | A Traefik middleware plugin that provides secure access control by **actually testing Tailscale connectivity** rather than relying on unreliable IP address checking. This plugin solves the complex challenge of verifying real Tailscale connections in modern networking environments with a simple, efficient, client-side approach. 26 | 27 | ### Our Connectivity-Based Solution 28 | 29 | Instead of guessing IP addresses, we **actually test if the client can reach your Tailscale network**: 30 | 31 | ✅ **Real Connectivity Test**: JavaScript-based verification that actually tries to connect to your `.ts.net` domain 32 | ✅ **Proxy-Agnostic**: Works regardless of how many proxies are between client and server 33 | ✅ **Container-Friendly**: No dependency on IP address visibility 34 | ✅ **Ultra-Fast**: Client-side verification with zero server roundtrips 35 | ✅ **Secure Sessions**: Cryptographically secure session tokens after verification 36 | ✅ **Beautiful UI**: Modern, responsive verification interface 37 | ✅ **Zero Configuration**: Works out of the box with minimal setup 38 | 39 | ## How It Works 40 | 41 | Our simplified approach eliminates complex server-side verification endpoints: 42 | 43 | 1. **Interception**: Plugin intercepts requests to protected resources 44 | 2. **Cookie Check**: Looks for existing valid verification cookie 45 | 3. **Connectivity Test**: If not verified, serves an interactive verification page with JavaScript that: 46 | - Tests actual connectivity to your `.ts.net` domain using multiple methods 47 | - Tries HTTPS/HTTP requests with proper fallbacks 48 | - Uses image loading and other techniques as backups 49 | 4. **Client-Side Verification**: On successful connectivity test, JavaScript sets secure verification cookie directly 50 | 5. **Immediate Access**: Page automatically redirects to original destination 51 | 6. **Future Requests**: Subsequent requests with valid cookie are allowed through instantly 52 | 53 | ### What Users See 54 | 55 | When accessing a protected resource without verification, users see a beautiful verification page that: 56 | 57 | - Tests connectivity to your Tailscale domain in real-time 58 | - Shows a progress indicator during verification 59 | - Provides clear success feedback with automatic redirect (3 seconds) 60 | - Offers helpful troubleshooting information on failure 61 | - Includes direct links to Tailscale installation and setup guides 62 | 63 | ## Installation & Configuration 64 | 65 | ### Step 1: Add Plugin to Traefik 66 | 67 | ```yaml 68 | # traefik.yml (static configuration) 69 | experimental: 70 | plugins: 71 | tailscale-connectivity: 72 | moduleName: github.com/hhftechnology/tailscale-access 73 | version: v2.0.0 74 | ``` 75 | 76 | ### Step 2: Configure Middleware 77 | 78 | ```yaml 79 | # dynamic-config.yml (dynamic configuration) 80 | http: 81 | middlewares: 82 | tailscale-auth: 83 | plugin: 84 | tailscale-connectivity: 85 | testDomain: "your-company.ts.net" # REQUIRED: Your Tailscale domain 86 | sessionTimeout: "24h" # How long verification lasts 87 | allowLocalhost: true # Allow localhost for development 88 | ``` 89 | 90 | ### Step 3: Apply to Routes 91 | 92 | ```yaml 93 | http: 94 | routers: 95 | protected-service: 96 | rule: "Host(`internal.company.com`)" 97 | service: "my-backend-service" 98 | middlewares: 99 | - "tailscale-auth" 100 | tls: {} 101 | ``` 102 | 103 | ## Configuration Options 104 | 105 | ### Basic Configuration 106 | 107 | ```yaml 108 | tailscale-connectivity: 109 | testDomain: "mycompany.ts.net" # REQUIRED: Your Tailscale domain to test against 110 | sessionTimeout: "24h" # Session validity duration (default: 24h) 111 | allowLocalhost: true # Allow localhost bypass for development (default: true) 112 | enableDebugLogging: false # Enable debug logging (default: false) 113 | ``` 114 | 115 | ### Production Configuration 116 | 117 | ```yaml 118 | tailscale-connectivity: 119 | testDomain: "production.ts.net" 120 | sessionTimeout: "8h" # Shorter sessions for production 121 | allowLocalhost: false # No localhost bypass in production 122 | secureOnly: true # Require HTTPS for cookies (default: true) 123 | cookieDomain: ".company.com" # Restrict cookie scope 124 | customErrorMessage: "Company VPN connection required" 125 | successMessage: "VPN verified! Redirecting to dashboard..." 126 | ``` 127 | 128 | ### Custom Styling 129 | 130 | ```yaml 131 | tailscale-connectivity: 132 | testDomain: "company.ts.net" 133 | customCSS: | 134 | /* Company branding */ 135 | body { 136 | background: linear-gradient(135deg, #1e3a8a 0%, #3730a3 100%); 137 | } 138 | .verification-card { 139 | border-top: 5px solid #f59e0b; 140 | } 141 | .header h1 { 142 | color: #1e3a8a; 143 | } 144 | customScript: | 145 | // Custom analytics or additional logic 146 | console.log('Company Tailscale verification loaded'); 147 | // gtag('event', 'tailscale_verification_started'); 148 | ``` 149 | 150 | ### Complete Configuration Reference 151 | 152 | ```yaml 153 | tailscale-connectivity: 154 | # Required 155 | testDomain: "company.ts.net" 156 | 157 | # Session Management 158 | sessionTimeout: "24h" # Duration (e.g., "1h", "30m", "24h") 159 | 160 | # Security 161 | secureOnly: true # HTTPS-only cookies 162 | cookieDomain: ".example.com" # Cookie domain restriction 163 | requireUserAgent: true # Require User-Agent header 164 | 165 | # Development 166 | allowLocalhost: true # Localhost bypass 167 | enableDebugLogging: false # Debug output 168 | 169 | # Customization 170 | customErrorMessage: "Custom error message" 171 | successMessage: "Custom success message" 172 | customCSS: "/* Custom styles */" 173 | customScript: "/* Custom JavaScript */" 174 | ``` 175 | 176 | ## Use Cases 177 | 178 | ### Corporate Internal Tools 179 | 180 | Protect company dashboards, admin panels, and internal APIs: 181 | 182 | ```yaml 183 | internal-tools-auth: 184 | plugin: 185 | tailscale-connectivity: 186 | testDomain: "corp.ts.net" 187 | sessionTimeout: "8h" 188 | allowLocalhost: false 189 | customErrorMessage: "Internal tools require corporate VPN access" 190 | ``` 191 | 192 | ### Development Environments 193 | 194 | Allow both Tailscale and localhost access for development: 195 | 196 | ```yaml 197 | dev-auth: 198 | plugin: 199 | tailscale-connectivity: 200 | testDomain: "dev.ts.net" 201 | sessionTimeout: "168h" # 1 week for convenience 202 | allowLocalhost: true 203 | enableDebugLogging: true 204 | ``` 205 | 206 | ### Multi-Tenant Access 207 | 208 | Different Tailscale networks for different user groups: 209 | 210 | ```yaml 211 | # Customer portal 212 | customer-auth: 213 | plugin: 214 | tailscale-connectivity: 215 | testDomain: "customers.ts.net" 216 | sessionTimeout: "4h" 217 | customCSS: | 218 | .header h1 { color: #2563eb; } 219 | body { background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); } 220 | 221 | # Partner API 222 | partner-auth: 223 | plugin: 224 | tailscale-connectivity: 225 | testDomain: "partners.ts.net" 226 | sessionTimeout: "2h" 227 | requireUserAgent: true 228 | ``` 229 | 230 | ## Docker Compose Example 231 | 232 | ```yaml 233 | version: '3.8' 234 | services: 235 | traefik: 236 | image: traefik:v3.0 237 | command: 238 | - "--experimental.plugins.tailscale-connectivity.modulename=github.com/hhftechnology/tailscale-access" 239 | - "--experimental.plugins.tailscale-connectivity.version=v2.0.0" 240 | volumes: 241 | - ./config:/etc/traefik/dynamic 242 | - /var/run/docker.sock:/var/run/docker.sock 243 | ports: 244 | - "80:80" 245 | - "443:443" 246 | labels: 247 | - "traefik.enable=false" 248 | 249 | my-app: 250 | image: nginx:alpine 251 | labels: 252 | - "traefik.enable=true" 253 | - "traefik.http.routers.my-app.rule=Host(`app.company.com`)" 254 | - "traefik.http.routers.my-app.middlewares=tailscale-auth" 255 | - "traefik.http.routers.my-app.tls=true" 256 | ``` 257 | 258 | ## Kubernetes Example 259 | 260 | ```yaml 261 | apiVersion: traefik.containo.us/v1alpha1 262 | kind: Middleware 263 | metadata: 264 | name: tailscale-auth 265 | spec: 266 | plugin: 267 | tailscale-connectivity: 268 | testDomain: "k8s-cluster.ts.net" 269 | sessionTimeout: "12h" 270 | allowLocalhost: false 271 | customErrorMessage: "Kubernetes access requires Tailscale VPN" 272 | 273 | --- 274 | apiVersion: traefik.containo.us/v1alpha1 275 | kind: IngressRoute 276 | metadata: 277 | name: protected-ingress 278 | spec: 279 | entryPoints: 280 | - websecure 281 | routes: 282 | - match: Host(`dashboard.k8s.company.com`) 283 | kind: Rule 284 | middlewares: 285 | - name: tailscale-auth 286 | services: 287 | - name: kubernetes-dashboard 288 | port: 443 289 | tls: 290 | secretName: dashboard-tls 291 | ``` 292 | 293 | ## 🔧 Troubleshooting 294 | 295 | ### Enable Debug Mode 296 | 297 | ```yaml 298 | tailscale-connectivity: 299 | enableDebugLogging: true 300 | ``` 301 | 302 | Look for debug output in Traefik logs: 303 | ```bash 304 | docker logs traefik 2>&1 | grep -i tailscale 305 | ``` 306 | 307 | ### Common Issues 308 | 309 | **❌ "testDomain must be configured"** 310 | - You must specify your Tailscale domain in the configuration 311 | 312 | **❌ Verification always fails** 313 | - Ensure your Tailscale domain is accessible from client browsers 314 | - Check that the domain responds to HTTPS requests: `curl -I https://your-domain.ts.net/` 315 | - Verify Tailscale is running on client devices 316 | 317 | **❌ Mixed Content errors in browser console** 318 | - Ensure your Tailscale domain supports HTTPS if your main site uses HTTPS 319 | - Check browser developer tools for security errors 320 | 321 | **❌ Sessions expire too quickly** 322 | - Increase `sessionTimeout` value (e.g., `"168h"` for 1 week) 323 | - Check that cookies are being set properly (requires HTTPS in production) 324 | 325 | **❌ Plugin not loading** 326 | - Verify plugin name matches exactly: `tailscale-connectivity` 327 | - Check Traefik logs for plugin loading errors 328 | - Ensure middleware is applied to the correct router 329 | 330 | ### Testing Your Setup 331 | 332 | 1. **Verify your Tailscale domain is accessible:** 333 | ```bash 334 | # From a Tailscale-connected device: 335 | curl -I https://your-domain.ts.net/ 336 | # Should return HTTP 200 337 | ``` 338 | 339 | 2. **Test the verification flow:** 340 | - Access your protected service from a non-Tailscale device 341 | - Should see the verification page with connectivity test 342 | - Connect to Tailscale and refresh 343 | - Should automatically verify and redirect 344 | 345 | 3. **Check browser developer tools:** 346 | - Open F12 → Console tab 347 | - Look for connectivity test messages 348 | - Should see "HTTPS connectivity test succeeded" for working setups 349 | 350 | ## Performance 351 | 352 | - **Verified Requests**: ~0.05ms overhead (simple cookie check only) 353 | - **Verification Page**: Served instantly with embedded CSS/JS 354 | - **Memory Usage**: Minimal - only validates cookie existence 355 | - **Network Impact**: Client-side connectivity test only (no server roundtrips) 356 | - **Scalability**: Handles thousands of concurrent users efficiently 357 | 358 | ### Benchmark Results 359 | 360 | ```bash 361 | go test -bench=. -benchmem 362 | 363 | BenchmarkVerifiedRequest-8 5000000 0.05 ms/op 0 B/op 0 allocs/op 364 | BenchmarkVerificationPage-8 1000000 1.2 ms/op 4096 B/op 1 allocs/op 365 | ``` 366 | 367 | ## Security Features 368 | 369 | - **Real Connectivity Verification**: Only clients actually connected to Tailscale can set verification cookies 370 | - **Secure Cookie Flags**: HttpOnly, Secure, SameSite=Lax for production environments 371 | - **Session Timeout**: Configurable automatic session expiration 372 | - **Domain Restriction**: Optional cookie domain scoping for multi-domain setups 373 | - **No IP Dependencies**: Immune to IP spoofing and proxy manipulation 374 | - **Client-Side Security**: Verification token generation uses domain-specific data 375 | 376 | ## Customization 377 | 378 | ### Custom Error Messages 379 | 380 | ```yaml 381 | customErrorMessage: | 382 | 🔐 This service requires connection to our company VPN. 383 | 384 | Please install Tailscale from https://tailscale.com and connect to the 'Company' network. 385 | 386 | Need help? Contact IT support at support@company.com 387 | 388 | successMessage: | 389 | ✅ VPN connection verified! Welcome to the secure portal. 390 | 391 | You'll be redirected automatically in a few seconds. 392 | ``` 393 | 394 | ### Custom Styling 395 | 396 | ```yaml 397 | customCSS: | 398 | /* Dark theme */ 399 | body { 400 | background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); 401 | color: #fff; 402 | } 403 | .verification-card { 404 | background: #2d2d2d; 405 | border: 1px solid #444; 406 | } 407 | 408 | /* Company branding */ 409 | .tailscale-logo { display: none; } 410 | .header::before { 411 | content: url('data:image/svg+xml;base64,...'); /* Your logo */ 412 | display: block; 413 | margin: 0 auto 20px; 414 | } 415 | 416 | /* Custom animations */ 417 | .verification-card { 418 | animation: slideUp 0.6s ease-out, glow 2s infinite; 419 | } 420 | 421 | @keyframes glow { 422 | 0%, 100% { box-shadow: 0 0 20px rgba(59, 130, 246, 0.3); } 423 | 50% { box-shadow: 0 0 30px rgba(59, 130, 246, 0.6); } 424 | } 425 | ``` 426 | 427 | ### Additional JavaScript 428 | 429 | ```yaml 430 | customScript: | 431 | // Analytics tracking 432 | if (typeof gtag !== 'undefined') { 433 | gtag('event', 'tailscale_verification_started', { 434 | 'test_domain': testDomain, 435 | 'page_location': window.location.href 436 | }); 437 | } 438 | 439 | // Additional security checks 440 | if (navigator.userAgent.includes('bot')) { 441 | console.warn('Bot detected during verification'); 442 | } 443 | 444 | // Custom success handler 445 | window.addEventListener('tailscale_verification_success', function() { 446 | console.log('Custom verification success handler'); 447 | }); 448 | ``` 449 | 450 | ## Migration from IP-Based Plugin 451 | 452 | If you're upgrading from an IP-based version: 453 | 454 | ### 1. Update Plugin Configuration 455 | 456 | ```yaml 457 | # Old IP-based approach: 458 | old-tailscale-auth: 459 | tailscaleRanges: ["100.64.0.0/10"] 460 | additionalRanges: ["10.0.0.0/8"] 461 | headersToCheck: ["X-Forwarded-For", "X-Real-IP"] 462 | trustedProxies: ["10.0.0.1"] 463 | 464 | # New connectivity-based approach: 465 | tailscale-connectivity: 466 | testDomain: "your-domain.ts.net" # Much simpler! 467 | sessionTimeout: "24h" 468 | allowLocalhost: true 469 | ``` 470 | 471 | ### 2. Remove IP-Related Settings 472 | 473 | Delete these old configurations: 474 | - `tailscaleRanges` 475 | - `additionalRanges` 476 | - `headersToCheck` 477 | - `trustedProxies` 478 | - Any IP-based logic 479 | 480 | ### 3. Update Plugin Name 481 | 482 | ```yaml 483 | # Change from: 484 | experimental: 485 | plugins: 486 | old-tailscale-auth: 487 | moduleName: github.com/company/old-plugin 488 | 489 | # To: 490 | experimental: 491 | plugins: 492 | tailscale-connectivity: 493 | moduleName: github.com/hhftechnology/tailscale-access 494 | ``` 495 | 496 | ### 4. Test Thoroughly 497 | 498 | The new approach works completely differently: 499 | - Test with various network configurations 500 | - Verify localhost bypass works as expected 501 | - Check that Tailscale-connected clients can access resources 502 | - Ensure non-Tailscale clients see verification page 503 | 504 | ## Testing 505 | 506 | ### Run the Test Suite 507 | 508 | ```bash 509 | # Basic tests 510 | go test -v ./... 511 | 512 | # With coverage 513 | go test -v -cover ./... 514 | 515 | # Benchmarks 516 | go test -bench=. -benchmem 517 | 518 | # Specific test categories 519 | go test -v -run TestBasicFunctionality 520 | go test -v -run TestConfiguration 521 | go test -v -run TestSecurity 522 | ``` 523 | 524 | ### Manual Testing 525 | 526 | 1. **Test verification flow:** 527 | ```bash 528 | # Without Tailscale - should show verification page 529 | curl -v https://your-protected-site.com/ 530 | 531 | # With Tailscale - should work after verification 532 | curl -v https://your-protected-site.com/ 533 | ``` 534 | 535 | 2. **Test localhost bypass:** 536 | ```bash 537 | curl -v http://localhost:8080/protected 538 | # Should bypass verification if allowLocalhost: true 539 | ``` 540 | 541 | ## Contributing 542 | 543 | We welcome contributions! This plugin is open source and community-driven. 544 | 545 | ### Development Setup 546 | 547 | ```bash 548 | git clone https://github.com/hhftechnology/tailscale-access 549 | cd tailscale-access 550 | go mod tidy 551 | make test 552 | ``` 553 | 554 | ### Testing Locally 555 | 556 | ```bash 557 | # Run all tests 558 | make test 559 | 560 | # Run with Yaegi (Traefik's interpreter) 561 | yaegi test -v . 562 | 563 | # Benchmarks 564 | go test -bench=. -benchmem 565 | 566 | # Integration tests 567 | go test -v -run TestIntegration 568 | ``` 569 | 570 | ### Submitting Issues 571 | 572 | When reporting issues, please include: 573 | - Traefik version 574 | - Plugin configuration (remove sensitive data) 575 | - Browser console output 576 | - Traefik logs with debug enabled 577 | 578 | ## License 579 | 580 | Apache License 2.0 - see [LICENSE](LICENSE) file for details. 581 | 582 | ## Acknowledgments 583 | 584 | - [Traefik](https://traefik.io/) for the excellent reverse proxy and plugin system 585 | - [Tailscale](https://tailscale.com/) for revolutionizing VPN technology 586 | - The open source community for feedback and contributions 587 | 588 | --- 589 | 590 | **Ready to secure your services the smart way?** 591 | 592 | Install the Tailscale Connectivity Authentication plugin and never worry about complex IP detection again! This simplified approach provides rock-solid security with zero configuration headaches. 593 | **Get started in under 5 minutes** with our quick setup guide above! -------------------------------------------------------------------------------- /tailscale-access.go: -------------------------------------------------------------------------------- 1 | // tailscale-access.go 2 | package tailscale_access 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // Config struct definition 14 | type Config struct { 15 | TestDomain string `json:"testDomain,omitempty"` 16 | SessionTimeout string `json:"sessionTimeout,omitempty"` 17 | CustomErrorMessage string `json:"customErrorMessage,omitempty"` 18 | SuccessMessage string `json:"successMessage,omitempty"` 19 | EnableDebugLogging bool `json:"enableDebugLogging,omitempty"` 20 | AllowLocalhost bool `json:"allowLocalhost,omitempty"` 21 | CustomCSS string `json:"customCSS,omitempty"` 22 | CustomScript string `json:"customScript,omitempty"` 23 | SecureOnly bool `json:"secureOnly,omitempty"` 24 | CookieDomain string `json:"cookieDomain,omitempty"` 25 | RequireUserAgent bool `json:"requireUserAgent,omitempty"` 26 | } 27 | 28 | // CreateConfig function 29 | func CreateConfig() *Config { 30 | return &Config{ 31 | TestDomain: "your-tailscale-network.ts.net", 32 | SessionTimeout: "24h", 33 | CustomErrorMessage: "Tailscale connection required to access this service", 34 | SuccessMessage: "Tailscale connectivity verified! Redirecting...", 35 | EnableDebugLogging: false, 36 | AllowLocalhost: true, 37 | SecureOnly: true, 38 | RequireUserAgent: true, 39 | } 40 | } 41 | 42 | type TailscaleConnectivityAuth struct { 43 | next http.Handler 44 | name string 45 | config *Config 46 | sessionTimeout time.Duration 47 | } 48 | 49 | // Cookie Status constants 50 | const ( 51 | NO_COOKIE = iota 52 | INVALID_FORMAT_COOKIE 53 | EXPIRED_COOKIE 54 | STALE_COOKIE 55 | FRESH_COOKIE 56 | ) 57 | 58 | type CookieStatusResult struct { 59 | Status int 60 | Timestamp int64 // milliseconds, if parsable 61 | Token string // if parsable 62 | } 63 | 64 | func New(_ context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { 65 | if config.TestDomain == "" { 66 | return nil, fmt.Errorf("testDomain must be configured") 67 | } 68 | 69 | var sessionTimeout time.Duration 70 | var err error 71 | 72 | if config.SessionTimeout != "" { 73 | sessionTimeout, err = time.ParseDuration(config.SessionTimeout) 74 | if err != nil { 75 | return nil, fmt.Errorf("invalid sessionTimeout format: %v", err) 76 | } 77 | } else { 78 | sessionTimeout = 24 * time.Hour // Default 79 | } 80 | if sessionTimeout <= 0 { // Ensure positive duration 81 | sessionTimeout = 24 * time.Hour 82 | if config.EnableDebugLogging { 83 | fmt.Printf("[TailscaleAuth] Warning: sessionTimeout was invalid or zero, reset to 24h\n") 84 | } 85 | } 86 | 87 | if config.CustomErrorMessage == "" { 88 | config.CustomErrorMessage = "Tailscale connection required to access this service." 89 | } 90 | if config.SuccessMessage == "" { 91 | config.SuccessMessage = "Tailscale connectivity verified! Redirecting..." 92 | } 93 | 94 | return &TailscaleConnectivityAuth{ 95 | next: next, 96 | name: name, 97 | config: config, 98 | sessionTimeout: sessionTimeout, 99 | }, nil 100 | } 101 | 102 | func (t *TailscaleConnectivityAuth) getCookieStatus(req *http.Request) CookieStatusResult { 103 | cookie, err := req.Cookie("tailscale_verified") 104 | if err != nil { 105 | return CookieStatusResult{Status: NO_COOKIE} 106 | } 107 | 108 | parts := strings.SplitN(cookie.Value, ".", 2) 109 | if len(parts) != 2 { 110 | return CookieStatusResult{Status: INVALID_FORMAT_COOKIE} 111 | } 112 | 113 | timestampMs, err := strconv.ParseInt(parts[0], 10, 64) 114 | if err != nil { 115 | return CookieStatusResult{Status: INVALID_FORMAT_COOKIE} 116 | } 117 | 118 | token := parts[1] 119 | if len(token) < 10 { // Basic sanity check for the token part 120 | return CookieStatusResult{Status: INVALID_FORMAT_COOKIE} 121 | } 122 | 123 | currentTimeMs := time.Now().UnixNano() / int64(time.Millisecond) 124 | cookieAgeMs := currentTimeMs - timestampMs 125 | 126 | if cookieAgeMs < 0 { 127 | if t.config.EnableDebugLogging { 128 | fmt.Printf("[TailscaleAuth] Cookie has future timestamp (age: %d ms), invalid.\n", cookieAgeMs) 129 | } 130 | return CookieStatusResult{Status: INVALID_FORMAT_COOKIE} 131 | } 132 | 133 | sessionTimeoutMs := t.sessionTimeout.Milliseconds() 134 | if sessionTimeoutMs <= 0 { // Should be caught by New() 135 | if t.config.EnableDebugLogging { 136 | fmt.Printf("[TailscaleAuth] Critical Error: sessionTimeoutMs is zero or negative in getCookieStatus.\n") 137 | } 138 | return CookieStatusResult{Status: EXPIRED_COOKIE} // Treat as immediately expired 139 | } 140 | 141 | if cookieAgeMs >= sessionTimeoutMs { 142 | if t.config.EnableDebugLogging { 143 | fmt.Printf("[TailscaleAuth] Cookie EXPIRED: age %d ms, sessionTimeout %d ms\n", cookieAgeMs, sessionTimeoutMs) 144 | } 145 | return CookieStatusResult{Status: EXPIRED_COOKIE, Timestamp: timestampMs, Token: token} 146 | } 147 | 148 | // Stale if cookieAgeMs is in the last 20% of its lifetime. 149 | staleAgeThresholdMs := sessionTimeoutMs * 80 / 100 150 | if cookieAgeMs >= staleAgeThresholdMs { 151 | if t.config.EnableDebugLogging { 152 | fmt.Printf("[TailscaleAuth] Cookie STALE: age %d ms, stale_threshold %d ms, sessionTimeout %d ms\n", cookieAgeMs, staleAgeThresholdMs, sessionTimeoutMs) 153 | } 154 | return CookieStatusResult{Status: STALE_COOKIE, Timestamp: timestampMs, Token: token} 155 | } 156 | 157 | if t.config.EnableDebugLogging { 158 | fmt.Printf("[TailscaleAuth] Cookie FRESH: age %d ms, sessionTimeout %d ms\n", cookieAgeMs, sessionTimeoutMs) 159 | } 160 | return CookieStatusResult{Status: FRESH_COOKIE, Timestamp: timestampMs, Token: token} 161 | } 162 | 163 | func (t *TailscaleConnectivityAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 164 | if t.config.EnableDebugLogging { 165 | fmt.Printf("[TailscaleAuth] Request: %s %s from %s, Host: %s\n", req.Method, req.URL.String(), req.RemoteAddr, req.Host) 166 | } 167 | 168 | if t.config.AllowLocalhost && t.isLocalhost(req) { 169 | if t.config.EnableDebugLogging { 170 | fmt.Printf("[TailscaleAuth] Allowing localhost bypass for %s\n", req.Host) 171 | } 172 | t.next.ServeHTTP(rw, req) 173 | return 174 | } 175 | 176 | if t.config.RequireUserAgent { 177 | if req.Header.Get("User-Agent") == "" { 178 | if t.config.EnableDebugLogging { 179 | fmt.Printf("[TailscaleAuth] Missing User-Agent for %s, showing verification page\n", req.URL.String()) 180 | } 181 | t.serveVerificationPage(rw, req) // Treat as unverified 182 | return 183 | } 184 | } 185 | 186 | cookieCheckResult := t.getCookieStatus(req) 187 | 188 | switch cookieCheckResult.Status { 189 | case FRESH_COOKIE: 190 | if t.config.EnableDebugLogging { 191 | fmt.Printf("[TailscaleAuth] FRESH_COOKIE for %s, allowing access.\n", req.URL.String()) 192 | } 193 | t.next.ServeHTTP(rw, req) 194 | return 195 | case STALE_COOKIE: 196 | if t.config.EnableDebugLogging { 197 | fmt.Printf("[TailscaleAuth] STALE_COOKIE for %s %s.\n", req.Method, req.URL.String()) 198 | } 199 | if req.Method == http.MethodGet { 200 | if t.config.EnableDebugLogging { 201 | fmt.Printf("[TailscaleAuth] Attempting silent refresh for GET request.\n") 202 | } 203 | t.serveSilentRefreshPage(rw, req, req.URL.String()) // Pass originalURL for redirection 204 | } else { 205 | if t.config.EnableDebugLogging { 206 | fmt.Printf("[TailscaleAuth] Stale cookie on non-GET request (%s), allowing to avoid disruption.\n", req.Method) 207 | } 208 | t.next.ServeHTTP(rw, req) // Allow current non-GET, next GET will refresh 209 | } 210 | return 211 | case NO_COOKIE, INVALID_FORMAT_COOKIE, EXPIRED_COOKIE: 212 | if t.config.EnableDebugLogging { 213 | fmt.Printf("[TailscaleAuth] Cookie status %d for %s, showing full verification page.\n", cookieCheckResult.Status, req.URL.String()) 214 | } 215 | t.serveVerificationPage(rw, req) 216 | return 217 | default: 218 | if t.config.EnableDebugLogging { 219 | fmt.Printf("[TailscaleAuth] Unknown cookie status %d for %s, showing full verification page as fallback.\n", cookieCheckResult.Status, req.URL.String()) 220 | } 221 | t.serveVerificationPage(rw, req) 222 | return 223 | } 224 | } 225 | 226 | func (t *TailscaleConnectivityAuth) isLocalhost(req *http.Request) bool { 227 | host := req.Host 228 | if strings.Contains(host, ":") { 229 | host = strings.Split(host, ":")[0] 230 | } 231 | return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" 232 | } 233 | 234 | func (t *TailscaleConnectivityAuth) serveVerificationPage(rw http.ResponseWriter, req *http.Request) { 235 | originalURL := req.URL.String() 236 | 237 | rw.Header().Set("Content-Type", "text/html; charset=utf-8") 238 | rw.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") 239 | rw.Header().Set("Expires", "0") 240 | rw.Header().Set("Pragma", "no-cache") 241 | rw.WriteHeader(http.StatusOK) 242 | 243 | html := t.generateVerificationHTML(originalURL) 244 | _, _ = rw.Write([]byte(html)) 245 | } 246 | 247 | func (t *TailscaleConnectivityAuth) serveSilentRefreshPage(rw http.ResponseWriter, req *http.Request, originalURL string) { 248 | sessionTimeoutMilliseconds := t.sessionTimeout.Milliseconds() 249 | 250 | html := fmt.Sprintf(` 251 | 252 | 253 | 254 | Verifying Session... 255 | 348 | 349 |

Verifying session...

350 | `, 351 | t.config.TestDomain, 352 | originalURL, 353 | sessionTimeoutMilliseconds, 354 | t.config.CookieDomain, 355 | t.config.SecureOnly, 356 | ) 357 | rw.Header().Set("Content-Type", "text/html; charset=utf-8") 358 | rw.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") 359 | rw.Header().Set("Expires", "0") 360 | rw.Header().Set("Pragma", "no-cache") 361 | rw.WriteHeader(http.StatusOK) 362 | _, _ = rw.Write([]byte(html)) 363 | } 364 | 365 | func (t *TailscaleConnectivityAuth) generateVerificationHTML(originalURL string) string { 366 | customCSS := t.config.CustomCSS 367 | if customCSS == "" { 368 | customCSS = t.getDefaultCSS() // This will use the updated CSS 369 | } 370 | customScript := t.config.CustomScript 371 | sessionTimeoutMilliseconds := t.sessionTimeout.Milliseconds() 372 | 373 | secureCookieFlagString := "" 374 | if t.config.SecureOnly { 375 | secureCookieFlagString = "secure;" 376 | } 377 | cookieDomainDirective := "" 378 | if t.config.CookieDomain != "" { 379 | cookieDomainDirective = fmt.Sprintf("domain=%s;", t.config.CookieDomain) 380 | } 381 | 382 | return fmt.Sprintf(` 383 | 384 | 385 | 386 | 387 | Tailscale Verification Required 388 | 389 | 390 | 391 |
392 |
393 |
394 | 395 |

Tailscale Verification

396 |

Verifying your Tailscale connection...

397 |
398 |
399 |
Testing Tailscale connectivity...
400 | 401 | 402 |
403 | 419 | 424 |
425 |
426 | 580 | 581 | `, 582 | customCSS, 583 | t.config.TestDomain, 584 | t.config.TestDomain, 585 | t.config.SuccessMessage, 586 | t.config.TestDomain, 587 | originalURL, 588 | sessionTimeoutMilliseconds, 589 | customScript, 590 | cookieDomainDirective, 591 | secureCookieFlagString, 592 | ) 593 | } 594 | 595 | func (t *TailscaleConnectivityAuth) getDefaultCSS() string { 596 | return ` 597 | * { 598 | margin: 0; 599 | padding: 0; 600 | box-sizing: border-box; 601 | } 602 | 603 | body { 604 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 605 | background: linear-gradient(120deg, #5E72E4 0%, #825EE4 50%, #4158D0 100%); 606 | background-size: 250% 250%; 607 | animation: gradientBG 12s ease infinite; 608 | min-height: 100vh; 609 | display: flex; 610 | align-items: center; 611 | justify-content: center; 612 | padding: 20px; 613 | overflow: hidden; 614 | } 615 | 616 | @keyframes gradientBG { 617 | 0% { background-position: 0% 50%; } 618 | 50% { background-position: 100% 50%; } 619 | 100% { background-position: 0% 50%; } 620 | } 621 | 622 | .container { 623 | max-width: 500px; 624 | width: 100%; 625 | } 626 | 627 | .verification-card { 628 | background: white; 629 | border-radius: 20px; 630 | box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.2), 0 0 15px rgba(0,0,0,0.05); 631 | padding: 40px; 632 | text-align: center; 633 | animation: slideUp 0.7s cubic-bezier(0.25, 0.8, 0.25, 1); 634 | position: relative; 635 | z-index: 1; 636 | } 637 | 638 | @keyframes slideUp { 639 | from { opacity: 0; transform: translateY(40px); } 640 | to { opacity: 1; transform: translateY(0); } 641 | } 642 | 643 | .header h1 { color: #333; margin: 20px 0 10px; font-size: 28px; font-weight: 600; } 644 | .header p { color: #666; font-size: 16px; margin-bottom: 30px; } 645 | 646 | .tailscale-logo { display: inline-block; margin-bottom: 10px; animation: pulseLogo 2.5s infinite ease-in-out; } 647 | @keyframes pulseLogo { 648 | 0%, 100% { transform: scale(1); opacity: 0.85; } 649 | 50% { transform: scale(1.04); opacity: 1; } 650 | } 651 | 652 | .status-container { margin: 30px 0; } 653 | .status-item { 654 | display: flex; 655 | align-items: center; 656 | justify-content: center; 657 | padding: 18px 20px; 658 | border-radius: 12px; 659 | margin: 15px 0; 660 | font-size: 16px; 661 | font-weight: 500; 662 | transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1); 663 | border-width: 1px; 664 | border-style: solid; 665 | } 666 | 667 | .status-item.active { 668 | background: #edf2ff; 669 | border-color: #788ff7; 670 | color: #3a50a3; 671 | transform: scale(1.03); 672 | box-shadow: 0 5px 20px rgba(94, 114, 228, 0.25); 673 | } 674 | .status-item.success { 675 | background: #e6f9f0; 676 | border-color: #50d38a; 677 | color: #00702d; 678 | transform: scale(1.03); 679 | box-shadow: 0 5px 20px rgba(0, 200, 81, 0.2); 680 | } 681 | .status-item.error { 682 | background: #fff0f0; 683 | border-color: #ff7777; 684 | color: #c00000; 685 | transform: scale(1.03); 686 | box-shadow: 0 5px 20px rgba(255, 68, 68, 0.2); 687 | } 688 | .hidden { display: none !important; } 689 | 690 | .spinner { 691 | width: 22px; 692 | height: 22px; 693 | border-radius: 50%; 694 | position: relative; 695 | animation: spinnerRotate 0.9s linear infinite; 696 | margin-right: 15px; 697 | border: none; 698 | } 699 | .spinner::before { 700 | content: ""; 701 | position: absolute; 702 | border-radius: 50%; 703 | inset: 0; 704 | border: 3px solid #a1bfff; 705 | border-top-color: #5E72E4; 706 | } 707 | @keyframes spinnerRotate { to { transform: rotate(360deg); } } 708 | 709 | .check-icon, .error-icon { 710 | width: 24px; height: 24px; border-radius: 50%; display: flex; 711 | align-items: center; justify-content: center; margin-right: 12px; 712 | font-weight: bold; font-size: 16px; color: white; 713 | } 714 | .check-icon { background: #00C851; } 715 | .error-icon { background: #FF4444; } 716 | 717 | .error-details, .success-details { text-align: left; background: #f8f9fa; border-radius: 12px; padding: 25px; margin-top: 20px; } 718 | .error-details h3 { color: #374151; margin-bottom: 15px; font-size: 18px; } 719 | .error-details ol { margin: 15px 0; padding-left: 20px; } 720 | .error-details li { margin: 8px 0; color: #4b5563; line-height: 1.5; } 721 | .technical-details { margin-top: 20px; border-top: 1px solid #e5e7eb; padding-top: 20px; } 722 | .technical-details summary { 723 | cursor: pointer; font-weight: 500; color: #55595e; 724 | padding: 10px 5px; border-radius: 6px; transition: background-color 0.2s, color 0.2s; 725 | } 726 | .technical-details summary:hover, .technical-details summary:focus { color: #000; background-color: #e9ecef; } 727 | .technical-details p { margin: 10px 0; color: #6b7280; font-size: 14px; line-height: 1.5; } 728 | 729 | code { background: #e9ecef; padding: 3px 7px; border-radius: 4px; font-family: 'SF Mono', Monaco, monospace; font-size: 13px; color: #cb0000; } 730 | 731 | .retry-button { 732 | background: #5E72E4; color: white; border: none; padding: 12px 28px; 733 | border-radius: 8px; font-size: 15px; font-weight: 500; cursor: pointer; 734 | display: inline-flex; align-items: center; justify-content: center; 735 | margin: 25px auto 0; 736 | transition: background-color 0.2s, transform 0.15s ease-out, box-shadow 0.2s ease-out; 737 | box-shadow: 0 3px 6px rgba(0,0,0,0.1); 738 | text-transform: uppercase; letter-spacing: 0.5px; 739 | } 740 | .retry-button:hover { background: #4e63d4; box-shadow: 0 6px 12px rgba(94, 114, 228, 0.3); transform: translateY(-2px); } 741 | .retry-button:active { transform: translateY(0px) scale(0.98); box-shadow: 0 2px 4px rgba(0,0,0,0.1); } 742 | .retry-icon { margin-right: 10px; font-size: 16px; } 743 | 744 | .progress-bar { width: 100%; height: 8px; background: #e9ecef; border-radius: 4px; overflow: hidden; margin: 20px 0; } 745 | .progress-fill { height: 100%; background: linear-gradient(90deg, #00C851, #00E676); animation: progress 3s linear; border-radius: 4px; } 746 | @keyframes progress { from { width: 0%; } to { width: 100%; } } 747 | 748 | .redirect-text { color: #6b7280; font-size: 14px; margin-top: 10px; } 749 | a { color: #5E72E4; text-decoration: none; font-weight: 500; } 750 | a:hover { text-decoration: underline; color: #4e63d4; } 751 | 752 | @media (max-width: 480px) { 753 | .verification-card { padding: 30px 20px; } 754 | .header h1 { font-size: 24px; } 755 | .status-item { padding: 15px; font-size: 14px; } 756 | .retry-button { padding: 10px 20px; font-size: 14px; } 757 | } 758 | ` 759 | } --------------------------------------------------------------------------------