From cf3d30b6f61ad685054bce900e46c23062bbad98 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 28 Jan 2026 13:37:48 +0000 Subject: [PATCH] types: add MarshalZerologObject to domain types Implement zerolog.LogObjectMarshaler interface on domain types for structured logging: - Node: logs node.id, node.name, machine.key (short), node.key (short), node.is_tagged, node.expired, node.online, node.tags, user.name - User: logs user.id, user.name, user.display, user.provider - PreAuthKey: logs pak.id, pak.prefix (masked), pak.reusable, pak.ephemeral, pak.used, pak.is_tagged, pak.tags - APIKey: logs api_key.id, api_key.prefix (masked), api_key.expiration Security: PreAuthKey and APIKey only log masked prefixes, never full keys or hashes. Uses zf.* constants for consistent field naming. --- hscontrol/types/api_key.go | 32 +++++++++++++++++++ hscontrol/types/node.go | 42 +++++++++++++++++++++++++ hscontrol/types/preauth_key.go | 57 +++++++++++++++++++++++++++------- hscontrol/types/users.go | 26 ++++++++++++++++ 4 files changed, 146 insertions(+), 11 deletions(-) diff --git a/hscontrol/types/api_key.go b/hscontrol/types/api_key.go index b6a12b65..2dac537d 100644 --- a/hscontrol/types/api_key.go +++ b/hscontrol/types/api_key.go @@ -4,6 +4,8 @@ import ( "time" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/hscontrol/util/zlog/zf" + "github.com/rs/zerolog" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -54,3 +56,33 @@ func (key *APIKey) Proto() *v1.ApiKey { return &protoKey } + +// maskedPrefix returns the API key prefix in masked format for safe logging. +// SECURITY: Never log the full key or hash, only the masked prefix. +func (k *APIKey) maskedPrefix() string { + if len(k.Prefix) == NewAPIKeyPrefixLength { + return "hskey-api-" + k.Prefix + "-***" + } + + return k.Prefix + "***" +} + +// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging. +// SECURITY: This method intentionally does NOT log the full key or hash. +// Only the masked prefix is logged for identification purposes. +func (k *APIKey) MarshalZerologObject(e *zerolog.Event) { + if k == nil { + return + } + + e.Uint64(zf.APIKeyID, k.ID) + e.Str(zf.APIKeyPrefix, k.maskedPrefix()) + + if k.Expiration != nil { + e.Time(zf.APIKeyExpiration, *k.Expiration) + } + + if k.LastSeen != nil { + e.Time(zf.APIKeyLastSeen, *k.LastSeen) + } +} diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 41cd9759..b5c649d6 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -13,6 +13,8 @@ import ( v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/util" + "github.com/juanfont/headscale/hscontrol/util/zlog/zf" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "go4.org/netipx" "google.golang.org/protobuf/types/known/timestamppb" @@ -487,6 +489,36 @@ func (node *Node) String() string { return node.Hostname } +// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging. +// This method is used with zerolog's EmbedObject() for flat field embedding +// or Object() for nested logging when multiple nodes are logged. +func (node *Node) MarshalZerologObject(e *zerolog.Event) { + if node == nil { + return + } + + e.Uint64(zf.NodeID, node.ID.Uint64()) + e.Str(zf.NodeName, node.Hostname) + e.Str(zf.MachineKey, node.MachineKey.ShortString()) + e.Str(zf.NodeKey, node.NodeKey.ShortString()) + e.Bool(zf.NodeIsTagged, node.IsTagged()) + e.Bool(zf.NodeExpired, node.IsExpired()) + + if node.IsOnline != nil { + e.Bool(zf.NodeOnline, *node.IsOnline) + } + + if len(node.Tags) > 0 { + e.Strs(zf.NodeTags, node.Tags) + } + + if node.User != nil { + e.Str(zf.UserName, node.User.Username()) + } else if node.UserID != nil { + e.Uint(zf.UserID, *node.UserID) + } +} + // PeerChangeFromMapRequest takes a MapRequest and compares it to the node // to produce a PeerChange struct that can be used to updated the node and // inform peers about smaller changes to the node. @@ -719,6 +751,16 @@ func (node Node) DebugString() string { return sb.String() } +// MarshalZerologObject implements zerolog.LogObjectMarshaler for NodeView. +// This delegates to the underlying Node's implementation. +func (nv NodeView) MarshalZerologObject(e *zerolog.Event) { + if !nv.Valid() { + return + } + + nv.ж.MarshalZerologObject(e) +} + // Owner returns the owner for display purposes. // For tagged nodes, returns TaggedDevices. For user-owned nodes, returns the user. func (nv NodeView) Owner() UserView { diff --git a/hscontrol/types/preauth_key.go b/hscontrol/types/preauth_key.go index 2ce02f02..39a94222 100644 --- a/hscontrol/types/preauth_key.go +++ b/hscontrol/types/preauth_key.go @@ -4,6 +4,8 @@ import ( "time" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/hscontrol/util/zlog/zf" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -120,19 +122,10 @@ func (pak *PreAuthKey) Validate() error { return PAKError("invalid authkey") } + // Use EmbedObject for safe logging - never log full key log.Debug(). Caller(). - Str("key", pak.Key). - Bool("hasExpiration", pak.Expiration != nil). - Time("expiration", func() time.Time { - if pak.Expiration != nil { - return *pak.Expiration - } - return time.Time{} - }()). - Time("now", time.Now()). - Bool("reusable", pak.Reusable). - Bool("used", pak.Used). + EmbedObject(pak). Msg("PreAuthKey.Validate: checking key") if pak.Expiration != nil && pak.Expiration.Before(time.Now()) { @@ -156,3 +149,45 @@ func (pak *PreAuthKey) Validate() error { func (pak *PreAuthKey) IsTagged() bool { return len(pak.Tags) > 0 } + +// maskedPrefix returns the key prefix in masked format for safe logging. +// SECURITY: Never log the full key or hash, only the masked prefix. +func (pak *PreAuthKey) maskedPrefix() string { + if pak.Prefix != "" { + return "hskey-auth-" + pak.Prefix + "-***" + } + + return "" +} + +// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging. +// SECURITY: This method intentionally does NOT log the full key or hash. +// Only the masked prefix is logged for identification purposes. +func (pak *PreAuthKey) MarshalZerologObject(e *zerolog.Event) { + if pak == nil { + return + } + + e.Uint64(zf.PAKID, pak.ID) + e.Bool(zf.PAKReusable, pak.Reusable) + e.Bool(zf.PAKEphemeral, pak.Ephemeral) + e.Bool(zf.PAKUsed, pak.Used) + e.Bool(zf.PAKIsTagged, pak.IsTagged()) + + // SECURITY: Only log masked prefix, never full key or hash + if masked := pak.maskedPrefix(); masked != "" { + e.Str(zf.PAKPrefix, masked) + } + + if len(pak.Tags) > 0 { + e.Strs(zf.PAKTags, pak.Tags) + } + + if pak.User != nil { + e.Str(zf.UserName, pak.User.Username()) + } + + if pak.Expiration != nil { + e.Time(zf.PAKExpiration, *pak.Expiration) + } +} diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index ec40492b..d2522d34 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -12,6 +12,8 @@ import ( v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/util" + "github.com/juanfont/headscale/hscontrol/util/zlog/zf" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "google.golang.org/protobuf/types/known/timestamppb" "gorm.io/gorm" @@ -194,6 +196,30 @@ func (u *User) Proto() *v1.User { } } +// MarshalZerologObject implements zerolog.LogObjectMarshaler for safe logging. +func (u *User) MarshalZerologObject(e *zerolog.Event) { + if u == nil { + return + } + + e.Uint(zf.UserID, u.ID) + e.Str(zf.UserName, u.Username()) + e.Str(zf.UserDisplay, u.Display()) + + if u.Provider != "" { + e.Str(zf.UserProvider, u.Provider) + } +} + +// MarshalZerologObject implements zerolog.LogObjectMarshaler for UserView. +func (u UserView) MarshalZerologObject(e *zerolog.Event) { + if !u.Valid() { + return + } + + u.ж.MarshalZerologObject(e) +} + // JumpCloud returns a JSON where email_verified is returned as a // string "true" or "false" instead of a boolean. // This maps bool to a specific type with a custom unmarshaler to