Skip to content

Commit dbd8732

Browse files
committed
release: v1.5.1
Fix: the active node reported "no speed" on a general / auto speed sweep. Testing it spun a second temp xray to the same server — fatal for WireGuard (one session per peer key), so the temp test and the live tunnel fought and the reachability gate flapped to "unreachable". The active node is now measured through the live tunnel: config_gen adds a loopback speed-probe inbound pinned to the active outbound, and speedtest reuses the session already up. No second session, no live-tunnel disruption. Non-active nodes unchanged. New: REALITY dest / SNI scanner at x-ui inbound creation. A "Scan (via active node)" button on the reality-sni preset field probes a domain or a bare IP through the active node and reports TLS1.3 / h2 suitability plus the cert the endpoint presents (a bare-IP scan surfaces the domain behind it). Extends sni_scan; no hardcoded candidate lists. UX: failed sweep nodes show an amber "no speed · age" badge instead of a blank row (the failure is stamped so it persists). Dark-theme polish — the main pane matches the sidebar and the brand accent returns to the sky-blue ramp in dark only (light keeps the TailAdmin indigo). Removed the redundant Check SNI from the add-node form. Relevant backend suites (config_gen, speedtest, autocheck, nodecircle) pass; frontend build + 82 tests green. No schema migration.
1 parent 52d3bc5 commit dbd8732

14 files changed

Lines changed: 346 additions & 109 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,44 @@ All notable user-facing changes to PiTun. Full per-release detail lives in the
44
[GitHub Releases](https://github.com/DaveBugg/PiTun/releases); this file is the
55
committed summary.
66

7+
## v1.5.1 — 2026-08-06
8+
9+
Fixes the active node reporting no speed on a general sweep, adds a REALITY
10+
dest / SNI scanner to x-ui inbound creation, and a small dark-theme refresh.
11+
12+
### Added
13+
14+
- **REALITY dest / SNI scanner at inbound creation.** Creating an x-ui inbound
15+
from a REALITY preset gains a **Scan (via active node)** button that probes
16+
the SNI field's target — a domain OR a bare IP — through the active node and
17+
reports TLS 1.3 / HTTP-2 suitability plus the certificate the endpoint
18+
presents, so a bare-IP scan surfaces the domain behind it (usable as the
19+
serverName). Mirrors 3x-ui's reality-sni scan, routed like every other
20+
server op. No hardcoded candidate lists — it scans exactly what you enter.
21+
22+
### Fixed
23+
24+
- **The active node reported "no speed" on a general / auto speed sweep.**
25+
Every speed test spun up a throwaway xray, which for the *active* node opens
26+
a SECOND tunnel to the same server — fatal for WireGuard, which holds one
27+
session per peer key: the temp test and the live tunnel fought, the
28+
reachability gate flapped to "unreachable", and the reading failed (briefly
29+
disrupting the live tunnel too). The active node is now measured through the
30+
live tunnel — config_gen adds a loopback `speed-probe` inbound pinned to the
31+
active outbound and the test reuses the session already up. No second
32+
session, no disruption, an honest number. Non-active nodes are unchanged.
33+
34+
### Changed
35+
36+
- **Failed speed checks are visible.** A node the sweep couldn't measure now
37+
shows an amber `no speed · <age>` badge instead of a blank row — the check
38+
is stamped so the failure persists across reloads.
39+
- **Dark-theme polish.** The main content pane now matches the sidebar colour,
40+
and the brand accent returns to the original sky-blue ramp in dark mode only
41+
(light keeps the TailAdmin indigo).
42+
- Removed the redundant "Check SNI" button from the add-node form — the scan
43+
belongs at inbound creation, not when registering an already-existing node.
44+
745
## v1.5.0 — 2026-08-05
846

947
Promotes v1.5.0-beta.1 to stable and lands a UI-framework refresh, a full

backend/app/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# OpenAPI metadata, `/health` response, and `/system/status` so the
66
# frontend can display it next to the xray version. Bump this on each
77
# release — frontend keeps its own version in `frontend/package.json`.
8-
APP_VERSION = "1.5.0"
8+
APP_VERSION = "1.5.1"
99

1010

1111
class Settings(BaseSettings):

backend/app/core/autocheck_scheduler.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,23 +160,30 @@ async def run_sweep(
160160
datetime.now(timezone.utc) < _as_utc(node.speed_tested_at) + interval:
161161
skipped += 1
162162
continue
163+
mbps = mx = None
163164
try:
164165
result = await _speedtest(node)
166+
mbps = result.get("download_mbps")
167+
mx = result.get("max_mbps")
165168
except Exception as exc: # noqa: BLE001 — isolate per node
166169
logger.info("AutoCheck: node %d speedtest error: %s", nid, exc)
167-
failed += 1
168-
continue
169-
mbps = result.get("download_mbps")
170+
# Stamp the check time either way. On failure we clear the
171+
# reading but keep the timestamp, so the UI shows a "no speed"
172+
# badge (with age) instead of a blank row — a node that
173+
# couldn't be measured should say so, not look untested.
174+
# `force` (manual Speed All) bypasses the staleness guard
175+
# above, so a failed node is always retried by hand.
170176
if mbps:
171177
node.speed_mbps = float(mbps)
172-
mx = result.get("max_mbps")
173178
node.speed_max_mbps = float(mx) if mx is not None else None
174-
node.speed_tested_at = datetime.now(timezone.utc)
175-
session.add(node)
176-
await session.commit()
177179
tested += 1
178180
else:
181+
node.speed_mbps = None
182+
node.speed_max_mbps = None
179183
failed += 1
184+
node.speed_tested_at = datetime.now(timezone.utc)
185+
session.add(node)
186+
await session.commit()
180187

181188
logger.info(
182189
"AutoCheck sweep done: tested=%d skipped=%d failed=%d (scope=%d)",

backend/app/core/config_gen.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,18 @@ def resolve_active_circle(circles, active_node_id):
890890
return None, None
891891

892892

893+
# Loopback SOCKS inbound the backend uses to speed-test the ACTIVE node
894+
# through the LIVE tunnel instead of spinning a second temp xray. A temp
895+
# instance re-opens the node's outbound — for WireGuard that means a SECOND
896+
# session with the same peer key, which the server can only hold once, so the
897+
# temp test and the live tunnel fight and the reading fails (and the live
898+
# tunnel is briefly disrupted). This inbound is force-routed to the active
899+
# outbound (see the top routing rule), so measuring through it reuses the
900+
# session that's already up. Present only when there's an active node.
901+
SPEED_PROBE_PORT = 10809
902+
SPEED_PROBE_TAG = "speed-probe"
903+
904+
893905
def generate_config(
894906
active_node: Optional[Node],
895907
all_nodes: List[Node],
@@ -1026,6 +1038,17 @@ def generate_config(
10261038
},
10271039
]
10281040

1041+
# Live-tunnel speed-probe inbound (loopback). Only useful when a node is
1042+
# active; the top routing rule below pins it to the active outbound.
1043+
if active_node is not None:
1044+
inbounds.append({
1045+
"tag": SPEED_PROBE_TAG,
1046+
"protocol": "socks",
1047+
"listen": "127.0.0.1",
1048+
"port": SPEED_PROBE_PORT,
1049+
"settings": {"auth": "noauth", "udp": False},
1050+
})
1051+
10291052
# TPROXY inbounds
10301053
if inbound_mode in ("tproxy", "both"):
10311054
# Per-set TPROXY inbounds first (v1.4). One inbound per RoutingSet
@@ -1335,6 +1358,17 @@ def generate_config(
13351358
# Default: direct
13361359
routing_rules.append({"type": "field", "ip": ["0.0.0.0/0", "::/0"], "outboundTag": "direct"})
13371360

1361+
# Force the speed-probe inbound straight to the active outbound, ahead of
1362+
# every other rule, so a live-tunnel measurement always egresses via the
1363+
# active node (never diverted by a geoip/domain/set rule).
1364+
if active_node is not None:
1365+
routing_rules.insert(0, {
1366+
"type": "field",
1367+
"inboundTag": [SPEED_PROBE_TAG],
1368+
**({"balancerTag": active_balancer_tag} if active_balancer_tag
1369+
else {"outboundTag": f"node-{active_node.id}"}),
1370+
})
1371+
13381372
config: Dict[str, Any] = {
13391373
"log": {
13401374
"loglevel": log_level,

backend/app/core/speedtest.py

Lines changed: 110 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
from sqlmodel.ext.asyncio.session import AsyncSession
2525

2626
from app.config import settings
27-
from app.core.config_gen import _build_outbound
27+
from app.core.config_gen import _build_outbound, SPEED_PROBE_PORT
2828
from app.core.healthcheck import HealthChecker
2929
from app.database import get_async_engine
30-
from app.models import Node
30+
from app.models import Node, Settings as DBSettings
3131

3232
logger = logging.getLogger(__name__)
3333

@@ -73,6 +73,51 @@
7373
_REACH_TIMEOUT_S = 6.0
7474

7575

76+
async def _is_active_node(node_id: int) -> bool:
77+
"""Is `node_id` the currently active node? Never raises — a lookup hiccup
78+
just falls back to the temp-xray path."""
79+
try:
80+
async with AsyncSession(get_async_engine()) as s:
81+
row = (await s.exec(
82+
select(DBSettings).where(DBSettings.key == "active_node_id")
83+
)).first()
84+
return bool(row and row.value and int(row.value) == node_id)
85+
except (TypeError, ValueError):
86+
return False
87+
except Exception as exc: # noqa: BLE001 — never block a speed test on this
88+
logger.debug("active-node lookup failed: %s", exc)
89+
return False
90+
91+
92+
async def _live_socks_ready(node_id: int) -> bool:
93+
"""True when `node_id` is active AND the main xray's speed-probe inbound is
94+
listening — i.e. we can measure it through the LIVE tunnel instead of a
95+
second temp xray (which, for WireGuard, would fight the live session)."""
96+
return await _is_active_node(node_id) and await _port_open("127.0.0.1", SPEED_PROBE_PORT)
97+
98+
99+
async def _gate_and_measure(node: Node, socks_port: int) -> Dict:
100+
"""Reachability gate + download measurement over an already-open SOCKS
101+
port — a throwaway xray OR the live speed-probe inbound. Shared so both
102+
paths measure identically."""
103+
reachable, latency_ms, detail = await _probe_reachable(socks_port)
104+
if not reachable:
105+
return _result(node, error=f"unreachable: {detail}",
106+
reachable=False, latency_ms=latency_ms)
107+
last_error = "no targets reachable"
108+
for label, url in _STREAM_TARGETS:
109+
try:
110+
measured = await _measure_download(socks_port, label, url)
111+
except Exception as exc: # noqa: BLE001 — try the next target
112+
last_error = f"{label}: {str(exc)[:120]}"
113+
continue
114+
if measured is not None:
115+
avg, mx = measured
116+
return _result(node, download_mbps=avg, max_mbps=mx,
117+
reachable=True, latency_ms=latency_ms)
118+
return _result(node, error=last_error, reachable=True, latency_ms=latency_ms)
119+
120+
76121
async def speedtest_node(node: Node) -> Dict:
77122
"""Measure download speed through a freshly-spawned xray for `node`.
78123
@@ -92,6 +137,15 @@ async def speedtest_node(node: Node) -> Dict:
92137
proc: Optional[asyncio.subprocess.Process] = None
93138
tmp_path: Optional[str] = None
94139
try:
140+
# Active node: measure through the LIVE tunnel (the main xray's
141+
# speed-probe inbound → active outbound) instead of a throwaway xray.
142+
# A second instance re-opens the node's outbound; for WireGuard that's
143+
# a second session with the same peer key, which the server can't hold
144+
# twice — the temp test and the live tunnel fight, the reachability
145+
# gate flaps to "unreachable", and the live tunnel is briefly disrupted.
146+
if await _live_socks_ready(node.id):
147+
return await _gate_and_measure(node, SPEED_PROBE_PORT)
148+
95149
# Resolve the full chain: [entry_parent, ..., node]. Only the entry
96150
# parent's address needs to be reachable directly; deeper hops tunnel.
97151
try:
@@ -121,26 +175,7 @@ async def speedtest_node(node: Node) -> Dict:
121175
if proc is None or start_err:
122176
return _result(node, error=start_err or "Failed to start temp xray")
123177

124-
# Reachability gate — skip the speed fallbacks entirely on a dead node.
125-
reachable, latency_ms, detail = await _probe_reachable(socks_port)
126-
if not reachable:
127-
return _result(node, error=f"unreachable: {detail}",
128-
reachable=False, latency_ms=latency_ms)
129-
130-
# Measure like the live test: avg after warm-up + peak steady window.
131-
# First target that yields data wins.
132-
last_error = "no targets reachable"
133-
for label, url in _STREAM_TARGETS:
134-
try:
135-
measured = await _measure_download(socks_port, label, url)
136-
except Exception as exc: # noqa: BLE001 — try the next target
137-
last_error = f"{label}: {str(exc)[:120]}"
138-
continue
139-
if measured is not None:
140-
avg, mx = measured
141-
return _result(node, download_mbps=avg, max_mbps=mx,
142-
reachable=True, latency_ms=latency_ms)
143-
return _result(node, error=last_error, reachable=True, latency_ms=latency_ms)
178+
return await _gate_and_measure(node, socks_port)
144179

145180
except Exception as exc:
146181
logger.warning("Speedtest node %d error: %s", node.id, exc)
@@ -167,33 +202,40 @@ async def speedtest_stream(node: Node):
167202
tmp_path: Optional[str] = None
168203
try:
169204
yield {"phase": "start", "node_id": node.id}
170-
try:
171-
chain = await _resolve_chain(node)
172-
except Exception as exc: # noqa: BLE001
173-
yield {"phase": "error", "error": f"chain resolve: {exc}"}
174-
return
175205

176-
entry = chain[0]
177-
entry_ip: Optional[str] = None
178-
if entry.protocol != "naive":
206+
# Active node → measure through the LIVE tunnel (speed-probe inbound),
207+
# never a second temp xray (WireGuard single-session contention — see
208+
# speedtest_node). Otherwise spin the usual throwaway instance.
209+
if await _live_socks_ready(node.id):
210+
socks_port = SPEED_PROBE_PORT
211+
else:
179212
try:
180-
entry_ip = await HealthChecker._resolve_direct(entry.address)
213+
chain = await _resolve_chain(node)
181214
except Exception as exc: # noqa: BLE001
182-
yield {"phase": "error", "error": f"dns entry: {exc}"}
183-
return
184-
if node.protocol == "naive":
185-
if not node.internal_port:
186-
yield {"phase": "error", "error": "naive sidecar port not allocated"}
187-
return
188-
if not await _port_open("127.0.0.1", int(node.internal_port)):
189-
yield {"phase": "error", "error": f"naive sidecar not on :{node.internal_port}"}
215+
yield {"phase": "error", "error": f"chain resolve: {exc}"}
190216
return
191217

192-
yield {"phase": "connecting", "host": "starting xray"}
193-
socks_port, proc, tmp_path, start_err = await _start_temp_xray(node, chain, entry_ip)
194-
if proc is None or start_err:
195-
yield {"phase": "error", "error": start_err or "failed to start temp xray"}
196-
return
218+
entry = chain[0]
219+
entry_ip: Optional[str] = None
220+
if entry.protocol != "naive":
221+
try:
222+
entry_ip = await HealthChecker._resolve_direct(entry.address)
223+
except Exception as exc: # noqa: BLE001
224+
yield {"phase": "error", "error": f"dns entry: {exc}"}
225+
return
226+
if node.protocol == "naive":
227+
if not node.internal_port:
228+
yield {"phase": "error", "error": "naive sidecar port not allocated"}
229+
return
230+
if not await _port_open("127.0.0.1", int(node.internal_port)):
231+
yield {"phase": "error", "error": f"naive sidecar not on :{node.internal_port}"}
232+
return
233+
234+
yield {"phase": "connecting", "host": "starting xray"}
235+
socks_port, proc, tmp_path, start_err = await _start_temp_xray(node, chain, entry_ip)
236+
if proc is None or start_err:
237+
yield {"phase": "error", "error": start_err or "failed to start temp xray"}
238+
return
197239

198240
# Reachability gate first — if the node can't reach the internet, say
199241
# so and stop instead of grinding every speed fallback to timeout.
@@ -272,6 +314,8 @@ async def sni_scan(domain: str, node: Optional[Node] = None) -> Dict:
272314
probed directly. Returns {ok, tls13, http2, status, via, detail}."""
273315
import re
274316

317+
import ipaddress
318+
275319
domain = (domain or "").strip()
276320
for pfx in ("https://", "http://"):
277321
if domain.lower().startswith(pfx):
@@ -280,6 +324,16 @@ async def sni_scan(domain: str, node: Optional[Node] = None) -> Dict:
280324
if not domain:
281325
return {"ok": False, "detail": "empty domain", "via": "direct"}
282326

327+
# A bare IP (or any host whose cert won't validate) still needs probing
328+
# so we can read the cert it presents — that's the "scan the IP and see
329+
# what turns up" case. `-k` below lets the handshake finish on a mismatch
330+
# so curl still prints the server-certificate block.
331+
try:
332+
ipaddress.ip_address(domain)
333+
is_ip = True
334+
except ValueError:
335+
is_ip = False
336+
283337
proc: Optional[asyncio.subprocess.Process] = None
284338
tmp_path: Optional[str] = None
285339
socks_port: Optional[int] = None
@@ -306,6 +360,8 @@ async def sni_scan(domain: str, node: Optional[Node] = None) -> Dict:
306360

307361
cmd = ["curl", "-sS", "-v", "-o", "/dev/null", "--max-time", "10",
308362
"--http2", "-A", _STREAM_UA]
363+
if is_ip:
364+
cmd.append("-k")
309365
if socks_port:
310366
cmd += ["-x", f"socks5h://127.0.0.1:{socks_port}"]
311367
cmd.append(f"https://{domain}")
@@ -323,6 +379,15 @@ async def sni_scan(domain: str, node: Optional[Node] = None) -> Dict:
323379
status = int(m_status.group(1)) if m_status else None
324380
reachable = status is not None
325381

382+
# Pull the certificate the endpoint actually presents, so scanning a
383+
# bare IP still surfaces the domain(s) behind it (what the operator
384+
# can use as the REALITY serverName). curl prints "subject: CN=…" and,
385+
# on a name match, the cert's own name on the subjectAltName line.
386+
m_subj = re.search(r"subject:\s*([^\r\n]+)", txt)
387+
cert_subject = m_subj.group(1).strip() if m_subj else None
388+
m_san = re.search(r"subjectAltName:[^\r\n]*?cert's \"([^\"]+)\"", txt)
389+
cert_name = m_san.group(1) if m_san else None
390+
326391
ok = bool(tls13 and http2 and reachable and status < 400)
327392
if ok:
328393
detail = "good REALITY dest"
@@ -341,6 +406,7 @@ async def sni_scan(domain: str, node: Optional[Node] = None) -> Dict:
341406
return {
342407
"ok": ok, "tls13": tls13, "http2": http2,
343408
"status": status, "via": via, "detail": detail,
409+
"cert_subject": cert_subject, "cert_name": cert_name,
344410
}
345411
except asyncio.TimeoutError:
346412
return {"ok": False, "detail": "timed out", "via": via}

backend/tests/test_autocheck.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ async def fake_speed(node):
115115
assert summary["tested"] == 1
116116
session.expire_all()
117117
assert session.get(Node, good.id).speed_mbps == 42.0
118+
# A failed check is stamped (time set, reading cleared) so the UI
119+
# can show a "no speed" badge instead of a blank row.
120+
bad_row = session.get(Node, bad.id)
121+
assert bad_row.speed_tested_at is not None
122+
assert bad_row.speed_mbps is None
118123

119124

120125
class TestAutoCheckAPI:

0 commit comments

Comments
 (0)