Skip to content

feat(hosts): clean IP scanner that saves working CDN IPs per ISP - #6950

Open
Plus98ir wants to merge 19 commits into
MHSanaei:mainfrom
Plus98ir:feat/clean-ip-scanner
Open

Plus98ir wants to merge 19 commits into
MHSanaei:mainfrom
Plus98ir:feat/clean-ip-scanner

Conversation

@Plus98ir

@Plus98ir Plus98ir commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a Clean IP scanner page (/clean-ip). It finds CDN edge IPs that work on a given ISP and saves them into a Host group as <group> · <ISP> (for example CDN · MCI), so each ISP's subscribers get IPs that work on their network. It supports Cloudflare, Fastly, Gcore and CloudFront.

Why

In censored networks (Iran, Russia, Turkmenistan, …) CDN-fronted inbounds only work through "clean" edge IPs. Which IPs are clean differs per ISP and changes over time. Admins currently run external scanners (CF-Clean-Scanner and similar) and copy the results into Hosts by hand. This page does it inside the panel, per ISP, and writes the result where 3x-ui already reads it: Host groups.

How it works (five steps on the page)

  1. Network: the page shows the ISP the visitor is on (ASN, org, short name such as MCI or MTN, from ipwho.is with ip-api.com as fallback). It warns when the visitor comes through WARP or a VPN, since the results would then be for the wrong network. CF-Connecting-IP is trusted only when the request really came from Cloudflare.
  2. Quick scan in the browser: the page probes edge IPs with a WebSocket handshake to wss://<ip>/ (the panel's CSP allows wss:). You pick the CDN and which of its ranges to scan, as in CF-Clean-Scanner. You can also paste your own IP list, find more IPs next to working ones, or download the result as CSV. Each working IP shows its datacenter (city and country) and can be filtered by country.
  3. Server-side check: the panel checks each IP that answered. On Cloudflare an IP can complete the TLS handshake and still refuse the domain with error 1034 or another 1xxx; those IPs are rejected.
  4. Full test (optional): a browser cannot do an upload test or fragmented TLS, so the page hands out a one-day command (curl … | python3 -, or PowerShell) to run on a device on that ISP.
    • The script uses the Python standard library only. It runs an upload test on the sampled IPs and on the quick scan's hits, then tests 26 Xray fragment settings (tlshello / 1-1, length and delay) against no fragment.
    • It reports back to the panel using only its token, and says why IPs failed (timeout, reset, certificate, …).
    • If the ISP blocks the panel's own domain, a second command reaches the panel through the fastest clean IP (curl --resolve), and the report goes through that IP too.
  5. Save: writes the chosen IPs as host rows into the ISP's Host group. When a fragment setting won, it also adds a TCP fragment finalMask to that group. A "Check saved IPs" / "Remove restricted" action re-checks rows saved earlier.

API (all documented in endpoints.ts, openapi.json and the docs MDX)

Route Purpose
GET /panel/api/cleanip/network ISP of the caller
GET /panel/api/cleanip/cdns CDNs and their range groups
POST /panel/api/cleanip/command make a one-day full-test command
GET /panel/api/cleanip/command/:token reports sent back so far
POST /panel/api/cleanip/verify server-side check: ok / restricted / unreachable
GET <base>cleanip/run/:token/script.py, POST <base>cleanip/run/:token/report public, token-only. They serve the script and accept its report

Commands live in memory only: at most 50, each valid for 24h, and lost on restart. The report endpoint accepts only its own token, caps the body at 256 KB, and validates every row (IPv4 only, known fragment shapes, bounded numbers).

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Documentation
  • Tests only
  • Build / CI / tooling
  • Other

Areas affected

  • Frontend (UI / panel pages)
  • Backend (API endpoints, login, settings)
  • Xray config generation
  • Subscription (share links / Clash / JSON)
  • Statistics / traffic counters
  • Database / migrations
  • Install / upgrade script
  • Docker image
  • Multi-node (sub-nodes)
  • Telegram bot

How was this tested?

  • Go tests (internal/web/service/cleanip_test.go) cover:
    • command input validation (a host that would break out of a Python literal is rejected);
    • every script placeholder gets filled;
    • report sanitizing;
    • expiry;
    • the CF-Connecting-IP trust rule;
    • Cloudflare 1xxx classification;
    • empty lists instead of null;
    • per-CDN and ticked-range sampling;
    • the clean-IP --resolve commands.
  • Python tests: some tests run the real embedded Python script (they are skipped when there is no Python 3). They cover the datacenter header parsing for each CDN, failure-reason counting, and that the report really goes through the pinned IP.
  • Frontend tests: target and range selection, fragment settings, datacenter codes.
  • Checks: make gen is clean, docs MDX regenerated, and go test ./internal/web/..., typecheck, lint, format and build all pass.
  • Production: running on two panels (Iran and Turkey, v3.11.0) since 2026-10-25, with real runs from Iranian ISPs including MCI. The hosts table and subscription output were compared before and after: rows outside the ISP groups are unchanged.

Screenshots / recordings

Steps 1-4 after a quick scan (local test panel, example domain):

clean IP scanner

The one-day full-test command:

full test command

Phone width:

clean IP scanner on a phone

Breaking changes

None. It is a new page and new routes. There is no DB migration: the scanner only writes ordinary Host rows through the existing host service.

Checklist

  • I tested the change locally and confirmed the described behavior.
  • I added or updated tests for the new behavior (when applicable).
  • go build ./... and the test suite pass locally.
  • For frontend changes: npm run lint, npm run typecheck, and npm run build pass.
  • I updated the Wiki / README / API docs if user-facing behavior changed.
  • My commits follow the project's existing message style.
  • I have no unrelated changes mixed into this PR.

A new page (Clean IP scanner) tests random addresses from Cloudflare's IPv4
ranges from the browser of whoever opens it, the only place that sees the
user's own ISP (the panel's server is usually abroad). Each probe is a
WebSocket to wss://<ip>/: the panel's CSP already allows wss: to any host, and
a fast TLS failure means the IP answers while silence means it is blocked.
Checked against a real TLS client from an Iranian ISP: no false positives in
two runs of 75 IPs.

GET /panel/api/cleanip/network names the caller's ISP (AS number via
ipwho.is, ip-api.com as fallback, cached for an hour, through the panel's
egress proxy) with the short names Iranian panels use (MCI, MTN, MKH, ...).
The best IPs go into a host group "<group> · <ISP>" that copies the chosen
group's settings, so each ISP gets its own configs; saving again replaces
that ISP's IPs, and the page lists and deletes the ISP groups.
…anner

A browser can neither upload through an IP nor cut the TLS ClientHello, and
some ISPs pass the handshake but cut the connection after ~1.3 KB, or block
the SNI unless the hello is fragmented. The scanner page now makes a one-day
command (curl ... | python3 - / irm ... | python -) that runs a
standard-library script on a device on the ISP to test: TLS with the CDN
domain as SNI and a real upload through each Cloudflare IP, then 26 Xray
fragment settings (tlshello and TCP segments, by length and delay) against no
fragment on five IPs. It posts the result back with the token alone.

The page shows each report, and saving writes the chosen IPs to the ISP's
host group "<group> · <ISP>" with the chosen fragment as a tcp "fragment"
Final Mask (lengths/delays arrays, other masks kept), so the JSON
subscription and the links' fm= give it to that ISP's clients. "No fragment"
is preselected unless a setting passes more often.

POST /panel/api/cleanip/command and GET /panel/api/cleanip/command/:token
sit behind the session; GET <base>cleanip/run/:token/script.py and POST
.../report are public, keyed by an unguessable 24-hour token that can only
add a report to its own job. Reported rows are validated (IPv4, fragment
packets/ranges, colo) before they are kept.
… on WARP/VPN

The page now follows the order the work is done in: 1 your network (ISP),
2 CDN domain (host group, port, the SNI the tests use, editable), 3 fragment
and upload test (the one-day command and the fragment to keep), 4 quick scan
(browser), 5 save (a summary of group, IPs and fragment, then save). The scan
table lists the upload-tested IPs first, marked, then the quick ones.

A visitor seen from a Cloudflare network (WARP, a VPN on Cloudflare) gets a
red warning instead of an ISP, so a scan is not saved for the wrong network.
A panel behind Cloudflare's proxy reads CF-Connecting-IP, but only when the
request comes from Cloudflare's published ranges, so the header can not be
spoofed by a direct client.
…the domain (error 1034)

Every Cloudflare edge answers the TLS handshake, but on an IP outside the zone's allowed ranges (and on third-party IPs in front of Cloudflare) it replies 403 "error code: 1034", so configs on it never connect. The browser scan could not see that and the full test counted any HTTP reply as a pass. POST /panel/api/cleanip/verify now checks found IPs from the panel's server with a real HTTPS request for the domain (ok / restricted / unreachable); the page runs it after a scan, keeps restricted IPs out of the selection, and can check and remove restricted IPs already saved in a host group and its ISP groups. The full-test script also rejects Cloudflare's 1xxx error pages.
…le into common.tsx

The DNS tunnel page (a separate PR) shows the same ISP card and numbered steps; keeping them in one module lets both pages share them without importing a page component.
…ith a country filter

The upload test already reports the CF-RAY colo of every working IP, but the page dropped it. Users pick IPs by where they exit (a German edge for a German server), so the table now names the city and country in the panel language and can be filtered by country.
…valid rows

A report whose IPs and fragments were all rejected marshalled them as null, and the page crashed on report.fragments.find.
…s, download CSV

The scanner only sampled Cloudflare's ranges. Admins often have IPs or ranges that worked before, and edges in the /24 of a working IP tend to work too, so the quick scan now takes a pasted list (IPs or CIDRs) and has "Find more nearby". Results download as CSV with the datacenter city and country.
Long step titles were cut off and the Copy / CSV buttons ran into them on narrow screens: titles now wrap and the buttons drop their text on mobile.
The scanner's strings were English outside en-US and fa-IR.
…uick scan's hits

The scanner only knew Cloudflare's ranges. The host group's CDN is now picked in the domain step (ranges served by GET /panel/api/cleanip/cdns, the same defaults as CF-Clean-Scanner) and both tests sample it. The browser can not read an edge's datacenter, so the command now upload-tests every quick-scan hit (up to 100, was 5 for the fragment test only): they get a datacenter, or show upload failed. The script reads the datacenter header of each CDN and prints why IPs failed (timeout, certificate, reset, ...) with a hint, and falls back to certifi where Python has no CA store.
The scanner sampled every default range, including popular extra ranges (5.x and others) that fail on many ISPs, with no way to leave them out. Step 2 now lists each CDN's range groups (official on, whole-AS groups opt-in) with a checkbox per group and per range plus extra CIDRs; the choice is kept per CDN and both the quick scan and the command sample exactly it.
… the panel's domain

On MCI the one-day command died in curl ("unexpected eof"): the ISP cuts the
panel's own domain, so neither the script download nor the report could reach
it. The command response now also carries curl --resolve variants that pin the
panel's domain to the fastest IP the quick scan found, and the script fetched
with ?via= sends its report through that same IP. Offered only when the panel
is opened by a domain and a page IP exists; it works when the panel's domain is
behind the same CDN, which the hint says.
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Fits with gaps (feature). Operators of CDN-fronted inbounds in filtered networks currently use outside scanners to find edge IPs that work on each ISP, then copy them into Hosts by hand. This pull request adds a Clean IP page that does that work inside the panel. It runs a quick browser scan, a server-side check for Cloudflare's "error 1034" edges, and an optional one-day script that a device on the target ISP runs for an upload and fragment test. The results are saved into a per-ISP copy of an existing Host group. The new routes, schema allowlist and docs copy are all in place, and nothing touches the database, Xray config generation or node sync. The main gap is the ISP label: a full-test report from a phone on one ISP is saved under whatever ISP the admin's browser is on. The commit that adds the "via a clean IP" command is written for ISPs that block the panel's domain, and in that case the browser and the test device are on different networks. A Host group whose path carries a query string, such as an early-data ?ed= path, also cannot get a full-test command at all.

No blocking issues — 0 HIGH / 2 MEDIUM / 4 LOW open (6 new), 0 pre-existing

Reviewed head: 151e05d

Evidence. There is no linked issue, so the claim comes from the body and the 14 commits. Panel routes sit under the authenticated /panel/api group (internal/web/controller/api.go:225), which has auth, the default-deny token scope and CSRF (api.go:196-201). The two public routes hang off the base group (internal/web/web.go:268, internal/web/controller/cleanip.go:100-104) and are keyed by an 18-byte random token (internal/web/service/cleanip_command.go:151-155). The script's literals are JSON-encoded from validated host/path/IP values (cleanip_command.go:129, :275-284). The fragment mask shape lengths/delays that withFragment writes (frontend/src/pages/clean-ip/scan.ts:195-198) matches upstream FragmentMask (xray-core infra/conf/transport_finalmask.go:262-267, Build at :293-315). It also matches what the existing FinalMask form writes (frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx:113-115), so links and JSON subscriptions see nothing new. Saves go through the existing host mutations (CleanIpPage.tsx:126, :437). Related work: open PR #6951 (DNS tunnel page) shares frontend/src/pages/clean-ip/common.tsx according to commit cde7508, so the two will touch the same module. I found no other PR or commit for a clean-IP scanner. No text in the diff tries to steer the review.

Design. The one-day command's URL is built from the Host header of the admin's own request (controller/cleanip.go:48-54). An admin who reaches the panel through an SSH port-forward, which is a common hardening setup, gets a command pointing at localhost that the ISP device cannot use. The command also carries the panel's secret base path to a device on the filtered network, in plain text when the panel is served over http. Commands live only in memory on whichever process issued them. That is fine for a single master, but a restart mid-test silently turns into "expired". The short ISP names cover only Iranian ASNs (internal/web/service/cleanip.go:29-34), so admins in Russia or Turkmenistan always type the label by hand. That ties into the first finding: the label is the one thing that decides which group subscribers get. The public /cleanip/run/... routes are not in endpoints.ts, contrary to the body's "all documented". They fall outside TestRouteRegistryContract's scope, so nothing fails, but they are also missing from the API reference.

Findings

MEDIUM — frontend/src/pages/clean-ip/CleanIpPage.tsx:431: a full-test report is saved under the browser's ISP, not the ISP the report came from. The page builds the group name from isp (:124), which is the browser's /network lookup or the typed value. The IPs come from the report and are ranked first (:216-219), the first five are preselected (:274-279), and the fragment also comes from the report (:178). The report's own ISP is only shown as a tag (FragmentStep.tsx:159) and is never compared with isp. Trigger: an admin on a fixed line (say SHT, which auto-fills) runs the command on an MCI phone, which is the scenario commit 151e05d builds the --resolve command for, since MCI blocks the panel's domain. Saving writes MCI's upload-tested IPs and MCI's fragment into "group · SHT", with no warning. SHT subscribers get IPs and a fragment mask chosen for another network, and MCI subscribers get nothing. This pull request added both the report flow and the save path. The finding comes from reading the code; I did not reproduce it.

MEDIUM — internal/web/service/cleanip_command.go:38: the full-test command is refused for any Host group whose path contains ?. cleanIpPathRe allows no ?, and NewCommand returns errCleanIpBadRequest (:129). The page always sends group?.path (CleanIpPage.tsx:340) and has no field to change it. Host.Path is free text (internal/database/model/model.go:1192), and a WebSocket early-data path such as /ws?ed=2048 is a valid value the panel emits as-is. For such a group, step 3 only shows the generic "command" toast, and the upload and fragment test cannot run. The PR added this validator. How common such paths are is my inference; the rejection itself comes from reading the code.

LOW — internal/web/service/cleanip_script.py:299: the report silently retries with certificate checking turned off. The stated reason ("a panel with a self-signed certificate") cannot occur through the commands the panel hands out, because curl -fsSL and irm both verify the panel's certificate before the script ever runs (cleanip_command.go:182-189). What does reach this branch is a Python with no usable CA store. The script expects that case for its probes (:162-172), but send_report uses the plain default context (:295). On such a device, the report URL and its token go to whoever intercepts TLS on that ISP. With the token, that party can post up to 20 reports (cleanip_command.go:361-364). The newest report drives the table (CleanIpPage.tsx:170), and its IPs are preselected without the server check, which only runs on quick-scan hits (:310). The PR added both the fallback and the public report endpoint.

LOW — internal/web/service/cleanip_script.py:249: the script crashes before reporting when the run has fewer than five distinct target IPs. random.sample raises ValueError when asked for more items than the population holds. That happens on line 249, before send_report (:275), so the admin waits for a report that never arrives. Trigger: no quick-scan hits, plus a range choice that yields under five addresses. Examples are a single extra /30 (accepted by scanRanges, scan.ts:49, and cleanIpTickedRanges, cleanip_command.go:246) or one small range with a low per-range count. This comes from reading the code; I did not run it.

LOW — internal/web/service/cleanip.go:37-41: the PR adds two copies of Cloudflare's IPv4 list. One is the list that decides when CF-Connecting-IP is trusted (CleanIpVisitorIp, :61-73). The other is the "official" scan group in cleanip_cdns.go:19-23. When Cloudflare publishes a new range and only one copy is updated, the scan and the trust rule disagree. Visitors on the new range would then have their header ignored and be looked up as Cloudflare/WARP.

LOW — internal/web/controller/cleanip.go:31-33 and internal/web/service/cleanip_command.go:222-224: two doc comments sit on the wrong declaration. The comment for network is attached to cdns. The comment "Script is the job's test…" is attached to cleanIpCdnRanges, ahead of that function's own comment, so godoc and editors show the wrong text for both.

Coverage (head 151e05d, 46 files, +6,795/−0; the diff was not built or run here)

  • CI: CI and Docs CI are action_required and await maintainer approval, so nothing they cover (Go tests incl. the embedded-Python tests, lint, typecheck, i18n dead-key test, gen-check, docs) is verified; prepare succeeded.
  • Controllers / public surface: controller/cleanip.go, api.go, web.go: auth group, token scope default-deny, the token-only run routes and the 256 KB body cap all checked; no exposed hole found.
  • Services: cleanip.go, cleanip_command.go, cleanip_verify.go, cleanip_cdns.go (structure, not every CIDR): validation, locking, TTL/eviction and probe timeouts read.
  • Embedded script cleanip_script.py: literal injection (JSON-encoded validated values), fragment simulation, report path and --resolve pinning read.
  • Wire format: the fragment finalMask shape was checked against xray-core infra/conf/transport_finalmask.go (base-pinned version) and against the existing FinalMaskForm. Links, subscriptions and Xray config generation are untouched.
  • Endpoint chain: endpoints.ts covers the five /panel/api/cleanip/* routes, StructAllow lists all ten structs, and docs/public/openapi.json is byte-identical to frontend/public/openapi.json. The MDX page was added; whether it is fresh was not run.
  • DB / runtime: no model, migration, runtime or node-sync change; saves use the existing host mutations.
  • Frontend: CleanIpPage.tsx (save, verify, rows, command), scan.ts, FragmentStep.tsx and common.tsx read; RangePicker.tsx, colos.ts and CleanIpPage.css were skimmed only.
  • Tests: Go cleanip_test.go and the three Vitest files read; they assert exact values, and nothing fake was found.
  • Not reviewed: wording in the 14 translation files and docs/content (higher-bar areas), and the generated files.

@HamedSanaei

Copy link
Copy Markdown
Contributor

⚠️ Hosting abuse / server-suspension risk — please address before merging

The Clean IP scanner is useful, but probing many IPs from a datacenter VPS can be flagged as network/port scanning under hosting providers' acceptable-use policies. That can result in abuse complaints, server suspension/termination, or account restrictions.

I reviewed the current code:

  • The quick scan runs in the browser; however, POST /panel/api/cleanip/verify opens outbound TCP/TLS connections from the 3x-ui server to up to 200 IPs per request, using 16 concurrent workers (internal/web/service/cleanip_verify.go). A per-request cap does not prevent repeated requests from probing thousands of IPs; I did not find a global scan-rate/target budget.
  • validIPv4 only verifies address syntax, so server-side checks do not appear restricted to public IPs belonging to the selected CDN. Internal/private/reserved destinations should not be probeable.
  • The optional full-test Python script runs on the chosen client/ISP device, not on the 3x-ui server, but it also uses 24 workers and many fragment comparison probes. That machine's provider policies matter too.

Suggested safeguards:

  1. Never run bulk IP discovery from the panel server by default. Keep it in the browser or on a separately selected device; allow server verification only for a small, explicitly selected shortlist.
  2. Enforce backend global + per-user limits: low concurrency, requests per minute, unique target IPs per hour/day, cooldowns, and no automatic repeat sweeps. UI limits alone are insufficient.
  3. Reject private, loopback, link-local, multicast, and other non-public IPs; ideally restrict server-originated probes to the selected CDN's published IP ranges. Prevent SSRF/internal-network probing.
  4. Make the scan's source IP/machine, maximum target count, and provider-AUP/abuse warning explicit, with user confirmation and a cancellation mechanism.
  5. Add tests for rate-limit enforcement, repeated/concurrent requests, and blocked target classes.

The current server-side probe uses port 443, not a multi-port sweep, but contacting large numbers of destination IPs may still be treated as scanning by the datacenter.

@MHSanaei @Plus98ir — please consider these safeguards a merge prerequisite. A panel feature should not put operators' servers or hosting accounts at risk.

Review on MHSanaei#6950: outgoing connections from the panel's server to many IPs
can get it flagged by its hosting provider, and the check took any IPv4,
private ones included. The server now only contacts IPs inside the chosen
CDN's published ranges (others come back "skipped"), at most 50 per request,
4 at a time and 300 distinct IPs an hour across requests ("limited" past
that; an IP checked this hour is answered from memory), and stops when the
caller goes away. The page no longer checks every scan hit by itself: the
admin checks the selected or saved IPs after a confirmation that names the
count and the reason, and can cancel.

Also from the review:
- a full-test report from another ISP than the browser's is saved under the
  report's ISP, without the browser's own quick-scan hits
- a Host path with a query (/ws?ed=2048) gets the full-test command
- the script no longer retries its report with certificate checks off, and
  no longer crashes before reporting when it has fewer than five IPs
- Cloudflare's IPv4 list exists once, for both scanning and CF-Connecting-IP
@Plus98ir

Copy link
Copy Markdown
Contributor Author

Thanks, all addressed in 3d59e03.

  • The server check only contacts IPs inside the selected CDN's published ranges. Anything else, including private, loopback and link-local addresses, comes back skipped without a connection.
  • At most 50 IPs per request, 4 at a time, and 300 distinct IPs per rolling hour across all requests (limited past that). An IP checked within the hour is answered from memory, so repeated requests add no traffic.
  • It no longer runs after a scan by itself: the admin checks the selected or saved IPs after a confirmation that states the count and the hosting-provider risk, and can cancel; the request context stops the probes.
  • Tests cover each limit and the blocked target classes (internal/web/service/cleanip_verify_test.go).

The bot's findings are fixed in the same commit: a full-test report from another ISP is saved under that ISP, a Host path with a query (/ws?ed=2048) gets the command, the script no longer retries its report with certificate checks off or crashes with fewer than five IPs, and Cloudflare's IPv4 list exists once.

Plus98ir added a commit to Plus98ir/3x-ui that referenced this pull request Oct 27, 2026
Review on MHSanaei#6950: outgoing connections from the panel's server to many IPs
can get it flagged by its hosting provider, and the check took any IPv4,
private ones included. The server now only contacts IPs inside the chosen
CDN's published ranges (others come back "skipped"), at most 50 per request,
4 at a time and 300 distinct IPs an hour across requests ("limited" past
that; an IP checked this hour is answered from memory), and stops when the
caller goes away. The page no longer checks every scan hit by itself: the
admin checks the selected or saved IPs after a confirmation that names the
count and the reason, and can cancel.

Also from the review:
- a full-test report from another ISP than the browser's is saved under the
  report's ISP, without the browser's own quick-scan hits
- a Host path with a query (/ws?ed=2048) gets the full-test command
- the script no longer retries its report with certificate checks off, and
  no longer crashes before reporting when it has fewer than five IPs
- Cloudflare's IPv4 list exists once, for both scanning and CF-Connecting-IP
…nbound

The page only offered existing host groups and always wrote a separate
"<host> · <ISP>" group, so an admin without a host had to make a dummy
one first and the scanned IPs never landed on the host they picked.
CDN-capable inbounds without a host are now listed and get their host
created on save, a "Save into" choice writes either into the host itself
or its ISP group (default: the ISP group when one exists), the saved host
is turned on, and the tested domain becomes the SNI when it has none.
@Plus98ir

Copy link
Copy Markdown
Contributor Author

Pushed ca5c47f after testing on a fresh server:

  • If there is no host group yet, CDN-capable inbounds (ws / httpupgrade / xhttp / grpc) that no host serves are listed as " (new host)". Saving creates their host, with the domain read from the inbound's transport host or TLS server name. Before this, the admin had to create a placeholder host by hand first.
  • A new "Save into" choice: the picked host itself, or its <host> · <ISP> group. The default is the ISP group when one already exists, otherwise the host itself. Before this, every save wrote a separate ISP group.
  • The saved host is enabled. If it has no SNI, the domain the IPs were tested with becomes its SNI, so IP-address configs still present the right name.

Tests: src/test/clean-ip-save.test.ts.

The one-day command piped whatever the script URL returned into Python. On a
panel reached over plain HTTP that fetch crosses the ISP being tested, so anyone
on the path could swap in their own code. The script (and the pinned variant
fetched through a page IP) is now fixed when the command is made, and each
command is a python -c that fetches it and runs it only if its SHA-256 matches.

- The pinned command maps the panel's domain to the page IP for the fetch, as
  curl --resolve did, so it no longer needs curl and works the same in
  PowerShell, where a pipe re-encodes the script and would break any hash.
- The command's base URL honours X-Forwarded-Proto/-Host only from a trusted
  proxy, like resolveHost, and keeps the port the browser used.
Plus98ir added a commit to Plus98ir/3x-ui that referenced this pull request Nov 2, 2026
Brings the clean IP and DNS resolver test fixes from MHSanaei#6950/MHSanaei#6951 to this
branch: each one-day command is a python -c that runs its script only if the
SHA-256 matches the one fixed when the command was made, and the shared
one-day store keeps those scripts. The finder now uses the same trusted-proxy
panelBaseURL as the other pages, so the two copies of it become one.
@Plus98ir

Plus98ir commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 968f8d0. The review of #7048 found the same problem in this PR's one-day command, so it is fixed here too.

  • The command piped whatever the script URL returned into Python. On a panel opened over plain HTTP, anyone on the ISP being tested could swap in their own code. The script is now fixed when the command is made. Each command is a python -c that fetches the script and runs it only if its SHA-256 matches; otherwise it prints that the script did not match and runs nothing.
  • The pinned variant (fetched through a page IP when the ISP blocks the panel's domain) now maps the domain to that IP inside Python, like curl --resolve did. curl is no longer needed. This also avoids PowerShell 5.1 pipes, which re-encode the script and would break any hash.
  • panelBaseURL reads X-Forwarded-Proto/-Host only from a trusted proxy, like resolveHost, and keeps the port the browser used.

Tests: TestCleanIpCommandRefusesASwappedScript runs the real direct and pinned commands against a server that returns other code. It is red without the hash check and green with it. TestPanelBaseURLTrustsForwardedHeadersOnlyFromAProxy covers the base URL. make verify passes locally, except TestPublicIPStatusUsesRefreshedAddressWithoutBlocking, which fails on Windows on the base commit too (gopsutil's load goroutine inside the synctest bubble).

The fake panel's handler runs on the server goroutine while the test resets and reads the fetch list, which the race job flagged on MHSanaei#6950.
Plus98ir added a commit to Plus98ir/3x-ui that referenced this pull request Nov 2, 2026
…omically

The fake panel's handler runs on the server goroutine while the test resets and reads the fetch list, which the race job flagged on MHSanaei#6950.
Plus98ir added a commit to Plus98ir/3x-ui that referenced this pull request Nov 2, 2026
…omically

The fake panel's handler runs on the server goroutine while the test resets and reads the fetch list, which the race job flagged on MHSanaei#6950.

This branch has not been deployed

No deployments
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.

2 participants