Skip to content

Feat/telegram proxy and xray reload - #44

Merged
AppsGanin merged 8 commits into
mainfrom
feat/telegram-proxy-and-xray-reload
Jul 30, 2026
Merged

Feat/telegram proxy and xray reload#44
AppsGanin merged 8 commits into
mainfrom
feat/telegram-proxy-and-xray-reload

Conversation

@AppsGanin

Copy link
Copy Markdown
Owner

No description provided.

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.
@AppsGanin
AppsGanin merged commit 606193e into main Jul 30, 2026
4 checks passed
@AppsGanin
AppsGanin deleted the feat/telegram-proxy-and-xray-reload branch July 30, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant