Add support for client identities in http requests

This commit is contained in:
Owen
2026-10-12 16:57:05 -04:00
parent 7856d5f4c1
commit 7c9841b2c6
8 changed files with 317 additions and 14 deletions
+115
View File
@@ -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)
}
+98
View File
@@ -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")
}
}
+16
View File
@@ -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,
}
+18
View File
@@ -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 {
+18
View File
@@ -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)