diff --git a/CHANGELOG.md b/CHANGELOG.md index 4306373b7a..5a56a1361e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - `oidc.map_legacy_users` is now `false` by default [#2350](https://github.com/juanfont/headscale/pull/2350) +- Print Tailscale version instead of capability versions for outdated nodes + [#2391](https://github.com/juanfont/headscale/pull/2391) ## 0.24.2 (2025-01-30) @@ -24,8 +26,8 @@ [#2367](https://github.com/juanfont/headscale/pull/2367) - Relax username validation to allow emails [#2364](https://github.com/juanfont/headscale/pull/2364) -- Remove invalid routes and add stronger constraints for routes to avoid API panic - [#2371](https://github.com/juanfont/headscale/pull/2371) +- Remove invalid routes and add stronger constraints for routes to avoid API + panic [#2371](https://github.com/juanfont/headscale/pull/2371) - Fix panic when `derp.update_frequency` is 0 [#2368](https://github.com/juanfont/headscale/pull/2368) @@ -60,8 +62,7 @@ and have it populate to Headscale automatically the next time they log in. However, this may affect the way you reference users in policies. Headscale v0.23.0 and earlier never recorded the `iss` and `sub` fields, so all -legacy (existing) OIDC accounts _need to be migrated_ to be properly -secured. +legacy (existing) OIDC accounts _need to be migrated_ to be properly secured. #### What do I need to do to migrate? @@ -73,8 +74,8 @@ The migration will mostly be done automatically, with one exception. If your OIDC does not provide an `email_verified` claim, Headscale will ignore the `email`. This means that either the administrator will have to mark the user emails as verified, or ensure the users verify their emails. Any unverified -emails will be ignored, meaning that the users will get new accounts instead -of being migrated. +emails will be ignored, meaning that the users will get new accounts instead of +being migrated. After this exception is ensured, make all users log into Headscale with their account, and Headscale will automatically update the account record. This will @@ -175,7 +176,8 @@ This will also affect the way you - User gRPC/API [#2261](https://github.com/juanfont/headscale/pull/2261): - If you depend on a Headscale Web UI, you should wait with this update until the UI have been updated to match the new API. - - `GET /api/v1/user/{name}` and `GetUser` have been removed in favour of `ListUsers` with an ID parameter + - `GET /api/v1/user/{name}` and `GetUser` have been removed in favour of + `ListUsers` with an ID parameter - `RenameUser` and `DeleteUser` now require an ID instead of a name. ### Changes @@ -197,9 +199,12 @@ This will also affect the way you - CLI for managing users now accepts `--identifier` in addition to `--name`, usage of `--identifier` is recommended [#2261](https://github.com/juanfont/headscale/pull/2261) -- Add `dns.extra_records_path` configuration option [#2262](https://github.com/juanfont/headscale/issues/2262) -- Support client verify for DERP [#2046](https://github.com/juanfont/headscale/pull/2046) -- Add PKCE Verifier for OIDC [#2314](https://github.com/juanfont/headscale/pull/2314) +- Add `dns.extra_records_path` configuration option + [#2262](https://github.com/juanfont/headscale/issues/2262) +- Support client verify for DERP + [#2046](https://github.com/juanfont/headscale/pull/2046) +- Add PKCE Verifier for OIDC + [#2314](https://github.com/juanfont/headscale/pull/2314) ## 0.23.0 (2024-09-18) @@ -730,8 +735,8 @@ behaviour. - All machines can communicate with all machines by default - Tags should now work correctly and adding a host to Headscale should now reload the rules. - - The documentation have a [fictional example](./docs/ref/acls.md) that should cover - some use cases of the ACLs features + - The documentation have a [fictional example](./docs/ref/acls.md) that should + cover some use cases of the ACLs features ### Features @@ -749,7 +754,8 @@ behaviour. - Add IPv6 support to the prefix assigned to namespaces - Add API Key support - - Enable remote control of `headscale` via CLI [docs](./docs/ref/remote-cli.md) + - Enable remote control of `headscale` via CLI + [docs](./docs/ref/remote-cli.md) - Enable HTTP API (beta, subject to change) - OpenID Connect users will be mapped per namespaces - Each user will get its own namespace, created if it does not exist diff --git a/hscontrol/app.go b/hscontrol/app.go index 263342d769..36f7df5d0c 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -24,6 +24,7 @@ import ( grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "github.com/juanfont/headscale" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/hscontrol/capver" "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/derp" derpServer "github.com/juanfont/headscale/hscontrol/derp/server" @@ -560,6 +561,11 @@ func (h *Headscale) Serve() error { spew.Dump(h.cfg) } + log.Info(). + Caller(). + Str("minimum_version", capver.TailscaleVersion(MinimumCapVersion)). + Msg("Clients with a lower minimum version will be rejected") + // Fetch an initial DERP Map before we start serving h.DERPMap = derp.GetDERPMap(h.cfg.DERP) h.mapper = mapper.NewMapper(h.db, h.cfg, h.DERPMap, h.nodeNotifier, h.polMan) diff --git a/hscontrol/capver/capver.go b/hscontrol/capver/capver.go new file mode 100644 index 0000000000..8dc7a437f5 --- /dev/null +++ b/hscontrol/capver/capver.go @@ -0,0 +1,92 @@ +package capver + +import ( + "sort" + "strings" + + xmaps "golang.org/x/exp/maps" + "tailscale.com/tailcfg" + "tailscale.com/util/set" +) + +func tailscaleVersSorted() []string { + vers := xmaps.Keys(tailscaleToCapVer) + sort.Strings(vers) + return vers +} + +func capVersSorted() []tailcfg.CapabilityVersion { + capVers := xmaps.Keys(capVerToTailscaleVer) + sort.Slice(capVers, func(i, j int) bool { + return capVers[i] < capVers[j] + }) + return capVers +} + +// TailscaleVersion returns the Tailscale version for the given CapabilityVersion. +func TailscaleVersion(ver tailcfg.CapabilityVersion) string { + return capVerToTailscaleVer[ver] +} + +// CapabilityVersion returns the CapabilityVersion for the given Tailscale version. +func CapabilityVersion(ver string) tailcfg.CapabilityVersion { + if !strings.HasPrefix(ver, "v") { + ver = "v" + ver + } + return tailscaleToCapVer[ver] +} + +// TailscaleLatest returns the n latest Tailscale versions. +func TailscaleLatest(n int) []string { + if n <= 0 { + return nil + } + + tsSorted := tailscaleVersSorted() + + if n > len(tsSorted) { + return tsSorted + } + + return tsSorted[len(tsSorted)-n:] +} + +// TailscaleLatestMajorMinor returns the n latest Tailscale versions (e.g. 1.80). +func TailscaleLatestMajorMinor(n int, stripV bool) []string { + if n <= 0 { + return nil + } + + majors := set.Set[string]{} + for _, vers := range tailscaleVersSorted() { + if stripV { + vers = strings.TrimPrefix(vers, "v") + } + v := strings.Split(vers, ".") + majors.Add(v[0] + "." + v[1]) + } + + majorSl := majors.Slice() + sort.Strings(majorSl) + + if n > len(majorSl) { + return majorSl + } + + return majorSl[len(majorSl)-n:] +} + +// CapVerLatest returns the n latest CapabilityVersions. +func CapVerLatest(n int) []tailcfg.CapabilityVersion { + if n <= 0 { + return nil + } + + s := capVersSorted() + + if n > len(s) { + return s + } + + return s[len(s)-n:] +} diff --git a/hscontrol/capver/capver_generated.go b/hscontrol/capver/capver_generated.go new file mode 100644 index 0000000000..d5a1f3d987 --- /dev/null +++ b/hscontrol/capver/capver_generated.go @@ -0,0 +1,54 @@ +package capver + +//Generated DO NOT EDIT + +import "tailscale.com/tailcfg" + +var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{ + "v1.44.3": 63, + "v1.56.1": 82, + "v1.58.0": 85, + "v1.58.1": 85, + "v1.58.2": 85, + "v1.60.0": 87, + "v1.60.1": 87, + "v1.62.0": 88, + "v1.62.1": 88, + "v1.64.0": 90, + "v1.64.1": 90, + "v1.64.2": 90, + "v1.66.0": 95, + "v1.66.1": 95, + "v1.66.2": 95, + "v1.66.3": 95, + "v1.66.4": 95, + "v1.68.0": 97, + "v1.68.1": 97, + "v1.68.2": 97, + "v1.70.0": 102, + "v1.72.0": 104, + "v1.72.1": 104, + "v1.74.0": 106, + "v1.74.1": 106, + "v1.76.0": 106, + "v1.76.1": 106, + "v1.76.6": 106, + "v1.78.0": 109, + "v1.78.1": 109, +} + + +var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{ + 63: "v1.44.3", + 82: "v1.56.1", + 85: "v1.58.0", + 87: "v1.60.0", + 88: "v1.62.0", + 90: "v1.64.0", + 95: "v1.66.0", + 97: "v1.68.0", + 102: "v1.70.0", + 104: "v1.72.0", + 106: "v1.74.0", + 109: "v1.78.0", +} diff --git a/hscontrol/capver/capver_test.go b/hscontrol/capver/capver_test.go new file mode 100644 index 0000000000..8d4659e1d3 --- /dev/null +++ b/hscontrol/capver/capver_test.go @@ -0,0 +1,53 @@ +package capver + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "tailscale.com/tailcfg" +) + +func TestTailscaleLatestMajorMinor(t *testing.T) { + tests := []struct { + n int + stripV bool + expected []string + }{ + {3, false, []string{"v1.74", "v1.76", "v1.78"}}, + {2, true, []string{"1.76", "1.78"}}, + {0, false, nil}, + } + + for _, test := range tests { + t.Run("", func(t *testing.T) { + output := TailscaleLatestMajorMinor(test.n, test.stripV) + if diff := cmp.Diff(output, test.expected); diff != "" { + t.Errorf("TailscaleLatestMajorMinor(%d, %v) mismatch (-want +got):\n%s", test.n, test.stripV, diff) + } + }) + } +} + +func TestCapVerMinimumTailscaleVersion(t *testing.T) { + tests := []struct { + input tailcfg.CapabilityVersion + expected string + }{ + {85, "v1.58.0"}, + {90, "v1.64.0"}, + {95, "v1.66.0"}, + {106, "v1.74.0"}, + {109, "v1.78.0"}, + {9001, ""}, // Test case for a version higher than any in the map + {60, ""}, // Test case for a version lower than any in the map + } + + for _, test := range tests { + t.Run("", func(t *testing.T) { + output := TailscaleVersion(test.input) + if output != test.expected { + t.Errorf("CapVerFromTailscaleVersion(%d) = %s; want %s", test.input, output, test.expected) + } + }) + } +} diff --git a/hscontrol/capver/gen/main.go b/hscontrol/capver/gen/main.go new file mode 100644 index 0000000000..3b31686d79 --- /dev/null +++ b/hscontrol/capver/gen/main.go @@ -0,0 +1,157 @@ +package main + +//go:generate go run main.go + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "sort" + "strconv" + "strings" + + xmaps "golang.org/x/exp/maps" + "tailscale.com/tailcfg" +) + +const ( + releasesURL = "https://api.github.com/repos/tailscale/tailscale/releases" + rawFileURL = "https://github.com/tailscale/tailscale/raw/refs/tags/%s/tailcfg/tailcfg.go" + outputFile = "../capver_generated.go" +) + +type Release struct { + Name string `json:"name"` +} + +func getCapabilityVersions() (map[string]tailcfg.CapabilityVersion, error) { + // Fetch the releases + resp, err := http.Get(releasesURL) + if err != nil { + return nil, fmt.Errorf("error fetching releases: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + var releases []Release + err = json.Unmarshal(body, &releases) + if err != nil { + return nil, fmt.Errorf("error unmarshalling JSON: %w", err) + } + + // Regular expression to find the CurrentCapabilityVersion line + re := regexp.MustCompile(`const CurrentCapabilityVersion CapabilityVersion = (\d+)`) + + versions := make(map[string]tailcfg.CapabilityVersion) + + for _, release := range releases { + version := strings.TrimSpace(release.Name) + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + + // Fetch the raw Go file + rawURL := fmt.Sprintf(rawFileURL, version) + resp, err := http.Get(rawURL) + if err != nil { + fmt.Printf("Error fetching raw file for version %s: %v\n", version, err) + continue + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Printf("Error reading raw file for version %s: %v\n", version, err) + continue + } + + // Find the CurrentCapabilityVersion + matches := re.FindStringSubmatch(string(body)) + if len(matches) > 1 { + capabilityVersionStr := matches[1] + capabilityVersion, _ := strconv.Atoi(capabilityVersionStr) + versions[version] = tailcfg.CapabilityVersion(capabilityVersion) + } else { + fmt.Printf("Version: %s, CurrentCapabilityVersion not found\n", version) + } + } + + return versions, nil +} + +func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion) error { + // Open the output file + file, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("error creating file: %w", err) + } + defer file.Close() + + // Write the package declaration and variable + file.WriteString("package capver\n\n") + file.WriteString("//Generated DO NOT EDIT\n\n") + file.WriteString(`import "tailscale.com/tailcfg"`) + file.WriteString("\n\n") + file.WriteString("var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{\n") + + sortedVersions := xmaps.Keys(versions) + sort.Strings(sortedVersions) + for _, version := range sortedVersions { + file.WriteString(fmt.Sprintf("\t\"%s\": %d,\n", version, versions[version])) + } + file.WriteString("}\n") + + file.WriteString("\n\n") + file.WriteString("var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{\n") + + capVarToTailscaleVer := make(map[tailcfg.CapabilityVersion]string) + for _, v := range sortedVersions { + cap := versions[v] + log.Printf("cap for v: %d, %s", cap, v) + + // If it is already set, skip and continue, + // we only want the first tailscale vsion per + // capability vsion. + if _, ok := capVarToTailscaleVer[cap]; ok { + log.Printf("Skipping %d, %s", cap, v) + continue + } + log.Printf("Storing %d, %s", cap, v) + capVarToTailscaleVer[cap] = v + } + + capsSorted := xmaps.Keys(capVarToTailscaleVer) + sort.Slice(capsSorted, func(i, j int) bool { + return capsSorted[i] < capsSorted[j] + }) + for _, capVer := range capsSorted { + file.WriteString(fmt.Sprintf("\t%d:\t\t\"%s\",\n", capVer, capVarToTailscaleVer[capVer])) + } + file.WriteString("}\n") + + return nil +} + +func main() { + versions, err := getCapabilityVersions() + if err != nil { + fmt.Println("Error:", err) + return + } + + err = writeCapabilityVersionsToFile(versions) + if err != nil { + fmt.Println("Error writing to file:", err) + return + } + + fmt.Println("Capability versions written to", outputFile) +} diff --git a/hscontrol/noise.go b/hscontrol/noise.go index b9107f1f53..b4e90f31d0 100644 --- a/hscontrol/noise.go +++ b/hscontrol/noise.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/gorilla/mux" + "github.com/juanfont/headscale/hscontrol/capver" "github.com/juanfont/headscale/hscontrol/types" "github.com/rs/zerolog/log" "golang.org/x/net/http2" @@ -155,10 +156,19 @@ func isSupportedVersion(version tailcfg.CapabilityVersion) bool { return version >= MinimumCapVersion } -func rejectUnsupported(writer http.ResponseWriter, version tailcfg.CapabilityVersion) bool { +func rejectUnsupported(writer http.ResponseWriter, version tailcfg.CapabilityVersion, mkey key.MachinePublic, nkey key.NodePublic) bool { // Reject unsupported versions if !isSupportedVersion(version) { - httpError(writer, nil, "unsupported client version", http.StatusBadRequest) + log.Error(). + Caller(). + Int("minimum_cap_ver", int(MinimumCapVersion)). + Int("client_cap_ver", int(version)). + Str("minimum_version", capver.TailscaleVersion(MinimumCapVersion)). + Str("client_version", capver.TailscaleVersion(version)). + Str("node_key", nkey.ShortString()). + Str("machine_key", mkey.ShortString()). + Msg("unsupported client connected") + http.Error(writer, "unsupported client version", http.StatusBadRequest) return true } @@ -188,7 +198,7 @@ func (ns *noiseServer) NoisePollNetMapHandler( } // Reject unsupported versions - if rejectUnsupported(writer, mapRequest.Version) { + if rejectUnsupported(writer, mapRequest.Version, ns.machineKey, mapRequest.NodeKey) { return } @@ -233,7 +243,7 @@ func (ns *noiseServer) NoiseRegistrationHandler( } // Reject unsupported versions - if rejectUnsupported(writer, registerRequest.Version) { + if rejectUnsupported(writer, registerRequest.Version, ns.machineKey, registerRequest.NodeKey) { return } diff --git a/integration/scenario.go b/integration/scenario.go index e45446a719..93d1f2affd 100644 --- a/integration/scenario.go +++ b/integration/scenario.go @@ -12,6 +12,7 @@ import ( "time" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/hscontrol/capver" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/integration/dockertestutil" "github.com/juanfont/headscale/integration/dsic" @@ -51,53 +52,6 @@ var ( errNoUserAvailable = errors.New("no user available") errNoClientFound = errors.New("client not found") - // Tailscale started adding TS2021 support in CapabilityVersion>=28 (v1.24.0), but - // proper support in Headscale was only added for CapabilityVersion>=39 clients (v1.30.0). - tailscaleVersions2021 = map[string]bool{ - "head": true, - "unstable": true, - "1.74": true, // CapVer: 106 - "1.72": true, // CapVer: 104 - "1.70": true, // CapVer: 102 - "1.68": true, // CapVer: 97 - "1.66": true, // CapVer: 95 - "1.64": true, // CapVer: 90 - "1.62": true, // CapVer: 88 - "1.60": true, // CapVer: 87 - "1.58": true, // CapVer: 85 - "1.56": true, // Oldest supported version, CapVer: 82 - "1.54": false, // CapVer: 79 - "1.52": false, // CapVer: 79 - "1.50": false, // CapVer: 74 - "1.48": false, // CapVer: 68 - "1.46": false, // CapVer: 65 - "1.44": false, // CapVer: 63 - "1.42": false, // CapVer: 61 - "1.40": false, // CapVer: 61 - "1.38": false, // CapVer: 58 - "1.36": false, // CapVer: 56 - "1.34": false, // CapVer: 51 - "1.32": false, // CapVer: 46 - "1.30": false, - } - - tailscaleVersions2019 = map[string]bool{ - "1.28": false, - "1.26": false, - "1.24": false, // Tailscale SSH - "1.22": false, - "1.20": false, - "1.18": false, - } - - // tailscaleVersionsUnavailable = []string{ - // // These versions seem to fail when fetching from apt. - // "1.14.6", - // "1.12.4", - // "1.10.2", - // "1.8.7", - // }. - // AllVersions represents a list of Tailscale versions the suite // uses to test compatibility with the ControlServer. // @@ -107,10 +61,7 @@ var ( // // The rest of the version represents Tailscale versions that can be // found in Tailscale's apt repository. - AllVersions = append( - enabledVersions(tailscaleVersions2021), - enabledVersions(tailscaleVersions2019)..., - ) + AllVersions = append([]string{"head", "unstable"}, capver.TailscaleLatestMajorMinor(10, true)...) // MustTestVersions is the minimum set of versions we should test. // At the moment, this is arbitrarily chosen as: