mirror of
https://github.com/fosrl/newt.git
synced 2026-10-21 12:09:05 +02:00
Add support for client identities in http requests
This commit is contained in:
+42
-6
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+9
-7
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user