commit 23f5649651de5b0fcba1a1f57c1fafb84245bf9e Author: Андрей Бобырев Date: Thu May 14 20:25:02 2026 +0300 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 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