refactor: replace maps with Snapshot for immutable state management

Kim committed May 15, 2026 at 15:22 UTC 34b52bd92195dadd90cde7724addeaf42c2dc2d1
21 files changed +640 -363
cmd/portal-tunnel/agent/control.go
+4 -4
@@ -9,7 +9,7 @@ import (
9 "strings"
10 "time"
11
12 - portalauth "github.com/gosuda/portal-tunnel/v2/portal/auth"
12 + "github.com/gosuda/portal-tunnel/v2/portal/auth"
13 "github.com/gosuda/portal-tunnel/v2/types"
14 "github.com/gosuda/portal-tunnel/v2/utils"
15 )
@@ -30,7 +30,7 @@ type endpoint struct {
30 type controlHandler struct {
31 manager *manager
32 token string
33 - auth *portalauth.WalletAuthenticator
33 + auth *auth.WalletAuthenticator
34 shutdown func()
35 }
36
@@ -272,9 +272,9 @@ func agentAuthURI(r *http.Request, endpointPath string) string {
272
273 func writeAgentWalletAuthError(w http.ResponseWriter, err error) {
274 switch {
275 - case errors.Is(err, portalauth.ErrWalletAuthUnauthorized):
275 + case errors.Is(err, auth.ErrWalletAuthUnauthorized):
276 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, err.Error())
277 - case errors.Is(err, portalauth.ErrWalletAuthChallengeNotFound), errors.Is(err, portalauth.ErrWalletAuthChallengeExpired), errors.Is(err, portalauth.ErrWalletAuthInvalidSignature):
277 + case errors.Is(err, auth.ErrWalletAuthChallengeNotFound), errors.Is(err, auth.ErrWalletAuthChallengeExpired), errors.Is(err, auth.ErrWalletAuthInvalidSignature):
278 utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, err.Error())
279 default:
280 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
cmd/portal-tunnel/agent/manager.go
+3 -2
@@ -411,6 +411,7 @@ func (m *manager) ApplyConfig(cfg Config) error {
411
412 func (m *manager) Snapshot() types.AgentStatusResponse {
413 m.mu.RLock()
414 + configPath := m.cfg.sourcePath
415 tunnels := make([]*managedTunnel, 0, len(m.tunnels))
416 for _, tunnel := range m.tunnels {
417 tunnels = append(tunnels, tunnel)
@@ -426,7 +427,7 @@ func (m *manager) Snapshot() types.AgentStatusResponse {
427 })
428
429 return types.AgentStatusResponse{
429 - ConfigPath: m.cfg.sourcePath,
430 + ConfigPath: configPath,
431 ControlAddr: m.controlAddr,
432 Tunnels: statuses,
433 }
@@ -521,8 +522,8 @@ func (t *managedTunnel) SetMultiHop(relayURLs []string) error {
522
523 func (t *managedTunnel) UpdateSettings(updateMetadata, updateMaxActiveRelays bool) error {
524 t.mu.RLock()
524 - cfg := t.cfg
525 exposure := t.exposure
526 + cfg := t.cfg
527 t.mu.RUnlock()
528 if exposure == nil {
529 return nil
cmd/portal-tunnel/agent/run.go
+2 -2
@@ -13,7 +13,7 @@ import (
13
14 "github.com/rs/zerolog/log"
15
16 - portalauth "github.com/gosuda/portal-tunnel/v2/portal/auth"
16 + "github.com/gosuda/portal-tunnel/v2/portal/auth"
17 "github.com/gosuda/portal-tunnel/v2/utils"
18 )
19
@@ -31,7 +31,7 @@ func Run(ctx context.Context, cfg Config) error {
31 defer cancel()
32
33 manager := newManager(cfg, "")
34 - walletAuth, err := portalauth.NewWalletAuthenticator(portalauth.WalletAuthConfig{
34 + walletAuth, err := auth.NewWalletAuthenticator(auth.WalletAuthConfig{
35 AllowedAddresses: cfg.Agent.AllowedWallets,
36 AllowAnyAddress: len(cfg.Agent.AllowedWallets) == 0,
37 Statement: "Sign in to Portal agent",
cmd/relay-server/admin.go
+15 -10
@@ -8,7 +8,8 @@ import (
8 "strings"
9 "time"
10
11 - portalauth "github.com/gosuda/portal-tunnel/v2/portal/auth"
11 + "github.com/gosuda/portal-tunnel/v2/portal"
12 + "github.com/gosuda/portal-tunnel/v2/portal/auth"
13 "github.com/gosuda/portal-tunnel/v2/portal/identity"
14 "github.com/gosuda/portal-tunnel/v2/portal/policy"
15 "github.com/gosuda/portal-tunnel/v2/types"
@@ -21,7 +22,7 @@ const (
22 adminBodyLimit = 1 << 16
23 )
24
24 -func loadAdminState(path string, runtime *policy.Runtime) (persistedAdminState, error) {
25 +func loadAdminState(path string, server *portal.Server) (persistedAdminState, error) {
26 path = strings.TrimSpace(path)
27 if path == "" {
28 return persistedAdminState{}, nil
@@ -31,7 +32,7 @@ func loadAdminState(path string, runtime *policy.Runtime) (persistedAdminState,
32 if _, err := utils.ReadJSONFileIfExists(path, &payload); err != nil {
33 return persistedAdminState{}, err
34 }
34 - if err := payload.apply(runtime); err != nil {
35 + if err := payload.apply(server); err != nil {
36 return persistedAdminState{}, err
37 }
38 return payload, nil
@@ -140,14 +141,14 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
141 })
142 case types.PathAdminUDP:
143 f.handlePortSettings(w, r, invalidRequestBody, runtime,
143 - runtime.SetUDPPolicy,
144 + f.server.SetUDPPolicy,
145 func() any {
146 return types.AdminUDPSettingsResponse{Enabled: runtime.IsUDPEnabled(), MaxLeases: runtime.UDPMaxLeases()}
147 },
148 )
149 case types.PathAdminTCPPort:
150 f.handlePortSettings(w, r, invalidRequestBody, runtime,
150 - runtime.SetTCPPortPolicy,
151 + f.server.SetTCPPortPolicy,
152 func() any {
153 return types.AdminTCPPortSettingsResponse{Enabled: runtime.IsTCPPortEnabled(), MaxLeases: runtime.TCPPortMaxLeases()}
154 },
@@ -385,9 +386,9 @@ func adminAuthURI(r *http.Request, endpointPath string) string {
386
387 func writeWalletAuthError(w http.ResponseWriter, err error) {
388 switch {
388 - case errors.Is(err, portalauth.ErrWalletAuthUnauthorized):
389 + case errors.Is(err, auth.ErrWalletAuthUnauthorized):
390 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, err.Error())
390 - case errors.Is(err, portalauth.ErrWalletAuthChallengeNotFound), errors.Is(err, portalauth.ErrWalletAuthChallengeExpired), errors.Is(err, portalauth.ErrWalletAuthInvalidSignature):
391 + case errors.Is(err, auth.ErrWalletAuthChallengeNotFound), errors.Is(err, auth.ErrWalletAuthChallengeExpired), errors.Is(err, auth.ErrWalletAuthInvalidSignature):
392 utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, err.Error())
393 default:
394 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
@@ -450,7 +451,11 @@ func applyOptionalPolicy(enabled *bool, maxLeases *int, getEnabled func() bool,
451 set(e, m)
452 }
453
453 -func (s persistedAdminState) apply(runtime *policy.Runtime) error {
454 +func (s persistedAdminState) apply(server *portal.Server) error {
455 + if server == nil {
456 + return nil
457 + }
458 + runtime := server.PolicyRuntime()
459 if runtime == nil {
460 return nil
461 }
@@ -466,7 +471,7 @@ func (s persistedAdminState) apply(runtime *policy.Runtime) error {
471 runtime.SetBannedIdentityKeys(identity.NormalizeIdentityKeys(s.BannedIdentityKeys))
472 runtime.IPFilter().SetBannedIPs(s.BannedIPs)
473 runtime.BPSManager().SetIdentityBPSLimits(identity.NormalizeIdentityKeyBPS(s.IdentityBPS))
469 - applyOptionalPolicy(s.UDPEnabled, s.UDPMaxLeases, runtime.IsUDPEnabled, runtime.UDPMaxLeases, runtime.SetUDPPolicy)
470 - applyOptionalPolicy(s.TCPPortEnabled, s.TCPPortMaxLeases, runtime.IsTCPPortEnabled, runtime.TCPPortMaxLeases, runtime.SetTCPPortPolicy)
474 + applyOptionalPolicy(s.UDPEnabled, s.UDPMaxLeases, runtime.IsUDPEnabled, runtime.UDPMaxLeases, server.SetUDPPolicy)
475 + applyOptionalPolicy(s.TCPPortEnabled, s.TCPPortMaxLeases, runtime.IsTCPPortEnabled, runtime.TCPPortMaxLeases, server.SetTCPPortPolicy)
476 return nil
477 }
cmd/relay-server/frontend.go
+4 -4
@@ -19,7 +19,7 @@ import (
19
20 "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
21 "github.com/gosuda/portal-tunnel/v2/portal"
22 - portalauth "github.com/gosuda/portal-tunnel/v2/portal/auth"
22 + "github.com/gosuda/portal-tunnel/v2/portal/auth"
23 "github.com/gosuda/portal-tunnel/v2/portal/identity"
24 "github.com/gosuda/portal-tunnel/v2/types"
25 "github.com/gosuda/portal-tunnel/v2/utils"
@@ -36,7 +36,7 @@ var embeddedDistFS embed.FS
36 type Frontend struct {
37 distFS readDirFileFS
38 server *portal.Server
39 - auth *portalauth.WalletAuthenticator
39 + auth *auth.WalletAuthenticator
40 adminSettingsPath string
41 thumbnails *thumbnailService
42
@@ -57,13 +57,13 @@ func NewFrontend(server *portal.Server, identityPath string, defaultLandingPageE
57 if adminSettingsPath == "" {
58 return nil, errors.New("frontend requires identity path")
59 }
60 - state, err := loadAdminState(adminSettingsPath, runtime)
60 + state, err := loadAdminState(adminSettingsPath, server)
61 if err != nil {
62 return nil, err
63 }
64 relayIdentity := server.RelayIdentity()
65 allowedWallets := append([]string{relayIdentity.Address}, adminWallets...)
66 - authenticator, err := portalauth.NewWalletAuthenticator(portalauth.WalletAuthConfig{
66 + authenticator, err := auth.NewWalletAuthenticator(auth.WalletAuthConfig{
67 AllowedAddresses: allowedWallets,
68 Statement: "Sign in to Portal relay admin",
69 })
portal/acme/acme.go
+12 -10
@@ -72,8 +72,7 @@ type Manager struct {
72 stopOnce sync.Once
73 dnssecLogOnce sync.Once
74 ensLogOnce sync.Once
75 - ensStatusMu sync.RWMutex
76 - ensStatus types.ENSStatus
75 + ensStatus *utils.Snapshot[types.ENSStatus]
76 trackedMu sync.Mutex
77 echMu sync.Mutex
78 echRecords map[string]HTTPSRecord
@@ -185,14 +184,14 @@ func NewManager(cfg Config) (*Manager, error) {
184 return &Manager{
185 cfg: cfg,
186 stopCh: make(chan struct{}),
188 - ensStatus: newENSStatus(cfg, nil),
187 + ensStatus: utils.NewSnapshot(newENSStatus(cfg, nil)),
188 }, nil
189 }
190
191 manager := &Manager{
192 cfg: cfg,
193 stopCh: make(chan struct{}),
195 - ensStatus: newENSStatus(cfg, nil),
194 + ensStatus: utils.NewSnapshot(newENSStatus(cfg, nil)),
195 }
196
197 acmeDNS, err := NewDNSProvider(cfg.DNSProvider, cfg)
@@ -224,9 +223,10 @@ func (m *Manager) ENSStatus() types.ENSStatus {
223 if m == nil {
224 return types.ENSStatus{}
225 }
227 - m.ensStatusMu.RLock()
228 - status := m.ensStatus
229 - m.ensStatusMu.RUnlock()
226 + status := types.ENSStatus{}
227 + if m.ensStatus != nil {
228 + status = m.ensStatus.Load()
229 + }
230 if status.Provider == "" && m.dns != nil {
231 status.Provider = m.dns.Name()
232 }
@@ -246,9 +246,11 @@ func (m *Manager) setENSStatus(state, record, message string, syncErr error) {
246 status.LastError = syncErr.Error()
247 }
248
249 - m.ensStatusMu.Lock()
250 - m.ensStatus = status
251 - m.ensStatusMu.Unlock()
249 + if m.ensStatus == nil {
250 + m.ensStatus = utils.NewSnapshot(status)
251 + return
252 + }
253 + m.ensStatus.Store(status)
254 }
255
256 func ensDNSSECVerified(state string) bool {
portal/acme/cloudflare/provider.go
+14 -14
@@ -7,7 +7,6 @@ import (
7 "net/http"
8 "net/url"
9 "strings"
10 - "sync"
10
11 "github.com/go-acme/lego/v4/challenge"
12 "github.com/go-acme/lego/v4/providers/dns/cloudflare"
@@ -22,8 +21,7 @@ const (
21 type Provider struct {
22 token string
23
25 - zoneMu sync.RWMutex
26 - zones map[string]string
24 + zones *utils.Snapshot[map[string]string]
25 }
26
27 type apiError struct {
@@ -80,7 +78,10 @@ type dnssecResult struct {
78 }
79
80 func New(token string) *Provider {
83 - return &Provider{token: strings.TrimSpace(token)}
81 + return &Provider{
82 + token: strings.TrimSpace(token),
83 + zones: utils.NewSnapshot(map[string]string{}, utils.CloneMap[string, string]),
84 + }
85 }
86
87 func (p *Provider) Name() string {
@@ -366,14 +367,12 @@ func (p *Provider) findZoneID(ctx context.Context, domain string) (string, error
367 domain = utils.NormalizeHostname(domain)
368 candidates := utils.DomainCandidates(domain)
369
369 - p.zoneMu.RLock()
370 + zones := p.zones.Load()
371 for _, candidate := range candidates {
371 - if zoneID := p.zones[candidate]; zoneID != "" {
372 - p.zoneMu.RUnlock()
372 + if zoneID := zones[candidate]; zoneID != "" {
373 return zoneID, nil
374 }
375 }
376 - p.zoneMu.RUnlock()
376
377 for _, candidate := range candidates {
378 zones, err := listZones(ctx, p.token, candidate)
@@ -386,12 +385,13 @@ func (p *Provider) findZoneID(ctx context.Context, domain string) (string, error
385 if zoneID == "" {
386 continue
387 }
389 - p.zoneMu.Lock()
390 - if p.zones == nil {
391 - p.zones = make(map[string]string)
392 - }
393 - p.zones[utils.NormalizeHostname(z.Name)] = zoneID
394 - p.zoneMu.Unlock()
388 + zoneName := utils.NormalizeHostname(z.Name)
389 + p.zones.UpdateCopy(func(zones *map[string]string) {
390 + if *zones == nil {
391 + *zones = make(map[string]string)
392 + }
393 + (*zones)[zoneName] = zoneID
394 + })
395 return zoneID, nil
396 }
397 }
portal/acme/gcloud/provider.go
+17 -20
@@ -7,7 +7,6 @@ import (
7 "net/http"
8 "strconv"
9 "strings"
10 - "sync"
10 "time"
11
12 "cloud.google.com/go/compute/metadata"
@@ -32,9 +31,8 @@ type Config struct {
31 }
32
33 type Provider struct {
35 - cfg Config
36 - zoneMu sync.RWMutex
37 - zones map[string]string
34 + cfg Config
35 + zones *utils.Snapshot[map[string]string]
36 }
37
38 type runtimeConfig struct {
@@ -49,6 +47,7 @@ func New(cfg Config) *Provider {
47 ProjectID: strings.TrimSpace(cfg.ProjectID),
48 ManagedZone: strings.TrimSpace(cfg.ManagedZone),
49 },
50 + zones: utils.NewSnapshot(map[string]string{}, utils.CloneMap[string, string]),
51 }
52 }
53
@@ -436,14 +435,12 @@ func (p *Provider) findManagedZone(ctx context.Context, service *dns.Service, pr
435 domain = utils.NormalizeHostname(domain)
436 candidates := utils.DomainCandidates(domain)
437
439 - p.zoneMu.RLock()
438 + zones := p.zones.Load()
439 for _, candidate := range candidates {
441 - if zoneName := p.zones[candidate]; zoneName != "" {
442 - p.zoneMu.RUnlock()
440 + if zoneName := zones[candidate]; zoneName != "" {
441 return &dns.ManagedZone{Name: zoneName, DnsName: fqdn(candidate)}, nil
442 }
443 }
446 - p.zoneMu.RUnlock()
444
445 if explicit = strings.TrimSpace(explicit); explicit != "" {
446 zone, err := service.ManagedZones.Get(projectID, explicit).Context(ctx).Do()
@@ -454,12 +451,12 @@ func (p *Provider) findManagedZone(ctx context.Context, service *dns.Service, pr
451 return nil, err
452 }
453 if zoneDomain := utils.NormalizeHostname(zone.DnsName); zoneDomain != "" && zone.Name != "" {
457 - p.zoneMu.Lock()
458 - if p.zones == nil {
459 - p.zones = make(map[string]string)
460 - }
461 - p.zones[zoneDomain] = zone.Name
462 - p.zoneMu.Unlock()
454 + p.zones.UpdateCopy(func(zones *map[string]string) {
455 + if *zones == nil {
456 + *zones = make(map[string]string)
457 + }
458 + (*zones)[zoneDomain] = zone.Name
459 + })
460 }
461 return zone, nil
462 }
@@ -474,12 +471,12 @@ func (p *Provider) findManagedZone(ctx context.Context, service *dns.Service, pr
471 continue
472 }
473 if zone.Name != "" {
477 - p.zoneMu.Lock()
478 - if p.zones == nil {
479 - p.zones = make(map[string]string)
480 - }
481 - p.zones[candidate] = zone.Name
482 - p.zoneMu.Unlock()
474 + p.zones.UpdateCopy(func(zones *map[string]string) {
475 + if *zones == nil {
476 + *zones = make(map[string]string)
477 + }
478 + (*zones)[candidate] = zone.Name
479 + })
480 }
481 return zone, nil
482 }
portal/acme/route53/provider.go
+13 -16
@@ -6,7 +6,6 @@ import (
6 "fmt"
7 "strconv"
8 "strings"
9 - "sync"
9 "time"
10
11 "github.com/aws/aws-sdk-go-v2/aws"
@@ -35,9 +34,8 @@ type Config struct {
34 }
35
36 type Provider struct {
38 - cfg Config
39 - zoneMu sync.RWMutex
40 - zones map[string]string
37 + cfg Config
38 + zones *utils.Snapshot[map[string]string]
39 }
40
41 func New(cfg Config) *Provider {
@@ -50,6 +48,7 @@ func New(cfg Config) *Provider {
48 HostedZoneID: normalizeZoneID(cfg.HostedZoneID),
49 KMSKeyARN: strings.TrimSpace(cfg.KMSKeyARN),
50 },
51 + zones: utils.NewSnapshot(map[string]string{}, utils.CloneMap[string, string]),
52 }
53 }
54
@@ -371,14 +370,12 @@ func (p *Provider) findHostedZoneID(ctx context.Context, client *awsroute53.Clie
370 return "", fmt.Errorf("invalid base domain for hosted zone lookup: %q", domain)
371 }
372
374 - p.zoneMu.RLock()
373 + zones := p.zones.Load()
374 for _, candidate := range candidates {
376 - if zoneID := p.zones[candidate]; zoneID != "" {
377 - p.zoneMu.RUnlock()
375 + if zoneID := zones[candidate]; zoneID != "" {
376 return zoneID, nil
377 }
378 }
381 - p.zoneMu.RUnlock()
379
380 zonesByName := make(map[string]string)
381 paginator := awsroute53.NewListHostedZonesPaginator(client, &awsroute53.ListHostedZonesInput{})
@@ -401,14 +398,14 @@ func (p *Provider) findHostedZoneID(ctx context.Context, client *awsroute53.Clie
398 }
399
400 if len(zonesByName) > 0 {
404 - p.zoneMu.Lock()
405 - if p.zones == nil {
406 - p.zones = make(map[string]string)
407 - }
408 - for zoneName, zoneID := range zonesByName {
409 - p.zones[zoneName] = zoneID
410 - }
411 - p.zoneMu.Unlock()
401 + p.zones.UpdateCopy(func(zones *map[string]string) {
402 + if *zones == nil {
403 + *zones = make(map[string]string)
404 + }
405 + for zoneName, zoneID := range zonesByName {
406 + (*zones)[zoneName] = zoneID
407 + }
408 + })
409 }
410
411 for _, candidate := range candidates {
portal/acme/vultr/provider.go
+13 -14
@@ -6,7 +6,6 @@ import (
6 "fmt"
7 "strconv"
8 "strings"
9 - "sync"
9
10 "github.com/go-acme/lego/v4/challenge"
11 legovultr "github.com/go-acme/lego/v4/providers/dns/vultr"
@@ -21,12 +20,14 @@ const defaultRecordTTL = 60
20 type Provider struct {
21 apiKey string
22
24 - zoneMu sync.RWMutex
25 - zones map[string]string
23 + zones *utils.Snapshot[map[string]string]
24 }
25
26 func New(apiKey string) *Provider {
29 - return &Provider{apiKey: strings.TrimSpace(apiKey)}
27 + return &Provider{
28 + apiKey: strings.TrimSpace(apiKey),
29 + zones: utils.NewSnapshot(map[string]string{}, utils.CloneMap[string, string]),
30 + }
31 }
32
33 func (p *Provider) Name() string {
@@ -290,14 +291,12 @@ func (p *Provider) findZone(ctx context.Context, client *govultr.Client, domain
291 domain = utils.NormalizeHostname(domain)
292 candidates := utils.DomainCandidates(domain)
293
293 - p.zoneMu.RLock()
294 + zones := p.zones.Load()
295 for _, candidate := range candidates {
295 - if zone := p.zones[candidate]; zone != "" {
296 - p.zoneMu.RUnlock()
296 + if zone := zones[candidate]; zone != "" {
297 return zone, nil
298 }
299 }
300 - p.zoneMu.RUnlock()
300
301 listOptions := &govultr.ListOptions{PerPage: 100}
302 for {
@@ -311,12 +310,12 @@ func (p *Provider) findZone(ctx context.Context, client *govultr.Client, domain
310 if zone != candidate {
311 continue
312 }
314 - p.zoneMu.Lock()
315 - if p.zones == nil {
316 - p.zones = make(map[string]string)
317 - }
318 - p.zones[candidate] = zone
319 - p.zoneMu.Unlock()
313 + p.zones.UpdateCopy(func(zones *map[string]string) {
314 + if *zones == nil {
315 + *zones = make(map[string]string)
316 + }
317 + (*zones)[candidate] = zone
318 + })
319 return zone, nil
320 }
321 }
portal/api_server.go
+11 -9
@@ -104,13 +104,13 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
104 case types.PathSDKConnect:
105 s.handleConnect(w, r)
106 case types.PathDiscovery:
107 - if !s.cfg.DiscoveryEnabled {
107 + if !s.config().DiscoveryEnabled {
108 base.ServeHTTP(w, r)
109 return
110 }
111 s.handleRelayDiscovery(w, r)
112 case types.PathDiscoveryAnnounce:
113 - if !s.cfg.DiscoveryEnabled {
113 + if !s.config().DiscoveryEnabled {
114 base.ServeHTTP(w, r)
115 return
116 }
@@ -212,7 +212,8 @@ func (s *Server) handleRelayDiscoveryAnnounce(w http.ResponseWriter, r *http.Req
212 fmt.Sprintf("self-announce rejected: host %q is local-only", host))
213 return
214 }
215 - if selfURL, err := utils.NormalizeRelayURL(s.cfg.PortalURL); err == nil && desc.APIHTTPSAddr == selfURL {
215 + cfg := s.config()
216 + if selfURL, err := utils.NormalizeRelayURL(cfg.PortalURL); err == nil && desc.APIHTTPSAddr == selfURL {
217 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest,
218 fmt.Sprintf("self-announce rejected: %q matches receiving relay url", desc.APIHTTPSAddr))
219 return
@@ -300,7 +301,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
301 writeAPIErrorResponse(w, err)
302 return
303 }
303 - s.registry.promoteECHDNS(record, s.acmeManager, s.cfg.SNIPort)
304 + s.registry.promoteECHDNS(record, s.acmeManager, s.config().SNIPort)
305
306 utils.WriteAPIData(w, http.StatusCreated, resp)
307 }
@@ -338,11 +339,11 @@ func (s *Server) handleRegisterChallenge(w http.ResponseWriter, r *http.Request)
339 utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
340 return
341 }
341 - if req.UDPEnabled && (!s.cfg.UDPEnabled || s.group != nil && s.quicBackhaul == nil) {
342 + if req.UDPEnabled && !s.supportsUDP() {
343 utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
344 return
345 }
345 - if req.TCPEnabled && !s.cfg.TCPEnabled {
346 + if req.TCPEnabled && !s.supportsTCP() {
347 utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
348 return
349 }
@@ -429,7 +430,8 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
430 utils.InvalidRequestError(err).Write(w)
431 return
432 }
432 - if route.RelayURL != s.cfg.PortalURL {
433 + cfg := s.config()
434 + if route.RelayURL != cfg.PortalURL {
435 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, "hop route relay url does not match receiving relay")
436 return
437 }
@@ -484,7 +486,7 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
486 writeAPIErrorResponse(w, err)
487 return
488 }
487 - s.registry.promoteECHDNS(record, s.acmeManager, s.cfg.SNIPort)
489 + s.registry.promoteECHDNS(record, s.acmeManager, cfg.SNIPort)
490 var accessToken string
491 if record.isPublicEntry() {
492 accessToken, err = s.registry.issueLeaseAccessToken(record, now)
@@ -495,7 +497,7 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
497 }
498 utils.WriteAPIData(w, http.StatusOK, types.HopRouteResponse{
499 AccessToken: accessToken,
498 - SNIPort: s.cfg.SNIPort,
500 + SNIPort: cfg.SNIPort,
501 })
502 }
503
portal/policy/approver.go
+83 -42
@@ -2,7 +2,9 @@ package policy
2
3 import (
4 "fmt"
5 - "sync"
5 + "maps"
6 +
7 + "github.com/gosuda/portal-tunnel/v2/utils"
8 )
9
10 type Mode string
@@ -13,91 +15,126 @@ const (
15 )
16
17 type Approver struct {
18 + state *utils.Snapshot[approvalState]
19 +}
20 +
21 +type approvalState struct {
22 approvedKeys map[string]struct{}
23 deniedKeys map[string]struct{}
24 approvalMode Mode
19 - mu sync.RWMutex
25 }
26
22 -func NewApprover() *Approver {
23 - return &Approver{
27 +func newApprovalState() approvalState {
28 + return approvalState{
29 approvalMode: ModeAuto,
30 approvedKeys: make(map[string]struct{}),
31 deniedKeys: make(map[string]struct{}),
32 }
33 }
34
35 +func (state approvalState) snapshot() approvalState {
36 + state.approvedKeys = maps.Clone(state.approvedKeys)
37 + state.deniedKeys = maps.Clone(state.deniedKeys)
38 + return state
39 +}
40 +
41 +func NewApprover() *Approver {
42 + return &Approver{
43 + state: utils.NewSnapshot(newApprovalState(), approvalState.snapshot),
44 + }
45 +}
46 +
47 +func (a *Approver) current() approvalState {
48 + if a == nil || a.state == nil {
49 + return newApprovalState()
50 + }
51 + return a.state.Load()
52 +}
53 +
54 func (a *Approver) Mode() Mode {
31 - a.mu.RLock()
32 - defer a.mu.RUnlock()
33 - return a.approvalMode
55 + return a.current().approvalMode
56 }
57
58 func (a *Approver) SetMode(mode Mode) error {
59 if mode != ModeAuto && mode != ModeManual {
60 return fmt.Errorf("invalid approval mode: %q", mode)
61 }
40 - a.mu.Lock()
41 - defer a.mu.Unlock()
42 - a.approvalMode = mode
62 + if a == nil || a.state == nil {
63 + return nil
64 + }
65 + a.state.UpdateCopy(func(state *approvalState) {
66 + state.approvalMode = mode
67 + })
68 return nil
69 }
70
71 func (a *Approver) IsApproved(key string) bool {
47 - a.mu.RLock()
48 - defer a.mu.RUnlock()
49 - _, ok := a.approvedKeys[key]
72 + _, ok := a.current().approvedKeys[key]
73 return ok
74 }
75
76 func (a *Approver) Approve(key string) {
54 - a.mu.Lock()
55 - defer a.mu.Unlock()
56 - a.approvedKeys[key] = struct{}{}
57 - delete(a.deniedKeys, key)
77 + if a == nil || a.state == nil {
78 + return
79 + }
80 + a.state.UpdateCopy(func(state *approvalState) {
81 + if state.approvedKeys == nil {
82 + state.approvedKeys = make(map[string]struct{})
83 + }
84 + state.approvedKeys[key] = struct{}{}
85 + delete(state.deniedKeys, key)
86 + })
87 }
88
89 func (a *Approver) Revoke(key string) {
61 - a.mu.Lock()
62 - defer a.mu.Unlock()
63 - delete(a.approvedKeys, key)
90 + if a == nil || a.state == nil {
91 + return
92 + }
93 + a.state.UpdateCopy(func(state *approvalState) {
94 + delete(state.approvedKeys, key)
95 + })
96 }
97
98 func (a *Approver) ApprovedKeys() []string {
67 - a.mu.RLock()
68 - defer a.mu.RUnlock()
69 - out := make([]string, 0, len(a.approvedKeys))
70 - for key := range a.approvedKeys {
99 + approvedKeys := a.current().approvedKeys
100 + out := make([]string, 0, len(approvedKeys))
101 + for key := range approvedKeys {
102 out = append(out, key)
103 }
104 return out
105 }
106
107 func (a *Approver) IsDenied(key string) bool {
77 - a.mu.RLock()
78 - defer a.mu.RUnlock()
79 - _, ok := a.deniedKeys[key]
108 + _, ok := a.current().deniedKeys[key]
109 return ok
110 }
111
112 func (a *Approver) Deny(key string) {
84 - a.mu.Lock()
85 - defer a.mu.Unlock()
86 - a.deniedKeys[key] = struct{}{}
87 - delete(a.approvedKeys, key)
113 + if a == nil || a.state == nil {
114 + return
115 + }
116 + a.state.UpdateCopy(func(state *approvalState) {
117 + if state.deniedKeys == nil {
118 + state.deniedKeys = make(map[string]struct{})
119 + }
120 + state.deniedKeys[key] = struct{}{}
121 + delete(state.approvedKeys, key)
122 + })
123 }
124
125 func (a *Approver) Undeny(key string) {
91 - a.mu.Lock()
92 - defer a.mu.Unlock()
93 - delete(a.deniedKeys, key)
126 + if a == nil || a.state == nil {
127 + return
128 + }
129 + a.state.UpdateCopy(func(state *approvalState) {
130 + delete(state.deniedKeys, key)
131 + })
132 }
133
134 func (a *Approver) DeniedKeys() []string {
97 - a.mu.RLock()
98 - defer a.mu.RUnlock()
99 - out := make([]string, 0, len(a.deniedKeys))
100 - for key := range a.deniedKeys {
135 + deniedKeys := a.current().deniedKeys
136 + out := make([]string, 0, len(deniedKeys))
137 + for key := range deniedKeys {
138 out = append(out, key)
139 }
140 return out
@@ -125,8 +162,12 @@ func (a *Approver) SetDecisions(approvedKeys, deniedKeys []string) {
162 denied[key] = struct{}{}
163 }
164
128 - a.mu.Lock()
129 - a.approvedKeys = approved
130 - a.deniedKeys = denied
131 - a.mu.Unlock()
165 + if a.state == nil {
166 + return
167 + }
168 + a.state.Update(func(state approvalState) approvalState {
169 + state.approvedKeys = approved
170 + state.deniedKeys = denied
171 + return state
172 + })
173 }
portal/policy/bps_manager.go
+42 -23
@@ -1,20 +1,21 @@
1 package policy
2
3 import (
4 - "maps"
4 "sync"
5 "time"
6 +
7 + "github.com/gosuda/portal-tunnel/v2/utils"
8 )
9
10 type BPSManager struct {
10 - identityBPS map[string]int64
11 + identityBPS *utils.Snapshot[map[string]int64]
12 identityLimiters map[string]*bpsLimiter
13 mu sync.RWMutex
14 }
15
16 func NewBPSManager() *BPSManager {
17 return &BPSManager{
17 - identityBPS: make(map[string]int64),
18 + identityBPS: utils.NewSnapshot(map[string]int64{}, utils.CloneMap[string, int64]),
19 identityLimiters: make(map[string]*bpsLimiter),
20 }
21 }
@@ -23,10 +24,10 @@ func (m *BPSManager) IdentityBPS(key string) int64 {
24 if m == nil || key == "" {
25 return 0
26 }
26 -
27 - m.mu.RLock()
28 - defer m.mu.RUnlock()
29 - return m.identityBPS[key]
27 + if m.identityBPS == nil {
28 + return 0
29 + }
30 + return m.identityBPS.Load()[key]
31 }
32
33 func (m *BPSManager) SetIdentityBPS(key string, bps int64) {
@@ -34,14 +35,24 @@ func (m *BPSManager) SetIdentityBPS(key string, bps int64) {
35 return
36 }
37
37 - m.mu.Lock()
38 - defer m.mu.Unlock()
38 + if m.identityBPS == nil {
39 + return
40 + }
41 if bps <= 0 {
40 - delete(m.identityBPS, key)
42 + m.identityBPS.UpdateCopy(func(limits *map[string]int64) {
43 + delete(*limits, key)
44 + })
45 + m.mu.Lock()
46 delete(m.identityLimiters, key)
47 + m.mu.Unlock()
48 return
49 }
44 - m.identityBPS[key] = bps
50 + m.identityBPS.UpdateCopy(func(limits *map[string]int64) {
51 + if *limits == nil {
52 + *limits = make(map[string]int64)
53 + }
54 + (*limits)[key] = bps
55 + })
56 }
57
58 func (m *BPSManager) DeleteIdentityBPS(key string) {
@@ -49,10 +60,14 @@ func (m *BPSManager) DeleteIdentityBPS(key string) {
60 return
61 }
62
63 + if m.identityBPS != nil {
64 + m.identityBPS.UpdateCopy(func(limits *map[string]int64) {
65 + delete(*limits, key)
66 + })
67 + }
68 m.mu.Lock()
53 - defer m.mu.Unlock()
54 - delete(m.identityBPS, key)
69 delete(m.identityLimiters, key)
70 + m.mu.Unlock()
71 }
72
73 func (m *BPSManager) IdentityBPSLimits() map[string]int64 {
@@ -60,12 +75,10 @@ func (m *BPSManager) IdentityBPSLimits() map[string]int64 {
75 return nil
76 }
77
63 - m.mu.RLock()
64 - defer m.mu.RUnlock()
65 -
66 - out := make(map[string]int64, len(m.identityBPS))
67 - maps.Copy(out, m.identityBPS)
68 - return out
78 + if m.identityBPS == nil {
79 + return nil
80 + }
81 + return m.identityBPS.Load()
82 }
83
84 func (m *BPSManager) SetIdentityBPSLimits(limits map[string]int64) {
@@ -81,8 +94,10 @@ func (m *BPSManager) SetIdentityBPSLimits(limits map[string]int64) {
94 next[key] = bps
95 }
96
97 + if m.identityBPS != nil {
98 + m.identityBPS.Store(next)
99 + }
100 m.mu.Lock()
85 - m.identityBPS = next
101 m.identityLimiters = make(map[string]*bpsLimiter)
102 m.mu.Unlock()
103 }
@@ -107,18 +122,22 @@ func (m *BPSManager) ThrottleIdentityBPS(key string, maxBytes int) int {
122 }
123
124 func (m *BPSManager) identityLimiter(key string) (int64, *bpsLimiter) {
125 + bps := m.IdentityBPS(key)
126 + if bps <= 0 {
127 + return 0, nil
128 + }
129 +
130 m.mu.RLock()
111 - bps := m.identityBPS[key]
131 limiter := m.identityLimiters[key]
132 m.mu.RUnlock()
114 - if bps <= 0 || limiter != nil {
133 + if limiter != nil {
134 return bps, limiter
135 }
136
137 m.mu.Lock()
138 defer m.mu.Unlock()
139
121 - bps = m.identityBPS[key]
140 + bps = m.IdentityBPS(key)
141 if bps <= 0 {
142 return 0, nil
143 }
portal/policy/ip_filter.go
+37 -19
@@ -4,10 +4,12 @@ import (
4 "slices"
5 "strings"
6 "sync"
7 +
8 + "github.com/gosuda/portal-tunnel/v2/utils"
9 )
10
11 type IPFilter struct {
10 - bannedIPs map[string]struct{}
12 + bannedIPs *utils.Snapshot[map[string]struct{}]
13 identityToIP map[string]string
14 ipToIdentities map[string][]string
15 mu sync.RWMutex
@@ -15,52 +17,68 @@ type IPFilter struct {
17
18 func NewIPFilter() *IPFilter {
19 return &IPFilter{
18 - bannedIPs: make(map[string]struct{}),
20 + bannedIPs: utils.NewSnapshot(map[string]struct{}{}, utils.CloneMap[string, struct{}]),
21 identityToIP: make(map[string]string),
22 ipToIdentities: make(map[string][]string),
23 }
24 }
25
26 func (f *IPFilter) BanIP(ip string) {
25 - f.mu.Lock()
26 - defer f.mu.Unlock()
27 - f.bannedIPs[strings.TrimSpace(ip)] = struct{}{}
27 + if f == nil || f.bannedIPs == nil {
28 + return
29 + }
30 + ip = strings.TrimSpace(ip)
31 + f.bannedIPs.UpdateCopy(func(ips *map[string]struct{}) {
32 + if *ips == nil {
33 + *ips = make(map[string]struct{})
34 + }
35 + (*ips)[ip] = struct{}{}
36 + })
37 }
38
39 func (f *IPFilter) UnbanIP(ip string) {
31 - f.mu.Lock()
32 - defer f.mu.Unlock()
33 - delete(f.bannedIPs, strings.TrimSpace(ip))
40 + if f == nil || f.bannedIPs == nil {
41 + return
42 + }
43 + ip = strings.TrimSpace(ip)
44 + f.bannedIPs.UpdateCopy(func(ips *map[string]struct{}) {
45 + delete(*ips, ip)
46 + })
47 }
48
49 func (f *IPFilter) IsIPBanned(ip string) bool {
37 - f.mu.RLock()
38 - defer f.mu.RUnlock()
39 - _, ok := f.bannedIPs[strings.TrimSpace(ip)]
50 + if f == nil || f.bannedIPs == nil {
51 + return false
52 + }
53 + _, ok := f.bannedIPs.Load()[strings.TrimSpace(ip)]
54 return ok
55 }
56
57 func (f *IPFilter) BannedIPs() []string {
44 - f.mu.RLock()
45 - defer f.mu.RUnlock()
46 - out := make([]string, 0, len(f.bannedIPs))
47 - for ip := range f.bannedIPs {
58 + if f == nil || f.bannedIPs == nil {
59 + return nil
60 + }
61 + ips := f.bannedIPs.Load()
62 + out := make([]string, 0, len(ips))
63 + for ip := range ips {
64 out = append(out, ip)
65 }
66 return out
67 }
68
69 func (f *IPFilter) SetBannedIPs(ips []string) {
54 - f.mu.Lock()
55 - defer f.mu.Unlock()
56 - f.bannedIPs = make(map[string]struct{}, len(ips))
70 + if f == nil || f.bannedIPs == nil {
71 + return
72 + }
73 + bannedIPs := make(map[string]struct{}, len(ips))
74 for _, ip := range ips {
75 ip = strings.TrimSpace(ip)
76 if ip == "" {
77 continue
78 }
62 - f.bannedIPs[ip] = struct{}{}
79 + bannedIPs[ip] = struct{}{}
80 }
81 + f.bannedIPs.Store(bannedIPs)
82 }
83
84 func (f *IPFilter) RegisterIdentityIP(key, ip string) {
portal/policy/proxy_trust.go
+5 -6
@@ -44,12 +44,11 @@ func (r *Runtime) ExtractClientIP(req *http.Request) string {
44 return ""
45 }
46
47 - r.mu.RLock()
48 - trustProxyHeaders := r.trustProxyHeaders
49 - trustedProxyCIDRs := append([]*net.IPNet(nil), r.trustedProxyCIDRs...)
50 - r.mu.RUnlock()
51 -
52 - if trustProxyHeaders && isTrustedProxyRemoteAddr(req.RemoteAddr, trustedProxyCIDRs) {
47 + cfg := runtimeConfig{}
48 + if r.config != nil {
49 + cfg = r.config.Load()
50 + }
51 + if cfg.trustProxyHeaders && isTrustedProxyRemoteAddr(req.RemoteAddr, cfg.trustedProxyCIDRs) {
52 if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
53 if before, _, ok := strings.Cut(xff, ","); ok {
54 if ip := normalizeClientIPCandidate(before); ip != "" {
portal/policy/runtime.go
+80 -55
@@ -3,7 +3,6 @@ package policy
3 import (
4 "fmt"
5 "net"
6 - "sync"
6
7 "github.com/gosuda/portal-tunnel/v2/utils"
8 )
@@ -21,27 +20,37 @@ func (p *PortPolicy) Set(enabled bool, maxLeases int) {
20 p.maxLeases = maxLeases
21 }
22
23 +type runtimeConfig struct {
24 + udp PortPolicy
25 + tcpPort PortPolicy
26 + trustProxyHeaders bool
27 + trustedProxyCIDRs []*net.IPNet
28 +}
29 +
30 +func (cfg runtimeConfig) snapshot() runtimeConfig {
31 + cfg.trustedProxyCIDRs = utils.CloneSlice(cfg.trustedProxyCIDRs)
32 + return cfg
33 +}
34 +
35 type Runtime struct {
36 approver *Approver
37 bpsManager *BPSManager
38 ipFilter *IPFilter
28 - bannedIdentityKeys map[string]struct{}
29 - udp PortPolicy
30 - tcpPort PortPolicy
31 - trustProxyHeaders bool
32 - trustedProxyCIDRs []*net.IPNet
33 - mu sync.RWMutex
39 + config *utils.Snapshot[runtimeConfig]
40 + bannedIdentityKeys *utils.Snapshot[map[string]struct{}]
41 }
42
43 func NewRuntime(udpEnabled, tcpPortEnabled bool, trustProxyHeaders bool, rawTrustedProxyCIDRs string) (*Runtime, error) {
44 runtime := &Runtime{
38 - approver: NewApprover(),
39 - bpsManager: NewBPSManager(),
40 - ipFilter: NewIPFilter(),
41 - bannedIdentityKeys: make(map[string]struct{}),
45 + approver: NewApprover(),
46 + bpsManager: NewBPSManager(),
47 + ipFilter: NewIPFilter(),
48 + config: utils.NewSnapshot(runtimeConfig{
49 + udp: PortPolicy{enabled: udpEnabled},
50 + tcpPort: PortPolicy{enabled: tcpPortEnabled},
51 + }, runtimeConfig.snapshot),
52 + bannedIdentityKeys: utils.NewSnapshot(map[string]struct{}{}, utils.CloneMap[string, struct{}]),
53 }
43 - runtime.udp.Set(udpEnabled, 0)
44 - runtime.tcpPort.Set(tcpPortEnabled, 0)
54 if err := runtime.SetProxyTrust(trustProxyHeaders, rawTrustedProxyCIDRs); err != nil {
55 return nil, err
56 }
@@ -61,44 +70,50 @@ func (r *Runtime) BPSManager() *BPSManager {
70 }
71
72 func (r *Runtime) BanIdentity(key string) {
64 - if key == "" {
73 + if r == nil || r.bannedIdentityKeys == nil || key == "" {
74 return
75 }
67 - r.mu.Lock()
68 - defer r.mu.Unlock()
69 - r.bannedIdentityKeys[key] = struct{}{}
76 + r.bannedIdentityKeys.UpdateCopy(func(keys *map[string]struct{}) {
77 + if *keys == nil {
78 + *keys = make(map[string]struct{})
79 + }
80 + (*keys)[key] = struct{}{}
81 + })
82 }
83
84 func (r *Runtime) UnbanIdentity(key string) {
73 - if key == "" {
85 + if r == nil || r.bannedIdentityKeys == nil || key == "" {
86 return
87 }
76 - r.mu.Lock()
77 - defer r.mu.Unlock()
78 - delete(r.bannedIdentityKeys, key)
88 + r.bannedIdentityKeys.UpdateCopy(func(keys *map[string]struct{}) {
89 + delete(*keys, key)
90 + })
91 }
92
93 func (r *Runtime) IsIdentityBanned(key string) bool {
82 - if key == "" {
94 + if r == nil || r.bannedIdentityKeys == nil || key == "" {
95 return false
96 }
85 - r.mu.RLock()
86 - defer r.mu.RUnlock()
87 - _, ok := r.bannedIdentityKeys[key]
97 + _, ok := r.bannedIdentityKeys.Load()[key]
98 return ok
99 }
100
101 func (r *Runtime) BannedIdentityKeys() []string {
92 - r.mu.RLock()
93 - defer r.mu.RUnlock()
94 - out := make([]string, 0, len(r.bannedIdentityKeys))
95 - for key := range r.bannedIdentityKeys {
102 + if r == nil || r.bannedIdentityKeys == nil {
103 + return nil
104 + }
105 + keys := r.bannedIdentityKeys.Load()
106 + out := make([]string, 0, len(keys))
107 + for key := range keys {
108 out = append(out, key)
109 }
110 return out
111 }
112
113 func (r *Runtime) SetBannedIdentityKeys(keys []string) {
114 + if r == nil || r.bannedIdentityKeys == nil {
115 + return
116 + }
117 bannedIdentityKeys := make(map[string]struct{}, len(keys))
118 for _, key := range keys {
119 if key == "" {
@@ -107,9 +122,7 @@ func (r *Runtime) SetBannedIdentityKeys(keys []string) {
122 bannedIdentityKeys[key] = struct{}{}
123 }
124
110 - r.mu.Lock()
111 - r.bannedIdentityKeys = bannedIdentityKeys
112 - r.mu.Unlock()
125 + r.bannedIdentityKeys.Store(bannedIdentityKeys)
126 }
127
128 func (r *Runtime) EffectiveApproval(key string) bool {
@@ -137,27 +150,35 @@ func (r *Runtime) IsIdentityRoutable(key string) bool {
150 }
151
152 func (r *Runtime) SetUDPPolicy(enabled bool, maxLeases int) {
140 - r.mu.Lock()
141 - r.udp.Set(enabled, maxLeases)
142 - r.mu.Unlock()
153 + if r == nil || r.config == nil {
154 + return
155 + }
156 + r.config.UpdateCopy(func(cfg *runtimeConfig) {
157 + cfg.udp.Set(enabled, maxLeases)
158 + })
159 }
160
161 func (r *Runtime) IsUDPEnabled() bool {
146 - r.mu.RLock()
147 - defer r.mu.RUnlock()
148 - return r.udp.IsEnabled()
162 + if r == nil || r.config == nil {
163 + return false
164 + }
165 + return r.config.Load().udp.IsEnabled()
166 }
167
168 func (r *Runtime) UDPMaxLeases() int {
152 - r.mu.RLock()
153 - defer r.mu.RUnlock()
154 - return r.udp.MaxLeases()
169 + if r == nil || r.config == nil {
170 + return 0
171 + }
172 + return r.config.Load().udp.MaxLeases()
173 }
174
175 func (r *Runtime) SetTCPPortPolicy(enabled bool, maxLeases int) {
158 - r.mu.Lock()
159 - r.tcpPort.Set(enabled, maxLeases)
160 - r.mu.Unlock()
176 + if r == nil || r.config == nil {
177 + return
178 + }
179 + r.config.UpdateCopy(func(cfg *runtimeConfig) {
180 + cfg.tcpPort.Set(enabled, maxLeases)
181 + })
182 }
183
184 func (r *Runtime) SetProxyTrust(trustProxyHeaders bool, rawTrustedProxyCIDRs string) error {
@@ -168,24 +189,28 @@ func (r *Runtime) SetProxyTrust(trustProxyHeaders bool, rawTrustedProxyCIDRs str
189 if err != nil {
190 return fmt.Errorf("parse trusted proxy cidrs: %w", err)
191 }
171 - copied := append([]*net.IPNet(nil), trustedProxyCIDRs...)
172 - r.mu.Lock()
173 - r.trustProxyHeaders = trustProxyHeaders
174 - r.trustedProxyCIDRs = copied
175 - r.mu.Unlock()
192 + if r.config == nil {
193 + r.config = utils.NewSnapshot(runtimeConfig{}, runtimeConfig.snapshot)
194 + }
195 + r.config.UpdateCopy(func(cfg *runtimeConfig) {
196 + cfg.trustProxyHeaders = trustProxyHeaders
197 + cfg.trustedProxyCIDRs = append([]*net.IPNet(nil), trustedProxyCIDRs...)
198 + })
199 return nil
200 }
201
202 func (r *Runtime) IsTCPPortEnabled() bool {
180 - r.mu.RLock()
181 - defer r.mu.RUnlock()
182 - return r.tcpPort.IsEnabled()
203 + if r == nil || r.config == nil {
204 + return false
205 + }
206 + return r.config.Load().tcpPort.IsEnabled()
207 }
208
209 func (r *Runtime) TCPPortMaxLeases() int {
186 - r.mu.RLock()
187 - defer r.mu.RUnlock()
188 - return r.tcpPort.MaxLeases()
210 + if r == nil || r.config == nil {
211 + return 0
212 + }
213 + return r.config.Load().tcpPort.MaxLeases()
214 }
215
216 func (r *Runtime) ForgetIdentity(key string) {
portal/server.go
+78 -24
@@ -103,17 +103,26 @@ func normalizeServerConfig(cfg ServerConfig) (ServerConfig, error) {
103 }
104 }
105
106 - cfg.UDPEnabled = cfg.UDPEnabled && hasPortRange
107 - cfg.TCPEnabled = cfg.TCPEnabled && hasPortRange
106 + cfg.UDPEnabled = cfg.UDPEnabled && cfg.hasLeasePortRange()
107 + cfg.TCPEnabled = cfg.TCPEnabled && cfg.hasLeasePortRange()
108 return cfg, nil
109 }
110
111 +func (cfg ServerConfig) snapshot() ServerConfig {
112 + cfg.Bootstraps = utils.CloneSlice(cfg.Bootstraps)
113 + return cfg
114 +}
115 +
116 +func (cfg ServerConfig) hasLeasePortRange() bool {
117 + return cfg.MinPort > 0 && cfg.MaxPort > 0 && cfg.MinPort <= 65535 && cfg.MaxPort <= 65535 && cfg.MinPort <= cfg.MaxPort
118 +}
119 +
120 type Server struct {
121 cancel context.CancelFunc
122 group *errgroup.Group
123 shutdownOnce sync.Once
124
116 - cfg ServerConfig
125 + cfg *utils.Snapshot[ServerConfig]
126 identity types.RelayIdentity
127 authority identity.Authority
128 acmeManager *acme.Manager
@@ -162,7 +171,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
171 }
172
173 server := &Server{
165 - cfg: cfg,
174 + cfg: utils.NewSnapshot(cfg, ServerConfig.snapshot),
175 identity: relayIdentity,
176 authority: relayAuthority,
177 registry: registry,
@@ -173,10 +182,52 @@ func NewServer(cfg ServerConfig) (*Server, error) {
182 return server, nil
183 }
184
185 +func (s *Server) config() ServerConfig {
186 + return s.cfg.Load()
187 +}
188 +
189 +func (s *Server) SetUDPPolicy(enabled bool, maxLeases int) {
190 + if enabled && !s.config().hasLeasePortRange() {
191 + enabled = false
192 + }
193 + if runtime := s.PolicyRuntime(); runtime != nil {
194 + runtime.SetUDPPolicy(enabled, maxLeases)
195 + }
196 + s.cfg.UpdateCopy(func(cfg *ServerConfig) {
197 + cfg.UDPEnabled = enabled
198 + })
199 +}
200 +
201 +func (s *Server) SetTCPPortPolicy(enabled bool, maxLeases int) {
202 + if enabled && !s.config().hasLeasePortRange() {
203 + enabled = false
204 + }
205 + if runtime := s.PolicyRuntime(); runtime != nil {
206 + runtime.SetTCPPortPolicy(enabled, maxLeases)
207 + }
208 + s.cfg.UpdateCopy(func(cfg *ServerConfig) {
209 + cfg.TCPEnabled = enabled
210 + })
211 +}
212 +
213 +func (s *Server) supportsUDP() bool {
214 + runtime := s.PolicyRuntime()
215 + if runtime == nil || !runtime.IsUDPEnabled() {
216 + return false
217 + }
218 + return s.group == nil || s.quicBackhaul != nil
219 +}
220 +
221 +func (s *Server) supportsTCP() bool {
222 + runtime := s.PolicyRuntime()
223 + return runtime != nil && runtime.IsTCPPortEnabled()
224 +}
225 +
226 func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
227 if s.group != nil {
228 return errors.New("server already started")
229 }
230 + cfg := s.config()
231 apiTLS, acmeManager, err := s.prepareAPITLS(ctx)
232 if err != nil {
233 return err
@@ -222,11 +273,11 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
273 }()
274 var listenConfig net.ListenConfig
275
225 - apiListener, err = listenConfig.Listen(serverCtx, "tcp", s.cfg.APIListenAddr)
276 + apiListener, err = listenConfig.Listen(serverCtx, "tcp", cfg.APIListenAddr)
277 if err != nil {
278 return fmt.Errorf("listen api: %w", err)
279 }
229 - sniListener, err = listenConfig.Listen(serverCtx, "tcp", s.cfg.SNIListenAddr)
280 + sniListener, err = listenConfig.Listen(serverCtx, "tcp", cfg.SNIListenAddr)
281 if err != nil {
282 return fmt.Errorf("listen sni: %w", err)
283 }
@@ -236,8 +287,8 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
287 if err != nil {
288 return err
289 }
239 - if s.cfg.PProfEnabled {
240 - pprofListener, err = listenConfig.Listen(serverCtx, "tcp", s.cfg.PProfListenAddr)
290 + if cfg.PProfEnabled {
291 + pprofListener, err = listenConfig.Listen(serverCtx, "tcp", cfg.PProfListenAddr)
292 if err != nil {
293 return fmt.Errorf("listen pprof: %w", err)
294 }
@@ -259,7 +310,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
310 return err
311 }
312 }
262 - if s.cfg.UDPEnabled {
313 + if cfg.UDPEnabled {
314 quicBackhaul, err = s.newQUICBackhaulListener(apiTLS)
315 if err != nil {
316 log.Warn().Err(err).Msg("quic backhaul listener disabled")
@@ -292,7 +343,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
343 group.Go(s.runQUICBackhaulListener)
344 }
345 group.Go(func() error { return s.runRegistryJanitor(groupCtx, 5*time.Second) })
295 - if s.cfg.DiscoveryEnabled {
346 + if cfg.DiscoveryEnabled {
347 group.Go(func() error { return s.runRelayDiscoveryLoop(groupCtx) })
348 }
349 s.acmeManager.Start(serverCtx)
@@ -307,14 +358,14 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
358 Str("api_addr", utils.HostPortOrLoopback(s.apiListener.Addr().String())).
359 Str("sni_addr", s.sniListener.Addr().String()).
360 Str("root_host", s.identity.Name).
310 - Str("acme_dns_provider", s.cfg.ACME.DNSProvider).
311 - Int("min_port", s.cfg.MinPort).
312 - Int("max_port", s.cfg.MaxPort).
313 - Bool("discovery_enabled", s.cfg.DiscoveryEnabled).
361 + Str("acme_dns_provider", cfg.ACME.DNSProvider).
362 + Int("min_port", cfg.MinPort).
363 + Int("max_port", cfg.MaxPort).
364 + Bool("discovery_enabled", cfg.DiscoveryEnabled).
365 Bool("wireguard_enabled", s.overlay != nil).
366 Bool("multihop_enabled", s.overlay != nil).
367 Bool("udp_enabled", s.quicBackhaul != nil).
317 - Bool("tcp_enabled", s.cfg.TCPEnabled).
368 + Bool("tcp_enabled", s.supportsTCP()).
369 Bool("api_ech_enabled", len(apiTLS.EncryptedClientHelloKeys) > 0).
370 Bool("pprof_enabled", s.pprofServer != nil)
371 if s.pprofListener != nil {
@@ -350,7 +401,7 @@ func (s *Server) PortalURL() string {
401 if s == nil {
402 return ""
403 }
353 - return s.cfg.PortalURL
404 + return s.config().PortalURL
405 }
406
407 func (s *Server) PublicLeases() []types.Lease {
@@ -420,7 +471,8 @@ func (s *Server) Shutdown(ctx context.Context) error {
471 }
472
473 func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig, *acme.Manager, error) {
423 - acmeCfg := s.cfg.ACME
474 + cfg := s.config()
475 + acmeCfg := cfg.ACME
476 if baseDomain := utils.NormalizeHostname(acmeCfg.BaseDomain); baseDomain != "" && baseDomain != s.identity.Name {
477 return keyless.TLSMaterialConfig{}, nil, fmt.Errorf("acme base domain %q does not match portal root host %q", acmeCfg.BaseDomain, s.identity.Name)
478 }
@@ -461,7 +513,7 @@ func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig,
513 }
514 if len(echKeys) > 0 {
515 apiTLS.EncryptedClientHelloKeys = echKeys
464 - if err := manager.SyncECHConfig(ctx, s.identity.Name, echConfigList, s.cfg.SNIPort); err != nil {
516 + if err := manager.SyncECHConfig(ctx, s.identity.Name, echConfigList, cfg.SNIPort); err != nil {
517 log.Warn().
518 Err(err).
519 Str("hostname", s.identity.Name).
@@ -627,7 +679,7 @@ func (s *Server) newQUICBackhaulListener(apiTLS keyless.TLSMaterialConfig) (*qui
679 if err != nil {
680 return nil, fmt.Errorf("parse quic backhaul tls keypair: %w", err)
681 }
630 - return transport.ListenQUICBackhaul(s.cfg.SNIListenAddr, tlsCert)
682 + return transport.ListenQUICBackhaul(s.config().SNIListenAddr, tlsCert)
683 }
684
685 func (s *Server) runQUICBackhaulListener() error {
@@ -686,17 +738,18 @@ func (s *Server) handleQUICBackhaulConn(conn *quic.Conn) {
738 }
739
740 func (s *Server) startOverlay() (*overlay.Overlay, error) {
741 + cfg := s.config()
742 peerMux := http.NewServeMux()
743 peerMux.HandleFunc(types.PathRoot, s.handleRoot)
744 peerMux.HandleFunc(types.PathHealthz, s.handleHealthz)
692 - if s.cfg.DiscoveryEnabled {
745 + if cfg.DiscoveryEnabled {
746 peerMux.HandleFunc(types.PathDiscovery, s.handleRelayDiscovery)
747 }
748
749 ov, err := overlay.NewOverlay(overlay.Config{
750 PrivateKey: s.identity.WireGuardPrivateKey,
751 PublicKey: s.identity.WireGuardPublicKey,
699 - ListenPort: s.cfg.WireGuardPort,
752 + ListenPort: cfg.WireGuardPort,
753 }, peerMux, nil)
754 if err != nil {
755 return nil, fmt.Errorf("start wireguard overlay: %w", err)
@@ -770,6 +823,7 @@ func (s *Server) newSelfDescriptor(now time.Time) (types.RelayDescriptor, error)
823 } else {
824 now = now.UTC()
825 }
826 + cfg := s.config()
827
828 var wireGuardPublicKey string
829 var wireGuardPort int
@@ -786,12 +840,12 @@ func (s *Server) newSelfDescriptor(now time.Time) (types.RelayDescriptor, error)
840 Version: types.DiscoveryVersion,
841 IssuedAt: now,
842 ExpiresAt: now.Add(discovery.DiscoveryDescriptorTTL),
789 - APIHTTPSAddr: s.cfg.PortalURL,
843 + APIHTTPSAddr: cfg.PortalURL,
844 WireGuardPublicKey: wireGuardPublicKey,
845 WireGuardPort: wireGuardPort,
846 SupportsOverlay: supportsOverlay,
793 - SupportsUDP: s.cfg.UDPEnabled && s.quicBackhaul != nil,
794 - SupportsTCP: s.cfg.TCPEnabled,
847 + SupportsUDP: s.supportsUDP(),
848 + SupportsTCP: s.supportsTCP(),
849 ActiveConnections: s.proxy.activeConnectionCount(),
850 TCPBPS: s.proxy.currentTCPBPS(now),
851 }, s.authority)
portal/server_test.go
+6 -6
@@ -292,8 +292,8 @@ func TestRegisterLeaseIncludesSNIPortForPublicIngress(t *testing.T) {
292 record.Close()
293 })
294
295 - if resp.SNIPort != server.cfg.SNIPort {
296 - t.Fatalf("RegisterResponse.SNIPort = %d, want %d", resp.SNIPort, server.cfg.SNIPort)
295 + if resp.SNIPort != server.config().SNIPort {
296 + t.Fatalf("RegisterResponse.SNIPort = %d, want %d", resp.SNIPort, server.config().SNIPort)
297 }
298 }
299
@@ -411,7 +411,7 @@ func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
411 if err != nil {
412 t.Fatalf("NewServer() error = %v", err)
413 }
414 - server.registry.policy.SetUDPPolicy(true, 0)
414 + server.SetUDPPolicy(true, 0)
415
416 record, resp, err := server.registry.Register(types.RegisterChallengeRequest{
417 Identity: types.Identity{
@@ -436,8 +436,8 @@ func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
436 if got := record.datagram.UDPPort(); got < 40000 || got > 40009 {
437 t.Fatalf("UDPPort() = %d, want port within %d-%d", got, 40000, 40009)
438 }
439 - if resp.SNIPort != server.cfg.SNIPort {
440 - t.Fatalf("RegisterResponse.SNIPort = %d, want %d", resp.SNIPort, server.cfg.SNIPort)
439 + if resp.SNIPort != server.config().SNIPort {
440 + t.Fatalf("RegisterResponse.SNIPort = %d, want %d", resp.SNIPort, server.config().SNIPort)
441 }
442 if resp.UDPAddr == "" {
443 t.Fatal("RegisterResponse.UDPAddr = empty, want public udp address")
@@ -476,7 +476,7 @@ func TestServerStartHidesDiscoveryRoutesWhenDisabled(t *testing.T) {
476 if resp.StatusCode != http.StatusNotFound {
477 t.Fatalf("GET relay discovery status = %d, want %d", resp.StatusCode, http.StatusNotFound)
478 }
479 - if server.cfg.DiscoveryEnabled {
479 + if server.config().DiscoveryEnabled {
480 t.Fatal("cfg.DiscoveryEnabled = true, want false without configured discovery service")
481 }
482 }
sdk/expose.go
+58 -71
@@ -27,8 +27,7 @@ type Exposure struct {
27 cancel context.CancelFunc
28 done <-chan struct{}
29
30 - cfgMu sync.RWMutex
31 - cfg ExposeConfig
30 + cfg *utils.Snapshot[ExposeConfig]
31
32 accepted chan net.Conn
33 datagrams chan types.DatagramFrame
@@ -59,32 +58,14 @@ type ExposeConfig struct {
58 Metadata types.LeaseMetadata
59 }
60
62 -func (cfg ExposeConfig) clone() ExposeConfig {
63 - cfg.RelayURLs = append([]string(nil), cfg.RelayURLs...)
61 +func (cfg ExposeConfig) snapshot() ExposeConfig {
62 + cfg.RelayURLs = utils.CloneSlice(cfg.RelayURLs)
63 cfg.Identity = cfg.Identity.Copy()
65 - cfg.MultiHop = append([]string(nil), cfg.MultiHop...)
64 + cfg.MultiHop = utils.CloneSlice(cfg.MultiHop)
65 cfg.Metadata = cfg.Metadata.Copy()
66 return cfg
67 }
68
70 -func (e *Exposure) config() ExposeConfig {
71 - if e == nil {
72 - return ExposeConfig{}
73 - }
74 - e.cfgMu.RLock()
75 - defer e.cfgMu.RUnlock()
76 - return e.cfg.clone()
77 -}
78 -
79 -func (e *Exposure) metadata() types.LeaseMetadata {
80 - if e == nil {
81 - return types.LeaseMetadata{}
82 - }
83 - e.cfgMu.RLock()
84 - defer e.cfgMu.RUnlock()
85 - return e.cfg.Metadata.Copy()
86 -}
87 -
69 // Expose creates relay listeners for the selected relay pool and exposes a
70 // dynamic listener hub for accepting traffic from all of them.
71 func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
@@ -160,7 +141,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
141 return nil, fmt.Errorf("invalid --udp-addr value %q: %w", cfg.UDPAddr, err)
142 }
143 }
163 - runtimeCfg := cfg.clone()
144 + runtimeCfg := cfg.snapshot()
145 runtimeCfg.RelayURLs = append([]string(nil), explicitRelayURLs...)
146 runtimeCfg.Identity = listenerIdentity.Copy()
147 runtimeCfg.TargetAddr = targetAddr
@@ -172,7 +153,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
153 exposure := &Exposure{
154 cancel: cancel,
155 done: exposureCtx.Done(),
175 - cfg: runtimeCfg.clone(),
156 + cfg: utils.NewSnapshot(runtimeCfg, ExposeConfig.snapshot),
157 accepted: make(chan net.Conn, max(initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)*defaultReadyTarget*2, 1)),
158 datagrams: make(chan types.DatagramFrame, max(initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)*32, 1)),
159 relaySet: discovery.NewRelaySet(relaySetURLs),
@@ -220,11 +201,11 @@ func (e *Exposure) AddRelay(relayURL string) error {
201 return errors.New("exposure relay set is not initialized")
202 }
203
223 - e.cfgMu.Lock()
224 - if !slices.Contains(e.cfg.RelayURLs, relayURL) {
225 - e.cfg.RelayURLs = append(append([]string(nil), e.cfg.RelayURLs...), relayURL)
226 - }
227 - e.cfgMu.Unlock()
204 + e.cfg.UpdateCopy(func(cfg *ExposeConfig) {
205 + if !slices.Contains(cfg.RelayURLs, relayURL) {
206 + cfg.RelayURLs = append(cfg.RelayURLs, relayURL)
207 + }
208 + })
209
210 e.relaySet.AllowRelayURL(relayURL)
211 e.relaySet.AddBootstrapRelayURL(relayURL)
@@ -245,19 +226,21 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
226 return errors.New("exposure relay set is not initialized")
227 }
228
248 - e.cfgMu.Lock()
249 - if slices.Contains(e.cfg.MultiHop, relayURL) {
250 - e.cfgMu.Unlock()
251 - return errors.New("relay is part of the multi-hop route; clear multi-hop first")
252 - }
253 - nextRelays := make([]string, 0, len(e.cfg.RelayURLs))
254 - for _, existing := range e.cfg.RelayURLs {
255 - if existing != relayURL {
256 - nextRelays = append(nextRelays, existing)
229 + if _, ok := e.cfg.UpdateIf(func(cfg ExposeConfig) (ExposeConfig, bool) {
230 + if slices.Contains(cfg.MultiHop, relayURL) {
231 + return cfg, false
232 }
233 + nextRelays := cfg.RelayURLs[:0]
234 + for _, existing := range cfg.RelayURLs {
235 + if existing != relayURL {
236 + nextRelays = append(nextRelays, existing)
237 + }
238 + }
239 + cfg.RelayURLs = nextRelays
240 + return cfg, true
241 + }); !ok {
242 + return errors.New("relay is part of the multi-hop route; clear multi-hop first")
243 }
259 - e.cfg.RelayURLs = nextRelays
260 - e.cfgMu.Unlock()
244
245 e.relaySet.DeactivateRelayURL(relayURL)
246 e.relaySet.RemoveBootstrapRelayURL(relayURL)
@@ -279,7 +262,7 @@ func (e *Exposure) SetMultiHop(relayURLs []string) error {
262 if len(multiHop) == 1 {
263 return errors.New("multi-hop requires at least entry and exit relay urls")
264 }
282 - cfg := e.config()
265 + cfg := e.Config()
266 if len(multiHop) > 0 && (cfg.UDPEnabled || cfg.TCPEnabled) {
267 return errors.New("multi-hop currently supports only the default SNI TLS stream transport")
268 }
@@ -295,10 +278,10 @@ func (e *Exposure) SetMultiHop(relayURLs []string) error {
278 e.relaySet.AddBootstrapRelayURL(relayURL)
279 }
280
298 - e.cfgMu.Lock()
299 - e.cfg.MultiHop = append([]string(nil), multiHop...)
300 - e.cfg.MultiHopDepth = 0
301 - e.cfgMu.Unlock()
281 + e.cfg.UpdateCopy(func(cfg *ExposeConfig) {
282 + cfg.MultiHop = append([]string(nil), multiHop...)
283 + cfg.MultiHopDepth = 0
284 + })
285 return e.reconcileRelayListeners(false)
286 }
287
@@ -307,9 +290,9 @@ func (e *Exposure) UpdateMetadata(metadata types.LeaseMetadata) error {
290 return net.ErrClosed
291 }
292
310 - e.cfgMu.Lock()
311 - e.cfg.Metadata = metadata.Copy()
312 - e.cfgMu.Unlock()
293 + e.cfg.UpdateCopy(func(cfg *ExposeConfig) {
294 + cfg.Metadata = metadata.Copy()
295 + })
296 return nil
297 }
298
@@ -321,10 +304,13 @@ func (e *Exposure) UpdateMaxActiveRelays(maxActiveRelays int) error {
304 return net.ErrClosed
305 }
306
324 - e.cfgMu.Lock()
325 - changed := e.cfg.MaxActiveRelays != maxActiveRelays
326 - e.cfg.MaxActiveRelays = maxActiveRelays
327 - e.cfgMu.Unlock()
307 + _, changed := e.cfg.UpdateIf(func(cfg ExposeConfig) (ExposeConfig, bool) {
308 + if cfg.MaxActiveRelays == maxActiveRelays {
309 + return cfg, false
310 + }
311 + cfg.MaxActiveRelays = maxActiveRelays
312 + return cfg, true
313 + })
314 if !changed {
315 return nil
316 }
@@ -359,7 +345,7 @@ func (e *Exposure) closed() bool {
345 }
346
347 func (e *Exposure) Addr() net.Addr {
362 - identity := e.config().Identity
348 + identity := e.Config().Identity
349 if identity.Address == "" {
350 return exposureAddr("portal:exposure")
351 }
@@ -372,11 +358,14 @@ func (a exposureAddr) Network() string { return "portal" }
358 func (a exposureAddr) String() string { return string(a) }
359
360 func (e *Exposure) Config() ExposeConfig {
375 - return e.config()
361 + if e == nil || e.cfg == nil {
362 + return ExposeConfig{}
363 + }
364 + return e.cfg.Load()
365 }
366
367 func (e *Exposure) Snapshot() types.AgentTunnelStatus {
379 - cfg := e.config()
368 + cfg := e.Config()
369 e.mu.RLock()
370 listeners := make([]*listener, 0, len(e.relayListeners))
371 for _, listener := range e.relayListeners {
@@ -458,7 +447,7 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
447 }
448
449 func (e *Exposure) AcceptDatagram() (types.DatagramFrame, error) {
461 - if !e.config().UDPEnabled {
450 + if !e.Config().UDPEnabled {
451 return types.DatagramFrame{}, net.ErrClosed
452 }
453
@@ -471,7 +460,7 @@ func (e *Exposure) AcceptDatagram() (types.DatagramFrame, error) {
460 }
461
462 func (e *Exposure) SendDatagram(frame types.DatagramFrame) error {
474 - if !e.config().UDPEnabled {
463 + if !e.Config().UDPEnabled {
464 return net.ErrClosed
465 }
466
@@ -485,7 +474,7 @@ func (e *Exposure) SendDatagram(frame types.DatagramFrame) error {
474 }
475
476 func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
488 - if !e.config().UDPEnabled {
477 + if !e.Config().UDPEnabled {
478 return nil, errors.New("exposure does not have udp enabled")
479 }
480
@@ -686,7 +675,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
675 var multiHop []string
676 var listenerRelayURLs []string
677
689 - cfg := e.config()
678 + cfg := e.Config()
679 e.mu.Lock()
680 multiHop = append([]string(nil), cfg.MultiHop...)
681 if len(multiHop) > 0 {
@@ -761,7 +750,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
750 TCPEnabled: cfg.TCPEnabled,
751 BanMITM: cfg.BanMITM,
752 Metadata: func() types.LeaseMetadata {
764 - return e.metadata()
753 + return e.Config().Metadata
754 },
755 MultiHop: listenerMultiHop,
756 RetryCount: retryCount,
@@ -856,17 +845,15 @@ func (e *Exposure) runListenerAcceptLoop(listener *listener) {
845 }
846
847 removedExplicit := false
859 - e.cfgMu.Lock()
860 - next := e.cfg.RelayURLs[:0]
861 - for _, existing := range e.cfg.RelayURLs {
862 - if existing == relayURL {
863 - removedExplicit = true
864 - continue
865 - }
866 - next = append(next, existing)
848 + if e.cfg != nil {
849 + _, removedExplicit = e.cfg.UpdateIf(func(cfg ExposeConfig) (ExposeConfig, bool) {
850 + if !slices.Contains(cfg.RelayURLs, relayURL) {
851 + return cfg, false
852 + }
853 + cfg.RelayURLs = utils.RemoveRelayURL(cfg.RelayURLs, relayURL)
854 + return cfg, true
855 + })
856 }
868 - e.cfg.RelayURLs = next
869 - e.cfgMu.Unlock()
857
858 if removedExplicit && e.relaySet != nil {
859 e.relaySet.DeactivateRelayURL(relayURL)
sdk/expose_test.go
+13 -12
@@ -6,6 +6,7 @@ import (
6
7 "github.com/gosuda/portal-tunnel/v2/portal/discovery"
8 "github.com/gosuda/portal-tunnel/v2/types"
9 + "github.com/gosuda/portal-tunnel/v2/utils"
10 )
11
12 func mustRelaySet(t *testing.T, relayURLs ...string) *discovery.RelaySet {
@@ -15,7 +16,7 @@ func mustRelaySet(t *testing.T, relayURLs ...string) *discovery.RelaySet {
16
17 func TestExposureConfigSnapshotsDoNotShareMutableState(t *testing.T) {
18 exposure := &Exposure{
18 - cfg: ExposeConfig{
19 + cfg: utils.NewSnapshot(ExposeConfig{
20 RelayURLs: []string{"https://relay-a.example"},
21 Identity: types.Identity{
22 Name: "svc",
@@ -24,7 +25,7 @@ func TestExposureConfigSnapshotsDoNotShareMutableState(t *testing.T) {
25 Metadata: types.LeaseMetadata{
26 Tags: []string{"initial"},
27 },
27 - },
28 + }, ExposeConfig.snapshot),
29 }
30
31 snapshot := exposure.Config()
@@ -39,15 +40,15 @@ func TestExposureConfigSnapshotsDoNotShareMutableState(t *testing.T) {
40 t.Fatalf("Metadata.Tags[0] = %q, want original tag", got)
41 }
42
42 - exposure.cfgMu.Lock()
43 - exposure.cfg.MaxActiveRelays = 2
44 - exposure.cfg.Metadata = types.LeaseMetadata{Tags: []string{"updated"}}
45 - exposure.cfgMu.Unlock()
43 + exposure.cfg.UpdateCopy(func(cfg *ExposeConfig) {
44 + cfg.MaxActiveRelays = 2
45 + cfg.Metadata = types.LeaseMetadata{Tags: []string{"updated"}}
46 + })
47
47 - metadata := exposure.metadata()
48 + metadata := exposure.Config().Metadata
49 metadata.Tags[0] = "mutated"
49 - if got := exposure.metadata().Tags[0]; got != "updated" {
50 - t.Fatalf("MetadataSnapshot().Tags[0] = %q, want updated", got)
50 + if got := exposure.Config().Metadata.Tags[0]; got != "updated" {
51 + t.Fatalf("Metadata.Tags[0] = %q, want updated", got)
52 }
53 if got := exposure.Config().MaxActiveRelays; got != 2 {
54 t.Fatalf("MaxActiveRelays = %d, want 2", got)
@@ -70,7 +71,7 @@ func TestExposureReconcileRemovesBannedRelayFromActiveSet(t *testing.T) {
71 }
72
73 exposure := &Exposure{
73 - cfg: ExposeConfig{RelayURLs: []string{relayA, relayB}},
74 + cfg: utils.NewSnapshot(ExposeConfig{RelayURLs: []string{relayA, relayB}}, ExposeConfig.snapshot),
75 relaySet: mustRelaySet(t, relayA, relayB),
76 relayListeners: make(map[string]*listener, 2),
77 }
@@ -126,7 +127,7 @@ func TestExposureReconcileRemovesStaleListener(t *testing.T) {
127
128 relayAClosed := make(chan struct{})
129 exposure := &Exposure{
129 - cfg: ExposeConfig{RelayURLs: []string{relayA, relayB}},
130 + cfg: utils.NewSnapshot(ExposeConfig{RelayURLs: []string{relayA, relayB}}, ExposeConfig.snapshot),
131 relaySet: mustRelaySet(t, relayA, relayB),
132 relayListeners: make(map[string]*listener, 2),
133 }
@@ -178,7 +179,7 @@ func TestExposureRemoveRelayStopsRunningListener(t *testing.T) {
179
180 relayAClosed := make(chan struct{})
181 exposure := &Exposure{
181 - cfg: ExposeConfig{RelayURLs: []string{relayA}},
182 + cfg: utils.NewSnapshot(ExposeConfig{RelayURLs: []string{relayA}}, ExposeConfig.snapshot),
183 relaySet: mustRelaySet(t, relayA),
184 relayListeners: make(map[string]*listener, 1),
185 }
utils/snapshot.go new
+130
@@ -0,0 +1,130 @@
1 +package utils
2 +
3 +import (
4 + "maps"
5 + "slices"
6 + "sync/atomic"
7 +)
8 +
9 +// Snapshot stores an immutable value snapshot for lock-free reads.
10 +// Use a snapshot function when T contains mutable maps, slices, or pointers.
11 +// Do not copy a Snapshot after first use.
12 +type Snapshot[T any] struct {
13 + value atomic.Pointer[T]
14 + snapshot func(T) T
15 +}
16 +
17 +func NewSnapshot[T any](initial T, snapshot ...func(T) T) *Snapshot[T] {
18 + s := &Snapshot[T]{}
19 + if len(snapshot) > 0 {
20 + s.snapshot = snapshot[0]
21 + }
22 + s.Store(initial)
23 + return s
24 +}
25 +
26 +func (s *Snapshot[T]) Load() T {
27 + if s == nil {
28 + var zero T
29 + return zero
30 + }
31 + value := s.value.Load()
32 + if value == nil {
33 + var zero T
34 + return zero
35 + }
36 + return s.snapshotValue(*value)
37 +}
38 +
39 +func (s *Snapshot[T]) Store(value T) {
40 + if s == nil {
41 + return
42 + }
43 + value = s.snapshotValue(value)
44 + s.value.Store(&value)
45 +}
46 +
47 +func (s *Snapshot[T]) snapshotValue(value T) T {
48 + if s != nil && s.snapshot != nil {
49 + return s.snapshot(value)
50 + }
51 + return value
52 +}
53 +
54 +// Update applies a copy-on-write update and stores the resulting snapshot.
55 +// The update function may be called more than once under contention, so it
56 +// must not perform side effects.
57 +func (s *Snapshot[T]) Update(update func(T) T) T {
58 + if s == nil {
59 + var zero T
60 + return zero
61 + }
62 + for {
63 + currentPtr := s.value.Load()
64 + var current T
65 + if currentPtr != nil {
66 + current = s.snapshotValue(*currentPtr)
67 + }
68 +
69 + next := update(current)
70 + next = s.snapshotValue(next)
71 + if s.value.CompareAndSwap(currentPtr, &next) {
72 + return s.snapshotValue(next)
73 + }
74 + }
75 +}
76 +
77 +// UpdateIf stores the returned snapshot only when update returns true.
78 +// The update function may be called more than once under contention, so it
79 +// must not perform side effects.
80 +func (s *Snapshot[T]) UpdateIf(update func(T) (T, bool)) (T, bool) {
81 + if s == nil {
82 + var zero T
83 + return zero, false
84 + }
85 + for {
86 + currentPtr := s.value.Load()
87 + var current T
88 + if currentPtr != nil {
89 + current = s.snapshotValue(*currentPtr)
90 + }
91 +
92 + next, ok := update(current)
93 + if !ok {
94 + return current, false
95 + }
96 + next = s.snapshotValue(next)
97 + if s.value.CompareAndSwap(currentPtr, &next) {
98 + return s.snapshotValue(next), true
99 + }
100 + }
101 +}
102 +
103 +// UpdateCopy copies the current snapshot, applies a local mutation to the copy,
104 +// and stores the copy. The update function may be called more than once under
105 +// contention, so it must not perform side effects.
106 +func (s *Snapshot[T]) UpdateCopy(update func(*T)) T {
107 + return s.Update(func(current T) T {
108 + next := current
109 + if update != nil {
110 + update(&next)
111 + }
112 + return next
113 + })
114 +}
115 +
116 +func CloneSlice[T any](values []T) []T {
117 + return slices.Clone(values)
118 +}
119 +
120 +func CloneMap[K comparable, V any](values map[K]V) map[K]V {
121 + return maps.Clone(values)
122 +}
123 +
124 +func ClonePtr[T any](value *T) *T {
125 + if value == nil {
126 + return nil
127 + }
128 + next := *value
129 + return &next
130 +}