mirror of
https://github.com/fosrl/olm.git
synced 2026-10-01 18:29:08 +02:00
Support gateway sites
This commit is contained in:
+116
-16
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user