Skip to content

Commit 7e099a4

Browse files
authored
Merge pull request #114 from getlantern/set-url-overrides
Add SetURLOverrides for live config updates
2 parents b50ea1f + f4a3524 commit 7e099a4

File tree

5 files changed

+175
-0
lines changed

5 files changed

+175
-0
lines changed

adapter/group.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ type MutableOutboundGroup interface {
1212
Remove(tags ...string) (n int, err error)
1313
}
1414

15+
// URLOverrideSetter is implemented by outbound groups that support per-outbound URL test overrides.
16+
type URLOverrideSetter interface {
17+
SetURLOverrides(overrides map[string]string)
18+
}
19+
1520
// TaggedConn is a net.Conn tagged with the outbound tag used to create it.
1621
type TaggedConn struct {
1722
net.Conn

adapter/groups/manager.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,26 @@ func (m *MutableGroupManager) addToGroup(outGroup adapter.MutableOutboundGroup,
159159
return nil
160160
}
161161

162+
// SetURLOverrides updates the per-outbound URL test overrides for the specified group.
163+
// The group must implement [adapter.URLOverrideSetter].
164+
func (m *MutableGroupManager) SetURLOverrides(group string, overrides map[string]string) error {
165+
m.mu.Lock()
166+
defer m.mu.Unlock()
167+
if m.closed.Load() {
168+
return ErrIsClosed
169+
}
170+
outGroup, ok := m.groups[group]
171+
if !ok {
172+
return fmt.Errorf("group %q not found", group)
173+
}
174+
setter, ok := outGroup.(adapter.URLOverrideSetter)
175+
if !ok {
176+
return fmt.Errorf("group %q does not support URL overrides", group)
177+
}
178+
setter.SetURLOverrides(overrides)
179+
return nil
180+
}
181+
162182
// RemoveFromGroup removes an outbound/endpoint from the specified group.
163183
func (m *MutableGroupManager) RemoveFromGroup(group, tag string) error {
164184
m.mu.Lock()

adapter/groups/manager_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"github.com/sagernet/sing-box/log"
1212
"github.com/stretchr/testify/assert"
1313
"github.com/stretchr/testify/require"
14+
15+
lbAdapter "github.com/getlantern/lantern-box/adapter"
1416
)
1517

1618
func TestRemovalQueue(t *testing.T) {
@@ -105,6 +107,81 @@ func TestRemovalQueue(t *testing.T) {
105107
}
106108
}
107109

110+
func TestSetURLOverrides(t *testing.T) {
111+
logger := log.NewNOPFactory().Logger()
112+
113+
t.Run("delegates to URLOverrideSetter group", func(t *testing.T) {
114+
mock := &mockURLOverrideGroup{urlOverrides: nil}
115+
mgr := &MutableGroupManager{
116+
groups: map[string]lbAdapter.MutableOutboundGroup{"auto-test": mock},
117+
removalQueue: newRemovalQueue(
118+
logger, &mockOutboundManager{}, &mockEndpointManager{}, &mockConnectionManager{},
119+
pollInterval, forceAfter,
120+
),
121+
}
122+
overrides := map[string]string{"out1": "https://example.com/cb"}
123+
err := mgr.SetURLOverrides("auto-test", overrides)
124+
require.NoError(t, err)
125+
assert.Equal(t, overrides, mock.urlOverrides)
126+
})
127+
128+
t.Run("error when group not found", func(t *testing.T) {
129+
mgr := &MutableGroupManager{
130+
groups: map[string]lbAdapter.MutableOutboundGroup{},
131+
removalQueue: newRemovalQueue(
132+
logger, &mockOutboundManager{}, &mockEndpointManager{}, &mockConnectionManager{},
133+
pollInterval, forceAfter,
134+
),
135+
}
136+
err := mgr.SetURLOverrides("nonexistent", nil)
137+
assert.Error(t, err)
138+
assert.Contains(t, err.Error(), "not found")
139+
})
140+
141+
t.Run("error when group does not implement URLOverrideSetter", func(t *testing.T) {
142+
mock := &mockPlainGroup{}
143+
mgr := &MutableGroupManager{
144+
groups: map[string]lbAdapter.MutableOutboundGroup{"plain": mock},
145+
removalQueue: newRemovalQueue(
146+
logger, &mockOutboundManager{}, &mockEndpointManager{}, &mockConnectionManager{},
147+
pollInterval, forceAfter,
148+
),
149+
}
150+
err := mgr.SetURLOverrides("plain", nil)
151+
assert.Error(t, err)
152+
assert.Contains(t, err.Error(), "does not support URL overrides")
153+
})
154+
155+
t.Run("error when manager is closed", func(t *testing.T) {
156+
mock := &mockURLOverrideGroup{}
157+
mgr := &MutableGroupManager{
158+
groups: map[string]lbAdapter.MutableOutboundGroup{"auto-test": mock},
159+
removalQueue: newRemovalQueue(
160+
logger, &mockOutboundManager{}, &mockEndpointManager{}, &mockConnectionManager{},
161+
pollInterval, forceAfter,
162+
),
163+
}
164+
mgr.closed.Store(true)
165+
err := mgr.SetURLOverrides("auto-test", nil)
166+
assert.ErrorIs(t, err, ErrIsClosed)
167+
})
168+
}
169+
170+
// mockURLOverrideGroup implements both MutableOutboundGroup and URLOverrideSetter.
171+
type mockURLOverrideGroup struct {
172+
lbAdapter.MutableOutboundGroup
173+
urlOverrides map[string]string
174+
}
175+
176+
func (m *mockURLOverrideGroup) SetURLOverrides(overrides map[string]string) {
177+
m.urlOverrides = overrides
178+
}
179+
180+
// mockPlainGroup implements MutableOutboundGroup but NOT URLOverrideSetter.
181+
type mockPlainGroup struct {
182+
lbAdapter.MutableOutboundGroup
183+
}
184+
108185
type mockOutboundManager struct {
109186
adapter.OutboundManager
110187
tags []string

protocol/group/mutableurltest.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"log/slog"
10+
"maps"
1011
"net"
1112
"net/http"
1213
"net/url"
@@ -131,6 +132,11 @@ func (s *MutableURLTest) Remove(tags ...string) (n int, err error) {
131132
return s.group.Remove(tags)
132133
}
133134

135+
// SetURLOverrides replaces the per-outbound URL override map used during URL testing.
136+
func (s *MutableURLTest) SetURLOverrides(overrides map[string]string) {
137+
s.group.SetURLOverrides(overrides)
138+
}
139+
134140
func (s *MutableURLTest) URLTest(ctx context.Context) (map[string]uint16, error) {
135141
return s.group.URLTest(ctx)
136142
}
@@ -363,6 +369,12 @@ func (g *urlTestGroup) Add(tags []string) (n int, err error) {
363369
return n, nil
364370
}
365371

372+
func (g *urlTestGroup) SetURLOverrides(overrides map[string]string) {
373+
g.access.Lock()
374+
defer g.access.Unlock()
375+
g.urlOverrides = maps.Clone(overrides)
376+
}
377+
366378
func (g *urlTestGroup) Remove(tags []string) (n int, err error) {
367379
g.access.Lock()
368380
defer g.access.Unlock()

protocol/group/mutableurltest_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,67 @@ func TestTestURLForTag_DefaultURL(t *testing.T) {
6969
assert.Equal(t, "https://default.example.com", g.testURLForTag("unknown"))
7070
}
7171

72+
func TestSetURLOverrides(t *testing.T) {
73+
ctx, cancel := context.WithCancel(context.Background())
74+
defer cancel()
75+
g := newURLTestGroup(
76+
ctx,
77+
&mockOutboundManager{outbounds: map[string]adapter.Outbound{
78+
"out1": nil, "out2": nil, "out3": nil,
79+
}},
80+
sboxLog.NewNOPFactory().Logger(),
81+
[]string{"out1", "out2", "out3"},
82+
"https://default.example.com",
83+
nil, // start with no overrides
84+
time.Minute, time.Minute, 50,
85+
)
86+
87+
// Initially all tags use the default URL
88+
assert.Equal(t, "https://default.example.com", g.testURLForTag("out1"))
89+
90+
// Set overrides
91+
g.SetURLOverrides(map[string]string{
92+
"out1": "https://new-override.example.com",
93+
"out3": "https://out3-override.example.com",
94+
})
95+
assert.Equal(t, "https://new-override.example.com", g.testURLForTag("out1"))
96+
assert.Equal(t, "https://default.example.com", g.testURLForTag("out2"))
97+
assert.Equal(t, "https://out3-override.example.com", g.testURLForTag("out3"))
98+
99+
// Replace with different overrides
100+
g.SetURLOverrides(map[string]string{
101+
"out2": "https://out2-override.example.com",
102+
})
103+
assert.Equal(t, "https://default.example.com", g.testURLForTag("out1"))
104+
assert.Equal(t, "https://out2-override.example.com", g.testURLForTag("out2"))
105+
106+
// Clear overrides
107+
g.SetURLOverrides(nil)
108+
assert.Equal(t, "https://default.example.com", g.testURLForTag("out1"))
109+
assert.Equal(t, "https://default.example.com", g.testURLForTag("out2"))
110+
}
111+
112+
func TestSetURLOverrides_DoesNotMutateCallerMap(t *testing.T) {
113+
ctx, cancel := context.WithCancel(context.Background())
114+
defer cancel()
115+
g := newURLTestGroup(
116+
ctx,
117+
&mockOutboundManager{outbounds: map[string]adapter.Outbound{"out1": nil}},
118+
sboxLog.NewNOPFactory().Logger(),
119+
[]string{"out1"},
120+
"https://default.example.com",
121+
nil,
122+
time.Minute, time.Minute, 50,
123+
)
124+
125+
callerMap := map[string]string{"out1": "https://original.example.com"}
126+
g.SetURLOverrides(callerMap)
127+
128+
// Mutating the caller's map after SetURLOverrides should not affect the group
129+
callerMap["out1"] = "https://mutated.example.com"
130+
assert.Equal(t, "https://original.example.com", g.testURLForTag("out1"))
131+
}
132+
72133
func TestTestURLForTag_WithOverrides(t *testing.T) {
73134
ctx, cancel := context.WithCancel(context.Background())
74135
defer cancel()

0 commit comments

Comments
 (0)