mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-31 02:34:07 +09:00
auth, state: keep only hostname, OS and tags in pending registrations
The rest arrives with the first MapRequest. A relogin keeps the node's current Hostinfo, endpoints and disco key; an oversized merge is a 400. Fixes ANT-2026-RQS7JAPP
This commit is contained in:
1 parent
4ce928411e
commit
5a4d366ce1
11 files changed
+375
-91
No files matched your search
@@ -160,6 +160,8 @@ the ACME DNS-01 challenge through a built-in DNS provider: `acmedns`,
|
|||||||
- Refuse a key expiry outside the years 0 to 9999 in UTC on registration, on the OIDC token-expiry path and in `headscale nodes expire` / `POST /api/v1/node/{nodeId}/expire` (400); the latest accepted expiry no longer fails node listings on PostgreSQL in a zone east of UTC
|
- Refuse a key expiry outside the years 0 to 9999 in UTC on registration, on the OIDC token-expiry path and in `headscale nodes expire` / `POST /api/v1/node/{nodeId}/expire` (400); the latest accepted expiry no longer fails node listings on PostgreSQL in a zone east of UTC
|
||||||
- Keep the first 256 endpoints and 4096 announced routes from client reports; oversized hostnames, requested tags or other metadata return a registration error or HTTP 413 on map updates. Zoned IPv6 endpoints are refused
|
- Keep the first 256 endpoints and 4096 announced routes from client reports; oversized hostnames, requested tags or other metadata return a registration error or HTTP 413 on map updates. Zoned IPv6 endpoints are refused
|
||||||
- At startup, trim stored hostnames, endpoints and routes to the client-report limits and remove zoned endpoints. Hostinfo exceeding its size or tag limit is dropped until the next valid report
|
- At startup, trim stored hostnames, endpoints and routes to the client-report limits and remove zoned endpoints. Hostinfo exceeding its size or tag limit is dropped until the next valid report
|
||||||
|
- Pending interactive registrations keep only the hostname, OS and requested tags; other device details arrive with the first map request. Reauthentication preserves routes, services and endpoints
|
||||||
|
- Web reauthentication preserves the discovery key so peers can still reach the device
|
||||||
|
|
||||||
## 0.29.7 (202x-xx-xx)
|
## 0.29.7 (202x-xx-xx)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func mapError(msg string, err error) error {
|
|||||||
errors.Is(err, state.ErrNodeHasNeitherUserNorTags),
|
errors.Is(err, state.ErrNodeHasNeitherUserNorTags),
|
||||||
errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted),
|
errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted),
|
||||||
errors.Is(err, types.ErrExpiryOutOfRange),
|
errors.Is(err, types.ErrExpiryOutOfRange),
|
||||||
|
errors.Is(err, types.ErrHostinfoTooLarge),
|
||||||
errors.Is(err, db.ErrUserStillHasNodes),
|
errors.Is(err, db.ErrUserStillHasNodes),
|
||||||
errors.Is(err, db.ErrCannotChangeOIDCUser),
|
errors.Is(err, db.ErrCannotChangeOIDCUser),
|
||||||
errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned),
|
errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned),
|
||||||
|
|||||||
+15
-7
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -388,25 +389,32 @@ func (h *Headscale) reqToNewRegisterResponse(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// registrationDataFromRequest builds the [types.RegistrationData] payload stored
|
// registrationDataFromRequest keeps only auth and confirmation metadata;
|
||||||
// in the auth cache for a pending registration. The original [tailcfg.Hostinfo] is
|
// operational metadata arrives with the node's first MapRequest.
|
||||||
// retained so that consumers (auth callback, observability) see the
|
|
||||||
// fields the client originally announced; the bounded-LRU cap on the
|
|
||||||
// cache is what bounds the unauthenticated cache-fill DoS surface.
|
|
||||||
func registrationDataFromRequest(
|
func registrationDataFromRequest(
|
||||||
req tailcfg.RegisterRequest,
|
req tailcfg.RegisterRequest,
|
||||||
machineKey key.MachinePublic,
|
machineKey key.MachinePublic,
|
||||||
) *types.RegistrationData {
|
) *types.RegistrationData {
|
||||||
var hostname string
|
var (
|
||||||
|
hostname string
|
||||||
|
hostinfo *tailcfg.Hostinfo
|
||||||
|
)
|
||||||
|
|
||||||
if req.Hostinfo != nil {
|
if req.Hostinfo != nil {
|
||||||
hostname = req.Hostinfo.Hostname
|
hostname = req.Hostinfo.Hostname
|
||||||
|
|
||||||
|
hostinfo = &tailcfg.Hostinfo{
|
||||||
|
Hostname: hostname,
|
||||||
|
OS: req.Hostinfo.OS,
|
||||||
|
RequestTags: slices.Clone(req.Hostinfo.RequestTags),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
regData := &types.RegistrationData{
|
regData := &types.RegistrationData{
|
||||||
MachineKey: machineKey,
|
MachineKey: machineKey,
|
||||||
NodeKey: req.NodeKey,
|
NodeKey: req.NodeKey,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
Hostinfo: req.Hostinfo,
|
Hostinfo: hostinfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !req.Expiry.IsZero() {
|
if !req.Expiry.IsZero() {
|
||||||
|
|||||||
+59
-18
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -1638,15 +1639,8 @@ func TestAuthenticationFlows(t *testing.T) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// === AUTH PROVIDER EDGE CASES ===
|
// === AUTH PROVIDER EDGE CASES ===
|
||||||
// TEST: Interactive workflow preserves custom hostinfo
|
// Interactive registration retains auth metadata. The first map request
|
||||||
// WHAT: Tests that custom hostinfo fields are preserved through interactive flow
|
// supplies the remaining Hostinfo fields.
|
||||||
// INPUT: Interactive registration with detailed hostinfo (OS, version, model)
|
|
||||||
// EXPECTED: Node registers with all hostinfo fields preserved
|
|
||||||
// WHY: Ensures interactive flow doesn't lose custom hostinfo data
|
|
||||||
// NOTE: RequestTags are NOT tested here because tag authorization via
|
|
||||||
// advertise-tags requires the user to have existing nodes (for IP-based
|
|
||||||
// ownership verification). New users registering their first node cannot
|
|
||||||
// claim tags via RequestTags - they must use a tagged PreAuthKey instead.
|
|
||||||
{
|
{
|
||||||
name: "interactive_workflow_with_custom_hostinfo",
|
name: "interactive_workflow_with_custom_hostinfo",
|
||||||
setupFunc: func(t *testing.T, app *Headscale) (string, error) { //nolint:thelper
|
setupFunc: func(t *testing.T, app *Headscale) (string, error) { //nolint:thelper
|
||||||
@@ -1673,15 +1667,29 @@ func TestAuthenticationFlows(t *testing.T) {
|
|||||||
validateCompleteResponse: true,
|
validateCompleteResponse: true,
|
||||||
expectedAuthURLPattern: "/register/",
|
expectedAuthURLPattern: "/register/",
|
||||||
validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper
|
validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper
|
||||||
// Verify custom hostinfo was preserved through interactive workflow
|
// Confirm the minimal cached report, then restore the operational fields.
|
||||||
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||||
assert.True(t, found, "node should be found after interactive registration")
|
assert.True(t, found, "node should be found after interactive registration")
|
||||||
|
|
||||||
if found {
|
if found {
|
||||||
assert.Equal(t, "custom-interactive-node", node.Hostname())
|
assert.Equal(t, "custom-interactive-node", node.Hostname())
|
||||||
assert.Equal(t, "linux", node.Hostinfo().OS())
|
assert.Equal(t, "linux", node.Hostinfo().OS())
|
||||||
assert.Equal(t, "20.04", node.Hostinfo().OSVersion())
|
assert.Empty(t, node.Hostinfo().OSVersion())
|
||||||
assert.Equal(t, "server", node.Hostinfo().DeviceModel())
|
assert.Empty(t, node.Hostinfo().DeviceModel())
|
||||||
|
|
||||||
|
_, err := app.state.UpdateNodeFromMapRequest(node.ID(), tailcfg.MapRequest{
|
||||||
|
NodeKey: node.NodeKey(), DiscoKey: node.DiscoKey(),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
Hostname: node.Hostname(), OS: "linux",
|
||||||
|
OSVersion: "20.04", DeviceModel: "server",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, ok := app.state.GetNodeByID(node.ID())
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "20.04", updated.Hostinfo().OSVersion())
|
||||||
|
assert.Equal(t, "server", updated.Hostinfo().DeviceModel())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -4340,14 +4348,14 @@ func TestHandleNodeFromAuthPath_ReloginWholeNode(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
expired bool
|
expired bool
|
||||||
services []tailcfg.Service
|
hostname string
|
||||||
wantPatch bool
|
wantPatch bool
|
||||||
}{
|
}{
|
||||||
{name: "keys only", wantPatch: true},
|
{name: "keys only", wantPatch: true},
|
||||||
{name: "expired", expired: true},
|
{name: "expired", expired: true},
|
||||||
{
|
{
|
||||||
name: "peer-visible hostinfo",
|
name: "peer-visible hostname",
|
||||||
services: []tailcfg.Service{{Proto: tailcfg.PeerAPI4, Port: 4242}},
|
hostname: "renamed-node",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4365,15 +4373,19 @@ func TestHandleNodeFromAuthPath_ReloginWholeNode(t *testing.T) {
|
|||||||
|
|
||||||
app.state.PutNodeInStoreForTest(*node)
|
app.state.PutNodeInStoreForTest(*node)
|
||||||
|
|
||||||
|
hostname := node.Hostname
|
||||||
|
if tt.hostname != "" {
|
||||||
|
hostname = tt.hostname
|
||||||
|
}
|
||||||
|
|
||||||
newNodeKey := key.NewNode()
|
newNodeKey := key.NewNode()
|
||||||
authID := types.MustAuthID()
|
authID := types.MustAuthID()
|
||||||
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
MachineKey: node.MachineKey,
|
MachineKey: node.MachineKey,
|
||||||
NodeKey: newNodeKey.Public(),
|
NodeKey: newNodeKey.Public(),
|
||||||
Hostname: node.Hostname,
|
Hostname: hostname,
|
||||||
Hostinfo: &tailcfg.Hostinfo{
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
Hostname: node.Hostname,
|
Hostname: hostname,
|
||||||
Services: tt.services,
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -4598,3 +4610,32 @@ func TestRegisterRejectsUnencodableExpiry(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, latest.Equal(*stored.Expiry))
|
assert.True(t, latest.Equal(*stored.Expiry))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPendingRegistrationKeepsAuthMetadata pins that operational reports do
|
||||||
|
// not stay in the auth cache before the first MapRequest.
|
||||||
|
func TestPendingRegistrationKeepsAuthMetadata(t *testing.T) {
|
||||||
|
app := createTestApp(t)
|
||||||
|
hostinfo := &tailcfg.Hostinfo{
|
||||||
|
Hostname: "pending-router", OS: "linux", OSVersion: "6.12.0",
|
||||||
|
RequestTags: []string{"tag:router"},
|
||||||
|
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
|
||||||
|
Services: []tailcfg.Service{{Proto: tailcfg.PeerAPI4, Port: 41641}},
|
||||||
|
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
|
||||||
|
SSH_HostKeys: []string{"ssh-ed25519 key"},
|
||||||
|
}
|
||||||
|
resp, err := app.handleRegister(context.Background(), tailcfg.RegisterRequest{
|
||||||
|
NodeKey: key.NewNode().Public(), Hostinfo: hostinfo,
|
||||||
|
}, key.NewMachine().Public())
|
||||||
|
require.NoError(t, err)
|
||||||
|
authID, err := extractRegistrationIDFromAuthURL(resp.AuthURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
entry, ok := app.state.GetAuthCacheEntry(authID)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, &tailcfg.Hostinfo{
|
||||||
|
Hostname: "pending-router", OS: "linux", RequestTags: []string{"tag:router"},
|
||||||
|
}, entry.RegistrationData().Hostinfo)
|
||||||
|
hostinfo.RequestTags[0] = "tag:changed"
|
||||||
|
assert.Equal(t, []string{"tag:router"}, entry.RegistrationData().Hostinfo.RequestTags)
|
||||||
|
assert.Len(t, hostinfo.RoutableIPs, 1, "the request itself must be left alone")
|
||||||
|
}
|
||||||
@@ -1936,6 +1936,42 @@ func TestPollNetMapHandler_UnknownNodeBeforeReportValidation(t *testing.T) {
|
|||||||
func TestRegistrationHandler_ValidatesClientReport(t *testing.T) {
|
func TestRegistrationHandler_ValidatesClientReport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("pending registration retains auth metadata", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := createTestApp(t)
|
||||||
|
ns := &noiseServer{headscale: app, machineKey: key.NewMachine().Public()}
|
||||||
|
|
||||||
|
rec := serveRegister(t, ns, tailcfg.RegisterRequest{
|
||||||
|
Version: tailcfg.CurrentCapabilityVersion,
|
||||||
|
NodeKey: key.NewNode().Public(),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{
|
||||||
|
Hostname: "report-pending-node",
|
||||||
|
OS: "linux",
|
||||||
|
OSVersion: "6.12",
|
||||||
|
RequestTags: []string{"tag:test"},
|
||||||
|
Services: manyServices(300),
|
||||||
|
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
||||||
|
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "body=%q", rec.Body.String())
|
||||||
|
|
||||||
|
var resp tailcfg.RegisterResponse
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
|
||||||
|
authID, err := extractRegistrationIDFromAuthURL(resp.AuthURL)
|
||||||
|
require.NoError(t, err, "resp=%+v", resp)
|
||||||
|
|
||||||
|
entry, ok := app.state.GetAuthCacheEntry(authID)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, &tailcfg.Hostinfo{
|
||||||
|
Hostname: "report-pending-node",
|
||||||
|
OS: "linux",
|
||||||
|
RequestTags: []string{"tag:test"},
|
||||||
|
}, entry.RegistrationData().Hostinfo)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("oversized report", func(t *testing.T) {
|
t.Run("oversized report", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -995,6 +995,11 @@ func (a *AuthProviderOIDC) handleRegistration(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
a.h.Change(nodeChange)
|
a.h.Change(nodeChange)
|
||||||
|
|
||||||
|
if errors.Is(err, types.ErrHostinfoTooLarge) {
|
||||||
|
return false, newHTTPUserError(http.StatusBadRequest, "node report too large",
|
||||||
|
"This device reports too much data. Ask your administrator to reduce its reported metadata, then sign in again.", err)
|
||||||
|
}
|
||||||
|
|
||||||
return false, fmt.Errorf("registering node: %w", err)
|
return false, fmt.Errorf("registering node: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package hscontrol
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
"github.com/oauth2-proxy/mockoidc"
|
"github.com/oauth2-proxy/mockoidc"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/key"
|
"tailscale.com/types/key"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -617,3 +620,43 @@ func TestRouterMethodNotAllowedIncludesAllow(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||||
assert.Equal(t, []string{http.MethodGet}, resp.Header.Values("Allow"))
|
assert.Equal(t, []string{http.MethodGet}, resp.Header.Values("Allow"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOIDCRegistrationRejectsOversizedMergedHostinfo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := createTestApp(t)
|
||||||
|
user := app.state.CreateUserForTest("oidc-report-user")
|
||||||
|
node := app.state.CreateNodeForTest(user, "oidc-report-node")
|
||||||
|
node.Hostinfo = &tailcfg.Hostinfo{Hostname: node.Hostname, OSVersion: "v"}
|
||||||
|
encoded, err := json.Marshal(node.Hostinfo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
node.Hostinfo.OSVersion = strings.Repeat("v", types.MaxHostinfoSize-len(encoded))
|
||||||
|
require.NoError(t, types.ValidateHostinfo(node.Hostinfo))
|
||||||
|
before := app.state.PutNodeInStoreForTest(*node)
|
||||||
|
|
||||||
|
hostname := strings.Repeat("h", types.MaxHostnameLength)
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
|
MachineKey: node.MachineKey, NodeKey: key.NewNode().Public(),
|
||||||
|
Hostname: hostname, Hostinfo: &tailcfg.Hostinfo{Hostname: hostname},
|
||||||
|
}))
|
||||||
|
|
||||||
|
provider := &AuthProviderOIDC{h: app}
|
||||||
|
changed, err := provider.handleRegistration(user, authID, nil)
|
||||||
|
require.ErrorIs(t, err, types.ErrHostinfoTooLarge)
|
||||||
|
|
||||||
|
var httpErr HTTPError
|
||||||
|
require.ErrorAs(t, err, &httpErr)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, httpErr.Code)
|
||||||
|
assert.Contains(t, strings.ToLower(httpErr.UserMsg), "reduce")
|
||||||
|
assert.Contains(t, httpErr.UserMsg, "metadata")
|
||||||
|
assert.Contains(t, httpErr.UserMsg, "sign")
|
||||||
|
assert.False(t, changed)
|
||||||
|
|
||||||
|
after, ok := app.state.GetNodeByID(node.ID)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, before.NodeKey(), after.NodeKey())
|
||||||
|
assert.Equal(t, before.Hostname(), after.Hostname())
|
||||||
|
assert.True(t, before.Hostinfo().AsStruct().Equal(after.Hostinfo().AsStruct()))
|
||||||
|
}
|
||||||
@@ -324,9 +324,8 @@ func TestReloginOfExpiredNodeClearsPeerExpiry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestReloginPeerVisibleHostinfoReachesPeers covers the rest of the #3531
|
// TestReloginPeerVisibleHostinfoReachesPeers covers the rest of the #3531
|
||||||
// class: a relogin stores the RegisterRequest's Hostinfo, so the following
|
// class: peers must see changed services after the first MapRequest following
|
||||||
// MapRequest carries no Hostinfo delta, and a peer-visible change made at
|
// relogin. A key-only patch cannot carry that change.
|
||||||
// relogin reaches peers only if the relogin itself sends the whole node.
|
|
||||||
func TestReloginPeerVisibleHostinfoReachesPeers(t *testing.T) {
|
func TestReloginPeerVisibleHostinfoReachesPeers(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package state
|
package state
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -363,7 +364,6 @@ func TestReauthRejectsNodeKeyClaimedByAnotherMachine(t *testing.T) {
|
|||||||
Hostname: "attacker",
|
Hostname: "attacker",
|
||||||
Hostinfo: &tailcfg.Hostinfo{},
|
Hostinfo: &tailcfg.Hostinfo{},
|
||||||
},
|
},
|
||||||
ValidHostinfo: &tailcfg.Hostinfo{},
|
|
||||||
Hostname: "attacker",
|
Hostname: "attacker",
|
||||||
User: attacker,
|
User: attacker,
|
||||||
RegisterMethod: util.RegisterMethodCLI,
|
RegisterMethod: util.RegisterMethodCLI,
|
||||||
@@ -424,7 +424,6 @@ func TestReauthPreservesEndpointsWhenClientOmitsThem(t *testing.T) {
|
|||||||
Hostinfo: &tailcfg.Hostinfo{},
|
Hostinfo: &tailcfg.Hostinfo{},
|
||||||
Endpoints: nil,
|
Endpoints: nil,
|
||||||
},
|
},
|
||||||
ValidHostinfo: &tailcfg.Hostinfo{},
|
|
||||||
Hostname: "node",
|
Hostname: "node",
|
||||||
User: user,
|
User: user,
|
||||||
RegisterMethod: util.RegisterMethodCLI,
|
RegisterMethod: util.RegisterMethodCLI,
|
||||||
@@ -1484,7 +1483,7 @@ func TestNewStateDiscardsOversizedHostinfo(t *testing.T) {
|
|||||||
"oversized Hostinfo is dropped until the node reports again")
|
"oversized Hostinfo is dropped until the node reports again")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBootKeepsHostinfoTheMapPathStored pins that the startup bound keeps
|
// TestBootKeepsHostinfoTheMapPathStored pins that the startup check keeps
|
||||||
// what the map path accepted. A report without NetInfo keeps the stored one,
|
// what the map path accepted. A report without NetInfo keeps the stored one,
|
||||||
// so a stored Hostinfo can combine fields from two reports.
|
// so a stored Hostinfo can combine fields from two reports.
|
||||||
func TestBootKeepsHostinfoTheMapPathStored(t *testing.T) {
|
func TestBootKeepsHostinfoTheMapPathStored(t *testing.T) {
|
||||||
@@ -1517,10 +1516,183 @@ func TestBootKeepsHostinfoTheMapPathStored(t *testing.T) {
|
|||||||
reloaded, ok := persistTestReopen(t, dbPath).GetNodeByID(nodeID)
|
reloaded, ok := persistTestReopen(t, dbPath).GetNodeByID(nodeID)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
require.True(t, reloaded.Hostinfo().Valid(),
|
require.True(t, reloaded.Hostinfo().Valid(),
|
||||||
"the startup bound must keep a Hostinfo the map path stored")
|
"the startup check must keep a Hostinfo the map path stored")
|
||||||
assert.Equal(t, tailcfg.DERPRegionID(1),
|
assert.Equal(t, tailcfg.DERPRegionID(1),
|
||||||
reloaded.Hostinfo().NetInfo().PreferredDERP())
|
reloaded.Hostinfo().NetInfo().PreferredDERP())
|
||||||
|
|
||||||
osVersionLen := len(reloaded.Hostinfo().OSVersion())
|
osVersionLen := len(reloaded.Hostinfo().OSVersion())
|
||||||
assert.Equal(t, 60<<10, osVersionLen)
|
assert.Equal(t, 60<<10, osVersionLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReauthKeepsOperationalMetadata covers a relogin before the next map request.
|
||||||
|
// Pending registrations carry only auth metadata; live connectivity must survive.
|
||||||
|
func TestReauthKeepsOperationalMetadata(t *testing.T) {
|
||||||
|
_, s, nodeID := persistTestSetup(t)
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
route := netip.MustParsePrefix("10.0.0.0/24")
|
||||||
|
|
||||||
|
node, ok := s.nodeStore.UpdateNode(nodeID, func(n *types.Node) {
|
||||||
|
n.Hostinfo = &tailcfg.Hostinfo{
|
||||||
|
Hostname: n.Hostname, RoutableIPs: []netip.Prefix{route},
|
||||||
|
OS: "linux", OSVersion: "6.12.0",
|
||||||
|
Services: []tailcfg.Service{{Proto: tailcfg.PeerAPI4, Port: 41641}},
|
||||||
|
SSH_HostKeys: []string{"ssh-ed25519 key"},
|
||||||
|
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
|
||||||
|
}
|
||||||
|
n.DiscoKey = key.NewDisco().Public()
|
||||||
|
n.Endpoints = []netip.AddrPort{netip.MustParseAddrPort("192.0.2.1:41641")}
|
||||||
|
n.ApprovedRoutes = []netip.Prefix{route}
|
||||||
|
})
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
_, epoch := s.Connect(nodeID)
|
||||||
|
require.NotZero(t, epoch)
|
||||||
|
require.Equal(t, []netip.Prefix{route}, s.GetNodePrimaryRoutes(nodeID),
|
||||||
|
"precondition: the node serves its route")
|
||||||
|
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
|
MachineKey: node.MachineKey(),
|
||||||
|
NodeKey: key.NewNode().Public(),
|
||||||
|
Hostname: node.Hostname(),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname()},
|
||||||
|
}))
|
||||||
|
|
||||||
|
relogged, _, err := s.HandleNodeFromAuthPath(
|
||||||
|
authID, node.TypedUserID(), nil, util.RegisterMethodOIDC,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, nodeID, relogged.ID(), "relogin must update the existing node")
|
||||||
|
|
||||||
|
assert.Equal(t, []netip.Prefix{route}, relogged.AnnouncedRoutes())
|
||||||
|
assert.Equal(t, []netip.Prefix{route}, s.GetNodePrimaryRoutes(nodeID))
|
||||||
|
assert.True(t, node.Hostinfo().Equal(relogged.Hostinfo()))
|
||||||
|
assert.Equal(t, node.DiscoKey(), relogged.DiscoKey())
|
||||||
|
assert.Equal(t, node.Endpoints(), relogged.Endpoints())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReauthRejectsOversizedMergedHostinfo(t *testing.T) {
|
||||||
|
_, s, nodeID := persistTestSetup(t)
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
node, ok := s.GetNodeByID(nodeID)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
hi := &tailcfg.Hostinfo{Hostname: node.Hostname(), OSVersion: "v"}
|
||||||
|
encoded, err := json.Marshal(hi)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hi.OSVersion = strings.Repeat("v", types.MaxHostinfoSize-len(encoded)+1)
|
||||||
|
require.NoError(t, types.ValidateHostinfo(hi))
|
||||||
|
_, err = s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
|
||||||
|
NodeKey: node.NodeKey(), DiscoKey: node.DiscoKey(), Hostinfo: hi,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
before, ok := s.GetNodeByID(nodeID)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
rowBefore, err := s.DB().GetNodeByID(nodeID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hostname := strings.Repeat("h", types.MaxHostnameLength)
|
||||||
|
require.NoError(t, types.ValidateHostinfo(&tailcfg.Hostinfo{Hostname: hostname}))
|
||||||
|
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
|
MachineKey: node.MachineKey(), NodeKey: key.NewNode().Public(),
|
||||||
|
Hostname: hostname, Hostinfo: &tailcfg.Hostinfo{Hostname: hostname},
|
||||||
|
}))
|
||||||
|
_, _, err = s.HandleNodeFromAuthPath(authID, node.TypedUserID(), nil, util.RegisterMethodOIDC)
|
||||||
|
require.ErrorIs(t, err, types.ErrHostinfoTooLarge)
|
||||||
|
|
||||||
|
after, ok := s.GetNodeByID(nodeID)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, before.AsStruct(), after.AsStruct(), "rejected merge leaves the live node unchanged")
|
||||||
|
|
||||||
|
rowAfter, err := s.DB().GetNodeByID(nodeID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, rowBefore, rowAfter, "rejected merge leaves the stored node unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReauthKeepsTaggedFutureExpiry(t *testing.T) {
|
||||||
|
_, s, nodeID := persistTestSetup(t)
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
_, err := s.SetPolicy([]byte(`{"tagOwners":{"tag:ci":["persist-user@"]}}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
node, ok := s.GetNodeByID(nodeID)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
userID := node.TypedUserID()
|
||||||
|
_, _, err = s.SetNodeTags(nodeID, []string{"tag:ci"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
future := time.Now().Add(time.Hour).UTC()
|
||||||
|
node, ok = s.nodeStore.UpdateNode(nodeID, func(n *types.Node) {
|
||||||
|
n.Expiry = &future
|
||||||
|
})
|
||||||
|
require.True(t, ok)
|
||||||
|
require.True(t, node.IsTagged())
|
||||||
|
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
|
MachineKey: node.MachineKey(), NodeKey: key.NewNode().Public(),
|
||||||
|
Hostname: node.Hostname(),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname(), RequestTags: []string{"tag:ci"}},
|
||||||
|
}))
|
||||||
|
relogged, _, err := s.HandleNodeFromAuthPath(authID, userID, nil, util.RegisterMethodOIDC)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, nodeID, relogged.ID())
|
||||||
|
require.True(t, relogged.IsTagged())
|
||||||
|
require.True(t, relogged.Expiry().Valid())
|
||||||
|
assert.True(t, future.Equal(relogged.Expiry().Get()))
|
||||||
|
|
||||||
|
row, err := s.DB().GetNodeByID(nodeID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, row.Expiry)
|
||||||
|
assert.True(t, future.Equal(*row.Expiry))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReauthReplacesReportedTags(t *testing.T) {
|
||||||
|
_, s, nodeID := persistTestSetup(t)
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
_, err := s.SetPolicy([]byte(`{"tagOwners":{"tag:a":["persist-user@"],"tag:b":["persist-user@"]}}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
node, ok := s.GetNodeByID(nodeID)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
userID := node.TypedUserID()
|
||||||
|
|
||||||
|
for _, tags := range [][]string{{"tag:a"}, nil, {"tag:b"}} {
|
||||||
|
want := slices.Clone(tags)
|
||||||
|
authID := types.MustAuthID()
|
||||||
|
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||||
|
MachineKey: node.MachineKey(), NodeKey: key.NewNode().Public(),
|
||||||
|
Hostname: node.Hostname(),
|
||||||
|
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname(), RequestTags: tags},
|
||||||
|
}))
|
||||||
|
relogged, _, err := s.HandleNodeFromAuthPath(authID, userID, nil, util.RegisterMethodOIDC)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, nodeID, relogged.ID())
|
||||||
|
assert.Equal(t, want, relogged.Hostinfo().RequestTags().AsSlice())
|
||||||
|
assert.Equal(t, len(want) > 0, relogged.IsTagged())
|
||||||
|
|
||||||
|
if len(tags) > 0 {
|
||||||
|
tags[0] = "tag:changed"
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, ok := s.GetNodeByID(nodeID)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, want, stored.Hostinfo().RequestTags().AsSlice(),
|
||||||
|
"cached tags must not alias stored Hostinfo")
|
||||||
|
|
||||||
|
row, err := s.DB().GetNodeByID(nodeID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, row.Hostinfo.RequestTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-46
@@ -51,10 +51,8 @@ const (
|
|||||||
registerCacheExpiration = time.Minute * 15
|
registerCacheExpiration = time.Minute * 15
|
||||||
|
|
||||||
// defaultRegisterCacheMaxEntries is the default upper bound on the number
|
// defaultRegisterCacheMaxEntries is the default upper bound on the number
|
||||||
// of pending registration entries the auth cache will hold. With a 15-minute
|
// of pending registration entries the auth cache will hold. Entries keep
|
||||||
// TTL and a stripped-down RegistrationData payload (~200 bytes per entry),
|
// only hostname, OS and requested tags, capped by [types.ValidateHostinfo].
|
||||||
// 1024 entries cap the worst-case cache footprint at well under 1 MiB even
|
|
||||||
// under sustained unauthenticated cache-fill attempts.
|
|
||||||
defaultRegisterCacheMaxEntries = 1024
|
defaultRegisterCacheMaxEntries = 1024
|
||||||
|
|
||||||
// defaultNodeStoreBatchSize is the default number of write operations to batch
|
// defaultNodeStoreBatchSize is the default number of write operations to batch
|
||||||
@@ -1973,8 +1971,6 @@ type authNodeUpdateParams struct {
|
|||||||
ExistingNode types.NodeView
|
ExistingNode types.NodeView
|
||||||
// Cached registration payload from the originating client request.
|
// Cached registration payload from the originating client request.
|
||||||
RegData *types.RegistrationData
|
RegData *types.RegistrationData
|
||||||
// Pre-validated hostinfo; NetInfo preserved from ExistingNode.
|
|
||||||
ValidHostinfo *tailcfg.Hostinfo
|
|
||||||
// Hostname from hostinfo, or generated from keys if client omits it.
|
// Hostname from hostinfo, or generated from keys if client omits it.
|
||||||
Hostname string
|
Hostname string
|
||||||
// Auth user; may differ from ExistingNode.User() on conversion.
|
// Auth user; may differ from ExistingNode.User() on conversion.
|
||||||
@@ -2051,19 +2047,27 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
|
|||||||
return types.NodeView{}, false, ErrNodeKeyInUse
|
return types.NodeView{}, false, ErrNodeKeyInUse
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update existing node in [NodeStore] - validation passed, safe to mutate
|
var hostinfoErr error
|
||||||
before, updatedNodeView, ok := s.nodeStore.UpdateNodeDiff(params.ExistingNode.ID(), func(node *types.Node) {
|
|
||||||
node.NodeKey = regData.NodeKey
|
|
||||||
node.DiscoKey = regData.DiscoKey
|
|
||||||
node.Hostname = params.Hostname
|
|
||||||
|
|
||||||
// Preserve NetInfo from existing node when re-registering
|
// Read current operational metadata while holding the NodeStore writer lock.
|
||||||
node.Hostinfo = params.ValidHostinfo
|
before, updatedNodeView, ok := s.nodeStore.UpdateNodeDiff(params.ExistingNode.ID(), func(node *types.Node) {
|
||||||
node.Hostinfo.NetInfo = preserveNetInfo(
|
hostinfo := cmp.Or(node.Hostinfo.Clone(), &tailcfg.Hostinfo{})
|
||||||
params.ExistingNode,
|
|
||||||
params.ExistingNode.ID(),
|
hostinfo.Hostname = params.Hostname
|
||||||
params.ValidHostinfo,
|
hostinfo.RequestTags = slices.Clone(requestTags)
|
||||||
)
|
|
||||||
|
hostinfoErr = types.ValidateHostinfo(hostinfo)
|
||||||
|
if hostinfoErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.NodeKey = regData.NodeKey
|
||||||
|
if !regData.DiscoKey.IsZero() {
|
||||||
|
node.DiscoKey = regData.DiscoKey
|
||||||
|
}
|
||||||
|
|
||||||
|
node.Hostname = params.Hostname
|
||||||
|
node.Hostinfo = hostinfo
|
||||||
|
|
||||||
// Preserve the node's live endpoints when the register request carried
|
// Preserve the node's live endpoints when the register request carried
|
||||||
// none. Web/OIDC relogins report endpoints via MapRequest, not register,
|
// none. Web/OIDC relogins report endpoints via MapRequest, not register,
|
||||||
@@ -2097,34 +2101,11 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
|
|||||||
isTagged := node.IsTagged()
|
isTagged := node.IsTagged()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case wasTagged && !isTagged:
|
|
||||||
// Tagged → Personal: set expiry from client request
|
|
||||||
if params.Expiry != nil {
|
|
||||||
node.Expiry = params.Expiry
|
|
||||||
} else {
|
|
||||||
node.Expiry = regData.Expiry
|
|
||||||
}
|
|
||||||
case !wasTagged && isTagged:
|
|
||||||
// Personal → Tagged: clear expiry; tagged nodes disable it by default.
|
|
||||||
node.Expiry = nil
|
|
||||||
case params.IsConvertFromTag && !isTagged:
|
|
||||||
// Explicit conversion from tagged to user-owned: set expiry from client request
|
|
||||||
if params.Expiry != nil {
|
|
||||||
node.Expiry = params.Expiry
|
|
||||||
} else {
|
|
||||||
node.Expiry = regData.Expiry
|
|
||||||
}
|
|
||||||
case !isTagged:
|
case !isTagged:
|
||||||
// Personal → Personal: update expiry from client
|
// User-owned nodes take the expiry from this authentication.
|
||||||
if params.Expiry != nil {
|
node.Expiry = cmp.Or(params.Expiry, regData.Expiry)
|
||||||
node.Expiry = params.Expiry
|
case !wasTagged || node.IsExpired():
|
||||||
} else {
|
// Becoming tagged disables expiry; reauth clears a past expiry.
|
||||||
node.Expiry = regData.Expiry
|
|
||||||
}
|
|
||||||
case isTagged && node.IsExpired():
|
|
||||||
// Tagged → Tagged and expired: a person re-authenticated it, so clear
|
|
||||||
// the past expiry. A future administrative expiry has not taken
|
|
||||||
// effect and falls through to the no-op below.
|
|
||||||
node.Expiry = nil
|
node.Expiry = nil
|
||||||
}
|
}
|
||||||
// Tagged → Tagged and not expired: keep existing expiry - no action.
|
// Tagged → Tagged and not expired: keep existing expiry - no action.
|
||||||
@@ -2145,6 +2126,10 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
|
|||||||
return types.NodeView{}, false, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, params.ExistingNode.ID())
|
return types.NodeView{}, false, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, params.ExistingNode.ID())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hostinfoErr != nil {
|
||||||
|
return types.NodeView{}, false, hostinfoErr
|
||||||
|
}
|
||||||
|
|
||||||
keysOnly := onlyKeysChanged(before, updatedNodeView, time.Now())
|
keysOnly := onlyKeysChanged(before, updatedNodeView, time.Now())
|
||||||
|
|
||||||
// Persist to database.
|
// Persist to database.
|
||||||
@@ -2585,7 +2570,6 @@ func (s *State) HandleNodeFromAuthPath(
|
|||||||
// Common params for update operations
|
// Common params for update operations
|
||||||
updateParams := authNodeUpdateParams{
|
updateParams := authNodeUpdateParams{
|
||||||
RegData: regData,
|
RegData: regData,
|
||||||
ValidHostinfo: hostinfo,
|
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
User: user,
|
User: user,
|
||||||
Expiry: expiry,
|
Expiry: expiry,
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ import (
|
|||||||
// only the fields the registration callback path actually consumes when
|
// only the fields the registration callback path actually consumes when
|
||||||
// promoting a pending registration to a real node.
|
// promoting a pending registration to a real node.
|
||||||
//
|
//
|
||||||
// Combined with the bounded-LRU cache that holds these entries, this caps
|
// Any client can open a pending registration. The bounded-LRU cache that
|
||||||
// the worst-case memory footprint of unauthenticated cache-fill attempts
|
// holds these entries caps their number. Hostinfo retains only auth and
|
||||||
// at (max_entries × per_entry_size). The cache is sized so that the
|
// confirmation metadata, bounded by [ValidateHostinfo] before caching.
|
||||||
// product is bounded to a few MiB even with attacker-supplied 1 MiB
|
|
||||||
// Hostinfos (the Noise body limit).
|
|
||||||
type RegistrationData struct {
|
type RegistrationData struct {
|
||||||
// MachineKey is the cryptographic identity of the machine being
|
// MachineKey is the cryptographic identity of the machine being
|
||||||
// registered. Required.
|
// registered. Required.
|
||||||
@@ -34,14 +32,9 @@ type RegistrationData struct {
|
|||||||
// Already validated/normalised by EnsureHostname at producer time.
|
// Already validated/normalised by EnsureHostname at producer time.
|
||||||
Hostname string
|
Hostname string
|
||||||
|
|
||||||
// Hostinfo is the original [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest],
|
// Hostinfo carries hostname and requested tags for auth, and OS for the
|
||||||
// stored so that the auth callback can populate the new node's
|
// confirmation page. Other fields arrive in the first MapRequest.
|
||||||
// initial [tailcfg.Hostinfo] (and so that observability/CLI consumers see
|
// It may be nil when the registration did not include Hostinfo.
|
||||||
// fields like OS, OSVersion, and IPNVersion before the first
|
|
||||||
// [tailcfg.MapRequest] restores the live set).
|
|
||||||
//
|
|
||||||
// May be nil if the client did not send [tailcfg.Hostinfo] in the original
|
|
||||||
// [tailcfg.RegisterRequest].
|
|
||||||
Hostinfo *tailcfg.Hostinfo
|
Hostinfo *tailcfg.Hostinfo
|
||||||
|
|
||||||
// Endpoints is the initial set of WireGuard endpoints the node
|
// Endpoints is the initial set of WireGuard endpoints the node
|
||||||
|
|||||||
Reference in new issue
Block a user