derp: pick the derp.paths format by file extension

Adds JSON and HuJSON maps. JSON read as YAML decoded to an empty map; unknown
extensions and empty maps now fail at startup.
This commit is contained in:
Kristoffer Dalby
2026-09-28 13:32:46 +00:00
committed by Kristoffer Dalby
parent d20a412079
commit dd8ce695cf
6 changed files with 101 additions and 12 deletions
+6
View File
@@ -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)
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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"
+14 -8
View File
@@ -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) {
+75
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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",
})
}
}