Skip to content

Commit 1421799

Browse files
committed
release: v1.6.6 — country flags from the observed exit, not the address
Flags never appeared on nodes addressed by hostname, and a chained node showed the wrong country. Two separate causes. The write hook was undoing the work. Enrichment runs on every node write, where DNS is deliberately skipped — a stalled resolver would hold up the flush — so for a hostname it found no country and stripped the prefix. Including the one /apply-country-flags had just resolved and set: the button reported renaming nodes that then showed no flag. It now leaves a name alone when it cannot determine the country. And the address was the wrong thing to ask. The speed test already holds a tunnel open, so it now reads the exit address back through it — one request to a host the reachability gate already contacts, returning both the address the internet saw and its country. That is where traffic surfaces, which for a chained node is the last hop, and it needs no GeoLite2 database on the box. Every path that opens a tunnel records it: manual test, speed-all, the live stream, the internet check, the background sweep. Verified on a live box: seven nodes, all previously unflagged or flagged from their address, now carry the country their traffic actually exits in — one of them corrected from the address's country to the real one. Migration 026 adds node.country / exit_ip / exit_checked_at. 1389 backend tests, 102 frontend tests green.
1 parent b1b59e3 commit 1421799

18 files changed

Lines changed: 611 additions & 66 deletions

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,39 @@ 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.6.6 — 2026-08-15
8+
9+
**Country flags now come from where the traffic actually comes out.** Nodes
10+
addressed by hostname never got one, and a chained node showed the wrong one.
11+
12+
### Fixed
13+
14+
- **The write hook stripped the flag the moment it was applied.** Enrichment
15+
runs on every node write, where DNS is deliberately skipped (a stalled
16+
resolver would hold up the database flush) — so for a hostname address it
17+
found no country and removed the prefix. Including the one
18+
`/apply-country-flags` had just resolved and set: the button reported
19+
renaming nodes that then showed no flag. It now leaves a name alone when it
20+
cannot determine the country, and only replaces a prefix when it can.
21+
22+
### Added
23+
24+
- **The speed test reads the exit address back through the tunnel.** One
25+
small request to a host the reachability gate already contacts returns both
26+
the address the internet saw and its country, so the flag reflects where
27+
traffic surfaces — for a chained node that is the last hop, not the entry
28+
whose address is stored. It needs no GeoLite2 database on the box, and it
29+
answers for a hostname that never resolved locally.
30+
31+
- Every check that opens a tunnel now records it: the manual speed test,
32+
"speed all", the live streaming test, the internet check and the background
33+
auto-check sweep. A node whose exit moves country is re-flagged by the next
34+
sweep without anyone pressing anything.
35+
36+
- `country`, `exit_ip` and `exit_checked_at` on the node (migration 026). The
37+
country badge prefers the observed country over anything inferred from the
38+
address, and its tooltip names the country and the exit address.
39+
740
## v1.6.5 — 2026-08-15
841

942
**The pre-upgrade database snapshot wasn't being taken — and the installer

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,9 +477,12 @@ Frontend is a single-page React app served by nginx.
477477
- **User-Agent templates** — an editable table (add / edit / delete)
478478
replacing the old hardcoded presets; each row carries a UA string and
479479
optional custom request headers, with catalogue export / import
480-
- Optional **GeoIP country flags** on node names (`🇳🇱 vless-nl`)
481-
— opt-in and licence-clean (drop a MaxMind `GeoLite2-Country.mmdb`
482-
next to the geo data; absent it's a silent no-op)
480+
- **Country flags** on nodes (`🇳🇱 vless-nl`) — read back **through the
481+
tunnel** by the speed test and the internet check, so the flag is where
482+
traffic actually surfaces (for a chained node, the last hop — not the
483+
entry whose address is stored). No database needed. Optionally also from
484+
the address at import time, if you drop a MaxMind `GeoLite2-Country.mmdb`
485+
next to the geo data; absent it, that half is a silent no-op
483486
- Optional regex filter, configurable interval
484487

485488
**Devices & DNS**

README.ru.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -486,9 +486,13 @@ xray-core, набором правил nftables и SQLite-базой со все
486486
вместо старых захардкоженных пресетов; каждая строка несёт UA-строку
487487
и опциональные кастомные request-заголовки, с экспортом / импортом
488488
каталога
489-
- Опциональные **флаги стран GeoIP** в именах нод
490-
(`🇳🇱 vless-nl`) — opt-in и чисто по лицензии (положи MaxMind
491-
`GeoLite2-Country.mmdb` рядом с geo-данными; без него — тихий no-op)
489+
- **Флаги стран** у нод (`🇳🇱 vless-nl`) — считываются **через сам
490+
туннель** тестом скорости и проверкой интернета, поэтому флаг
491+
показывает, где трафик реально выходит наружу (у цепочки — последний
492+
хоп, а не входной, чей адрес хранится). База для этого не нужна.
493+
Дополнительно можно определять страну по адресу при импорте — если
494+
положить MaxMind `GeoLite2-Country.mmdb` рядом с geo-данными; без него
495+
эта половина просто молчит
492496
- Опциональный regex-фильтр, настраиваемый интервал
493497

494498
**Устройства и DNS**
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Add node.country / exit_ip / exit_checked_at — the observed exit identity.
2+
3+
The country flag used to be derived from the node's `address`, which answers
4+
only for a literal IP: a hostname needed DNS (unavailable inside the write
5+
flush) and a chained node reported its entry hop rather than where its traffic
6+
actually surfaces. The speed test already opens a tunnel to the internet, so
7+
it now reads the exit address back through it and stores what it saw.
8+
9+
Revision ID: 026
10+
Revises: 025
11+
Create Date: 2026-08-15
12+
"""
13+
from typing import Sequence, Union
14+
15+
from alembic import op
16+
import sqlalchemy as sa
17+
18+
19+
revision: str = "026"
20+
down_revision: Union[str, None] = "025"
21+
branch_labels: Union[str, Sequence[str], None] = None
22+
depends_on: Union[str, Sequence[str], None] = None
23+
24+
25+
def upgrade() -> None:
26+
op.add_column("node", sa.Column("country", sa.String(length=2), nullable=True))
27+
op.add_column("node", sa.Column("exit_ip", sa.String(), nullable=True))
28+
op.add_column("node", sa.Column("exit_checked_at", sa.DateTime(), nullable=True))
29+
30+
31+
def downgrade() -> None:
32+
op.drop_column("node", "exit_checked_at")
33+
op.drop_column("node", "exit_ip")
34+
op.drop_column("node", "country")

backend/app/api/nodes.py

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -988,28 +988,39 @@ async def check_all_nodes():
988988
# ── Speed test ────────────────────────────────────────────────────────────────
989989

990990
async def _persist_node_speed(
991-
node_id: int, mbps: Optional[float], max_mbps: Optional[float] = None
991+
node_id: int,
992+
mbps: Optional[float],
993+
max_mbps: Optional[float] = None,
994+
exit_info: Optional[dict] = None,
992995
) -> None:
993996
"""Cache a speed reading (avg + peak) on the Node row so it survives a
994997
restart and feeds NodeCircle best/min_speed + the UI's staleness colour.
995-
Best-effort: a write failure must never break the speed test itself."""
996-
if mbps is None:
998+
999+
`exit_info` is stored even when the download produced no number: the
1000+
tunnel came up far enough to be identified, and that is worth keeping —
1001+
a node with a flag but no speed reading is a fair description of it.
1002+
Best-effort throughout: a write failure must never break the test."""
1003+
if mbps is None and not exit_info:
9971004
return
9981005
try:
9991006
async with AsyncSession(get_async_engine()) as session:
10001007
node = await session.get(Node, node_id)
10011008
if node:
1002-
node.speed_mbps = float(mbps)
1003-
node.speed_max_mbps = float(max_mbps) if max_mbps is not None else None
1004-
node.speed_tested_at = datetime.now(timezone.utc)
1009+
if mbps is not None:
1010+
node.speed_mbps = float(mbps)
1011+
node.speed_max_mbps = float(max_mbps) if max_mbps is not None else None
1012+
node.speed_tested_at = datetime.now(timezone.utc)
1013+
if exit_info:
1014+
from app.core.speedtest import apply_exit
1015+
apply_exit(node, exit_info)
10051016
await session.commit()
10061017
except Exception: # noqa: BLE001 — advisory cache, never fatal
10071018
pass
10081019

10091020

10101021
@router.post("/{node_id:int}/speedtest", response_model=SpeedTestResult)
10111022
async def speedtest_node(node_id: int, session: AsyncSession = Depends(get_session)):
1012-
from app.core.speedtest import speedtest_node as _speedtest
1023+
from app.core.speedtest import apply_exit, speedtest_node as _speedtest
10131024

10141025
node = await session.get(Node, node_id)
10151026
if not node:
@@ -1022,7 +1033,11 @@ async def speedtest_node(node_id: int, session: AsyncSession = Depends(get_sessi
10221033
mx = result.get("max_mbps")
10231034
node.speed_max_mbps = float(mx) if mx is not None else None
10241035
node.speed_tested_at = datetime.now(timezone.utc)
1036+
changed_exit = apply_exit(node, result)
1037+
if mbps is not None or changed_exit:
10251038
await session.commit()
1039+
await session.refresh(node)
1040+
result["node_name"] = node.name # the flag prefix may have just changed
10261041
return SpeedTestResult(**result)
10271042

10281043

@@ -1046,14 +1061,17 @@ async def _gen():
10461061
import json
10471062
final_mbps = None
10481063
final_max = None
1064+
exit_info: dict = {}
10491065
async for event in speedtest_stream(node):
1066+
if event.get("phase") == "exit":
1067+
exit_info = {k: event.get(k) for k in ("exit_ip", "exit_country")}
10501068
if event.get("phase") == "done":
10511069
final_mbps = event.get("mbps")
10521070
final_max = event.get("mbps_max")
10531071
yield json.dumps(event) + "\n"
10541072
# Persist the post-warmup average + peak after the stream closes. A
10551073
# fresh session — the request-scoped one is gone once streaming starts.
1056-
await _persist_node_speed(node_id, final_mbps, final_max)
1074+
await _persist_node_speed(node_id, final_mbps, final_max, exit_info)
10571075

10581076
return StreamingResponse(
10591077
_gen(),
@@ -1067,13 +1085,17 @@ async def _gen():
10671085
async def node_reachability(node_id: int, session: AsyncSession = Depends(get_session)):
10681086
"""Does the internet actually work through this node? Fetches Google's
10691087
generate_204 over the tunnel — a real proxied round trip, distinct from
1070-
the TCP-only health check. Returns {ok, latency_ms, detail}."""
1071-
from app.core.speedtest import reachability_check
1088+
the TCP-only health check. Returns {ok, latency_ms, detail} plus the exit
1089+
identity it read back while the tunnel was open, which is also stored."""
1090+
from app.core.speedtest import apply_exit, reachability_check
10721091

10731092
node = await session.get(Node, node_id)
10741093
if not node:
10751094
raise HTTPException(404, "Node not found")
1076-
return await reachability_check(node)
1095+
result = await reachability_check(node)
1096+
if apply_exit(node, result):
1097+
await session.commit()
1098+
return result
10771099

10781100

10791101
@router.get("/{node_id:int}/uri")
@@ -1094,7 +1116,7 @@ async def node_uri(node_id: int, session: AsyncSession = Depends(get_session)):
10941116
@router.post("/speedtest-all", response_model=List[SpeedTestResult])
10951117
async def speedtest_all_nodes(session: AsyncSession = Depends(get_session)):
10961118
"""Run speed test on all enabled nodes sequentially (each spawns its own xray)."""
1097-
from app.core.speedtest import speedtest_node as _speedtest
1119+
from app.core.speedtest import apply_exit, speedtest_node as _speedtest
10981120

10991121
nodes = list((await session.exec(select(Node).where(Node.enabled == True))).all())
11001122
results = []
@@ -1107,6 +1129,7 @@ async def speedtest_all_nodes(session: AsyncSession = Depends(get_session)):
11071129
mx = result.get("max_mbps")
11081130
node.speed_max_mbps = float(mx) if mx is not None else None
11091131
node.speed_tested_at = now
1132+
apply_exit(node, result)
11101133
results.append(SpeedTestResult(**result))
11111134
await session.commit()
11121135
return results
@@ -1121,32 +1144,39 @@ async def apply_country_flags(session: AsyncSession = Depends(get_session)):
11211144
— keep the name they were created with. This is the one-off that brings
11221145
them in line.
11231146
1124-
Unlike the write-time hook, this resolves hostnames: it runs in a worker
1125-
thread rather than inside a flush, so a slow DNS server costs time here
1126-
instead of stalling a database write. Idempotent — a stale flag is
1127-
stripped before the current one is applied, so running it twice is the
1128-
same as running it once, and a node whose country can't be determined
1129-
simply keeps its bare name.
1147+
A country a speed test observed at the exit is used first and needs no
1148+
database at all. Failing that the address is looked up — and unlike the
1149+
write-time hook this resolves hostnames: it runs in a worker thread
1150+
rather than inside a flush, so a slow DNS server costs time here instead
1151+
of stalling a database write.
1152+
1153+
Idempotent — a stale flag is replaced by the current one, so running it
1154+
twice is the same as running it once. A node whose country can't be
1155+
determined is left alone rather than having its name rewritten.
11301156
"""
11311157
import anyio
11321158
from app.core.geoip_lookup import _get_reader, enrich_name
11331159

1134-
if _get_reader() is None:
1160+
nodes = (await session.exec(select(Node))).all()
1161+
if _get_reader() is None and not any(n.country for n in nodes):
11351162
raise HTTPException(
11361163
400,
1137-
"No GeoLite2 country database is installed, so there is nothing "
1138-
"to look names up in. Add GeoLite2-Country.mmdb next to the geo "
1139-
"data and restart the backend.",
1164+
"No country is known for any node yet: no GeoLite2 database is "
1165+
"installed, and no node has been speed-tested (the speed test "
1166+
"reads the exit country back through the tunnel). Run a speed "
1167+
"test, or add GeoLite2-Country.mmdb next to the geo data and "
1168+
"restart the backend.",
11401169
)
11411170

1142-
nodes = (await session.exec(select(Node))).all()
1143-
1144-
def _resolve_all(pairs: List[tuple]) -> List[tuple]:
1171+
def _resolve_all(rows: List[tuple]) -> List[tuple]:
11451172
# One thread for the whole batch: each name may cost a DNS lookup.
1146-
return [(nid, enrich_name(name, addr)) for nid, name, addr in pairs]
1173+
return [
1174+
(nid, enrich_name(name, addr, country=cc, keep_on_miss=True))
1175+
for nid, name, addr, cc in rows
1176+
]
11471177

11481178
renamed = await anyio.to_thread.run_sync(
1149-
_resolve_all, [(n.id, n.name, n.address) for n in nodes],
1179+
_resolve_all, [(n.id, n.name, n.address, n.country) for n in nodes],
11501180
)
11511181

11521182
by_id = {nid: new_name for nid, new_name in renamed}

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.6.5"
8+
APP_VERSION = "1.6.6"
99

1010

1111
class Settings(BaseSettings):

backend/app/core/autocheck_scheduler.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ async def run_sweep(
147147
await session.commit()
148148
node_ids = await resolve_scope_node_ids(session, eff_kind, eff_value)
149149

150-
from app.core.speedtest import speedtest_node as _speedtest
150+
from app.core.speedtest import apply_exit, speedtest_node as _speedtest
151151
for nid in node_ids:
152152
async with AsyncSession(get_async_engine()) as session:
153153
node = await session.get(Node, nid)
@@ -165,6 +165,11 @@ async def run_sweep(
165165
result = await _speedtest(node)
166166
mbps = result.get("download_mbps")
167167
mx = result.get("max_mbps")
168+
# The sweep is the only check most nodes ever get, so
169+
# it is what keeps their flag honest — an exit that
170+
# moved country is picked up here without anyone
171+
# pressing anything.
172+
apply_exit(node, result)
168173
except Exception as exc: # noqa: BLE001 — isolate per node
169174
logger.info("AutoCheck: node %d speedtest error: %s", nid, exc)
170175
# Stamp the check time either way. On failure we clear the

0 commit comments

Comments
 (0)