diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2336a21 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +.git +.github +.venv +venv +__pycache__ +**/__pycache__ +*.pyc +*.pyo +.pytest_cache +.mypy_cache +.ruff_cache +*.md +!README.md +.env +.env.* +!.env.example +data.json +ssl_temp +bin +tunnels_state.json +*.zip +Amnezia-Web-Panel-main.zip +screen +.idea +.vscode +*.log +dist +build +*.spec +.dockerignore +Dockerfile.warp +docker-compose.warp.yml +protocol_telemt diff --git a/.env.example b/.env.example index 2200963..7053f84 100644 --- a/.env.example +++ b/.env.example @@ -2,12 +2,11 @@ APP_PORT=5000 SECRET_KEY=change-me-to-a-long-random-string -# PostgreSQL 17 +# PostgreSQL (used by docker-compose) POSTGRES_USER=amnezia POSTGRES_PASSWORD=amnezia POSTGRES_DB=amnezia_panel POSTGRES_PORT=5432 -# Used by the panel process (local run or Docker) -# Docker Compose sets this automatically to point at the db service. -DATABASE_URL=postgresql://amnezia:amnezia@localhost:5432/amnezia_panel +# Full DSN (override if panel runs outside compose) +# DATABASE_URL=postgresql://amnezia:amnezia@db:5432/amnezia_panel diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 40fd03e..7a9abf3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Install dependencies run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5fecfe2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Validate translation JSON + run: | + python - <<'PY' + import json, pathlib, sys + root = pathlib.Path("translations") + errors = [] + for path in sorted(root.glob("*.json")): + try: + json.loads(path.read_text(encoding="utf-8")) + print(f"OK {path}") + except Exception as exc: + errors.append(f"{path}: {exc}") + if errors: + print("\n".join(errors)) + sys.exit(1) + PY + + - name: Compile Python sources + run: python -m compileall -q app.py telegram_bot.py managers db + + - name: Import smoke check + env: + DATABASE_URL: postgresql://amnezia:amnezia@127.0.0.1:5432/amnezia_panel + SECRET_KEY: ci-smoke-secret + run: | + python - <<'PY' + import ast + from pathlib import Path + # Syntax-only parse of entrypoints (full import needs live Postgres) + for name in ("app.py", "telegram_bot.py"): + ast.parse(Path(name).read_text(encoding="utf-8"), filename=name) + print(f"parsed {name}") + PY + + docker: + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + tags: amnezia-panel:ci + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..829476b --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,52 @@ +name: Docker Image + +on: + push: + branches: [main, master] + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index c6990fd..5b8dc5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,26 @@ # syntax=docker/dockerfile:1 -FROM python:3.14-slim +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + APP_PORT=5000 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 WORKDIR /app -# Копируем requirements.txt и устанавливаем зависимости -COPY requirements.txt requirements.txt -RUN pip3 install --no-cache-dir -r requirements.txt +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -# Копируем остальные файлы проекта COPY . . +EXPOSE 5000 -# Команда запуска приложения -CMD ["python3", "app.py"] \ No newline at end of file +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${APP_PORT}/docs" >/dev/null || exit 1 + +CMD ["python3", "app.py"] diff --git a/README.md b/README.md index 896bb01..a535012 100644 --- a/README.md +++ b/README.md @@ -172,10 +172,34 @@ Linux Mac ``` -## 🐳 Docker Image +## 🐳 Docker -https://hub.docker.com/r/prvtpro/amnezia-panel +**Quick start (panel + PostgreSQL 17):** +```bash +cp .env.example .env +# set SECRET_KEY and strong POSTGRES_PASSWORD in .env +docker compose up -d --build +``` + +Panel: `http://localhost:5000` (or `APP_PORT` from `.env`). + +| File | Purpose | +| --- | --- | +| `Dockerfile` | Production image (Python 3.12) | +| `docker-compose.yml` | Panel + PostgreSQL with healthchecks | +| `.env.example` | Environment template | + +**Environment** + +| Variable | Default | Description | +| --- | --- | --- | +| `APP_PORT` | `5000` | Host port / in-container listen port | +| `SECRET_KEY` | (random) | Session signing key — set in production | +| `DATABASE_URL` | compose DSN | PostgreSQL connection string | +| `POSTGRES_*` | `amnezia` | DB credentials for the `db` service | + +Prebuilt Hub image (upstream): https://hub.docker.com/r/prvtpro/amnezia-panel ### Initial Login * **Username**: `admin` @@ -183,6 +207,29 @@ https://hub.docker.com/r/prvtpro/amnezia-panel > [!IMPORTANT] > Secure your panel by changing the default password in the **Users** section immediately after first login. +## 🔁 CI/CD + +GitHub Actions workflows in `.github/workflows/`: + +| Workflow | Trigger | What it does | +| --- | --- | --- | +| `ci.yml` | push / PR to `main` | Validate translations, `compileall`, Docker build smoke | +| `docker.yml` | push to `main` / tags `v*` | Build & push image to GHCR (`ghcr.io//`) | +| `build.yml` | push / tags `v*` | PyInstaller binaries (Linux / Windows / macOS) + release assets | + +## 📋 Fix / changelog (this fork) + +Recent panel fixes and changes: + +* **Removed cascade (double VPN)** for now — the feature showed “active” while client traffic often had no working internet; will return after a solid redesign. +* **WG/AWG backups**: ZIP export of client `.conf` files + restore of protocol backups from the server UI (with loading feedback so the panel no longer freezes). +* **API performance**: in-memory data cache, faster server check/stats, heavy SSH work moved off the event loop. +* **Share / guest pages**: one-tap “Copy key” for configs. +* **User expiration**: optional countdown that starts on first config use (panel / share / guest / Telegram), with UTC-safe datetime comparison. +* **UI icons**: shared SVG icon system (emoji icons removed). +* **Docker / CI**: refreshed `Dockerfile` + compose, `.env.example`, CI checks, GHCR image workflow. +* **Removed for now**: Hysteria 2 and 3x-ui integration (kept out of the main panel path). + ## 🔧 Project Details ### API Documentation @@ -227,8 +274,9 @@ curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers/0/ping ### Technology Stack * **Backend**: FastAPI (Python), `asyncio` for concurrent SSH/probe work * **Frontend**: Vanilla JS, Jinja2, Custom CSS (Glassmorphism, full set of CSS animations for promo blocks) -* **Database**: Local JSON storage (`data.json`) with an `asyncio.Lock` for thread-safe writes +* **Database**: PostgreSQL 17 (`DATABASE_URL`) with a dict-compatible store API (`db/`) * **SSH Engine**: Paramiko +* **Deploy**: Docker Compose (panel + Postgres), optional GHCR image via CI ### Project Structure @@ -236,6 +284,7 @@ curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers/0/ping web-panel/ ├── app.py # FastAPI entry point + all routes ├── telegram_bot.py # Optional Telegram bot integration +├── db/ # PostgreSQL store + schema helpers ├── managers/ # Protocol & service managers (one file per protocol) │ ├── ssh_manager.py # SSH abstraction (Paramiko wrapper) │ ├── awg_manager.py # AmneziaWG / AWG 2.0 / AWG Legacy @@ -244,11 +293,14 @@ web-panel/ │ ├── telemt_manager.py # Telegram MTProxy │ ├── dns_manager.py # AmneziaDNS (Unbound) │ ├── adguard_manager.py # AdGuard Home -│ └── socks5_manager.py # 3proxy-based SOCKS5 +│ ├── socks5_manager.py # 3proxy-based SOCKS5 +│ └── backup_manager.py # Protocol backup / restore on remote hosts ├── static/ # CSS / favicon / vendored JS ├── templates/ # Jinja2 templates ├── translations/ # en / ru / fr / zh / fa -└── data.json # Panel state (servers, users, tokens, settings) +├── Dockerfile # Panel image +├── docker-compose.yml # Panel + PostgreSQL +└── .github/workflows/ # CI, Docker publish, binary builds ``` ## 🛡 Security Recommendations diff --git a/app.py b/app.py index 5037a09..a474b68 100644 --- a/app.py +++ b/app.py @@ -46,7 +46,6 @@ from managers.awg_manager import AWGManager from managers.xray_manager import XrayManager from managers.wireguard_manager import WireGuardManager from managers.backup_manager import BackupManager -from managers.cascade_manager import CascadeManager, normalize_settings, DEFAULT_SETTINGS as CASCADE_DEFAULT_SETTINGS import telegram_bot as tg_bot # Configure logging @@ -1444,25 +1443,6 @@ class ProtocolRequest(BaseModel): protocol: str = 'awg' -class CascadeSetupRequest(BaseModel): - entry_protocol: str = 'awg2' - exit_server_id: Optional[int] = None - exit_protocol: str = 'awg2' - enabled: bool = True - # Per-step cascade tuning - pin_exit_route: Optional[bool] = True - remove_eth_masquerade: Optional[bool] = True - force_forward: Optional[bool] = True - mss_clamp: Optional[bool] = True - wait_handshake: Optional[bool] = True - handshake_timeout_sec: Optional[int] = 25 - allowed_ips: Optional[str] = '0.0.0.0/0, ::/0' - keep_exit_dns: Optional[bool] = False - vpn_subnet_override: Optional[str] = '' - table_id: Optional[int] = 200 - rule_priority: Optional[int] = 100 - - class ContainerLogsRequest(BaseModel): protocol: str = 'awg' tail: Optional[int] = 200 @@ -2779,208 +2759,6 @@ CONTAINER_NAMES = { -@app.post('/api/servers/{server_id}/cascade', tags=["Protocols"]) -async def api_cascade_get(request: Request, server_id: int): - """Return saved cascade settings and live tunnel status for this (entry) server.""" - if not _check_admin(request): - return JSONResponse({'error': 'Forbidden'}, status_code=403) - try: - data = await load_data_async() - if server_id >= len(data['servers']): - return JSONResponse({'error': 'Server not found'}, status_code=404) - server = data['servers'][server_id] - cascade = dict(server.get('cascade') or {}) - live = {'enabled': False, 'up': False} - entry_proto = cascade.get('entry_protocol') or '' - if cascade.get('enabled') and entry_proto and CascadeManager.is_wg_family(entry_proto): - def _status(): - ssh = get_ssh(server) - ssh.connect() - try: - return CascadeManager(ssh).status(entry_proto) - finally: - ssh.disconnect() - live = await asyncio.to_thread(_status) - return { - 'cascade': cascade, - 'live': live, - 'defaults': CASCADE_DEFAULT_SETTINGS, - 'servers': [ - { - 'id': i, - 'name': s.get('name') or s.get('host'), - 'host': s.get('host'), - 'protocols': list((s.get('protocols') or {}).keys()), - } - for i, s in enumerate(data['servers']) if i != server_id - ], - } - except Exception as e: - logger.exception("Error reading cascade status") - return JSONResponse({'error': str(e)}, status_code=500) - - -@app.post('/api/servers/{server_id}/cascade/setup', tags=["Protocols"]) -async def api_cascade_setup(request: Request, server_id: int, req: CascadeSetupRequest): - """Enable or disable double-VPN cascade: clients → this entry → exit server.""" - if not _check_admin(request): - return JSONResponse({'error': 'Forbidden'}, status_code=403) - if not CascadeManager.is_wg_family(req.entry_protocol): - return JSONResponse({'error': 'Cascade supports WireGuard / AmneziaWG only'}, status_code=400) - if req.enabled and not CascadeManager.is_wg_family(req.exit_protocol): - return JSONResponse({'error': 'Cascade supports WireGuard / AmneziaWG only'}, status_code=400) - - try: - data = await load_data_async() - if server_id >= len(data['servers']): - return JSONResponse({'error': 'Server not found'}, status_code=404) - entry = data['servers'][server_id] - settings = normalize_settings({ - 'pin_exit_route': req.pin_exit_route, - 'remove_eth_masquerade': req.remove_eth_masquerade, - 'force_forward': req.force_forward, - 'mss_clamp': req.mss_clamp, - 'wait_handshake': req.wait_handshake, - 'handshake_timeout_sec': req.handshake_timeout_sec, - 'allowed_ips': req.allowed_ips, - 'keep_exit_dns': req.keep_exit_dns, - 'vpn_subnet_override': req.vpn_subnet_override, - 'table_id': req.table_id, - 'rule_priority': req.rule_priority, - }) - if not req.enabled: - def _disable(): - ssh = get_ssh(entry) - ssh.connect() - try: - return CascadeManager(ssh).disable( - req.entry_protocol, - settings=dict((entry.get('cascade') or {}).get('settings') or settings), - ) - finally: - ssh.disconnect() - - result = await asyncio.to_thread(_disable) - if result.get('status') == 'error': - return JSONResponse({'error': result.get('message', 'Failed to disable cascade')}, status_code=500) - prev = dict(entry.get('cascade') or {}) - entry['cascade'] = { - 'enabled': False, - 'entry_protocol': req.entry_protocol or prev.get('entry_protocol'), - 'exit_server_id': prev.get('exit_server_id'), - 'exit_protocol': prev.get('exit_protocol') or req.exit_protocol, - 'settings': settings, - 'updated_at': datetime.now().isoformat(timespec='seconds'), - } - await save_data_async(data) - return {'status': 'success', 'cascade': entry['cascade']} - - if req.exit_server_id is None: - return JSONResponse({'error': 'exit_server_id is required'}, status_code=400) - if req.exit_server_id < 0 or req.exit_server_id >= len(data['servers']): - return JSONResponse({'error': 'Exit server not found'}, status_code=404) - if req.exit_server_id == server_id: - return JSONResponse({'error': 'Entry and exit servers must be different'}, status_code=400) - - exit_srv = data['servers'][req.exit_server_id] - - # Enable: create/reuse peer on exit, then apply tunnel on entry - def _enable(): - exit_ssh = get_ssh(exit_srv) - exit_ssh.connect() - try: - exit_mgr = get_protocol_manager(exit_ssh, req.exit_protocol) - clients = _manager_call(exit_mgr, 'get_clients', req.exit_protocol) or [] - cascade_name = f"cascade-from-{(entry.get('name') or entry.get('host') or 'entry')[:40]}" - cascade_name = re.sub(r'[^\w.\-]+', '_', cascade_name).strip('._') or 'cascade-entry' - existing = None - for c in clients: - ud = c.get('userData') or {} - if ud.get('clientName') == cascade_name or ud.get('cascade_entry_server_id') == server_id: - existing = c - break - - if existing: - client_id = existing.get('clientId') - conf = _manager_call( - exit_mgr, 'get_client_config', req.exit_protocol, - client_id, exit_srv['host'], - (exit_srv.get('protocols') or {}).get(req.exit_protocol, {}).get('port', '55424'), - ) - else: - add_res = _manager_call( - exit_mgr, 'add_client', req.exit_protocol, cascade_name, - exit_srv['host'], - (exit_srv.get('protocols') or {}).get(req.exit_protocol, {}).get('port', '55424'), - ) - if not add_res.get('client_id'): - raise RuntimeError(add_res.get('message') or 'Failed to create cascade peer on exit') - client_id = add_res['client_id'] - conf = add_res.get('config') - if not conf: - conf = _manager_call( - exit_mgr, 'get_client_config', req.exit_protocol, - client_id, exit_srv['host'], - (exit_srv.get('protocols') or {}).get(req.exit_protocol, {}).get('port', '55424'), - ) - # Mark peer in clientsTable metadata if possible - try: - table = exit_mgr._get_clients_table(req.exit_protocol) if hasattr(exit_mgr, '_get_clients_table') else [] - for row in table: - if row.get('clientId') == client_id: - row.setdefault('userData', {})['cascade_entry_server_id'] = server_id - if hasattr(exit_mgr, '_save_clients_table'): - exit_mgr._save_clients_table(req.exit_protocol, table) - break - except Exception: - pass - finally: - exit_ssh.disconnect() - - entry_ssh = get_ssh(entry) - entry_ssh.connect() - try: - applied = CascadeManager(entry_ssh).apply( - req.entry_protocol, - conf, - exit_srv.get('host') or '', - settings=settings, - ) - finally: - entry_ssh.disconnect() - - if applied.get('status') != 'success': - raise RuntimeError(applied.get('message') or 'Failed to apply cascade on entry') - - return { - 'client_id': client_id, - 'client_name': cascade_name, - 'applied': applied, - } - - info = await asyncio.to_thread(_enable) - applied = info.get('applied') or {} - entry['cascade'] = { - 'enabled': True, - 'entry_protocol': req.entry_protocol, - 'exit_server_id': req.exit_server_id, - 'exit_protocol': req.exit_protocol, - 'exit_client_id': info['client_id'], - 'exit_client_name': info['client_name'], - 'settings': settings, - 'up': bool(applied.get('up')), - 'handshake': bool(applied.get('handshake')), - 'updated_at': datetime.now().isoformat(timespec='seconds'), - 'last_error': None, - 'diagnostics': applied.get('diagnostics') or '', - } - await save_data_async(data) - return {'status': 'success', 'cascade': entry['cascade'], 'applied': applied} - except Exception as e: - logger.exception("Error setting up cascade") - return JSONResponse({'error': str(e)}, status_code=500) - - @app.post('/api/servers/{server_id}/backups', tags=["Protocols"]) async def api_protocol_backups_list(request: Request, server_id: int, req: ProtocolRequest): """List backups created on the remote server for one protocol.""" @@ -5291,10 +5069,14 @@ if __name__ == '__main__': with open(key_file, 'w') as f: f.write(ssl_conf['key_text'].strip() + '\n') + try: + env_port = int(os.environ.get('APP_PORT', '0') or '0') + except ValueError: + env_port = 0 uvicorn_kwargs = { "app": app, "host": "0.0.0.0", - "port": ssl_conf.get('panel_port', 5000) + "port": env_port or ssl_conf.get('panel_port', 5000) or 5000, } if ssl_conf.get('enabled') and cert_file and key_file: diff --git a/docker-compose.yml b/docker-compose.yml index 276a0ef..7795183 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,12 +32,12 @@ services: environment: DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel} SECRET_KEY: ${SECRET_KEY:-} - APP_PORT: ${APP_PORT:-5000} + APP_PORT: "5000" volumes: - amnezia_data:/app/data restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "python3 -c \"import socket; s=socket.socket(); s.connect(('localhost', 5000)); s.close()\""] + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/docs >/dev/null || exit 1"] interval: 30s timeout: 10s retries: 3 diff --git a/managers/cascade_manager.py b/managers/cascade_manager.py deleted file mode 100644 index 928b292..0000000 --- a/managers/cascade_manager.py +++ /dev/null @@ -1,473 +0,0 @@ -"""Cascade (double-VPN) between two panel-managed WireGuard/AWG servers. - -Traffic path: - Clients → Entry server (accessible) → Exit server (blocked / target) → Internet - -Implemented inside the entry Docker container as a second AWG/WG interface -(`cascade.conf`) with source-based routing for the VPN client subnet. -""" - -from __future__ import annotations - -import re -import shlex -from datetime import datetime -from typing import Any, Optional - - -WG_FAMILY = {'awg', 'awg2', 'awg_legacy', 'wireguard'} -CASCADE_MARKER = '# amnezia-web-panel-cascade' - -DEFAULT_SETTINGS = { - 'pin_exit_route': True, # host route to exit IP via real gateway - 'remove_eth_masquerade': True, # stop NATing VPN clients out entry eth0/eth1 - 'force_forward': True, # FORWARD accept between awg0/wg0 and cascade - 'mss_clamp': True, # fix broken TCP / endless page load - 'wait_handshake': True, # fail if exit tunnel has no handshake - 'handshake_timeout_sec': 25, - 'allowed_ips': '0.0.0.0/0, ::/0', - 'keep_exit_dns': False, # keep DNS= from exit client config - 'vpn_subnet_override': '', # e.g. 10.8.1.0/24; empty = auto - 'table_id': 200, - 'rule_priority': 100, -} - - -def normalize_settings(raw: Optional[dict]) -> dict: - settings = dict(DEFAULT_SETTINGS) - if isinstance(raw, dict): - for key, default in DEFAULT_SETTINGS.items(): - if key not in raw or raw[key] is None: - continue - if isinstance(default, bool): - settings[key] = bool(raw[key]) - elif isinstance(default, int): - try: - settings[key] = int(raw[key]) - except (TypeError, ValueError): - settings[key] = default - else: - settings[key] = str(raw[key]).strip() - # Sanity clamps - settings['handshake_timeout_sec'] = max(5, min(120, int(settings['handshake_timeout_sec']))) - settings['table_id'] = max(1, min(252, int(settings['table_id']))) - settings['rule_priority'] = max(1, min(32765, int(settings['rule_priority']))) - if not settings.get('allowed_ips'): - settings['allowed_ips'] = DEFAULT_SETTINGS['allowed_ips'] - return settings - - -class CascadeManager: - def __init__(self, ssh_manager): - self.ssh = ssh_manager - - @staticmethod - def proto_base(protocol: str) -> str: - return str(protocol or '').split('__', 1)[0] - - @staticmethod - def is_wg_family(protocol: str) -> bool: - return CascadeManager.proto_base(protocol) in WG_FAMILY - - @staticmethod - def _container_name(protocol: str) -> str: - base = CascadeManager.proto_base(protocol) - match = re.search(r'__(\d+)$', str(protocol or '')) - idx = int(match.group(1)) if match else 1 - names = { - 'awg': 'amnezia-awg', - 'awg2': 'amnezia-awg2', - 'awg_legacy': 'amnezia-awg-legacy', - 'wireguard': 'amnezia-wireguard', - } - name = names.get(base) - if not name: - return '' - return name if idx <= 1 else f'{name}-{idx}' - - @staticmethod - def _wg_binary(protocol: str) -> str: - return 'wg' if CascadeManager.proto_base(protocol) in ('awg_legacy', 'wireguard') else 'awg' - - @staticmethod - def _quick_binary(protocol: str) -> str: - return 'wg-quick' if CascadeManager.proto_base(protocol) in ('awg_legacy', 'wireguard') else 'awg-quick' - - @staticmethod - def _conf_dir(protocol: str) -> str: - return '/opt/amnezia/wireguard' if CascadeManager.proto_base(protocol) == 'wireguard' else '/opt/amnezia/awg' - - @classmethod - def _cascade_conf(cls, protocol: str) -> str: - return f'{cls._conf_dir(protocol)}/cascade.conf' - - @classmethod - def _cascade_up(cls, protocol: str) -> str: - return f'{cls._conf_dir(protocol)}/cascade-up.sh' - - @staticmethod - def _server_conf(protocol: str) -> str: - base = CascadeManager.proto_base(protocol) - if base == 'wireguard': - return '/opt/amnezia/wireguard/wg0.conf' - if base == 'awg_legacy': - return '/opt/amnezia/awg/wg0.conf' - return '/opt/amnezia/awg/awg0.conf' - - @staticmethod - def prepare_exit_client_config( - raw_config: str, - endpoint_host: str, - *, - keep_dns: bool = False, - allowed_ips: str = '0.0.0.0/0, ::/0', - ) -> str: - """Adapt a normal client config for use as an entry→exit cascade tunnel.""" - lines = [] - in_interface = False - in_peer = False - has_table = False - for raw in (raw_config or '').splitlines(): - stripped = raw.strip() - lower = stripped.lower() - if stripped.startswith('[') and stripped.endswith(']'): - in_interface = stripped.lower() == '[interface]' - in_peer = stripped.lower() == '[peer]' - lines.append(stripped) - continue - if in_interface and lower.startswith('dns') and not keep_dns: - continue - if in_interface and lower.startswith('table'): - has_table = True - lines.append('Table = off') - continue - if lower.startswith('endpoint'): - port = '51820' - if '=' in stripped: - rhs = stripped.split('=', 1)[1].strip() - if ':' in rhs: - port = rhs.rsplit(':', 1)[-1] - host = (endpoint_host or '').strip() - if host: - lines.append(f'Endpoint = {host}:{port}') - else: - lines.append(stripped) - continue - if in_peer and lower.startswith('allowedips'): - lines.append(f'AllowedIPs = {allowed_ips}') - continue - lines.append(stripped) - - if not has_table: - out = [] - inserted = False - for line in lines: - out.append(line) - if not inserted and line.strip().lower() == '[interface]': - out.append('Table = off') - inserted = True - lines = out - return '\n'.join(lines).strip() + '\n' - - @staticmethod - def _extract_endpoint_host(config_text: str) -> str: - for line in (config_text or '').splitlines(): - if line.strip().lower().startswith('endpoint'): - rhs = line.split('=', 1)[-1].strip() - return rhs.rsplit(':', 1)[0].strip().strip('[]') - return '' - - def status(self, entry_protocol: str) -> dict: - container = self._container_name(entry_protocol) - if not container: - return {'enabled': False, 'up': False, 'handshake': False, 'error': 'Unsupported protocol'} - conf = self._cascade_conf(entry_protocol) - wg_bin = self._wg_binary(entry_protocol) - out, _, _ = self.ssh.run_sudo_command( - f"docker exec {shlex.quote(container)} bash -lc " - f"'echo HAS=$(test -f {shlex.quote(conf)} && echo 1 || echo 0); " - f"echo IFACES=$({wg_bin} show interfaces 2>/dev/null); " - f"echo HANDSHAKE=$({wg_bin} show cascade latest-handshakes 2>/dev/null | awk \"{{print \\$2}}\" | head -1); " - f"echo TRANSFER=$({wg_bin} show cascade transfer 2>/dev/null | head -1); " - f"ip rule show 2>/dev/null | head -20; " - f"ip route show table 200 2>/dev/null | head -10'" - ) - text = out or '' - has_conf = 'HAS=1' in text - ifaces_line = '' - handshake_ts = 0 - for line in text.splitlines(): - if line.startswith('IFACES='): - ifaces_line = line.split('=', 1)[1].strip() - if line.startswith('HANDSHAKE='): - try: - handshake_ts = int(line.split('=', 1)[1].strip() or '0') - except ValueError: - handshake_ts = 0 - up = 'cascade' in ifaces_line.split() - # WireGuard reports unix timestamp; 0 means never - handshake_ok = handshake_ts > 0 - return { - 'enabled': has_conf, - 'up': up, - 'handshake': handshake_ok, - 'handshake_ts': handshake_ts, - 'raw': text.strip()[:3000], - } - - def _vpn_subnet(self, entry_protocol: str, container: str, override: str = '') -> str: - if override and re.match(r'^\d+\.\d+\.\d+\.\d+/\d+$', override.strip()): - return override.strip() - conf = self._server_conf(entry_protocol) - out, _, _ = self.ssh.run_sudo_command( - f"docker exec {shlex.quote(container)} bash -lc " - f"\"grep -E '^Address' {shlex.quote(conf)} | head -1 | cut -d= -f2 | tr -d ' '\"" - ) - addr = (out or '').strip() - if not addr: - return '10.8.1.0/24' - if '/' in addr: - ip, cidr = addr.split('/', 1) - parts = ip.split('.') - if len(parts) == 4 and cidr.isdigit() and int(cidr) >= 24: - return f"{parts[0]}.{parts[1]}.{parts[2]}.0/{cidr}" - return addr - parts = addr.split('.') - if len(parts) == 4: - return f"{parts[0]}.{parts[1]}.{parts[2]}.0/24" - return '10.8.1.0/24' - - def apply( - self, - entry_protocol: str, - exit_client_config: str, - exit_host: str, - settings: Optional[dict] = None, - ) -> dict: - opts = normalize_settings(settings) - container = self._container_name(entry_protocol) - if not container: - return {'status': 'error', 'message': 'Unsupported entry protocol'} - - exists, _, code = self.ssh.run_sudo_command( - f"docker inspect -f '{{{{.State.Running}}}}' {shlex.quote(container)} 2>/dev/null" - ) - if code != 0 or exists.strip().lower() != 'true': - return {'status': 'error', 'message': f'Entry container {container} is not running'} - - cascade_conf_path = self._cascade_conf(entry_protocol) - cascade_up_path = self._cascade_up(entry_protocol) - cascade_conf = self.prepare_exit_client_config( - exit_client_config, - exit_host, - keep_dns=bool(opts['keep_exit_dns']), - allowed_ips=str(opts['allowed_ips']), - ) - endpoint_host = self._extract_endpoint_host(cascade_conf) or exit_host - subnet = self._vpn_subnet(entry_protocol, container, opts.get('vpn_subnet_override') or '') - wg_bin = self._wg_binary(entry_protocol) - quick_bin = self._quick_binary(entry_protocol) - conf_dir = self._conf_dir(entry_protocol) - table_id = int(opts['table_id']) - prio = int(opts['rule_priority']) - hs_timeout = int(opts['handshake_timeout_sec']) - - pin_block = '' - if opts['pin_exit_route']: - pin_block = f""" -EXIT_IP="$EXIT_HOST" -if ! printf '%s' "$EXIT_IP" | grep -Eq '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$'; then - EXIT_IP=$(getent ahostsv4 "$EXIT_HOST" 2>/dev/null | awk '{{print $1; exit}}') - if [ -z "$EXIT_IP" ]; then - EXIT_IP=$(python3 - <<'PY' 2>/dev/null || true -import socket -print(socket.gethostbyname("{endpoint_host}")) -PY -) - fi -fi -GW=$(ip route | awk '/default/ {{print $3; exit}}') -DEV=$(ip route | awk '/default/ {{print $5; exit}}') -if [ -n "$EXIT_IP" ] && [ -n "$GW" ] && [ -n "$DEV" ]; then - ip route replace "$EXIT_IP/32" via "$GW" dev "$DEV" 2>/dev/null || true -fi -""" - - remove_masq = '' - if opts['remove_eth_masquerade']: - remove_masq = f""" -# Stop leaking VPN clients out of the entry server NIC -for ETH in eth0 eth1 eth2 ens3 ens5 enp0s3 enp1s0; do - while iptables -t nat -D POSTROUTING -s "$SUBNET" -o "$ETH" -j MASQUERADE 2>/dev/null; do :; done -done -""" - - forward_block = '' - if opts['force_forward']: - forward_block = f""" -sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true -for SRC_IF in awg0 wg0; do - iptables -C FORWARD -i "$SRC_IF" -o "$IFACE" -j ACCEPT 2>/dev/null \\ - || iptables -I FORWARD 1 -i "$SRC_IF" -o "$IFACE" -j ACCEPT 2>/dev/null || true - iptables -C FORWARD -i "$IFACE" -o "$SRC_IF" -j ACCEPT 2>/dev/null \\ - || iptables -I FORWARD 1 -i "$IFACE" -o "$SRC_IF" -j ACCEPT 2>/dev/null || true -done -iptables -C FORWARD -i "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null \\ - || iptables -I FORWARD 1 -i "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT -""" - - mss_block = '' - if opts['mss_clamp']: - mss_block = """ -iptables -t mangle -C FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null \\ - || iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu -""" - - handshake_block = '' - if opts['wait_handshake']: - handshake_block = f""" -OK=0 -for i in $(seq 1 {hs_timeout}); do - HS=$({shlex.quote(wg_bin)} show "$IFACE" latest-handshakes 2>/dev/null | awk '{{print $2}}' | head -1) - if [ -n "$HS" ] && [ "$HS" != "0" ]; then OK=1; break; fi - sleep 1 -done -if [ "$OK" != "1" ]; then - echo "CASCADE_NO_HANDSHAKE" >&2 - echo "Tunnel interface is up but exit peer did not handshake. Check exit server / UDP port / keys." >&2 - exit 42 -fi -""" - - up_script = f"""#!/bin/bash -{CASCADE_MARKER} -set -e -CONF={shlex.quote(cascade_conf_path)} -QUICK={shlex.quote(quick_bin)} -SUBNET={shlex.quote(subnet)} -EXIT_HOST={shlex.quote(endpoint_host)} -IFACE=cascade -TABLE={table_id} -PRIO={prio} - -"$QUICK" down "$CONF" 2>/dev/null || true -ip link delete "$IFACE" 2>/dev/null || true -ip rule del from "$SUBNET" table "$TABLE" 2>/dev/null || true -ip route flush table "$TABLE" 2>/dev/null || true - -{pin_block} - -"$QUICK" up "$CONF" - -# Source-based routing: VPN clients go through cascade iface -ip route replace default dev "$IFACE" table "$TABLE" 2>/dev/null || ip route add default dev "$IFACE" table "$TABLE" -ip rule del from "$SUBNET" table "$TABLE" 2>/dev/null || true -ip rule add from "$SUBNET" table "$TABLE" priority "$PRIO" - -{remove_masq} -{forward_block} -iptables -t nat -C POSTROUTING -s "$SUBNET" -o "$IFACE" -j MASQUERADE 2>/dev/null \\ - || iptables -t nat -A POSTROUTING -s "$SUBNET" -o "$IFACE" -j MASQUERADE -{mss_block} -{handshake_block} - -echo CASCADE_UP_OK -""" - - self.ssh.run_sudo_command(f"docker exec {shlex.quote(container)} mkdir -p {shlex.quote(conf_dir)}") - self.ssh.upload_file(cascade_conf, '/tmp/_amnz_cascade.conf') - self.ssh.upload_file(up_script, '/tmp/_amnz_cascade_up.sh') - self.ssh.run_sudo_command( - f"docker cp /tmp/_amnz_cascade.conf {shlex.quote(container)}:{shlex.quote(cascade_conf_path)} && " - f"docker cp /tmp/_amnz_cascade_up.sh {shlex.quote(container)}:{shlex.quote(cascade_up_path)} && " - f"docker exec {shlex.quote(container)} chmod 644 {shlex.quote(cascade_conf_path)} && " - f"docker exec {shlex.quote(container)} chmod +x {shlex.quote(cascade_up_path)}" - ) - self.ssh.run_command('rm -f /tmp/_amnz_cascade.conf /tmp/_amnz_cascade_up.sh') - - self._ensure_start_hook(container, cascade_up_path) - - out, err, code = self.ssh.run_sudo_command( - f"docker exec {shlex.quote(container)} bash {shlex.quote(cascade_up_path)}", - timeout=max(90, hs_timeout + 40), - ) - combined = ((out or '') + '\n' + (err or '')).strip() - if code != 0 or 'CASCADE_UP_OK' not in (out or ''): - msg = combined - if 'CASCADE_NO_HANDSHAKE' in combined: - msg = ( - 'Туннель к серверу выхода поднят, но handshake не прошёл. ' - 'Проверьте, что на выходе открыт UDP-порт, протокол совпадает, ' - 'и входной сервер может достучаться до IP выхода.' - ) - return { - 'status': 'error', - 'message': (msg or 'Failed to bring cascade interface up').strip()[:900], - 'log': combined[:2000], - } - - st = self.status(entry_protocol) - return { - 'status': 'success', - 'subnet': subnet, - 'endpoint': endpoint_host, - 'container': container, - 'up': bool(st.get('up')), - 'handshake': bool(st.get('handshake')), - 'settings': opts, - 'applied_at': datetime.now().isoformat(timespec='seconds'), - 'diagnostics': st.get('raw', '')[:1500], - } - - def _ensure_start_hook(self, container: str, cascade_up_path: str) -> None: - marker = CASCADE_MARKER - installer = f"""#!/bin/bash -set -e -START=/opt/amnezia/start.sh -[ -f "$START" ] || exit 0 -grep -q '{marker}' "$START" 2>/dev/null && exit 0 -printf '\\n%s\\n' '{marker}' >> "$START" -printf '%s\\n' 'if [ -x {cascade_up_path} ]; then bash {cascade_up_path} || true; fi' >> "$START" -""" - self.ssh.upload_file(installer, '/tmp/_amnz_cascade_hook.sh') - self.ssh.run_sudo_command( - f"docker cp /tmp/_amnz_cascade_hook.sh {shlex.quote(container)}:/tmp/_hook.sh && " - f"docker exec {shlex.quote(container)} bash /tmp/_hook.sh && " - f"docker exec {shlex.quote(container)} rm -f /tmp/_hook.sh" - ) - self.ssh.run_command('rm -f /tmp/_amnz_cascade_hook.sh') - - def disable(self, entry_protocol: str, settings: Optional[dict] = None) -> dict: - opts = normalize_settings(settings) - container = self._container_name(entry_protocol) - if not container: - return {'status': 'error', 'message': 'Unsupported entry protocol'} - conf = self._cascade_conf(entry_protocol) - up = self._cascade_up(entry_protocol) - quick_bin = self._quick_binary(entry_protocol) - table_id = int(opts['table_id']) - script = f"""#!/bin/bash -set +e -QUICK={shlex.quote(quick_bin)} -CONF={shlex.quote(conf)} -UP={shlex.quote(up)} -TABLE={table_id} -"$QUICK" down "$CONF" 2>/dev/null -ip link delete cascade 2>/dev/null -ip rule del table "$TABLE" 2>/dev/null -ip rule del table "$TABLE" 2>/dev/null -ip route flush table "$TABLE" 2>/dev/null -rm -f "$CONF" "$UP" -echo CASCADE_DOWN_OK -""" - self.ssh.upload_file(script, '/tmp/_amnz_cascade_down.sh') - out, err, code = self.ssh.run_sudo_command( - f"docker cp /tmp/_amnz_cascade_down.sh {shlex.quote(container)}:/tmp/_cascade_down.sh && " - f"docker exec {shlex.quote(container)} bash /tmp/_cascade_down.sh && " - f"docker exec {shlex.quote(container)} rm -f /tmp/_cascade_down.sh", - timeout=60, - ) - self.ssh.run_command('rm -f /tmp/_amnz_cascade_down.sh') - if code != 0 and 'CASCADE_DOWN_OK' not in (out or ''): - return {'status': 'success', 'message': (err or out or 'Cascade cleared (best effort)').strip()[:400]} - return {'status': 'success'} diff --git a/templates/server.html b/templates/server.html index f43664f..4cef8ad 100644 --- a/templates/server.html +++ b/templates/server.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "base.html" %} {% from "macros/icons.html" import icon %} {% block title_extra %} — {{ server.name }}{% endblock %} @@ -477,144 +477,6 @@ - -
-
-
-

{{ _('cascade_title') }}

-

{{ _('cascade_desc') }}

-
- {{ _('cascade_off') }} -
-
-
-
- - -
-
- - -
-
- - -
-
-
{{ _('cascade_hint') }}
- -
- {{ _('cascade_settings_title') }} -

{{ _('cascade_settings_desc') }}

-
- - - - - - -
-
-
- - -
-
- - -
-
- - -
-
-
-
- - - {{ _('cascade_opt_allowed_ips_hint') }} -
-
- - - {{ _('cascade_opt_vpn_subnet_hint') }} -
-
-
- - - -
- - - -
- -
-