Feat/telegram proxy and xray reload - #44
Merged
Merged
Conversation
An unreachable telegram.org left /tg.js empty with nothing said about it: the operator saw a working panel whose "open in app" buttons silently did nothing, and diagnosing it took a user's DevTools session (#43). fetchTelegramSDK now reports the reason, with separate lines for a connection failure and for a 200 carrying something that isn't the SDK — a transparent-proxy block page and a dead route need different fixes, so they must not read alike. Both name the consequence, since that is what connects the line to the symptom being reported. Rate-limited to one line per hour: the retry cooldown is a minute, so a blocked upstream fails ~1440 times a day with traffic on the page, and logging each one would flush the 1000-line dashboard ring within a day. Recovery gets a line too, but only when a failure was actually reported, so a healthy panel stays quiet.
…roxy A server that cannot reach Telegram loses all three bots AND the Mini App SDK fetch, which takes the subscription page's "open in app" buttons with it (#43). One setting now covers every one of them: Settings → Telegram picks "direct" or a proxy URL, and Settings.TelegramProxyURL is the single place a choice becomes an address. The parser and shared transport live in internal/netguard, not internal/telegram: core has to validate the setting and must not import telegram, whose dependency direction is deliberately one-way. Schemes are http, https, socks5 and socks5h — all native to net/http, so no protocol code of ours. Go's socks5 already resolves remotely, making socks5h an accepted alias rather than a second implementation. NewClient takes the proxy as a parameter rather than reading a package global: a global lets a new call site silently bypass the setting and look fine on any reachable dev box. A proxy that fails to parse makes every request fail with that reason instead of quietly going direct. Stored as a mode plus a URL, not just a URL. Inferring "custom" from a non-empty field would silently restore a stale hand-typed proxy the moment an operator picked "direct", instead of leaving the value visible in the box where they can see it. The URL is encrypted at rest — it commonly carries credentials — while the mode is one of two fixed words and encrypting it would only make the column unreadable in a support session. Deliberately scoped to Telegram. Blocklist feeds, ACME and the updater have separate reachability, and routing them through a proxy chosen for Telegram would be a surprise — including a privacy one, since that proxy's operator would then see traffic the setting never mentioned.
The panel provisions WARP and Opera for user traffic, but neither could be used by anything else on the box. Opera already listens on loopback; WARP is a WireGuard outbound with no address at all, so nothing but Xray's own routing could reach it. Xray now carries a loopback SOCKS inbound as WARP's entrance, and the Routing page shows the address of each egress it has running. Sending the Telegram proxy — or anything else — through one is then a copy-paste, with no need to know a port number. Tied to WARP being available and nothing else. Making the inbound depend on who happens to consume it would mean an unrelated save could rewrite the Xray config and bounce every VPN lane; and an address published for an egress that isn't running is a dead port dressed as an instruction, so each one is shown only while its lane is up. The dispatch rule sits below the private-address floor and above every operator rule. Below, so an entrance on loopback gets no more reach into the LAN through Xray than a VPN client does. Above, because whoever dialled it asked for that tunnel by name and a lane rule written for client traffic has no business redirecting it. The inbound binds 127.0.0.1 and needs no credentials — anything that can reach it already runs on the box — and it is registered with the port-conflict check so a custom inbound cannot claim its port.
…leak
Xray takes a kernel TUN for a wireguard outbound whenever it has CAP_NET_ADMIN,
which the panel's systemd unit grants it for nftables and the BBR sysctls. In
that mode every Xray start adds an `ip -6 rule` pair and a routing table it never
removes, and the panel restarts Xray on each config change.
The end state is not a slow WARP. It is:
[Warning] proxy/wireguard: Using kernel TUN
Failed to start: main: failed to create server >
failed to find available ipv6 table index
Xray then refuses to boot at all, taking down every lane rather than just WARP.
Observed on the test box: 30 stale rules where a healthy one has 2, and a dead
Xray. Before that it presents as WARP working right after a restart and dying
minutes later, which reads like an upstream problem and sends you chasing it.
noKernelTun switches to the userspace implementation, which allocates no kernel
state — verified across four consecutive restarts with the rule count unchanged.
The knob is spelled noKernelTun; kernelMode does not exist in the shipped binary.
Userspace is slower than a kernel TUN. That is the right trade: a WARP lane at
reduced throughput beats a panel that stops serving after enough edits.
Startup launches Xray and the three bots back to back. An operator who pointed the Telegram proxy at the WARP address the Routing page publishes had the bots dialling that port while Xray was still coming up: connection refused first, then a tunnel that accepts but does not yet carry. They recover on their own, but only after the retry backoff unwinds — about 40 seconds of silent bots on every restart, which reads as a broken panel. The wait applies only to an egress this panel brings up itself. Someone else's proxy being down is their outage, and blocking boot on it would turn that into our delay with no bots at all, which is strictly worse than starting and retrying. It probes with a real request to api.telegram.org rather than a TCP connect, because for the WARP route the open port proves nothing: Xray's inbound accepts from the instant the process starts, seconds before the WireGuard handshake behind it completes. Aiming at the host the bots actually need answers the question that matters instead of a proxy-shaped stand-in. Bounded, off the startup path so the panel keeps serving, and a timeout is not fatal — the bots retry regardless. This only removes the part of the wait that was predictable.
Apply restarted Xray unconditionally, and plenty of saves reach it without changing a byte of the config: renaming a lane, switching a client fingerprint, toggling TLS fragment or block-QUIC all live in the subscription links, never in config.json. Each of those dropped every live VPN connection for nothing. It also handed the operator a "Failed to fetch" for a save that had succeeded. The panel is served through Xray — :443 is the VLESS inbound, the panel sits on its fallback — so a restart kills the admin's own connection mid-request. The UI only shielded the saves it predicted would restart, and those cosmetic ones were correctly not on that list. The comparison is against what the RUNNING process was started with, not the file on disk. WriteConfig deliberately moves the file ahead of the process after a live user add/remove, so a file comparison would read "nothing changed" for exactly the change that still needs a restart. A live process is required as well: on boot the generated config already matches the file from last time, and matching on bytes alone would mean Xray never starts. Since an apply can now be a no-op, the UI can no longer wait on a newer start time — it would spin out its full timeout on a save that finished instantly. The status endpoint gained applied_at, which advances on every apply either way, and a dropped connection is no longer reported as a failure on its own: an ApiError is the server's own answer and is shown, while a lost reply waits for the config to settle and only complains if it never does. Guarded by a determinism test as well: one map iterated into a slice in the generator and the comparison would never match again, silently restoring the old behaviour with everything still appearing to work.
…changes
Measured against Xray 26.6.27, with VLESS as a control:
api adu on hysteria-in → "unsupported inbound type", Added 0 user(s)
api adu on vless-in → result: ok, Added 1 user(s)
api rmu of a user that never existed:
hysteria-in → "Removed 1 user(s)", no error
vless-in → rpc error: User not found, Removed 0
The second pair is the dangerous one. Removing a user from a QUIC inbound reports
success while doing nothing, so the panel believed it had revoked access it still
granted. Hysteria2 is therefore excluded from the live adu/rmu path entirely
rather than merely supplemented by it.
Its user set is now swapped by rebuilding the inbound: rmi then adi reconstructs
it from scratch, users included, with the process untouched — same pid, same
listening socket. That replaces a full Xray restart, which dropped every other
lane's connections and the panel's own for a change confined to one inbound. Only
the QUIC sessions of the rebuilt lane are lost now: the users whose set changed.
Custom Hysteria2 inbounds count the same, so the list comes from the generated
config by protocol rather than from the built-in lane alone — testing only the
built-in one would leave a revoked user tunnelling through a custom QUIC inbound.
adi needs the whole inbound, port and TLS included, not the tag-plus-users stub
adu takes.
rmi can land while adi fails, leaving that lane down, so a failure falls back to a
full reconcile — the one thing guaranteed to put it back.
AddUsers no longer trusts the exit code either: adu exits 0 having added nobody.
It compares the count xray reports against the number asked for, which also
catches a partial failure that a non-zero count would otherwise hide.
Drops the sentence spelling out what breaks without a proxy — the card sits first on the page precisely because that is already the point, and the warning read as alarm for a setting most installs never need. "Empty means direct" goes with it. It described the field before the mode picker existed; an empty address is now a validation error, not a way to choose direct.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.