Skip to content

Commit 606193e

Browse files
authored
Merge pull request #44 from AppsGanin/feat/telegram-proxy-and-xray-reload
Feat/telegram proxy and xray reload
2 parents 261dfac + ec77990 commit 606193e

47 files changed

Lines changed: 2424 additions & 134 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/rospanel/service.go

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import (
1414
"syscall"
1515
"time"
1616

17-
"github.com/AppsGanin/rospanel/internal/auth"
1817
"github.com/AppsGanin/rospanel/internal/abuse"
18+
"github.com/AppsGanin/rospanel/internal/auth"
1919
"github.com/AppsGanin/rospanel/internal/autobackup"
2020
"github.com/AppsGanin/rospanel/internal/backup"
2121
"github.com/AppsGanin/rospanel/internal/connguard"
@@ -228,16 +228,27 @@ func runServer(dataDir string) {
228228
// Scheduled local backups. Independent of Telegram, so an operator with no bot
229229
// still gets automatic backups; idles until a cron is set in Settings.
230230
go autobackup.New(mgr, st, dataDir).Run(context.Background())
231-
// Telegram admin bot: view/add/remove users + scheduled backups. It idles until
232-
// enabled with a token in Settings → Telegram, re-reading config each cycle.
233-
go telegram.New(mgr, st, dataDir).Run(context.Background())
234-
// Telegram user bot: public self-service for VPN clients (registration,
235-
// subscription, stats). Idles until enabled with its own token in Settings.
236-
go telegram.NewUser(mgr, st).Run(context.Background())
237-
// Telegram support bot: relays messages between a user's private chat and a
238-
// per-user topic in the operator's forum supergroup. Idles until enabled with its
239-
// own token and a group in Settings → Telegram.
240-
go telegram.NewSupport(mgr, st).Run(context.Background())
231+
// All three bots reach Telegram through the same egress, and in the WARP / Opera
232+
// modes that egress is something this very startup brought up moments ago — Xray
233+
// needs a couple of seconds past "process started" before its inbound accepts.
234+
// Launched straight away they dial a refused port and then sit out their retry
235+
// backoff, so the bots stay silent for ~40s after every restart. One bounded wait,
236+
// shared by all three, off the startup path so the panel still serves meanwhile
237+
// (it returns immediately for the direct and custom routes).
238+
go func() {
239+
ctx := context.Background()
240+
mgr.AwaitTelegramEgress(ctx)
241+
// Telegram admin bot: view/add/remove users + scheduled backups. It idles until
242+
// enabled with a token in Settings → Telegram, re-reading config each cycle.
243+
go telegram.New(mgr, st, dataDir).Run(ctx)
244+
// Telegram user bot: public self-service for VPN clients (registration,
245+
// subscription, stats). Idles until enabled with its own token in Settings.
246+
go telegram.NewUser(mgr, st).Run(ctx)
247+
// Telegram support bot: relays messages between a user's private chat and a
248+
// per-user topic in the operator's forum supergroup. Idles until enabled with
249+
// its own token and a group in Settings → Telegram.
250+
go telegram.NewSupport(mgr, st).Run(ctx)
251+
}()
241252
// Broadcast delivery. Polls the store rather than holding a queue, so a restart
242253
// mid-run resumes from the remaining recipients instead of losing or repeating.
243254
go telegram.NewBroadcast(st, dataDir).Run(context.Background())
@@ -410,7 +421,7 @@ func retentionLoop(mgr *core.Manager) {
410421
mgr.PurgeOldEvents()
411422
mgr.PurgeOldAdminAudit()
412423
mgr.PurgeOldConnections()
413-
mgr.PurgeOldAbuse() // blocklist matches past their (short) window
424+
mgr.PurgeOldAbuse() // blocklist matches past their (short) window
414425
mgr.PurgeOldTraffic() // per-day traffic history past a year
415426
mgr.PurgeExpiredUsers() // no-op unless the operator set a grace period
416427
mgr.PurgeDeletedNodes() // reclaim node tombstones past their grace window

internal/core/manager.go

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ type Manager struct {
101101
tgSDKBody []byte // cached telegram.org telegram-web-app.js (nil until first fetch)
102102
tgSDKAt time.Time // when tgSDKBody was fetched
103103
tgSDKFailAt time.Time // when the last fetch failed; suppresses inline retries for a cooldown
104+
tgSDKLogAt time.Time // when the last fetch failure was logged; rate-limits that line
104105
tgSDKWait chan struct{} // non-nil while a fetch is in flight; closed when it lands (singleflight)
105106

106107
// userNotify pushes a message to a VPN user's Telegram chat (set by the user
@@ -567,20 +568,25 @@ func (m *Manager) syncUsers() error {
567568
}
568569
m.setApplied(users)
569570
// Xray's HandlerService can't live-apply user changes to a Hysteria2 (QUIC)
570-
// inbound — its authenticator is fixed when the inbound starts. The live adu/rmu
571-
// above already made the TCP lanes reflect the change instantly; only Hysteria
572-
// still needs a restart to pick it up. Defer that through the normal reconcile
573-
// path instead of restarting inline: the reconcile debounce coalesces a burst of
574-
// user changes into a SINGLE restart, and live traffic isn't dropped on every add
575-
// when nothing but Hysteria membership needs the reload. Trade-off: a removed/
576-
// disabled user keeps Hysteria access until this restart (~1 debounce cycle
577-
// later) — acceptable, and the TCP lanes were already revoked live.
571+
// inbound: `adu` rejects it outright, and `rmu` reports success while removing
572+
// nothing — so a revoked user would keep their QUIC access. The live adu/rmu above
573+
// therefore skip Hysteria entirely (see xray.UserInbounds / EnabledInboundTags),
574+
// and its user set is swapped by REBUILDING the inbound through the API.
578575
//
579-
// A CUSTOM Hysteria2 inbound counts exactly the same. Testing only the built-in
580-
// lane would leave a removed user tunnelling through a custom QUIC inbound until
581-
// something unrelated happened to trigger a reconcile.
582-
if set.HysteriaEnabled || hasHysteria(custom) {
583-
m.TriggerReconcile()
576+
// That rebuild replaces what used to be a full Xray restart. A restart dropped
577+
// every other lane's connections and the panel's own (:443 is Xray's; the panel
578+
// sits on its fallback) for a change confined to one inbound. Only the QUIC
579+
// sessions of the rebuilt lane are lost now — the users whose set just changed.
580+
//
581+
// A CUSTOM Hysteria2 inbound counts exactly the same, which is why the list comes
582+
// from the generated config by protocol rather than from the built-in lane alone.
583+
if hy := xray.HysteriaInbounds(cfg); len(hy) > 0 {
584+
if err := m.sup.ReplaceInbounds(apiAddr, hy); err != nil {
585+
// rmi may already have landed, so that lane could be down. A full
586+
// reconcile is the one thing guaranteed to put it back.
587+
logWarn("xray: rebuilding the hysteria inbounds failed; falling back to a full reload", "err", err)
588+
m.TriggerReconcile()
589+
}
584590
}
585591
return m.store.MarkConfigApplied()
586592
}

internal/core/manager_inbounds.go

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ func reservedPorts(set *model.Settings) model.ReservedPorts {
156156
if set.OperaEnabled {
157157
hold(set.OperaPortOr(), "Opera VPN")
158158
}
159+
// WARP's loopback entrance. Loopback-only, but it still occupies a port on the
160+
// box, so a custom inbound must not be allowed to claim it.
161+
if set.WarpEnabled && set.WarpRegistered() {
162+
hold(model.PanelEgressPort, "WARP local entrance")
163+
}
159164
return r
160165
}
161166

@@ -453,18 +458,6 @@ func inboundConflict(err error) error {
453458
return err
454459
}
455460

456-
// hasHysteria reports whether any custom inbound is Hysteria2. Such an inbound
457-
// cannot have its users live-updated (QUIC authenticators are fixed at start), so
458-
// its presence is what makes a user change need a reconcile.
459-
func hasHysteria(list []model.Inbound) bool {
460-
for _, in := range list {
461-
if in.Protocol == model.InbHysteria {
462-
return true
463-
}
464-
}
465-
return false
466-
}
467-
468461
// portNetwork is the transport-layer network an inbound listens on. Hysteria2 is
469462
// QUIC, so it binds UDP; everything else binds TCP. Testing the wrong one would pass
470463
// while the real bind fails.

internal/core/manager_settings.go

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@ const (
630630
telegramSDKTTL = 24 * time.Hour // how long a cached copy is served before a refresh
631631
telegramSDKBudget = 5 * time.Second // cap on a single upstream fetch, inline ones included
632632
telegramSDKRetryGap = time.Minute // after a failed fetch, don't stall a page again this soon
633+
telegramSDKLogGap = time.Hour // rate limit on the "fetch failed" log line (retries are far more frequent)
633634
telegramSDKMaxBytes = 1 << 20 // the wrapper is ~120 KB; 1 MiB is ample headroom
634635
)
635636

@@ -692,10 +693,15 @@ func (m *Manager) TelegramWebAppSDK() ([]byte, bool) {
692693
return body, body != nil
693694
}
694695

695-
// telegramSDKFetch performs the upstream GET. It's a var so tests can drive the
696-
// cache logic without a network (netguard rejects loopback, so httptest is out).
697-
var telegramSDKFetch = func(ctx context.Context) ([]byte, error) {
698-
return netguard.Get(ctx, telegramSDKURL, telegramSDKMaxBytes)
696+
// telegramSDKFetch performs the upstream GET, through the operator's Telegram proxy
697+
// when one is set (empty = direct). It's a var so tests can drive the cache logic
698+
// without a network (netguard rejects loopback, so httptest is out).
699+
//
700+
// The proxy matters most precisely here. A server that cannot reach Telegram is the
701+
// only one that ever fails this fetch, and it is also the one whose operator has
702+
// configured a proxy to fix exactly that.
703+
var telegramSDKFetch = func(ctx context.Context, proxy string) ([]byte, error) {
704+
return netguard.GetVia(ctx, telegramSDKURL, telegramSDKMaxBytes, proxy)
699705
}
700706

701707
// refreshTelegramSDK refreshes a stale copy in the background (the only caller —
@@ -730,23 +736,64 @@ func (m *Manager) fetchTelegramSDK() {
730736
var (
731737
b []byte
732738
err error
739+
// Decided inside the defer while the mutex is held, acted on by the logging
740+
// switch once it is released.
741+
landed, recovered, shout bool
733742
)
734743
defer func() {
735744
m.tgSDKMu.Lock()
736-
if err == nil && bytes.Contains(b, telegramSDKMarker) {
745+
if landed = err == nil && bytes.Contains(b, telegramSDKMarker); landed {
746+
// tgSDKLogAt non-zero means we complained about this being broken, so the
747+
// operator is owed the "it works again" line.
748+
recovered = !m.tgSDKLogAt.IsZero()
737749
m.tgSDKBody, m.tgSDKAt = b, time.Now()
738-
m.tgSDKFailAt = time.Time{}
750+
m.tgSDKFailAt, m.tgSDKLogAt = time.Time{}, time.Time{}
739751
} else {
740752
m.tgSDKFailAt = time.Now()
753+
// Log the reason, but not on every attempt: a blocked telegram.org fails
754+
// once per telegramSDKRetryGap for as long as the page sees traffic, and
755+
// that would push everything else out of the 1000-line dashboard ring
756+
// inside a day. One line per telegramSDKLogGap is enough to diagnose it.
757+
if shout = time.Since(m.tgSDKLogAt) >= telegramSDKLogGap; shout {
758+
m.tgSDKLogAt = time.Now()
759+
}
741760
}
742761
if m.tgSDKWait != nil {
743762
close(m.tgSDKWait) // wake the riders; they re-read the cache
744763
m.tgSDKWait = nil
745764
}
746765
m.tgSDKMu.Unlock()
766+
767+
// Logging happens after the unlock: a slog handler can write to disk, and the
768+
// riders released just above are waiting to take this same mutex.
769+
//
770+
// The two failure branches cost the operator the same thing — /tg.js goes out
771+
// empty, so window.Telegram never appears and the "open in app" buttons stop
772+
// working inside the Telegram Mini App — so both spell that out. They differ
773+
// only in cause, which is exactly what the operator can't see from outside.
774+
switch {
775+
case landed:
776+
if recovered {
777+
logInfo("telegram mini app sdk reachable again", "bytes", len(b))
778+
}
779+
case !shout: // same failure already reported inside the log gap
780+
case err != nil:
781+
logWarn("telegram mini app sdk fetch failed; /tg.js will be served empty and in-app deep links will not work",
782+
"url", telegramSDKURL, "err", err)
783+
default:
784+
logWarn("telegram mini app sdk fetch returned an unexpected body (blocked or truncated); /tg.js will be served empty and in-app deep links will not work",
785+
"url", telegramSDKURL, "bytes", len(b))
786+
}
747787
}()
748788

749789
ctx, cancel := context.WithTimeout(context.Background(), telegramSDKBudget)
750790
defer cancel()
751-
b, err = telegramSDKFetch(ctx)
791+
// Read fresh rather than caching the proxy on the Manager: this runs at most once
792+
// per cooldown, so one settings read is nothing, and it means a just-saved proxy
793+
// takes effect on the next page load instead of after a restart.
794+
var proxy string
795+
if set, serr := m.store.GetSettings(); serr == nil {
796+
proxy = set.TelegramProxyURL()
797+
}
798+
b, err = telegramSDKFetch(ctx, proxy)
752799
}

0 commit comments

Comments
 (0)