├── .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 |