diff --git a/clients/clients.go b/clients/clients.go index dc2d3c1..048968c 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -35,11 +35,12 @@ import ( ) type WgConfig struct { - IpAddress string `json:"ipAddress"` - Peers []Peer `json:"peers"` - Targets []Target `json:"targets"` - Certs []CertData `json:"certs"` - ChainId string `json:"chainId"` + IpAddress string `json:"ipAddress"` + Peers []Peer `json:"peers"` + Targets []Target `json:"targets"` + Certs []CertData `json:"certs"` + ClientIdentities []netstack2.ClientIdentity `json:"clientIdentities"` + ChainId string `json:"chainId"` } type Target struct { @@ -223,6 +224,7 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string wsClient.RegisterHandler("newt/wg/targets/update", service.handleUpdateTarget) wsClient.RegisterHandler("newt/certs/add", service.handleAddCerts) wsClient.RegisterHandler("newt/certs/remove", service.handleRemoveCerts) + wsClient.RegisterHandler("newt/wg/client-identities/add", service.handleAddClientIdentities) return service, nil } @@ -589,6 +591,10 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) { logger.Error("Failed to ensure WireGuard peers: %v", err) } + if s.tnet != nil { + s.tnet.ReplaceClientIdentities(config.ClientIdentities) + } + if err := s.ensureTargets(config.Targets); err != nil { logger.Error("Failed to ensure WireGuard targets: %v", err) } @@ -697,13 +703,43 @@ func (s *WireGuardService) handleRemoveCerts(msg websocket.WSMessage) { logger.Info("Removed %d certs", len(data.IDs)) } +// handleAddClientIdentities processes a "newt/wg/client-identities/add" +// message: an array of ClientIdentity to upsert into the HTTP proxy's +// identity table. +func (s *WireGuardService) handleAddClientIdentities(msg websocket.WSMessage) { + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling client identities data: %v", err) + return + } + + var identities []netstack2.ClientIdentity + if err := json.Unmarshal(jsonData, &identities); err != nil { + logger.Warn("Error unmarshaling client identities data: %v", err) + return + } + + if s.tnet == nil { + // Native interface mode - proxy features not available, skip silently + logger.Debug("Skipping client identities - using native interface (no proxy support)") + return + } + + s.tnet.AddClientIdentities(identities) + logger.Info("Added %d client identities", len(identities)) +} + // Sync synchronizes the clients WireGuard peers and targets with the desired state // received as part of the main newt/sync message. -func (s *WireGuardService) Sync(peers []Peer, targets []Target) { +func (s *WireGuardService) Sync(peers []Peer, targets []Target, identities []netstack2.ClientIdentity) { if err := s.syncPeers(peers); err != nil { logger.Error("Failed to sync client peers: %v", err) } + if s.tnet != nil { + s.tnet.ReplaceClientIdentities(identities) + } + if err := s.syncTargets(targets); err != nil { logger.Error("Failed to sync client targets: %v", err) } diff --git a/netstack2/client_identity.go b/netstack2/client_identity.go new file mode 100644 index 0000000..9a85690 --- /dev/null +++ b/netstack2/client_identity.go @@ -0,0 +1,115 @@ +package netstack2 + +import ( + "net/http" + "net/netip" + "sync" + + "github.com/fosrl/newt/logger" +) + +// Header names match the defaults that Badger sets for resources behind +// Traefik, so downstream services see the same identity headers regardless of +// whether a request came through Pangolin's public proxy or through newt. +const ( + remoteUserIDHeader = "Remote-User-Id" + remoteUserHeader = "Remote-User" + remoteEmailHeader = "Remote-Email" + remoteNameHeader = "Remote-Name" + remoteRoleHeader = "Remote-Role" +) + +// ClientIdentity describes who is behind a client's tunnel IP. The server +// sends one per client that can reach an HTTP resource; the HTTP handler maps +// a request's source address to it to set the Remote-* headers. Machine +// clients have no user, so only Name and Role are set for them. +type ClientIdentity struct { + IP string `json:"ip"` + UserID string `json:"userId,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Role string `json:"role,omitempty"` +} + +// clientIdentityStore maps client tunnel IPs to their identity. +type clientIdentityStore struct { + mu sync.RWMutex + identities map[netip.Addr]ClientIdentity +} + +func parseIdentityAddr(id ClientIdentity) (netip.Addr, bool) { + addr, err := netip.ParseAddr(id.IP) + if err != nil { + logger.Warn("Ignoring client identity with invalid IP %q: %v", id.IP, err) + return netip.Addr{}, false + } + return addr.Unmap(), true +} + +// replace swaps in the full set of identities, as sent on every full sync. +func (s *clientIdentityStore) replace(ids []ClientIdentity) { + m := make(map[netip.Addr]ClientIdentity, len(ids)) + for _, id := range ids { + if addr, ok := parseIdentityAddr(id); ok { + m[addr] = id + } + } + s.mu.Lock() + s.identities = m + s.mu.Unlock() +} + +// upsert adds or overwrites the given identities, keeping all others. +func (s *clientIdentityStore) upsert(ids []ClientIdentity) { + s.mu.Lock() + defer s.mu.Unlock() + if s.identities == nil { + s.identities = make(map[netip.Addr]ClientIdentity, len(ids)) + } + for _, id := range ids { + if addr, ok := parseIdentityAddr(id); ok { + s.identities[addr] = id + } + } +} + +// lookup returns the identity for the source address of a request +// ("ip:port", as found in http.Request.RemoteAddr). +func (s *clientIdentityStore) lookup(remoteAddr string) (ClientIdentity, bool) { + addrPort, err := netip.ParseAddrPort(remoteAddr) + if err != nil { + return ClientIdentity{}, false + } + s.mu.RLock() + defer s.mu.RUnlock() + id, ok := s.identities[addrPort.Addr().Unmap()] + return id, ok +} + +// setRemoteHeaders strips any client-supplied Remote-* headers from out, so a +// client can never spoof them, then sets them from the identity of the client +// the request came from, if known. +func (s *clientIdentityStore) setRemoteHeaders(out http.Header, remoteAddr string) { + out.Del(remoteUserIDHeader) + out.Del(remoteUserHeader) + out.Del(remoteEmailHeader) + out.Del(remoteNameHeader) + out.Del(remoteRoleHeader) + + id, ok := s.lookup(remoteAddr) + if !ok { + return + } + + set := func(name, value string) { + if value != "" { + out.Set(name, value) + } + } + set(remoteUserIDHeader, id.UserID) + set(remoteUserHeader, id.Username) + set(remoteEmailHeader, id.Email) + set(remoteNameHeader, id.Name) + set(remoteRoleHeader, id.Role) +} diff --git a/netstack2/client_identity_test.go b/netstack2/client_identity_test.go new file mode 100644 index 0000000..700a7e3 --- /dev/null +++ b/netstack2/client_identity_test.go @@ -0,0 +1,98 @@ +package netstack2 + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestHTTPHandlerSetsRemoteHeaders(t *testing.T) { + received := make(chan http.Header, 1) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received <- r.Header.Clone() + })) + defer backend.Close() + + backendURL, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse backend URL: %v", err) + } + backendHost, backendPort, err := net.SplitHostPort(backendURL.Host) + if err != nil { + t.Fatalf("split backend host: %v", err) + } + port, err := net.LookupPort("tcp", backendPort) + if err != nil { + t.Fatalf("parse backend port: %v", err) + } + + handler := NewHTTPHandler(nil, nil) + handler.ReplaceClientIdentities([]ClientIdentity{ + {IP: "100.90.128.5", UserID: "u1", Username: "alice", Email: "alice@example.com", Name: "Alice", Role: "Admin, Member"}, + {IP: "100.90.128.6", Name: "build-server", Role: "CI"}, + }) + rule := &SubnetRule{ + Protocol: "http", + HTTPTargets: []HTTPTarget{ + {DestAddr: backendHost, DestPort: uint16(port), Scheme: backendURL.Scheme}, + }, + } + + send := func(remoteAddr string) http.Header { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "http://app.internal/", nil) + req.RemoteAddr = remoteAddr + // a client must not be able to spoof any of these + req.Header.Set("Remote-User", "mallory") + req.Header.Set("Remote-Role", "Admin") + req = req.WithContext(context.WithValue(req.Context(), connCtxKey{}, rule)) + handler.handleRequest(httptest.NewRecorder(), req) + return <-received + } + + user := send("100.90.128.5:51234") + for name, want := range map[string]string{ + "Remote-User-Id": "u1", + "Remote-User": "alice", + "Remote-Email": "alice@example.com", + "Remote-Name": "Alice", + "Remote-Role": "Admin, Member", + } { + if got := user.Get(name); got != want { + t.Errorf("user client: %s = %q, want %q", name, got, want) + } + } + + machine := send("100.90.128.6:51234") + for name, want := range map[string]string{ + "Remote-User-Id": "", + "Remote-User": "", + "Remote-Email": "", + "Remote-Name": "build-server", + "Remote-Role": "CI", + } { + if got := machine.Get(name); got != want { + t.Errorf("machine client: %s = %q, want %q", name, got, want) + } + } + + unknown := send("100.90.128.7:51234") + for _, name := range []string{"Remote-User", "Remote-Role"} { + if got := unknown.Get(name); got != "" { + t.Errorf("unknown client: %s = %q, want it stripped", name, got) + } + } + + // an incremental add overwrites a stale entry for a reused IP + handler.AddClientIdentities([]ClientIdentity{{IP: "100.90.128.5", Name: "other-machine"}}) + reused := send("100.90.128.5:51234") + if got := reused.Get("Remote-User"); got != "" { + t.Errorf("reused IP: Remote-User = %q, want empty", got) + } + if got := reused.Get("Remote-Name"); got != "other-machine" { + t.Errorf("reused IP: Remote-Name = %q, want %q", got, "other-machine") + } +} diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 2ec7ff8..ed307b1 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -78,6 +78,10 @@ type HTTPHandler struct { // silently dropping every connection. fallbackTLSOnce sync.Once fallbackTLSCfg *tls.Config + + // identities maps client tunnel IPs to the user or machine behind them, + // used to set Remote-* headers on proxied requests. + identities clientIdentityStore } // --------------------------------------------------------------------------- @@ -169,6 +173,16 @@ func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler { } } +// ReplaceClientIdentities replaces the full set of known client identities. +func (h *HTTPHandler) ReplaceClientIdentities(ids []ClientIdentity) { + h.identities.replace(ids) +} + +// AddClientIdentities adds or overwrites the given client identities. +func (h *HTTPHandler) AddClientIdentities(ids []ClientIdentity) { + h.identities.upsert(ids) +} + // SetRequestLogger attaches an HTTPRequestLogger so that every proxied request // is recorded and periodically shipped to the server. func (h *HTTPHandler) SetRequestLogger(rl *HTTPRequestLogger) { @@ -387,6 +401,8 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { if isTLS, _ := pr.In.Context().Value(connTLSKey{}).(bool); isTLS { pr.Out.Header.Set("X-Forwarded-Proto", "https") } + + h.identities.setRemoteHeaders(pr.Out.Header, pr.In.RemoteAddr) }, Transport: transport, } diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 28b6506..963eb98 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -291,6 +291,24 @@ func (p *ProxyHandler) ReplaceAllSubnetRules(rules []SubnetRule) { p.subnetLookup.ReplaceAll(rules) } +// ReplaceClientIdentities replaces the full set of client identities used by +// the HTTP handler to set Remote-* headers. +func (p *ProxyHandler) ReplaceClientIdentities(ids []ClientIdentity) { + if p == nil || !p.enabled || p.httpHandler == nil { + return + } + p.httpHandler.ReplaceClientIdentities(ids) +} + +// AddClientIdentities adds or overwrites client identities used by the HTTP +// handler to set Remote-* headers. +func (p *ProxyHandler) AddClientIdentities(ids []ClientIdentity) { + if p == nil || !p.enabled || p.httpHandler == nil { + return + } + p.httpHandler.AddClientIdentities(ids) +} + // GetAllRules returns all subnet rules from the proxy handler func (p *ProxyHandler) GetAllRules() []SubnetRule { if p == nil || !p.enabled { diff --git a/netstack2/tun.go b/netstack2/tun.go index 014cbf9..40274b1 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -376,6 +376,24 @@ func (net *Net) ReplaceProxySubnetRules(rules []SubnetRule) { } } +// ReplaceClientIdentities replaces the full set of client identities used by +// the HTTP proxy to set Remote-* headers. +func (net *Net) ReplaceClientIdentities(ids []ClientIdentity) { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + tun.proxyHandler.ReplaceClientIdentities(ids) + } +} + +// AddClientIdentities adds or overwrites client identities used by the HTTP +// proxy to set Remote-* headers. +func (net *Net) AddClientIdentities(ids []ClientIdentity) { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + tun.proxyHandler.AddClientIdentities(ids) + } +} + // GetProxySubnetRules returns all subnet rules from the proxy handler func (net *Net) GetProxySubnetRules() []SubnetRule { tun := (*netTun)(net) diff --git a/newt/data.go b/newt/data.go index 5870b72..f96b2e9 100644 --- a/newt/data.go +++ b/newt/data.go @@ -145,7 +145,7 @@ func (n *Newt) handleSync(msg websocket.WSMessage) { // Sync clients WireGuard peers and targets, if clients are set up if n.wgService != nil { n.wgService.SetCerts(syncData.Certs) - n.wgService.Sync(syncData.Peers, syncData.ClientTargets) + n.wgService.Sync(syncData.Peers, syncData.ClientTargets, syncData.ClientIdentities) } // Sync browser gateway targets diff --git a/newt/types.go b/newt/types.go index 8b2a2e8..0ffc803 100644 --- a/newt/types.go +++ b/newt/types.go @@ -4,6 +4,7 @@ import ( wgclients "github.com/fosrl/newt/clients" "github.com/fosrl/newt/exitnode" "github.com/fosrl/newt/healthcheck" + "github.com/fosrl/newt/netstack2" ) type BrowserGatewayTarget struct { @@ -52,11 +53,12 @@ type BlueprintResult struct { // Define the sync data structure type SyncData struct { - Targets TargetsByType `json:"proxyTargets"` - HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` - RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"` - Peers []wgclients.Peer `json:"peers"` - ClientTargets []wgclients.Target `json:"clientTargets"` - Certs []wgclients.CertData `json:"certs"` - BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` + Targets TargetsByType `json:"proxyTargets"` + HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` + RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"` + Peers []wgclients.Peer `json:"peers"` + ClientTargets []wgclients.Target `json:"clientTargets"` + Certs []wgclients.CertData `json:"certs"` + ClientIdentities []netstack2.ClientIdentity `json:"clientIdentities"` + BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` }