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 <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 20:25:02 +03:00
commit 23f5649651
11 changed files with 666 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
*.md
install.sh
uninstall.sh
.python-version
__pycache__

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Byte-compiled / cache
__pycache__/
*.py[cod]
.Python
.venv/
venv/
.env
*.local
.DS_Store

17
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

71
README.md Normal file
View File

@@ -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

56
app.py Normal file
View File

@@ -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()

96
install.sh Executable file
View File

@@ -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 при установке)"

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
Flask==3.0.3
gunicorn==23.0.0

227
static/styles.css Normal file
View File

@@ -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);
}

144
templates/index.html Normal file
View File

@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Каскад AmneziaWG — памятка</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
</head>
<body>
<div class="shell">
<header class="site-header">
<div class="support-line">
<span class="support-title">Поддержка автора</span>
<span class="sep" aria-hidden="true">·</span>
<a href="https://github.com/andrey271192/kaskad_web_vpn" target="_blank" rel="noopener noreferrer">GitHub</a>
<span class="sep">·</span>
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
<span class="sep">·</span>
<a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener noreferrer">Ozon&nbsp;СБП</a>
<span class="sep">·</span>
<a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">Telegram @lot_andrey</a>
</div>
<p class="support-blurb">Звезда репозиторию и донат помогают развивать утилиты. На этой странице нет сторонней рекламы.</p>
</header>
<p class="eyebrow">VPN · каскад</p>
<h1>Каскад для AmneziaWG</h1>
<p class="lead">
Краткая памятка: клиент подключается сначала к вашему <strong>промежуточному узлу</strong> (Endpoint),
а ключ и peer создаются на основном сервере AmneziaWG. Эта страница только объясняет шаги и может собрать текст-памятку;
генерация <span class="inline-code">.conf</span> выполняется в вашей <strong>админ-панели</strong> или вручную.
</p>
{% if panel_url %}
<p class="muted">Ваша панель: <a href="{{ panel_url }}">{{ panel_url }}</a></p>
{% endif %}
<details class="panel-fold" open>
<summary>
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-h">Что такое каскад</span>
</summary>
<div class="fold-body">
<p class="muted">
В конфиге клиента поле <span class="inline-code">Endpoint</span> указывает на IP или DNS узла «перед» VPS
(домашний роутер с пробросом UDP, другой VPS и т.д.). На этом узле нужен проброс порта на ваш основной сервер WireGuard/AWG.
Сервер AmneziaWG добавляет peer и выдаёт готовый <span class="inline-code">.conf</span>.
</p>
</div>
</details>
<details class="panel-fold" open>
<summary>
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-h">Через панель Amnezia Admin</span>
</summary>
<div class="fold-body">
<p class="muted">
Если установлена <strong>Amnezia Admin WebUI</strong>, откройте блок <strong>«Новый клиент под каскад»</strong>:
укажите Endpoint (ваш публичный IP/DNS узла и при необходимости порт), затем скачайте <span class="inline-code">.conf</span>.
</p>
<p class="muted">
Репозиторий панели:
<a href="https://github.com/andrey271192/Amnezia_web" target="_blank" rel="noopener noreferrer">github.com/andrey271192/Amnezia_web</a>
</p>
</div>
</details>
<details class="panel-fold">
<summary>
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-h">Собрать памятку (локально в браузере)</span>
</summary>
<div class="fold-body">
<p class="muted">Данные не отправляются на сервер — только текст для копирования.</p>
<div class="grid-form">
<label for="ep-host">Endpoint (хост для клиента)</label>
<input id="ep-host" type="text" placeholder="203.0.113.50 или ddns.example.com" autocomplete="off">
<label for="ep-port">Порт Endpoint (если пусто — как у сервера)</label>
<input id="ep-port" type="number" min="1" max="65535" placeholder="51820" autocomplete="off">
<label for="memo-name">Имя клиента (произвольно)</label>
<input id="memo-name" type="text" placeholder="Телефон через узел X" autocomplete="off">
<div class="btn-row">
<button type="button" class="btn" id="btn-build">Собрать текст</button>
<button type="button" class="btn" id="btn-copy">Копировать</button>
</div>
</div>
<pre class="out-box" id="out-memo" aria-live="polite"></pre>
</div>
</details>
<details class="panel-fold">
<summary>
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-h">Проброс UDP</span>
</summary>
<div class="fold-body">
<p class="muted">
На промежуточном узле пробросьте UDP с выбранного порта на IP VPS и порт, где слушает ваш инстанс AmneziaWG.
Убедитесь, что фаервол не режет входящий UDP.
</p>
</div>
</details>
<footer class="site-footer">
Сервис: HTTP Basic Auth (realm «kaskad»). Установка одной ссылкой — см. README репозитория kaskad_web_vpn.
</footer>
</div>
<script>
(function () {
function buildText() {
var host = (document.getElementById("ep-host").value || "").trim();
var port = (document.getElementById("ep-port").value || "").trim();
var name = (document.getElementById("memo-name").value || "").trim() || "клиент";
var epLine = host ? (host + (port ? ":" + port : "")) : "(укажите Endpoint)";
return (
"Памятка по каскаду\n" +
"— Имя: " + name + "\n" +
"— Endpoint для клиента: " + epLine + "\n" +
"— На промежуточном узле: проброс UDP на VPS и порт WG/AWG.\n" +
"— На основном сервере: создать peer (панель Amnezia Admin → «Новый клиент под каскад») и выдать .conf.\n"
);
}
document.getElementById("btn-build").addEventListener("click", function () {
document.getElementById("out-memo").textContent = buildText();
});
document.getElementById("btn-copy").addEventListener("click", async function () {
var t = document.getElementById("out-memo").textContent || buildText();
try {
await navigator.clipboard.writeText(t);
} catch (e) {
alert("Не удалось скопировать — выделите текст вручную.");
}
});
})();
</script>
</body>
</html>

16
uninstall.sh Executable file
View File

@@ -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