tunnel-client 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ include client/client_config.yaml.example
2
+ recursive-include client/static *
3
+ recursive-include shared *.py
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: tunnel-client
3
+ Version: 1.0.0
4
+ Summary: Tunnel client for tunnel.tunneloon.online
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0.0
7
+ Requires-Dist: rich>=13.0.0
8
+ Requires-Dist: websockets>=12.0
9
+ Requires-Dist: httpx>=0.25.0
10
+ Requires-Dist: fastapi>=0.104.0
11
+ Requires-Dist: uvicorn>=0.24.0
12
+ Requires-Dist: sqlalchemy>=2.0.0
13
+ Requires-Dist: aiosqlite>=0.19.0
14
+ Requires-Dist: pyyaml>=6.0
15
+ Dynamic: requires-dist
16
+ Dynamic: requires-python
17
+ Dynamic: summary
@@ -0,0 +1,113 @@
1
+ # Tunnel System
2
+
3
+ ## Клиент: установка и запуск
4
+
5
+ ### Вариант 1. Установка из PyPI (рекомендуется)
6
+
7
+ После каждого изменения в `client/` пакет автоматически публикуется в PyPI.
8
+
9
+ ```bash
10
+ pipx install tunnel-client
11
+ # или
12
+ pip install tunnel-client
13
+ ```
14
+
15
+ ### Вариант 2. Установка из GitLab Package Registry
16
+
17
+ После каждого изменения в `client/` пакет также публикуется в GitLab Package Registry.
18
+
19
+ **Для публичного проекта:**
20
+ ```bash
21
+ pipx install tunnel-client \
22
+ --index-url https://gitlab.com/api/v4/projects/PROJECT_ID/packages/pypi/simple \
23
+ --extra-index-url https://pypi.org/simple
24
+ ```
25
+
26
+ **Для приватного проекта:**
27
+ 1. Создайте Personal Access Token с правами `read_api` и `read_repository`
28
+ 2. Установите:
29
+ ```bash
30
+ pipx install tunnel-client \
31
+ --index-url https://__token__:YOUR_TOKEN@gitlab.com/api/v4/projects/PROJECT_ID/packages/pypi/simple \
32
+ --extra-index-url https://pypi.org/simple
33
+ ```
34
+
35
+ ### Вариант 3. Установка из артефакта (wheel)
36
+ 1. После пуша в `main/master` с изменениями в `client/` скачайте wheel из артефактов job `build_client`
37
+ 2. Установите:
38
+ ```bash
39
+ pipx install dist/tunnel_client-*.whl
40
+ ```
41
+
42
+ ### Вариант 4. Установка из исходников
43
+ ```bash
44
+ pip install .
45
+ # или в editable-режиме для разработки
46
+ pip install -e .
47
+ ```
48
+
49
+ ### Запуск клиента
50
+ ```bash
51
+ tunnel-client --port 3000
52
+ ```
53
+ Сервер по умолчанию: `wss://tunnel.tunneloon.online` (можно переопределить через `--server`)
54
+
55
+ ## Сервер: деплой в Docker
56
+
57
+ ### Автоматический деплой
58
+
59
+ При каждом изменении в `server_/` или `shared/`:
60
+ 1. Автоматически собирается Docker образ
61
+ 2. Образ пушится в GitLab Container Registry
62
+ 3. Автоматически деплоится на сервер (перезапускается контейнер)
63
+
64
+ **Требуется настройка:**
65
+ - GitLab CI/CD Variables: `SSH_PRIVATE_KEY`, `SSH_USER`, `SERVER_HOST`
66
+ - На сервере должны быть созданы директории: `/opt/tunnel-server/{config.yaml,data,certs}`
67
+
68
+ ### Ручной запуск контейнера
69
+ ```bash
70
+ docker run -d \
71
+ --name tunnel-server \
72
+ --restart unless-stopped \
73
+ --network host \
74
+ -v /opt/tunnel-server/config.yaml:/app/config.yaml:ro \
75
+ -v /opt/tunnel-server/certs:/app/certs:ro \
76
+ -v /opt/tunnel-server/data:/app/data \
77
+ -e SERVER_CONFIG=/app/config.yaml \
78
+ registry.gitlab.com/YOUR_PROJECT/server:latest
79
+ ```
80
+
81
+ ## CI/CD (GitLab)
82
+
83
+ ### Автоматические процессы:
84
+
85
+ **При изменении `server_/` или `shared/`:**
86
+ - `build_server_image` — сборка Docker образа
87
+ - `deploy_server_docker` — автоматический деплой на сервер
88
+
89
+ **При изменении `client/`, `shared/`, `setup.py` или `MANIFEST.in`:**
90
+ - `build_client` — сборка wheel + sdist (артефакты)
91
+ - `publish_client_gitlab` — автоматическая публикация в GitLab Package Registry
92
+ - `publish_client_pypi` — автоматическая публикация в PyPI
93
+
94
+ ### Переменные для деплоя сервера
95
+ Задайте в GitLab → Settings → CI/CD → Variables:
96
+ - `SSH_PRIVATE_KEY` — приватный ключ для SSH
97
+ - `SSH_USER` — пользователь на сервере
98
+ - `SERVER_HOST` — адрес сервера
99
+ - (опц.) `SERVER_CONFIG_REMOTE`, `SERVER_CERTS_REMOTE`, `SERVER_DATA_REMOTE`, `SERVER_CONTAINER_NAME`, `SERVER_PORT_HTTP`, `SSH_PORT`
100
+
101
+ ### Переменные для публикации в PyPI
102
+ Задайте в GitLab → Settings → CI/CD → Variables:
103
+ - `PYPI_USERNAME` — должно быть `__token__` (буквально так)
104
+ - `PYPI_PASSWORD` — ваш PyPI API token (создайте на https://pypi.org/manage/account/token/)
105
+
106
+ ## Разработка
107
+ ```bash
108
+ make install-dev # инструменты разработки (ruff, black, mypy, build)
109
+ make pre-commit-install
110
+ make lint # ruff
111
+ make fmt # black
112
+ make build-client # сборка пакета клиента
113
+ ```
@@ -0,0 +1,2 @@
1
+ # Client package
2
+
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import click
9
+ import uvicorn
10
+ import yaml
11
+ from rich.console import Console
12
+ from sqlalchemy import select
13
+
14
+ from .database import init_db, get_session
15
+ from .models import Settings
16
+ from .tunnel_client import TunnelClient
17
+ from .dashboard import app as dashboard_app
18
+ from .utils import normalize_server_url, generate_random_subdomain
19
+
20
+
21
+ console = Console()
22
+
23
+ # Default server URL - hardcoded
24
+ DEFAULT_SERVER_URL = "wss://tunnel.tunneloon.online"
25
+
26
+
27
+ def load_config(path: Optional[str]) -> dict:
28
+ cfg_path = path or str(Path(__file__).with_name("client_config.yaml"))
29
+ if Path(cfg_path).exists():
30
+ with open(cfg_path, "r") as f:
31
+ return yaml.safe_load(f)
32
+ return {}
33
+
34
+
35
+ async def get_or_create_settings() -> tuple[Optional[str], Optional[int], str]:
36
+ """Get server_url, local_port, and subdomain from Settings, or generate defaults."""
37
+ async for session in get_session():
38
+ res = await session.execute(select(Settings).order_by(Settings.id.asc()))
39
+ settings = res.scalars().first()
40
+
41
+ server_url = None
42
+ local_port = None
43
+ subdomain = None
44
+
45
+ if settings:
46
+ server_url = settings.server_url
47
+ local_port = settings.local_port
48
+ subdomain = settings.selected_subdomain
49
+
50
+ # If missing, generate random subdomain
51
+ if not subdomain:
52
+ subdomain = generate_random_subdomain()
53
+ if settings:
54
+ settings.selected_subdomain = subdomain
55
+ await session.commit()
56
+ else:
57
+ new_settings = Settings(selected_subdomain=subdomain)
58
+ session.add(new_settings)
59
+ await session.commit()
60
+
61
+ return server_url, local_port, subdomain
62
+
63
+
64
+ @click.group(invoke_without_command=True)
65
+ @click.pass_context
66
+ @click.option("--server", "server", "-s", type=str, required=False, hidden=True, help="Server URL override (advanced, not recommended)")
67
+ @click.option("--port", "port", "-p", type=int, required=False, help="Local port to expose")
68
+ @click.option("--config", "config_path", type=str, required=False, help="Path to client_config.yaml (advanced)")
69
+ def cli(ctx: click.Context, server: Optional[str], port: Optional[int], config_path: Optional[str]):
70
+ """Tunnel client for tunnel.tunneloon.online"""
71
+ # If no subcommand was invoked, run start by default
72
+ if ctx.invoked_subcommand is None:
73
+ ctx.invoke(start, server=server, port=port, config_path=config_path)
74
+
75
+
76
+ @cli.command()
77
+ @click.option("--server", "server", "-s", type=str, required=False, hidden=True, help="Server URL override (advanced, not recommended)")
78
+ @click.option("--port", "port", "-p", type=int, required=False, help="Local port to expose")
79
+ @click.option("--config", "config_path", type=str, required=False, help="Path to client_config.yaml (advanced)")
80
+ def start(server: Optional[str], port: Optional[int], config_path: Optional[str]):
81
+ """
82
+ Start tunnel client and local dashboard.
83
+
84
+ Simplest usage:
85
+ tunnel-client --port 3000
86
+
87
+ Server is pre-configured to tunnel.tunneloon.online.
88
+ If port not specified, uses settings from dashboard.
89
+ """
90
+ cfg = load_config(config_path)
91
+
92
+ # Load settings from DB
93
+ async def load_settings():
94
+ await init_db(cfg.get("database", {}).get("path"))
95
+ return await get_or_create_settings()
96
+
97
+ db_server_url, db_local_port, db_subdomain = asyncio.run(load_settings())
98
+
99
+ # Server URL: CLI override -> DB -> default (hardcoded)
100
+ if server:
101
+ # Only allow override for advanced users
102
+ server_url = normalize_server_url(server)
103
+ console.print(f"[yellow]Warning:[/yellow] Using custom server URL: {server_url}")
104
+ else:
105
+ # Use default hardcoded server
106
+ server_url = DEFAULT_SERVER_URL
107
+
108
+ if port is None:
109
+ port = db_local_port or cfg.get("client", {}).get("local_port")
110
+
111
+ if not port:
112
+ console.print("[red]Error:[/red] Local port is required. Use --port or configure in dashboard.")
113
+ return
114
+
115
+ # Use subdomain from DB (might be auto-generated)
116
+ subdomain = db_subdomain or generate_random_subdomain()
117
+
118
+ dashboard_enabled = cfg.get("dashboard", {}).get("enabled", True)
119
+ dashboard_port = int(cfg.get("dashboard", {}).get("port", 8080))
120
+
121
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
122
+
123
+ # Save settings to DB for future use (server_url is not saved, it's hardcoded)
124
+ async def save_settings():
125
+ async for session in get_session():
126
+ res = await session.execute(select(Settings).order_by(Settings.id.asc()))
127
+ settings = res.scalars().first()
128
+ if not settings:
129
+ settings = Settings()
130
+ session.add(settings)
131
+
132
+ # Don't save server_url - it's hardcoded
133
+ settings.local_port = port
134
+ settings.selected_subdomain = subdomain
135
+ await session.commit()
136
+
137
+ asyncio.run(save_settings())
138
+
139
+ console.print(f"[green]🚀 Starting tunnel...[/green]")
140
+ console.print(f"[blue]Server:[/blue] {server_url}")
141
+ console.print(f"[blue]WebSocket:[/blue] {server_url}/tunnel/connect")
142
+ console.print(f"[blue]Subdomain:[/blue] {subdomain}")
143
+ console.print(f"[blue]Public URL:[/blue] https://{subdomain}.tunnel.tunneloon.online")
144
+ console.print(f"[blue]Local:[/blue] localhost:{port}")
145
+ console.print(f"[yellow]Dashboard:[/yellow] http://127.0.0.1:{dashboard_port}")
146
+ console.print(f"[dim]Managing tunnel via dashboard at http://127.0.0.1:{dashboard_port}[/dim]")
147
+
148
+ async def runner():
149
+ client = TunnelClient()
150
+ tasks: list[asyncio.Task] = []
151
+ tunnel_task = asyncio.create_task(client.connect(server_url, subdomain, port))
152
+ tasks.append(tunnel_task)
153
+ server_obj = None
154
+ if dashboard_enabled:
155
+ dashboard_app.state.local_port = port
156
+ dashboard_app.state.tunnel_client = client # Store reference for status checks
157
+ dashboard_app.state.connection_status = "connecting" # Initial status
158
+ config = uvicorn.Config(dashboard_app, host="127.0.0.1", port=dashboard_port, log_level="info")
159
+ server_obj = uvicorn.Server(config)
160
+ server_task = asyncio.create_task(server_obj.serve())
161
+ tasks.append(server_task)
162
+
163
+ try:
164
+ await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
165
+ except asyncio.CancelledError:
166
+ pass
167
+ finally:
168
+ await client.stop()
169
+ if server_obj is not None:
170
+ server_obj.should_exit = True
171
+ for t in tasks:
172
+ t.cancel()
173
+ await asyncio.gather(*tasks, return_exceptions=True)
174
+
175
+ try:
176
+ asyncio.run(runner())
177
+ except KeyboardInterrupt:
178
+ console.print("[red]Shutting down...[/red]")
179
+
180
+
181
+ @cli.command()
182
+ @click.option("--server", "server", "-S", type=str, required=False, default=None, hidden=True, help="Server URL override (advanced)")
183
+ def list(server: Optional[str]):
184
+ """List registered domains via server API."""
185
+ import httpx
186
+ base_url = "https://tunnel.tunneloon.online"
187
+ if server:
188
+ base = server.replace("ws://", "http://").replace("wss://", "https://")
189
+ base = base.replace("http://", "https://")
190
+ else:
191
+ base = base_url
192
+ try:
193
+ resp = httpx.get(f"{base}/api/domains", timeout=10)
194
+ resp.raise_for_status()
195
+ data = resp.json()
196
+ domains = data.get("domains", [])
197
+ if not domains:
198
+ console.print("[yellow]No domains found[/yellow]")
199
+ else:
200
+ console.print("[green]Domains:[/green] " + ", ".join(domains))
201
+ except Exception as e:
202
+ console.print(f"[red]Error:[/red] {e}")
203
+
204
+
205
+ if __name__ == "__main__":
206
+ cli()
207
+
208
+
@@ -0,0 +1,16 @@
1
+ client:
2
+ server: ws://localhost:8081
3
+ subdomain: myapp
4
+ local_port: 3000
5
+
6
+ dashboard:
7
+ enabled: true
8
+ port: 8080
9
+
10
+ database:
11
+ path: ./client.db
12
+
13
+ logging:
14
+ level: INFO
15
+ file: ./client.log
16
+
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Dict, Any
5
+
6
+ _queue: asyncio.Queue[Dict[str, Any]] | None = None
7
+
8
+
9
+ def get_queue() -> asyncio.Queue[Dict[str, Any]]:
10
+ global _queue
11
+ if _queue is None:
12
+ _queue = asyncio.Queue()
13
+ return _queue
14
+
15
+
16
+ async def enqueue(cmd: Dict[str, Any]) -> None:
17
+ await get_queue().put(cmd)
18
+
19
+