Skip to content

Commit 77b3935

Browse files
committed
release: v1.5.2
New: HTTPS for the panel. The reverse proxy serves the UI over TLS on 443 alongside plain HTTP on 80 (a cert issue can't lock you out). A per-install leaf is signed by a local PiTun CA, generated before nginx starts (scripts/gen-cert.sh) with the box's LAN IP + pitun/pitun.local in the SAN; Settings offers the root CA for download to drop the browser warning. WS / log streams upgrade to wss:// automatically. gen-cert runs from 03-deploy so existing boxes pick it up on the next deploy. New: NodeCircle "shrank by refresh" highlight. A subscription refresh that removes a circle member records a circle.pruned event (Recent Events) and the NodeCircles page flags circles with a missing member or <2 nodes with a "check members" badge — the provider-moved-a-node-to-a-new-address case (new id, not auto-re-added). Changed: host IPv6 disabled by default on a CLEAN install (disable_ipv6=true seeded for fresh DBs only; INSERT OR IGNORE leaves existing installs alone). Dark-theme blue pills use a desaturated grey-blue. Housekeeping: sanitized example identifiers in a few comments and test fixtures (no behaviour change). Frontend build + 82 tests green; changed backend suites (config_gen, route_explain, routing_import_export) pass. No schema migration.
1 parent dbd8732 commit 77b3935

20 files changed

Lines changed: 252 additions & 21 deletions

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.2 — 2026-08-06
8+
9+
Adds HTTPS for the panel (per-install cert + downloadable local CA), disables
10+
host IPv6 by default on clean installs, and surfaces NodeCircles that a
11+
subscription refresh shrank.
12+
13+
### Added
14+
15+
- **HTTPS for the panel.** The reverse proxy now serves the UI over TLS on
16+
**443** alongside plain HTTP on 80, so a cert issue can't lock you out. A
17+
per-install certificate is signed by a local **PiTun CA**, generated before
18+
nginx starts (`scripts/gen-cert.sh`), with the box's LAN IP + `pitun` /
19+
`pitun.local` in the SAN. **Settings → HTTPS** offers the root CA for
20+
download — trust it once to drop the browser warning. WebSocket / log
21+
streams upgrade to `wss://` automatically. Existing boxes pick it up on the
22+
next deploy.
23+
- **NodeCircle "shrank by refresh" highlight.** When a subscription refresh
24+
removes a node that belonged to a circle, PiTun records a `circle.pruned`
25+
event (Recent Events) naming the affected circle(s), and the NodeCircles
26+
page flags any circle with a missing member or fewer than 2 nodes with a
27+
**check members** badge. Covers the case where a provider moves a node to a
28+
new address — it comes back as a new id and isn't auto-re-added.
29+
30+
### Changed
31+
32+
- **Host IPv6 is disabled by default on a CLEAN install** (`disable_ipv6=true`
33+
seeded for fresh DBs only). PiTun's TPROXY is IPv4-only, so this avoids a
34+
class of IPv6-path surprises; existing installs keep whatever they had
35+
(`INSERT OR IGNORE` — an upgrade never flips it). Client-side IPv6 leaks
36+
were already closed by `dns_query_strategy=UseIPv4`.
37+
- **Dark-theme polish.** Blue type/mode pills (rules / vmess / src_ip) use a
38+
desaturated grey-blue in dark mode instead of the saturated blue-900 wash.
39+
40+
### Housekeeping
41+
42+
- Sanitized example identifiers in a few source comments and test fixtures
43+
(no behaviour change).
44+
745
## v1.5.1 — 2026-08-06
846

947
Fixes the active node reporting no speed on a general sweep, adds a REALITY

backend/app/api/subscriptions.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,7 @@ async def _fetch_subscription_unlocked(sub_id: int) -> None:
684684
# the circle enabled lets it spring back to life automatically
685685
# if the operator re-adds nodes to the panel.
686686
circles_pruned: list[int] = []
687+
pruned_summary: list[str] = [] # "'name' (-N)" per affected circle
687688
if removed_ids:
688689
import json as _json
689690
all_circles = (await session.exec(select(NodeCircle))).all()
@@ -709,6 +710,7 @@ async def _fetch_subscription_unlocked(sub_id: int) -> None:
709710
circle.node_ids = _json.dumps(surviving_ids)
710711
session.add(circle)
711712
circles_pruned.append(circle.id)
713+
pruned_summary.append(f"'{circle.name}' (-{len(ids) - len(surviving_ids)})")
712714
if circles_pruned:
713715
logger.warning(
714716
"Subscription %d refresh: pruned dangling refs from "
@@ -779,6 +781,24 @@ async def _fetch_subscription_unlocked(sub_id: int) -> None:
779781
),
780782
)
781783

784+
# Surface pruned circles in the Recent Events feed so the operator
785+
# notices a circle that shrank — especially the "provider moved a node
786+
# to a new address" case, where the node returns as a NEW id and is
787+
# not auto-re-added to the circle.
788+
if pruned_summary:
789+
from app.core.events import record_event
790+
await record_event(
791+
category="circle.pruned",
792+
severity="warning",
793+
title="NodeCircle membership changed by refresh",
794+
details=(
795+
f"Subscription {sub_id} refresh removed node(s) from circle(s): "
796+
f"{', '.join(pruned_summary)}. A node removed from the panel — or "
797+
f"moved to a new address, which returns as a new id — is not "
798+
f"auto-re-added; check the circle membership."
799+
),
800+
)
801+
782802
await _reload_if_config_nodes_touched(
783803
session,
784804
touched_ids=changed_ids | removed_ids,

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.1"
8+
APP_VERSION = "1.5.2"
99

1010

1111
class Settings(BaseSettings):

backend/app/database.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,13 @@ async def init_default_settings():
200200
# Health check
201201
"health_interval": str(settings.health_interval),
202202
"health_timeout": str(settings.health_timeout),
203-
"disable_ipv6": "false",
203+
# Disabled by default on a CLEAN install: PiTun's TPROXY pipeline is
204+
# IPv4-only, so turning host IPv6 off avoids a whole class of
205+
# IPv6-path surprises (the DNS engine already returns IPv4-only to
206+
# clients via dns_query_strategy=UseIPv4). INSERT OR IGNORE means
207+
# existing boxes keep whatever they already have — only a fresh DB
208+
# picks up this default, so upgrades never flip it silently.
209+
"disable_ipv6": "true",
204210
"dns_over_tcp": "false",
205211
# LAN proxy authentication (since v1.3.0-beta.6). Applies to
206212
# the explicit SOCKS5 + HTTP inbounds (not TPROXY — that one

backend/app/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,7 @@ class ProxyChain(SQLModel, table=True):
722722
723723
A `ChainClient` is one logical user — when created it spawns N
724724
panel-side clients (one per channel), so the user gets N VLESS
725-
URIs (e.g. "VPN-VK" / "VPN-MAX" / ...) all backed by the same
725+
URIs (e.g. "VPN-A" / "VPN-B" / ...) all backed by the same
726726
exit IP. Importing any of them as a `Node` makes PiTun route
727727
through that chain end-to-end.
728728
"""

backend/tests/test_config_gen.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -349,19 +349,19 @@ def test_global_mode_skips_user_routing_rules(self):
349349
catch-all to active node. This is the contract the new
350350
Routing-page banner (frontend 1.3.5) tells the user about."""
351351
node = _make_node(id=11)
352-
# A user rule that would normally send vk.com direct
352+
# A user rule that would normally send ru-site.example direct
353353
user_rule = RoutingRule(
354354
id=99, name="bypass vk", rule_type="domain",
355-
match_value="vk.com", action="direct", order=0, enabled=True,
355+
match_value="ru-site.example", action="direct", order=0, enabled=True,
356356
)
357357
cfg = generate_config(
358358
node, [node], [user_rule], "global", _default_settings(),
359359
)
360-
# No rule mentions vk.com
360+
# No rule mentions ru-site.example
361361
for r in cfg["routing"]["rules"]:
362-
assert "vk.com" not in str(r), (
362+
assert "ru-site.example" not in str(r), (
363363
"global mode must NOT emit user RoutingRule rows; "
364-
f"found one referencing vk.com: {r}"
364+
f"found one referencing ru-site.example: {r}"
365365
)
366366

367367

@@ -652,7 +652,7 @@ def test_doh_uses_https(self):
652652
# ── DNS-upstream outboundTag pinning (since v1.3.5) ────────────────
653653
#
654654
# Pinning DNS servers to `outboundTag: direct` is the fix for the
655-
# 192.168.1.4 burn-in lockup. The contract these tests pin: every
655+
# DNS burn-in lockup. The contract these tests pin: every
656656
# DNS server entry must end up as a dict with outboundTag=direct,
657657
# regardless of whether the operator configured it as a bare
658658
# upstream, a per-domain object, or a fallback. The only exception
@@ -717,7 +717,7 @@ def test_per_rule_dns_pinned(self):
717717
outboundTag=direct without losing their existing fields."""
718718
from app.models import DNSRule
719719
rules = [DNSRule(
720-
id=1, name="r", domain_match="vk.com",
720+
id=1, name="r", domain_match="ru-site.example",
721721
dns_server="77.88.8.8", dns_type="plain",
722722
enabled=True, order=10,
723723
)]
@@ -732,7 +732,7 @@ def test_per_rule_dns_pinned(self):
732732
assert yandex is not None, cfg["dns"]["servers"]
733733
assert yandex["outboundTag"] == "direct"
734734
# Existing fields preserved
735-
assert "vk.com" in yandex.get("domains", [])
735+
assert "ru-site.example" in yandex.get("domains", [])
736736

737737
def test_ru_bypass_dns_pinned(self):
738738
"""`bypass_ru_dns=true` adds a Yandex-DNS server for RU domains.

backend/tests/test_route_explain.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,9 +386,9 @@ def test_parses_arrow_separator(self):
386386
def test_parses_legacy_doublearrow(self):
387387
from app.core.route_explain_probe import _read_chosen_outbound
388388
import tempfile, os
389-
log = "2026 accepted tcp:vk.com:443 [probe-in >> direct]\n"
389+
log = "2026 accepted tcp:ru-site.example:443 [probe-in >> direct]\n"
390390
fd, p = tempfile.mkstemp(); os.write(fd, log.encode()); os.close(fd)
391391
try:
392-
assert _read_chosen_outbound(p, "vk.com") == "direct"
392+
assert _read_chosen_outbound(p, "ru-site.example") == "direct"
393393
finally:
394394
os.remove(p)

backend/tests/test_routing_import_export.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def _seed_global(session, *rules):
2626
class TestImportPreview:
2727
def test_new_rules_into_global(self, client, session, auth_headers, default_settings):
2828
r = client.post("/api/routing/import/preview", headers=auth_headers, json={
29-
"rules": [_r("domain", "youtube.com", "proxy"), _r("domain", "vk.com", "direct")],
29+
"rules": [_r("domain", "youtube.com", "proxy"), _r("domain", "ru-site.example", "direct")],
3030
"destination": {"kind": "global"},
3131
})
3232
assert r.status_code == 200, r.text

backend/tests/test_subscriptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ class TestSubscriptionRefreshUpsert:
174174
`active_node_id` across a refresh. Drives the same code path as
175175
the real `_fetch_subscription` but with the network fetch stubbed
176176
to a deterministic URI list. Mirrors the real-world failure mode
177-
the user hit on 192.168.1.4 with a 1256-node subscription."""
177+
seen with a 1256-node subscription."""
178178

179179
def test_active_node_survives_refresh_when_node_returns(
180180
self, client, admin_user, auth_headers, session,

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,12 @@ services:
127127
restart: unless-stopped
128128
ports:
129129
- "80:80"
130+
- "443:443"
130131
volumes:
131132
- ./nginx.conf:/etc/nginx/nginx.conf:ro
133+
# Per-install TLS cert + local CA, generated by scripts/gen-cert.sh
134+
# BEFORE nginx starts (listen 443 ssl aborts without them).
135+
- /etc/pitun/certs:/etc/nginx/certs:ro
132136
extra_hosts:
133137
# backend uses network_mode:host, so it's not on the Docker bridge.
134138
# host-gateway resolves to the host IP so nginx can reach it.

0 commit comments

Comments
 (0)