mirror of
https://github.com/fosrl/olm.git
synced 2026-10-01 18:29:08 +02:00
Tie DisableRoutesAndAliases to exit nodes being in or not
This commit is contained in:
+19
-19
@@ -19,25 +19,25 @@ import (
|
||||
// non-empty, and is how olm later matches server-pushed gateway updates to the
|
||||
// resource the user actually connected through.
|
||||
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"`
|
||||
DisableRoutesAndAliases bool `json:"disableRoutesAndAliases,omitempty"`
|
||||
GatewaySiteResourceId int `json:"gatewaySiteResourceId,omitempty"`
|
||||
GatewaySiteIds []int `json:"gatewaySiteIds,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"`
|
||||
DisableRoutesAndAliasesOnExitNode bool `json:"disableRoutesAndAliasesOnExitNode,omitempty"`
|
||||
GatewaySiteResourceId int `json:"gatewaySiteResourceId,omitempty"`
|
||||
GatewaySiteIds []int `json:"gatewaySiteIds,omitempty"`
|
||||
}
|
||||
|
||||
// SwitchOrgRequest defines the structure for switching organizations
|
||||
|
||||
@@ -47,14 +47,14 @@ type OlmConfig struct {
|
||||
PingTimeout string `json:"pingTimeout"`
|
||||
|
||||
// Advanced
|
||||
DisableHolepunch bool `json:"disableHolepunch"`
|
||||
TlsClientCert string `json:"tlsClientCert"`
|
||||
OverrideDNS bool `json:"overrideDNS"`
|
||||
TunnelDNS bool `json:"tunnelDNS"`
|
||||
DisableRelay bool `json:"disableRelay"`
|
||||
PreferLocalRoutes bool `json:"preferLocalRoutes"`
|
||||
SubnetRouter bool `json:"subnetRouter"`
|
||||
DisableRoutesAndAliases bool `json:"disableRoutesAndAliases"`
|
||||
DisableHolepunch bool `json:"disableHolepunch"`
|
||||
TlsClientCert string `json:"tlsClientCert"`
|
||||
OverrideDNS bool `json:"overrideDNS"`
|
||||
TunnelDNS bool `json:"tunnelDNS"`
|
||||
DisableRelay bool `json:"disableRelay"`
|
||||
PreferLocalRoutes bool `json:"preferLocalRoutes"`
|
||||
SubnetRouter bool `json:"subnetRouter"`
|
||||
DisableRoutesAndAliasesOnExitNode bool `json:"disableRoutesAndAliasesOnExitNode"`
|
||||
// DoNotCreateNewClient bool `json:"doNotCreateNewClient"`
|
||||
|
||||
// Parsed values (not in JSON)
|
||||
@@ -123,7 +123,7 @@ func DefaultConfig() *OlmConfig {
|
||||
config.sources["disableRelay"] = string(SourceDefault)
|
||||
config.sources["preferLocalRoutes"] = string(SourceDefault)
|
||||
config.sources["subnetRouter"] = string(SourceDefault)
|
||||
config.sources["disableRoutesAndAliases"] = string(SourceDefault)
|
||||
config.sources["disableRoutesAndAliasesOnExitNode"] = string(SourceDefault)
|
||||
// config.sources["doNotCreateNewClient"] = string(SourceDefault)
|
||||
|
||||
return config
|
||||
@@ -300,8 +300,8 @@ func loadConfigFromEnv(config *OlmConfig) {
|
||||
config.sources["subnetRouter"] = string(SourceEnv)
|
||||
}
|
||||
if val := os.Getenv("DISABLE_ROUTES_AND_ALIASES"); val == "true" {
|
||||
config.DisableRoutesAndAliases = true
|
||||
config.sources["disableRoutesAndAliases"] = string(SourceEnv)
|
||||
config.DisableRoutesAndAliasesOnExitNode = true
|
||||
config.sources["disableRoutesAndAliasesOnExitNode"] = string(SourceEnv)
|
||||
}
|
||||
// if val := os.Getenv("DO_NOT_CREATE_NEW_CLIENT"); val == "true" {
|
||||
// config.DoNotCreateNewClient = true
|
||||
@@ -315,29 +315,29 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
|
||||
// Store original values to detect changes
|
||||
origValues := map[string]interface{}{
|
||||
"endpoint": config.Endpoint,
|
||||
"id": config.ID,
|
||||
"secret": config.Secret,
|
||||
"org": config.OrgID,
|
||||
"userToken": config.UserToken,
|
||||
"mtu": config.MTU,
|
||||
"dns": config.DNS,
|
||||
"upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS),
|
||||
"matchDomains": fmt.Sprintf("%v", config.MatchDomains),
|
||||
"logLevel": config.LogLevel,
|
||||
"interface": config.InterfaceName,
|
||||
"httpAddr": config.HTTPAddr,
|
||||
"socketPath": config.SocketPath,
|
||||
"pingInterval": config.PingInterval,
|
||||
"pingTimeout": config.PingTimeout,
|
||||
"enableApi": config.EnableAPI,
|
||||
"disableHolepunch": config.DisableHolepunch,
|
||||
"overrideDNS": config.OverrideDNS,
|
||||
"disableRelay": config.DisableRelay,
|
||||
"preferLocalRoutes": config.PreferLocalRoutes,
|
||||
"tunnelDNS": config.TunnelDNS,
|
||||
"subnetRouter": config.SubnetRouter,
|
||||
"disableRoutesAndAliases": config.DisableRoutesAndAliases,
|
||||
"endpoint": config.Endpoint,
|
||||
"id": config.ID,
|
||||
"secret": config.Secret,
|
||||
"org": config.OrgID,
|
||||
"userToken": config.UserToken,
|
||||
"mtu": config.MTU,
|
||||
"dns": config.DNS,
|
||||
"upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS),
|
||||
"matchDomains": fmt.Sprintf("%v", config.MatchDomains),
|
||||
"logLevel": config.LogLevel,
|
||||
"interface": config.InterfaceName,
|
||||
"httpAddr": config.HTTPAddr,
|
||||
"socketPath": config.SocketPath,
|
||||
"pingInterval": config.PingInterval,
|
||||
"pingTimeout": config.PingTimeout,
|
||||
"enableApi": config.EnableAPI,
|
||||
"disableHolepunch": config.DisableHolepunch,
|
||||
"overrideDNS": config.OverrideDNS,
|
||||
"disableRelay": config.DisableRelay,
|
||||
"preferLocalRoutes": config.PreferLocalRoutes,
|
||||
"tunnelDNS": config.TunnelDNS,
|
||||
"subnetRouter": config.SubnetRouter,
|
||||
"disableRoutesAndAliasesOnExitNode": config.DisableRoutesAndAliasesOnExitNode,
|
||||
// "doNotCreateNewClient": config.DoNotCreateNewClient,
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
serviceFlags.BoolVar(&config.PreferLocalRoutes, "prefer-local-routes", config.PreferLocalRoutes, "Add tunnel routes with a high metric so overlapping local/connected routes take precedence (default false)")
|
||||
serviceFlags.BoolVar(&config.TunnelDNS, "tunnel-dns", config.TunnelDNS, "When enabled, DNS queries are routed through the tunnel for remote resolution. To ensure queries are tunneled correctly, you must define the DNS server as a Pangolin resource and enter its address as an Upstream DNS Server. (default false)")
|
||||
serviceFlags.BoolVar(&config.SubnetRouter, "subnet-router", config.SubnetRouter, "Enable this client to act as a subnet router: traffic forwarded from the local network is NATed to this client's own tunnel IP before going out over the tunnel. Linux only, requires CAP_NET_ADMIN. (default false)")
|
||||
serviceFlags.BoolVar(&config.DisableRoutesAndAliases, "disable-routes-and-aliases", config.DisableRoutesAndAliases, "Do not add routes to the system routing table and do not run the DNS proxy (aliases are not resolved). Gateway routes are still added. (default false)")
|
||||
serviceFlags.BoolVar(&config.DisableRoutesAndAliasesOnExitNode, "disable-routes-and-aliases", config.DisableRoutesAndAliasesOnExitNode, "Make the exit node take precedence over individual resources: while an exit node is connected, remove routes/aliases for site resources, restoring them once it disconnects. Gateway routes are still added. (default false)")
|
||||
// serviceFlags.BoolVar(&config.DoNotCreateNewClient, "do-not-create-new-client", config.DoNotCreateNewClient, "Do not create new client")
|
||||
|
||||
version := serviceFlags.Bool("version", false, "Print the version")
|
||||
@@ -459,8 +459,8 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
if config.SubnetRouter != origValues["subnetRouter"].(bool) {
|
||||
config.sources["subnetRouter"] = string(SourceCLI)
|
||||
}
|
||||
if config.DisableRoutesAndAliases != origValues["disableRoutesAndAliases"].(bool) {
|
||||
config.sources["disableRoutesAndAliases"] = string(SourceCLI)
|
||||
if config.DisableRoutesAndAliasesOnExitNode != origValues["disableRoutesAndAliasesOnExitNode"].(bool) {
|
||||
config.sources["disableRoutesAndAliasesOnExitNode"] = string(SourceCLI)
|
||||
}
|
||||
// if config.DoNotCreateNewClient != origValues["doNotCreateNewClient"].(bool) {
|
||||
// config.sources["doNotCreateNewClient"] = string(SourceCLI)
|
||||
@@ -598,9 +598,9 @@ func mergeConfigs(dest, src *OlmConfig) {
|
||||
dest.SubnetRouter = src.SubnetRouter
|
||||
dest.sources["subnetRouter"] = string(SourceFile)
|
||||
}
|
||||
if src.DisableRoutesAndAliases {
|
||||
dest.DisableRoutesAndAliases = src.DisableRoutesAndAliases
|
||||
dest.sources["disableRoutesAndAliases"] = string(SourceFile)
|
||||
if src.DisableRoutesAndAliasesOnExitNode {
|
||||
dest.DisableRoutesAndAliasesOnExitNode = src.DisableRoutesAndAliasesOnExitNode
|
||||
dest.sources["disableRoutesAndAliasesOnExitNode"] = string(SourceFile)
|
||||
}
|
||||
// if src.DoNotCreateNewClient {
|
||||
// dest.DoNotCreateNewClient = src.DoNotCreateNewClient
|
||||
@@ -696,7 +696,7 @@ func (c *OlmConfig) ShowConfig() {
|
||||
fmt.Printf(" disable-relay = %v [%s]\n", c.DisableRelay, getSource("disableRelay"))
|
||||
fmt.Printf(" prefer-local-routes = %v [%s]\n", c.PreferLocalRoutes, getSource("preferLocalRoutes"))
|
||||
fmt.Printf(" subnet-router = %v [%s]\n", c.SubnetRouter, getSource("subnetRouter"))
|
||||
fmt.Printf(" disable-routes-and-aliases = %v [%s]\n", c.DisableRoutesAndAliases, getSource("disableRoutesAndAliases"))
|
||||
fmt.Printf(" disable-routes-and-aliases = %v [%s]\n", c.DisableRoutesAndAliasesOnExitNode, getSource("disableRoutesAndAliasesOnExitNode"))
|
||||
// fmt.Printf(" do-not-create-new-client = %v [%s]\n", c.DoNotCreateNewClient, getSource("doNotCreateNewClient"))
|
||||
if c.TlsClientCert != "" {
|
||||
fmt.Printf(" tls-cert = %s [%s]\n", c.TlsClientCert, getSource("tlsClientCert"))
|
||||
|
||||
@@ -256,26 +256,26 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
|
||||
|
||||
if config.ID != "" && config.Secret != "" && config.Endpoint != "" {
|
||||
tunnelConfig := olmpkg.TunnelConfig{
|
||||
Endpoint: config.Endpoint,
|
||||
ID: config.ID,
|
||||
Secret: config.Secret,
|
||||
UserToken: config.UserToken,
|
||||
MTU: config.MTU,
|
||||
DNS: config.DNS,
|
||||
UpstreamDNS: config.UpstreamDNS,
|
||||
MatchDomains: config.MatchDomains,
|
||||
InterfaceName: config.InterfaceName,
|
||||
Holepunch: !config.DisableHolepunch,
|
||||
TlsClientCert: config.TlsClientCert,
|
||||
PingIntervalDuration: config.PingIntervalDuration,
|
||||
PingTimeoutDuration: config.PingTimeoutDuration,
|
||||
OrgID: config.OrgID,
|
||||
OverrideDNS: config.OverrideDNS,
|
||||
DisableRelay: config.DisableRelay,
|
||||
PreferLocalRoutes: config.PreferLocalRoutes,
|
||||
SubnetRouter: config.SubnetRouter,
|
||||
DisableRoutesAndAliases: config.DisableRoutesAndAliases,
|
||||
EnableUAPI: true,
|
||||
Endpoint: config.Endpoint,
|
||||
ID: config.ID,
|
||||
Secret: config.Secret,
|
||||
UserToken: config.UserToken,
|
||||
MTU: config.MTU,
|
||||
DNS: config.DNS,
|
||||
UpstreamDNS: config.UpstreamDNS,
|
||||
MatchDomains: config.MatchDomains,
|
||||
InterfaceName: config.InterfaceName,
|
||||
Holepunch: !config.DisableHolepunch,
|
||||
TlsClientCert: config.TlsClientCert,
|
||||
PingIntervalDuration: config.PingIntervalDuration,
|
||||
PingTimeoutDuration: config.PingTimeoutDuration,
|
||||
OrgID: config.OrgID,
|
||||
OverrideDNS: config.OverrideDNS,
|
||||
DisableRelay: config.DisableRelay,
|
||||
PreferLocalRoutes: config.PreferLocalRoutes,
|
||||
SubnetRouter: config.SubnetRouter,
|
||||
DisableRoutesAndAliasesOnExitNode: config.DisableRoutesAndAliasesOnExitNode,
|
||||
EnableUAPI: true,
|
||||
}
|
||||
go olm.StartTunnel(tunnelConfig)
|
||||
} else {
|
||||
|
||||
+34
-27
@@ -181,17 +181,15 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
|
||||
logger.Warn("Failed to parse tunnel IP %q: %v", interfaceIP, err)
|
||||
}
|
||||
|
||||
// Create the DNS proxy, unless routes/aliases are disabled - the proxy is
|
||||
// what resolves aliases, and the utility subnet it lives on is only
|
||||
// reachable through a system route we wouldn't add. o.dnsProxy stays nil
|
||||
// in that case; everything that uses it is nil-checked.
|
||||
if o.tunnelConfig.DisableRoutesAndAliases {
|
||||
logger.Info("Routes and aliases disabled: not adding system routes and not starting the DNS proxy (gateway routes are unaffected)")
|
||||
} else {
|
||||
o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP, o.tunnelConfig.MatchDomains, o.tunnelConfig.PublicDNS)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create DNS proxy: %v", err)
|
||||
}
|
||||
// The DNS proxy is always created - it resolves both exit node aliases
|
||||
// (unaffected by DisableRoutesAndAliasesOnExitNode - see connectExitNode)
|
||||
// and site resource aliases, which the peer manager created below adds
|
||||
// and removes dynamically as an exit node connects/disconnects (see
|
||||
// PeerManager.SetExitNode/ClearExitNode). o.dnsProxy is still nil-checked
|
||||
// everywhere it's used, in case creation itself fails.
|
||||
o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP, o.tunnelConfig.MatchDomains, o.tunnelConfig.PublicDNS)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create DNS proxy: %v", err)
|
||||
}
|
||||
|
||||
// Tell the system DNS monitor to exclude the proxy IP so that subsequent
|
||||
@@ -214,25 +212,34 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
if !o.tunnelConfig.DisableRoutesAndAliases {
|
||||
if err := network.AddRoutesWithSource([]string{wgData.UtilitySubnet}, o.tunnelConfig.InterfaceName, interfaceIP); err != nil { // also route the utility subnet
|
||||
logger.Error("Failed to add route for utility subnet: %v", err)
|
||||
}
|
||||
// The utility subnet route is what makes the DNS proxy (always created
|
||||
// above) reachable at all, so it's always added too, independent of
|
||||
// DisableRoutesAndAliasesOnExitNode - which only gates routes/aliases for
|
||||
// individual site resources, applied dynamically below as the peer
|
||||
// manager is told about the exit node's connection state.
|
||||
if err := network.AddRoutesWithSource([]string{wgData.UtilitySubnet}, o.tunnelConfig.InterfaceName, interfaceIP); err != nil { // also route the utility subnet
|
||||
logger.Error("Failed to add route for utility subnet: %v", err)
|
||||
}
|
||||
|
||||
// Create peer manager with integrated peer monitoring
|
||||
// Create peer manager with integrated peer monitoring. If
|
||||
// DisableRoutesAndAliasesOnExitNode is enabled, resource routes/aliases
|
||||
// are suppressed reactively once an exit node (ExitNodeConfig peer or
|
||||
// gateway mode) actually activates below/later - see
|
||||
// PeerManager.SetExitNode/SetGateway - rather than being pre-seeded here,
|
||||
// so a failed initial exit-node/gateway setup can never leave routes
|
||||
// stuck suppressed with nothing active to justify it.
|
||||
o.peerManager = peers.NewPeerManager(peers.PeerManagerConfig{
|
||||
Device: o.dev,
|
||||
DNSProxy: o.dnsProxy,
|
||||
InterfaceName: o.tunnelConfig.InterfaceName,
|
||||
PrivateKey: o.privateKey,
|
||||
MiddleDev: o.middleDev,
|
||||
LocalIP: interfaceIP,
|
||||
SharedBind: o.sharedBind,
|
||||
WSClient: o.websocket,
|
||||
APIServer: o.apiServer,
|
||||
PublicDNS: o.tunnelConfig.PublicDNS,
|
||||
DisableRoutes: o.tunnelConfig.DisableRoutesAndAliases,
|
||||
Device: o.dev,
|
||||
DNSProxy: o.dnsProxy,
|
||||
InterfaceName: o.tunnelConfig.InterfaceName,
|
||||
PrivateKey: o.privateKey,
|
||||
MiddleDev: o.middleDev,
|
||||
LocalIP: interfaceIP,
|
||||
SharedBind: o.sharedBind,
|
||||
WSClient: o.websocket,
|
||||
APIServer: o.apiServer,
|
||||
PublicDNS: o.tunnelConfig.PublicDNS,
|
||||
DisableRoutesAndAliasesOnExitNode: o.tunnelConfig.DisableRoutesAndAliasesOnExitNode,
|
||||
})
|
||||
|
||||
for i := range wgData.Sites {
|
||||
|
||||
+16
-16
@@ -301,22 +301,22 @@ 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,
|
||||
DisableRoutesAndAliases: req.DisableRoutesAndAliases,
|
||||
GatewaySiteIds: req.GatewaySiteIds,
|
||||
GatewaySiteResourceId: req.GatewaySiteResourceId,
|
||||
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,
|
||||
DisableRoutesAndAliasesOnExitNode: req.DisableRoutesAndAliasesOnExitNode,
|
||||
GatewaySiteIds: req.GatewaySiteIds,
|
||||
GatewaySiteResourceId: req.GatewaySiteResourceId,
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
+14
-6
@@ -182,15 +182,23 @@ type TunnelConfig struct {
|
||||
// LAN address it originally arrived with. Linux only. Defaults to false.
|
||||
SubnetRouter bool
|
||||
|
||||
// DisableRoutesAndAliases, when enabled, stops olm from adding routes to
|
||||
// the host's routing table (server IPs, remote subnets, the utility
|
||||
// subnet) and from launching the DNS proxy, so aliases are not resolved
|
||||
// either. WireGuard AllowedIPs are still configured, so the tunnel can
|
||||
// DisableRoutesAndAliasesOnExitNode, when enabled, makes the exit node
|
||||
// take precedence over individual resources: for as long as an exit node
|
||||
// is connected, olm removes routes to the host's routing table for site
|
||||
// resources (server IPs, remote subnets) and their alias DNS records, and
|
||||
// restores them the moment the exit node disconnects. If the tunnel
|
||||
// starts with an exit node already selected, these routes/aliases are
|
||||
// never added in the first place. The exit node's own routes and aliases
|
||||
// are unaffected, and it can connect/disconnect at any time - via the
|
||||
// initial connect, a server push (olm/wg/exitnode/connect|disconnect), or
|
||||
// the local API - all of which converge through the same connect/
|
||||
// disconnect path (see PeerManager.SetExitNode/ClearExitNode).
|
||||
// WireGuard AllowedIPs are still configured throughout, so the tunnel can
|
||||
// still be used by anything that reaches it without the OS routing table
|
||||
// (e.g. a file descriptor/netstack consumer). Gateway routes (the
|
||||
// default-route-equivalent and its endpoint bypass routes) are unaffected
|
||||
// and are still installed. Defaults to false.
|
||||
DisableRoutesAndAliases bool
|
||||
// and are always installed. Defaults to false.
|
||||
DisableRoutesAndAliasesOnExitNode bool
|
||||
|
||||
// GatewaySiteIds, when non-empty, designates these site IDs as gateway
|
||||
// (full-tunnel/default-route) candidates from the moment the tunnel
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package peers
|
||||
|
||||
import "testing"
|
||||
|
||||
// newExitNodeTestManager returns a PeerManager for exercising the
|
||||
// DisableRoutesAndAliasesOnExitNode state machine (resourceRoutesSuppressed,
|
||||
// exitNodeActive, gatewayActive) without touching the OS routing table, a
|
||||
// WireGuard device, or a DNS proxy - safe as long as pm.peers stays empty,
|
||||
// same constraint as newGatewayTestManager in gateway_test.go. Unlike
|
||||
// NewPeerManager, disableRoutesAndAliasesOnExitNode is the only state seeded
|
||||
// here; exitNodeActive/gatewayActive/resourceRoutesSuppressed always start
|
||||
// false, matching NewPeerManager's real (purely reactive, no pre-seeding)
|
||||
// behavior.
|
||||
func newExitNodeTestManager(disableRoutesAndAliasesOnExitNode bool) *PeerManager {
|
||||
return &PeerManager{
|
||||
peers: make(map[int]SiteConfig),
|
||||
allowedIPOwners: make(map[string]int),
|
||||
allowedIPClaims: make(map[string]map[int]bool),
|
||||
disableRoutesAndAliasesOnExitNode: disableRoutesAndAliasesOnExitNode,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetClearExitNodeSuppressesResourceRoutesWhenEnabled(t *testing.T) {
|
||||
pm := newExitNodeTestManager(true)
|
||||
|
||||
if pm.resourceRoutesSuppressed {
|
||||
t.Fatalf("must start unsuppressed")
|
||||
}
|
||||
|
||||
pm.SetExitNode("100.64.0.1", "100.64.0.2")
|
||||
if !pm.exitNodeActive {
|
||||
t.Fatalf("SetExitNode must mark the exit node active")
|
||||
}
|
||||
if !pm.resourceRoutesSuppressed {
|
||||
t.Fatalf("SetExitNode must suppress resource routes when the feature is enabled")
|
||||
}
|
||||
|
||||
pm.ClearExitNode()
|
||||
if pm.exitNodeActive {
|
||||
t.Fatalf("ClearExitNode must mark the exit node inactive")
|
||||
}
|
||||
if pm.resourceRoutesSuppressed {
|
||||
t.Fatalf("ClearExitNode must restore resource routes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetClearExitNodeNoopWhenDisabled(t *testing.T) {
|
||||
pm := newExitNodeTestManager(false)
|
||||
|
||||
pm.SetExitNode("100.64.0.1", "100.64.0.2")
|
||||
if pm.resourceRoutesSuppressed {
|
||||
t.Fatalf("resource routes must never be suppressed when the feature is disabled")
|
||||
}
|
||||
|
||||
pm.ClearExitNode()
|
||||
if pm.resourceRoutesSuppressed {
|
||||
t.Fatalf("resource routes must stay unsuppressed when the feature is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearExitNodeKeepsSuppressedWhileGatewayActive covers the two
|
||||
// independent "exit node" signals (see exitNodeOrGatewayActiveLocked):
|
||||
// disconnecting the ExitNodeConfig WireGuard peer must not restore resource
|
||||
// routes if gateway/full-tunnel mode - what client apps and the CLI actually
|
||||
// call "select exit node" - is still active. gatewayActive is set directly
|
||||
// here (bypassing SetGateway, which touches the OS routing table) purely to
|
||||
// exercise ClearExitNode's OR-check.
|
||||
func TestClearExitNodeKeepsSuppressedWhileGatewayActive(t *testing.T) {
|
||||
pm := newExitNodeTestManager(true)
|
||||
|
||||
pm.SetExitNode("100.64.0.1", "100.64.0.2")
|
||||
if !pm.resourceRoutesSuppressed {
|
||||
t.Fatalf("SetExitNode must suppress resource routes")
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
pm.gatewayActive = true
|
||||
pm.mu.Unlock()
|
||||
|
||||
pm.ClearExitNode()
|
||||
if pm.exitNodeActive {
|
||||
t.Fatalf("ClearExitNode must mark the exit node inactive regardless of gateway state")
|
||||
}
|
||||
if !pm.resourceRoutesSuppressed {
|
||||
t.Fatalf("resource routes must stay suppressed while gateway mode is still active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitNodeOrGatewayActiveLocked(t *testing.T) {
|
||||
pm := newExitNodeTestManager(true)
|
||||
|
||||
if pm.exitNodeOrGatewayActiveLocked() {
|
||||
t.Fatalf("neither signal is active yet")
|
||||
}
|
||||
|
||||
pm.exitNodeActive = true
|
||||
if !pm.exitNodeOrGatewayActiveLocked() {
|
||||
t.Fatalf("exit node signal alone must count as active")
|
||||
}
|
||||
pm.exitNodeActive = false
|
||||
|
||||
pm.gatewayActive = true
|
||||
if !pm.exitNodeOrGatewayActiveLocked() {
|
||||
t.Fatalf("gateway signal alone must count as active")
|
||||
}
|
||||
}
|
||||
+198
-41
@@ -36,10 +36,19 @@ type PeerManagerConfig struct {
|
||||
WSClient *websocket.Client
|
||||
APIServer *api.API
|
||||
PublicDNS []string
|
||||
// DisableRoutes stops the manager from adding/removing routes in the
|
||||
// system routing table for server IPs and remote subnets. Gateway routes
|
||||
// are unaffected.
|
||||
DisableRoutes bool
|
||||
// DisableRoutesAndAliasesOnExitNode, when true, suppresses routes and
|
||||
// alias DNS records for individual resources (server IPs, remote
|
||||
// subnets, alias records) for as long as an "exit node" is active, and
|
||||
// restores them as soon as it's no longer active. Two distinct
|
||||
// mechanisms both count as "exit node" here and are ORed together (see
|
||||
// exitNodeOrGatewayActiveLocked): the ExitNodeConfig WireGuard peer used
|
||||
// for resources hosted directly on an exit node server (SetExitNode/
|
||||
// ClearExitNode), and gateway/full-tunnel mode (SetGateway/
|
||||
// clearGatewayLocked) - which is what client apps and the CLI actually
|
||||
// mean by "select exit node" (a gateway-mode site resource). The
|
||||
// gateway's own default-route-equivalent and bypass routes are
|
||||
// unaffected either way.
|
||||
DisableRoutesAndAliasesOnExitNode bool
|
||||
}
|
||||
|
||||
type PeerManager struct {
|
||||
@@ -62,7 +71,24 @@ type PeerManager struct {
|
||||
allowedIPClaims map[string]map[int]bool
|
||||
APIServer *api.API
|
||||
publicDNS []string
|
||||
disableRoutes bool
|
||||
// disableRoutesAndAliasesOnExitNode is static config (see
|
||||
// PeerManagerConfig) for whether resource routes/aliases should ever be
|
||||
// suppressed while an "exit node" (see the doc comment on
|
||||
// PeerManagerConfig.DisableRoutesAndAliasesOnExitNode) is active.
|
||||
disableRoutesAndAliasesOnExitNode bool
|
||||
// exitNodeActive tracks whether the ExitNodeConfig WireGuard peer is
|
||||
// currently connected - see SetExitNode/ClearExitNode. gatewayActive
|
||||
// (below, pre-existing) is the other "exit node" signal.
|
||||
exitNodeActive bool
|
||||
// resourceRoutesSuppressed is the live, computed state: true exactly
|
||||
// when disableRoutesAndAliasesOnExitNode && (exitNodeActive ||
|
||||
// gatewayActive) - see exitNodeOrGatewayActiveLocked. It gates
|
||||
// addRoutes/removeRoutes/addServerRoute/removeServerRoute/addDNSRecord/
|
||||
// removeDNSRecord/removeDNSRecordForSite below. Kept as its own field
|
||||
// (rather than recomputed each time) so suppressResourceRoutesLocked/
|
||||
// restoreResourceRoutesLocked can tell whether a transition actually
|
||||
// occurred.
|
||||
resourceRoutesSuppressed bool
|
||||
|
||||
PersistentKeepalive int
|
||||
|
||||
@@ -153,21 +179,21 @@ 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,
|
||||
disableRoutes: config.DisableRoutes,
|
||||
lastOwnerChange: make(map[string]time.Time),
|
||||
gatewaySiteIds: make(map[int]bool),
|
||||
gatewayExcludedIPs: make(map[string]int),
|
||||
gatewayExtraEndpoints: make(map[string]bool),
|
||||
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,
|
||||
disableRoutesAndAliasesOnExitNode: config.DisableRoutesAndAliasesOnExitNode,
|
||||
lastOwnerChange: make(map[string]time.Time),
|
||||
gatewaySiteIds: make(map[int]bool),
|
||||
gatewayExcludedIPs: make(map[string]int),
|
||||
gatewayExtraEndpoints: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Create the peer monitor
|
||||
@@ -204,22 +230,36 @@ func (pm *PeerManager) GetPeerMonitor() *monitor.PeerMonitor {
|
||||
// SetExitNode starts (or updates) ICMP connectivity monitoring of the given exit node.
|
||||
// tunnelIP is the secondary address assigned to us for this exit node, which the ping
|
||||
// probe must be sourced from since the exit node's WireGuard peer entry only accepts
|
||||
// traffic from that address.
|
||||
// traffic from that address. If DisableRoutesAndAliasesOnExitNode was enabled (see
|
||||
// PeerManagerConfig), this also suppresses resource routes/aliases for every tracked
|
||||
// site peer - see suppressResourceRoutesLocked.
|
||||
func (pm *PeerManager) SetExitNode(serverIP, tunnelIP string) {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
if pm.peerMonitor != nil {
|
||||
pm.peerMonitor.SetExitNode(serverIP, tunnelIP)
|
||||
}
|
||||
pm.exitNodeActive = true
|
||||
if pm.disableRoutesAndAliasesOnExitNode {
|
||||
pm.suppressResourceRoutesLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// ClearExitNode stops ICMP connectivity monitoring of the exit node
|
||||
// ClearExitNode stops ICMP connectivity monitoring of the exit node. If
|
||||
// DisableRoutesAndAliasesOnExitNode was enabled (see PeerManagerConfig), this
|
||||
// also restores resource routes/aliases for every tracked site peer - unless
|
||||
// gateway mode is still active, since that's the other "exit node" signal
|
||||
// (see exitNodeOrGatewayActiveLocked) - see restoreResourceRoutesLocked.
|
||||
func (pm *PeerManager) ClearExitNode() {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
if pm.peerMonitor != nil {
|
||||
pm.peerMonitor.ClearExitNode()
|
||||
}
|
||||
pm.exitNodeActive = false
|
||||
if pm.disableRoutesAndAliasesOnExitNode && !pm.exitNodeOrGatewayActiveLocked() {
|
||||
pm.restoreResourceRoutesLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// SetPublicDNS replaces the DNS servers used to resolve WireGuard peer
|
||||
@@ -442,6 +482,12 @@ func (pm *PeerManager) SetGateway(siteResourceId int, siteIds []int, controlEndp
|
||||
return err
|
||||
}
|
||||
pm.gatewayActive = true
|
||||
// Gateway/full-tunnel mode is what client apps and the CLI call
|
||||
// "exit node" - see the doc comment on
|
||||
// PeerManagerConfig.DisableRoutesAndAliasesOnExitNode.
|
||||
if pm.disableRoutesAndAliasesOnExitNode {
|
||||
pm.suppressResourceRoutesLocked()
|
||||
}
|
||||
}
|
||||
|
||||
newSet := make(map[int]bool, len(siteIds))
|
||||
@@ -581,6 +627,12 @@ func (pm *PeerManager) clearGatewayLocked() {
|
||||
pm.gatewaySiteResourceId = 0
|
||||
pm.deactivateGatewayLocked()
|
||||
pm.gatewayActive = false
|
||||
// Mirror SetGateway's activation hook, restoring resource routes/aliases
|
||||
// unless the ExitNodeConfig WireGuard peer is still active (the other
|
||||
// "exit node" signal).
|
||||
if pm.disableRoutesAndAliasesOnExitNode && !pm.exitNodeOrGatewayActiveLocked() {
|
||||
pm.restoreResourceRoutesLocked()
|
||||
}
|
||||
logger.Info("Gateway cleared")
|
||||
}
|
||||
|
||||
@@ -648,55 +700,160 @@ func (pm *PeerManager) GetAllPeers() []SiteConfig {
|
||||
|
||||
// addRoutes/removeRoutes/addServerRoute/removeServerRoute wrap the system
|
||||
// route helpers for site traffic (server IPs and remote subnets) and are a
|
||||
// no-op when route management is disabled. They deliberately do NOT cover the
|
||||
// gateway default-route-equivalent or its bypass routes, which are always
|
||||
// installed regardless (see activateGatewayLocked).
|
||||
// no-op while resource routes are suppressed (see resourceRoutesSuppressed).
|
||||
// They deliberately do NOT cover the gateway default-route-equivalent or its
|
||||
// bypass routes, which are always installed regardless (see
|
||||
// activateGatewayLocked).
|
||||
func (pm *PeerManager) addRoutes(subnets []string) error {
|
||||
if pm.disableRoutes {
|
||||
if pm.resourceRoutesSuppressed {
|
||||
return nil
|
||||
}
|
||||
return network.AddRoutesWithSource(subnets, pm.interfaceName, pm.localIP)
|
||||
}
|
||||
|
||||
func (pm *PeerManager) removeRoutes(subnets []string) error {
|
||||
if pm.disableRoutes {
|
||||
if pm.resourceRoutesSuppressed {
|
||||
return nil
|
||||
}
|
||||
return network.RemoveRoutes(subnets, pm.interfaceName)
|
||||
}
|
||||
|
||||
func (pm *PeerManager) addServerRoute(serverIP string) error {
|
||||
if pm.disableRoutes {
|
||||
if pm.resourceRoutesSuppressed {
|
||||
return nil
|
||||
}
|
||||
return network.AddRouteForServerIPWithSource(normalizeServerRouteDestination(serverIP), pm.interfaceName, pm.localIP)
|
||||
}
|
||||
|
||||
func (pm *PeerManager) removeServerRoute(serverIP string) error {
|
||||
if pm.disableRoutes {
|
||||
if pm.resourceRoutesSuppressed {
|
||||
return nil
|
||||
}
|
||||
return network.RemoveRouteForServerIPWithSource(normalizeServerRouteDestination(serverIP), pm.interfaceName, pm.localIP)
|
||||
}
|
||||
|
||||
// The DNS proxy is not created when aliases are disabled, so every alias
|
||||
// record operation must tolerate a nil proxy.
|
||||
// addDNSRecord/removeDNSRecord/removeDNSRecordForSite tolerate a nil proxy
|
||||
// (defensively - the proxy is now always created, see olm's handleConnect)
|
||||
// and are a no-op while resource routes/aliases are suppressed, same as the
|
||||
// route helpers above.
|
||||
func (pm *PeerManager) addDNSRecord(alias string, address net.IP, siteId int) {
|
||||
if pm.dnsProxy != nil {
|
||||
pm.dnsProxy.AddDNSRecord(alias, address, siteId)
|
||||
if pm.dnsProxy == nil || pm.resourceRoutesSuppressed {
|
||||
return
|
||||
}
|
||||
pm.dnsProxy.AddDNSRecord(alias, address, siteId)
|
||||
}
|
||||
|
||||
func (pm *PeerManager) removeDNSRecord(alias string, address net.IP) {
|
||||
if pm.dnsProxy != nil {
|
||||
pm.dnsProxy.RemoveDNSRecord(alias, address)
|
||||
if pm.dnsProxy == nil || pm.resourceRoutesSuppressed {
|
||||
return
|
||||
}
|
||||
pm.dnsProxy.RemoveDNSRecord(alias, address)
|
||||
}
|
||||
|
||||
func (pm *PeerManager) removeDNSRecordForSite(alias string, address net.IP, siteId int) {
|
||||
if pm.dnsProxy != nil {
|
||||
pm.dnsProxy.RemoveDNSRecordForSite(alias, address, siteId)
|
||||
if pm.dnsProxy == nil || pm.resourceRoutesSuppressed {
|
||||
return
|
||||
}
|
||||
pm.dnsProxy.RemoveDNSRecordForSite(alias, address, siteId)
|
||||
}
|
||||
|
||||
// exitNodeOrGatewayActiveLocked reports whether either "exit node" signal is
|
||||
// currently active - the ExitNodeConfig WireGuard peer (SetExitNode) or
|
||||
// gateway/full-tunnel mode (SetGateway), which is what client apps and the
|
||||
// CLI actually mean by "select exit node" - see the doc comment on
|
||||
// PeerManagerConfig.DisableRoutesAndAliasesOnExitNode. Must be called with
|
||||
// pm.mu held.
|
||||
func (pm *PeerManager) exitNodeOrGatewayActiveLocked() bool {
|
||||
return pm.exitNodeActive || pm.gatewayActive
|
||||
}
|
||||
|
||||
// suppressResourceRoutesLocked removes routes and alias DNS records for every
|
||||
// tracked site peer's resources (server IP, remote subnets, aliases) so that
|
||||
// only the exit node's own routes remain in effect. WireGuard peer
|
||||
// configuration (AllowedIps) is left untouched - the tunnel remains usable by
|
||||
// anything that reaches it without relying on the OS routing table. Called
|
||||
// when either "exit node" signal becomes active while
|
||||
// DisableRoutesAndAliasesOnExitNode is enabled (see SetExitNode/SetGateway).
|
||||
// No-op if already suppressed. Must be called with pm.mu held.
|
||||
//
|
||||
// Deliberately calls network.* and pm.dnsProxy directly rather than through
|
||||
// the addRoutes/removeRoutes/addServerRoute/removeServerRoute/addDNSRecord/
|
||||
// removeDNSRecord wrappers above: those are gated on resourceRoutesSuppressed,
|
||||
// which this function is itself in the middle of flipping - going through
|
||||
// them here would silently no-op the very removal this function exists to do.
|
||||
func (pm *PeerManager) suppressResourceRoutesLocked() {
|
||||
if pm.resourceRoutesSuppressed {
|
||||
return
|
||||
}
|
||||
pm.resourceRoutesSuppressed = true
|
||||
|
||||
removedSubnets := make(map[string]bool, len(pm.peers))
|
||||
for _, peer := range pm.peers {
|
||||
if err := network.RemoveRouteForServerIPWithSource(normalizeServerRouteDestination(peer.ServerIP), pm.interfaceName, pm.localIP); err != nil {
|
||||
logger.Warn("Exit node active: failed to remove route for server IP %s: %v", peer.ServerIP, err)
|
||||
}
|
||||
for _, subnet := range peer.RemoteSubnets {
|
||||
if removedSubnets[subnet] {
|
||||
continue
|
||||
}
|
||||
removedSubnets[subnet] = true
|
||||
if err := network.RemoveRoutes([]string{subnet}, pm.interfaceName); err != nil {
|
||||
logger.Warn("Exit node active: failed to remove route for remote subnet %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
if pm.dnsProxy != nil {
|
||||
for _, alias := range peer.Aliases {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
continue
|
||||
}
|
||||
pm.dnsProxy.RemoveDNSRecordForSite(alias.Alias, address, peer.SiteId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Exit node active: removed resource routes/aliases for %d site(s)", len(pm.peers))
|
||||
}
|
||||
|
||||
// restoreResourceRoutesLocked is suppressResourceRoutesLocked's inverse,
|
||||
// re-adding routes and alias DNS records for every tracked site peer's
|
||||
// resources. Called when neither "exit node" signal remains active (see
|
||||
// ClearExitNode/clearGatewayLocked). No-op if not currently suppressed. Must
|
||||
// be called with pm.mu held.
|
||||
func (pm *PeerManager) restoreResourceRoutesLocked() {
|
||||
if !pm.resourceRoutesSuppressed {
|
||||
return
|
||||
}
|
||||
pm.resourceRoutesSuppressed = false
|
||||
|
||||
addedSubnets := make(map[string]bool, len(pm.peers))
|
||||
for _, peer := range pm.peers {
|
||||
if err := network.AddRouteForServerIPWithSource(normalizeServerRouteDestination(peer.ServerIP), pm.interfaceName, pm.localIP); err != nil {
|
||||
logger.Warn("Exit node inactive: failed to add route for server IP %s: %v", peer.ServerIP, err)
|
||||
}
|
||||
for _, subnet := range peer.RemoteSubnets {
|
||||
if addedSubnets[subnet] {
|
||||
continue
|
||||
}
|
||||
addedSubnets[subnet] = true
|
||||
if err := network.AddRoutesWithSource([]string{subnet}, pm.interfaceName, pm.localIP); err != nil {
|
||||
logger.Warn("Exit node inactive: failed to add route for remote subnet %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
if pm.dnsProxy != nil {
|
||||
for _, alias := range peer.Aliases {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
continue
|
||||
}
|
||||
if err := pm.dnsProxy.AddDNSRecord(alias.Alias, address, peer.SiteId); err != nil {
|
||||
logger.Warn("Exit node inactive: failed to add DNS record for alias %s: %v", alias.Alias, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Exit node inactive: restored resource routes/aliases for %d site(s)", len(pm.peers))
|
||||
}
|
||||
|
||||
func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
|
||||
|
||||
Reference in New Issue
Block a user