Improve logging and error handling

This commit is contained in:
Viktor Liu
2026-02-12 13:26:36 +08:00
parent 54a73c6649
commit d689718b50
10 changed files with 111 additions and 89 deletions

View File

@@ -3,6 +3,7 @@ package accesslog
import (
"context"
"net/netip"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
@@ -62,7 +63,9 @@ func (l *Logger) log(ctx context.Context, entry logEntry) {
// allow for resolving that on the server.
now := timestamppb.Now() // Grab the timestamp before launching the goroutine to try to prevent weird timing issues. This is probably unnecessary.
go func() {
if _, err := l.client.SendAccessLog(context.Background(), &proto.SendAccessLogRequest{
logCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := l.client.SendAccessLog(logCtx, &proto.SendAccessLogRequest{
Log: &proto.AccessLog{
LogId: entry.ID,
AccountId: entry.AccountID,

View File

@@ -30,15 +30,14 @@ type SessionValidator interface {
ValidateSession(ctx context.Context, in *proto.ValidateSessionRequest, opts ...grpc.CallOption) (*proto.ValidateSessionResponse, error)
}
// Scheme defines an authentication mechanism for a domain.
type Scheme interface {
Type() auth.Method
// Authenticate should check the passed request and determine whether
// it represents an authenticated user request. If it does not, then
// an empty string should indicate an unauthenticated request which
// will be rejected; optionally, it can also return any data that should
// be included in a UI template when prompting the user to authenticate.
// If the request is authenticated, then a session token should be returned.
Authenticate(*http.Request) (token string, promptData string)
// Authenticate checks the request and determines whether it represents
// an authenticated user. An empty token indicates an unauthenticated
// request; optionally, promptData may be returned for the login UI.
// An error indicates an infrastructure failure (e.g. gRPC unavailable).
Authenticate(*http.Request) (token string, promptData string, err error)
}
type DomainConfig struct {
@@ -141,7 +140,15 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
methods := make(map[string]string)
var attemptedMethod string
for _, scheme := range config.Schemes {
token, promptData := scheme.Authenticate(r)
token, promptData, err := scheme.Authenticate(r)
if err != nil {
mw.logger.WithField("scheme", scheme.Type().String()).Warnf("authentication infrastructure error: %v", err)
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
}
http.Error(w, "authentication service unavailable", http.StatusBadGateway)
return
}
// Track if credentials were submitted but auth failed
if token == "" && wasCredentialSubmitted(r, scheme.Type()) {

View File

@@ -32,16 +32,16 @@ type stubScheme struct {
method auth.Method
token string
promptID string
authFn func(*http.Request) (string, string)
authFn func(*http.Request) (string, string, error)
}
func (s *stubScheme) Type() auth.Method { return s.method }
func (s *stubScheme) Authenticate(r *http.Request) (string, string) {
func (s *stubScheme) Authenticate(r *http.Request) (string, string, error) {
if s.authFn != nil {
return s.authFn(r)
}
return s.token, s.promptID
return s.token, s.promptID, nil
}
func newPassthroughHandler() http.Handler {
@@ -344,11 +344,11 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
scheme := &stubScheme{
method: auth.MethodPIN,
authFn: func(r *http.Request) (string, string) {
authFn: func(r *http.Request) (string, string, error) {
if r.FormValue("pin") == "111111" {
return token, ""
return token, "", nil
}
return "", "pin"
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", ""))
@@ -391,8 +391,8 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
scheme := &stubScheme{
method: auth.MethodPIN,
authFn: func(_ *http.Request) (string, string) {
return "", "pin"
authFn: func(_ *http.Request) (string, string, error) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", ""))
@@ -418,17 +418,17 @@ func TestProtect_MultipleSchemes(t *testing.T) {
// First scheme (PIN) always fails, second scheme (password) succeeds.
pinScheme := &stubScheme{
method: auth.MethodPIN,
authFn: func(_ *http.Request) (string, string) {
return "", "pin"
authFn: func(_ *http.Request) (string, string, error) {
return "", "pin", nil
},
}
passwordScheme := &stubScheme{
method: auth.MethodPassword,
authFn: func(r *http.Request) (string, string) {
authFn: func(r *http.Request) (string, string, error) {
if r.FormValue("password") == "secret" {
return token, ""
return token, "", nil
}
return "", "password"
return "", "password", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", ""))
@@ -457,8 +457,8 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) {
// Return a garbage token that won't validate.
scheme := &stubScheme{
method: auth.MethodPIN,
authFn: func(_ *http.Request) (string, string) {
return "invalid-jwt-token", ""
authFn: func(_ *http.Request) (string, string, error) {
return "invalid-jwt-token", "", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", ""))
@@ -517,8 +517,8 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) {
// Scheme that always fails authentication (returns empty token)
scheme := &stubScheme{
method: auth.MethodPIN,
authFn: func(_ *http.Request) (string, string) {
return "", "pin"
authFn: func(_ *http.Request) (string, string, error) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", ""))
@@ -544,8 +544,8 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) {
scheme := &stubScheme{
method: auth.MethodPassword,
authFn: func(_ *http.Request) (string, string) {
return "", "password"
authFn: func(_ *http.Request) (string, string, error) {
return "", "password", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", ""))
@@ -571,8 +571,8 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) {
scheme := &stubScheme{
method: auth.MethodPIN,
authFn: func(_ *http.Request) (string, string) {
return "", "pin"
authFn: func(_ *http.Request) (string, string, error) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", ""))

View File

@@ -2,6 +2,7 @@ package auth
import (
"context"
"fmt"
"net/http"
"net/url"
@@ -36,12 +37,13 @@ func (OIDC) Type() auth.Method {
return auth.MethodOIDC
}
func (o OIDC) Authenticate(r *http.Request) (string, string) {
// Authenticate checks for an OIDC session token or obtains the OIDC redirect URL.
func (o OIDC) Authenticate(r *http.Request) (string, string, error) {
// Check for the session_token query param (from OIDC redirects).
// The management server passes the token in the URL because it cannot set
// cookies for the proxy's domain (cookies are domain-scoped per RFC 6265).
if token := r.URL.Query().Get("session_token"); token != "" {
return token, ""
return token, "", nil
}
redirectURL := &url.URL{
@@ -56,9 +58,8 @@ func (o OIDC) Authenticate(r *http.Request) (string, string) {
RedirectUrl: redirectURL.String(),
})
if err != nil {
// TODO: log
return "", ""
return "", "", fmt.Errorf("get OIDC URL: %w", err)
}
return "", res.GetUrl()
return "", res.GetUrl(), nil
}

View File

@@ -1,6 +1,7 @@
package auth
import (
"fmt"
"net/http"
"github.com/netbirdio/netbird/proxy/auth"
@@ -31,12 +32,12 @@ func (Password) Type() auth.Method {
// If authentication fails, the required HTTP form ID is returned
// so that it can be injected into a request from the UI so that
// authentication may be successful.
func (p Password) Authenticate(r *http.Request) (string, string) {
func (p Password) Authenticate(r *http.Request) (string, string, error) {
password := r.FormValue(passwordFormId)
if password == "" {
// This cannot be authenticated, so not worth wasting time sending the request.
return "", passwordFormId
// No password submitted; return the form ID so the UI can prompt the user.
return "", passwordFormId, nil
}
res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
@@ -49,13 +50,12 @@ func (p Password) Authenticate(r *http.Request) (string, string) {
},
})
if err != nil {
// TODO: log error here
return "", passwordFormId
return "", "", fmt.Errorf("authenticate password: %w", err)
}
if res.GetSuccess() {
return res.GetSessionToken(), ""
return res.GetSessionToken(), "", nil
}
return "", passwordFormId
return "", passwordFormId, nil
}

View File

@@ -1,6 +1,7 @@
package auth
import (
"fmt"
"net/http"
"github.com/netbirdio/netbird/proxy/auth"
@@ -31,12 +32,12 @@ func (Pin) Type() auth.Method {
// If authentication fails, the required HTTP form ID is returned
// so that it can be injected into a request from the UI so that
// authentication may be successful.
func (p Pin) Authenticate(r *http.Request) (string, string) {
func (p Pin) Authenticate(r *http.Request) (string, string, error) {
pin := r.FormValue(pinFormId)
if pin == "" {
// This cannot be authenticated, so not worth wasting time sending the request.
return "", pinFormId
// No PIN submitted; return the form ID so the UI can prompt the user.
return "", pinFormId, nil
}
res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
@@ -49,13 +50,12 @@ func (p Pin) Authenticate(r *http.Request) (string, string) {
},
})
if err != nil {
// TODO: log error here
return "", pinFormId
return "", "", fmt.Errorf("authenticate pin: %w", err)
}
if res.GetSuccess() {
return res.GetSessionToken(), ""
return res.GetSessionToken(), "", nil
}
return "", pinFormId
return "", pinFormId, nil
}

View File

@@ -220,7 +220,7 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, d doma
if errors.Is(err, context.DeadlineExceeded) {
n.logger.WithFields(log.Fields{
"account_id": accountID,
}).Debug("netbird client start timed out, will retry on first request")
}).Warn("netbird client start timed out, will retry on first request")
} else {
n.logger.WithFields(log.Fields{
"account_id": accountID,
@@ -279,6 +279,7 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, d d
entry, exists := n.clients[accountID]
if !exists {
n.clientsMux.Unlock()
n.logger.WithField("account_id", accountID).Debug("remove peer: account not found")
return nil
}
@@ -286,6 +287,10 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, d d
domInfo, domainExists := entry.domains[d]
if !domainExists {
n.clientsMux.Unlock()
n.logger.WithFields(log.Fields{
"account_id": accountID,
"domain": d,
}).Debug("remove peer: domain not registered")
return nil
}

View File

@@ -23,7 +23,7 @@ func (m *mockMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxy
// mockNetBird creates a NetBird instance for testing without actually connecting.
// It uses an invalid management URL to prevent real connections.
func mockNetBird() *NetBird {
return NewNetBird("http://invalid.test:9999", "test-proxy", "localhost", 0, nil, nil, &mockMgmtClient{}, nil)
return NewNetBird("http://invalid.test:9999", "test-proxy", "invalid.test", 0, nil, nil, &mockMgmtClient{}, nil)
}
func TestNetBird_AddPeer_CreatesClientForNewAccount(t *testing.T) {

View File

@@ -502,7 +502,12 @@ func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.Pr
}).Error("Error adding new mapping, ignoring this mapping and continuing processing")
}
case proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED:
s.updateMapping(ctx, mapping)
if err := s.updateMapping(ctx, mapping); err != nil {
s.Logger.WithFields(log.Fields{
"service_id": mapping.GetId(),
"domain": mapping.GetDomain(),
}).Errorf("failed to update mapping: %v", err)
}
case proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED:
s.removeMapping(ctx, mapping)
}
@@ -536,11 +541,14 @@ func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) er
// Pass the mapping through to the update function to avoid duplicating the
// setup, currently update is simply a subset of this function, so this
// separation makes sense...to me at least.
s.updateMapping(ctx, mapping)
if err := s.updateMapping(ctx, mapping); err != nil {
s.removeMapping(ctx, mapping)
return fmt.Errorf("update mapping for domain %q: %w", d, err)
}
return nil
}
func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) {
func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) error {
// Very simple implementation here, we don't touch the existing peer
// connection or any existing TLS configuration, we simply overwrite
// the auth and proxy mappings.
@@ -559,10 +567,10 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, mapping.GetAccountId(), mapping.GetId()); err != nil {
s.Logger.WithField("domain", mapping.GetDomain()).WithError(err).Error("Auth setup failed, refusing to serve domain without authentication")
return
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
}
s.proxy.AddMapping(s.protoToMapping(mapping))
return nil
}
func (s *Server) removeMapping(ctx context.Context, mapping *proto.ProxyMapping) {
@@ -594,8 +602,8 @@ func (s *Server) protoToMapping(mapping *proto.ProxyMapping) proxy.Mapping {
"domain": mapping.GetDomain(),
"path": pathMapping.GetPath(),
"target": pathMapping.GetTarget(),
"error": err,
}).Error("Error parsing target URL for path, this path will be ignored but other paths will still be configured")
}).WithError(err).Error("failed to parse target URL for path, skipping")
continue
}
paths[pathMapping.GetPath()] = targetURL
}