From 23f5649651de5b0fcba1a1f57c1fafb84245bf9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Thu, 14 May 2026 20:25:02 +0300 Subject: [PATCH] feat: kaskad memo site with Basic Auth Flask + Docker, header support links, install/uninstall curl scripts, password file or ADMIN_PASSWORD. Co-authored-by: Cursor --- .dockerignore | 6 ++ .gitignore | 10 ++ Dockerfile | 17 ++++ LICENSE | 21 ++++ README.md | 71 ++++++++++++++ app.py | 56 +++++++++++ install.sh | 96 ++++++++++++++++++ requirements.txt | 2 + static/styles.css | 227 +++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 144 +++++++++++++++++++++++++++ uninstall.sh | 16 +++ 11 files changed, 666 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.py create mode 100755 install.sh create mode 100644 requirements.txt create mode 100644 static/styles.css create mode 100644 templates/index.html create mode 100755 uninstall.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ab4b876 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +*.md +install.sh +uninstall.sh +.python-version +__pycache__ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6ee57d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Byte-compiled / cache +__pycache__/ +*.py[cod] +.Python +.venv/ +venv/ +.env +*.local + +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2f58ea6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY templates ./templates +COPY static ./static + +ENV PORT=8088 +EXPOSE 8088 + +CMD ["sh", "-c", "exec gunicorn -w 2 -b 0.0.0.0:${PORT} app:app"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7550702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Andrey271192 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..576b7bd --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# kaskad_web_vpn + +Мини-сайт на Flask с **HTTP Basic Auth**: памятка про каскад **AmneziaWG**, без сторонней рекламы. В шапке — ссылки автора на поддержку (GitHub, Boosty, Ozon СБП, Telegram). + +Репозиторий: [github.com/andrey271192/kaskad_web_vpn](https://github.com/andrey271192/kaskad_web_vpn) + +## Требования + +- Docker с доступом для текущего пользователя (или запуск скриптов от root). + +## Установка одной командой + +На сервере (порт хоста по умолчанию **8088**, логин **user1**, пароль генерируется и пишется в `/root/kaskad_web.initial-password`): + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo bash +``` + +Свой пароль и порт: + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo \ + env ADMIN_PASSWORD='ваш_секрет' HOST_PORT=8443 bash +``` + +Опционально ссылка на вашу веб-панель Amnezia (подставится на страницу): + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo \ + env PANEL_URL='https://panel.example.com' bash +``` + +Переменные окружения установки: + +| Переменная | По умолчанию | Описание | +|------------|--------------|----------| +| `HOST_PORT` | `8088` | Порт на хосте (`HOST_PORT:8088` в контейнере) | +| `BASIC_AUTH_USER` | `user1` | Логин Basic Auth | +| `ADMIN_PASSWORD` | — | Пароль; если не задан — берётся из файла или генерируется | +| `PASSWORD_FILE` | `/root/kaskad_web.initial-password` | Файл с паролем при автогенерации | +| `PANEL_URL` | пусто | URL панели для блока на главной | +| `KASKAD_REPO` | `andrey271192/kaskad_web_vpn` | Репозиторий GitHub | +| `KASKAD_REF` | `main` | Ветка | +| `KASKAD_INSTALL_ROOT` | `/opt` | Куда распаковать исходники для сборки | + +## Удаление + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/uninstall.sh | sudo bash +``` + +Удалить ещё и Docker-образ: + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/uninstall.sh | sudo env REMOVE_IMAGE=1 bash +``` + +## Локально без Docker + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +export BASIC_AUTH_PASSWORD='secret' +flask --app app run --host 0.0.0.0 --port 8088 +``` + +Проверка без авторизации: `GET /health`. + +## Лицензия + +MIT diff --git a/app.py b/app.py new file mode 100644 index 0000000..b0de062 --- /dev/null +++ b/app.py @@ -0,0 +1,56 @@ +"""Мини-сайт про каскад AmneziaWG: только страница и HTTP Basic Auth.""" +from __future__ import annotations + +import functools +import os + +from flask import Flask, Response, render_template, request + +USER = os.environ.get("BASIC_AUTH_USER", "user1").strip() or "user1" +PW = os.environ.get("BASIC_AUTH_PASSWORD", "").strip() +REALM = os.environ.get("BASIC_AUTH_REALM", "kaskad").strip() or "kaskad" +PANEL_URL = os.environ.get("PANEL_URL", "").strip() + +app = Flask(__name__) + + +def check_auth(u: str, p: str) -> bool: + if not PW: + return False + return u == USER and p == PW + + +def requires_auth(view): + @functools.wraps(view) + def wrapped(*args, **kwargs): + auth = request.authorization + if auth is None or not check_auth(auth.username or "", auth.password or ""): + return Response( + "Требуется вход\n", + 401, + {"WWW-Authenticate": f'Basic realm="{REALM}"'}, + mimetype="text/plain; charset=utf-8", + ) + return view(*args, **kwargs) + + return wrapped + + +@app.get("/health") +def health(): + return {"ok": True} + + +@app.get("/") +@requires_auth +def index(): + return render_template("index.html", panel_url=PANEL_URL) + + +def main(): + port = int(os.environ.get("PORT", "8088")) + app.run(host="0.0.0.0", port=port, threaded=True) + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..29b7e9f --- /dev/null +++ b/install.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Однострочная установка (пример в README): клон/скачивание с GitHub, сборка образа, запуск контейнера. +set -euo pipefail + +REPO="${KASKAD_REPO:-andrey271192/kaskad_web_vpn}" +REF="${KASKAD_REF:-main}" +IMAGE="${KASKAD_IMAGE:-kaskad-web-vpn:latest}" +CONTAINER="${KASKAD_CONTAINER:-kaskad-web-vpn}" +INSTALL_ROOT="${KASKAD_INSTALL_ROOT:-/opt}" +HOST_PORT="${HOST_PORT:-8088}" +BASIC_AUTH_USER="${BASIC_AUTH_USER:-user1}" +BASIC_AUTH_REALM="${BASIC_AUTH_REALM:-kaskad}" +PASSWORD_FILE="${PASSWORD_FILE:-/root/kaskad_web.initial-password}" +PANEL_URL="${PANEL_URL:-}" + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "Нужна команда: $1" >&2 + exit 1 + } +} + +need_cmd docker + +if ! docker info >/dev/null 2>&1; then + echo "Docker недоступен (запустите сервис или добавьте пользователя в группу docker)." >&2 + exit 1 +fi + +SRC="${INSTALL_ROOT}/kaskad_web_vpn_src" +mkdir -p "$(dirname "$SRC")" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +ARCHIVE_URL="https://github.com/${REPO}/archive/refs/heads/${REF}.tar.gz" +echo "Скачивание ${ARCHIVE_URL} …" +curl -fsSL "$ARCHIVE_URL" -o "${TMP}/src.tar.gz" +tar -xzf "${TMP}/src.tar.gz" -C "$TMP" +EXTRACTED="$(find "$TMP" -maxdepth 1 -type d -name "*-${REF}" | head -n1)" +if [[ -z "$EXTRACTED" ]]; then + EXTRACTED="$(find "$TMP" -maxdepth 1 -type d ! -path "$TMP" | head -n1)" +fi +rm -rf "$SRC" +mkdir -p "$SRC" +shopt -s dotglob +mv "$EXTRACTED"/* "$SRC"/ + +echo "Сборка образа ${IMAGE} …" +docker build -t "$IMAGE" "$SRC" + +if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then + echo "Удаление старого контейнера ${CONTAINER} …" + docker rm -f "$CONTAINER" >/dev/null +fi + +if [[ -n "${ADMIN_PASSWORD:-}" ]]; then + BASIC_AUTH_PASSWORD="$ADMIN_PASSWORD" +elif [[ -f "$PASSWORD_FILE" ]] && grep -qs . "$PASSWORD_FILE"; then + BASIC_AUTH_PASSWORD="$(tr -d '\r\n' <"$PASSWORD_FILE")" +else + if command -v openssl >/dev/null 2>&1; then + BASIC_AUTH_PASSWORD="$(openssl rand -hex 16)" + else + BASIC_AUTH_PASSWORD="$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)" + fi + umask 077 + printf '%s\n' "$BASIC_AUTH_PASSWORD" >"$PASSWORD_FILE" + echo "Сгенерирован пароль Basic Auth, сохранён в ${PASSWORD_FILE}" +fi + +if [[ -z "$BASIC_AUTH_PASSWORD" ]]; then + echo "BASIC_AUTH_PASSWORD пуст — задайте ADMIN_PASSWORD или файл ${PASSWORD_FILE}" >&2 + exit 1 +fi + +RUN_ARGS=( + -d + --name "$CONTAINER" + --restart unless-stopped + -p "${HOST_PORT}:8088" + -e "BASIC_AUTH_USER=${BASIC_AUTH_USER}" + -e "BASIC_AUTH_PASSWORD=${BASIC_AUTH_PASSWORD}" + -e "BASIC_AUTH_REALM=${BASIC_AUTH_REALM}" +) + +if [[ -n "$PANEL_URL" ]]; then + RUN_ARGS+=(-e "PANEL_URL=${PANEL_URL}") +fi + +docker run "${RUN_ARGS[@]}" "$IMAGE" + +echo "Готово." +echo " URL: http://$(hostname -f 2>/dev/null || hostname):${HOST_PORT}/" +echo " Логин: ${BASIC_AUTH_USER}" +echo " Пароль: см. ${PASSWORD_FILE} (или переменная ADMIN_PASSWORD при установке)" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ea9c8a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +gunicorn==23.0.0 diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..aa753b3 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,227 @@ +:root { + --bg: #070b10; + --card: #111722; + --line: rgba(255, 255, 255, 0.08); + --text: #f4f7ff; + --muted: #94a3b8; + --accent: #7dd3fc; + --warn: #fbbf24; + font-family: "DM Sans", system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.07), transparent 40%), + var(--bg); + color: var(--text); + line-height: 1.55; + min-height: 100vh; +} + +a { + color: var(--accent); +} + +.shell { + max-width: min(900px, 100%); + margin: 0 auto; + padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 3rem; +} + +.site-header { + border: 1px solid var(--line); + border-radius: 14px; + background: var(--card); + padding: 0.85rem 1rem; + margin-bottom: 1.25rem; +} + +.site-header .support-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.75rem; + font-size: 0.88rem; +} + +.site-header .support-title { + font-weight: 700; + margin-right: 0.25rem; +} + +.sep { + color: var(--muted); +} + +.support-blurb { + margin: 0.5rem 0 0; + font-size: 0.8rem; + color: var(--muted); +} + +h1 { + font-size: clamp(1.35rem, 3.5vw, 1.75rem); + margin: 0 0 0.35rem; +} + +.eyebrow { + letter-spacing: 0.14em; + text-transform: uppercase; + font-size: 0.68rem; + color: var(--accent); + margin: 0 0 0.35rem; +} + +.lead { + color: var(--muted); + margin: 0 0 1rem; + max-width: 62ch; +} + +.panel-fold { + margin-top: 1rem; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--card); + overflow: hidden; +} + +.panel-fold > summary { + list-style: none; + cursor: pointer; + padding: 0.85rem 1rem; + display: flex; + align-items: flex-start; + gap: 0.6rem; + user-select: none; + border-bottom: 1px solid transparent; +} + +.panel-fold > summary::-webkit-details-marker { + display: none; +} + +.panel-fold[open] > summary { + border-bottom-color: var(--line); +} + +.fold-arrow { + width: 0; + height: 0; + margin-top: 0.28rem; + border-style: solid; + border-width: 5px 0 5px 8px; + border-color: transparent transparent transparent var(--accent); + transition: transform 0.15s ease; +} + +.panel-fold[open] > summary .fold-arrow { + transform: rotate(90deg); +} + +.fold-h { + font-weight: 700; + font-size: 1rem; +} + +.fold-body { + padding: 0 1rem 1rem; +} + +.fold-body > *:first-child { + margin-top: 0.65rem; +} + +.note-warn { + border: 1px solid rgba(251, 191, 36, 0.35); + background: rgba(251, 191, 36, 0.06); + border-radius: 10px; + padding: 0.65rem 0.85rem; + font-size: 0.88rem; + color: #fde68a; +} + +.muted { + color: var(--muted); + font-size: 0.9rem; +} + +code, +.mono { + font-family: ui-monospace, "JetBrains Mono", monospace; + font-size: 0.85em; +} + +.inline-code { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--line); + padding: 0.12rem 0.35rem; + border-radius: 6px; +} + +.grid-form { + display: grid; + gap: 0.5rem; + max-width: 28rem; +} + +.grid-form label { + font-size: 0.82rem; + color: var(--muted); +} + +.grid-form input { + width: 100%; + padding: 0.55rem 0.65rem; + border-radius: 10px; + border: 1px solid var(--line); + background: #0a0f16; + color: var(--text); + font: inherit; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.35rem; +} + +.btn { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.5rem 0.85rem; + font: inherit; + cursor: pointer; + background: rgba(125, 211, 252, 0.12); + color: var(--text); +} + +.btn:hover { + background: rgba(125, 211, 252, 0.2); +} + +.out-box { + margin-top: 0.65rem; + padding: 0.75rem; + border-radius: 10px; + border: 1px solid var(--line); + background: #0a0f16; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.82rem; + color: #cbd5f5; + min-height: 3rem; +} + +footer.site-footer { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--line); + font-size: 0.78rem; + color: var(--muted); +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b36f6af --- /dev/null +++ b/templates/index.html @@ -0,0 +1,144 @@ + + + + + + Каскад AmneziaWG — памятка + + + + + + +
+ + +

VPN · каскад

+

Каскад для AmneziaWG

+

+ Краткая памятка: клиент подключается сначала к вашему промежуточному узлу (Endpoint), + а ключ и peer создаются на основном сервере AmneziaWG. Эта страница только объясняет шаги и может собрать текст-памятку; + генерация .conf выполняется в вашей админ-панели или вручную. +

+ + {% if panel_url %} +

Ваша панель: {{ panel_url }}

+ {% endif %} + +
+ + + Что такое каскад + +
+

+ В конфиге клиента поле Endpoint указывает на IP или DNS узла «перед» VPS + (домашний роутер с пробросом UDP, другой VPS и т.д.). На этом узле нужен проброс порта на ваш основной сервер WireGuard/AWG. + Сервер AmneziaWG добавляет peer и выдаёт готовый .conf. +

+
+
+ +
+ + + Через панель Amnezia Admin + +
+

+ Если установлена Amnezia Admin WebUI, откройте блок «Новый клиент под каскад»: + укажите Endpoint (ваш публичный IP/DNS узла и при необходимости порт), затем скачайте .conf. +

+

+ Репозиторий панели: + github.com/andrey271192/Amnezia_web +

+
+
+ +
+ + + Собрать памятку (локально в браузере) + +
+

Данные не отправляются на сервер — только текст для копирования.

+
+ + + + + + + + + +
+ + +
+
+

+      
+
+ +
+ + + Проброс UDP + +
+

+ На промежуточном узле пробросьте UDP с выбранного порта на IP VPS и порт, где слушает ваш инстанс AmneziaWG. + Убедитесь, что фаервол не режет входящий UDP. +

+
+
+ +
+ Сервис: HTTP Basic Auth (realm «kaskad»). Установка одной ссылкой — см. README репозитория kaskad_web_vpn. +
+
+ + + diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..dcecd1c --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONTAINER="${KASKAD_CONTAINER:-kaskad-web-vpn}" +IMAGE="${KASKAD_IMAGE:-kaskad-web-vpn:latest}" + +if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then + docker rm -f "$CONTAINER" + echo "Контейнер ${CONTAINER} удалён." +else + echo "Контейнер ${CONTAINER} не найден." +fi + +if [[ "${REMOVE_IMAGE:-0}" == "1" ]]; then + docker rmi "$IMAGE" 2>/dev/null && echo "Образ ${IMAGE} удалён." || echo "Образ ${IMAGE} не найден или занят." +fi