@@ -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).
138139func (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).
142154func (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.
207296func (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.
243370func (m * Manager ) SetProxyMode (enabled bool , typ string , port int , user , pass string ) error {
244371 if typ != "socks" && typ != "http" {
0 commit comments