Support gateway sites

This commit is contained in:
Owen
2026-09-24 11:56:42 -04:00
parent e04e7e0bcf
commit 8b02cf2f32
6 changed files with 623 additions and 43 deletions
+116 -16
View File
@@ -15,22 +15,23 @@ import (
// ConnectionRequest defines the structure for an incoming connection request
type ConnectionRequest struct {
ID string `json:"id"`
Secret string `json:"secret"`
Endpoint string `json:"endpoint"`
UserToken string `json:"userToken,omitempty"`
MTU int `json:"mtu,omitempty"`
DNS string `json:"dns,omitempty"`
DNSProxyIP string `json:"dnsProxyIP,omitempty"`
UpstreamDNS []string `json:"upstreamDNS,omitempty"`
InterfaceName string `json:"interfaceName,omitempty"`
Holepunch bool `json:"holepunch,omitempty"`
TlsClientCert string `json:"tlsClientCert,omitempty"`
PingInterval string `json:"pingInterval,omitempty"`
PingTimeout string `json:"pingTimeout,omitempty"`
OrgID string `json:"orgId,omitempty"`
MatchDomains []string `json:"matchDomains,omitempty"`
SubnetRouter bool `json:"subnetRouter,omitempty"`
ID string `json:"id"`
Secret string `json:"secret"`
Endpoint string `json:"endpoint"`
UserToken string `json:"userToken,omitempty"`
MTU int `json:"mtu,omitempty"`
DNS string `json:"dns,omitempty"`
DNSProxyIP string `json:"dnsProxyIP,omitempty"`
UpstreamDNS []string `json:"upstreamDNS,omitempty"`
InterfaceName string `json:"interfaceName,omitempty"`
Holepunch bool `json:"holepunch,omitempty"`
TlsClientCert string `json:"tlsClientCert,omitempty"`
PingInterval string `json:"pingInterval,omitempty"`
PingTimeout string `json:"pingTimeout,omitempty"`
OrgID string `json:"orgId,omitempty"`
MatchDomains []string `json:"matchDomains,omitempty"`
SubnetRouter bool `json:"subnetRouter,omitempty"`
GatewaySiteIds []int `json:"gatewaySiteIds,omitempty"`
}
// SwitchOrgRequest defines the structure for switching organizations
@@ -38,6 +39,13 @@ type SwitchOrgRequest struct {
OrgID string `json:"org_id"`
}
// GatewayRequest defines the structure for a "select gateway" request: the
// set of site IDs that should act as the gateway (full-tunnel/default-route)
// for all tunnel traffic. Every ID must already be a tracked/connected peer.
type GatewayRequest struct {
SiteIds []int `json:"siteIds"`
}
// PowerModeRequest represents a request to change power mode
type PowerModeRequest struct {
Mode string `json:"mode"` // "normal" or "low"
@@ -84,6 +92,8 @@ type StatusResponse struct {
PeerStatuses map[int]*PeerStatus `json:"peers,omitempty"`
NetworkSettings network.NetworkSettings `json:"networkSettings,omitempty"`
ExitNodeStatus *ExitNodeStatus `json:"exitNode,omitempty"`
GatewayActive bool `json:"gatewayActive,omitempty"`
GatewaySiteIds []int `json:"gatewaySiteIds,omitempty"`
}
type MetadataChangeRequest struct {
@@ -113,6 +123,8 @@ type API struct {
onRebind func() error
onPowerMode func(PowerModeRequest) error
onJITConnect func(JITConnectionRequest) error
onSelectGateway func(GatewayRequest) error
onDisableGateway func() error
statusMu sync.RWMutex
peerStatuses map[int]*PeerStatus
@@ -122,6 +134,8 @@ type API struct {
isRegistered bool
isTerminated bool
olmError *OlmError
gatewayActive bool
gatewaySiteIds []int
version string
agent string
@@ -166,6 +180,8 @@ func (s *API) SetHandlers(
onRebind func() error,
onPowerMode func(PowerModeRequest) error,
onJITConnect func(JITConnectionRequest) error,
onSelectGateway func(GatewayRequest) error,
onDisableGateway func() error,
) {
s.onConnect = onConnect
s.onSwitchOrg = onSwitchOrg
@@ -175,6 +191,8 @@ func (s *API) SetHandlers(
s.onRebind = onRebind
s.onPowerMode = onPowerMode
s.onJITConnect = onJITConnect
s.onSelectGateway = onSelectGateway
s.onDisableGateway = onDisableGateway
}
// Start starts the HTTP server
@@ -194,6 +212,8 @@ func (s *API) Start() error {
mux.HandleFunc("/rebind", s.handleRebind)
mux.HandleFunc("/power-mode", s.handlePowerMode)
mux.HandleFunc("/jit-connect", s.handleJITConnect)
mux.HandleFunc("/gateway/select", s.handleSelectGateway)
mux.HandleFunc("/gateway/disable", s.handleDisableGateway)
s.server = &http.Server{
Handler: mux,
@@ -440,6 +460,15 @@ func (s *API) ClearExitNodeStatus() {
s.exitNodeStatus = nil
}
// SetGatewayStatus records the current gateway (full-tunnel/default-route)
// state for exposure via the status endpoint.
func (s *API) SetGatewayStatus(active bool, siteIds []int) {
s.statusMu.Lock()
defer s.statusMu.Unlock()
s.gatewayActive = active
s.gatewaySiteIds = siteIds
}
// handleConnect handles the /connect endpoint
func (s *API) handleConnect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -505,6 +534,8 @@ func (s *API) handleStatus(w http.ResponseWriter, r *http.Request) {
PeerStatuses: s.peerStatuses,
NetworkSettings: network.GetSettings(),
ExitNodeStatus: s.exitNodeStatus,
GatewayActive: s.gatewayActive,
GatewaySiteIds: s.gatewaySiteIds,
}
s.statusMu.RUnlock()
@@ -673,6 +704,8 @@ func (s *API) GetStatus() StatusResponse {
PeerStatuses: s.peerStatuses,
NetworkSettings: network.GetSettings(),
ExitNodeStatus: s.exitNodeStatus,
GatewayActive: s.gatewayActive,
GatewaySiteIds: s.gatewaySiteIds,
}
}
@@ -754,6 +787,73 @@ func (s *API) handleJITConnect(w http.ResponseWriter, r *http.Request) {
})
}
// handleSelectGateway handles the /gateway/select endpoint. It designates
// the given site IDs as the gateway (full-tunnel/default-route) candidate
// set - the onSelectGateway handler is responsible for rejecting the call if
// the tunnel isn't registered/connected or any site ID isn't a tracked peer.
func (s *API) handleSelectGateway(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req GatewayRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest)
return
}
if len(req.SiteIds) == 0 {
http.Error(w, "Missing required field: siteIds must be provided", http.StatusBadRequest)
return
}
logger.Info("Received select-gateway request via API: siteIds=%v", req.SiteIds)
if s.onSelectGateway != nil {
if err := s.onSelectGateway(req); err != nil {
http.Error(w, fmt.Sprintf("Select gateway failed: %v", err), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "Select gateway handler not configured", http.StatusNotImplemented)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "gateway selection accepted",
})
}
// handleDisableGateway handles the /gateway/disable endpoint. It fully clears
// gateway state; no request body is needed.
func (s *API) handleDisableGateway(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
logger.Info("Received disable-gateway request via API")
if s.onDisableGateway != nil {
if err := s.onDisableGateway(); err != nil {
http.Error(w, fmt.Sprintf("Disable gateway failed: %v", err), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "Disable gateway handler not configured", http.StatusNotImplemented)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "gateway disabled",
})
}
// handlePowerMode handles the /power-mode endpoint
// This allows changing the power mode between "normal" and "low"
func (s *API) handlePowerMode(w http.ResponseWriter, r *http.Request) {
+5
View File
@@ -305,6 +305,10 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
o.registered = true
if len(o.tunnelConfig.GatewaySiteIds) > 0 {
o.applyPendingGatewayConfig(o.tunnelConfig.GatewaySiteIds)
}
// Start ping monitor now that we are registered and connected
o.websocket.StartPingMonitor()
@@ -383,6 +387,7 @@ func (o *Olm) handleTerminate(msg websocket.WSMessage) {
o.apiServer.SetConnectionStatus(false)
o.apiServer.SetRegistered(false)
o.apiServer.ClearPeerStatuses()
o.apiServer.SetGatewayStatus(false, nil)
network.ClearNetworkSettings()
+95
View File
@@ -0,0 +1,95 @@
package olm
import (
"fmt"
"net/url"
"github.com/fosrl/newt/logger"
)
// SelectGateway designates siteIds as the gateway (full-tunnel/default-route)
// candidate set. Requires the tunnel to already be registered/connected;
// rejects otherwise. Every site ID must already be a tracked peer, or the
// call is rejected outright by the peer manager.
func (o *Olm) SelectGateway(siteIds []int) error {
if !o.registered {
return fmt.Errorf("cannot select gateway: not registered/connected")
}
return o.applySelectGateway(siteIds)
}
// DisableGateway fully clears gateway state. Requires the tunnel to already
// be registered/connected; rejects otherwise.
func (o *Olm) DisableGateway() error {
if !o.registered {
return fmt.Errorf("cannot disable gateway: not registered/connected")
}
pm := o.getPeerManager()
if pm == nil {
return fmt.Errorf("cannot disable gateway: tunnel not running")
}
if err := pm.ClearGateway(); err != nil {
return err
}
o.apiServer.SetGatewayStatus(false, nil)
return nil
}
// applySelectGateway resolves the control-plane endpoint host and delegates
// to the peer manager. Shared by SelectGateway (API-invoked, already
// registered-checked by the caller) and applyPendingGatewayConfig
// (StartTunnel-time, called after registration completes).
func (o *Olm) applySelectGateway(siteIds []int) error {
pm := o.getPeerManager()
if pm == nil {
return fmt.Errorf("cannot select gateway: tunnel not running")
}
if err := pm.SetGateway(siteIds, extractControlEndpointHost(o.tunnelConfig.Endpoint)); err != nil {
return err
}
o.apiServer.SetGatewayStatus(true, siteIds)
return nil
}
// applyPendingGatewayConfig applies TunnelConfig.GatewaySiteIds once sites
// are tracked peers (called from handleConnect after o.registered is set
// true). IDs that never showed up as tracked peers are logged and dropped;
// if none show up at all, gateway is not established and this is logged
// clearly, without failing tunnel startup.
func (o *Olm) applyPendingGatewayConfig(requestedSiteIds []int) {
pm := o.getPeerManager()
if pm == nil {
return
}
var tracked []int
for _, id := range requestedSiteIds {
if _, ok := pm.GetPeer(id); ok {
tracked = append(tracked, id)
} else {
logger.Warn("Gateway site %d requested at connect time was not found among tracked peers; skipping", id)
}
}
if len(tracked) == 0 {
logger.Warn("None of the requested gateway site IDs were found among tracked peers; gateway not established")
return
}
if err := o.applySelectGateway(tracked); err != nil {
logger.Error("Failed to establish gateway from StartTunnel config: %v", err)
}
}
// extractControlEndpointHost returns the bare host (no scheme/port) of the
// Pangolin server olm is registered against, for gateway bypass-route
// purposes. Falls back to the raw endpoint string on parse failure -
// resolveEndpointIPLocked/net.SplitHostPort tolerate a bare host - rather
// than failing the whole gateway activation over a cosmetic parse issue.
func extractControlEndpointHost(endpoint string) string {
u, err := url.Parse(endpoint)
if err != nil || u.Hostname() == "" {
return endpoint
}
return u.Hostname()
}
+27 -14
View File
@@ -8,8 +8,8 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
_ "net/http/pprof"
"net/netip"
"os"
"os/exec"
"sync"
@@ -243,19 +243,20 @@ func (o *Olm) registerAPICallbacks() {
logger.Info("Received connection request via HTTP: id=%s, endpoint=%s", req.ID, req.Endpoint)
tunnelConfig := TunnelConfig{
Endpoint: req.Endpoint,
ID: req.ID,
Secret: req.Secret,
UserToken: req.UserToken,
MTU: req.MTU,
DNS: req.DNS,
UpstreamDNS: req.UpstreamDNS,
InterfaceName: req.InterfaceName,
Holepunch: req.Holepunch,
TlsClientCert: req.TlsClientCert,
OrgID: req.OrgID,
MatchDomains: req.MatchDomains,
SubnetRouter: req.SubnetRouter,
Endpoint: req.Endpoint,
ID: req.ID,
Secret: req.Secret,
UserToken: req.UserToken,
MTU: req.MTU,
DNS: req.DNS,
UpstreamDNS: req.UpstreamDNS,
InterfaceName: req.InterfaceName,
Holepunch: req.Holepunch,
TlsClientCert: req.TlsClientCert,
OrgID: req.OrgID,
MatchDomains: req.MatchDomains,
SubnetRouter: req.SubnetRouter,
GatewaySiteIds: req.GatewaySiteIds,
}
var err error
@@ -360,6 +361,16 @@ func (o *Olm) registerAPICallbacks() {
return nil
},
// onSelectGateway
func(req api.GatewayRequest) error {
logger.Info("Received select-gateway request via API: siteIds=%v", req.SiteIds)
return o.SelectGateway(req.SiteIds)
},
// onDisableGateway
func() error {
logger.Info("Received disable-gateway request via API")
return o.DisableGateway()
},
)
}
@@ -755,6 +766,7 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
o.apiServer.SetRegistered(false)
o.apiServer.ClearOlmError()
o.apiServer.ClearPeerStatuses()
o.apiServer.SetGatewayStatus(false, nil)
network.ClearNetworkSettings()
o.Close()
@@ -964,6 +976,7 @@ func (o *Olm) StopTunnel() error {
o.apiServer.SetConnectionStatus(false)
o.apiServer.SetRegistered(false)
o.apiServer.ClearOlmError()
o.apiServer.SetGatewayStatus(false, nil)
network.ClearNetworkSettings()
o.apiServer.ClearPeerStatuses()
+6
View File
@@ -181,4 +181,10 @@ type TunnelConfig struct {
// authorizes traffic by the client's tunnel identity, not by whatever
// LAN address it originally arrived with. Linux only. Defaults to false.
SubnetRouter bool
// GatewaySiteIds, when non-empty, designates these site IDs as gateway
// (full-tunnel/default-route) candidates from the moment the tunnel
// starts, for callers that want a gateway already established rather
// than issuing a separate SelectGateway API call after connecting.
GatewaySiteIds []int
}
+374 -13
View File
@@ -66,8 +66,31 @@ type PeerManager struct {
// lastOwnerChange tracks, per allowed-IP CIDR, when ownership was last transferred.
// Used to enforce a cooldown so routes don't flap between two similarly-performing sites.
lastOwnerChange map[string]time.Time
// Gateway (full-tunnel/default-route) state. gatewaySiteIds is business
// intent - the current candidate set - not WireGuard ownership, which is
// tracked the same way as any other shared CIDR via allowedIPOwners/
// allowedIPClaims (see gatewayCIDR). gatewayExcludedIPs is a refcount so a
// destination referenced by more than one thing (or re-added while
// already excluded) is only actually un-excluded once nothing references
// it any more. gatewayControlIP is the control-plane (Pangolin server)
// endpoint, resolved once at activation and excluded for the lifetime of
// the gateway so the management connection doesn't depend on the current
// gateway-owner site's own uplink.
gatewayActive bool
gatewaySiteIds map[int]bool
gatewayExcludedIPs map[string]int
gatewayControlIP string
}
// gatewayCIDR is the WireGuard AllowedIPs claim key for "this site is the
// gateway (full-tunnel/default-route)". It is never added to
// SiteConfig.RemoteSubnets/AllowedIps and never sent over the wire - it only
// ever lives in allowedIPOwners/allowedIPClaims, exactly like a shared remote
// subnet, so it is never clobbered by AddPeer/UpdatePeer recomputing
// SiteConfig.AllowedIps from scratch.
const gatewayCIDR = "0.0.0.0/0"
const (
// routeSwitchRTTMargin requires a candidate site's RTT to be at least this much
// better (as a fraction) than the current owner's before we consider it worth
@@ -107,17 +130,19 @@ func normalizeServerRouteDestination(serverIP string) string {
// NewPeerManager creates a new PeerManager with an internal PeerMonitor
func NewPeerManager(config PeerManagerConfig) *PeerManager {
pm := &PeerManager{
device: config.Device,
peers: make(map[int]SiteConfig),
dnsProxy: config.DNSProxy,
interfaceName: config.InterfaceName,
localIP: config.LocalIP,
privateKey: config.PrivateKey,
allowedIPOwners: make(map[string]int),
allowedIPClaims: make(map[string]map[int]bool),
APIServer: config.APIServer,
publicDNS: config.PublicDNS,
lastOwnerChange: make(map[string]time.Time),
device: config.Device,
peers: make(map[int]SiteConfig),
dnsProxy: config.DNSProxy,
interfaceName: config.InterfaceName,
localIP: config.LocalIP,
privateKey: config.PrivateKey,
allowedIPOwners: make(map[string]int),
allowedIPClaims: make(map[string]map[int]bool),
APIServer: config.APIServer,
publicDNS: config.PublicDNS,
lastOwnerChange: make(map[string]time.Time),
gatewaySiteIds: make(map[int]bool),
gatewayExcludedIPs: make(map[string]int),
}
// Create the peer monitor
@@ -187,6 +212,248 @@ func (pm *PeerManager) SetPublicDNS(servers []string) {
}
}
// resolveEndpointIPLocked resolves a raw "host[:port]" endpoint string (as
// stored on SiteConfig.Endpoint/RelayEndpoint, or passed directly to
// RelayPeer/UnRelayPeer) to its bare IP address, for gateway bypass-route
// purposes. Must be called with pm.mu held (uses pm.publicDNS).
func (pm *PeerManager) resolveEndpointIPLocked(endpoint string) (string, bool) {
if endpoint == "" {
return "", false
}
resolved, err := util.ResolveDomainUpstream(formatEndpoint(endpoint), pm.publicDNS)
if err != nil {
logger.Warn("Gateway: failed to resolve endpoint %q for bypass route: %v", endpoint, err)
return "", false
}
host, _, err := net.SplitHostPort(resolved)
if err != nil {
host = resolved
}
return host, true
}
// resolveActiveEndpointIPLocked resolves the endpoint peer is currently using
// (per its own Endpoint/RelayEndpoint fields and the peer monitor's relayed
// flag) to a bare IP, for gateway bypass-route purposes. Returns ("", false)
// for an active local endpoint - on-link traffic never traverses the OS
// default route, so it needs no bypass route. Must be called with pm.mu held.
func (pm *PeerManager) resolveActiveEndpointIPLocked(peer SiteConfig) (string, bool) {
if peer.ActiveLocalEndpoint != "" {
return "", false
}
endpoint := peer.Endpoint
if pm.peerMonitor != nil && pm.peerMonitor.IsPeerRelayed(peer.SiteId) && peer.RelayEndpoint != "" {
endpoint = peer.RelayEndpoint
}
return pm.resolveEndpointIPLocked(endpoint)
}
// excludeEndpointLocked adds a bypass route for ip if this is its first
// reference, or just bumps the refcount if something is already excluding
// it (gatewayExcludedIPs). No-op for an empty ip (the "no endpoint yet" /
// "active local endpoint" case from the resolve helpers above). Must be
// called with pm.mu held.
func (pm *PeerManager) excludeEndpointLocked(ip string) {
if ip == "" {
return
}
if pm.gatewayExcludedIPs[ip] == 0 {
if err := network.AddBypassRouteForDestination(ip); err != nil {
logger.Error("Gateway: failed to add bypass route for %s: %v", ip, err)
}
}
pm.gatewayExcludedIPs[ip]++
}
// unexcludeEndpointLocked reverses excludeEndpointLocked: decrements the
// refcount and only actually removes the bypass route once nothing
// references ip any more. Must be called with pm.mu held.
func (pm *PeerManager) unexcludeEndpointLocked(ip string) {
if ip == "" {
return
}
if pm.gatewayExcludedIPs[ip] <= 1 {
delete(pm.gatewayExcludedIPs, ip)
if err := network.RemoveBypassRouteForDestination(ip); err != nil {
logger.Error("Gateway: failed to remove bypass route for %s: %v", ip, err)
}
return
}
pm.gatewayExcludedIPs[ip]--
}
// activateGatewayLocked performs the one-time setup for gateway mode:
// resolving and excluding the control-plane endpoint and every
// currently-tracked peer's active endpoint (so none of them can be captured
// by the default-route-equivalent installed at the end), then installing
// that route. Must be called with pm.mu held, and only once (guarded by
// pm.gatewayActive in the caller).
func (pm *PeerManager) activateGatewayLocked(controlEndpointHost string) error {
if ip, ok := pm.resolveEndpointIPLocked(controlEndpointHost); ok {
pm.gatewayControlIP = ip
pm.excludeEndpointLocked(ip)
} else if controlEndpointHost != "" {
logger.Warn("Gateway: failed to resolve control endpoint %q for bypass route", controlEndpointHost)
}
for _, peer := range pm.peers {
if ip, ok := pm.resolveActiveEndpointIPLocked(peer); ok {
pm.excludeEndpointLocked(ip)
}
}
if err := network.AddGatewayDefaultRoute(pm.interfaceName, pm.localIP); err != nil {
return fmt.Errorf("failed to install gateway route: %v", err)
}
return nil
}
// deactivateGatewayLocked reverses activateGatewayLocked: removes the
// default-route-equivalent, then every remaining bypass route (including the
// control endpoint), and resets gateway exclusion state. Must be called with
// pm.mu held.
func (pm *PeerManager) deactivateGatewayLocked() {
if err := network.RemoveGatewayDefaultRoute(pm.interfaceName); err != nil {
logger.Error("Gateway: failed to remove gateway route: %v", err)
}
for ip := range pm.gatewayExcludedIPs {
if err := network.RemoveBypassRouteForDestination(ip); err != nil {
logger.Error("Gateway: failed to remove bypass route for %s: %v", ip, err)
}
}
pm.gatewayExcludedIPs = make(map[string]int)
pm.gatewayControlIP = ""
}
// claimGatewayClaimLocked registers siteId's claim to the gateway CIDR via
// the same generic ownership machinery used for shared remote subnets
// (claimAllowedIP), then pushes an incremental WireGuard AllowedIPs update if
// this claim made siteId the owner. Deliberately bypasses
// addAllowedIp/SiteConfig.AllowedIps - see gatewayCIDR's doc comment. Must be
// called with pm.mu held.
func (pm *PeerManager) claimGatewayClaimLocked(siteId int) {
pm.claimAllowedIP(siteId, gatewayCIDR)
if pm.allowedIPOwners[gatewayCIDR] != siteId {
return
}
peer, exists := pm.peers[siteId]
if !exists {
return
}
if err := AddAllowedIP(pm.device, peer.PublicKey, gatewayCIDR); err != nil {
logger.Error("Gateway: failed to claim %s for site %d: %v", gatewayCIDR, siteId, err)
}
}
// releaseGatewayClaimLocked reverses claimGatewayClaimLocked. If siteId was
// the owner, promotes another candidate the same way releaseAllowedIP/
// transferOwnership already do for shared remote subnets. Must be called
// with pm.mu held.
func (pm *PeerManager) releaseGatewayClaimLocked(siteId int) {
wasOwner := pm.allowedIPOwners[gatewayCIDR] == siteId
newOwner, promoted := pm.releaseAllowedIP(siteId, gatewayCIDR)
if wasOwner {
if peer, exists := pm.peers[siteId]; exists {
remaining := pm.getWireGuardAllowedIPs(siteId)
if err := RemoveAllowedIP(pm.device, peer.PublicKey, remaining); err != nil {
logger.Error("Gateway: failed to release %s from site %d: %v", gatewayCIDR, siteId, err)
}
}
}
if promoted && newOwner >= 0 {
if peer, exists := pm.peers[newOwner]; exists {
if err := AddAllowedIP(pm.device, peer.PublicKey, gatewayCIDR); err != nil {
logger.Error("Gateway: failed to promote site %d to owner of %s: %v", newOwner, gatewayCIDR, err)
}
}
}
}
// SetGateway designates siteIds as the gateway (full-tunnel/default-route)
// candidate set. Every siteId must already be a tracked peer, or the call is
// rejected outright (no partial application). On first activation this
// installs the OS-level gateway route plus every bypass route needed so the
// tunnel's own traffic (control-plane endpoint, every tracked peer's active
// endpoint) isn't captured by it; subsequent calls only change which sites
// may own the "0.0.0.0/0" WireGuard AllowedIP, via the existing generic
// claim/optimizer machinery - exactly like remote subnets. controlEndpointHost
// is the Pangolin server host olm is registered against (bare host, port
// optional); always excluded regardless of which sites are selected.
func (pm *PeerManager) SetGateway(siteIds []int, controlEndpointHost string) error {
pm.mu.Lock()
defer pm.mu.Unlock()
if len(siteIds) == 0 {
return fmt.Errorf("at least one site ID must be provided")
}
var missing []int
for _, id := range siteIds {
if _, ok := pm.peers[id]; !ok {
missing = append(missing, id)
}
}
if len(missing) > 0 {
return fmt.Errorf("site IDs not tracked as peers: %v", missing)
}
if !pm.gatewayActive {
if err := pm.activateGatewayLocked(controlEndpointHost); err != nil {
return err
}
pm.gatewayActive = true
}
newSet := make(map[int]bool, len(siteIds))
for _, id := range siteIds {
newSet[id] = true
}
for id := range pm.gatewaySiteIds {
if !newSet[id] {
pm.releaseGatewayClaimLocked(id)
}
}
for id := range newSet {
if !pm.gatewaySiteIds[id] {
pm.claimGatewayClaimLocked(id)
}
}
pm.gatewaySiteIds = newSet
logger.Info("Gateway set to sites %v", siteIds)
return nil
}
// clearGatewayLocked is ClearGateway's body, split out so Close() (which
// already holds pm.mu) can reuse it without re-locking. Must be called with
// pm.mu held.
func (pm *PeerManager) clearGatewayLocked() {
if !pm.gatewayActive {
return
}
for id := range pm.gatewaySiteIds {
pm.releaseGatewayClaimLocked(id)
}
pm.gatewaySiteIds = make(map[int]bool)
pm.deactivateGatewayLocked()
pm.gatewayActive = false
logger.Info("Gateway cleared")
}
// ClearGateway fully removes gateway state: releases every candidate's
// claim, tears down the OS-level gateway route, and removes every bypass
// route. No-op if gateway is not currently active.
func (pm *PeerManager) ClearGateway() error {
pm.mu.Lock()
defer pm.mu.Unlock()
pm.clearGatewayLocked()
return nil
}
func (pm *PeerManager) GetAllPeers() []SiteConfig {
pm.mu.RLock()
defer pm.mu.RUnlock()
@@ -232,6 +499,17 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
}
}
// If this site is a gateway candidate, claim the gateway CIDR the same
// way as any other shared allowed IP - this must happen even for a
// re-add (e.g. server-directed peer churn while gateway mode is active),
// or the site would silently lose its claim.
if pm.gatewaySiteIds[siteConfig.SiteId] {
pm.claimAllowedIP(siteConfig.SiteId, gatewayCIDR)
if pm.allowedIPOwners[gatewayCIDR] == siteConfig.SiteId {
ownedIPs = append(ownedIPs, gatewayCIDR)
}
}
// Create a config with only the owned IPs for WireGuard
wgConfig := siteConfig
wgConfig.AllowedIps = ownedIPs
@@ -260,6 +538,16 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
pm.peers[siteConfig.SiteId] = siteConfig
// Independent of gateway candidacy: while gateway mode is active, every
// tracked site (not just the current candidates) needs its own endpoint
// protected from the default-route-equivalent, so a newly/JIT-connected
// site is covered too.
if pm.gatewayActive {
if ip, ok := pm.resolveActiveEndpointIPLocked(siteConfig); ok {
pm.excludeEndpointLocked(ip)
}
}
pm.APIServer.AddPeerStatus(siteConfig.SiteId, siteConfig.Name, false, 0, siteConfig.Endpoint, false, false)
// Perform rapid initial holepunch test (outside of lock to avoid blocking)
@@ -343,7 +631,11 @@ func (pm *PeerManager) RemovePeer(siteId int) error {
pm.dnsProxy.RemoveDNSRecord(alias.Alias, address)
}
// Release all IP claims and promote other peers as needed
// Release all IP claims and promote other peers as needed. Scan
// allowedIPClaims directly (rather than peer.AllowedIps) so this also
// releases claims that never entered SiteConfig.AllowedIps - e.g. the
// gateway CIDR (see gatewayCIDR's doc comment) - otherwise removing a
// gateway-candidate peer would leak its claim forever.
// Collect promotions first to avoid modifying while iterating
type promotion struct {
newOwner int
@@ -351,7 +643,13 @@ func (pm *PeerManager) RemovePeer(siteId int) error {
}
var promotions []promotion
for _, ip := range peer.AllowedIps {
var claimedCIDRs []string
for cidr, claimants := range pm.allowedIPClaims {
if claimants[siteId] {
claimedCIDRs = append(claimedCIDRs, cidr)
}
}
for _, ip := range claimedCIDRs {
newOwner, promoted := pm.releaseAllowedIP(siteId, ip)
if promoted && newOwner >= 0 {
promotions = append(promotions, promotion{newOwner: newOwner, cidr: ip})
@@ -384,6 +682,21 @@ func (pm *PeerManager) RemovePeer(siteId int) error {
pm.APIServer.RemovePeerStatus(siteId)
// Deliberately leave siteId in pm.gatewaySiteIds (if present) rather than
// deleting it here: it is business intent, separate from the WG-level
// claim already released above via the allowedIPClaims scan (which is
// what actually matters for ownership/optimizeRoutes), and keeping it
// lets AddPeer transparently re-establish the claim if this is a
// remove+re-add churn rather than a real removal. A stale entry for a
// site that never comes back is harmless - the next SetGateway/
// ClearGateway call reconciles it, and releaseGatewayClaimLocked already
// no-ops safely for a site with no remaining claim or peer.
if pm.gatewayActive {
if ip, ok := pm.resolveActiveEndpointIPLocked(peer); ok {
pm.unexcludeEndpointLocked(ip)
}
}
delete(pm.peers, siteId)
return nil
}
@@ -401,6 +714,14 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
// local connection isn't disrupted by an unrelated site update.
siteConfig.ActiveLocalEndpoint = oldPeer.ActiveLocalEndpoint
// Snapshot the old active endpoint now, before anything changes, for the
// gateway bypass-route churn at the end of this function.
var oldEndpointIP string
var haveOldEndpointIP bool
if pm.gatewayActive {
oldEndpointIP, haveOldEndpointIP = pm.resolveActiveEndpointIPLocked(oldPeer)
}
// Update aliases
// Remove old aliases
for _, alias := range oldPeer.Aliases {
@@ -554,6 +875,16 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
monitorPeer := net.JoinHostPort(monitorAddress, strconv.Itoa(int(siteConfig.ServerPort+1))) // +1 for the monitor port
pm.peerMonitor.UpdatePeerEndpoint(siteConfig.SiteId, monitorPeer) // +1 for monitor port
if pm.gatewayActive {
newIP, haveNewIP := pm.resolveActiveEndpointIPLocked(siteConfig)
if haveNewIP {
pm.excludeEndpointLocked(newIP)
}
if haveOldEndpointIP && oldEndpointIP != newIP {
pm.unexcludeEndpointLocked(oldEndpointIP)
}
}
pm.peers[siteConfig.SiteId] = siteConfig
return nil
}
@@ -899,6 +1230,20 @@ func (pm *PeerManager) RelayPeer(siteId int, relayEndpoint string, relayPort uin
logger.Info("Ignoring relay request for site %d: local connection is active", siteId)
return
}
if exists && pm.gatewayActive {
// Exclude the endpoint we're switching to before unexcluding the one
// we're switching from, so there's never a window with no bypass
// route for whichever endpoint is actually in use.
oldIP, haveOld := pm.resolveActiveEndpointIPLocked(peer)
if newIP, ok := pm.resolveEndpointIPLocked(relayEndpoint); ok {
pm.excludeEndpointLocked(newIP)
if haveOld && oldIP != newIP {
pm.unexcludeEndpointLocked(oldIP)
}
} else if haveOld {
pm.unexcludeEndpointLocked(oldIP)
}
}
if exists {
// Store the relay endpoint
peer.RelayEndpoint = relayEndpoint
@@ -1018,6 +1363,10 @@ func (pm *PeerManager) Close() {
pm.stopRouteOptimizer()
pm.mu.Lock()
// Bypass routes live on the physical interface, not the tun interface, so
// unlike tunnel routes they don't disappear for free when the tun device
// is torn down - they must be explicitly removed here or they leak.
pm.clearGatewayLocked()
peerMonitor := pm.peerMonitor
pm.peerMonitor = nil
pm.mu.Unlock()
@@ -1056,6 +1405,18 @@ func (pm *PeerManager) UnRelayPeer(siteId int, endpoint string) error {
logger.Info("Ignoring unrelay request for site %d: local connection is active", siteId)
return nil
}
if exists && pm.gatewayActive {
// Same add-new-before-remove-old ordering as RelayPeer.
oldIP, haveOld := pm.resolveActiveEndpointIPLocked(peer)
if newIP, ok := pm.resolveEndpointIPLocked(endpoint); ok {
pm.excludeEndpointLocked(newIP)
if haveOld && oldIP != newIP {
pm.unexcludeEndpointLocked(oldIP)
}
} else if haveOld {
pm.unexcludeEndpointLocked(oldIP)
}
}
if exists {
// Store the relay endpoint
peer.Endpoint = endpoint