diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4bb433..d6e8a319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,11 @@ removed on this schedule: - Errors that previously returned HTTP 500 — unknown users or nodes, malformed input, duplicate names — now return the correct 404, 400 or 409 [#3324](https://github.com/juanfont/headscale/pull/3324) - The OpenAPI document is OpenAPI 3.1 at `/api/v1/openapi.yaml` (docs at `/api/v1/docs`), replacing Swagger 2.0 at `/swagger` [#3324](https://github.com/juanfont/headscale/pull/3324) +#### Configuration + +- `derp.paths` files must end in `.yaml`, `.yml`, `.json` or `.hujson`; the extension picks the format +- A `derp.paths` file that decodes to no regions now stops headscale from starting instead of being silently ignored + #### CLI - `--output json` / `--output yaml` now emit the API's shape — camelCase fields, string-encoded IDs, RFC3339 timestamps — instead of the old Protobuf encoding [#3324](https://github.com/juanfont/headscale/pull/3324) @@ -88,6 +93,7 @@ removed on this schedule: - Fix `headscale users destroy`/`rename` reporting "multiple users match query" when no user matches; an ambiguous match now lists the matching users [#3476](https://github.com/juanfont/headscale/pull/3476) - Deleting a user that still owns nodes now lists the nodes (ID and hostname) that must be deleted first [#3475](https://github.com/juanfont/headscale/pull/3475) - Headscale now requires Go 1.27 to build +- `derp.paths` files may be Tailscale JSON or HuJSON DERP maps as well as YAML - A `derp.paths` region set to `null` removes that region again, as documented ## 0.29.4 (2026-09-23) diff --git a/config-example.yaml b/config-example.yaml index 09f0356b..0e3bdf9f 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -114,7 +114,8 @@ derp: urls: - https://controlplane.tailscale.com/derpmap/default - # Locally available DERP map files encoded in YAML + # Locally available DERP map files. The extension picks the format: .yaml/.yml, + # or Tailscale JSON as .json or .hujson. # # This option is mostly interesting for people hosting their own DERP servers: # https://tailscale.com/docs/reference/derp-servers/custom-derp-servers diff --git a/docs/ref/derp.md b/docs/ref/derp.md index d615f095..c53c3c1f 100644 --- a/docs/ref/derp.md +++ b/docs/ref/derp.md @@ -54,8 +54,9 @@ derp: ### Customize DERP map The DERP map offered to clients can be customized with a [dedicated YAML-configuration -file](https://github.com/juanfont/headscale/blob/main/derp-example.yaml). This allows to modify previously loaded DERP -maps fetched via URL or to offer your own, custom DERP servers to nodes. +file](https://github.com/juanfont/headscale/blob/main/derp-example.yaml). The file extension picks the format: `.yaml` +or `.yml`, or the JSON format Tailscale serves DERP maps in as `.json` or `.hujson`. This allows to modify previously +loaded DERP maps fetched via URL or to offer your own, custom DERP servers to nodes. === "Remove specific DERP regions" diff --git a/hscontrol/derp/derp.go b/hscontrol/derp/derp.go index 03b67171..5d8a5c12 100644 --- a/hscontrol/derp/derp.go +++ b/hscontrol/derp/derp.go @@ -4,34 +4,40 @@ import ( "cmp" "context" "encoding/json" + "errors" + "fmt" "hash/crc64" "io" "math/rand" "net/http" "net/url" - "os" "reflect" "slices" "sync" "time" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" "github.com/spf13/viper" - "gopkg.in/yaml.v3" "tailscale.com/tailcfg" ) +var errEmptyDERPMapFile = errors.New("DERP map file has no regions (YAML keys are lowercased Go field names, e.g. regionid)") + +// loadDERPMapFromPath reads a DERP map file in the format its extension names +// (see [util.UnmarshalByExt]). A map with no regions is an error: unknown keys +// decode to nothing, e.g. YAML written with JSON's field names. func loadDERPMapFromPath(path string) (*tailcfg.DERPMap, error) { - b, err := os.ReadFile(path) + derpMap, err := util.ReadFileByExt[tailcfg.DERPMap](path) if err != nil { - return nil, err + return nil, fmt.Errorf("reading DERP map: %w", err) } - var derpMap tailcfg.DERPMap + if len(derpMap.Regions) == 0 { + return nil, fmt.Errorf("%w: %s", errEmptyDERPMapFile, path) + } - err = yaml.Unmarshal(b, &derpMap) - - return &derpMap, err + return &derpMap, nil } func loadDERPMapFromURL(addr url.URL) (*tailcfg.DERPMap, error) { diff --git a/hscontrol/derp/derp_test.go b/hscontrol/derp/derp_test.go index fb0423b2..07094c07 100644 --- a/hscontrol/derp/derp_test.go +++ b/hscontrol/derp/derp_test.go @@ -1,10 +1,14 @@ package derp import ( + "encoding/json" + "os" + "path/filepath" "testing" "github.com/google/go-cmp/cmp" "github.com/spf13/viper" + "github.com/stretchr/testify/require" "tailscale.com/tailcfg" ) @@ -350,3 +354,74 @@ func TestShuffleDERPMapWithoutBaseDomain(t *testing.T) { t.Errorf("Shuffle changed node set (-original +shuffled):\n%s", diff) } } + +// TestLoadDERPMapFromPath covers each file format and the silent-empty trap: +// keys the decoder doesn't know decode to nothing. +func TestLoadDERPMapFromPath(t *testing.T) { + want := &tailcfg.DERPMap{ + Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{ + 999: { + RegionID: 999, + RegionCode: "test", + Nodes: []*tailcfg.DERPNode{ + {Name: "999a", RegionID: 999, HostName: "derp.test", DERPPort: 443, InsecureForTests: true}, + }, + }, + }, + } + + tailcfgJSON, err := json.Marshal(want) + require.NoError(t, err) + + yamlMap := `regions: + 999: + regionid: 999 + regioncode: test + nodes: + - name: 999a + regionid: 999 + hostname: derp.test + derpport: 443 + insecurefortests: true +` + + tests := []struct { + name string + file string + content string + wantErr bool + }{ + {name: "yaml", file: "derp.yaml", content: yamlMap}, + {name: "yml", file: "derp.yml", content: yamlMap}, + { + name: "flow-style yaml", + file: "derp.yaml", + content: "{regions: {999: {regionid: 999, regioncode: test, nodes: [{name: 999a, regionid: 999, hostname: derp.test, derpport: 443, insecurefortests: true}]}}}", + }, + {name: "tailcfg json", file: "derp.json", content: string(tailcfgJSON)}, + {name: "tailcfg hujson", file: "derp.hujson", content: "// test map\n" + string(tailcfgJSON)}, + {name: "json as yaml decodes to nothing", file: "derp.yaml", content: string(tailcfgJSON), wantErr: true}, + {name: "empty", file: "derp.yaml", content: "", wantErr: true}, + {name: "no extension", file: "derp", content: yamlMap, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), tt.file) + require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o600)) + + got, err := loadDERPMapFromPath(path) + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("loadDERPMapFromPath() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index f6f59ac4..69fa3760 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -796,7 +796,7 @@ func validateDERPConfig(v *configValidator) { {"derp.server.automatically_add_embedded_derp_region", false}, {"derp.paths", "[]"}, }, - Hint: "list at least one DERP map JSON file in derp.paths, or set automatically_add_embedded_derp_region: true", + Hint: "list at least one DERP map file (.yaml, .yml, .json or .hujson) in derp.paths, or set automatically_add_embedded_derp_region: true", }) } }