mirror of
https://github.com/andrey271192/Domain_web.git
synced 2026-09-20 14:41:58 +00:00
feat(deploy): full one-line install with search API
Bundle static site, FastAPI search, nginx proxy, and systemd unit so a fresh Ubuntu VPS can run curl install.sh and get /search working. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
91
README.md
91
README.md
@@ -1,43 +1,59 @@
|
|||||||
# Domain Web (PCA Lab)
|
# PCA Lab — Domain Web
|
||||||
|
|
||||||
Лёгкий статический сайт: кабинет студента (VPN Amnezia), смена региона магазинов, кураторские списки заблокированных в РФ доменов.
|
Сайт PCA Lab: кабинет студента (VPN Amnezia), группы доменов, **поиск доменов** (CT, DNS, reverse IP, geosite/geoip, .bat для Keenetic).
|
||||||
|
|
||||||
**Без поиска доменов** на сайте (crt.sh и API сканера отключены). Списки — статические пресеты.
|
## Установка одной командой
|
||||||
|
|
||||||
## Возможности
|
На свежем Ubuntu VPS (root):
|
||||||
|
|
||||||
- **Главная** — обложка PCA Lab (исследования AI и кибербезопасности)
|
|
||||||
- **Кабинет студента** — пошаговое подключение WireGuard / Amnezia VPN
|
|
||||||
- **VPN по региону** — инструкции смены региона App Store / Google Play / Microsoft Store
|
|
||||||
- **Группы доменов** — карточки по категориям:
|
|
||||||
- Медиа (Netflix, YouTube, …)
|
|
||||||
- Социальные сети (Instagram, Facebook, X, …)
|
|
||||||
- Искусственный интеллект (ChatGPT, Claude, …)
|
|
||||||
- Мессенджеры (Telegram, WhatsApp, …)
|
|
||||||
- Другое / VPN / Dev (GitHub, Steam, …)
|
|
||||||
- Копирование списка доменов / IP, скачивание **Keenetic .bat** (статические маршруты)
|
|
||||||
|
|
||||||
Связанный Telegram-бот (поиск доменов): [domain-finder-bot](https://github.com/andrey271192/domain-finder-bot) — на сайте только статика.
|
|
||||||
|
|
||||||
## Установка на VPS (одна строка)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Переменные окружения (опционально):
|
Скрипт автоматически:
|
||||||
|
|
||||||
|
- ставит **nginx** и отдаёт сайт на **порту 80** (HTTP);
|
||||||
|
- разворачивает **поиск доменов** (`/search`) и API (`/api/search`);
|
||||||
|
- клонирует [domain-finder-bot](https://github.com/andrey271192/domain-finder-bot) для логики discovery;
|
||||||
|
- **не трогает** Amnezia/Docker на **443**, существующий Telegram-бот и другие сервисы.
|
||||||
|
|
||||||
|
### Переменные окружения (опционально)
|
||||||
|
|
||||||
| Переменная | По умолчанию |
|
| Переменная | По умолчанию |
|
||||||
|------------|----------------|
|
|------------|----------------|
|
||||||
| `DOMAIN_WEB_ROOT` | `/var/www/domain-web` |
|
| `DOMAIN_WEB_ROOT` | `/var/www/domain-web` |
|
||||||
| `DOMAIN_WEB_INSTALL_SRC` | `/opt/domain-web-src` |
|
| `DOMAIN_WEB_INSTALL_SRC` | `/opt/domain-web-src` |
|
||||||
|
| `DOMAIN_WEB_API_DIR` | `/opt/domain-web-api` |
|
||||||
|
| `DOMAIN_FINDER_BOT_DIR` | `/opt/domain-finder-bot` |
|
||||||
| `DOMAIN_WEB_BRANCH` | `main` |
|
| `DOMAIN_WEB_BRANCH` | `main` |
|
||||||
|
|
||||||
Скрипт:
|
## Страницы после установки
|
||||||
|
|
||||||
- ставит **nginx** и отдаёт каталог `web/` на **порту 80**;
|
| URL | Описание |
|
||||||
- **не останавливает** Amnezia (443), Docker и `domain-finder-bot`;
|
|-----|----------|
|
||||||
- отключает legacy-сервис `domain-scanner` (Node), если был установлен ранее.
|
| `/` | Главная PCA Lab |
|
||||||
|
| `/connect.html` | Кабинет студента — VPN Amnezia |
|
||||||
|
| `/connect.html?mode=region` | VPN по региону магазинов |
|
||||||
|
| `/domains.html` | Группы доменов + выбор + .bat |
|
||||||
|
| `/search` | Поиск доменов (как в Telegram-боте) |
|
||||||
|
|
||||||
|
## Прокси Telegram (редактируется без перезапуска)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano /var/www/domain-web/data/site-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_ip": "YOUR_SERVER_IP",
|
||||||
|
"telegram_proxy": "https://t.me/proxy?server=...&port=443&secret=...",
|
||||||
|
"telegram_proxy_label": "Прокси Telegram"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Сохраните файл и обновите страницу кабинета — ссылка появится сразу.
|
||||||
|
|
||||||
## Удаление
|
## Удаление
|
||||||
|
|
||||||
@@ -45,22 +61,29 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/instal
|
|||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/uninstall.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/uninstall.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
## Локальный просмотр
|
## Структура репозитория
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web && python3 -m http.server 8080
|
|
||||||
# http://127.0.0.1:8080/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура
|
|
||||||
|
|
||||||
```
|
```
|
||||||
web/ — HTML + React (CDN) + JSX
|
web/ — HTML + React (CDN) + JSX
|
||||||
nginx/ — шаблон vhost
|
api/ — FastAPI search API
|
||||||
install.sh — деплой на сервер
|
nginx/ — vhost + proxy snippet
|
||||||
|
systemd/ — domain-web-api.service
|
||||||
|
install.sh — полный деплой
|
||||||
uninstall.sh
|
uninstall.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Локальный просмотр (только статика)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && python3 -m http.server 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Поиск локально требует API (`/api/search` на сервере).
|
||||||
|
|
||||||
|
## Связанные проекты
|
||||||
|
|
||||||
|
- [domain-finder-bot](https://github.com/andrey271192/domain-finder-bot) — Telegram-бот с той же логикой поиска
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
См. [LICENSE](LICENSE).
|
См. [LICENSE](LICENSE).
|
||||||
|
|||||||
139
api/main.py
Normal file
139
api/main.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Domain Web search API — reuses domain-finder-bot discovery."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
BOT_DIR = os.environ.get("DOMAIN_FINDER_BOT_DIR", "/opt/domain-finder-bot")
|
||||||
|
if BOT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, BOT_DIR)
|
||||||
|
|
||||||
|
from discovery import ( # noqa: E402
|
||||||
|
MAX_BATCH_DOMAINS,
|
||||||
|
discover_batch_with_timeout,
|
||||||
|
discover_with_timeout,
|
||||||
|
format_batch_domains_column,
|
||||||
|
format_batch_full_report,
|
||||||
|
format_batch_ips_column,
|
||||||
|
format_batch_v2fly_column,
|
||||||
|
format_domains_column,
|
||||||
|
format_full_report,
|
||||||
|
format_ips_column,
|
||||||
|
format_v2fly_column,
|
||||||
|
parse_domains_from_message,
|
||||||
|
)
|
||||||
|
from router_bat import ( # noqa: E402
|
||||||
|
format_router_bat,
|
||||||
|
format_router_bat_batch,
|
||||||
|
sanitize_bat_filename,
|
||||||
|
sanitize_batch_bat_filename,
|
||||||
|
)
|
||||||
|
from v2fly_lookup import start_background_sync # noqa: E402
|
||||||
|
|
||||||
|
MAX_CONCURRENT = max(1, int(os.environ.get("MAX_CONCURRENT_SEARCHES", "4")))
|
||||||
|
_SEARCH_SEM = asyncio.Semaphore(MAX_CONCURRENT)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
domain: str | None = None
|
||||||
|
domains: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
domains: list[str]
|
||||||
|
is_batch: bool
|
||||||
|
report: str
|
||||||
|
domains_column: str
|
||||||
|
ips_column: str
|
||||||
|
geosite_geoip: str
|
||||||
|
bat_content: str
|
||||||
|
bat_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_domains(body: SearchRequest) -> list[str]:
|
||||||
|
if body.domains:
|
||||||
|
raw = "\n".join(body.domains)
|
||||||
|
elif body.domain:
|
||||||
|
raw = body.domain
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Provide domain or domains")
|
||||||
|
|
||||||
|
domains = parse_domains_from_message(raw)
|
||||||
|
if not domains:
|
||||||
|
raise HTTPException(status_code=400, detail="No valid domains")
|
||||||
|
if len(domains) > MAX_BATCH_DOMAINS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Max {MAX_BATCH_DOMAINS} domains per request",
|
||||||
|
)
|
||||||
|
return domains
|
||||||
|
|
||||||
|
|
||||||
|
def _build_response(domains: list[str], results: list[Any]) -> SearchResponse:
|
||||||
|
is_batch = len(domains) > 1
|
||||||
|
if is_batch:
|
||||||
|
report = format_batch_full_report(results)
|
||||||
|
domains_column = format_batch_domains_column(results)
|
||||||
|
ips_column = format_batch_ips_column(results)
|
||||||
|
geosite_geoip = format_batch_v2fly_column(results)
|
||||||
|
bat_content = format_router_bat_batch(results)
|
||||||
|
bat_filename = sanitize_batch_bat_filename(domains[0], len(domains))
|
||||||
|
else:
|
||||||
|
result = results[0]
|
||||||
|
domain = domains[0]
|
||||||
|
report = format_full_report(result)
|
||||||
|
domains_column = format_domains_column(result)
|
||||||
|
ips_column = format_ips_column(result)
|
||||||
|
geosite_geoip = format_v2fly_column(result)
|
||||||
|
bat_content = format_router_bat(domain, result)
|
||||||
|
bat_filename = sanitize_bat_filename(domain)
|
||||||
|
|
||||||
|
return SearchResponse(
|
||||||
|
domains=domains,
|
||||||
|
is_batch=is_batch,
|
||||||
|
report=report,
|
||||||
|
domains_column=domains_column,
|
||||||
|
ips_column=ips_column,
|
||||||
|
geosite_geoip=geosite_geoip,
|
||||||
|
bat_content=bat_content,
|
||||||
|
bat_filename=bat_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
start_background_sync()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Domain Web Search API", lifespan=lifespan)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["POST", "GET", "OPTIONS"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/search", response_model=SearchResponse)
|
||||||
|
async def search(body: SearchRequest) -> SearchResponse:
|
||||||
|
domains = _resolve_domains(body)
|
||||||
|
async with _SEARCH_SEM:
|
||||||
|
if len(domains) == 1:
|
||||||
|
results = [await discover_with_timeout(domains[0])]
|
||||||
|
else:
|
||||||
|
results = await discover_batch_with_timeout(domains)
|
||||||
|
return _build_response(domains, results)
|
||||||
4
api/requirements.txt
Normal file
4
api/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.115.0,<1.0
|
||||||
|
uvicorn[standard]>=0.32.0,<1.0
|
||||||
|
aiohttp>=3.9.0,<4.0
|
||||||
|
dnspython>=2.6.0,<3.0
|
||||||
133
install.sh
133
install.sh
@@ -1,13 +1,17 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Domain Web — lightweight static site (nginx :80)
|
# Domain Web — full deploy: static site + search API (nginx :80)
|
||||||
# curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
|
# curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_URL="${DOMAIN_WEB_REPO_URL:-https://github.com/andrey271192/Domain_web.git}"
|
REPO_URL="${DOMAIN_WEB_REPO_URL:-https://github.com/andrey271192/Domain_web.git}"
|
||||||
|
BOT_REPO_URL="${DOMAIN_FINDER_REPO:-https://github.com/andrey271192/domain-finder-bot.git}"
|
||||||
BRANCH="${DOMAIN_WEB_BRANCH:-main}"
|
BRANCH="${DOMAIN_WEB_BRANCH:-main}"
|
||||||
INSTALL_SRC="${DOMAIN_WEB_INSTALL_SRC:-/opt/domain-web-src}"
|
INSTALL_SRC="${DOMAIN_WEB_INSTALL_SRC:-/opt/domain-web-src}"
|
||||||
WEB_ROOT="${DOMAIN_WEB_ROOT:-/var/www/domain-web}"
|
WEB_ROOT="${DOMAIN_WEB_ROOT:-/var/www/domain-web}"
|
||||||
|
API_DIR="${DOMAIN_WEB_API_DIR:-/opt/domain-web-api}"
|
||||||
|
BOT_DIR="${DOMAIN_FINDER_BOT_DIR:-/opt/domain-finder-bot}"
|
||||||
NGINX_SITE="domain-web"
|
NGINX_SITE="domain-web"
|
||||||
|
API_SERVICE="domain-web-api"
|
||||||
LEGACY_SERVICES=("domain-scanner" "geoexport-site")
|
LEGACY_SERVICES=("domain-scanner" "geoexport-site")
|
||||||
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
@@ -22,9 +26,19 @@ need_root() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_packages() {
|
install_packages() {
|
||||||
log "Installing nginx, git, curl..."
|
log "Installing system packages..."
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq nginx git curl ca-certificates rsync >/dev/null
|
apt-get install -y -qq \
|
||||||
|
nginx git curl ca-certificates rsync \
|
||||||
|
python3 python3-venv python3-pip >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_domainbot_user() {
|
||||||
|
if ! id domainbot >/dev/null 2>&1; then
|
||||||
|
log "Creating system user domainbot..."
|
||||||
|
useradd --system --home "${BOT_DIR}" --shell /usr/sbin/nologin domainbot
|
||||||
|
fi
|
||||||
|
mkdir -p "${BOT_DIR}/data" "${API_DIR}"
|
||||||
}
|
}
|
||||||
|
|
||||||
stop_legacy_scanner() {
|
stop_legacy_scanner() {
|
||||||
@@ -51,29 +65,78 @@ clone_repo() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clone_discovery_bot() {
|
||||||
|
log "Fetching discovery modules (${BOT_REPO_URL})..."
|
||||||
|
if [[ -d "${BOT_DIR}/.git" ]]; then
|
||||||
|
git -C "${BOT_DIR}" fetch --depth 1 origin main 2>/dev/null || true
|
||||||
|
git -C "${BOT_DIR}" reset --hard origin/main 2>/dev/null || \
|
||||||
|
git -C "${BOT_DIR}" pull --ff-only 2>/dev/null || true
|
||||||
|
else
|
||||||
|
rm -rf "${BOT_DIR}"
|
||||||
|
git clone --depth 1 "${BOT_REPO_URL}" "${BOT_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "${BOT_DIR}/.venv/bin/python" ]]; then
|
||||||
|
log "Creating domain-finder-bot venv (discovery deps)..."
|
||||||
|
python3 -m venv "${BOT_DIR}/.venv"
|
||||||
|
"${BOT_DIR}/.venv/bin/pip" install -q -U pip
|
||||||
|
"${BOT_DIR}/.venv/bin/pip" install -q -r "${BOT_DIR}/requirements.txt"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
deploy_web() {
|
deploy_web() {
|
||||||
log "Deploying static files to ${WEB_ROOT}..."
|
log "Deploying static site to ${WEB_ROOT}..."
|
||||||
mkdir -p "${WEB_ROOT}"
|
mkdir -p "${WEB_ROOT}/data"
|
||||||
rsync -a --delete "${INSTALL_SRC}/web/" "${WEB_ROOT}/"
|
rsync -a --delete "${INSTALL_SRC}/web/" "${WEB_ROOT}/"
|
||||||
chown -R www-data:www-data "${WEB_ROOT}" 2>/dev/null || chown -R nginx:nginx "${WEB_ROOT}" 2>/dev/null || true
|
|
||||||
|
if [[ ! -f "${WEB_ROOT}/data/site-config.json" ]]; then
|
||||||
|
cp "${INSTALL_SRC}/web/data/site-config.json.example" "${WEB_ROOT}/data/site-config.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown -R www-data:www-data "${WEB_ROOT}" 2>/dev/null || \
|
||||||
|
chown -R nginx:nginx "${WEB_ROOT}" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_api() {
|
||||||
|
log "Deploying search API to ${API_DIR}..."
|
||||||
|
mkdir -p "${API_DIR}"
|
||||||
|
rsync -a --delete "${INSTALL_SRC}/api/" "${API_DIR}/"
|
||||||
|
|
||||||
|
if [[ ! -x "${API_DIR}/.venv/bin/python" ]]; then
|
||||||
|
python3 -m venv "${API_DIR}/.venv"
|
||||||
|
fi
|
||||||
|
"${API_DIR}/.venv/bin/pip" install -q -U pip
|
||||||
|
"${API_DIR}/.venv/bin/pip" install -q -r "${API_DIR}/requirements.txt"
|
||||||
|
|
||||||
|
chown -R domainbot:domainbot "${API_DIR}" "${BOT_DIR}"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_systemd() {
|
||||||
|
log "Installing ${API_SERVICE}.service..."
|
||||||
|
cp "${INSTALL_SRC}/systemd/domain-web-api.service" "/etc/systemd/system/${API_SERVICE}.service"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "${API_SERVICE}.service"
|
||||||
|
systemctl restart "${API_SERVICE}.service"
|
||||||
}
|
}
|
||||||
|
|
||||||
configure_nginx() {
|
configure_nginx() {
|
||||||
log "Configuring nginx (HTTP :80)..."
|
log "Configuring nginx (HTTP :80 only — port 443 untouched)..."
|
||||||
local conf_src="${INSTALL_SRC}/nginx/domain-web.conf"
|
local conf_src="${INSTALL_SRC}/nginx/domain-web.conf"
|
||||||
if [[ ! -f "${conf_src}" ]]; then
|
local snippet_src="${INSTALL_SRC}/nginx/domain-web-api.conf"
|
||||||
|
|
||||||
|
if [[ ! -f "${conf_src}" ]] || [[ ! -f "${snippet_src}" ]]; then
|
||||||
echo "Missing nginx config in repo" >&2
|
echo "Missing nginx config in repo" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Backup existing default if it listens on 80 and is not ours
|
|
||||||
if [[ -f /etc/nginx/sites-enabled/default ]] && ! grep -q "domain-web" /etc/nginx/sites-enabled/default 2>/dev/null; then
|
if [[ -f /etc/nginx/sites-enabled/default ]] && ! grep -q "domain-web" /etc/nginx/sites-enabled/default 2>/dev/null; then
|
||||||
if grep -q "listen 80" /etc/nginx/sites-enabled/default 2>/dev/null; then
|
if grep -q "listen 80" /etc/nginx/sites-enabled/default 2>/dev/null; then
|
||||||
mv /etc/nginx/sites-enabled/default /etc/nginx/sites-enabled/default.bak.$(date +%s) 2>/dev/null || true
|
mv /etc/nginx/sites-enabled/default "/etc/nginx/sites-enabled/default.bak.$(date +%s)" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sed "s|/var/www/domain-web|${WEB_ROOT}|g" "${conf_src}" > "/etc/nginx/sites-available/${NGINX_SITE}"
|
cp "${snippet_src}" /etc/nginx/snippets/domain-web-api.conf
|
||||||
|
sed "s|WEB_ROOT|${WEB_ROOT}|g" "${conf_src}" > "/etc/nginx/sites-available/${NGINX_SITE}"
|
||||||
ln -sf "/etc/nginx/sites-available/${NGINX_SITE}" "/etc/nginx/sites-enabled/${NGINX_SITE}"
|
ln -sf "/etc/nginx/sites-available/${NGINX_SITE}" "/etc/nginx/sites-enabled/${NGINX_SITE}"
|
||||||
|
|
||||||
nginx -t
|
nginx -t
|
||||||
@@ -81,26 +144,66 @@ configure_nginx() {
|
|||||||
systemctl reload nginx
|
systemctl reload nginx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allow_firewall() {
|
||||||
|
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||||
|
log "Allowing HTTP (80) in ufw..."
|
||||||
|
ufw allow 80/tcp >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
verify() {
|
||||||
|
log "Verifying deployment..."
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if ! systemctl is-active --quiet "${API_SERVICE}.service"; then
|
||||||
|
journalctl -u "${API_SERVICE}.service" -n 30 --no-pager || true
|
||||||
|
echo "ERROR: ${API_SERVICE} failed to start" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! curl -fsS "http://127.0.0.1:8081/api/health" | grep -q '"status"'; then
|
||||||
|
echo "ERROR: API health check failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for path in / /search /connect.html /domains.html; do
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1${path}")"
|
||||||
|
if [[ "${code}" != "200" && "${code}" != "301" && "${code}" != "302" ]]; then
|
||||||
|
echo "WARNING: http://127.0.0.1${path} returned ${code}" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
print_done() {
|
print_done() {
|
||||||
local ip
|
local ip
|
||||||
ip="$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'SERVER_IP')"
|
ip="$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'SERVER_IP')"
|
||||||
log "Done."
|
log "Done."
|
||||||
echo ""
|
echo ""
|
||||||
echo " Site root: ${WEB_ROOT}"
|
echo " Site: http://${ip}/"
|
||||||
echo " URL: http://${ip}/"
|
echo " Search: http://${ip}/search"
|
||||||
echo " Pages: /connect.html /domains.html"
|
echo " Connect: http://${ip}/connect.html"
|
||||||
|
echo " Domains: http://${ip}/domains.html"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Amnezia (443) and Docker containers were not stopped."
|
echo " Telegram proxy config: ${WEB_ROOT}/data/site-config.json"
|
||||||
|
echo " API service: systemctl status ${API_SERVICE}"
|
||||||
|
echo ""
|
||||||
|
echo " Port 443 / Docker / Amnezia were not modified."
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
need_root
|
need_root
|
||||||
install_packages
|
install_packages
|
||||||
|
ensure_domainbot_user
|
||||||
stop_legacy_scanner
|
stop_legacy_scanner
|
||||||
clone_repo
|
clone_repo
|
||||||
|
clone_discovery_bot
|
||||||
deploy_web
|
deploy_web
|
||||||
|
deploy_api
|
||||||
|
install_systemd
|
||||||
configure_nginx
|
configure_nginx
|
||||||
|
allow_firewall
|
||||||
|
verify
|
||||||
print_done
|
print_done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
nginx/domain-web-api.conf
Normal file
11
nginx/domain-web-api.conf
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Domain Web API reverse proxy (included from domain-web.conf)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
}
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
# PCA Lab / Domain Web — static site on port 80
|
# PCA Lab / Domain Web — HTTP :80 (does not touch :443 / Amnezia)
|
||||||
# Installed to /etc/nginx/sites-available/domain-web
|
# Placeholders WEB_ROOT are replaced by install.sh
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
listen [::]:80 default_server;
|
listen [::]:80 default_server;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /var/www/domain-web;
|
root WEB_ROOT;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Clean URL for domain search
|
||||||
|
location = /search {
|
||||||
|
try_files /search.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /search.html {
|
||||||
|
return 301 /search;
|
||||||
|
}
|
||||||
|
|
||||||
|
include /etc/nginx/snippets/domain-web-api.conf;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
@@ -18,7 +29,6 @@ server {
|
|||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Do not proxy — Amnezia and other services stay on 443 separately
|
|
||||||
access_log /var/log/nginx/domain-web-access.log;
|
access_log /var/log/nginx/domain-web-access.log;
|
||||||
error_log /var/log/nginx/domain-web-error.log;
|
error_log /var/log/nginx/domain-web-error.log;
|
||||||
}
|
}
|
||||||
|
|||||||
21
systemd/domain-web-api.service
Normal file
21
systemd/domain-web-api.service
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Domain Web Search API
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=domainbot
|
||||||
|
Group=domainbot
|
||||||
|
WorkingDirectory=/opt/domain-web-api
|
||||||
|
Environment=PYTHONPATH=/opt/domain-finder-bot
|
||||||
|
Environment=DOMAIN_FINDER_BOT_DIR=/opt/domain-finder-bot
|
||||||
|
EnvironmentFile=-/opt/domain-finder-bot/.env
|
||||||
|
ExecStart=/opt/domain-web-api/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8081 --workers 1
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
17
uninstall.sh
17
uninstall.sh
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Remove Domain Web nginx site (does not remove Amnezia/Docker)
|
# Remove Domain Web site + search API (does not remove Amnezia/Docker/bot)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
WEB_ROOT="${DOMAIN_WEB_ROOT:-/var/www/domain-web}"
|
WEB_ROOT="${DOMAIN_WEB_ROOT:-/var/www/domain-web}"
|
||||||
INSTALL_SRC="${DOMAIN_WEB_INSTALL_SRC:-/opt/domain-web-src}"
|
INSTALL_SRC="${DOMAIN_WEB_INSTALL_SRC:-/opt/domain-web-src}"
|
||||||
|
API_DIR="${DOMAIN_WEB_API_DIR:-/opt/domain-web-api}"
|
||||||
NGINX_SITE="domain-web"
|
NGINX_SITE="domain-web"
|
||||||
|
API_SERVICE="domain-web-api"
|
||||||
|
|
||||||
log() { echo "[domain-web-uninstall] $*"; }
|
log() { echo "[domain-web-uninstall] $*"; }
|
||||||
|
|
||||||
@@ -13,9 +15,16 @@ if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
log "Stopping search API..."
|
||||||
|
systemctl stop "${API_SERVICE}.service" 2>/dev/null || true
|
||||||
|
systemctl disable "${API_SERVICE}.service" 2>/dev/null || true
|
||||||
|
rm -f "/etc/systemd/system/${API_SERVICE}.service"
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
log "Removing nginx site..."
|
log "Removing nginx site..."
|
||||||
rm -f "/etc/nginx/sites-enabled/${NGINX_SITE}"
|
rm -f "/etc/nginx/sites-enabled/${NGINX_SITE}"
|
||||||
rm -f "/etc/nginx/sites-available/${NGINX_SITE}"
|
rm -f "/etc/nginx/sites-available/${NGINX_SITE}"
|
||||||
|
rm -f /etc/nginx/snippets/domain-web-api.conf
|
||||||
|
|
||||||
if [[ ! -f /etc/nginx/sites-enabled/default ]]; then
|
if [[ ! -f /etc/nginx/sites-enabled/default ]]; then
|
||||||
if [[ -f /etc/nginx/sites-available/default ]]; then
|
if [[ -f /etc/nginx/sites-available/default ]]; then
|
||||||
@@ -25,7 +34,7 @@ fi
|
|||||||
|
|
||||||
nginx -t && systemctl reload nginx
|
nginx -t && systemctl reload nginx
|
||||||
|
|
||||||
log "Removing web files (optional paths)..."
|
log "Removing deployed files..."
|
||||||
rm -rf "${WEB_ROOT}" "${INSTALL_SRC}"
|
rm -rf "${WEB_ROOT}" "${INSTALL_SRC}" "${API_DIR}"
|
||||||
|
|
||||||
log "Uninstall complete."
|
log "Uninstall complete. domain-finder-bot at /opt/domain-finder-bot was kept."
|
||||||
|
|||||||
@@ -27,14 +27,41 @@ function Hero(){
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SERVER_IP = "SERVER_IP";
|
||||||
|
|
||||||
// ───────── Server info card ─────────
|
// ───────── Server info card ─────────
|
||||||
function ServerCard(){
|
function ServerCard(){
|
||||||
const [copied,setCopied] = useStateC(false);
|
const [copied,setCopied] = useStateC(false);
|
||||||
function copy(){
|
const [copiedProxy,setCopiedProxy] = useStateC(false);
|
||||||
navigator.clipboard?.writeText("SERVER_IP");
|
const [config,setConfig] = useStateC({
|
||||||
|
server_ip: DEFAULT_SERVER_IP,
|
||||||
|
telegram_proxy: "",
|
||||||
|
telegram_proxy_label: "Прокси Telegram",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffectC(() => {
|
||||||
|
fetch("/data/site-config.json?t=" + Date.now())
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data) setConfig(prev => ({ ...prev, ...data })); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const serverIp = (config.server_ip || DEFAULT_SERVER_IP).trim();
|
||||||
|
const proxyLabel = config.telegram_proxy_label || "Прокси Telegram";
|
||||||
|
const proxyUrl = (config.telegram_proxy || "").trim();
|
||||||
|
|
||||||
|
function copyIp(){
|
||||||
|
navigator.clipboard?.writeText(serverIp);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(()=>setCopied(false), 1600);
|
setTimeout(()=>setCopied(false), 1600);
|
||||||
}
|
}
|
||||||
|
function copyProxy(){
|
||||||
|
if (!proxyUrl) return;
|
||||||
|
navigator.clipboard?.writeText(proxyUrl);
|
||||||
|
setCopiedProxy(true);
|
||||||
|
setTimeout(()=>setCopiedProxy(false), 1600);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={24} style={{maxWidth:880, margin:"0 auto 18px"}}>
|
<Card padding={24} style={{maxWidth:880, margin:"0 auto 18px"}}>
|
||||||
<div style={{fontSize:11, fontWeight:700, letterSpacing:".14em", color:"var(--accent-2)",
|
<div style={{fontSize:11, fontWeight:700, letterSpacing:".14em", color:"var(--accent-2)",
|
||||||
@@ -45,11 +72,46 @@ function ServerCard(){
|
|||||||
background:"var(--bg-tint)", border:"1px solid var(--line)", borderRadius:14,
|
background:"var(--bg-tint)", border:"1px solid var(--line)", borderRadius:14,
|
||||||
fontFamily:"var(--mono)", fontSize:18, fontWeight:600,
|
fontFamily:"var(--mono)", fontSize:18, fontWeight:600,
|
||||||
letterSpacing:"-0.005em", color:"var(--ink)"}}>
|
letterSpacing:"-0.005em", color:"var(--ink)"}}>
|
||||||
SERVER_IP
|
{serverIp}
|
||||||
</div>
|
</div>
|
||||||
<Button variant={copied?"success":"primary"} size="lg" onClick={copy}
|
<Button variant={copied?"success":"primary"} size="lg" onClick={copyIp}
|
||||||
icon={copied?"check":"clipboard"}>{copied?"Скопировано":"Копировать"}</Button>
|
icon={copied?"check":"clipboard"}>{copied?"Скопировано":"Копировать"}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{marginTop:18, paddingTop:18, borderTop:"1px solid var(--line-2)"}}>
|
||||||
|
<div style={{fontSize:13, fontWeight:500, color:"var(--ink-3)"}}>{proxyLabel}</div>
|
||||||
|
{proxyUrl ? (
|
||||||
|
<div style={{marginTop:8, display:"flex", gap:10}}>
|
||||||
|
<div style={{flex:1, minHeight:54, display:"flex", alignItems:"center", padding:"12px 18px",
|
||||||
|
background:"var(--bg-tint)", border:"1px solid var(--line)", borderRadius:14,
|
||||||
|
fontFamily:"var(--mono)", fontSize:14, fontWeight:500,
|
||||||
|
letterSpacing:"-0.005em", color:"var(--ink)", wordBreak:"break-all"}}>
|
||||||
|
<a href={proxyUrl} target="_blank" rel="noreferrer"
|
||||||
|
style={{color:"var(--accent-2)", fontWeight:600, textDecoration:"underline",
|
||||||
|
textUnderlineOffset:3}}>
|
||||||
|
{proxyUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Button variant={copiedProxy?"success":"primary"} size="lg" onClick={copyProxy}
|
||||||
|
icon={copiedProxy?"check":"clipboard"}>{copiedProxy?"Скопировано":"Копировать"}</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{marginTop:8, padding:"14px 18px", background:"var(--bg-tint)",
|
||||||
|
border:"1px dashed var(--line)", borderRadius:14}}>
|
||||||
|
<div style={{fontSize:15, fontWeight:600, color:"var(--ink-2)"}}>Не настроено</div>
|
||||||
|
<p style={{margin:"8px 0 0", fontSize:12.5, lineHeight:1.5, color:"var(--ink-3)"}}>
|
||||||
|
Администратор может добавить ссылку в{" "}
|
||||||
|
<code style={{fontSize:12, background:"#fff", padding:"2px 6px", borderRadius:6,
|
||||||
|
border:"1px solid var(--line-2)"}}>/var/www/domain-web/data/site-config.json</code>
|
||||||
|
{" "}→ поле <code style={{fontSize:12, background:"#fff", padding:"2px 6px",
|
||||||
|
borderRadius:6, border:"1px solid var(--line-2)"}}>telegram_proxy</code>.
|
||||||
|
Подробнее — в <code style={{fontSize:12, background:"#fff", padding:"2px 6px",
|
||||||
|
borderRadius:6, border:"1px solid var(--line-2)"}}>data/README.txt</code> на сервере.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{marginTop:18, paddingTop:18, borderTop:"1px solid var(--line-2)",
|
<div style={{marginTop:18, paddingTop:18, borderTop:"1px solid var(--line-2)",
|
||||||
display:"flex", alignItems:"center", gap:14, flexWrap:"wrap"}}>
|
display:"flex", alignItems:"center", gap:14, flexWrap:"wrap"}}>
|
||||||
<span style={{fontSize:13.5, color:"var(--ink-2)", fontWeight:500}}>Сервисы на машине:</span>
|
<span style={{fontSize:13.5, color:"var(--ink-2)", fontWeight:500}}>Сервисы на машине:</span>
|
||||||
|
|||||||
5
web/data/README.txt
Normal file
5
web/data/README.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Как изменить прокси Telegram:
|
||||||
|
1. ssh root@SERVER_IP
|
||||||
|
2. nano /var/www/domain-web/data/site-config.json
|
||||||
|
3. Измените "telegram_proxy": "ваша ссылка"
|
||||||
|
4. Ctrl+O, Enter, Ctrl+X — готово, обновите страницу
|
||||||
5
web/data/site-config.json.example
Normal file
5
web/data/site-config.json.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"server_ip": "SERVER_IP",
|
||||||
|
"telegram_proxy": "https://t.me/proxy?server=YOUR_SERVER&port=443&secret=YOUR_SECRET",
|
||||||
|
"telegram_proxy_label": "Прокси Telegram"
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
// Curated blocked domains — static lists (no search)
|
// Curated blocked domains — static lists (no search)
|
||||||
|
|
||||||
const { useState, useEffect, useMemo } = React;
|
|
||||||
|
|
||||||
function cidrToMask(bits) {
|
function cidrToMask(bits) {
|
||||||
const n = parseInt(bits || "32", 10);
|
const n = parseInt(bits || "32", 10);
|
||||||
if (n === 32) return "255.255.255.255";
|
if (n === 32) return "255.255.255.255";
|
||||||
@@ -27,6 +25,29 @@ function buildKeeneticBat(group, allGroups) {
|
|||||||
return lines.join("\r\n") + "\r\n";
|
return lines.join("\r\n") + "\r\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildBatForSelectedDomains(selectedSet, allGroups) {
|
||||||
|
const selected = [...selectedSet];
|
||||||
|
const lines = ["@echo off", "rem Keenetic — выбранные домены (" + selected.length + ")", ""];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
allGroups.forEach((g) => {
|
||||||
|
const picked = g.domains.filter((d) => selectedSet.has(d));
|
||||||
|
if (!picked.length) return;
|
||||||
|
(g.ips || []).forEach((cidr) => {
|
||||||
|
const [ip, bits] = cidr.split("/");
|
||||||
|
if (!ip || seen.has(cidr)) return;
|
||||||
|
seen.add(cidr);
|
||||||
|
lines.push(`route ADD ${ip} MASK ${cidrToMask(bits)} 0.0.0.0 & rem ${g.title}: ${picked.join(", ")}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lines.length <= 3) {
|
||||||
|
lines.push("rem Нет IP/CIDR для выбранных доменов — настройте VPN вручную");
|
||||||
|
selected.forEach((d) => lines.push(`rem ${d}`));
|
||||||
|
}
|
||||||
|
return lines.join("\r\n") + "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
function downloadText(filename, text) {
|
function downloadText(filename, text) {
|
||||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -52,18 +73,19 @@ async function copyText(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
function DomainGroupCard({ group, selectedDomains, onToggleDomain, onToggleGroup, onDownloadBat }) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = React.useState(true);
|
||||||
const [copied, setCopied] = useState(null);
|
const [copied, setCopied] = React.useState(null);
|
||||||
const domainText = group.domains.join("\n");
|
const domainText = group.domains.join("\n");
|
||||||
const ipText = (group.ips || []).join("\n");
|
const ipText = (group.ips || []).join("\n");
|
||||||
|
const selectedInGroup = group.domains.filter((d) => selectedDomains.has(d));
|
||||||
|
const allSelected = group.domains.length > 0 && selectedInGroup.length === group.domains.length;
|
||||||
|
|
||||||
async function handleCopy(kind) {
|
async function handleCopy(kind) {
|
||||||
const text = kind === "domains" ? domainText : ipText;
|
const text = kind === "domains" ? domainText : ipText;
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
await copyText(text);
|
await copyText(text);
|
||||||
setCopied(kind);
|
setCopied(kind);
|
||||||
onCopyDomains?.(kind);
|
|
||||||
setTimeout(() => setCopied(null), 1800);
|
setTimeout(() => setCopied(null), 1800);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +104,9 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
{selectedInGroup.length > 0 && (
|
||||||
|
<Badge tone="blue" size="sm">{selectedInGroup.length} выбрано</Badge>
|
||||||
|
)}
|
||||||
<Badge tone="neutral" size="sm">{group.domains.length} доменов</Badge>
|
<Badge tone="neutral" size="sm">{group.domains.length} доменов</Badge>
|
||||||
<Icon name={open ? "chevron-down" : "chevron-right"} size={18} color="var(--ink-3)" />
|
<Icon name={open ? "chevron-down" : "chevron-right"} size={18} color="var(--ink-3)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -96,16 +121,28 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div style={{ marginBottom: 10, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onToggleGroup(group, !allSelected)}>
|
||||||
|
{allSelected ? "Снять выделение группы" : "Выбрать все в группе"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||||
gap: 8, marginBottom: 16,
|
gap: 8, marginBottom: 16,
|
||||||
}}>
|
}}>
|
||||||
{group.domains.map((d) => (
|
{group.domains.map((d) => {
|
||||||
<div key={d} style={{
|
const selected = selectedDomains.has(d);
|
||||||
padding: "10px 12px", borderRadius: 10, background: "var(--bg-tint)",
|
return (
|
||||||
border: "1px solid var(--line-2)", fontFamily: "var(--mono)", fontSize: 12.5,
|
<button key={d} type="button" onClick={() => onToggleDomain(d)} style={{
|
||||||
}}>{d}</div>
|
padding: "10px 12px", borderRadius: 10, textAlign: "left", cursor: "pointer",
|
||||||
))}
|
fontFamily: "var(--mono)", fontSize: 12.5,
|
||||||
|
background: selected ? "var(--brand-soft)" : "var(--bg-tint)",
|
||||||
|
border: selected ? "2px solid var(--brand)" : "1px solid var(--line-2)",
|
||||||
|
color: selected ? "var(--brand)" : "var(--ink)",
|
||||||
|
fontWeight: selected ? 600 : 400,
|
||||||
|
}}>{d}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{(group.ips || []).length > 0 && (
|
{(group.ips || []).length > 0 && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
@@ -133,7 +170,7 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
|||||||
)}
|
)}
|
||||||
<Button variant="primary" size="sm" icon="download"
|
<Button variant="primary" size="sm" icon="download"
|
||||||
onClick={() => onDownloadBat(group)}>
|
onClick={() => onDownloadBat(group)}>
|
||||||
Keenetic .bat
|
Keenetic .bat (вся группа)
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,18 +180,20 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DomainsApp() {
|
function DomainsApp() {
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = React.useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = React.useState("");
|
||||||
|
const [selectedDomains, setSelectedDomains] = React.useState(new Set());
|
||||||
|
const [copiedSelected, setCopiedSelected] = React.useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetch("data/blocked-domains.json")
|
fetch("data/blocked-domains.json")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => { setGroups(data.groups || []); setLoading(false); })
|
.then((data) => { setGroups(data.groups || []); setLoading(false); })
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = React.useMemo(() => {
|
||||||
const q = filter.trim().toLowerCase();
|
const q = filter.trim().toLowerCase();
|
||||||
if (!q) return groups;
|
if (!q) return groups;
|
||||||
return groups.map((g) => ({
|
return groups.map((g) => ({
|
||||||
@@ -165,6 +204,34 @@ function DomainsApp() {
|
|||||||
|
|
||||||
const allDomains = groups.flatMap((g) => g.domains).join("\n");
|
const allDomains = groups.flatMap((g) => g.domains).join("\n");
|
||||||
|
|
||||||
|
const toggleDomain = (d) => {
|
||||||
|
setSelectedDomains((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(d)) next.delete(d); else next.add(d);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGroup = (group, select) => {
|
||||||
|
setSelectedDomains((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
group.domains.forEach((d) => {
|
||||||
|
if (select) next.add(d); else next.delete(d);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copySelectedDomains = async () => {
|
||||||
|
await copyText([...selectedDomains].join("\n"));
|
||||||
|
setCopiedSelected(true);
|
||||||
|
setTimeout(() => setCopiedSelected(false), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadSelectedBat = () => {
|
||||||
|
downloadText("keenetic-selected.bat", buildBatForSelectedDomains(selectedDomains, groups));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SiteNav brandSub="ЗАБЛОКИРОВАННЫЕ СЕРВИСЫ" />
|
<SiteNav brandSub="ЗАБЛОКИРОВАННЫЕ СЕРВИСЫ" />
|
||||||
@@ -174,10 +241,10 @@ function DomainsApp() {
|
|||||||
Популярные заблокированные домены
|
Популярные заблокированные домены
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ margin: "0 0 24px", fontSize: 16, lineHeight: 1.55, color: "var(--ink-2)", maxWidth: 640 }}>
|
<p style={{ margin: "0 0 24px", fontSize: 16, lineHeight: 1.55, color: "var(--ink-2)", maxWidth: 640 }}>
|
||||||
|
Кликните по доменам для выбора, затем скачайте .bat только для выбранных.
|
||||||
Кураторские списки для настройки VPN и маршрутизации на роутере Keenetic.
|
Кураторские списки для настройки VPN и маршрутизации на роутере Keenetic.
|
||||||
Поиск по crt.sh на сайте отключён — только готовые пресеты по категориям.
|
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 28 }}>
|
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 16 }}>
|
||||||
<input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Фильтр по домену…"
|
<input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Фильтр по домену…"
|
||||||
style={{
|
style={{
|
||||||
flex: "1 1 220px", height: 42, padding: "0 16px", borderRadius: 12,
|
flex: "1 1 220px", height: 42, padding: "0 16px", borderRadius: 12,
|
||||||
@@ -191,6 +258,25 @@ function DomainsApp() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedDomains.size > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 24, padding: "14px 16px",
|
||||||
|
background: "var(--brand-soft)", borderRadius: 14, border: "1px solid rgba(15,93,255,.15)",
|
||||||
|
alignItems: "center",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>Выбрано: {selectedDomains.size}</span>
|
||||||
|
<Button variant="primary" size="sm" icon="download" onClick={downloadSelectedBat}>
|
||||||
|
Скачать .bat для выбранных
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" icon="clipboard" onClick={copySelectedDomains}>
|
||||||
|
{copiedSelected ? "Скопировано" : "Копировать выбранные домены"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setSelectedDomains(new Set())}>
|
||||||
|
Снять выделение
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && <p style={{ color: "var(--ink-3)" }}>Загрузка списков…</p>}
|
{loading && <p style={{ color: "var(--ink-3)" }}>Загрузка списков…</p>}
|
||||||
{!loading && filtered.length === 0 && (
|
{!loading && filtered.length === 0 && (
|
||||||
<Card padding={24}><p style={{ margin: 0, color: "var(--ink-3)" }}>Ничего не найдено по фильтру.</p></Card>
|
<Card padding={24}><p style={{ margin: 0, color: "var(--ink-3)" }}>Ничего не найдено по фильтру.</p></Card>
|
||||||
@@ -198,6 +284,9 @@ function DomainsApp() {
|
|||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
{filtered.map((g) => (
|
{filtered.map((g) => (
|
||||||
<DomainGroupCard key={g.id} group={g}
|
<DomainGroupCard key={g.id} group={g}
|
||||||
|
selectedDomains={selectedDomains}
|
||||||
|
onToggleDomain={toggleDomain}
|
||||||
|
onToggleGroup={toggleGroup}
|
||||||
onDownloadBat={(grp) => downloadText(`keenetic-${grp.id}.bat`, buildKeeneticBat(grp, groups))} />
|
onDownloadBat={(grp) => downloadText(`keenetic-${grp.id}.bat`, buildKeeneticBat(grp, groups))} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -205,6 +294,8 @@ function DomainsApp() {
|
|||||||
<footer style={{ maxWidth: 960, margin: "48px auto", padding: "0 24px 40px", textAlign: "center", fontSize: 13, color: "var(--ink-3)" }}>
|
<footer style={{ maxWidth: 960, margin: "48px auto", padding: "0 24px 40px", textAlign: "center", fontSize: 13, color: "var(--ink-3)" }}>
|
||||||
<a href="connect.html" style={{ color: "var(--brand)", fontWeight: 600 }}>Кабинет студента</a>
|
<a href="connect.html" style={{ color: "var(--brand)", fontWeight: 600 }}>Кабинет студента</a>
|
||||||
{" · "}
|
{" · "}
|
||||||
|
<a href="/search">Поиск доменов</a>
|
||||||
|
{" · "}
|
||||||
<a href="index.html">На главную</a>
|
<a href="index.html">На главную</a>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// Shared site navigation
|
// Shared site navigation
|
||||||
|
|
||||||
const SITE_NAV = [
|
const SITE_NAV = [
|
||||||
{ href: "index.html", label: "Главная", match: (p) => /index\.html$/.test(p) || p === "/" || /\/$/.test(p) && !/connect|domains/.test(p) },
|
{ href: "index.html", label: "Главная", match: (p) => /index\.html$/.test(p) || (p === "/" || /\/$/.test(p)) && !/connect|domains|search/.test(p) },
|
||||||
{ href: "connect.html", label: "Кабинет студента", match: (p) => /connect\.html/.test(p) && !/mode=region/.test(p) },
|
{ href: "connect.html", label: "Кабинет студента", match: (p) => /connect\.html/.test(p) && !/mode=region/.test(p) },
|
||||||
{ href: "connect.html?mode=region", label: "VPN по региону", match: (p) => /connect\.html/.test(p) && /mode=region/.test(p) },
|
{ href: "connect.html?mode=region", label: "VPN по региону", match: (p) => /connect\.html/.test(p) && /mode=region/.test(p) },
|
||||||
|
{ href: "/search", label: "Поиск доменов", match: (p) => /\/search(\.html)?\/?$/.test(p) },
|
||||||
{ href: "domains.html", label: "Группы доменов", match: (p) => /domains\.html/.test(p) },
|
{ href: "domains.html", label: "Группы доменов", match: (p) => /domains\.html/.test(p) },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
262
web/search-app.jsx
Normal file
262
web/search-app.jsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
function cidrToMask(bits) {
|
||||||
|
const n = parseInt(bits || "32", 10);
|
||||||
|
if (n === 32) return "255.255.255.255";
|
||||||
|
return Array.from({ length: 4 }, (_, i) => (65280 >> Math.min(8, Math.max(0, n - i * 8))) & 255).join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDomainsColumn(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
return text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("Все домены"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpsColumn(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const ips = [];
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith("Все IP") || line.startsWith("IPv6")) break;
|
||||||
|
const cidr = line.replace(/\s*\(диапазон\)\s*$/, "");
|
||||||
|
if (/^\d+\.\d+\.\d+\.\d+(\/\d+)?$/.test(cidr)) ips.push(cidr);
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBatFromIps(ips) {
|
||||||
|
const lines = ["@echo off", "rem Keenetic — выбранные IP из поиска", ""];
|
||||||
|
const seen = new Set();
|
||||||
|
ips.forEach((entry) => {
|
||||||
|
if (entry.includes("/")) {
|
||||||
|
const [ip, bits] = entry.split("/");
|
||||||
|
if (!ip || seen.has(entry)) return;
|
||||||
|
seen.add(entry);
|
||||||
|
lines.push(`route ADD ${ip} MASK ${cidrToMask(bits)} 0.0.0.0`);
|
||||||
|
} else if (/^\d+\.\d+\.\d+\.\d+$/.test(entry) && !seen.has(entry)) {
|
||||||
|
seen.add(entry);
|
||||||
|
lines.push(`route ADD ${entry} MASK 255.255.0.0 0.0.0.0`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (lines.length <= 3) lines.push("rem Нет IP для выбранных записей");
|
||||||
|
return lines.join("\r\n") + "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterBatContent(batContent, selectedIps) {
|
||||||
|
if (!batContent || !selectedIps.size) return "";
|
||||||
|
const selected = new Set([...selectedIps].map((ip) => ip.split("/")[0]));
|
||||||
|
return batContent.split("\n").filter((line) => {
|
||||||
|
const m = line.match(/route add (\S+)/i);
|
||||||
|
return m && selected.has(m[1]);
|
||||||
|
}).join("\n") + (selected.size ? "\n" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyBlock({ title, text, filename }) {
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text || "");
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
const download = () => {
|
||||||
|
const blob = new Blob([text || ""], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename || "export.txt";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
if (!text) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 16, background: "var(--bg-elev)", border: "1px solid var(--line)", borderRadius: 16, padding: 16, boxShadow: "var(--shadow-card)" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10, gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button onClick={copy} style={btnStyle}>{copied ? "Скопировано" : "Копировать"}</button>
|
||||||
|
<button onClick={download} style={btnStyle}>Скачать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word", fontFamily: "var(--mono)", fontSize: 12, maxHeight: 320, overflow: "auto", background: "#f9fafb", padding: 12, borderRadius: 10 }}>{text}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectableChips({ items, selected, onToggle, label }) {
|
||||||
|
if (!items.length) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 16, background: "var(--bg-elev)", border: "1px solid var(--line)", borderRadius: 16, padding: 16, boxShadow: "var(--shadow-card)" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10, gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<strong>{label} — клик для выбора ({selected.size}/{items.length})</strong>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button type="button" onClick={() => items.forEach((i) => !selected.has(i) && onToggle(i))} style={btnStyle}>Выбрать все</button>
|
||||||
|
<button type="button" onClick={() => items.forEach((i) => selected.has(i) && onToggle(i))} style={btnStyle}>Снять все</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const on = selected.has(item);
|
||||||
|
return (
|
||||||
|
<button key={item} type="button" onClick={() => onToggle(item)} style={{
|
||||||
|
padding: "8px 12px", borderRadius: 10, cursor: "pointer", fontFamily: "var(--mono)", fontSize: 12,
|
||||||
|
border: on ? "2px solid var(--brand)" : "1px solid var(--line)",
|
||||||
|
background: on ? "var(--brand-soft)" : "#fff",
|
||||||
|
color: on ? "var(--brand)" : "var(--ink)",
|
||||||
|
fontWeight: on ? 600 : 400,
|
||||||
|
}}>{item}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnStyle = {
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "8px 12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
function SearchApp() {
|
||||||
|
const [query, setQuery] = React.useState("");
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState("");
|
||||||
|
const [result, setResult] = React.useState(null);
|
||||||
|
const [selectedDomains, setSelectedDomains] = React.useState(new Set());
|
||||||
|
const [selectedIps, setSelectedIps] = React.useState(new Set());
|
||||||
|
|
||||||
|
const resultDomains = React.useMemo(() => parseDomainsColumn(result?.domains_column), [result]);
|
||||||
|
const resultIps = React.useMemo(() => parseIpsColumn(result?.ips_column), [result]);
|
||||||
|
|
||||||
|
const toggleDomain = (d) => {
|
||||||
|
setSelectedDomains((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(d)) next.delete(d); else next.add(d);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleIp = (ip) => {
|
||||||
|
setSelectedIps((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(ip)) next.delete(ip); else next.add(ip);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSearch = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setResult(null);
|
||||||
|
setSelectedDomains(new Set());
|
||||||
|
setSelectedIps(new Set());
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const isMulti = /[\n,]/.test(trimmed) || trimmed.split(/\s+/).length > 1;
|
||||||
|
const body = isMulti ? { domains: trimmed.split(/[\s,\n]+/).filter(Boolean) } : { domain: trimmed };
|
||||||
|
const res = await fetch("/api/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
|
||||||
|
setResult(data);
|
||||||
|
setSelectedDomains(new Set(parseDomainsColumn(data.domains_column)));
|
||||||
|
setSelectedIps(new Set(parseIpsColumn(data.ips_column)));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Ошибка поиска");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBat = (content, filename) => {
|
||||||
|
if (!content) return;
|
||||||
|
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename || "routes.bat";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadSelectedBat = () => {
|
||||||
|
let bat = filterBatContent(result?.bat_content, selectedIps);
|
||||||
|
if (!bat.trim()) bat = buildBatFromIps([...selectedIps]);
|
||||||
|
downloadBat(bat, (result?.domains?.[0] || "selected") + "-selected.bat");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copySelectedDomains = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText([...selectedDomains].join("\n"));
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 960, margin: "0 auto", padding: "24px 20px 64px" }}>
|
||||||
|
<SiteNav brand="PCA Lab" brandSub="DOMAIN SEARCH · SERVER" />
|
||||||
|
<h1 style={{ fontSize: 32, margin: "24px 0 8px" }}>Поиск доменов</h1>
|
||||||
|
<p style={{ color: "var(--ink-3)", marginTop: 0 }}>
|
||||||
|
CT, DNS, reverse IP, geosite/geoip — как в Telegram-боте. Один домен или пакет через пробел/запятую/новую строку.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={runSearch} style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 20 }}>
|
||||||
|
<textarea
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="example.com или chatgpt.com openai.com"
|
||||||
|
rows={2}
|
||||||
|
style={{ flex: "1 1 280px", padding: 12, borderRadius: 12, border: "1px solid var(--line)", fontFamily: "var(--mono)", fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading} style={{ ...btnStyle, background: "var(--brand)", color: "#fff", border: "none", padding: "12px 20px", fontWeight: 600 }}>
|
||||||
|
{loading ? "Ищем…" : "Найти"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && <p style={{ color: "#b42318", marginTop: 12 }}>{error}</p>}
|
||||||
|
{loading && <p style={{ color: "var(--ink-3)", marginTop: 16 }}>Поиск может занять до 90 секунд…</p>}
|
||||||
|
{result && (
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<strong>{result.is_batch ? `Пакет: ${result.domains.length} доменов` : result.domains[0]}</strong>
|
||||||
|
{result.bat_content ? (
|
||||||
|
<button type="button" onClick={() => downloadBat(result.bat_content, result.bat_filename)} style={{ ...btnStyle, background: "var(--brand-soft)", borderColor: "transparent" }}>
|
||||||
|
Скачать {result.bat_filename}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SelectableChips items={resultDomains} selected={selectedDomains} onToggle={toggleDomain} label="Домены" />
|
||||||
|
<SelectableChips items={resultIps} selected={selectedIps} onToggle={toggleIp} label="IP / CIDR" />
|
||||||
|
|
||||||
|
{(selectedDomains.size > 0 || selectedIps.size > 0) && (
|
||||||
|
<div style={{ marginTop: 16, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{selectedDomains.size > 0 && (
|
||||||
|
<button type="button" onClick={copySelectedDomains} style={{ ...btnStyle, background: "var(--brand-soft)", borderColor: "transparent" }}>
|
||||||
|
Копировать выбранные домены ({selectedDomains.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedIps.size > 0 && (
|
||||||
|
<button type="button" onClick={downloadSelectedBat} style={{ ...btnStyle, background: "var(--brand)", color: "#fff", border: "none" }}>
|
||||||
|
Скачать .bat для выбранных IP ({selectedIps.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CopyBlock title="Полный отчёт" text={result.report} filename={(result.domains[0] || "report") + "-report.txt"} />
|
||||||
|
<CopyBlock title="Домены (столбик)" text={result.domains_column} filename={(result.domains[0] || "domains") + "-domains.txt"} />
|
||||||
|
<CopyBlock title="IP (столбик)" text={result.ips_column} filename={(result.domains[0] || "ips") + "-ips.txt"} />
|
||||||
|
<CopyBlock title="Geosite / GeoIP" text={result.geosite_geoip} filename={(result.domains[0] || "v2fly") + "-geosite.txt"} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(<SearchApp />);
|
||||||
34
web/search.html
Normal file
34
web/search.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Поиск доменов — PCA Lab</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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#f4f5f7; --bg-elev:#fff; --ink:#1d1d1f; --ink-3:#6e6e73;
|
||||||
|
--line:rgba(0,0,0,.07); --brand:#0f5dff; --brand-soft:#e6f0ff;
|
||||||
|
--radius-card:22px; --shadow-card:0 8px 24px rgba(16,24,40,.05);
|
||||||
|
--font:"Inter",-apple-system,BlinkMacSystemFont,sans-serif;
|
||||||
|
--mono:"JetBrains Mono",ui-monospace,monospace;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:var(--font)}
|
||||||
|
body{background:radial-gradient(900px 500px at 100% 0%,#e7f0ff 0%,transparent 55%),var(--bg);min-height:100vh}
|
||||||
|
#root{min-height:100vh}
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
button{font-family:inherit}
|
||||||
|
</style>
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js" crossorigin></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel" data-presets="react" src="nav.jsx"></script>
|
||||||
|
<script type="text/babel" data-presets="react" src="search-app.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user