Skip to content

Commit 33042a5

Browse files
committed
feat(iplist): add iplist database support with independent refresh cadence
1 parent d0b1295 commit 33042a5

19 files changed

Lines changed: 1181 additions & 107 deletions

cmd/rospanel/service.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ func runServer(dataDir string) {
157157
if err := geo.Ensure(geoDir); err != nil {
158158
log.Printf("geo: %v", err)
159159
}
160+
// The iplist databases are fetched separately, below: Xray never reads them
161+
// (the panel compiles "iplist:" rules into plain matchers itself), so they must
162+
// not hold up its start.
160163

161164
sup := xray.NewSupervisor(xrayBin, xrayConfig, geoDir)
162165
mgr := core.New(st, sup, xray.Options{PanelDest: panelDest(adminAddr)},
@@ -177,6 +180,25 @@ func runServer(dataDir string) {
177180
log.Printf("initial reconcile failed (panel still starting): %v", err)
178181
}
179182

183+
// Fetch the iplist databases if this box has never had them (~2.7 MB), then
184+
// reconcile so a config already referencing a group picks it up now rather than
185+
// routing without those rules until the next refresh tick. Backgrounded: Xray is
186+
// already serving and does not depend on these.
187+
go func() {
188+
missing := false
189+
for _, f := range geo.StatusLists(geoDir) {
190+
missing = missing || !f.Present
191+
}
192+
if !missing {
193+
return
194+
}
195+
if err := geo.EnsureLists(geoDir); err != nil {
196+
log.Printf("geo: %v", err)
197+
return
198+
}
199+
mgr.TriggerReconcile()
200+
}()
201+
180202
// Daily TLS check: renews ACME certs near expiry and reloads Xray on change.
181203
go tlsLoop(mgr)
182204
// Periodic traffic accounting + quota/expiry enforcement.

internal/core/manager.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"sync/atomic"
1111
"time"
1212

13+
"github.com/AppsGanin/rospanel/internal/geo"
1314
"github.com/AppsGanin/rospanel/internal/logbuf"
1415
"github.com/AppsGanin/rospanel/internal/model"
1516
"github.com/AppsGanin/rospanel/internal/nodeapi"
@@ -98,9 +99,10 @@ type Manager struct {
9899
lastVPNT time.Time
99100
vpnViewers atomic.Int32 // active dashboard-stream subscribers; gates vpnSpeedLoop
100101

101-
geoMu sync.Mutex
102-
geoSite []string // cached geosite category codes
103-
geoIP []string // cached geoip category codes
102+
geoMu sync.Mutex
103+
geoSite []string // cached geosite category codes
104+
geoIP []string // cached geoip category codes
105+
geoGroups geo.GroupSet // cached iplist groups ("<source>/<group>" → rules)
104106

105107
proxyMu sync.Mutex
106108
// proxies holds the local server's current egress proxies of each lane, keyed by
@@ -206,7 +208,8 @@ func New(st *store.Store, sup *xray.Supervisor, opts xray.Options, tls TLSPaths,
206208
m.sup.SetOnCrash(m.onXrayCrash) // alert admins when Xray exits unexpectedly
207209
go m.reconcileLoop()
208210
go m.proxyLoop()
209-
go m.geoLoop() // auto-refresh geo databases on the operator's cadence
211+
go m.geoLoop() // auto-refresh geo databases on the operator's cadence
212+
go m.ipListLoop() // ...and the iplist lists on their own, separate cadence
210213
go m.bruteGuardLoop()
211214
go m.healthLoop() // probe Opera/Hola lane liveness for the UI
212215
m.startWebhookWorkers() // drain the outbound-webhook delivery queue
@@ -390,7 +393,7 @@ func (m *Manager) syncUsers() error {
390393
}
391394
// Keep config.json current (no restart) so the monitor's crash-restart loads
392395
// the right user set.
393-
cfg, err := xray.Generate(set, users, m.opts, m.getProxies())
396+
cfg, err := xray.Generate(set, users, m.genOpts(), m.getProxies())
394397
if err != nil {
395398
return err
396399
}
@@ -459,7 +462,7 @@ func (m *Manager) reconcileLocked() error {
459462
if err != nil {
460463
return err
461464
}
462-
cfg, err := xray.Generate(set, users, m.opts, m.getProxies())
465+
cfg, err := xray.Generate(set, users, m.genOpts(), m.getProxies())
463466
if err != nil {
464467
logErr("reconcile: config generation failed", "err", err)
465468
_ = m.store.SetConfigError(err.Error())

internal/core/manager_nodes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ func (m *Manager) NodeDesiredState(n *model.Node) (*nodeapi.NodeState, error) {
143143
ns.KeyPath = nodeapi.KeyPathSentinel
144144
// The node's own fallback points at its local decoy/panel loopback, same as the
145145
// panel's own layout. Egress lanes resolve against the node's OWN proxy pool.
146-
cfg, err := xray.Generate(ns, users, m.opts, m.getNodeProxies(n.ID))
146+
cfg, err := xray.Generate(ns, users, m.genOpts(), m.getNodeProxies(n.ID))
147147
if err != nil {
148148
return nil, err
149149
}

internal/core/manager_settings.go

Lines changed: 148 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/AppsGanin/rospanel/internal/model"
1717
"github.com/AppsGanin/rospanel/internal/netguard"
1818
"github.com/AppsGanin/rospanel/internal/warp"
19+
"github.com/AppsGanin/rospanel/internal/xray"
1920
)
2021

2122
// SetTimezone validates and persists the operator's IANA timezone, then updates
@@ -137,6 +138,17 @@ func (m *Manager) SetXrayDNS(dns string) error {
137138
// Settings returns the current settings row (read-only handlers).
138139
func (m *Manager) Settings() (*model.Settings, error) { return m.store.GetSettings() }
139140

141+
// assetDir is the directory holding the geo + iplist databases, or "" when the
142+
// Manager has no supervisor (unit tests build a bare Manager). The geo readers
143+
// treat "" as "no databases present" and error rather than panicking, so every
144+
// caller degrades to "no categories / no groups" instead of taking the panel down.
145+
func (m *Manager) assetDir() string {
146+
if m.sup == nil {
147+
return ""
148+
}
149+
return m.sup.AssetDir()
150+
}
151+
140152
// GeoCategories returns the geosite + geoip category codes from the on-disk
141153
// databases, parsed once and cached (the .dat files only change on refresh).
142154
func (m *Manager) GeoCategories() (geosite, geoip []string, err error) {
@@ -145,30 +157,82 @@ func (m *Manager) GeoCategories() (geosite, geoip []string, err error) {
145157
if m.geoSite != nil || m.geoIP != nil {
146158
return m.geoSite, m.geoIP, nil
147159
}
148-
gs, gi, err := geo.Categories(m.sup.AssetDir())
160+
gs, gi, err := geo.Categories(m.assetDir())
149161
if err != nil {
150162
return nil, nil, err
151163
}
152164
m.geoSite, m.geoIP = gs, gi
153165
return gs, gi, nil
154166
}
155167

156-
// GeoStatus reports the on-disk state of the geoip/geosite databases (presence,
157-
// size, last-download time) for the settings UI.
158-
func (m *Manager) GeoStatus() []geo.FileInfo { return geo.Status(m.sup.AssetDir()) }
168+
// GeoGroups returns the iplist groups parsed from the on-disk databases, cached
169+
// like GeoCategories (they only change on a refresh). Callers must not mutate
170+
// the returned set.
171+
func (m *Manager) GeoGroups() (geo.GroupSet, error) {
172+
m.geoMu.Lock()
173+
defer m.geoMu.Unlock()
174+
if m.geoGroups != nil {
175+
return m.geoGroups, nil
176+
}
177+
g, err := geo.Groups(m.assetDir())
178+
if err != nil {
179+
return nil, err
180+
}
181+
m.geoGroups = g
182+
return g, nil
183+
}
159184

160-
// RefreshGeo re-downloads the geo databases to their latest version, drops the
161-
// parsed-category cache, and reloads Xray so routing rules pick up the new data.
162-
func (m *Manager) RefreshGeo() ([]geo.FileInfo, error) {
163-
dir := m.sup.AssetDir()
164-
if err := geo.Refresh(dir); err != nil {
165-
return geo.Status(dir), err
185+
// genOpts returns the generation options with the iplist groups resolved, so
186+
// "iplist:" routing entries compile to real matchers. A parse failure (databases
187+
// not downloaded yet) degrades to no groups rather than blocking generation —
188+
// those rules are skipped and their traffic falls through to the next lane.
189+
func (m *Manager) genOpts() xray.Options {
190+
opts := m.opts
191+
if g, err := m.GeoGroups(); err == nil {
192+
opts.Groups = g
166193
}
194+
return opts
195+
}
196+
197+
// GeoStatus reports the on-disk state of the Xray geo databases (presence, size,
198+
// last-download time) for the settings UI.
199+
func (m *Manager) GeoStatus() []geo.FileInfo { return geo.Status(m.assetDir()) }
200+
201+
// IPListStatus reports the on-disk state of the iplist databases. Separate from
202+
// GeoStatus because they are a separate concern with their own panel tab: Xray
203+
// reads the geo .dat files, while the iplist lists are the panel's own source for
204+
// "iplist:" rules.
205+
func (m *Manager) IPListStatus() []geo.FileInfo { return geo.StatusLists(m.assetDir()) }
206+
207+
// dropGeoCache forces a re-parse of the categories and groups on next use. Called
208+
// after every refresh — including a partial failure, since each file is written
209+
// atomically and independently, so whatever did land must be picked up.
210+
func (m *Manager) dropGeoCache() {
167211
m.geoMu.Lock()
168-
m.geoSite, m.geoIP = nil, nil // force re-parse on next GeoCategories
212+
m.geoSite, m.geoIP, m.geoGroups = nil, nil, nil
169213
m.geoMu.Unlock()
170-
m.TriggerReconcile() // reload Xray with the refreshed databases
171-
return geo.Status(dir), nil
214+
}
215+
216+
// RefreshGeo re-downloads the Xray geo databases to their latest version, drops
217+
// the parsed caches, and reloads Xray so routing rules pick up the new data.
218+
func (m *Manager) RefreshGeo() ([]geo.FileInfo, error) {
219+
if err := geo.Refresh(m.assetDir()); err != nil {
220+
return m.GeoStatus(), err
221+
}
222+
m.dropGeoCache()
223+
m.TriggerReconcile()
224+
return m.GeoStatus(), nil
225+
}
226+
227+
// RefreshIPLists re-downloads the iplist databases, drops the parsed caches and
228+
// reloads Xray, so a changed group takes effect at once.
229+
func (m *Manager) RefreshIPLists() ([]geo.FileInfo, error) {
230+
if err := geo.RefreshLists(m.assetDir()); err != nil {
231+
return m.IPListStatus(), err
232+
}
233+
m.dropGeoCache()
234+
m.TriggerReconcile()
235+
return m.IPListStatus(), nil
172236
}
173237

174238
// GeoRefreshHours returns the configured geo auto-refresh cadence (hours; 0 ⇒ off).
@@ -189,32 +253,76 @@ func (m *Manager) currentGeoRefresh() time.Duration {
189253
return time.Duration(set.GeoRefreshHours) * time.Hour
190254
}
191255

192-
// geoStale reports whether any geo database is missing or older than maxAge.
193-
func (m *Manager) geoStale(maxAge time.Duration) bool {
256+
// IPListRefreshHours returns the configured iplist auto-refresh cadence (hours;
257+
// 0 ⇒ off).
258+
func (m *Manager) IPListRefreshHours() int {
259+
set, err := m.store.GetSettings()
260+
if err != nil {
261+
return 0
262+
}
263+
return set.IPListRefreshHours
264+
}
265+
266+
// currentIPListRefresh reads the iplist auto-refresh cadence as a duration (0 ⇒ off).
267+
func (m *Manager) currentIPListRefresh() time.Duration {
268+
set, err := m.store.GetSettings()
269+
if err != nil || set.IPListRefreshHours <= 0 {
270+
return 0
271+
}
272+
return time.Duration(set.IPListRefreshHours) * time.Hour
273+
}
274+
275+
// stale reports whether any file in the set is missing or older than maxAge.
276+
func stale(files []geo.FileInfo, maxAge time.Duration) bool {
194277
cutoff := time.Now().Add(-maxAge).Unix()
195-
for _, f := range geo.Status(m.sup.AssetDir()) {
278+
for _, f := range files {
196279
if !f.Present || f.ModifiedAt < cutoff {
197280
return true
198281
}
199282
}
200283
return false
201284
}
202285

286+
// geoStale reports whether any geo database is missing or older than maxAge.
287+
func (m *Manager) geoStale(maxAge time.Duration) bool { return stale(m.GeoStatus(), maxAge) }
288+
289+
// ipListStale reports whether any iplist database is missing or older than maxAge.
290+
func (m *Manager) ipListStale(maxAge time.Duration) bool { return stale(m.IPListStatus(), maxAge) }
291+
203292
// geoLoop auto-refreshes the geo databases when they go stale, on the operator's
204293
// cadence (0 ⇒ off). It re-checks hourly so a cadence change takes effect without a
205294
// restart and a reboot doesn't reset a long timer. Sleeps first so boot stays quiet;
206295
// enabling the cadence refreshes promptly via SetGeoRefresh.
207296
func (m *Manager) geoLoop() {
297+
refreshLoop("geo", m.currentGeoRefresh, m.geoStale, func() error {
298+
_, err := m.RefreshGeo()
299+
return err
300+
})
301+
}
302+
303+
// ipListLoop is geoLoop's twin for the iplist databases, on their OWN cadence —
304+
// they follow a different upstream clock (~12h) and are panel-only, so tying them
305+
// to the geo schedule would either poll the lists too rarely or drag ~28 MB of
306+
// .dat files down far too often.
307+
func (m *Manager) ipListLoop() {
308+
refreshLoop("iplist", m.currentIPListRefresh, m.ipListStale, func() error {
309+
_, err := m.RefreshIPLists()
310+
return err
311+
})
312+
}
313+
314+
// refreshLoop is the shared hourly staleness poll behind geoLoop/ipListLoop.
315+
func refreshLoop(what string, cadence func() time.Duration, isStale func(time.Duration) bool, refresh func() error) {
208316
for {
209317
time.Sleep(time.Hour)
210-
d := m.currentGeoRefresh()
211-
if d <= 0 || !m.geoStale(d) {
318+
d := cadence()
319+
if d <= 0 || !isStale(d) {
212320
continue
213321
}
214-
if _, err := m.RefreshGeo(); err != nil {
215-
logWarn("geo: auto-refresh failed", "err", err)
322+
if err := refresh(); err != nil {
323+
logWarn(what+": auto-refresh failed", "err", err)
216324
} else {
217-
logInfo("geo: auto-refreshed", "cadence_hours", int(d/time.Hour))
325+
logInfo(what+": auto-refreshed", "cadence_hours", int(d/time.Hour))
218326
}
219327
}
220328
}
@@ -239,6 +347,25 @@ func (m *Manager) SetGeoRefresh(hours int) error {
239347
return nil
240348
}
241349

350+
// SetIPListRefresh persists the iplist auto-refresh cadence (hours; 0 ⇒ never),
351+
// refreshing at once if enabling with the lists already stale.
352+
func (m *Manager) SetIPListRefresh(hours int) error {
353+
if hours < 0 {
354+
hours = 0
355+
}
356+
if err := m.store.SetIPListRefresh(hours); err != nil {
357+
return err
358+
}
359+
if d := time.Duration(hours) * time.Hour; d > 0 && m.ipListStale(d) {
360+
go func() {
361+
if _, err := m.RefreshIPLists(); err != nil {
362+
logWarn("iplist: refresh on enable failed", "err", err)
363+
}
364+
}()
365+
}
366+
return nil
367+
}
368+
242369
// SetProxyMode persists the forward-proxy inbound (proxy mode) and reloads Xray.
243370
func (m *Manager) SetProxyMode(enabled bool, typ string, port int, user, pass string) error {
244371
if typ != "socks" && typ != "http" {

0 commit comments

Comments
 (0)