Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions application/backup/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const maxSettingsSchemaVersion = 1

// maxDeviceAssignmentsVersion must match discovery.deviceAssignmentsVersion in
// persistence.go. Same duplication rationale as above.
const maxDeviceAssignmentsVersion = 1
const maxDeviceAssignmentsVersion = 2

const (
settingsSchemaKey = "settings_schema_version"
Expand Down Expand Up @@ -77,6 +77,12 @@ func CreateBackup(settings, devices *gatesentry2storage.MapStore, binaryVersion
if err != nil {
return nil, fmt.Errorf("backup: snapshot devices: %w", err)
}
// A clean installation has no assignment key until persistence is first
// attached. Backups must still be self-validating and restorable, so encode
// the canonical empty document rather than emitting an unverifiable map.
if devicesSnapshot[deviceAssignmentsKey] == "" {
devicesSnapshot[deviceAssignmentsKey] = fmt.Sprintf(`{"version":%d,"assignments":{}}`, maxDeviceAssignmentsVersion)
}
keyPath, managed := gatesentry2storage.InstallationKeyPath()
keyData, keyErr := os.ReadFile(keyPath)
if keyErr != nil && !errors.Is(keyErr, os.ErrNotExist) {
Expand Down Expand Up @@ -135,11 +141,14 @@ func ValidateArchive(data []byte) (*Archive, error) {
if schemaVersion > maxSettingsSchemaVersion {
return nil, fmt.Errorf("restore: settings schema %d is newer than this release supports (%d); restore the matching newer GateSentry binary or the pre-upgrade backup", schemaVersion, maxSettingsSchemaVersion)
}
// Validate device assignments version if present.
if assignments, ok := archive.Devices[deviceAssignmentsKey]; ok && assignments != "" {
if err := validateDeviceAssignmentsVersion(assignments); err != nil {
return nil, fmt.Errorf("restore: %w", err)
}
// Device assignments are required so compatibility can be verified before
// any live state is altered.
assignments, ok := archive.Devices[deviceAssignmentsKey]
if !ok || assignments == "" {
return nil, fmt.Errorf("restore: devices are missing %q data; cannot verify compatibility", deviceAssignmentsKey)
}
if err := validateDeviceAssignmentsVersion(assignments); err != nil {
return nil, fmt.Errorf("restore: %w", err)
}
return &archive, nil
}
Expand All @@ -149,13 +158,24 @@ func ValidateArchive(data []byte) (*Archive, error) {
// discovery.deviceAssignmentsVersion without importing the discovery package.
func validateDeviceAssignmentsVersion(raw string) error {
var doc struct {
Version int `json:"version"`
Version *int `json:"version"`
Assignments json.RawMessage `json:"assignments"`
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return fmt.Errorf("parse device assignments: %w", err)
}
if doc.Version > maxDeviceAssignmentsVersion {
return fmt.Errorf("device assignments version %d is newer than this release supports (%d)", doc.Version, maxDeviceAssignmentsVersion)
if doc.Version == nil {
return errors.New("device assignments are missing version")
}
if *doc.Version < 1 || *doc.Version > maxDeviceAssignmentsVersion {
return fmt.Errorf("device assignments version %d is not supported (expected 1 or %d)", *doc.Version, maxDeviceAssignmentsVersion)
}
var assignments map[string]json.RawMessage
if len(doc.Assignments) == 0 {
return errors.New("device assignments payload must contain an assignments object")
}
if err := json.Unmarshal(doc.Assignments, &assignments); err != nil || assignments == nil {
return errors.New("device assignments payload must contain an assignments object")
}
return nil
}
Expand Down
67 changes: 65 additions & 2 deletions application/backup/backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package backup
import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -56,11 +57,31 @@ func seedStores(t *testing.T, settings, devices *gatesentry2storage.MapStore) {
if err := settings.Update("strictness", "2000"); err != nil {
t.Fatal(err)
}
if err := devices.Update("assignments", `{"version":1,"assignments":{"dev-1":{"id":"dev-1","dns_name":"phone"}}}`); err != nil {
if err := devices.Update("assignments", `{"version":2,"assignments":{"dev-1":{"id":"dev-1","dns_name":"phone","tailscale_nodes":[{"node_id":"node-1","name":"phone-tailnet"}]}}}`); err != nil {
t.Fatal(err)
}
}

func TestCreateBackupSeedsEmptyDeviceAssignments(t *testing.T) {
setupTestDir(t)
settings, devices := openStores(t)
if err := settings.Update("settings_schema_version", "1"); err != nil {
t.Fatal(err)
}

data, err := CreateBackup(settings, devices, "test")
if err != nil {
t.Fatal(err)
}
archive, err := ValidateArchive(data)
if err != nil {
t.Fatalf("clean-install backup is not restorable: %v", err)
}
if got := archive.Devices[deviceAssignmentsKey]; got != `{"version":2,"assignments":{}}` {
t.Fatalf("assignments = %q", got)
}
}

// TestBackupRoundTrip verifies that a backup created from a configured
// installation can be restored to a clean install and all data matches.
func TestBackupRoundTrip(t *testing.T) {
Expand Down Expand Up @@ -174,7 +195,7 @@ func TestValidateArchiveInvalid(t *testing.T) {
{"non_numeric_schema", []byte(`{"format_version":1,"settings":{"settings_schema_version":"abc"},"devices":{}}`)},
{
"newer_device_version",
[]byte(`{"format_version":1,"settings":{"settings_schema_version":"1"},"devices":{"assignments":"{\"version\":99}"}}`),
[]byte(`{"format_version":1,"settings":{"settings_schema_version":"1"},"devices":{"assignments":"{\"version\":3}"}}`),
},
{
"malformed_device_version",
Expand All @@ -190,6 +211,48 @@ func TestValidateArchiveInvalid(t *testing.T) {
}
}

func TestValidateDeviceAssignmentsVersionCompatibility(t *testing.T) {
for _, version := range []int{1, 2} {
t.Run(fmt.Sprintf("accepts_v%d", version), func(t *testing.T) {
raw := fmt.Sprintf(`{"version":%d,"assignments":{}}`, version)
if err := validateDeviceAssignmentsVersion(raw); err != nil {
t.Fatalf("validateDeviceAssignmentsVersion(v%d): %v", version, err)
}
})
}

tests := []struct {
name string
raw string
}{
{name: "missing version", raw: `{"assignments":{}}`},
{name: "zero version", raw: `{"version":0,"assignments":{}}`},
{name: "negative version", raw: `{"version":-1,"assignments":{}}`},
{name: "malformed version", raw: `{"version":"2","assignments":{}}`},
{name: "unsupported version", raw: `{"version":3,"assignments":{}}`},
{name: "malformed document", raw: `{"version":2`},
{name: "missing assignments", raw: `{"version":2}`},
{name: "null assignments", raw: `{"version":2,"assignments":null}`},
{name: "malformed assignments", raw: `{"version":2,"assignments":[]}`},
}
for _, tt := range tests {
t.Run("rejects_"+tt.name, func(t *testing.T) {
if err := validateDeviceAssignmentsVersion(tt.raw); err == nil {
t.Fatal("expected validation error, got nil")
}
})
}
}

func TestValidateArchiveRequiresDeviceAssignments(t *testing.T) {
for _, devices := range []string{`{}`, `{"assignments":""}`} {
data := []byte(fmt.Sprintf(`{"format_version":1,"settings":{"settings_schema_version":"1"},"devices":%s}`, devices))
if _, err := ValidateArchive(data); err == nil {
t.Fatalf("ValidateArchive with devices %s: expected validation error", devices)
}
}
}

// TestValidateArchiveAcceptsValid verifies that a well-formed archive passes.
func TestValidateArchiveAcceptsValid(t *testing.T) {
setupTestDir(t)
Expand Down
27 changes: 27 additions & 0 deletions application/diagnostics/diagnostics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,30 @@ func TestCreateBundleUsesSafeAllowlist(t *testing.T) {
t.Fatalf("filter summary missing: %+v", bundle.Filters)
}
}

func TestRunDoesNotExposeStableTailscaleNodeIDs(t *testing.T) {
devices := gatesentryDiscovery.NewDeviceStore("local")
if _, err := devices.UpsertDeviceE(&gatesentryDiscovery.Device{
ID: "canonical-phone", IPv4: "192.0.2.70", Persistent: true,
}); err != nil {
t.Fatal(err)
}
const nodeID = "node-stable-secret"
if _, err := devices.LinkTailscaleIdentityE("canonical-phone", gatesentryDiscovery.TailscaleIdentity{
NodeID: nodeID, Addresses: []string{"100.64.0.70"}, Online: true,
}); err != nil {
t.Fatal(err)
}

data, err := json.Marshal(Run(Deps{
Devices: devices,
Listener: func(context.Context, string, string) error { return nil },
UpstreamCheck: func(context.Context, string) error { return nil },
}))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), nodeID) {
t.Fatalf("diagnostics leaked stable Tailscale node ID: %s", data)
}
}
137 changes: 74 additions & 63 deletions application/dns/discovery/mdns.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ const DefaultBrowseTimeout = 5 * time.Second
// MDNSBrowser performs periodic mDNS/Bonjour service discovery on the
// local network and feeds discovered devices into the DeviceStore.
type MDNSBrowser struct {
store *DeviceStore
interval time.Duration
browseTimeout time.Duration
serviceTypes []string
store *DeviceStore
interval time.Duration
browseTimeout time.Duration
serviceTypes []string
localAddressProvider func() []net.IP
browseProvider func(string, time.Duration) []*bonjour.ServiceEntry

stopCh chan struct{}
stopped chan struct{}
Expand All @@ -70,12 +72,15 @@ func NewMDNSBrowser(store *DeviceStore, interval time.Duration) *MDNSBrowser {
if interval <= 0 {
interval = DefaultScanInterval
}
return &MDNSBrowser{
store: store,
interval: interval,
browseTimeout: DefaultBrowseTimeout,
serviceTypes: DefaultServiceTypes,
browser := &MDNSBrowser{
store: store,
interval: interval,
browseTimeout: DefaultBrowseTimeout,
serviceTypes: DefaultServiceTypes,
localAddressProvider: localInterfaceAddresses,
}
browser.browseProvider = browser.browseServiceType
return browser
}

// SetServiceTypes overrides the default list of mDNS service types to browse.
Expand Down Expand Up @@ -187,7 +192,7 @@ func (b *MDNSBrowser) scanOnce() {
default:
}

entries := b.browseServiceType(svcType, browseTimeout)
entries := b.browseProvider(svcType, browseTimeout)
for _, entry := range entries {
b.processEntry(entry)
}
Expand Down Expand Up @@ -240,7 +245,7 @@ func (b *MDNSBrowser) browseServiceType(serviceType string, timeout time.Duratio
}

func (b *MDNSBrowser) processEntry(entry *bonjour.ServiceEntry) {
if entry == nil {
if entry == nil || b.isSelfGateSentryEntry(entry) {
return
}

Expand All @@ -255,82 +260,88 @@ func (b *MDNSBrowser) processEntry(entry *bonjour.ServiceEntry) {
ipv6 = entry.AddrIPv6.String()
}

// Need at least an IP or hostname to create a meaningful device entry
if ipv4 == "" && ipv6 == "" && hostname == "" {
// A service name without an address cannot identify a usable network
// device. It may arrive as a partial mDNS response and must not create an
// addressless row that later restores as Unknown.
if ipv4 == "" && ipv6 == "" {
return
}

// Associate the observation using the most stable available identity:
// MAC address, then hostname, then the current IP address.
mac := ""
if ipv4 != "" {
mac = LookupARPEntry(ipv4)
}
var existing *Device
if mac != "" {
existing = b.store.FindDeviceByMAC(mac)
}
if existing == nil && hostname != "" {
existing = b.store.FindDeviceByHostname(hostname)
}
if existing == nil && instanceName != "" {
existing = b.store.FindDeviceByHostname(instanceName)
}
if existing == nil && ipv4 != "" {
existing = b.store.FindDeviceByIP(ipv4)
}
if existing == nil && ipv6 != "" {
existing = b.store.FindDeviceByIP(ipv6)
}

// Build the device struct for upsert
device := &Device{
device := Device{
Source: SourceMDNS,
Sources: []DiscoverySource{SourceMDNS},
IPv4: ipv4,
IPv6: ipv6,
Online: true,
}

if instanceName != "" {
device.MDNSNames = []string{instanceName}
}
if hostname != "" {
device.Hostnames = []string{hostname}
}
if ipv4 != "" {
if mac := LookupARPEntry(ipv4); mac != "" {
device.MACs = []string{mac}
}
}

// If enriching an existing device, set its ID so UpsertDevice merges
if existing != nil {
device.ID = existing.ID
deviceID, created, err := b.store.ObserveDevice(device)
if err != nil {
log.Printf("[mDNS] Ignored ambiguous or invalid service observation: %v", err)
return
}
if created {
log.Printf("[mDNS] New device: %q (%s) at %s/%s [%s]", instanceName, hostname, ipv4, ipv6, entry.Service)
} else {
log.Printf("[mDNS] Enriched device %s: %q (%s) [%s]", deviceID, instanceName, hostname, entry.Service)
}
}

// Preserve existing IPs that mDNS didn't provide
if device.IPv4 == "" && existing.IPv4 != "" {
device.IPv4 = existing.IPv4
func (b *MDNSBrowser) isSelfGateSentryEntry(entry *bonjour.ServiceEntry) bool {
marker := false
for _, text := range entry.Text {
if strings.EqualFold(strings.TrimSpace(text), "app=gatesentry") {
marker = true
break
}
if device.IPv6 == "" && existing.IPv6 != "" {
device.IPv6 = existing.IPv6
}
if !marker {
return false
}
addresses := b.localAddressProvider()
for _, advertised := range []net.IP{entry.AddrIPv4, entry.AddrIPv6} {
if advertised == nil {
continue
}

// Prefer GUA/ULA over link-local IPv6 — don't downgrade a better address
if device.IPv6 != "" && existing.IPv6 != "" &&
IsLinkLocalIPv6(device.IPv6) && !IsLinkLocalIPv6(existing.IPv6) {
device.IPv6 = existing.IPv6
for _, local := range addresses {
if local.Equal(advertised) {
return true
}
}
}
return false
}

if mac != "" {
device.MACs = []string{mac}
func localInterfaceAddresses() []net.IP {
addresses, err := net.InterfaceAddrs()
if err != nil {
return nil
}

deviceID := b.store.UpsertDevice(device)

if existing == nil {
log.Printf("[mDNS] New device: %q (%s) at %s/%s [%s]",
instanceName, hostname, ipv4, ipv6, entry.Service)
} else {
log.Printf("[mDNS] Enriched device %s: %q (%s) [%s]",
deviceID, instanceName, hostname, entry.Service)
result := make([]net.IP, 0, len(addresses))
for _, address := range addresses {
var value string
switch typed := address.(type) {
case *net.IPNet:
value = typed.IP.String()
case *net.IPAddr:
value = typed.IP.String()
}
if ip := net.ParseIP(value); ip != nil {
result = append(result, ip)
}
}
return result
}

// CleanMDNSHostname strips mDNS suffixes and trailing dots from a hostname.
Expand Down
Loading
Loading