Files
headscale/hscontrol/util/zlog/zlog_test.go
Kristoffer Dalby 58020696fe zlog: add utility package for safe and consistent logging
Add hscontrol/util/zlog package with:

- zf subpackage: field name constants for compile-time safety
- SafeHostinfo: wrapper that redacts device fingerprinting data
- SafeMapRequest: wrapper that redacts client endpoints

The zf (zerolog fields) subpackage provides short constant names
(e.g., zf.NodeID instead of inline "node.id" strings) ensuring
consistent field naming across all log statements.

Security considerations:
- SafeHostinfo never logs: OSVersion, DeviceModel, DistroName
- SafeMapRequest only logs endpoint counts, not actual IPs
2026-02-06 07:40:29 +01:00

228 lines
5.4 KiB
Go

package zlog
import (
"bytes"
"encoding/json"
"net/netip"
"testing"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
func TestSafeHostinfo_MarshalZerologObject(t *testing.T) {
tests := []struct {
name string
hostinfo *tailcfg.Hostinfo
wantFields map[string]any
wantAbsent []string // Fields that should NOT be present
}{
{
name: "nil hostinfo",
hostinfo: nil,
wantFields: map[string]any{},
},
{
name: "basic hostinfo",
hostinfo: &tailcfg.Hostinfo{
Hostname: "myhost",
OS: "linux",
},
wantFields: map[string]any{
zf.Hostname: "myhost",
zf.OS: "linux",
},
},
{
name: "hostinfo with routes and tags",
hostinfo: &tailcfg.Hostinfo{
Hostname: "router",
OS: "linux",
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
RequestTags: []string{"tag:server"},
},
wantFields: map[string]any{
zf.Hostname: "router",
zf.OS: "linux",
zf.RoutableIPCount: float64(1),
},
},
{
name: "hostinfo with netinfo",
hostinfo: &tailcfg.Hostinfo{
Hostname: "myhost",
OS: "windows",
NetInfo: &tailcfg.NetInfo{
PreferredDERP: 1,
},
},
wantFields: map[string]any{
zf.Hostname: "myhost",
zf.OS: "windows",
zf.DERP: float64(1),
},
},
{
name: "sensitive fields are NOT logged",
hostinfo: &tailcfg.Hostinfo{
Hostname: "myhost",
OS: "linux",
OSVersion: "5.15.0-generic", // Should NOT be logged
DeviceModel: "ThinkPad X1", // Should NOT be logged
IPNVersion: "1.50.0", // Should NOT be logged
},
wantFields: map[string]any{
zf.Hostname: "myhost",
zf.OS: "linux",
},
wantAbsent: []string{"os_version", "device_model", "ipn_version", "OSVersion", "DeviceModel", "IPNVersion"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
log := zerolog.New(&buf)
log.Info().EmbedObject(Hostinfo(tt.hostinfo)).Msg("test")
var result map[string]any
err := json.Unmarshal(buf.Bytes(), &result)
require.NoError(t, err)
// Check expected fields are present
for key, wantVal := range tt.wantFields {
assert.Equal(t, wantVal, result[key], "field %s", key)
}
// Check sensitive fields are absent
for _, key := range tt.wantAbsent {
_, exists := result[key]
assert.False(t, exists, "sensitive field %s should not be logged", key)
}
})
}
}
func TestSafeMapRequest_MarshalZerologObject(t *testing.T) {
nodeKey := key.NewNode().Public()
tests := []struct {
name string
req *tailcfg.MapRequest
wantFields map[string]any
wantAbsent []string
}{
{
name: "nil request",
req: nil,
wantFields: map[string]any{},
},
{
name: "basic request",
req: &tailcfg.MapRequest{
Stream: true,
OmitPeers: false,
Version: 100,
NodeKey: nodeKey,
},
wantFields: map[string]any{
zf.Stream: true,
zf.OmitPeers: false,
zf.Version: float64(100),
},
},
{
name: "request with endpoints - only count logged",
req: &tailcfg.MapRequest{
Stream: false,
OmitPeers: true,
Version: 100,
NodeKey: nodeKey,
Endpoints: []netip.AddrPort{
netip.MustParseAddrPort("192.168.1.100:41641"),
netip.MustParseAddrPort("10.0.0.50:41641"),
},
},
wantFields: map[string]any{
zf.Stream: false,
zf.OmitPeers: true,
zf.EndpointsCount: float64(2),
},
wantAbsent: []string{"endpoints", "Endpoints"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
log := zerolog.New(&buf)
log.Info().EmbedObject(MapRequest(tt.req)).Msg("test")
var result map[string]any
err := json.Unmarshal(buf.Bytes(), &result)
require.NoError(t, err)
// Check expected fields are present
for key, wantVal := range tt.wantFields {
assert.Equal(t, wantVal, result[key], "field %s", key)
}
// Check node.key is a short string (not full key)
if tt.req != nil {
nodeKeyStr, ok := result[zf.NodeKey].(string)
if ok {
// Short keys are truncated, full keys are 64+ chars
assert.Less(t, len(nodeKeyStr), 20, "node key should be short form")
}
}
// Check sensitive fields are absent
for _, key := range tt.wantAbsent {
_, exists := result[key]
assert.False(t, exists, "sensitive field %s should not be logged", key)
}
})
}
}
func TestFieldConstants(t *testing.T) {
// Verify field constants follow the expected naming pattern
fieldTests := []struct {
constant string
expected string
}{
{zf.NodeID, "node.id"},
{zf.NodeName, "node.name"},
{zf.NodeKey, "node.key"},
{zf.MachineKey, "machine.key"},
{zf.NodeTags, "node.tags"},
{zf.NodeIsTagged, "node.is_tagged"},
{zf.NodeOnline, "node.online"},
{zf.NodeExpired, "node.expired"},
{zf.UserID, "user.id"},
{zf.UserName, "user.name"},
{zf.PAKID, "pak.id"},
{zf.PAKPrefix, "pak.prefix"},
{zf.APIKeyID, "api_key.id"},
{zf.APIKeyPrefix, "api_key.prefix"},
{zf.OmitPeers, "omit_peers"},
{zf.Stream, "stream"},
}
for _, tt := range fieldTests {
t.Run(tt.expected, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.constant)
})
}
}