Skip to content

Commit 472a310

Browse files
committed
release: v1.2.3
Two new feature areas + hardening + install/UX polish. New: Servers (managed VPS inventory) Separate page from Nodes — tracks the remote VPS hosts that may carry one or more proxy outbounds, with their SSH connection, provider/region metadata, tags, and a Deployments tab listing which protocols/ports are currently set up on each box. Async-SSH probe via asyncssh==2.18.0. Optional manual provisioning scripts (Caddy + forwardproxy for naive, xray, SSH hardening) over the same SSH link. 3 new alembic migrations: 008 (server table), 009 (deployments), 010 (geo URL defaults). New: full-fidelity JSON Export/Import for Nodes and Servers Versioned bundle envelope ({kind, version, exported_at, pitun_version, count, items}). Append (default) or replace mode; dedup by natural keys (Nodes: protocol+address+port+uuid; Servers: name+host+port). Server export defaults to no-secrets, with an opt-in checkbox to include passwords/keys for migrations between trusted hosts. Distinct from URI/subscription import which only carries a single node. NodeCircle pre-ping with retry + Failover<->Circle integration Before switching the active outbound to a candidate, the scheduler now TCP-probes it (SO_MARK=0xFF to bypass TPROXY) with a single retry to absorb transient SYN drops. Disabled or removed nodes are skipped automatically; if every candidate fails, the rotation aborts and the active node stays put. Each circle gets its own asyncio.Lock and a 20s hard deadline so scheduler ticks and manual rotate-now never race. When the active node fails its health check repeatedly AND it belongs to an enabled circle, the failover handler delegates recovery to circle_scheduler.rotate_circle() instead of walking the fallback list — reusing the pre-ping logic to skip dead siblings. If no circle owns the node (or all siblings are dead), Tier-2 fallback is the existing list-based path. New "Auto-failover" toggle on the NodeCircles page is the master switch. Comprehensive geo profiles Three switchable upstream profiles for geoip.dat / geosite.dat: Loyalsoldier (CN-focused, largest geosite:cn coverage), runetfreedom (Russian-internet curated, geosite:ru-blocked + clean geosite:ru), v2fly (vanilla baseline). UI lets you select + Update; PiTun fetches into the xray asset directory and reloads. Routing rules: comprehensive Proxy streaming preset Quick Add 'Proxy streaming' grew from a handful of entries to a curated list of 50 (Netflix, Disney+, HBO, Hulu, Spotify, YouTube Premium, Twitch, Steam, Epic Games, BBC iPlayer, ChatGPT/OpenAI, Anthropic Claude, etc.). Backend auto-prefixes bare domain entries with `domain:` on save so users don't have to type the prefix. Install: autodetect LAN_CIDR + host IP from default-route interface install.sh now writes correct INTERFACE, LAN_CIDR, GATEWAY_IP, VITE_API_BASE_URL, VITE_WS_BASE_URL, and CORS_ORIGINS into the freshly generated .env, derived from the host's primary interface (python3 ipaddress for CIDR math, pure-bash bitwise fallback). Previously only INTERFACE was autodetected — users on subnets other than 192.168.1.0/24 had to edit four places by hand. Backend gains a parallel _detect_cidr() runtime fallback in GET /settings, mirroring the existing _detect_ip() behavior. Stays read-only (does not overwrite a deliberate manual lan_cidr in DB). README.md / README.ru.md / .env.example: clarified that GATEWAY_IP is a misnomer for the PiTun host's own LAN IP (not the home router's IP), and documented the new autodetect + runtime fallback. UI polish - NodeCircles page InfoTips now open downward (position="bottom") to avoid clipping at the top of the viewport on narrow screens. - Knowledge Base updated: Servers, Geo profiles, JSON Export/Import, NodeCircle pre-ping/retry, Failover<->Circle integration, Routing Quick Add presets details all documented. Two new sections: "Servers (VPS Inventory)" and "Geo Data Profiles". Routing rules JSON Export/Import (v2ray-style) Round-trip rule sets as v2ray routing JSON for backups and migrating curated rules between PiTun instances. Backend versioning is now a single source of truth (APP_VERSION in config.py); surfaced in /health, /system/status, and the OpenAPI metadata. 284 backend tests pass. No breaking changes; 3 alembic migrations apply automatically on first start.
1 parent a7a3218 commit 472a310

41 files changed

Lines changed: 13471 additions & 145 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ XRAY_GEOSITE_PATH=/usr/local/share/xray/geosite.dat
1313
XRAY_LOG_LEVEL=warning
1414

1515
# Network
16+
#
17+
# Tip: when you generate this file via `install.sh`, INTERFACE,
18+
# LAN_CIDR, and GATEWAY_IP are autodetected from the host's
19+
# default-route interface — these example values are only used as a
20+
# fallback when autodetect fails or you copy this file by hand.
21+
#
22+
# GATEWAY_IP: misnomer kept for backward compat — this is the LAN IP
23+
# of the PiTun host itself (what your devices set as their default
24+
# gateway), NOT your home router's IP. The backend will also sync
25+
# the live interface IP to the database on the first `GET /settings`,
26+
# so even a stale value here is corrected at runtime.
1627
TPROXY_PORT_TCP=7893
1728
TPROXY_PORT_UDP=7894
1829
DNS_PORT=5353

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ build/
2828
dist/
2929
backend/dist/
3030
frontend/dist/
31+
32+
# Maintainer-side scratch space — Claude Code uses this for throwaway
33+
# helpers (SSH runners, deploy tarballs, ad-hoc test scripts) during
34+
# interactive work. Never committed; safe to wipe between sessions.
35+
.claude_temp/
36+
37+
# Internal product/business roadmap, monetization plans, decisions log.
38+
# Lives in the repo for convenient editing alongside code, but never
39+
# committed — these are private notes not suitable for the public repo.
40+
roadmap/
3141
docs/design/
3242

3343
# Node

README.md

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,10 @@ Frontend is a single-page React app served by nginx.
154154
through node #5")
155155

156156
**Health & resilience**
157-
- Background liveness probe with auto-failover to a fallback node
157+
- Background liveness probe with two-tier auto-failover: if the failed
158+
node belongs to an enabled NodeCircle, the failover handler delegates
159+
recovery to the circle (which skips dead siblings via pre-ping +
160+
retry); otherwise it walks a configurable fallback list
158161
- Speed test per node via short-lived isolated xray instance
159162
- Naive sidecar supervisor — auto-restarts crashed Naive containers
160163
with a sliding-window rate limiter
@@ -164,7 +167,9 @@ Frontend is a single-page React app served by nginx.
164167
**Balancing & rotation**
165168
- Balancer groups (xray's `leastPing` or `random` strategies)
166169
- Node Circles — automatically rotate the active node on a schedule,
167-
seamlessly via xray's gRPC API (no dropped connections)
170+
seamlessly via xray's gRPC API (no dropped connections); each
171+
candidate is TCP-pinged with a single retry before switching, so
172+
dead siblings are skipped without a connection blip
168173

169174
**Subscriptions**
170175
- Periodic refresh from VLESS / VMess / Trojan / SS / Hysteria2 /
@@ -179,8 +184,19 @@ Frontend is a single-page React app served by nginx.
179184
- FakeDNS pool for sniffing-friendly geoip resolution
180185
- DNS query log with stats
181186

187+
**Servers & deployments**
188+
- Inventory of remote VPS hosts (host, SSH credentials, tags) separate
189+
from runtime nodes — async-SSH probe, deployment records track which
190+
protocol/port is set up on which box, optional manual provisioning
191+
scripts (Caddy + naive, xray, SSH hardening) over the same SSH link
192+
182193
**Operations**
183-
- One-click GeoIP / GeoSite refresh from Loyalsoldier's dataset
194+
- One-click GeoIP / GeoSite refresh — three switchable upstream
195+
profiles: Loyalsoldier (CN-focused community list), runetfreedom
196+
(Russian-internet curated list), v2fly (vanilla baseline)
197+
- Full-fidelity JSON Export/Import for Nodes and Servers — versioned
198+
bundle envelope, append/replace modes, optional secret redaction
199+
(separate from URI/subscription import which is single-node only)
184200
- Built-in diagnostics page (DNS reachability, gateway, xray status,
185201
resource usage)
186202
- Streaming xray log viewer
@@ -258,10 +274,12 @@ Useful flags (after `bash -s --`):
258274
After the script finishes:
259275
- Web UI is at `http://<this-host-ip>/`, login `admin` / `password`
260276
(**change it on first login** via *Settings → Account*).
261-
- `/opt/pitun/.env` was generated with a random `SECRET_KEY` and your
262-
default LAN interface autodetected. Edit it to set `LAN_CIDR` /
263-
`GATEWAY_IP` matching your network, then `docker compose -f
264-
/opt/pitun/docker-compose.yml restart`.
277+
- `/opt/pitun/.env` was generated with a random `SECRET_KEY` and the
278+
network block autodetected from your default-route interface:
279+
`INTERFACE`, `LAN_CIDR`, `GATEWAY_IP` (the PiTun host's own LAN IP),
280+
`VITE_API_BASE_URL`, `VITE_WS_BASE_URL`, `CORS_ORIGINS`. Verify with
281+
`head -30 /opt/pitun/.env` before going to production; if anything
282+
looks off, edit and `docker compose -f /opt/pitun/docker-compose.yml restart`.
265283

266284
> See [`install.sh --help`](install.sh) for the full option list.
267285
@@ -282,8 +300,14 @@ cd pitun
282300
sudo bash scripts/setup.sh
283301

284302
cp .env.example .env
285-
# Edit .env — at minimum set SECRET_KEY, INTERFACE, LAN_CIDR, GATEWAY_IP.
286-
# A random SECRET_KEY: openssl rand -hex 32
303+
# Edit .env — at minimum set SECRET_KEY, INTERFACE, LAN_CIDR,
304+
# GATEWAY_IP (the PiTun host's own LAN IP — what devices will use as
305+
# their default gateway). A random SECRET_KEY: openssl rand -hex 32
306+
#
307+
# Tip: instead of editing manually, run `sudo bash install.sh
308+
# --skip-host-prep` from the same checkout — it autodetects all the
309+
# network values from your default-route interface and writes them
310+
# into .env (only on first generation).
287311

288312
docker compose up -d --build
289313
```
@@ -378,8 +402,8 @@ must be set before first start, via `.env`:
378402
|---|---|---|
379403
| `SECRET_KEY` | `changeme-…` | JWT signing key — `openssl rand -hex 32` |
380404
| `INTERFACE` | `eth0` | LAN interface name on the host |
381-
| `LAN_CIDR` | `192.168.1.0/24` | Your LAN subnet |
382-
| `GATEWAY_IP` | `192.168.1.1` | Your home router's IP (used for `direct` traffic) |
405+
| `LAN_CIDR` | `192.168.1.0/24` | Your LAN subnet (autodetected by `install.sh`) |
406+
| `GATEWAY_IP` | `192.168.1.100` | **The PiTun host's own LAN IP** — devices set this as their default gateway. (Misnomer kept for backward compat; *not* the router's IP.) Autodetected by `install.sh`. |
383407
| `BACKEND_PORT` | `8000` | Backend listen port (behind nginx) |
384408
| `TPROXY_PORT_TCP` | `7893` | TPROXY TCP listener |
385409
| `DNS_PORT` | `5353` | Internal DNS forwarder port |
@@ -388,6 +412,13 @@ must be set before first start, via `.env`:
388412

389413
Full annotated example: [`.env.example`](.env.example).
390414

415+
> **About `GATEWAY_IP`:** the variable name predates the LAN-gateway
416+
> feature and refers to the PiTun host itself, not your home router.
417+
> If the .env value disagrees with the actual interface IP, the backend
418+
> auto-syncs the live IP into the database on the first `GET /settings`,
419+
> so the UI always shows the truth. `LAN_CIDR` has the same runtime
420+
> fallback as of 1.2.3.
421+
391422
## Development
392423

393424
```bash

README.ru.md

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,10 @@ xray-core, набором правил nftables и SQLite-базой со все
152152
через ноду #5»)
153153

154154
**Здоровье и устойчивость**
155-
- Фоновая проверка живости с автоматическим failover на резервную ноду
155+
- Фоновая проверка живости с двухуровневым auto-failover: если упавшая
156+
нода входит в активный NodeCircle — failover делегирует
157+
восстановление кругу (он пропускает мёртвых соседей через pre-ping +
158+
retry); иначе идёт по настраиваемому списку fallback-нод
156159
- Speed test для каждой ноды через короткоживущий изолированный xray
157160
- Supervisor для Naive sidecars — авторестарт упавших контейнеров с
158161
rate-limiter (sliding window)
@@ -162,7 +165,9 @@ xray-core, набором правил nftables и SQLite-базой со все
162165
**Балансировка и ротация**
163166
- Группы балансировки (стратегии xray `leastPing` / `random`)
164167
- Node Circles — автоматическая ротация активной ноды по расписанию,
165-
бесшовно через xray gRPC API (соединения не рвутся)
168+
бесшовно через xray gRPC API (соединения не рвутся); каждый
169+
кандидат проверяется TCP-пингом с одним повтором перед
170+
переключением — мёртвые соседи пропускаются без обрыва
166171

167172
**Подписки**
168173
- Периодическое обновление с VLESS / VMess / Trojan / SS / Hysteria2 /
@@ -178,8 +183,21 @@ xray-core, набором правил nftables и SQLite-базой со все
178183
- FakeDNS-пул для sniffing-friendly geoip-резолва
179184
- Лог DNS-запросов со статистикой
180185

186+
**Серверы и развёртывания**
187+
- Инвентарь удалённых VPS (host, SSH-доступы, теги) отдельно от runtime-
188+
нод — async-SSH probe, записи о развёртываниях помнят какой
189+
протокол/порт настроен на какой машине, опционально — manual
190+
provisioning скрипты (Caddy + naive, xray, харднинг SSH) по тому же
191+
SSH-каналу
192+
181193
**Эксплуатация**
182-
- One-click обновление GeoIP / GeoSite из dataset Loyalsoldier
194+
- One-click обновление GeoIP / GeoSite — три переключаемых upstream-
195+
профиля: Loyalsoldier (CN-ориентированный community-список),
196+
runetfreedom (курируемый список для рунета), v2fly (vanilla baseline)
197+
- Полноформатный JSON Export/Import для Nodes и Servers — версионный
198+
конверт, режимы append/replace, опциональная редактирование секретов
199+
(отдельно от URI/subscription импорта, который работает только на
200+
одну ноду)
183201
- Встроенная страница диагностики (DNS, шлюз, статус xray, ресурсы)
184202
- Стриминг логов xray
185203
- Многоязычный UI (English / Русский)
@@ -257,8 +275,11 @@ curl -fsSL https://raw.githubusercontent.com/DaveBugg/PiTun/master/install.sh |
257275
- Web UI на `http://<ip-хоста>/`, логин `admin` / `password`
258276
(**смени при первом входе** через *Settings → Account*).
259277
- `/opt/pitun/.env` сгенерирован со случайным `SECRET_KEY` и
260-
авто-детектом LAN-интерфейса. Отредактируй чтобы выставить `LAN_CIDR`
261-
/ `GATEWAY_IP` под свою сеть, потом `docker compose -f
278+
авто-детектом сетевого блока с интерфейса дефолтного маршрута:
279+
`INTERFACE`, `LAN_CIDR`, `GATEWAY_IP` (это LAN-IP самого PiTun, не
280+
роутера), `VITE_API_BASE_URL`, `VITE_WS_BASE_URL`, `CORS_ORIGINS`.
281+
Проверь через `head -30 /opt/pitun/.env` перед боевым запуском; если
282+
что-то не так — отредактируй и `docker compose -f
262283
/opt/pitun/docker-compose.yml restart`.
263284

264285
> Полный список опций — [`install.sh --help`](install.sh).
@@ -278,8 +299,15 @@ cd pitun
278299
sudo bash scripts/setup.sh
279300

280301
cp .env.example .env
281-
# Отредактируйте .env — минимум: SECRET_KEY, INTERFACE, LAN_CIDR, GATEWAY_IP.
282-
# Случайный SECRET_KEY: openssl rand -hex 32
302+
# Отредактируйте .env — минимум: SECRET_KEY, INTERFACE, LAN_CIDR,
303+
# GATEWAY_IP (это LAN-IP самого PiTun — то, что устройства будут
304+
# использовать как default gateway). Случайный SECRET_KEY:
305+
# openssl rand -hex 32
306+
#
307+
# Совет: вместо ручной правки можно запустить `sudo bash install.sh
308+
# --skip-host-prep` из этого же checkout — оно автодетектит все
309+
# сетевые значения с дефолтного интерфейса и пишет в .env (только при
310+
# первой генерации).
283311

284312
docker compose up -d --build
285313
```
@@ -373,8 +401,8 @@ docker compose up -d
373401
|---|---|---|
374402
| `SECRET_KEY` | `changeme-…` | Ключ подписи JWT — `openssl rand -hex 32` |
375403
| `INTERFACE` | `eth0` | Имя LAN-интерфейса на хосте |
376-
| `LAN_CIDR` | `192.168.1.0/24` | Ваша LAN-подсеть |
377-
| `GATEWAY_IP` | `192.168.1.1` | IP домашнего роутера (для `direct` трафика) |
404+
| `LAN_CIDR` | `192.168.1.0/24` | Ваша LAN-подсеть (автодетектится `install.sh`) |
405+
| `GATEWAY_IP` | `192.168.1.100` | **LAN-IP самого PiTun** — устройства задают это как default gateway. (Имя оставлено для обратной совместимости; это *не* IP роутера.) Автодетектится `install.sh`. |
378406
| `BACKEND_PORT` | `8000` | Порт бэкенда (за nginx) |
379407
| `TPROXY_PORT_TCP` | `7893` | TCP-листенер TPROXY |
380408
| `DNS_PORT` | `5353` | Внутренний DNS-форвардер |
@@ -383,6 +411,13 @@ docker compose up -d
383411

384412
Полный аннотированный пример: [`.env.example`](.env.example).
385413

414+
> **О `GATEWAY_IP`:** имя переменной осталось с тех времён когда LAN-
415+
> gateway фичи ещё не было, и относится к самому PiTun-хосту, а не к
416+
> роутеру. Если в .env лежит несовпадающий с реальным IP интерфейса —
417+
> бэкенд автоматически синкнет живой IP в БД при первом `GET /settings`,
418+
> так что в UI всегда будет правда. У `LAN_CIDR` такой же runtime-
419+
> fallback с версии 1.2.3.
420+
386421
## Разработка
387422

388423
```bash
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Add server table + node.server_id link.
2+
3+
Introduces the Servers feature: a catalogue of SSH-reachable VPS instances
4+
that the user manages from PiTun. Each server can be referenced by zero or
5+
more nodes (1:N) — the link is purely informational and survives an empty
6+
delete via ON DELETE SET NULL.
7+
8+
Plain-text credential storage is intentional and matches existing Node
9+
password storage. Threat model is documented in SECURITY.md (LAN-only
10+
deployment, do not expose to public internet).
11+
12+
Revision ID: 008
13+
Revises: 007
14+
Create Date: 2026-05-02
15+
"""
16+
from typing import Sequence, Union
17+
18+
from alembic import op
19+
import sqlalchemy as sa
20+
21+
revision: str = "008"
22+
down_revision: Union[str, None] = "007"
23+
branch_labels: Union[str, Sequence[str], None] = None
24+
depends_on: Union[str, Sequence[str], None] = None
25+
26+
27+
def upgrade() -> None:
28+
op.create_table(
29+
"server",
30+
sa.Column("id", sa.Integer(), nullable=False),
31+
sa.Column("name", sa.String(), nullable=False),
32+
sa.Column("description", sa.String(), nullable=True),
33+
sa.Column("host", sa.String(), nullable=False),
34+
sa.Column("port", sa.Integer(), nullable=False, server_default="22"),
35+
sa.Column("user", sa.String(), nullable=False, server_default="root"),
36+
sa.Column("auth_type", sa.String(), nullable=False, server_default="password"),
37+
sa.Column("password", sa.String(), nullable=True),
38+
sa.Column("private_key", sa.Text(), nullable=True),
39+
sa.Column("passphrase", sa.String(), nullable=True),
40+
sa.Column("status", sa.String(), nullable=False, server_default="unknown"),
41+
sa.Column("last_check", sa.DateTime(), nullable=True),
42+
sa.Column("last_check_error", sa.String(), nullable=True),
43+
sa.Column("latency_ms", sa.Integer(), nullable=True),
44+
sa.Column("created_at", sa.DateTime(), nullable=False),
45+
sa.Column("updated_at", sa.DateTime(), nullable=False),
46+
sa.PrimaryKeyConstraint("id"),
47+
)
48+
49+
# Add server_id FK to node table. Use batch_alter_table so SQLite (no
50+
# native ALTER) gets the table-rename + recreate dance for free.
51+
with op.batch_alter_table("node") as batch:
52+
batch.add_column(sa.Column("server_id", sa.Integer(), nullable=True))
53+
batch.create_foreign_key(
54+
"fk_node_server_id",
55+
"server",
56+
["server_id"],
57+
["id"],
58+
ondelete="SET NULL",
59+
)
60+
61+
62+
def downgrade() -> None:
63+
with op.batch_alter_table("node") as batch:
64+
batch.drop_constraint("fk_node_server_id", type_="foreignkey")
65+
batch.drop_column("server_id")
66+
op.drop_table("server")
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Add server_deployment table.
2+
3+
Persists "what credentials did the user pick last time they generated a
4+
NaiveProxy install script for this server", so:
5+
- Re-opening the script generator pre-fills with last values
6+
- The auto-generated naive password isn't lost when the modal closes
7+
- One-click "Create Node from this deployment" pre-populates a Node
8+
row with the right host / user / password
9+
10+
Unique on (server_id, protocol) — one deployment plan per protocol per
11+
server. Re-saving updates the existing row.
12+
13+
Revision ID: 009
14+
Revises: 008
15+
Create Date: 2026-05-06
16+
"""
17+
from typing import Sequence, Union
18+
19+
from alembic import op
20+
import sqlalchemy as sa
21+
22+
revision: str = "009"
23+
down_revision: Union[str, None] = "008"
24+
branch_labels: Union[str, Sequence[str], None] = None
25+
depends_on: Union[str, Sequence[str], None] = None
26+
27+
28+
def upgrade() -> None:
29+
op.create_table(
30+
"serverdeployment",
31+
sa.Column("id", sa.Integer(), nullable=False),
32+
sa.Column("server_id", sa.Integer(), nullable=False),
33+
sa.Column("protocol", sa.String(), nullable=False),
34+
sa.Column("config_json", sa.Text(), nullable=False),
35+
sa.Column("status", sa.String(), nullable=False, server_default="configured"),
36+
sa.Column("last_node_id", sa.Integer(), nullable=True),
37+
sa.Column("created_at", sa.DateTime(), nullable=False),
38+
sa.Column("updated_at", sa.DateTime(), nullable=False),
39+
sa.ForeignKeyConstraint(
40+
["server_id"], ["server.id"],
41+
name="fk_deployment_server_id",
42+
ondelete="CASCADE", # delete server → drop its deployments
43+
),
44+
sa.ForeignKeyConstraint(
45+
["last_node_id"], ["node.id"],
46+
name="fk_deployment_last_node_id",
47+
ondelete="SET NULL", # delete node → keep deployment, drop link
48+
),
49+
sa.UniqueConstraint("server_id", "protocol", name="uq_server_protocol"),
50+
sa.PrimaryKeyConstraint("id"),
51+
)
52+
op.create_index(
53+
"ix_serverdeployment_server_id",
54+
"serverdeployment",
55+
["server_id"],
56+
)
57+
58+
59+
def downgrade() -> None:
60+
op.drop_index("ix_serverdeployment_server_id", table_name="serverdeployment")
61+
op.drop_table("serverdeployment")

0 commit comments

Comments
 (0)