mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-30 03:49:36 +09:00
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:
committed by
Kristoffer Dalby
parent
d20a412079
commit
dd8ce695cf
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user