21 Commits

Author SHA1 Message Date
nineap bb77a76200 Fix: nginx-конфиг для cms.netrunner-vpn.com + задвоенный /cms-api/api/api/ на проде
Build & Push Image / Build and push to Gitea Registry (push) Successful in 1m46s
- cms.netrunner-vpn.com не имел своего vhost на новом проде после переезда
  CMS с французского сервера — падал 502 (уходил в чужой server_name).
  Добавлены cms-http.conf/cms-ssl.conf (Basic Auth + сертификат), deploy-nginx.yml
  теперь синкает и их.
- landing-ssl.conf: /cms-api/ проксировал в /api/ одним location, из-за чего
  запросы PocketBase SDK (уже содержащие /api/ после /cms-api/) задваивались
  в /api/api/... и 404-ились. Добавлен точный /cms-api/api/ location + общий
  fallback с rewrite — как было руками пропатчено на старом сервере, но не
  попало в репозиторий.
2026-07-11 02:26:39 +07:00
nineap 4b1ef9f951 Цены тарифов — с бэкенда (регион/валюта), не из PocketBase
Build & Push Image / Build and push to Gitea Registry (push) Successful in 2m28s
PocketBase pricing_{lang} остаётся источником переводного маркетингового
контента (название, тэглайн, описание, фичи, порядок) — но сами цифры
price1m/6m/1y теперь подменяются данными с GET /billing/plans нового
бэкенда (см. netrunner-backend::modules::geoip): валюта определяется по
IP посетителя, VPN/датацентр всегда получает базовый USD.

Соответствие тарифа PocketBase к тарифу бэкенда — по полю `name`
(ожидается "GHOST"/"NETRUNNER"/"MAINFRAME"). Бэкенд недоступен или для
конкретного тарифа не нашлось соответствия — тихий фолбэк на цену как
она есть в PocketBase, страница не ломается (тот же принцип, что уже
был у offline-кеша).
2026-07-11 00:55:15 +07:00
nineap d9b2e24b8e Переименовать GITEA_REGISTRY_TOKEN → GT_REGISTRY_TOKEN (правила именования Gitea не пускают префикс GITEA_)
Build & Push Image / Build and push to Gitea Registry (push) Successful in 19m50s
2026-07-10 21:28:25 +07:00
nineap df699a1171 Переезд с ghcr.io на встроенный Container Registry Gitea
Build & Push Image / Build and push to Gitea Registry (push) Failing after 15s
Свой netrunner-landing/netrunner-landing-proxy теперь на
gitea.netrunner-vpn.com. Сторонний образ PocketBase (ghcr.io/muchobien/
pocketbase) не трогаю — это чужой образ, живёт там же, где и жил.
2026-07-10 20:44:14 +07:00
nineap 7076bbcc78 nginx+TLS перед лендингом на прод VPS вместо голых портов наружу
Порты 3000/3001 закрыты для интернета (ufw allow только с IP VPS данных —
Prometheus). Публичный вход только netrunner-vpn.com через nginx: "/" на
сам лендинг, "/cms-api/" на PocketBase (со сменой префикса на "/api"),
"/api/" на proxy-ws (comments/speedtest, уже с этим префиксом внутри).
2026-07-10 19:07:10 +07:00
nineap bc500c8716 CI сам собирает .env на dev VPS (BOT_USERNAME) из repo Variables
Тот же принцип, что и в netrunner-backend/netrunner-data — deploy-dev
пишет .env из vars.DEV_BOT_USERNAME перед docker compose up, руками на
сервере создавать больше не нужно. Прод по-прежнему без .env — все
значения там хардкожены в docker-compose.yml.
2026-07-10 18:18:57 +07:00
nineap 383077e1e6 Метрики /metrics для Next.js и ws-proxy (центральный Prometheus)
proxy-ws (Rust/axum) — тот же паттерн, что api.rs в netrunner-backend:
PrometheusBuilder + middleware, считающий http_requests_total/
http_request_duration_seconds по методу/пути/статусу.

Next.js — app/api/metrics/route.ts на prom-client (явный
runtime="nodejs", т.к. Edge Runtime не тянет perf_hooks/process.*),
отдаёт default-метрики процесса (CPU/память/event loop lag).

Оба порта (3000/3001) перестают быть 127.0.0.1-only — их должен
скрейпить центральный Prometheus на VPS с данными, обязательно
зафайрволить там на его IP (см. комментарии в docker-compose.yml).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 23:21:43 +07:00
nineap d99026d13b CI: VPS_IP/DEV_VPS_IP переносит из secrets в vars
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:37:06 +07:00
nineap 0fa87e2d9a Фолбэк на кэш при недоступности PocketBase (Pricing/FAQ)
Pricing: при полном отказе всех запросов пробует localStorage-кэш
последнего успешного ответа вместо пустого состояния, пишет туда же при
успехе. Faq: отличает "в кэше правда пусто" от "не смогли загрузить" —
раньше оба случая показывали один и тот же текст "0 protocols found",
что вводило в заблуждение при обычном сетевом сбое.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:37:06 +07:00
nineap b8bf9f7337 Добавляет .env.example + чинит .gitignore (widlcard .env* его же и ронял)
.gitignore имел общий паттерн ".env*" (стандартный для Next.js) без
исключения для .env.example — сам шаблон никогда не мог быть закоммичен.
Добавлено исключение !.env.example.

docker-compose.dev-remote.yml требует ${BOT_USERNAME:?...} — жёсткая
зависимость от .env на dev VPS, нигде не задокументированная до этого.
Прод (docker-compose.yml) секретов не требует — там всё захардкожено
(публичные URL/домены, ни одного пароля/токена).
2026-07-09 15:35:10 +07:00
nineap 61755f4922 Дублирует пайплайны для Gitea Actions (.gitea/workflows/)
Build & Push Image / Build and push to GHCR (push) Failing after 1m26s
Копии .github/workflows/{build,deploy}.yml. Логин на ghcr.io — через
secrets.GHCR_TOKEN (настоящий GitHub PAT), не secrets.GITHUB_TOKEN —
автотокен Gitea валиден только для самого Gitea. Триггер workflow_run в
deploy.yml поддержан не во всех версиях Gitea Actions — см. комментарий
в файле.

Нужно завести в Gitea секрет GHCR_TOKEN (write:packages) + все остальные
секреты, что уже есть в GitHub Actions (отдельное хранилище).
2026-07-09 14:28:25 +07:00
nineap f14d20d727 pnpm: копировать файлы вместо hardlink-стора (.npmrc)
pnpm по умолчанию хардлинкает пакеты из общего стора (next сам хардлинкает
dist/api/*.js на dist/esm/api/*.js). При переносе слоя node_modules через
registry и разворачивании containerd/overlayfs на dev-хосте пара
хардлинков может разойтись по разным слоям — overlayfs не находит второй
конец линка и падает с "no such file or directory". package-import-method=copy
убирает хардлинки в node_modules целиком.
2026-07-08 23:31:51 +07:00
nineap 31368fdc17 Dockerfile: чинит cache-busting шаг под alpine (busybox grep без -P)
Builder-стейдж — node:20-alpine, там busybox grep, не понимающий -P/\K
(перловый lookbehind). Локально с GNU grep всё работало, в CI на alpine
падало с exit 2 ещё до sed. Извлекает ENGINE_VERSION чистым POSIX sed
вместо grep -oP.
2026-07-08 23:21:04 +07:00
nineap 32b4460b66 Локализует лендинг на 10 языков + RTL для арабского и персидского
Добавляет zh-CN, fa, ar, tr, uk, be, vi, uz словари (170 ключей, идентичная
вложенная структура) к существующим en/ru. lib/locales.ts — единый источник
списка локалей/дефолтной локали/RTL-набора вместо захардкоженного ["en","ru"]
в proxy.ts. language-switcher.tsx: переключение языка резало только
двухбуквенные коды регэкспом (/^\/[a-z]{2}/), из-за чего zh-CN никогда не
матчился и путь задваивался — заменено на honest-поиск по списку locales.

app/[lang]/layout.tsx — dir="rtl"/"ltr" на <html> по локали (SSR, в отличие
от реактивного клиентского переключения в netrunner-app). Header.tsx/
Footer.tsx — разбитое на три <span> имя бренда визуально переворачивалось
Flexbox'ом под dir=rtl ("VPNrunnerNet"), исправлено через dir="ltr" на
самом враппере.

BlogPreview/Faq/Pricing и обе blog-страницы: fallback на *_en-коллекцию
PocketBase, если для языка ещё нет переведённого контента (FAQ-ответы,
детали тарифов, посты блога не тянутся автоматически — это отдельный от
статических словарей CMS-контент), плюс i18n-ключи period1m/period6m/
period1y вместо захардкоженных ru/en-подписей периодов в Pricing.tsx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:53:37 +07:00
nineap 0d1cd042bb Переносит FAQ в конец главной страницы
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:53:17 +07:00
nineap 142e1c6fd4 cursor and matrix update 2026-07-07 18:49:09 +07:00
nineap f02124eb1d Наполнить /docs реальным контентом вместо визуальной заглушки
Раздел документации на сайте (documents_ru/documents_en в PocketBase)
содержал только два обрубленных стаб-документа. Добавлен полный набор
из 12 документов на русском и английском (Core/Security/Network/Legal),
технически сверенных с реальной реализацией протокола (NRXP, ChaCha20-
Poly1305, X25519, TLS-маскировка под JA3/JA4) и с моделью сессий
(HttpOnly-cookie, ротация refresh-токенов) — не маркетинговые обещания,
а описание того, что фактически работает в коде.

Юридические документы (Privacy/Terms/Refund) содержат явные плейсхолдеры
для реквизитов оператора сервиса — сервис ещё не зарегистрирован ни в
одной юрисдикции, поэтому не полагаться на них как на итоговый текст
без замены плейсхолдеров.

scripts/seed-docs.mjs — идемпотентный скрипт наполнения PocketBase
(upsert по title, с alias-картой под старые названия стабов); ничего не
записывает без --apply и без PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD в
окружении — админ-доступ к CMS нигде не хардкожен.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 20:25:10 +07:00
nineap 6b5dbe16d1 matrix cache fix 2026-07-05 20:01:53 +07:00
nineap 5f9d607d07 Обновить dev ACCOUNT_ORIGIN/API_URL под Caddy на 443 вместо 8443
netrunner-backend dev теперь проксирует Caddy на стандартный порт 443
(VPN-нода, ранее занимавшая его, перенесена на другой порт) — Telegram
Login Widget в браузере требует именно этот порт.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 20:00:15 +07:00
nineap 0dd19ceda2 glass update 2026-07-05 03:42:57 +07:00
nineap e568ac76b8 matrix update 2026-07-04 22:40:12 +07:00
72 changed files with 4635 additions and 166 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "netrunner-landing-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+11
View File
@@ -0,0 +1,11 @@
# УСТАРЕЛО КАК ИНСТРУКЦИЯ: .env на dev VPS теперь собирает сам deploy.yml
# (job deploy-dev, vars.DEV_BOT_USERNAME) — руками копировать не нужно.
#
# Только для dev-remote (docker-compose.dev-remote.yml). Прод
# (docker-compose.yml) секретов не требует — все URL/домены захардкожены
# прямо в нём (там нет ни одного пароля/токена, только публичные адреса).
#
# ОБЯЗАТЕЛЬНО: отдельный dev-бот, заведённый через @BotFather специально под
# домен dev.netrunner-vpn.com — НЕ прод-бот (у него уже прописан прод-домен
# через /setdomain, молчаливый фолбэк на него ловится как "Bot domain invalid").
BOT_USERNAME=CHANGE_ME_DEV_BOT_USERNAME
+57
View File
@@ -0,0 +1,57 @@
# Копия .github/workflows/build.yml для Gitea Actions. Реестр — встроенный
# Container Registry самой Gitea (gitea.netrunner-vpn.com), не ghcr.io —
# secrets.GT_REGISTRY_TOKEN (токен Gitea с правом write:package), не
# GHCR_TOKEN/GITHUB_TOKEN. Тот же токен, что и в netrunner-backend/
# netrunner-proxy — пакеты в Gitea принадлежат аккаунту, не репозиторию,
# но значение секрета нужно продублировать в Settings каждого репозитория.
name: Build & Push Image
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
name: Build and push to Gitea Registry
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
lfs: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: gitea.netrunner-vpn.com
username: ${{ github.actor }}
password: ${{ secrets.GT_REGISTRY_TOKEN }}
# Сборка Лендинга (Next.js)
- name: Build and Push Landing
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: gitea.netrunner-vpn.com/nineap/netrunner-landing:latest
cache-from: type=registry,ref=gitea.netrunner-vpn.com/nineap/netrunner-landing:cache
cache-to: type=registry,ref=gitea.netrunner-vpn.com/nineap/netrunner-landing:cache,mode=max
# Сборка Прокси (Rust)
- name: Build and Push Proxy
uses: docker/build-push-action@v5
with:
context: ./proxy-ws
file: ./proxy-ws/Dockerfile
push: true
tags: gitea.netrunner-vpn.com/nineap/netrunner-landing-proxy:latest
cache-from: type=registry,ref=gitea.netrunner-vpn.com/nineap/netrunner-landing-proxy:cache
cache-to: type=registry,ref=gitea.netrunner-vpn.com/nineap/netrunner-landing-proxy:cache,mode=max
+87
View File
@@ -0,0 +1,87 @@
name: Deploy Nginx
# Единственный публичный вход (443/80) на прод VPS (backend+лендинг вместе,
# см. deploy-nginx.yml у netrunner-backend — тот же сервер, свой домен).
# Только ручной запуск.
#
# Порядок первого запуска:
# 1. Запустить эту джобу (поднимет http-конфиг).
# 2. Один раз руками на сервере:
# sudo apt-get install -y certbot
# sudo certbot certonly --webroot -w /var/www/certbot \
# -d netrunner-vpn.com
# sudo certbot certonly --webroot -w /var/www/certbot \
# -d cms.netrunner-vpn.com
# sudo htpasswd -c /etc/nginx/.htpasswd <username> # спросит пароль
# 3. Запустить эту джобу ещё раз — включит HTTPS.
#
# cms.netrunner-vpn.com — прямой доступ к PocketBase (см. nginx/cms-ssl.conf),
# отдельно от /cms-api/ на основном домене, которым в рантайме пользуется сам
# лендинг.
on:
workflow_dispatch:
jobs:
deploy:
name: Deploy nginx config for netrunner-vpn.com
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Sync nginx configs to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "nginx/landing-http.conf,nginx/landing-ssl.conf,nginx/cms-http.conf,nginx/cms-ssl.conf"
target: "/root/netrunner-frontend"
strip_components: 0
- name: Install/enable nginx via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
if ! command -v nginx >/dev/null 2>&1; then
apt-get update
apt-get install -y nginx
fi
mkdir -p /var/www/certbot
rm -f /etc/nginx/sites-enabled/default
cp /root/netrunner-frontend/nginx/landing-http.conf /etc/nginx/sites-available/landing-http.conf
cp /root/netrunner-frontend/nginx/landing-ssl.conf /etc/nginx/sites-available/landing-ssl.conf
cp /root/netrunner-frontend/nginx/cms-http.conf /etc/nginx/sites-available/cms-http.conf
cp /root/netrunner-frontend/nginx/cms-ssl.conf /etc/nginx/sites-available/cms-ssl.conf
ln -sf /etc/nginx/sites-available/landing-http.conf /etc/nginx/sites-enabled/landing-http.conf
ln -sf /etc/nginx/sites-available/cms-http.conf /etc/nginx/sites-enabled/cms-http.conf
CERT=/etc/letsencrypt/live/netrunner-vpn.com/fullchain.pem
if [ -f "$CERT" ]; then
ln -sf /etc/nginx/sites-available/landing-ssl.conf /etc/nginx/sites-enabled/landing-ssl.conf
echo "🔒 Сертификат (netrunner-vpn.com) найден — HTTPS-конфиг включён."
else
rm -f /etc/nginx/sites-enabled/landing-ssl.conf
echo "⚠️ Сертификата ещё нет ($CERT) — HTTPS-конфиг НЕ включён."
fi
CMS_CERT=/etc/letsencrypt/live/cms.netrunner-vpn.com/fullchain.pem
if [ -f "$CMS_CERT" ]; then
ln -sf /etc/nginx/sites-available/cms-ssl.conf /etc/nginx/sites-enabled/cms-ssl.conf
echo "🔒 Сертификат (cms.netrunner-vpn.com) найден — HTTPS-конфиг включён."
else
rm -f /etc/nginx/sites-enabled/cms-ssl.conf
echo "⚠️ Сертификата для cms.netrunner-vpn.com ещё нет ($CMS_CERT) — HTTPS-конфиг НЕ включён."
fi
nginx -t
systemctl enable --now nginx
systemctl reload nginx
echo "✅ nginx (netrunner-vpn.com + cms.netrunner-vpn.com) synchronized."
+111
View File
@@ -0,0 +1,111 @@
# Копия .github/workflows/deploy.yml для Gitea Actions.
# - Образы тянутся из встроенного Container Registry самой Gitea
# (gitea.netrunner-vpn.com), не ghcr.io — secrets.GT_REGISTRY_TOKEN.
# - Триггер workflow_run (авто-деплой после успешной сборки) поддерживается
# не во всех версиях Gitea Actions — если deploy-dev не срабатывает
# автоматически после build, проверить версию Gitea или временно дергать
# деплой вручную через workflow_dispatch.
name: Deploy
# Один пайплайн, две джобы (по аналогии с netrunner-backend):
# - deploy-dev: авто, срабатывает после успешного "Build & Push Image" на main.
# - deploy-prod: вручную из вкладки Actions ("Run workflow").
on:
workflow_run:
workflows: ["Build & Push Image"]
types: [completed]
branches: [main]
workflow_dispatch:
jobs:
deploy-dev:
name: Auto-deploy to Dev VPS
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
# Копируем только compose-файл — сам образ дев-сервер тянет из Gitea Registry.
- name: Sync compose file to Dev VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.DEV_VPS_IP }}
username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
source: "docker-compose.dev-remote.yml"
target: "/root/netrunner-frontend-dev"
strip_components: 0
- name: Write .env and deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.DEV_VPS_IP }}
username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
envs: "BOT_USERNAME"
script: |
set -e
cd /root/netrunner-frontend-dev
# Единственное, что этому compose-файлу нужно из .env — свой dev-бот
# (см. BOT_USERNAME:? в docker-compose.dev-remote.yml). Тот же бот,
# что и у netrunner-backend dev — vars.DEV_BOT_USERNAME там же.
cat > .env <<EOF
BOT_USERNAME=${BOT_USERNAME}
EOF
chmod 600 .env
echo "🚀 Deploying landing+proxy:latest to DEV"
echo "${{ secrets.GT_REGISTRY_TOKEN }}" | docker login gitea.netrunner-vpn.com -u ${{ github.actor }} --password-stdin
docker compose -f docker-compose.dev-remote.yml pull
docker compose -f docker-compose.dev-remote.yml up -d --remove-orphans
docker image prune -f
echo "✅ Dev synchronized."
env:
BOT_USERNAME: ${{ vars.DEV_BOT_USERNAME }}
deploy-prod:
name: Pull & restart on Prod VPS
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Copy docker-compose.yml to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "docker-compose.yml"
target: "/root/netrunner-frontend"
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
cd /root/netrunner-frontend
echo ${{ secrets.GT_REGISTRY_TOKEN }} | docker login gitea.netrunner-vpn.com -u ${{ github.actor }} --password-stdin
echo "🚀 Deploying landing+proxy:latest to PROD"
# Тянем оба образа
docker compose pull
# Поднимаем всё
docker compose up -d --force-recreate --remove-orphans
# Чистим старье
docker image prune -f
echo "✅ Uplink synchronized."
+68
View File
@@ -0,0 +1,68 @@
name: Deploy Nginx
# Единственный публичный вход (443/80) на прод VPS (backend+лендинг вместе,
# см. deploy-nginx.yml у netrunner-backend — тот же сервер, свой домен).
# Только ручной запуск.
#
# Порядок первого запуска:
# 1. Запустить эту джобу (поднимет http-конфиг).
# 2. Один раз руками на сервере:
# sudo apt-get install -y certbot
# sudo certbot certonly --webroot -w /var/www/certbot \
# -d netrunner-vpn.com
# 3. Запустить эту джобу ещё раз — включит HTTPS.
on:
workflow_dispatch:
jobs:
deploy:
name: Deploy nginx config for netrunner-vpn.com
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Sync nginx configs to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "nginx/landing-http.conf,nginx/landing-ssl.conf"
target: "/root/netrunner-frontend"
strip_components: 0
- name: Install/enable nginx via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
if ! command -v nginx >/dev/null 2>&1; then
apt-get update
apt-get install -y nginx
fi
mkdir -p /var/www/certbot
rm -f /etc/nginx/sites-enabled/default
cp /root/netrunner-frontend/nginx/landing-http.conf /etc/nginx/sites-available/landing-http.conf
cp /root/netrunner-frontend/nginx/landing-ssl.conf /etc/nginx/sites-available/landing-ssl.conf
ln -sf /etc/nginx/sites-available/landing-http.conf /etc/nginx/sites-enabled/landing-http.conf
CERT=/etc/letsencrypt/live/netrunner-vpn.com/fullchain.pem
if [ -f "$CERT" ]; then
ln -sf /etc/nginx/sites-available/landing-ssl.conf /etc/nginx/sites-enabled/landing-ssl.conf
echo "🔒 Сертификат найден — HTTPS-конфиг включён."
else
rm -f /etc/nginx/sites-enabled/landing-ssl.conf
echo "⚠️ Сертификата ещё нет ($CERT) — HTTPS-конфиг НЕ включён."
fi
nginx -t
systemctl enable --now nginx
systemctl reload nginx
echo "✅ nginx (netrunner-vpn.com) synchronized."
+16 -5
View File
@@ -24,23 +24,32 @@ jobs:
- name: Sync compose file to Dev VPS - name: Sync compose file to Dev VPS
uses: appleboy/scp-action@v0.1.7 uses: appleboy/scp-action@v0.1.7
with: with:
host: ${{ secrets.DEV_VPS_IP }} host: ${{ vars.DEV_VPS_IP }}
username: root username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }} key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
source: "docker-compose.dev-remote.yml" source: "docker-compose.dev-remote.yml"
target: "/root/netrunner-frontend-dev" target: "/root/netrunner-frontend-dev"
strip_components: 0 strip_components: 0
- name: Remote deploy via SSH - name: Write .env and deploy via SSH
uses: appleboy/ssh-action@v1 uses: appleboy/ssh-action@v1
with: with:
host: ${{ secrets.DEV_VPS_IP }} host: ${{ vars.DEV_VPS_IP }}
username: root username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }} key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
envs: "BOT_USERNAME"
script: | script: |
set -e set -e
cd /root/netrunner-frontend-dev cd /root/netrunner-frontend-dev
# Единственное, что этому compose-файлу нужно из .env — свой dev-бот
# (см. BOT_USERNAME:? в docker-compose.dev-remote.yml). Тот же бот,
# что и у netrunner-backend dev — vars.DEV_BOT_USERNAME там же.
cat > .env <<EOF
BOT_USERNAME=${BOT_USERNAME}
EOF
chmod 600 .env
echo "🚀 Deploying landing+proxy:latest to DEV" echo "🚀 Deploying landing+proxy:latest to DEV"
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
@@ -50,6 +59,8 @@ jobs:
docker image prune -f docker image prune -f
echo "✅ Dev synchronized." echo "✅ Dev synchronized."
env:
BOT_USERNAME: ${{ vars.DEV_BOT_USERNAME }}
deploy-prod: deploy-prod:
name: Pull & restart on Prod VPS name: Pull & restart on Prod VPS
@@ -62,7 +73,7 @@ jobs:
- name: Copy docker-compose.yml to VPS - name: Copy docker-compose.yml to VPS
uses: appleboy/scp-action@v0.1.7 uses: appleboy/scp-action@v0.1.7
with: with:
host: ${{ secrets.VPS_IP }} host: ${{ vars.VPS_IP }}
username: root username: root
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "docker-compose.yml" source: "docker-compose.yml"
@@ -71,7 +82,7 @@ jobs:
- name: Deploy via SSH - name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3 uses: appleboy/ssh-action@v1.0.3
with: with:
host: ${{ secrets.VPS_IP }} host: ${{ vars.VPS_IP }}
username: root username: root
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
script: | script: |
+1
View File
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel
+1
View File
@@ -0,0 +1 @@
package-import-method=copy
+14
View File
@@ -39,6 +39,20 @@ RUN cp matrix-engine/matrix_worker.js ./public/wasm-matrix/ && \
cp matrix-engine/shaders.js ./public/wasm-matrix/ && \ cp matrix-engine/shaders.js ./public/wasm-matrix/ && \
cp matrix-engine/gl-utils.js ./public/wasm-matrix/ cp matrix-engine/gl-utils.js ./public/wasm-matrix/
# 3.1 Прописываем cache-busting версию (ENGINE_VERSION из NetrunnerMatrix.tsx)
# в импорты воркера. Без этого matrix_worker.js?v=X кэш-бастится сам по себе
# (см. NetrunnerMatrix.tsx), а его собственные `import ... from "./shaders.js"`
# и "./gl-utils.js" — нет: после деплоя браузер/CDN может отдать по тому же
# пути старую версию этих файлов, воркер не найдёт в ней нужный экспорт,
# упадёт молча и никогда не пришлёт READY — на экране просто фон Hero
# (белый/чёрный по теме) вместо матрицы, до жёсткой перезагрузки страницы.
RUN ENGINE_VERSION=$(sed -n 's/.*ENGINE_VERSION = "\([^"]*\)".*/\1/p' matrix-engine/NetrunnerMatrix.tsx | head -1) && \
sed -i \
-e "s/^const version = \".*\";/const version = \"${ENGINE_VERSION}\";/" \
-e "s/from \"\.\/shaders\.js\";/from \".\/shaders.js?v=${ENGINE_VERSION}\";/" \
-e "s/from \"\.\/gl-utils\.js\";/from \".\/gl-utils.js?v=${ENGINE_VERSION}\";/" \
./public/wasm-matrix/matrix_worker.js
# 4. Собираем Next.js # 4. Собираем Next.js
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build RUN pnpm run build
+4 -2
View File
@@ -88,8 +88,10 @@ pnpm dev
(по аналогии с `netrunner-backend`, тот же хост — `45.76.161.137` / DNS (по аналогии с `netrunner-backend`, тот же хост — `45.76.161.137` / DNS
`dev.netrunner-vpn.com`), сам лендинг без reverse-proxy (порт `3000` открыт `dev.netrunner-vpn.com`), сам лендинг без reverse-proxy (порт `3000` открыт
напрямую, обычный HTTP), но смотрит на dev-бэкенд уже через Caddy на напрямую, обычный HTTP), но смотрит на dev-бэкенд уже через Caddy на
`https://dev.netrunner-vpn.com:8443` (не голый `:8080`) — у `netrunner-backend` `https://dev.netrunner-vpn.com` (порт 443, не голый `:8080`) — у
там настоящий TLS, нужный для Telegram Web Apps (кнопки бота) и Secure-cookie. `netrunner-backend` там настоящий TLS, нужный и для Telegram Web Apps (кнопки
бота) и Secure-cookie, и для Telegram Login Widget (требует в браузере именно
порт 443).
Домен, а не IP: Telegram Login Widget требует настоящий домен, зарегистрированный Домен, а не IP: Telegram Login Widget требует настоящий домен, зарегистрированный
в BotFather под отдельным dev-ботом (`BOT_USERNAME` для этого стека — в BotFather под отдельным dev-ботом (`BOT_USERNAME` для этого стека —
обязательная переменная, без дефолта на прод-бота, см. комментарии в файле). обязательная переменная, без дефолта на прод-бота, см. комментарии в файле).
+23 -16
View File
@@ -24,20 +24,29 @@ export default async function BlogPostPage({ params }: Props) {
const { lang, slug } = await params; const { lang, slug } = await params;
const dict = await getDictionary(lang); const dict = await getDictionary(lang);
let post: PBPost; let post: PBPost | undefined;
try { // CMS-контент переведён только для части языков — коллекция `posts_<lang>`
// cache: "no-store" — в отличие от списка постов (там ISR на час), отдельный // для новых языков ещё не заведена в PocketBase. Пока для них нет перевода,
// пост всегда читается свежим: страница не самая горячая по трафику, а свежесть // показываем английскую версию поста (тот же slug), а не 404.
// контента (например, сразу после публикации) важнее экономии на кэше. for (const fallbackLang of Array.from(new Set([lang, "en"]))) {
post = await pb try {
.collection(`posts_${lang}`) // cache: "no-store" — в отличие от списка постов (там ISR на час), отдельный
.getFirstListItem<PBPost>(`slug = "${slug}" && published = true`, { // пост всегда читается свежим: страница не самая горячая по трафику, а свежесть
cache: "no-store", // контента (например, сразу после публикации) важнее экономии на кэше.
}); post = await pb
} catch (error) { .collection(`posts_${fallbackLang}`)
// getFirstListItem бросает исключение, если запись не найдена (404 от PocketBase) .getFirstListItem<PBPost>(`slug = "${slug}" && published = true`, {
// или не опубликована — в обоих случаях просто показываем стандартный notFound(). cache: "no-store",
});
break;
} catch (error) {
// getFirstListItem бросает исключение, если запись не найдена (404 от PocketBase)
// или не опубликована — пробуем следующий язык из фолбэка.
}
}
if (!post) {
notFound(); notFound();
} }
return ( return (
@@ -71,9 +80,7 @@ export default async function BlogPostPage({ params }: Props) {
<div className="flex flex-wrap items-center gap-6 text-[10px] font-mono text-primary mb-8 uppercase tracking-[0.2em]"> <div className="flex flex-wrap items-center gap-6 text-[10px] font-mono text-primary mb-8 uppercase tracking-[0.2em]">
<span className="flex items-center gap-2 px-3 py-1 bg-primary/10 rounded-full border border-primary/20"> <span className="flex items-center gap-2 px-3 py-1 bg-primary/10 rounded-full border border-primary/20">
<Calendar size={12} /> <Calendar size={12} />
{new Date(post.created).toLocaleDateString( {new Date(post.created).toLocaleDateString(lang || "en")}
lang === "ru" ? "ru-RU" : "en-US",
)}
</span> </span>
<span className="flex items-center gap-2 text-muted-foreground"> <span className="flex items-center gap-2 text-muted-foreground">
<Clock size={12} /> <Clock size={12} />
+19 -15
View File
@@ -23,18 +23,24 @@ export default async function BlogIndexPage({ params }: Props) {
let records: PBPost[] = []; let records: PBPost[] = [];
try { // CMS-контент переведён только для части языков — коллекция `posts_<lang>`
// Передаём кастомный fetch с next.revalidate, чтобы PocketBase SDK использовал // для новых языков ещё не заведена в PocketBase. Пока для них нет перевода,
// нативное ISR-кэширование Next.js (обновление раз в час) вместо запроса на // показываем английские записи, а не пустой архив.
// каждый рендер — иначе SDK делает обычный fetch без опций кэша Next.js. for (const fallbackLang of Array.from(new Set([lang, "en"]))) {
records = await pb.collection(`posts_${lang}`).getFullList<PBPost>({ try {
filter: "published = true", // Передаём кастомный fetch с next.revalidate, чтобы PocketBase SDK использовал
sort: "-created", // нативное ISR-кэширование Next.js (обновление раз в час) вместо запроса на
fetch: (url, config) => // каждый рендер — иначе SDK делает обычный fetch без опций кэша Next.js.
fetch(url, { ...config, next: { revalidate: 3600 } }), records = await pb.collection(`posts_${fallbackLang}`).getFullList<PBPost>({
}); filter: "published = true",
} catch (err) { sort: "-created",
console.error("Failed to fetch blog posts:", err); fetch: (url, config) =>
fetch(url, { ...config, next: { revalidate: 3600 } }),
});
break;
} catch (err) {
console.error(`Failed to fetch blog posts (${fallbackLang}):`, err);
}
} }
return ( return (
@@ -67,9 +73,7 @@ export default async function BlogIndexPage({ params }: Props) {
<article className="p-8 border border-border/50 rounded-2xl bg-card/20 hover:bg-card/40 hover:border-primary/50 transition-all duration-300 h-full flex flex-col"> <article className="p-8 border border-border/50 rounded-2xl bg-card/20 hover:bg-card/40 hover:border-primary/50 transition-all duration-300 h-full flex flex-col">
<div className="text-xs text-primary font-mono mb-4 flex items-center gap-2"> <div className="text-xs text-primary font-mono mb-4 flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-primary rounded-full animate-pulse" /> <span className="w-1.5 h-1.5 bg-primary rounded-full animate-pulse" />
{new Date(post.created).toLocaleDateString( {new Date(post.created).toLocaleDateString(lang || "en")}
lang === "ru" ? "ru-RU" : "en-US",
)}
</div> </div>
<h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors duration-300"> <h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors duration-300">
+6 -1
View File
@@ -5,6 +5,7 @@ import { getDictionary } from "@/lib/dictionaries";
import { Header } from "@/components/sections/Header"; import { Header } from "@/components/sections/Header";
import { JetBrains_Mono } from "next/font/google"; import { JetBrains_Mono } from "next/font/google";
import { Footer } from "@/components/sections/Footer"; import { Footer } from "@/components/sections/Footer";
import { RTL_LOCALES } from "@/lib/locales";
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
subsets: ["latin", "cyrillic"], subsets: ["latin", "cyrillic"],
@@ -45,8 +46,12 @@ export default async function RootLayout({
const { lang } = await params; const { lang } = await params;
const dict = await getDictionary(lang); const dict = await getDictionary(lang);
const dir = RTL_LOCALES.includes(lang as (typeof RTL_LOCALES)[number])
? "rtl"
: "ltr";
return ( return (
<html lang={lang} suppressHydrationWarning> <html lang={lang} dir={dir} suppressHydrationWarning>
<body <body
className={`${jetbrainsMono.className} bg-background text-foreground min-h-screen flex flex-col transition-colors duration-300`} className={`${jetbrainsMono.className} bg-background text-foreground min-h-screen flex flex-col transition-colors duration-300`}
> >
+9 -2
View File
@@ -48,18 +48,25 @@ export default async function Home({
// запросе (см. подробный комментарий в app/[lang]/auth/auth-client.tsx про ACCOUNT_ORIGIN). // запросе (см. подробный комментарий в app/[lang]/auth/auth-client.tsx про ACCOUNT_ORIGIN).
const pbUrl = const pbUrl =
process.env.PB_PUBLIC_URL || "https://netrunner-vpn.com/cms-api"; process.env.PB_PUBLIC_URL || "https://netrunner-vpn.com/cms-api";
// Те же соображения про NEXT_PUBLIC_ vs обычную переменную, что и у pbUrl
// выше (см. комментарий) — см. также app/[lang]/auth/page.tsx, тот же
// паттерн для того же самого бэкенда.
const accountOrigin =
process.env.ACCOUNT_ORIGIN || "https://account.netrunner-vpn.com";
const apiUrl = process.env.API_URL || `${accountOrigin}/api/v1`;
return ( return (
<> <>
<Hero dict={dict.hero} lang={lang} /> <Hero dict={dict.hero} lang={lang} />
<Features dict={dict.features} /> <Features dict={dict.features} />
<Pricing dict={dict.pricing} lang={lang} pbUrl={pbUrl} /> <Pricing dict={dict.pricing} lang={lang} pbUrl={pbUrl} apiUrl={apiUrl} />
<FAQ dict={dict.faq} lang={lang} />
<TerminalGuestbook dict={dict.guestbook} pbUrl={pbUrl} /> <TerminalGuestbook dict={dict.guestbook} pbUrl={pbUrl} />
<Suspense fallback={<SectionSkeleton />}> <Suspense fallback={<SectionSkeleton />}>
<BlogPreview lang={lang} dict={dict.blog} /> <BlogPreview lang={lang} dict={dict.blog} />
</Suspense> </Suspense>
<FAQ dict={dict.faq} lang={lang} />
</> </>
); );
} }
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import client from "prom-client";
// Явный Node.js рантайм — prom-client (perf_hooks, process.*) не работает в
// Edge Runtime, дефолтном для route handlers в Next.js.
export const runtime = "nodejs";
// register — модульный синглтон: Next.js держит один и тот же процесс между
// запросами в проде (не пересоздаёт модуль на каждый вызов), поэтому
// collectDefaultMetrics() должен быть вызван ровно один раз за жизнь
// процесса — глобальный флаг предотвращает повторную регистрацию тех же
// метрик при hot-reload в dev-режиме.
declare global {
var __prometheusMetricsInitialized: boolean | undefined;
}
if (!global.__prometheusMetricsInitialized) {
client.collectDefaultMetrics();
global.__prometheusMetricsInitialized = true;
}
export async function GET() {
const metrics = await client.register.metrics();
return new NextResponse(metrics, {
status: 200,
headers: { "Content-Type": client.register.contentType },
});
}
+11 -15
View File
@@ -101,23 +101,19 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
cursor: cursor:
url("/cursors/default.png") 0 0, url("/cursors/default.png") 1 1,
auto; auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
/* 2. ВСЕ КЛИКАБЕЛЬНЫЕ ЭЛЕМЕНТЫ */ /* 2. ВСЕ КЛИКАБЕЛЬНЫЕ ЭЛЕМЕНТЫ (по всему сайту, не только в хедере) */
/* Убираем глобальный a, button, так как Tailwind сам вешает cursor-pointer, a,
мы просто переопределим утилиту Tailwind .cursor-pointer ниже */ button,
select,
/* Хедер (жестко привязываем только к элементам внутри хедера) */ [role="button"],
header a, .cursor-pointer-trigger {
header button,
header select,
header [role="button"],
header .cursor-pointer-trigger {
cursor: cursor:
url("/cursors/pointer.png") 7 0, url("/cursors/pointer.png") 13 1,
pointer !important; pointer !important;
} }
@@ -162,12 +158,12 @@
@layer utilities { @layer utilities {
.cursor-pointer { .cursor-pointer {
cursor: cursor:
url("/cursors/pointer.png") 7 0, url("/cursors/pointer.png") 13 1,
pointer !important; pointer !important;
} }
.cursor-default { .cursor-default {
cursor: cursor:
url("/cursors/default.png") 0 0, url("/cursors/default.png") 1 1,
default !important; default !important;
} }
.cursor-text { .cursor-text {
@@ -187,7 +183,7 @@
} }
.cursor-help { .cursor-help {
cursor: cursor:
url("/cursors/help.png") 0 0, url("/cursors/help.png") 1 1,
help !important; help !important;
} }
.cursor-move { .cursor-move {
+23
View File
@@ -22,6 +22,29 @@ cp -f matrix_worker.js pkg/
cp -f shaders.js pkg/ cp -f shaders.js pkg/
cp -f gl-utils.js pkg/ cp -f gl-utils.js pkg/
# 3.1 Прописываем cache-busting версию в ДЕПЛОИМУЮ копию воркера.
# Единственный источник версии — ENGINE_VERSION в NetrunnerMatrix.tsx (её же
# компонент добавляет как ?v= к самому URL воркера). БЕЗ этого шага
# matrix_worker.js импортирует ./shaders.js и ./gl-utils.js СОВСЕМ без версии
# — после деплоя воркер обновляется (у него есть ?v= на своём URL), а эти два
# файла на том же пути могут отдаться из кэша браузера/CDN старой версией. Если
# новый воркер ожидает от них экспорт, которого в старой версии ещё не было
# (как fsBackground в этой сессии), import падает молча внутри воркера — он
# никогда не шлёт READY, канвас остаётся с opacity:0, и виден только фон
# Hero (белый/чёрный по теме) без матрицы. Один и тот же ?v= на воркере, его
# импортах и wasm-glue гарантирует, что все четыре файла инвалидируются вместе.
ENGINE_VERSION=$(grep -oP 'ENGINE_VERSION = "\K[^"]+' NetrunnerMatrix.tsx)
if [ -z "$ENGINE_VERSION" ]; then
echo "⚠️ Warning: could not extract ENGINE_VERSION from NetrunnerMatrix.tsx, skipping cache-busting stamp."
else
echo "🔖 Stamping cache-busting version $ENGINE_VERSION into pkg/matrix_worker.js..."
sed -i \
-e "s/^const version = \".*\";/const version = \"${ENGINE_VERSION}\";/" \
-e "s/from \"\.\/shaders\.js\";/from \".\/shaders.js?v=${ENGINE_VERSION}\";/" \
-e "s/from \"\.\/gl-utils\.js\";/from \".\/gl-utils.js?v=${ENGINE_VERSION}\";/" \
pkg/matrix_worker.js
fi
# 4. Копируем React компонент (если нужно хранить исходник в pkg) # 4. Копируем React компонент (если нужно хранить исходник в pkg)
# Если NetrunnerMatrix.tsx лежит уровнем выше (в src/components), # Если NetrunnerMatrix.tsx лежит уровнем выше (в src/components),
# измени путь соответствующим образом. # измени путь соответствующим образом.
+18 -13
View File
@@ -22,19 +22,24 @@ interface BlogPreviewProps {
export async function BlogPreview({ lang, dict }: BlogPreviewProps) { export async function BlogPreview({ lang, dict }: BlogPreviewProps) {
let posts: PBPost[] = []; let posts: PBPost[] = [];
try { // CMS-контент переведён только для части языков — коллекция `posts_<lang>`
// В Next.js App Router лучше использовать нативный fetch для контроля кэша, // для новых языков ещё не заведена в PocketBase. Пока для них нет перевода,
// либо передавать кастомный fetch в PB SDK. Здесь используем ISR на 3600 секунд (1 час). // показываем английские записи, а не пустую секцию.
posts = await pb.collection(`posts_${lang}`).getFullList<PBPost>({ for (const fallbackLang of Array.from(new Set([lang, "en"]))) {
filter: "published = true", try {
sort: "-created", // В Next.js App Router лучше использовать нативный fetch для контроля кэша,
perPage: 3, // либо передавать кастомный fetch в PB SDK. Здесь используем ISR на 3600 секунд (1 час).
fetch: (url, config) => posts = await pb.collection(`posts_${fallbackLang}`).getFullList<PBPost>({
fetch(url, { ...config, next: { revalidate: 3600 } }), filter: "published = true",
}); sort: "-created",
} catch (error) { perPage: 3,
console.error("Ошибка загрузки логов:", error); fetch: (url, config) =>
return null; // Тихо фейлимся без падения страницы fetch(url, { ...config, next: { revalidate: 3600 } }),
});
break;
} catch (error) {
console.error(`Ошибка загрузки логов (${fallbackLang}):`, error);
}
} }
if (posts.length === 0) return null; if (posts.length === 0) return null;
+20 -9
View File
@@ -25,15 +25,26 @@ export async function FAQ({
lang: string; lang: string;
}) { }) {
let faqs: PBFaq[] = []; let faqs: PBFaq[] = [];
// Различаем "PocketBase ответил, но для этого языка коллекции ещё нет" (штатно,
// это и есть fallback на en ниже) от "PocketBase вообще не ответил ни разу" —
// раньше оба случая одинаково показывали "0 protocols found", что вводит в
// заблуждение при реальном сбое инфраструктуры (выглядит как "у нас нет FAQ",
// а не "сейчас не грузится").
let hadFetchError = false;
try { for (const fallbackLang of Array.from(new Set([lang, "en"]))) {
faqs = await pb.collection(`faqs_${lang}`).getFullList<PBFaq>({ try {
sort: "order", faqs = await pb.collection(`faqs_${fallbackLang}`).getFullList<PBFaq>({
fetch: (url, config) => sort: "order",
fetch(url, { ...config, next: { revalidate: 86400 } }), // Кэшируем на сутки fetch: (url, config) =>
}); fetch(url, { ...config, next: { revalidate: 86400 } }), // Кэшируем на сутки
} catch (error) { });
console.error("Ошибка загрузки FAQ:", error); hadFetchError = false;
break;
} catch (error) {
hadFetchError = true;
console.error(`Ошибка загрузки FAQ (${fallbackLang}):`, error);
}
} }
const formatAnswer = (text: string) => { const formatAnswer = (text: string) => {
@@ -82,7 +93,7 @@ export async function FAQ({
{faqs.length === 0 ? ( {faqs.length === 0 ? (
<div className="text-center py-20 border border-dashed border-border/50 rounded-[2.5rem] bg-card/20"> <div className="text-center py-20 border border-dashed border-border/50 rounded-[2.5rem] bg-card/20">
<p className="text-muted-foreground font-mono text-sm uppercase tracking-widest"> <p className="text-muted-foreground font-mono text-sm uppercase tracking-widest">
0 protocols found in cache. {hadFetchError ? dict.loadError : "0 protocols found in cache."}
</p> </p>
</div> </div>
) : ( ) : (
+4 -1
View File
@@ -53,7 +53,10 @@ export function Footer({
href={`/${lang}`} href={`/${lang}`}
className="flex items-center gap-3 group hover:scale-105 transition-transform duration-300 w-fit mt-1" className="flex items-center gap-3 group hover:scale-105 transition-transform duration-300 w-fit mt-1"
> >
<div className="text-2xl sm:text-3xl tracking-widest flex items-center select-none font-bold"> <div
dir="ltr"
className="text-2xl sm:text-3xl tracking-widest flex items-center select-none font-bold"
>
<span <span
className="text-transparent animate-neon-flicker transition-all duration-300" className="text-transparent animate-neon-flicker transition-all duration-300"
style={{ style={{
+4 -1
View File
@@ -56,7 +56,10 @@ export function Header({
className="flex items-center gap-2 md:gap-3 group hover:scale-105 transition-transform duration-300 w-fit relative z-50" className="flex items-center gap-2 md:gap-3 group hover:scale-105 transition-transform duration-300 w-fit relative z-50"
onClick={() => setIsMenuOpen(false)} onClick={() => setIsMenuOpen(false)}
> >
<div className="text-xl sm:text-2xl md:text-3xl tracking-wide md:tracking-widest flex items-center select-none font-bold uppercase"> <div
dir="ltr"
className="text-xl sm:text-2xl md:text-3xl tracking-wide md:tracking-widest flex items-center select-none font-bold uppercase"
>
<span <span
className="text-transparent animate-neon-flicker transition-all duration-300" className="text-transparent animate-neon-flicker transition-all duration-300"
style={{ style={{
+6
View File
@@ -7,6 +7,7 @@ import { Shield, EyeOff } from "lucide-react";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
import { CyberButton } from "../ui/cyber-button"; import { CyberButton } from "../ui/cyber-button";
import { NetrunnerMatrix } from "../../matrix-engine/NetrunnerMatrix"; // Импорт из пакета import { NetrunnerMatrix } from "../../matrix-engine/NetrunnerMatrix"; // Импорт из пакета
import { GlassShatter } from "../../matrix-engine/GlassShatter";
import { useTheme } from "next-themes"; // <-- ДОБАВЛЕНО import { useTheme } from "next-themes"; // <-- ДОБАВЛЕНО
export function Hero({ export function Hero({
@@ -40,6 +41,11 @@ export function Hero({
движок матрицы (matrix-engine/) инициализируется один раз и сам не движок матрицы (matrix-engine/) инициализируется один раз и сам не
реагирует на смену пропсов лучше, чем через пересоздание инстанса */} реагирует на смену пропсов лучше, чем через пересоздание инстанса */}
<NetrunnerMatrix isSecure={isVpnOn} isDarkMode={isDark} key={lang} /> <NetrunnerMatrix isSecure={isVpnOn} isDarkMode={isDark} key={lang} />
{/* Стекло поверх матрицы, показывается только когда глаза закрыты
(isVpnOn = true) — бей кликом/тапом, трещины растут от удара,
после нескольких ударов стекло осыпается и через паузу собирается
заново. См. matrix-engine/GlassShatter.tsx. */}
<GlassShatter active={isVpnOn} />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_transparent_20%,_var(--background)_120%)] opacity-80 pointer-events-none" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_transparent_20%,_var(--background)_120%)] opacity-80 pointer-events-none" />
<div className="absolute inset-x-0 top-0 h-64 bg-gradient-to-b from-background via-transparent to-transparent opacity-90 pointer-events-none" /> <div className="absolute inset-x-0 top-0 h-64 bg-gradient-to-b from-background via-transparent to-transparent opacity-90 pointer-events-none" />
</div> </div>
+153 -24
View File
@@ -32,6 +32,36 @@ interface PBPricingPlan {
order: number; order: number;
} }
interface BackendPlan {
plan: string; // "GHOST_1M", "NETRUNNER_6M", "MAINFRAME_12M" и т.п.
amount: number; // минорные единицы (центы/копейки), кроме WHOLE_UNIT_CURRENCIES
}
interface BackendPlansResponse {
currency: string;
detected_country: string | null;
vpn_detected: boolean;
plans: BackendPlan[];
}
// У IRR/VND/UZS нет ходовой разменной монеты — amount там уже целыми
// единицами, не центами/копейками (см. modules/geoip/currency.rs на
// бэкенде — та же логика, значения синхронизированы вручную, это два
// независимых рантайма).
const WHOLE_UNIT_CURRENCIES = new Set(["IRR", "VND", "UZS"]);
const CURRENCY_SYMBOLS: Record<string, string> = {
USD: "$",
RUB: "₽",
CNY: "¥",
TRY: "₺",
IRR: "﷼",
UAH: "₴",
BYN: "Br",
VND: "₫",
UZS: "сум",
};
type Period = "1m" | "6m" | "1y"; type Period = "1m" | "6m" | "1y";
interface PeriodOption { interface PeriodOption {
@@ -41,15 +71,27 @@ interface PeriodOption {
} }
/** /**
* Секция тарифов. Тарифные планы приходят из PocketBase (`pricing_{lang}`) с ценами * Секция тарифов. Маркетинговый контент (название, тэглайн, описание,
* сразу на три периода (price1m/price6m/price1y) — компонент на клиенте пересчитывает * фичи, порядок) — из PocketBase (`pricing_{lang}`), переведён по языкам.
* "цену в месяц" для выбранного периода и подсвечивает скидку, если помесячная цена * Сами ЦИФРЫ (price1m/6m/1y) — из бэкенда (`GET /billing/plans`), одного
* при длинном периоде ниже базовой месячной ставки. * источника правды на весь проект (см. modules/billing на бэкенде):
* бэкенд же определяет региональную валюту по IP посетителя и режет
* VPN/датацентр-трафик на базовый USD. Если бэкенд недоступен или для
* какого-то тарифа не нашлось соответствия по имени — используются цены
* из самого PocketBase как есть (graceful degradation, та же логика, что
* уже была для offline-кеша ниже).
*
* Соответствие между записью PocketBase и тарифами бэкенда — по полю
* `name` (ожидается "GHOST"/"NETRUNNER"/"MAINFRAME", регистронезависимо),
* плюс суффикс периода ("_1M"/"_6M"/"_12M"). Если контент-мейкеры в
* PocketBase когда-нибудь заведут `name`, не совпадающее с этим — просто
* тихо не смерджится для этого тарифа, страница не сломается.
*/ */
export function Pricing({ export function Pricing({
dict, dict,
lang, lang,
pbUrl, pbUrl,
apiUrl,
}: { }: {
dict: Dictionary["pricing"]; dict: Dictionary["pricing"];
lang: string; lang: string;
@@ -59,41 +101,122 @@ export function Pricing({
* app/[lang]/auth/auth-client.tsx для полного объяснения). * app/[lang]/auth/auth-client.tsx для полного объяснения).
*/ */
pbUrl: string; pbUrl: string;
/** Адрес бэкенда (control plane) — тот же паттерн, что и pbUrl выше. */
apiUrl: string;
}) { }) {
const [period, setPeriod] = useState<Period>("1y"); const [period, setPeriod] = useState<Period>("1y");
const [plans, setPlans] = useState<PBPricingPlan[]>([]); const [plans, setPlans] = useState<PBPricingPlan[]>([]);
const [loading, setLoading] = useState(!!lang); const [loading, setLoading] = useState(!!lang);
// PocketBase недоступен целиком (не просто нет перевода для этого языка) —
// показываем последний успешно закэшированный список вместо пустой секции.
const [usingCache, setUsingCache] = useState(false);
const [currency, setCurrency] = useState("USD");
useEffect(() => { useEffect(() => {
if (!lang) return; if (!lang) return;
const cacheKey = `netrunner_pricing_cache_${lang}`;
async function fetchPlans() { async function fetchPlans() {
try { try {
setLoading(true); setLoading(true);
pb.baseURL = pbUrl; pb.baseURL = pbUrl;
const records = await pb // CMS-контент переведён только для части языков — коллекция
.collection(`pricing_${lang}`) // `pricing_<lang>` для новых языков ещё не заведена в PocketBase.
.getFullList<PBPricingPlan>({ // Пока для них нет перевода, показываем английские тарифы, а не пустоту.
sort: "order", let records: PBPricingPlan[] = [];
requestKey: null, let allFailed = true;
for (const fallbackLang of Array.from(new Set([lang, "en"]))) {
try {
records = await pb
.collection(`pricing_${fallbackLang}`)
.getFullList<PBPricingPlan>({
sort: "order",
requestKey: null,
});
allFailed = false;
break;
} catch (error) {
console.error(`Ошибка загрузки тарифов (${fallbackLang}):`, error);
}
}
if (allFailed) {
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
records = JSON.parse(cached);
setUsingCache(true);
}
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто нет кеша.
}
} else {
setUsingCache(false);
if (records.length > 0) {
try {
localStorage.setItem(cacheKey, JSON.stringify(records));
} catch {
// Диск/квота localStorage — не критично, просто не кешируем.
}
}
}
// Реальные цены — с бэкенда, в валюте региона посетителя. Бэкенд
// недоступен/упал — просто оставляем цены как есть из PocketBase
// (не блокируем страницу).
try {
const res = await fetch(`${apiUrl}/billing/plans`, {
cache: "no-store",
}); });
if (res.ok) {
const data: BackendPlansResponse = await res.json();
setCurrency(data.currency);
records = records.map((plan) => {
const tier = plan.name.trim().toUpperCase();
const findAmount = (suffix: string) =>
data.plans.find((p) => p.plan === `${tier}_${suffix}`)?.amount;
const toDisplay = (amount: number | undefined, fallback: number) => {
if (amount === undefined) return fallback;
return WHOLE_UNIT_CURRENCIES.has(data.currency)
? amount
: amount / 100;
};
return {
...plan,
price1m: toDisplay(findAmount("1M"), plan.price1m),
price6m: toDisplay(findAmount("6M"), plan.price6m),
price1y: toDisplay(findAmount("12M"), plan.price1y),
};
});
}
} catch (error) {
console.error("Не удалось получить цены с бэкенда:", error);
}
setPlans(records); setPlans(records);
} catch (error) {
console.error("Ошибка загрузки тарифов:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
fetchPlans(); fetchPlans();
}, [lang, pbUrl]); }, [lang, pbUrl, apiUrl]);
const currencySign = lang === "ru" ? "₽" : "$"; const currencySign = CURRENCY_SYMBOLS[currency] ?? "$";
// Валюты справа от числа (как рубль в исходном UI), не слева — только
// если так исторически было для RUB; остальные региональные — слева,
// как доллар, кроме "Br"/"сум" (буквенные, тоже справа — иначе "Br199"
// читается странно).
const symbolAfterAmount =
currency === "RUB" || currency === "BYN" || currency === "UZS";
const periods: PeriodOption[] = [ const periods: PeriodOption[] = [
{ id: "1m", label: lang === "ru" ? "1 месяц" : "1 month" }, { id: "1m", label: dict.period1m },
{ id: "6m", label: lang === "ru" ? "6 месяцев" : "6 months" }, { id: "6m", label: dict.period6m },
{ {
id: "1y", id: "1y",
label: lang === "ru" ? "1 год" : "1 year", label: dict.period1y,
badge: dict.recommend, badge: dict.recommend,
}, },
]; ];
@@ -112,10 +235,16 @@ export function Pricing({
> >
{dict.badge} {dict.badge}
</Badge> </Badge>
<h2 className="text-4xl md:text-5xl font-bold tracking-tighter mb-8"> <h2 className="text-4xl md:text-5xl font-bold tracking-tighter mb-4">
{dict.title} {dict.title}
</h2> </h2>
{usingCache && (
<p className="mb-4 text-xs font-mono uppercase tracking-widest text-muted-foreground/70">
{dict.offlineNotice}
</p>
)}
<div className="inline-flex items-center justify-center p-1.5 bg-card/50 border border-border/50 rounded-2xl backdrop-blur-sm relative"> <div className="inline-flex items-center justify-center p-1.5 bg-card/50 border border-border/50 rounded-2xl backdrop-blur-sm relative">
{periods.map((p: PeriodOption) => ( {periods.map((p: PeriodOption) => (
<button <button
@@ -169,7 +298,7 @@ export function Pricing({
const monthlyEquivalent = Math.round(totalPrice / months); const monthlyEquivalent = Math.round(totalPrice / months);
const isDiscounted = monthlyEquivalent < baseMonthlyPrice; const isDiscounted = monthlyEquivalent < baseMonthlyPrice;
const formatPrice = (price: number) => const formatPrice = (price: number) =>
lang === "ru" ? price.toLocaleString("ru-RU") : price.toString(); new Intl.NumberFormat(lang || "en").format(price);
return ( return (
<Card <Card
@@ -209,9 +338,9 @@ export function Pricing({
{isDiscounted && ( {isDiscounted && (
<> <>
<span className="text-sm text-muted-foreground/50 line-through decoration-primary/50 font-mono"> <span className="text-sm text-muted-foreground/50 line-through decoration-primary/50 font-mono">
{lang === "ru" ? "" : currencySign} {symbolAfterAmount ? "" : currencySign}
{formatPrice(baseMonthlyPrice)} {formatPrice(baseMonthlyPrice)}
{lang === "ru" ? currencySign : ""} {symbolAfterAmount ? currencySign : ""}
</span> </span>
<Badge <Badge
variant="outline" variant="outline"
@@ -230,10 +359,10 @@ export function Pricing({
<span <span
className={`text-5xl font-black tracking-tighter font-mono ${plan.popular ? "text-foreground drop-shadow-[0_0_15px_rgba(var(--primary),0.3)]" : "text-foreground"}`} className={`text-5xl font-black tracking-tighter font-mono ${plan.popular ? "text-foreground drop-shadow-[0_0_15px_rgba(var(--primary),0.3)]" : "text-foreground"}`}
> >
{lang === "ru" ? "" : currencySign} {symbolAfterAmount ? "" : currencySign}
{formatPrice(monthlyEquivalent)} {formatPrice(monthlyEquivalent)}
</span> </span>
{lang === "ru" && ( {symbolAfterAmount && (
<span className="text-3xl font-bold ml-1 text-muted-foreground"> <span className="text-3xl font-bold ml-1 text-muted-foreground">
{currencySign} {currencySign}
</span> </span>
@@ -248,9 +377,9 @@ export function Pricing({
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-muted/40 border border-border/50 text-[11px] text-muted-foreground font-mono uppercase tracking-wider"> <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-muted/40 border border-border/50 text-[11px] text-muted-foreground font-mono uppercase tracking-wider">
<span>{dict.billedOnce}</span> <span>{dict.billedOnce}</span>
<span className="font-bold text-foreground"> <span className="font-bold text-foreground">
{lang === "ru" ? "" : currencySign} {symbolAfterAmount ? "" : currencySign}
{formatPrice(totalPrice)} {formatPrice(totalPrice)}
{lang === "ru" ? currencySign : ""} {symbolAfterAmount ? currencySign : ""}
</span> </span>
</div> </div>
)} )}
+16 -9
View File
@@ -9,15 +9,23 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { locales, LOCALE_LABELS } from "@/lib/locales";
export function LanguageSwitcher() { export function LanguageSwitcher() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const switchLanguage = (lang: string) => { const switchLanguage = (lang: string) => {
// Срезаем текущий двухбуквенный префикс локали из пути и подставляем новый — // Срезаем текущий префикс локали из пути и подставляем новый — так
// так переключение языка сохраняет текущую страницу (/ru/blog -> /en/blog). // переключение языка сохраняет текущую страницу (/ru/blog -> /en/blog).
const currentPath = pathname.replace(/^\/[a-z]{2}/, ""); // Раньше резался только 2-буквенный код ([a-z]{2}), из-за чего "zh-CN"
// никогда не совпадал и путь просто задваивался ("/zh-CN/zh-CN/...").
const currentLocale = locales.find(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`),
);
const currentPath = currentLocale
? pathname.slice(currentLocale.length + 1)
: pathname;
router.push(`/${lang}${currentPath === "" ? "/" : currentPath}`); router.push(`/${lang}${currentPath === "" ? "/" : currentPath}`);
}; };
@@ -34,12 +42,11 @@ export function LanguageSwitcher() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => switchLanguage("en")}> {locales.map((lang) => (
English <DropdownMenuItem key={lang} onClick={() => switchLanguage(lang)}>
</DropdownMenuItem> {LOCALE_LABELS[lang]}
<DropdownMenuItem onClick={() => switchLanguage("ru")}> </DropdownMenuItem>
Русский ))}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
+244
View File
@@ -0,0 +1,244 @@
{
"nav": {
"features": "الميزات",
"pricing": "الأسعار",
"faq": "الأسئلة الشائعة",
"blog": "السجلات",
"terminal": "الطرفية",
"docs": "التوثيق",
"cta": "احصل على Netrunner",
"profile": "الملف الشخصي",
"menu": "Menu.exe",
"speedtest": "التشخيص",
"doom": "DOOM OS"
},
"hero": {
"badge": "النظام يراقب",
"badgeSecure": "تم إنشاء نفق آمن",
"titleStart": "النظام يراقب الجميع،",
"titleHighlight": "ما عداك.",
"subtitle": "بصمتك الرقمية مخفية. السجلات مدمَّرة. السرعة غير محدودة.",
"switchLabel": "Netrunner VPN",
"ctaPrimary": "تنزيل العميل",
"ctaSecondary": "قائمة العقد"
},
"features": {
"title": "تقنيات الوصول",
"items": [
{
"title": "تجاوز DPI وتقنية uTLS",
"description": "يُخفي Netrunner حركة البيانات كأنها HTTPS عادية. أنظمة الفحص العميق للحزم لا ترى سوى ضوضاء بيضاء."
},
{
"title": "تشفير ChaCha20-Poly1305",
"description": "تشفير تدفقي متطور مع سرية تامة إلى الأمام. مفاتيح الجلسات تُولَّد ديناميكياً."
},
{
"title": "ضغط عكسي تكيفي",
"description": "يتم ضبط حجم المخزن المؤقت ديناميكياً حسب زمن الاستجابة، ما يمنع تلقائياً ارتفاعات التأخير."
},
{
"title": "نواة متعددة المنصات",
"description": "نواة Rust منخفضة المستوى تضمن توافقاً مثالياً وبلا أي عبء إضافي على أندرويد وويندوز ولينكس. دعم macOS وiOS قيد التطوير."
},
{
"title": "بنية تحتية خاصة بنا",
"description": "نُشغّل خوادمنا الخاصة بدلاً من استئجارها من مزودي السحابة الكبار، مع تعدد إرسال التدفقات عبر العقد للحصول على اتصال مستقر يصعب حظره."
}
]
},
"pricing": {
"badge": "بروتوكولات التسعير",
"title": "اختر Uplink الخاص بك",
"perMonth": "/شهرياً",
"recommend": "موصى به",
"syncing": "جارٍ مزامنة العقد...",
"empty": "لم يتم العثور على اتصالات متاحة.",
"discount": "خصم",
"mo": "/شهرياً",
"billedOnce": "يُدفع دفعة واحدة:",
"period1m": "شهر واحد",
"period6m": "6 أشهر",
"period1y": "سنة واحدة",
"offlineNotice": "يتم عرض آخر أسعار محفوظة — لا يتوفر اتصال بالخادم الآن."
},
"faq": {
"title": "استعلامات النظام (الأسئلة الشائعة)",
"empty": "لم يتم العثور على سجلات.",
"loadError": "فشل التحميل — حاول تحديث الصفحة."
},
"blog": {
"badge": "سجلات البيانات",
"title": "سجلات الشبكة",
"more": "جميع المقالات",
"readMore": "اقرأ المزيد",
"empty": "لم يتم العثور على إشارات.",
"archive": "أرشيف الطرفية",
"systemLogs": "سجلات النظام",
"emptySector": "[لم يتم العثور على سجلات في هذا القطاع]",
"decrypt": "فك تشفير المقال ←",
"backBtn": "cd ../logs",
"readTime": "وقت القراءة: 4 دقائق",
"verified": "موثّق: Netrunner",
"endTransmission": "نهاية الإرسال // node_th_01"
},
"footer": {
"brand": "Netrunner VPN",
"tagline": "النظام يراقب الجميع، ما عداك.",
"sections": {
"product": "المنتج",
"resources": "الموارد",
"legal": "قانوني"
},
"links": {
"features": "الميزات",
"pricing": "الأسعار",
"faq": "الأسئلة الشائعة",
"protocol": "البروتوكول",
"comms": "المدونة",
"privacy": "الخصوصية",
"terms": "الشروط",
"profile": "الملف الشخصي"
},
"rights": "© 2026 Netrunner VPN. جميع الحزم مشفّرة.",
"protocolStatus": "البروتوكول // وصول مشفّر",
"uplinkStatus": "حالة Uplink",
"activeNode": "العقدة النشطة",
"latency": "زمن الاستجابة (RTT)"
},
"download": {
"title": "نشر العقدة",
"subtitle": "اختر منصتك لنشر عميل Netrunner.",
"badges": {
"active": "إصدار مستقر",
"dev": "قيد التطوير"
},
"buttons": {
"direct": "تنزيل مباشر",
"store": "رابط المتجر",
"github": "الكود المصدري",
"notify": "أعلمني",
"soon": "قريباً في المتجر",
"pending": "بانتظار البناء"
},
"platforms": {
"windows": "Windows 10 / 11",
"android": "Android 8.0+",
"linux": "Ubuntu / Debian / Arch",
"macos": "macOS 12.0+",
"ios": "iOS 15.0+"
},
"accessingBuild": "جارٍ الوصول إلى خادم البناء..."
},
"terminal": {
"title": "قناة الاتصال العالمية",
"description": "قناة اتصال عامة. سجل غير محدود.",
"alias": "الاسم المستعار",
"message": "أدخل البيانات...",
"send": "بث",
"systemReady": "SYSTEM_READY // UPLINK_SECURED",
"loadingHistory": "جارٍ تحميل الأرشيف...",
"noMoreLogs": "تم الوصول إلى نهاية الأرشيف",
"encrypting": "جارٍ التشفير...",
"cooldown": "فترة التهدئة: {{time}} ث",
"manifestoTitle": "بيان قناة الاتصال الحرة // الإصدار 1.0",
"manifestoText": "هذه الطرفية قناة مباشرة إلى نواة Netrunner. نبث السجلات دون رقابة وبشكل مباشر، لأن بروتوكولاً قادراً على تجاوز DPI لا ينبغي أن يخشى الحقيقة.",
"rulesTitle": "بروتوكول السلوك:",
"rule1": "— لا إشراف مسبق. تصل رسالتك إلى الشبكة فوراً.",
"rule2": "— انتقاد الـ VPN، تقارير الأخطاء، أو الأسئلة الصعبة — كل شيء مسموح.",
"rule3": "— القيد الوحيد: الإهانات المباشرة لأعضاء الشبكة الآخرين والبريد المزعج. كن سايبربانك، لا متصيداً.",
"statusReady": "UPLINK_ESTABLISHED // NO_FILTERS_DETECTED"
},
"loading": {
"establishing": "جارٍ إنشاء الاتصال"
},
"notFound": {
"errorBadge": "[ خطأ: العقدة غير موجودة ]",
"message": "جدول التوجيه لا يحتوي على العقدة المطلوبة. تم إسقاط الإرسال. عد إلى الشبكة الآمنة.",
"returnHome": "العودة إلى الرئيسية"
},
"nodes": {
"title": "شبكة العقد العالمية",
"subtitle": "الطوبولوجيا اللحظية لخوادم Netrunner.",
"status": {
"online": "متصل",
"full": "ممتلئ",
"maintenance": "قيد الصيانة"
},
"labels": {
"load": "حمل الخادم",
"latency": "زمن الاستجابة"
}
},
"guestbook": {
"title": "COMM-LINK_",
"subtitle": "طرفية الاتصال",
"description": "قناة مباشرة إلى عقد Netrunner.",
"philosophyTitle": "رقابة صفرية",
"philosophyText": "بنينا Netrunner لتجاوز كل الحواجز، لذا لا نخشى كلماتك. هذا العرض شهادة حية على حرية التعبير. بينما يخفي الآخرون الملاحظات، نبثّها مباشرة. إذا كان بروتوكولنا يصمد أمام DPI، فسيصمد أمام صدقك أيضاً.",
"liveFeed": "البث المباشر (آخر 50)",
"noTransmissions": "لم يتم العثور على إرسالات نشطة.",
"labelAlias": "الاسم المستعار",
"labelPayload": "المحتوى",
"placeholderAlias": "Guest_77",
"placeholderPayload": "أدخل بيانات الإرسال...",
"statusSuccess": "تم تسليم البيانات بنجاح.",
"statusErrorBreach": "خرق أمني. تم قطع الاتصال.",
"statusErrorEmpty": "يتطلب اسماً مستعاراً ومحتوى.",
"statusErrorSize": "حجم المحتوى يتجاوز الحد الأقصى.",
"statusErrorFirewall": "جدار الحماية نشط. انتظر {{time}} ث.",
"statusErrorOffline": "فشل الإرسال. العقدة غير متصلة.",
"statusErrorFilter": "⛔ مرفوض: رسالتك تنتهك سياستنا الأمنية (ألفاظ نابية، بريد مزعج، أو محتوى مسيء). يرجى إعادة الصياغة.",
"btnEncrypting": "جارٍ التشفير...",
"btnBroadcast": "بث",
"cooldown": "فترة التهدئة: {{time}} ث"
},
"auth": {
"title": "تفويض UPLINK",
"processing": "جارٍ معالجة بيانات الاعتماد...",
"verifying": "جارٍ التحقق من التوقيع التشفيري...",
"granted": "تم منح الوصول",
"denied": "تم رفض الوصول",
"noToken": "لم يتم العثور على رمز تفويض. يرجى المصادقة عبر طرفية Netrunner في تيليجرام.",
"redirecting": "جارٍ إعادة التوجيه إلى القطاع الآمن...",
"toBot": "فتح طرفية تيليجرام",
"awaiting": "بانتظار بيانات الاعتماد",
"authPrompt": "قم بتهيئة اتصال آمن عبر تيليجرام للوصول إلى لوحة التحكم.",
"logs": {
"waitInit": "> [بانتظار] تهيئة تسجيل الدخول الآمن عبر تيليجرام...",
"sigValid": "> [OK] التوقيع صالح",
"apiRejected": "> [فشل] رفض الـ API الرمز",
"coreOffline": "> [فشل] النواة غير متصلة",
"authPayload": "> [AUTH] تم استلام بيانات تيليجرام",
"verifyHmac": "> جارٍ التحقق من توقيع HMAC-SHA256...",
"invalidSig": "> [فشل] توقيع غير صالح أو منتهي الصلاحية",
"networkError": "> [فشل] خطأ في الشبكة"
}
},
"docs": {
"establishing": "جارٍ إنشاء اتصال آمن...",
"vaultTitle": "DATA_VAULT",
"vaultSubtitle": "سجلات النظام والتوثيق // V4.2",
"searchPlaceholder": "root@nrxp:~# grep -i ...",
"empty": "تم العثور على 0 سجل لهذا البحث",
"closeSession": "إغلاق الجلسة",
"noContent": "لم يتم العثور على محتوى",
"eof": "EOF"
},
"speedtest": {
"title": "تشخيص Uplink",
"mockMode": "وضع المحاكاة المحلية",
"prodMode": "قياس مباشر مقابل بوابة شبكة Netrunner.",
"btnError": "إعادة المحاولة",
"btnDone": "إعادة تشغيل Uplink",
"btnIdle": "بدء الاختبار",
"statusPinging": "جارٍ قياس الپينغ",
"statusDownloading": "جارٍ التنزيل",
"statusUploading": "جارٍ الرفع",
"latency": "زمن الاستجابة",
"download": "التنزيل",
"upload": "الرفع",
"unitMs": "مللي ثانية",
"unitMbps": "ميجابت/ث"
}
}
+244
View File
@@ -0,0 +1,244 @@
{
"nav": {
"features": "Магчымасці",
"pricing": "Тарыфы",
"faq": "Пытанні",
"blog": "Логі",
"terminal": "Тэрмінал",
"docs": "Дакументацыя",
"cta": "Спампаваць Netrunner",
"profile": "Профіль",
"menu": "Menu.exe",
"speedtest": "Дыягностыка",
"doom": "DOOM OS"
},
"hero": {
"badge": "Сістэма сочыць за ўсімі",
"badgeSecure": "Абаронены тунэль устаноўлены",
"titleStart": "Сістэма сочыць за ўсімі,",
"titleHighlight": "акрамя цябе.",
"subtitle": "Лічбавы след схаваны. Логі знішчаны. Хуткасць не абмежаваная.",
"switchLabel": "Netrunner VPN",
"ctaPrimary": "Спампаваць кліент",
"ctaSecondary": "Спіс вузлоў"
},
"features": {
"title": "Тэхналогіі доступу",
"items": [
{
"title": "Абыход DPI і uTLS",
"description": "Пратакол Netrunner маскіруе трафік пад звычайныя HTTPS-запыты. Сістэмы глыбокага аналізу пакетаў бачаць толькі белы шум."
},
{
"title": "Крыптаграфія ChaCha20-Poly1305",
"description": "Высакахуткасны шыфр патокавай перадачы з Perfect Forward Secrecy. Ключы сесій змяняюцца дынамічна."
},
{
"title": "Adaptive Backpressure",
"description": "Памер буфера маштабуецца ў залежнасці ад затрымкі злучэння. Аўтаматычна прадухіляе скачкі затрымкі."
},
{
"title": "Multi-Platform Core",
"description": "Нізкаўзроўневае ядро на Rust гарантуе бездакорную сумяшчальнасць і нулявы накладны выдатак на Android, Windows і Linux. Падтрымка macOS і iOS у распрацоўцы."
},
{
"title": "Уласная інфраструктура",
"description": "Мы выкарыстоўваем уласныя серверы, а не арандуем іх у воблачных правайдараў — з мультыплексаваннем патокаў паміж вузламі дзеля стабільнага злучэння, якое цяжка занесці ў чорны спіс."
}
]
},
"pricing": {
"badge": "Тарыфныя пратаколы",
"title": "Абярыце ваш Uplink",
"perMonth": "/мес",
"recommend": "Рэкамендуем",
"syncing": "Сінхранізацыя_Вузлоў...",
"empty": "0 даступных падключэнняў.",
"discount": "ЗНІЖКА",
"mo": "/ мес",
"billedOnce": "Спішацца адразу:",
"period1m": "1 месяц",
"period6m": "6 месяцаў",
"period1y": "1 год",
"offlineNotice": "Паказаны апошнія захаваныя тарыфы — зараз няма сувязі з серверам."
},
"faq": {
"title": "Сістэмныя запыты (FAQ)",
"empty": "База ведаў пустая.",
"loadError": "Не ўдалося загрузіць — паспрабуйце абнавіць старонку."
},
"blog": {
"badge": "Data Logs",
"title": "Сеткавыя Хронікі",
"more": "Усе запісы",
"readMore": "Чытаць далей",
"empty": "Сігналы не знойдзены.",
"archive": "Архіў тэрмінала",
"systemLogs": "Сістэмныя Логі",
"emptySector": "[Запісы не знойдзены ў гэтым сектары]",
"decrypt": "Дэшыфраваць →",
"backBtn": "cd ../logs",
"readTime": "Чытанне: 4 хв",
"verified": "Правярана: Netrunner",
"endTransmission": "Канец перадачы // node_th_01"
},
"footer": {
"brand": "Netrunner VPN",
"tagline": "Сістэма сочыць за ўсімі, акрамя цябе.",
"sections": {
"product": "Прадукт",
"resources": "Рэсурсы",
"legal": "Права"
},
"links": {
"features": "Магчымасці",
"pricing": "Тарыфы",
"faq": "FAQ",
"protocol": "Пратакол",
"comms": "Блог",
"privacy": "Прыватнасць",
"terms": "Умовы",
"profile": "Профіль"
},
"rights": "© 2026 Netrunner VPN. Усе пакеты зашыфраваны.",
"protocolStatus": "ПРАТАКОЛ // АБАРОНЕНЫ ДОСТУП",
"uplinkStatus": "Статус Uplink",
"activeNode": "Актыўны Вузел",
"latency": "Затрымка (RTT)"
},
"download": {
"title": "Усталяванне Вузла",
"subtitle": "Абярыце вашу платформу для загрузкі кліента Netrunner.",
"badges": {
"active": "Стабільная",
"dev": "У распрацоўцы"
},
"buttons": {
"direct": "Спампаваць напрамую",
"store": "У Store",
"github": "Source Code",
"notify": "Паведаміць",
"soon": "Хутка ў Store",
"pending": "Чаканне зборкі"
},
"platforms": {
"windows": "Windows 10 / 11",
"android": "Android 8.0+",
"linux": "Ubuntu / Debian / Arch",
"macos": "macOS 12.0+",
"ios": "iOS 15.0+"
},
"accessingBuild": "Доступ_да_Build_Сервера..."
},
"terminal": {
"title": "GLOBAL COMM-LINK",
"description": "Агульны канал сувязі. Бясконцая гісторыя.",
"alias": "ПАЗЫЎНЫ",
"message": "УВОД ДАДЗЕНЫХ...",
"send": "АДПРАВІЦЬ",
"systemReady": "SYSTEM_READY // UPLINK_SECURED",
"loadingHistory": "Загрузка архіва...",
"noMoreLogs": "ДАСЯГНУТА КАНЕЦ АРХІВА",
"encrypting": "ШЫФРАВАННЕ...",
"cooldown": "АЧАКВАННЕ: {{time}}с",
"manifestoTitle": "МАНІФЕСТ ВОЛЬНАЙ СУВЯЗІ // v1.0",
"manifestoText": "Гэты тэрмінал з'яўляецца прамым каналам сувязі з ядром Netrunner. Мы транслюем логі без цэнзуры ў рэальным часе, бо пратакол, здольны абыходзіць DPI, не павінен баяцца праўды.",
"rulesTitle": "ПРАТАКОЛ ПАВОДЗІН:",
"rule1": "— Ніякай прэмадэрацыі. Ваша паведамленне імгненна трапляе ў сетку.",
"rule2": "— Крытыка VPN, баг-рэпорты ці нязручныя пытанні — дазволена ўсё.",
"rule3": "— Адзінае абмежаванне: прамыя абразы іншых удзельнікаў сеткі і спам. Будзьце кіберпанкам, а не тролем.",
"statusReady": "UPLINK_ESTABLISHED // NO_FILTERS_DETECTED"
},
"loading": {
"establishing": "Устанаўленне злучэння"
},
"notFound": {
"errorBadge": "[ Памылка: Вузел не знойдзены ]",
"message": "У табліцы маршрутызацыі няма запытанага вузла. Перадача перарвана. Вярніцеся ў абароненую сетку.",
"returnHome": "Вярнуцца на базу"
},
"nodes": {
"title": "Глабальная Сетка Вузлоў",
"subtitle": "Тапалогія сервераў Netrunner у рэальным часе.",
"status": {
"online": "У сетцы",
"full": "Перагружаны",
"maintenance": "Абслугоўванне"
},
"labels": {
"load": "Нагрузка",
"latency": "Затрымка"
}
},
"guestbook": {
"title": "COMM-LINK_",
"subtitle": "Тэрмінал сувязі",
"description": "Прамы канал сувязі з вузламі Netrunner.",
"philosophyTitle": "Нам не патрэбна мадэрацыя",
"philosophyText": "Мы стварылі Netrunner для абыходу любых бар'ераў, таму не баімся таго, што вы напішаце. Гэты чат — жывы доказ свабоды слова. Пакуль іншыя хаваюць негатыў, мы выводзім яго ў рэальным часе. Калі наш пратакол вытрымлівае атакі DPI, ён вытрымае і вашу шчырасць.",
"liveFeed": "Прамы эфір (Апошнія 50)",
"noTransmissions": "Актыўных перадач не выяўлена.",
"labelAlias": "Пазыўны",
"labelPayload": "Дадзеныя",
"placeholderAlias": "Guest_77",
"placeholderPayload": "Увядзіце паведамленне...",
"statusSuccess": "Дадзеныя паспяхова дастаўлены ў сетку.",
"statusErrorBreach": "Парушэнне бяспекі. Злучэнне разарвана.",
"statusErrorEmpty": "Патрабуецца пазыўны і пакет дадзеных.",
"statusErrorSize": "Памер пакета перавышае ліміт.",
"statusErrorFirewall": "Файрвол актыўны. Пачакайце {{time}} сек.",
"statusErrorOffline": "Перадача перарвана. Вузел недаступны.",
"statusErrorFilter": "⛔ Адхілена: Ваша паведамленне парушае палітыку бяспекі (мат, спам ці таксічнасць). Калі ласка, зменіце фармулёўку для праходжання фільтра.",
"btnEncrypting": "Шыфраванне...",
"btnBroadcast": "Транслiраваць",
"cooldown": "Кулдаун: {{time}}с"
},
"auth": {
"title": "АЎТАРЫЗАЦЫЯ UPLINK",
"processing": "Апрацоўка ўліковых дадзеных...",
"verifying": "Праверка крыптаграфічнага подпісу...",
"granted": "ДОСТУП ДАЗВОЛЕНЫ",
"denied": "У ДОСТУПЕ АДМОЎЛЕНА",
"noToken": "Токен аўтарызацыі не знойдзены. Калі ласка, прайдзіце аўтэнтыфікацыю праз тэрмінал Netrunner у Telegram.",
"redirecting": "Перанакіраванне ў абаронены сектар...",
"toBot": "Адкрыць тэрмінал Telegram",
"awaiting": "АЧАКВАННЕ ЎЛІКОВЫХ ДАДЗЕНЫХ",
"authPrompt": "Ініцыялізуйце абароненае злучэнне праз Telegram для доступу да панэлі кіравання.",
"logs": {
"waitInit": "> [АЧАКВАННЕ] ІНІЦЫЯЛІЗАЦЫЯ АБАРОНЕНАГА ЎВАХОДУ TELEGRAM...",
"sigValid": "> [OK] ПОДПІС САПРАЎДНЫ",
"apiRejected": "> [ПАМЫЛКА] API АДХІЛІЎ ТОКЕН",
"coreOffline": "> [ПАМЫЛКА] ЯДРО НЕДАСТУПНА",
"authPayload": "> [AUTH] АТРЫМАНЫ ДАДЗЕНЫЯ TELEGRAM",
"verifyHmac": "> ПРАВЕРКА ПОДПІСУ HMAC-SHA256...",
"invalidSig": "> [ПАМЫЛКА] НЕСАПРАЎДНЫ АБО СПРАЦАВАНЫ ПОДПІС",
"networkError": "> [ПАМЫЛКА] СЕТКАВЫ ЗБОЙ"
}
},
"docs": {
"establishing": "Устанаўленне_абароненага_злучэння...",
"vaultTitle": "DATA_VAULT",
"vaultSubtitle": "Сістэмныя логі і дакументацыя // V4.2",
"searchPlaceholder": "root@nrxp:~# grep -i ...",
"empty": "0 запісаў знойдзена па запыце",
"closeSession": "Закрыць сесію",
"noContent": "ДАДЗЕНЫЯ_НЕ_ЗНОЙДЗЕНЫ",
"eof": "EOF"
},
"speedtest": {
"title": "Дыягностыка Uplink",
"mockMode": "Лакальная сімуляцыя",
"prodMode": "Прамое вымярэнне да сеткавага шлюза Netrunner.",
"btnError": "ПАЎТАРЫЦЬ ЦЫКЛ",
"btnDone": "ПЕРАЗАПУСЦІЦЬ",
"btnIdle": "ПАЧАЦЬ ТЭСТ",
"statusPinging": "ПІНГ",
"statusDownloading": "ЗАГРУЗКА",
"statusUploading": "АДДАЧА",
"latency": "Затрымка",
"download": "Загрузка",
"upload": "Аддача",
"unitMs": "мс",
"unitMbps": "Мбіт/с"
}
}
+10 -5
View File
@@ -39,11 +39,11 @@
}, },
{ {
"title": "Multi-Platform Core", "title": "Multi-Platform Core",
"description": "Low-level Rust core ensures flawless compatibility and zero overhead across all major OS." "description": "Low-level Rust core ensures flawless compatibility and zero overhead on Android, Windows, and Linux. macOS and iOS support are in development."
}, },
{ {
"title": "Global Network", "title": "Own Infrastructure",
"description": "Private node network with flow multiplexing, providing ultimate anonymity and stable connection." "description": "We run our own servers instead of renting from major cloud providers, with flow multiplexing across nodes for a stable connection that's hard to blacklist."
} }
] ]
}, },
@@ -56,11 +56,16 @@
"empty": "0 uplinks found.", "empty": "0 uplinks found.",
"discount": "SAVE", "discount": "SAVE",
"mo": "/ mo", "mo": "/ mo",
"billedOnce": "Billed once:" "billedOnce": "Billed once:",
"period1m": "1 month",
"period6m": "6 months",
"period1y": "1 year",
"offlineNotice": "Showing last saved prices — live data unavailable right now."
}, },
"faq": { "faq": {
"title": "System Queries (FAQ)", "title": "System Queries (FAQ)",
"empty": "No records found." "empty": "No records found.",
"loadError": "Failed to load — try refreshing the page."
}, },
"blog": { "blog": {
"badge": "Data Logs", "badge": "Data Logs",
+244
View File
@@ -0,0 +1,244 @@
{
"nav": {
"features": "امکانات",
"pricing": "قیمت‌گذاری",
"faq": "سوالات متداول",
"blog": "لاگ‌ها",
"terminal": "ترمینال",
"docs": "مستندات",
"cta": "دریافت Netrunner",
"profile": "پروفایل",
"menu": "Menu.exe",
"speedtest": "تشخیص",
"doom": "DOOM OS"
},
"hero": {
"badge": "سیستم در حال نظارت است",
"badgeSecure": "تونل امن برقرار شد",
"titleStart": "سیستم همه را زیر نظر دارد،",
"titleHighlight": "به‌جز تو.",
"subtitle": "رد پای دیجیتال پنهان شد. لاگ‌ها نابود شدند. سرعت نامحدود.",
"switchLabel": "Netrunner VPN",
"ctaPrimary": "دانلود کلاینت",
"ctaSecondary": "لیست نودها"
},
"features": {
"title": "فناوری‌های دسترسی",
"items": [
{
"title": "دور زدن DPI و uTLS",
"description": "Netrunner ترافیک را به شکل HTTPS معمولی درمی‌آورد. سیستم‌های بازرسی عمیق بسته فقط نویز سفید می‌بینند."
},
{
"title": "رمزنگاری ChaCha20-Poly1305",
"description": "رمزنگاری جریانی پیشرفته با Perfect Forward Secrecy. کلیدهای نشست به‌صورت پویا تولید می‌شوند."
},
{
"title": "فشار برگشتی تطبیقی",
"description": "اندازه بافر بر اساس تأخیر رفت‌وبرگشت سیستم به‌صورت پویا تنظیم می‌شود و به‌طور خودکار از افزایش ناگهانی تأخیر جلوگیری می‌کند."
},
{
"title": "هسته چندسکویی",
"description": "هسته سطح‌پایین Rust سازگاری کامل و بدون سربار اضافه را در اندروید، ویندوز و لینوکس تضمین می‌کند. پشتیبانی از macOS و iOS در حال توسعه است."
},
{
"title": "زیرساخت اختصاصی",
"description": "به‌جای اجاره از ارائه‌دهندگان بزرگ ابری، سرورهای خودمان را اجرا می‌کنیم و با مالتی‌پلکسینگ جریان میان نودها، اتصالی پایدار و به‌سختی قابل مسدودسازی فراهم می‌کنیم."
}
]
},
"pricing": {
"badge": "پروتکل‌های قیمت‌گذاری",
"title": "Uplink خود را انتخاب کنید",
"perMonth": "/ماه",
"recommend": "پیشنهادی",
"syncing": "در حال همگام‌سازی نودها...",
"empty": "هیچ اتصالی یافت نشد.",
"discount": "تخفیف",
"mo": "/ماه",
"billedOnce": "پرداخت یک‌باره:",
"period1m": "۱ ماه",
"period6m": "۶ ماه",
"period1y": "۱ سال",
"offlineNotice": "قیمت‌های ذخیره‌شده قبلی نمایش داده می‌شود — اتصال به سرور اکنون برقرار نیست."
},
"faq": {
"title": "پرسش‌های سیستم (سوالات متداول)",
"empty": "هیچ رکوردی یافت نشد.",
"loadError": "بارگذاری ناموفق بود — صفحه را دوباره بارگذاری کنید."
},
"blog": {
"badge": "لاگ‌های داده",
"title": "وقایع‌نگاری شبکه",
"more": "همه مطالب",
"readMore": "بیشتر بخوانید",
"empty": "سیگنالی یافت نشد.",
"archive": "بایگانی ترمینال",
"systemLogs": "لاگ‌های سیستم",
"emptySector": "[هیچ رکوردی در این بخش یافت نشد]",
"decrypt": "رمزگشایی مطلب ←",
"backBtn": "cd ../logs",
"readTime": "زمان مطالعه: ۴ دقیقه",
"verified": "تأیید شده: Netrunner",
"endTransmission": "پایان انتقال // node_th_01"
},
"footer": {
"brand": "Netrunner VPN",
"tagline": "سیستم همه را زیر نظر دارد، به‌جز تو.",
"sections": {
"product": "محصول",
"resources": "منابع",
"legal": "حقوقی"
},
"links": {
"features": "امکانات",
"pricing": "قیمت‌گذاری",
"faq": "سوالات متداول",
"protocol": "پروتکل",
"comms": "وبلاگ",
"privacy": "حریم خصوصی",
"terms": "شرایط",
"profile": "پروفایل"
},
"rights": "© ۲۰۲۶ Netrunner VPN. تمام بسته‌ها رمزنگاری شده‌اند.",
"protocolStatus": "پروتکل // دسترسی رمزنگاری‌شده",
"uplinkStatus": "وضعیت Uplink",
"activeNode": "نود فعال",
"latency": "تأخیر (RTT)"
},
"download": {
"title": "استقرار نود",
"subtitle": "پلتفرم خود را برای استقرار کلاینت Netrunner انتخاب کنید.",
"badges": {
"active": "نسخه پایدار",
"dev": "در حال توسعه"
},
"buttons": {
"direct": "دانلود مستقیم",
"store": "لینک فروشگاه",
"github": "کد منبع",
"notify": "اطلاع‌رسانی به من",
"soon": "به‌زودی در فروشگاه",
"pending": "در انتظار ساخت"
},
"platforms": {
"windows": "Windows 10 / 11",
"android": "Android 8.0+",
"linux": "Ubuntu / Debian / Arch",
"macos": "macOS 12.0+",
"ios": "iOS 15.0+"
},
"accessingBuild": "در حال دسترسی به سرور بیلد..."
},
"terminal": {
"title": "کانال ارتباطی جهانی",
"description": "کانال ارتباطی عمومی. تاریخچه نامحدود.",
"alias": "نام مستعار",
"message": "ورود داده...",
"send": "ارسال همگانی",
"systemReady": "SYSTEM_READY // UPLINK_SECURED",
"loadingHistory": "در حال بارگذاری بایگانی...",
"noMoreLogs": "به پایان بایگانی رسیدید",
"encrypting": "در حال رمزنگاری...",
"cooldown": "خنک‌سازی: {{time}} ثانیه",
"manifestoTitle": "مانیفست ارتباط آزاد // نسخه ۱.۰",
"manifestoText": "این ترمینال یک کانال مستقیم به هسته Netrunner است. ما لاگ‌ها را بدون سانسور و به‌صورت زنده پخش می‌کنیم، چرا که پروتکلی که می‌تواند از DPI عبور کند، نباید از حقیقت بترسد.",
"rulesTitle": "پروتکل رفتاری:",
"rule1": "— بدون پیش‌بررسی. پیام شما فوراً وارد شبکه می‌شود.",
"rule2": "— انتقاد از VPN، گزارش باگ یا سوالات سخت — همه چیز مجاز است.",
"rule3": "— تنها محدودیت: توهین مستقیم به دیگر اعضای شبکه و اسپم. یک سایبرپانک باش، نه یک مزاحم.",
"statusReady": "UPLINK_ESTABLISHED // NO_FILTERS_DETECTED"
},
"loading": {
"establishing": "در حال برقراری اتصال"
},
"notFound": {
"errorBadge": "[ خطا: نود یافت نشد ]",
"message": "جدول مسیریابی حاوی نود درخواستی نیست. انتقال قطع شد. به شبکه امن بازگردید.",
"returnHome": "بازگشت به خانه"
},
"nodes": {
"title": "شبکه جهانی نودها",
"subtitle": "توپولوژی لحظه‌ای سرورهای Netrunner.",
"status": {
"online": "آنلاین",
"full": "پر",
"maintenance": "در حال تعمیر"
},
"labels": {
"load": "بار سرور",
"latency": "تأخیر"
}
},
"guestbook": {
"title": "COMM-LINK_",
"subtitle": "ترمینال ارتباطی",
"description": "کانال مستقیم به نودهای Netrunner.",
"philosophyTitle": "سانسور صفر",
"philosophyText": "ما Netrunner را برای عبور از همه موانع ساختیم، پس از حرف‌های شما نمی‌ترسیم. این فید گواهی زنده‌ای بر آزادی بیان است. وقتی دیگران بازخوردها را پنهان می‌کنند، ما آن را به‌صورت زنده پخش می‌کنیم. اگر پروتکل ما در برابر DPI مقاومت می‌کند، در برابر صداقت شما هم مقاومت خواهد کرد.",
"liveFeed": "فید زنده (۵۰ مورد آخر)",
"noTransmissions": "هیچ انتقال فعالی یافت نشد.",
"labelAlias": "نام مستعار",
"labelPayload": "محتوا",
"placeholderAlias": "Guest_77",
"placeholderPayload": "داده انتقال را وارد کنید...",
"statusSuccess": "داده با موفقیت ارسال شد.",
"statusErrorBreach": "نقض امنیتی. اتصال قطع شد.",
"statusErrorEmpty": "نام مستعار و محتوا الزامی است.",
"statusErrorSize": "حجم داده از حد مجاز بیشتر است.",
"statusErrorFirewall": "فایروال فعال است. {{time}} ثانیه صبر کنید.",
"statusErrorOffline": "انتقال ناموفق بود. نود آفلاین است.",
"statusErrorFilter": "⛔ رد شد: پیام شما سیاست امنیتی ما را نقض می‌کند (الفاظ رکیک، اسپم یا محتوای مسموم). لطفاً آن را بازنویسی کنید.",
"btnEncrypting": "در حال رمزنگاری...",
"btnBroadcast": "ارسال همگانی",
"cooldown": "خنک‌سازی: {{time}} ثانیه"
},
"auth": {
"title": "احراز هویت UPLINK",
"processing": "در حال پردازش اطلاعات ورود...",
"verifying": "در حال تأیید امضای رمزنگاری‌شده...",
"granted": "دسترسی تأیید شد",
"denied": "دسترسی رد شد",
"noToken": "توکن احراز هویت یافت نشد. لطفاً از طریق ترمینال Netrunner در تلگرام احراز هویت کنید.",
"redirecting": "در حال انتقال به بخش امن...",
"toBot": "باز کردن ترمینال تلگرام",
"awaiting": "در انتظار اطلاعات ورود",
"authPrompt": "برای دسترسی به پنل کنترل، یک اتصال امن از طریق تلگرام برقرار کنید.",
"logs": {
"waitInit": "> [در انتظار] راه‌اندازی ورود امن تلگرام...",
"sigValid": "> [OK] امضا معتبر است",
"apiRejected": "> [خطا] API توکن را رد کرد",
"coreOffline": "> [خطا] هسته آفلاین است",
"authPayload": "> [AUTH] داده تلگرام دریافت شد",
"verifyHmac": "> در حال تأیید امضای HMAC-SHA256...",
"invalidSig": "> [خطا] امضا نامعتبر یا منقضی شده",
"networkError": "> [خطا] خطای شبکه"
}
},
"docs": {
"establishing": "در حال برقراری اتصال امن...",
"vaultTitle": "DATA_VAULT",
"vaultSubtitle": "لاگ‌های سیستم و مستندات // نسخه ۴.۲",
"searchPlaceholder": "root@nrxp:~# grep -i ...",
"empty": "برای این جستجو ۰ رکورد یافت شد",
"closeSession": "بستن نشست",
"noContent": "محتوایی یافت نشد",
"eof": "EOF"
},
"speedtest": {
"title": "تشخیص Uplink",
"mockMode": "حالت شبیه‌سازی محلی",
"prodMode": "اندازه‌گیری مستقیم نسبت به دروازه شبکه Netrunner.",
"btnError": "تکرار توالی",
"btnDone": "راه‌اندازی مجدد Uplink",
"btnIdle": "شروع آزمایش",
"statusPinging": "در حال پینگ",
"statusDownloading": "در حال دانلود",
"statusUploading": "در حال آپلود",
"latency": "تأخیر",
"download": "دانلود",
"upload": "آپلود",
"unitMs": "میلی‌ثانیه",
"unitMbps": "مگابیت بر ثانیه"
}
}
+10 -5
View File
@@ -39,11 +39,11 @@
}, },
{ {
"title": "Multi-Platform Core", "title": "Multi-Platform Core",
"description": "Низкоуровневое сетевое ядро на Rust гарантирует нулевой оверхед на iOS, Android, Windows и macOS." "description": "Низкоуровневое сетевое ядро на Rust гарантирует нулевой оверхед на Android, Windows и Linux. Поддержка macOS и iOS в разработке."
}, },
{ {
"title": "Global Network", "title": "Собственная инфраструктура",
"description": "Собственная сеть узлов по всему миру с мультиплексированием потоков для обеспечения максимальной анонимности." "description": "Мы используем собственные серверы, а не арендуем их у облачных провайдеров — с мультиплексированием потоков между узлами для стабильного соединения, которое сложно занести в чёрный список."
} }
] ]
}, },
@@ -56,11 +56,16 @@
"empty": "0 доступных подключений.", "empty": "0 доступных подключений.",
"discount": "СКИДКА", "discount": "СКИДКА",
"mo": "/ мес", "mo": "/ мес",
"billedOnce": "Спишется разом:" "billedOnce": "Спишется разом:",
"period1m": "1 месяц",
"period6m": "6 месяцев",
"period1y": "1 год",
"offlineNotice": "Показаны последние сохранённые тарифы — сейчас нет связи с сервером."
}, },
"faq": { "faq": {
"title": "Системные запросы (FAQ)", "title": "Системные запросы (FAQ)",
"empty": "База знаний пуста." "empty": "База знаний пуста.",
"loadError": "Не удалось загрузить — попробуйте обновить страницу."
}, },
"blog": { "blog": {
"badge": "Data Logs", "badge": "Data Logs",
+244
View File
@@ -0,0 +1,244 @@
{
"nav": {
"features": "Özellikler",
"pricing": "Fiyatlandırma",
"faq": "SSS",
"blog": "Kayıtlar",
"terminal": "Terminal",
"docs": "Dokümantasyon",
"cta": "Netrunner'ı Al",
"profile": "Profil",
"menu": "Menu.exe",
"speedtest": "Tanılama",
"doom": "DOOM OS"
},
"hero": {
"badge": "Sistem izliyor",
"badgeSecure": "Güvenli tünel kuruldu",
"titleStart": "Sistem herkesi izliyor,",
"titleHighlight": "senin dışında.",
"subtitle": "Dijital iz gizlendi. Kayıtlar yok edildi. Hız sınırsız.",
"switchLabel": "Netrunner VPN",
"ctaPrimary": "İstemciyi İndir",
"ctaSecondary": "Node Listesi"
},
"features": {
"title": "Erişim Teknolojileri",
"items": [
{
"title": "DPI Atlatma ve uTLS",
"description": "Netrunner trafiği standart HTTPS olarak gizler. Derin Paket İncelemesi sistemleri yalnızca beyaz gürültü görür."
},
{
"title": "ChaCha20-Poly1305",
"description": "Perfect Forward Secrecy özellikli son teknoloji akış şifrelemesi. Oturum anahtarları dinamik olarak üretilir."
},
{
"title": "Uyarlanabilir Geri Basınç",
"description": "Tampon boyutları sistem RTT'sine göre dinamik olarak ölçeklenir. Gecikme sıçramalarını otomatik olarak önler."
},
{
"title": "Çok Platformlu Çekirdek",
"description": "Düşük seviyeli Rust çekirdeği, Android, Windows ve Linux'ta kusursuz uyumluluk ve sıfır ek yük sağlar. macOS ve iOS desteği geliştirme aşamasındadır."
},
{
"title": "Kendi Altyapımız",
"description": "Büyük bulut sağlayıcılarından kiralamak yerine kendi sunucularımızı işletiyoruz; düğümler arası akış çoklanmasıyla kararlı ve kara listeye almanın zor olduğu bir bağlantı sunuyoruz."
}
]
},
"pricing": {
"badge": "Fiyatlandırma Protokolleri",
"title": "Uplink'inizi Seçin",
"perMonth": "/ay",
"recommend": "Önerilen",
"syncing": "Node'lar Senkronize Ediliyor...",
"empty": "Kullanılabilir bağlantı bulunamadı.",
"discount": "İNDİRİM",
"mo": "/ay",
"billedOnce": "Tek seferlik ödeme:",
"period1m": "1 ay",
"period6m": "6 ay",
"period1y": "1 yıl",
"offlineNotice": "Son kaydedilen fiyatlar gösteriliyor — şu anda sunucuya bağlanılamıyor."
},
"faq": {
"title": "Sistem Sorguları (SSS)",
"empty": "Kayıt bulunamadı.",
"loadError": "Yüklenemedi — sayfayı yenilemeyi deneyin."
},
"blog": {
"badge": "Veri Kayıtları",
"title": "Ağ Günlükleri",
"more": "Tüm girişler",
"readMore": "Devamını oku",
"empty": "Sinyal bulunamadı.",
"archive": "Terminal Arşivi",
"systemLogs": "Sistem Kayıtları",
"emptySector": "[Bu sektörde kayıt bulunamadı]",
"decrypt": "Girişin şifresini çöz →",
"backBtn": "cd ../logs",
"readTime": "Okuma: 4 dk",
"verified": "Doğrulandı: Netrunner",
"endTransmission": "İletim sonu // node_th_01"
},
"footer": {
"brand": "Netrunner VPN",
"tagline": "Sistem herkesi izliyor, senin dışında.",
"sections": {
"product": "Ürün",
"resources": "Kaynaklar",
"legal": "Yasal"
},
"links": {
"features": "Özellikler",
"pricing": "Fiyatlandırma",
"faq": "SSS",
"protocol": "Protokol",
"comms": "Blog",
"privacy": "Gizlilik",
"terms": "Koşullar",
"profile": "Profil"
},
"rights": "© 2026 Netrunner VPN. Tüm paketler şifrelenmiştir.",
"protocolStatus": "PROTOKOL // ŞİFRELİ ERİŞİM",
"uplinkStatus": "Uplink Durumu",
"activeNode": "Aktif Node",
"latency": "Gecikme (RTT)"
},
"download": {
"title": "Node Dağıtımı",
"subtitle": "Netrunner istemcisini dağıtmak için platformunuzu seçin.",
"badges": {
"active": "Kararlı Sürüm",
"dev": "Geliştiriliyor"
},
"buttons": {
"direct": "Doğrudan İndir",
"store": "Mağaza Bağlantısı",
"github": "Kaynak Kod",
"notify": "Bana Haber Ver",
"soon": "Yakında Mağazada",
"pending": "Derleme Bekleniyor"
},
"platforms": {
"windows": "Windows 10 / 11",
"android": "Android 8.0+",
"linux": "Ubuntu / Debian / Arch",
"macos": "macOS 12.0+",
"ios": "iOS 15.0+"
},
"accessingBuild": "Derleme_Sunucusuna_Erişiliyor..."
},
"terminal": {
"title": "KÜRESEL İLETİŞİM HATTI",
"description": "Genel iletişim kanalı. Sınırsız geçmiş.",
"alias": "TAKMA AD",
"message": "VERİ GİRİN...",
"send": "YAYINLA",
"systemReady": "SYSTEM_READY // UPLINK_SECURED",
"loadingHistory": "Arşiv yükleniyor...",
"noMoreLogs": "ARŞİVİN SONUNA ULAŞILDI",
"encrypting": "ŞİFRELENİYOR...",
"cooldown": "BEKLEME: {{time}}sn",
"manifestoTitle": "ÖZGÜR İLETİŞİM HATTI MANİFESTOSU // v1.0",
"manifestoText": "Bu terminal, Netrunner çekirdeğine doğrudan bir hattır. Kayıtları sansürsüz ve gerçek zamanlı olarak yayınlıyoruz, çünkü DPI'ı atlatabilen bir protokol gerçeklerden korkmamalı.",
"rulesTitle": "DAVRANIŞ PROTOKOLÜ:",
"rule1": "— Ön moderasyon yok. Mesajınız ağa anında ulaşır.",
"rule2": "— VPN eleştirisi, hata bildirimleri veya zor sorular — her şeye izin verilir.",
"rule3": "— Tek sınır: diğer ağ üyelerine doğrudan hakaret ve spam. Trol değil, siberpunk ol.",
"statusReady": "UPLINK_ESTABLISHED // NO_FILTERS_DETECTED"
},
"loading": {
"establishing": "Bağlantı kuruluyor"
},
"notFound": {
"errorBadge": "[ Hata: Node Bulunamadı ]",
"message": "Yönlendirme tablosunda istenen node bulunmuyor. İletim düşürüldü. Güvenli ağa geri dönün.",
"returnHome": "Ana Sayfaya Dön"
},
"nodes": {
"title": "Küresel Node Ağı",
"subtitle": "Netrunner sunucularının gerçek zamanlı topolojisi.",
"status": {
"online": "Çevrimiçi",
"full": "Dolu",
"maintenance": "Bakımda"
},
"labels": {
"load": "Sunucu Yükü",
"latency": "Gecikme"
}
},
"guestbook": {
"title": "COMM-LINK_",
"subtitle": "İletişim Terminali",
"description": "Netrunner node'larına doğrudan hat.",
"philosophyTitle": "Sıfır Sansür",
"philosophyText": "Netrunner'ı tüm engelleri aşmak için inşa ettik, bu yüzden sözlerinizden korkmuyoruz. Bu akış, ifade özgürlüğünün canlı bir kanıtıdır. Diğerleri geri bildirimleri gizlerken, biz onu gerçek zamanlı yayınlıyoruz. Protokolümüz DPI'a karşı duruyorsa, dürüstlüğünüze karşı da duracaktır.",
"liveFeed": "Canlı Akış (Son 50)",
"noTransmissions": "Aktif iletim bulunamadı.",
"labelAlias": "Takma Ad",
"labelPayload": "İçerik",
"placeholderAlias": "Guest_77",
"placeholderPayload": "İletim verisini girin...",
"statusSuccess": "Veri başarıyla iletildi.",
"statusErrorBreach": "Güvenlik ihlali. Bağlantı kesildi.",
"statusErrorEmpty": "Takma ad ve içerik gerekli.",
"statusErrorSize": "İçerik maksimum boyutu aşıyor.",
"statusErrorFirewall": "Güvenlik duvarı aktif. {{time}}sn bekleyin.",
"statusErrorOffline": "İletim başarısız. Node çevrimdışı.",
"statusErrorFilter": "⛔ Reddedildi: Mesajınız güvenlik politikamızı ihlal ediyor (küfür, spam veya zararlı içerik). Lütfen yeniden ifade edin.",
"btnEncrypting": "Şifreleniyor...",
"btnBroadcast": "Yayınla",
"cooldown": "Bekleme: {{time}}sn"
},
"auth": {
"title": "UPLINK YETKİLENDİRME",
"processing": "Kimlik bilgileri işleniyor...",
"verifying": "Kriptografik imza doğrulanıyor...",
"granted": "ERİŞİM İZNİ VERİLDİ",
"denied": "ERİŞİM REDDEDİLDİ",
"noToken": "Yetkilendirme jetonu bulunamadı. Lütfen Telegram'daki Netrunner Terminali üzerinden kimlik doğrulaması yapın.",
"redirecting": "Güvenli sektöre yönlendiriliyor...",
"toBot": "Telegram Terminalini Aç",
"awaiting": "KİMLİK BİLGİLERİ BEKLENİYOR",
"authPrompt": "Kontrol paneline erişmek için Telegram üzerinden güvenli bir bağlantı başlatın.",
"logs": {
"waitInit": "> [BEKLENİYOR] TELEGRAM GÜVENLİ GİRİŞİ BAŞLATILIYOR...",
"sigValid": "> [OK] İMZA GEÇERLİ",
"apiRejected": "> [BAŞARISIZ] API JETONU REDDETTİ",
"coreOffline": "> [BAŞARISIZ] ÇEKİRDEK ÇEVRİMDIŞI",
"authPayload": "> [AUTH] TELEGRAM VERİSİ ALINDI",
"verifyHmac": "> HMAC-SHA256 İMZASI DOĞRULANIYOR...",
"invalidSig": "> [BAŞARISIZ] GEÇERSİZ VEYA SÜRESİ DOLMUŞ İMZA",
"networkError": "> [BAŞARISIZ] AĞ HATASI"
}
},
"docs": {
"establishing": "Güvenli_bağlantı_kuruluyor...",
"vaultTitle": "DATA_VAULT",
"vaultSubtitle": "Sistem kayıtları ve dokümantasyon // V4.2",
"searchPlaceholder": "root@nrxp:~# grep -i ...",
"empty": "Bu sorgu için 0 kayıt bulundu",
"closeSession": "Oturumu Kapat",
"noContent": "İÇERİK_BULUNAMADI",
"eof": "EOF"
},
"speedtest": {
"title": "Uplink Tanılaması",
"mockMode": "Yerel Simülasyon Modu",
"prodMode": "Netrunner ağ geçidine karşı doğrudan ölçüm.",
"btnError": "SIRAYI YENİDEN DENE",
"btnDone": "UPLINK'İ YENİDEN BAŞLAT",
"btnIdle": "TESTİ BAŞLAT",
"statusPinging": "PING ATILIYOR",
"statusDownloading": "İNDİRİLİYOR",
"statusUploading": "YÜKLENİYOR",
"latency": "Gecikme",
"download": "İndirme",
"upload": "Yükleme",
"unitMs": "ms",
"unitMbps": "Mbps"
}
}
+244
View File
@@ -0,0 +1,244 @@
{
"nav": {
"features": "Можливості",
"pricing": "Тарифи",
"faq": "Питання",
"blog": "Логи",
"terminal": "Термінал",
"docs": "Документація",
"cta": "Завантажити Netrunner",
"profile": "Профіль",
"menu": "Menu.exe",
"speedtest": "Діагностика",
"doom": "DOOM OS"
},
"hero": {
"badge": "Система стежить за всіма",
"badgeSecure": "Захищений тунель встановлено",
"titleStart": "Система стежить за всіма,",
"titleHighlight": "крім тебе.",
"subtitle": "Цифровий слід прихований. Логи знищені. Швидкість не обмежена.",
"switchLabel": "Netrunner VPN",
"ctaPrimary": "Завантажити клієнт",
"ctaSecondary": "Список вузлів"
},
"features": {
"title": "Технології доступу",
"items": [
{
"title": "Обхід DPI та uTLS",
"description": "Протокол Netrunner маскує трафік під звичайні HTTPS-запити. Системи глибокого аналізу пакетів бачать лише білий шум."
},
{
"title": "Криптографія ChaCha20-Poly1305",
"description": "Високошвидкісний потоковий шифр з Perfect Forward Secrecy. Ключі сесій змінюються динамічно."
},
{
"title": "Adaptive Backpressure",
"description": "Розмір буфера масштабується залежно від затримки з'єднання. Автоматично запобігає стрибкам латентності."
},
{
"title": "Multi-Platform Core",
"description": "Низькорівневе ядро на Rust гарантує бездоганну сумісність і нульовий оверхед на Android, Windows та Linux. Підтримка macOS та iOS у розробці."
},
{
"title": "Власна інфраструктура",
"description": "Ми використовуємо власні сервери, а не орендуємо їх у хмарних провайдерів — з мультиплексуванням потоків між вузлами для стабільного з'єднання, яке важко занести в чорний список."
}
]
},
"pricing": {
"badge": "Тарифні протоколи",
"title": "Оберіть свій Uplink",
"perMonth": "/міс",
"recommend": "Рекомендуємо",
"syncing": "Синхронізація_Вузлів...",
"empty": "0 доступних підключень.",
"discount": "ЗНИЖКА",
"mo": "/ міс",
"billedOnce": "Спишеться одразу:",
"period1m": "1 місяць",
"period6m": "6 місяців",
"period1y": "1 рік",
"offlineNotice": "Показано останні збережені тарифи — зараз немає зв'язку із сервером."
},
"faq": {
"title": "Системні запити (FAQ)",
"empty": "База знань порожня.",
"loadError": "Не вдалося завантажити — спробуйте оновити сторінку."
},
"blog": {
"badge": "Data Logs",
"title": "Мережеві Хроніки",
"more": "Усі записи",
"readMore": "Читати далі",
"empty": "Сигнали не знайдено.",
"archive": "Архів термінала",
"systemLogs": "Системні Логи",
"emptySector": "[Записи не знайдено в цьому секторі]",
"decrypt": "Дешифрувати →",
"backBtn": "cd ../logs",
"readTime": "Читання: 4 хв",
"verified": "Перевірено: Netrunner",
"endTransmission": "Кінець передачі // node_th_01"
},
"footer": {
"brand": "Netrunner VPN",
"tagline": "Система стежить за всіма, крім тебе.",
"sections": {
"product": "Продукт",
"resources": "Ресурси",
"legal": "Право"
},
"links": {
"features": "Можливості",
"pricing": "Тарифи",
"faq": "FAQ",
"protocol": "Протокол",
"comms": "Блог",
"privacy": "Приватність",
"terms": "Умови",
"profile": "Профіль"
},
"rights": "© 2026 Netrunner VPN. Усі пакети зашифровано.",
"protocolStatus": "ПРОТОКОЛ // ЗАХИЩЕНИЙ ДОСТУП",
"uplinkStatus": "Статус Uplink",
"activeNode": "Активний Вузол",
"latency": "Затримка (RTT)"
},
"download": {
"title": "Встановлення Вузла",
"subtitle": "Оберіть вашу платформу для завантаження клієнта Netrunner.",
"badges": {
"active": "Стабільна",
"dev": "У розробці"
},
"buttons": {
"direct": "Завантажити напряму",
"store": "У Store",
"github": "Source Code",
"notify": "Повідомити",
"soon": "Скоро в Store",
"pending": "Очікування збірки"
},
"platforms": {
"windows": "Windows 10 / 11",
"android": "Android 8.0+",
"linux": "Ubuntu / Debian / Arch",
"macos": "macOS 12.0+",
"ios": "iOS 15.0+"
},
"accessingBuild": "Доступ_до_Build_Сервера..."
},
"terminal": {
"title": "GLOBAL COMM-LINK",
"description": "Загальний канал зв'язку. Нескінченна історія.",
"alias": "ПОЗИВНИЙ",
"message": "ВВЕДЕННЯ ДАНИХ...",
"send": "НАДІСЛАТИ",
"systemReady": "SYSTEM_READY // UPLINK_SECURED",
"loadingHistory": "Завантаження архіву...",
"noMoreLogs": "ДОСЯГНУТО КІНЦЯ АРХІВУ",
"encrypting": "ШИФРУВАННЯ...",
"cooldown": "ОЧІКУВАННЯ: {{time}}с",
"manifestoTitle": "МАНІФЕСТ ВІЛЬНОГО ЗВ'ЯЗКУ // v1.0",
"manifestoText": "Цей термінал є прямим каналом зв'язку з ядром Netrunner. Ми транслюємо логи без цензури в реальному часі, тому що протокол, здатний обходити DPI, не повинен боятися правди.",
"rulesTitle": "ПРОТОКОЛ ПОВЕДІНКИ:",
"rule1": "— Жодної премодерації. Ваше повідомлення миттєво потрапляє в мережу.",
"rule2": "— Критика VPN, баг-репорти чи незручні питання — дозволено все.",
"rule3": "— Єдине обмеження: прямі образи інших учасників мережі та спам. Будьте кіберпанком, а не тролем.",
"statusReady": "UPLINK_ESTABLISHED // NO_FILTERS_DETECTED"
},
"loading": {
"establishing": "Встановлення з'єднання"
},
"notFound": {
"errorBadge": "[ Помилка: Вузол не знайдено ]",
"message": "У таблиці маршрутизації немає запитуваного вузла. Передачу перервано. Поверніться до захищеної мережі.",
"returnHome": "Повернутися на базу"
},
"nodes": {
"title": "Глобальна Мережа Вузлів",
"subtitle": "Топологія серверів Netrunner у реальному часі.",
"status": {
"online": "У мережі",
"full": "Перевантажено",
"maintenance": "Обслуговування"
},
"labels": {
"load": "Навантаження",
"latency": "Затримка"
}
},
"guestbook": {
"title": "COMM-LINK_",
"subtitle": "Термінал зв'язку",
"description": "Прямий канал зв'язку з вузлами Netrunner.",
"philosophyTitle": "Нам не потрібна модерація",
"philosophyText": "Ми створили Netrunner для обходу будь-яких бар'єрів, тому не боїмося того, що ви напишете. Ця стрічка — живий доказ свободи слова. Поки інші ховають негатив, ми виводимо його в реальному часі. Якщо наш протокол витримує атаки DPI, він витримає і вашу чесність.",
"liveFeed": "Пряма трансляція (Останні 50)",
"noTransmissions": "Активних передач не виявлено.",
"labelAlias": "Позивний",
"labelPayload": "Дані",
"placeholderAlias": "Guest_77",
"placeholderPayload": "Введіть повідомлення...",
"statusSuccess": "Дані успішно доставлено в мережу.",
"statusErrorBreach": "Порушення безпеки. З'єднання розірвано.",
"statusErrorEmpty": "Потрібні позивний і пакет даних.",
"statusErrorSize": "Розмір пакета перевищує ліміт.",
"statusErrorFirewall": "Файрвол активний. Зачекайте {{time}} с.",
"statusErrorOffline": "Передачу перервано. Вузол недоступний.",
"statusErrorFilter": "⛔ Відхилено: Ваше повідомлення порушує політику безпеки (лайка, спам чи токсичність). Будь ласка, змініть формулювання для проходження фільтра.",
"btnEncrypting": "Шифрування...",
"btnBroadcast": "Транслювати",
"cooldown": "Кулдаун: {{time}}с"
},
"auth": {
"title": "АВТОРИЗАЦІЯ UPLINK",
"processing": "Обробка облікових даних...",
"verifying": "Перевірка криптографічного підпису...",
"granted": "ДОСТУП ДОЗВОЛЕНО",
"denied": "У ДОСТУПІ ВІДМОВЛЕНО",
"noToken": "Токен авторизації не знайдено. Будь ласка, пройдіть автентифікацію через термінал Netrunner у Telegram.",
"redirecting": "Перенаправлення в захищений сектор...",
"toBot": "Відкрити термінал Telegram",
"awaiting": "ОЧІКУВАННЯ ОБЛІКОВИХ ДАНИХ",
"authPrompt": "Ініціалізуйте захищене з'єднання через Telegram для доступу до панелі керування.",
"logs": {
"waitInit": "> [ОЧІКУВАННЯ] ІНІЦІАЛІЗАЦІЯ ЗАХИЩЕНОГО ВХОДУ TELEGRAM...",
"sigValid": "> [OK] ПІДПИС ДІЙСНИЙ",
"apiRejected": "> [ПОМИЛКА] API ВІДХИЛИВ ТОКЕН",
"coreOffline": "> [ПОМИЛКА] ЯДРО НЕДОСТУПНЕ",
"authPayload": "> [AUTH] ОТРИМАНО ДАНІ TELEGRAM",
"verifyHmac": "> ПЕРЕВІРКА ПІДПИСУ HMAC-SHA256...",
"invalidSig": "> [ПОМИЛКА] НЕДІЙСНИЙ АБО ПРОСТРОЧЕНИЙ ПІДПИС",
"networkError": "> [ПОМИЛКА] МЕРЕЖЕВИЙ ЗБІЙ"
}
},
"docs": {
"establishing": "Встановлення_захищеного_з'єднання...",
"vaultTitle": "DATA_VAULT",
"vaultSubtitle": "Системні логи та документація // V4.2",
"searchPlaceholder": "root@nrxp:~# grep -i ...",
"empty": "0 записів знайдено за запитом",
"closeSession": "Закрити сесію",
"noContent": "ДАНІ_НЕ_ЗНАЙДЕНО",
"eof": "EOF"
},
"speedtest": {
"title": "Діагностика Uplink",
"mockMode": "Локальна симуляція",
"prodMode": "Пряме вимірювання до мережевого шлюзу Netrunner.",
"btnError": "ПОВТОРИТИ ЦИКЛ",
"btnDone": "ПЕРЕЗАПУСТИТИ",
"btnIdle": "ПОЧАТИ ТЕСТ",
"statusPinging": "ПІНГ",
"statusDownloading": "ЗАВАНТАЖЕННЯ",
"statusUploading": "ВІДДАЧА",
"latency": "Затримка",
"download": "Завантаження",
"upload": "Віддача",
"unitMs": "мс",
"unitMbps": "Мбіт/с"
}
}
+244
View File
@@ -0,0 +1,244 @@
{
"nav": {
"features": "Imkoniyatlar",
"pricing": "Narxlar",
"faq": "Savollar",
"blog": "Jurnallar",
"terminal": "Terminal",
"docs": "Hujjatlar",
"cta": "Netrunner'ni oling",
"profile": "Profil",
"menu": "Menu.exe",
"speedtest": "Diagnostika",
"doom": "DOOM OS"
},
"hero": {
"badge": "Tizim kuzatmoqda",
"badgeSecure": "Xavfsiz tunnel o'rnatildi",
"titleStart": "Tizim hammani kuzatadi,",
"titleHighlight": "sizdan tashqari.",
"subtitle": "Raqamli iz yashirilgan. Jurnallar yo'q qilingan. Tezlik cheklanmagan.",
"switchLabel": "Netrunner VPN",
"ctaPrimary": "Klientni yuklab olish",
"ctaSecondary": "Node ro'yxati"
},
"features": {
"title": "Kirish Texnologiyalari",
"items": [
{
"title": "DPI'ni Chetlab O'tish va uTLS",
"description": "Netrunner trafikni oddiy HTTPS sifatida niqoblaydi. Chuqur paket tekshiruvi tizimlari faqat oq shovqinni ko'radi."
},
{
"title": "ChaCha20-Poly1305 Shifrlash",
"description": "Perfect Forward Secrecy bilan zamonaviy oqim shifrlash. Sessiya kalitlari dinamik tarzda yaratiladi."
},
{
"title": "Moslashuvchan Backpressure",
"description": "Bufer hajmi tizim RTT'siga qarab dinamik ravishda o'zgaradi. Kechikish sakrashlarining oldini avtomatik oladi."
},
{
"title": "Ko'p Platformali Yadro",
"description": "Quyi darajadagi Rust yadrosi Android, Windows va Linux'da mukammal moslik va nol qo'shimcha yukni ta'minlaydi. macOS va iOS qo'llab-quvvatlashi ishlab chiqilmoqda."
},
{
"title": "O'z Infratuzilmamiz",
"description": "Yirik bulutli provayderlardan ijaraga olish o'rniga o'z serverlarimizni ishlatamiz — tugunlar orasida oqimni multipleksatsiya qilish orqali barqaror va qora ro'yxatga kiritish qiyin bo'lgan ulanish."
}
]
},
"pricing": {
"badge": "Narxlash Protokollari",
"title": "Uplink'ingizni Tanlang",
"perMonth": "/oy",
"recommend": "Tavsiya etiladi",
"syncing": "Tugunlar sinxronlanmoqda...",
"empty": "Mavjud ulanishlar topilmadi.",
"discount": "CHEGIRMA",
"mo": "/oy",
"billedOnce": "Bir martalik to'lov:",
"period1m": "1 oy",
"period6m": "6 oy",
"period1y": "1 yil",
"offlineNotice": "Oxirgi saqlangan narxlar ko'rsatilmoqda — hozir serverga ulanib bo'lmayapti."
},
"faq": {
"title": "Tizim So'rovlari (Savollar)",
"empty": "Yozuvlar topilmadi.",
"loadError": "Yuklab bo'lmadi — sahifani yangilashga urinib ko'ring."
},
"blog": {
"badge": "Ma'lumotlar Jurnali",
"title": "Tarmoq Xronikalari",
"more": "Barcha yozuvlar",
"readMore": "Batafsil o'qish",
"empty": "Signallar topilmadi.",
"archive": "Terminal Arxivi",
"systemLogs": "Tizim Jurnallari",
"emptySector": "[Bu bo'limda yozuvlar topilmadi]",
"decrypt": "Yozuvni ochish →",
"backBtn": "cd ../logs",
"readTime": "O'qish: 4 daqiqa",
"verified": "Tasdiqlangan: Netrunner",
"endTransmission": "Uzatish yakuni // node_th_01"
},
"footer": {
"brand": "Netrunner VPN",
"tagline": "Tizim hammani kuzatadi, sizdan tashqari.",
"sections": {
"product": "Mahsulot",
"resources": "Resurslar",
"legal": "Huquqiy"
},
"links": {
"features": "Imkoniyatlar",
"pricing": "Narxlar",
"faq": "Savollar",
"protocol": "Protokol",
"comms": "Blog",
"privacy": "Maxfiylik",
"terms": "Shartlar",
"profile": "Profil"
},
"rights": "© 2026 Netrunner VPN. Barcha paketlar shifrlangan.",
"protocolStatus": "PROTOKOL // SHIFRLANGAN KIRISH",
"uplinkStatus": "Uplink Holati",
"activeNode": "Faol Tugun",
"latency": "Kechikish (RTT)"
},
"download": {
"title": "Tugunni Joylashtirish",
"subtitle": "Netrunner klientini o'rnatish uchun platformangizni tanlang.",
"badges": {
"active": "Barqaror Versiya",
"dev": "Ishlab Chiqilmoqda"
},
"buttons": {
"direct": "To'g'ridan-to'g'ri Yuklash",
"store": "Do'kon Havolasi",
"github": "Manba Kodi",
"notify": "Menga Xabar Bering",
"soon": "Tez Orada Do'konda",
"pending": "Build Kutilmoqda"
},
"platforms": {
"windows": "Windows 10 / 11",
"android": "Android 8.0+",
"linux": "Ubuntu / Debian / Arch",
"macos": "macOS 12.0+",
"ios": "iOS 15.0+"
},
"accessingBuild": "Build serveriga ulanmoqda..."
},
"terminal": {
"title": "GLOBAL ALOQA KANALI",
"description": "Ochiq aloqa kanali. Cheksiz tarix.",
"alias": "TAXALLUS",
"message": "MA'LUMOT KIRITING...",
"send": "TARQATISH",
"systemReady": "SYSTEM_READY // UPLINK_SECURED",
"loadingHistory": "Arxiv yuklanmoqda...",
"noMoreLogs": "ARXIV OXIRIGA YETILDI",
"encrypting": "SHIFRLANMOQDA...",
"cooldown": "KUTISH: {{time}}s",
"manifestoTitle": "ERKIN ALOQA KANALI MANIFESTI // v1.0",
"manifestoText": "Ushbu terminal Netrunner yadrosiga to'g'ridan-to'g'ri kanaldir. Biz jurnallarni tsenzurasiz va real vaqtda tarqatamiz, chunki DPI'ni chetlab o'tishga qodir protokol haqiqatdan qo'rqmasligi kerak.",
"rulesTitle": "XULQ-ATVOR PROTOKOLI:",
"rule1": "— Oldindan moderatsiya yo'q. Xabaringiz darhol tarmoqqa yetib boradi.",
"rule2": "— VPN'ni tanqid qilish, xato haqida xabar berish yoki qiyin savollar — barchasiga ruxsat.",
"rule3": "— Yagona cheklov: boshqa tarmoq a'zolarini to'g'ridan-to'g'ri haqorat qilish va spam. Trol emas, kiberpank bo'ling.",
"statusReady": "UPLINK_ESTABLISHED // NO_FILTERS_DETECTED"
},
"loading": {
"establishing": "Ulanish o'rnatilmoqda"
},
"notFound": {
"errorBadge": "[ Xato: Tugun Topilmadi ]",
"message": "Marshrutlash jadvalida so'ralgan tugun yo'q. Uzatish bekor qilindi. Xavfsiz tarmoqqa qayting.",
"returnHome": "Bosh Sahifaga Qaytish"
},
"nodes": {
"title": "Global Tugunlar Tarmog'i",
"subtitle": "Netrunner serverlarining real vaqtdagi topologiyasi.",
"status": {
"online": "Onlayn",
"full": "To'liq",
"maintenance": "Texnik xizmat"
},
"labels": {
"load": "Server Yuki",
"latency": "Kechikish"
}
},
"guestbook": {
"title": "COMM-LINK_",
"subtitle": "Aloqa Terminali",
"description": "Netrunner tugunlariga to'g'ridan-to'g'ri kanal.",
"philosophyTitle": "Nol Tsenzura",
"philosophyText": "Biz Netrunner'ni barcha to'siqlarni chetlab o'tish uchun yaratdik, shuning uchun so'zlaringizdan qo'rqmaymiz. Bu lenta so'z erkinligining tirik dalilidir. Boshqalar fikr-mulohazalarni yashirganda, biz uni real vaqtda translyatsiya qilamiz. Agar bizning protokolimiz DPI'ga qarshi tursa, u sizning samimiyligingizga ham qarshi turadi.",
"liveFeed": "Jonli Translyatsiya (Oxirgi 50)",
"noTransmissions": "Faol uzatishlar topilmadi.",
"labelAlias": "Taxallus",
"labelPayload": "Mazmun",
"placeholderAlias": "Guest_77",
"placeholderPayload": "Uzatish ma'lumotlarini kiriting...",
"statusSuccess": "Ma'lumot muvaffaqiyatli yetkazildi.",
"statusErrorBreach": "Xavfsizlik buzilishi. Ulanish uzildi.",
"statusErrorEmpty": "Taxallus va mazmun talab qilinadi.",
"statusErrorSize": "Mazmun maksimal hajmdan oshib ketdi.",
"statusErrorFirewall": "Firewall faol. {{time}}s kuting.",
"statusErrorOffline": "Uzatish muvaffaqiyatsiz tugadi. Tugun oflayn.",
"statusErrorFilter": "⛔ Rad etildi: Xabaringiz xavfsizlik siyosatimizni buzadi (haqoratli so'zlar, spam yoki zararli mazmun). Iltimos, qayta yozing.",
"btnEncrypting": "Shifrlanmoqda...",
"btnBroadcast": "Tarqatish",
"cooldown": "Kutish: {{time}}s"
},
"auth": {
"title": "UPLINK AVTORIZATSIYASI",
"processing": "Kirish ma'lumotlari qayta ishlanmoqda...",
"verifying": "Kriptografik imzo tekshirilmoqda...",
"granted": "KIRISH RUXSAT ETILDI",
"denied": "KIRISH RAD ETILDI",
"noToken": "Avtorizatsiya tokeni topilmadi. Iltimos, Telegram'dagi Netrunner Terminali orqali autentifikatsiyadan o'ting.",
"redirecting": "Xavfsiz bo'limga yo'naltirilmoqda...",
"toBot": "Telegram Terminalini Ochish",
"awaiting": "KIRISH MA'LUMOTLARI KUTILMOQDA",
"authPrompt": "Boshqaruv paneliga kirish uchun Telegram orqali xavfsiz ulanishni ishga tushiring.",
"logs": {
"waitInit": "> [KUTILMOQDA] TELEGRAM XAVFSIZ KIRISHI BOSHLANMOQDA...",
"sigValid": "> [OK] IMZO HAQIQIY",
"apiRejected": "> [XATO] API TOKENNI RAD ETDI",
"coreOffline": "> [XATO] YADRO OFLAYN",
"authPayload": "> [AUTH] TELEGRAM MA'LUMOTLARI QABUL QILINDI",
"verifyHmac": "> HMAC-SHA256 IMZOSI TEKSHIRILMOQDA...",
"invalidSig": "> [XATO] IMZO NOTO'G'RI YOKI MUDDATI O'TGAN",
"networkError": "> [XATO] TARMOQ XATOSI"
}
},
"docs": {
"establishing": "Xavfsiz aloqa o'rnatilmoqda...",
"vaultTitle": "DATA_VAULT",
"vaultSubtitle": "Tizim jurnallari va hujjatlar // V4.2",
"searchPlaceholder": "root@nrxp:~# grep -i ...",
"empty": "Ushbu so'rov uchun 0 ta yozuv topildi",
"closeSession": "Sessiyani Yopish",
"noContent": "MAZMUN_TOPILMADI",
"eof": "EOF"
},
"speedtest": {
"title": "Uplink Diagnostikasi",
"mockMode": "Lokal Simulyatsiya Rejimi",
"prodMode": "Netrunner tarmoq shlyuziga nisbatan to'g'ridan-to'g'ri o'lchov.",
"btnError": "KETMA-KETLIKNI QAYTA URINISH",
"btnDone": "UPLINK'NI QAYTA ISHGA TUSHIRISH",
"btnIdle": "TESTNI BOSHLASH",
"statusPinging": "PING QILINMOQDA",
"statusDownloading": "YUKLAB OLINMOQDA",
"statusUploading": "YUKLANMOQDA",
"latency": "Kechikish",
"download": "Yuklab olish",
"upload": "Yuklash",
"unitMs": "ms",
"unitMbps": "Mbps"
}
}
+244
View File
@@ -0,0 +1,244 @@
{
"nav": {
"features": "Tính năng",
"pricing": "Bảng giá",
"faq": "Hỏi đáp",
"blog": "Nhật ký",
"terminal": "Terminal",
"docs": "Tài liệu",
"cta": "Tải Netrunner",
"profile": "Hồ sơ",
"menu": "Menu.exe",
"speedtest": "Chẩn đoán",
"doom": "DOOM OS"
},
"hero": {
"badge": "Hệ thống đang theo dõi",
"badgeSecure": "Đã thiết lập đường hầm an toàn",
"titleStart": "Hệ thống theo dõi mọi người,",
"titleHighlight": "trừ bạn.",
"subtitle": "Dấu vết số đã ẩn. Nhật ký đã bị xóa. Tốc độ không giới hạn.",
"switchLabel": "Netrunner VPN",
"ctaPrimary": "Tải Ứng Dụng",
"ctaSecondary": "Danh Sách Node"
},
"features": {
"title": "Công Nghệ Truy Cập",
"items": [
{
"title": "Vượt DPI & uTLS",
"description": "Netrunner ngụy trang lưu lượng thành HTTPS thông thường. Hệ thống kiểm tra gói tin sâu chỉ thấy nhiễu trắng."
},
{
"title": "ChaCha20-Poly1305",
"description": "Mã hóa luồng tiên tiến với Perfect Forward Secrecy. Khóa phiên được tạo động."
},
{
"title": "Áp Suất Ngược Thích Ứng",
"description": "Kích thước bộ đệm tự động điều chỉnh theo độ trễ hệ thống. Ngăn chặn tự động các đợt tăng độ trễ đột ngột."
},
{
"title": "Lõi Đa Nền Tảng",
"description": "Lõi Rust cấp thấp đảm bảo tương thích hoàn hảo và không hao tổn tài nguyên trên Android, Windows và Linux. Hỗ trợ macOS và iOS đang được phát triển."
},
{
"title": "Hạ Tầng Riêng",
"description": "Chúng tôi vận hành máy chủ riêng thay vì thuê từ các nhà cung cấp đám mây lớn, với ghép kênh luồng qua các node để có kết nối ổn định và khó bị đưa vào danh sách đen."
}
]
},
"pricing": {
"badge": "Giao Thức Giá",
"title": "Chọn Uplink Của Bạn",
"perMonth": "/tháng",
"recommend": "Đề xuất",
"syncing": "Đang đồng bộ Node...",
"empty": "Không tìm thấy kết nối khả dụng.",
"discount": "TIẾT KIỆM",
"mo": "/tháng",
"billedOnce": "Thanh toán một lần:",
"period1m": "1 tháng",
"period6m": "6 tháng",
"period1y": "1 năm",
"offlineNotice": "Đang hiển thị giá đã lưu gần nhất — hiện không thể kết nối máy chủ."
},
"faq": {
"title": "Truy Vấn Hệ Thống (Hỏi Đáp)",
"empty": "Không tìm thấy bản ghi nào.",
"loadError": "Không tải được — hãy thử tải lại trang."
},
"blog": {
"badge": "Nhật Ký Dữ Liệu",
"title": "Biên Niên Sử Mạng",
"more": "Tất cả bài viết",
"readMore": "Đọc thêm",
"empty": "Không tìm thấy tín hiệu.",
"archive": "Kho Lưu Trữ Terminal",
"systemLogs": "Nhật Ký Hệ Thống",
"emptySector": "[Không tìm thấy bản ghi trong khu vực này]",
"decrypt": "Giải mã bài viết →",
"backBtn": "cd ../logs",
"readTime": "Đọc: 4 phút",
"verified": "Đã xác minh: Netrunner",
"endTransmission": "Kết thúc truyền tải // node_th_01"
},
"footer": {
"brand": "Netrunner VPN",
"tagline": "Hệ thống theo dõi mọi người, trừ bạn.",
"sections": {
"product": "Sản Phẩm",
"resources": "Tài Nguyên",
"legal": "Pháp Lý"
},
"links": {
"features": "Tính năng",
"pricing": "Bảng giá",
"faq": "Hỏi đáp",
"protocol": "Giao thức",
"comms": "Blog",
"privacy": "Quyền riêng tư",
"terms": "Điều khoản",
"profile": "Hồ sơ"
},
"rights": "© 2026 Netrunner VPN. Mọi gói tin đã được mã hóa.",
"protocolStatus": "GIAO THỨC // TRUY CẬP MÃ HÓA",
"uplinkStatus": "Trạng Thái Uplink",
"activeNode": "Node Đang Hoạt Động",
"latency": "Độ Trễ (RTT)"
},
"download": {
"title": "Triển Khai Node",
"subtitle": "Chọn nền tảng của bạn để triển khai ứng dụng Netrunner.",
"badges": {
"active": "Bản Ổn Định",
"dev": "Đang Phát Triển"
},
"buttons": {
"direct": "Tải Trực Tiếp",
"store": "Liên Kết Cửa Hàng",
"github": "Mã Nguồn",
"notify": "Thông Báo Cho Tôi",
"soon": "Sắp Có Trên Store",
"pending": "Đang Chờ Bản Dựng"
},
"platforms": {
"windows": "Windows 10 / 11",
"android": "Android 8.0+",
"linux": "Ubuntu / Debian / Arch",
"macos": "macOS 12.0+",
"ios": "iOS 15.0+"
},
"accessingBuild": "Đang truy cập máy chủ bản dựng..."
},
"terminal": {
"title": "KÊNH LIÊN LẠC TOÀN CẦU",
"description": "Kênh liên lạc công khai. Lịch sử vô hạn.",
"alias": "BÍ DANH",
"message": "NHẬP DỮ LIỆU...",
"send": "PHÁT SÓNG",
"systemReady": "SYSTEM_READY // UPLINK_SECURED",
"loadingHistory": "Đang tải kho lưu trữ...",
"noMoreLogs": "ĐÃ ĐẾN CUỐI KHO LƯU TRỮ",
"encrypting": "ĐANG MÃ HÓA...",
"cooldown": "HỒI CHIÊU: {{time}}s",
"manifestoTitle": "TUYÊN NGÔN KÊNH LIÊN LẠC TỰ DO // v1.0",
"manifestoText": "Terminal này là kênh liên lạc trực tiếp đến lõi Netrunner. Chúng tôi phát sóng nhật ký không kiểm duyệt theo thời gian thực, vì một giao thức có khả năng vượt qua DPI không nên sợ sự thật.",
"rulesTitle": "GIAO THỨC ỨNG XỬ:",
"rule1": "— Không kiểm duyệt trước. Tin nhắn của bạn đến mạng ngay lập tức.",
"rule2": "— Chỉ trích VPN, báo cáo lỗi, hoặc câu hỏi hóc búa — mọi thứ đều được phép.",
"rule3": "— Giới hạn duy nhất: xúc phạm trực tiếp thành viên khác trong mạng và spam. Hãy là cyberpunk, không phải kẻ troll.",
"statusReady": "UPLINK_ESTABLISHED // NO_FILTERS_DETECTED"
},
"loading": {
"establishing": "Đang thiết lập kết nối"
},
"notFound": {
"errorBadge": "[ Lỗi: Không Tìm Thấy Node ]",
"message": "Bảng định tuyến không chứa node được yêu cầu. Việc truyền tải đã bị hủy. Hãy quay lại mạng an toàn.",
"returnHome": "Về Trang Chủ"
},
"nodes": {
"title": "Mạng Lưới Node Toàn Cầu",
"subtitle": "Cấu trúc liên kết thời gian thực của máy chủ Netrunner.",
"status": {
"online": "Trực tuyến",
"full": "Đầy",
"maintenance": "Bảo trì"
},
"labels": {
"load": "Tải Máy Chủ",
"latency": "Độ Trễ"
}
},
"guestbook": {
"title": "COMM-LINK_",
"subtitle": "Terminal Liên Lạc",
"description": "Kênh liên lạc trực tiếp đến các node Netrunner.",
"philosophyTitle": "Không Kiểm Duyệt",
"philosophyText": "Chúng tôi xây dựng Netrunner để vượt qua mọi rào cản, vì vậy chúng tôi không sợ lời nói của bạn. Bảng tin này là minh chứng sống động cho tự do ngôn luận. Trong khi những nơi khác giấu phản hồi, chúng tôi phát trực tiếp theo thời gian thực. Nếu giao thức của chúng tôi chống lại được DPI, nó cũng sẽ chống lại được sự thẳng thắn của bạn.",
"liveFeed": "Bảng Tin Trực Tiếp (50 Gần Nhất)",
"noTransmissions": "Không tìm thấy truyền tải đang hoạt động.",
"labelAlias": "Bí Danh",
"labelPayload": "Nội Dung",
"placeholderAlias": "Guest_77",
"placeholderPayload": "Nhập dữ liệu truyền tải...",
"statusSuccess": "Dữ liệu đã được gửi thành công.",
"statusErrorBreach": "Vi phạm bảo mật. Kết nối đã bị ngắt.",
"statusErrorEmpty": "Cần có bí danh và nội dung.",
"statusErrorSize": "Nội dung vượt quá kích thước tối đa.",
"statusErrorFirewall": "Tường lửa đang hoạt động. Chờ {{time}}s.",
"statusErrorOffline": "Truyền tải thất bại. Node ngoại tuyến.",
"statusErrorFilter": "⛔ Từ chối: Tin nhắn của bạn vi phạm chính sách bảo mật (ngôn từ tục tĩu, spam hoặc nội dung độc hại). Vui lòng diễn đạt lại.",
"btnEncrypting": "Đang mã hóa...",
"btnBroadcast": "Phát sóng",
"cooldown": "Hồi chiêu: {{time}}s"
},
"auth": {
"title": "ỦY QUYỀN UPLINK",
"processing": "Đang xử lý thông tin xác thực...",
"verifying": "Đang xác minh chữ ký mã hóa...",
"granted": "TRUY CẬP ĐƯỢC CẤP",
"denied": "TRUY CẬP BỊ TỪ CHỐI",
"noToken": "Không tìm thấy token ủy quyền. Vui lòng xác thực qua Terminal Netrunner trong Telegram.",
"redirecting": "Đang chuyển hướng đến khu vực an toàn...",
"toBot": "Mở Terminal Telegram",
"awaiting": "ĐANG CHỜ THÔNG TIN XÁC THỰC",
"authPrompt": "Khởi tạo kết nối an toàn qua Telegram để truy cập bảng điều khiển.",
"logs": {
"waitInit": "> [ĐANG CHỜ] KHỞI TẠO ĐĂNG NHẬP AN TOÀN TELEGRAM...",
"sigValid": "> [OK] CHỮ KÝ HỢP LỆ",
"apiRejected": "> [THẤT BẠI] API TỪ CHỐI TOKEN",
"coreOffline": "> [THẤT BẠI] LÕI NGOẠI TUYẾN",
"authPayload": "> [AUTH] ĐÃ NHẬN DỮ LIỆU TELEGRAM",
"verifyHmac": "> ĐANG XÁC MINH CHỮ KÝ HMAC-SHA256...",
"invalidSig": "> [THẤT BẠI] CHỮ KÝ KHÔNG HỢP LỆ HOẶC ĐÃ HẾT HẠN",
"networkError": "> [THẤT BẠI] LỖI MẠNG"
}
},
"docs": {
"establishing": "Đang thiết lập liên kết an toàn...",
"vaultTitle": "DATA_VAULT",
"vaultSubtitle": "Nhật ký hệ thống & tài liệu // V4.2",
"searchPlaceholder": "root@nrxp:~# grep -i ...",
"empty": "Tìm thấy 0 bản ghi cho truy vấn này",
"closeSession": "Đóng Phiên",
"noContent": "KHÔNG_TÌM_THẤY_NỘI_DUNG",
"eof": "EOF"
},
"speedtest": {
"title": "Chẩn Đoán Uplink",
"mockMode": "Chế Độ Mô Phỏng Cục Bộ",
"prodMode": "Đo lường trực tiếp với cổng mạng Netrunner.",
"btnError": "THỬ LẠI TRÌNH TỰ",
"btnDone": "KHỞI ĐỘNG LẠI UPLINK",
"btnIdle": "BẮT ĐẦU KIỂM TRA",
"statusPinging": "ĐANG PING",
"statusDownloading": "ĐANG TẢI XUỐNG",
"statusUploading": "ĐANG TẢI LÊN",
"latency": "Độ Trễ",
"download": "Tải Xuống",
"upload": "Tải Lên",
"unitMs": "ms",
"unitMbps": "Mbps"
}
}
+244
View File
@@ -0,0 +1,244 @@
{
"nav": {
"features": "功能",
"pricing": "价格",
"faq": "常见问题",
"blog": "日志",
"terminal": "终端",
"docs": "文档",
"cta": "获取 Netrunner",
"profile": "个人资料",
"menu": "Menu.exe",
"speedtest": "诊断",
"doom": "DOOM OS"
},
"hero": {
"badge": "系统正在监视",
"badgeSecure": "安全隧道已建立",
"titleStart": "系统监视着所有人,",
"titleHighlight": "除了你。",
"subtitle": "数字足迹已隐藏。日志已销毁。速度无限制。",
"switchLabel": "Netrunner VPN",
"ctaPrimary": "下载客户端",
"ctaSecondary": "节点列表"
},
"features": {
"title": "接入技术",
"items": [
{
"title": "DPI 规避与 uTLS",
"description": "Netrunner 将流量伪装成标准 HTTPS。深度包检测系统只能看到白噪声。"
},
{
"title": "ChaCha20-Poly1305",
"description": "具有完美前向保密的先进流加密技术。会话密钥动态生成。"
},
{
"title": "自适应背压",
"description": "缓冲区大小根据系统往返时延动态调整,自动防止延迟峰值。"
},
{
"title": "多平台核心",
"description": "底层 Rust 核心确保在 Android、Windows 和 Linux 上完美兼容且零开销。macOS 和 iOS 支持正在开发中。"
},
{
"title": "自有基础设施",
"description": "我们运行自己的服务器,而非租用主流云服务商——通过跨节点流量复用实现稳定且难以被封锁的连接。"
}
]
},
"pricing": {
"badge": "定价协议",
"title": "选择你的 Uplink",
"perMonth": "/月",
"recommend": "推荐",
"syncing": "正在同步节点...",
"empty": "未找到可用连接。",
"discount": "优惠",
"mo": "/月",
"billedOnce": "一次性扣款:",
"period1m": "1 个月",
"period6m": "6 个月",
"period1y": "1 年",
"offlineNotice": "显示上次保存的价格——当前无法连接服务器。"
},
"faq": {
"title": "系统查询(常见问题)",
"empty": "未找到记录。",
"loadError": "加载失败——请尝试刷新页面。"
},
"blog": {
"badge": "数据日志",
"title": "网络编年史",
"more": "全部条目",
"readMore": "阅读更多",
"empty": "未找到信号。",
"archive": "终端存档",
"systemLogs": "系统日志",
"emptySector": "[此扇区未找到记录]",
"decrypt": "解密条目 →",
"backBtn": "cd ../logs",
"readTime": "阅读:4 分钟",
"verified": "已验证:Netrunner",
"endTransmission": "传输结束 // node_th_01"
},
"footer": {
"brand": "Netrunner VPN",
"tagline": "系统监视着所有人,除了你。",
"sections": {
"product": "产品",
"resources": "资源",
"legal": "法律"
},
"links": {
"features": "功能",
"pricing": "价格",
"faq": "常见问题",
"protocol": "协议",
"comms": "博客",
"privacy": "隐私",
"terms": "条款",
"profile": "个人资料"
},
"rights": "© 2026 Netrunner VPN。所有数据包均已加密。",
"protocolStatus": "协议 // 加密访问",
"uplinkStatus": "Uplink 状态",
"activeNode": "活跃节点",
"latency": "延迟 (RTT)"
},
"download": {
"title": "节点部署",
"subtitle": "选择你的平台以部署 Netrunner 客户端。",
"badges": {
"active": "稳定版",
"dev": "开发中"
},
"buttons": {
"direct": "直接下载",
"store": "商店链接",
"github": "源代码",
"notify": "提醒我",
"soon": "即将上架",
"pending": "构建中"
},
"platforms": {
"windows": "Windows 10 / 11",
"android": "Android 8.0+",
"linux": "Ubuntu / Debian / Arch",
"macos": "macOS 12.0+",
"ios": "iOS 15.0+"
},
"accessingBuild": "正在访问构建服务器..."
},
"terminal": {
"title": "全球通讯链路",
"description": "公共通讯频道。历史记录无限。",
"alias": "代号",
"message": "输入数据...",
"send": "广播",
"systemReady": "SYSTEM_READY // UPLINK_SECURED",
"loadingHistory": "正在加载存档...",
"noMoreLogs": "已到达存档末尾",
"encrypting": "加密中...",
"cooldown": "冷却中:{{time}}秒",
"manifestoTitle": "自由通讯宣言 // v1.0",
"manifestoText": "该终端是通往 Netrunner 核心的直接链路。我们实时无审查地广播日志,因为一个能够绕过 DPI 的协议不应惧怕真相。",
"rulesTitle": "行为准则:",
"rule1": "— 无需预审核。你的消息将立即传入网络。",
"rule2": "— 批评 VPN、提交错误报告或提出尖锐问题——一切皆可。",
"rule3": "— 唯一的限制:直接侮辱其他网络成员及垃圾信息。做一个赛博朋克,而不是喷子。",
"statusReady": "UPLINK_ESTABLISHED // NO_FILTERS_DETECTED"
},
"loading": {
"establishing": "正在建立连接"
},
"notFound": {
"errorBadge": "[ 错误:未找到节点 ]",
"message": "路由表中不存在所请求的节点。传输已中断。请返回安全网络。",
"returnHome": "返回主页"
},
"nodes": {
"title": "全球节点网络",
"subtitle": "Netrunner 服务器的实时拓扑。",
"status": {
"online": "在线",
"full": "已满",
"maintenance": "维护中"
},
"labels": {
"load": "服务器负载",
"latency": "延迟"
}
},
"guestbook": {
"title": "COMM-LINK_",
"subtitle": "通讯终端",
"description": "与 Netrunner 节点的直接链路。",
"philosophyTitle": "零审查",
"philosophyText": "我们打造 Netrunner 就是为了突破一切壁垒,所以我们不惧怕你的言论。这条动态是言论自由的活见证。当别人隐藏反馈时,我们实时展示它。如果我们的协议能对抗 DPI,它也能承受你的坦诚。",
"liveFeed": "实时动态(最近 50 条)",
"noTransmissions": "未发现活跃传输。",
"labelAlias": "代号",
"labelPayload": "内容",
"placeholderAlias": "Guest_77",
"placeholderPayload": "输入传输数据...",
"statusSuccess": "数据已成功送达。",
"statusErrorBreach": "安全漏洞。连接已断开。",
"statusErrorEmpty": "需要代号和内容。",
"statusErrorSize": "数据超出最大大小限制。",
"statusErrorFirewall": "防火墙已启用。请等待 {{time}} 秒。",
"statusErrorOffline": "传输失败。节点离线。",
"statusErrorFilter": "⛔ 已拒绝:你的消息违反了我们的安全策略(粗俗语言、垃圾信息或攻击性内容)。请修改后重试。",
"btnEncrypting": "加密中...",
"btnBroadcast": "广播",
"cooldown": "冷却:{{time}}秒"
},
"auth": {
"title": "UPLINK 授权",
"processing": "正在处理凭证...",
"verifying": "正在验证加密签名...",
"granted": "访问已授权",
"denied": "访问被拒绝",
"noToken": "未找到授权令牌。请通过 Telegram 中的 Netrunner 终端进行身份验证。",
"redirecting": "正在重定向至安全区域...",
"toBot": "打开 Telegram 终端",
"awaiting": "等待凭证",
"authPrompt": "通过 Telegram 初始化安全连接以访问控制面板。",
"logs": {
"waitInit": "> [等待] 正在初始化 TELEGRAM 安全登录...",
"sigValid": "> [OK] 签名有效",
"apiRejected": "> [失败] API 拒绝了令牌",
"coreOffline": "> [失败] 核心离线",
"authPayload": "> [AUTH] 已接收 TELEGRAM 数据",
"verifyHmac": "> 正在验证 HMAC-SHA256 签名...",
"invalidSig": "> [失败] 签名无效或已过期",
"networkError": "> [失败] 网络错误"
}
},
"docs": {
"establishing": "正在建立安全链接...",
"vaultTitle": "DATA_VAULT",
"vaultSubtitle": "系统日志与文档 // V4.2",
"searchPlaceholder": "root@nrxp:~# grep -i ...",
"empty": "未找到与查询匹配的记录",
"closeSession": "关闭会话",
"noContent": "未找到内容",
"eof": "EOF"
},
"speedtest": {
"title": "Uplink 诊断",
"mockMode": "本地模拟模式",
"prodMode": "针对 Netrunner 网络网关的直接测量。",
"btnError": "重试序列",
"btnDone": "重新启动 UPLINK",
"btnIdle": "开始测试",
"statusPinging": "正在测试延迟",
"statusDownloading": "正在下载",
"statusUploading": "正在上传",
"latency": "延迟",
"download": "下载",
"upload": "上传",
"unitMs": "毫秒",
"unitMbps": "Mbps"
}
}
+10 -9
View File
@@ -12,7 +12,7 @@
services: services:
# Фронтенд (Next.js) # Фронтенд (Next.js)
landing: landing:
image: ${IMAGE:-ghcr.io/nineap/netrunner-landing:latest} image: ${IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-landing:latest}
container_name: netrunner-landing-dev container_name: netrunner-landing-dev
restart: always restart: always
ports: ports:
@@ -33,17 +33,18 @@ services:
- NEXT_PUBLIC_PB_URL=http://dev.netrunner-vpn.com:8090/cms-api - NEXT_PUBLIC_PB_URL=http://dev.netrunner-vpn.com:8090/cms-api
- NEXT_PUBLIC_PROXY_URL=http://dev.netrunner-vpn.com:3001 - NEXT_PUBLIC_PROXY_URL=http://dev.netrunner-vpn.com:3001
- PB_INTERNAL_URL=http://netrunner-cms:8090 - PB_INTERNAL_URL=http://netrunner-cms:8090
# Единая авторизация: смотрим на dev-бэкенд через Caddy (порт 8443, # Единая авторизация: смотрим на dev-бэкенд через Caddy (порт 443,
# настоящий TLS) — НЕ напрямую на :8080. Бэкенд на dev теперь ставит # настоящий TLS) — НЕ напрямую на :8080. Бэкенд на dev теперь ставит
# сессионные cookie с атрибутом Secure (как в проде, раз уже есть TLS), # сессионные cookie с атрибутом Secure (как в проде, раз уже есть TLS),
# такую cookie браузер примет только с ответа по HTTPS; см. # такую cookie браузер примет только с ответа по HTTPS; см.
# netrunner-backend WEB_APP_BASE_URL/COOKIE_SECURE в .env.dev-remote.example # netrunner-backend WEB_APP_BASE_URL/COOKIE_SECURE в .env.dev-remote.example
# и Caddyfile.dev (Caddy нужен ради Telegram Web Apps, которым обязателен # и Caddyfile.dev (Caddy нужен и ради Telegram Web Apps, которым обязателен
# https://). Без NEXT_PUBLIC_ — читаются на сервере (app/[lang]/auth/page.tsx), # https://, и ради Telegram Login Widget, которому в браузере обязателен
# поэтому реально применяются в рантайме этого образа, а не только # именно порт 443). Без NEXT_PUBLIC_ — читаются на сервере
# прод-образа, собранного с другими значениями. # (app/[lang]/auth/page.tsx), поэтому реально применяются в рантайме
- ACCOUNT_ORIGIN=https://dev.netrunner-vpn.com:8443 # этого образа, а не только прод-образа, собранного с другими значениями.
- API_URL=https://dev.netrunner-vpn.com:8443/api/v1 - ACCOUNT_ORIGIN=https://dev.netrunner-vpn.com
- API_URL=https://dev.netrunner-vpn.com/api/v1
# ОБЯЗАТЕЛЬНО задать явно (без дефолта на прод-бота!) в .env на сервере — # ОБЯЗАТЕЛЬНО задать явно (без дефолта на прод-бота!) в .env на сервере —
# отдельный dev-бот, заведённый через @BotFather специально под # отдельный dev-бот, заведённый через @BotFather специально под
# dev.netrunner-vpn.com. Молчаливый фолбэк на прод-бота приводил ровно # dev.netrunner-vpn.com. Молчаливый фолбэк на прод-бота приводил ровно
@@ -72,7 +73,7 @@ services:
# Прокси-слой (Rust) # Прокси-слой (Rust)
ws-proxy: ws-proxy:
image: ghcr.io/nineap/netrunner-landing-proxy:latest image: gitea.netrunner-vpn.com/nineap/netrunner-landing-proxy:latest
container_name: netrunner-ws-proxy-dev container_name: netrunner-ws-proxy-dev
restart: always restart: always
ports: ports:
+13 -4
View File
@@ -1,11 +1,17 @@
services: services:
# Фронтенд (Next.js) # Фронтенд (Next.js)
landing: landing:
image: ghcr.io/nineap/netrunner-landing:latest image: gitea.netrunner-vpn.com/nineap/netrunner-landing:latest
container_name: netrunner-landing container_name: netrunner-landing
restart: always restart: always
# Не 127.0.0.1 — /api/metrics (см. app/api/metrics/route.ts) должен
# скрейпиться удалённым центральным Prometheus (см.
# netrunner-data/docker-compose.observability.yml). Порт 3000 в целом уже
# не секрет (это тот же самый сайт, что и по HTTPS через реверс-прокси
# перед ним) — но ОБЯЗАТЕЛЬНО зафайрволить на этом VPS на IP
# observability-сервера, чтобы не плодить лишний голый-HTTP путь к сайту.
ports: ports:
- "127.0.0.1:3000:3000" - "3000:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
# PB_PUBLIC_URL — адрес PocketBase для клиентских компонентов (Pricing, # PB_PUBLIC_URL — адрес PocketBase для клиентских компонентов (Pricing,
@@ -48,11 +54,14 @@ services:
# Прокси-слой (Rust) # Прокси-слой (Rust)
ws-proxy: ws-proxy:
image: ghcr.io/nineap/netrunner-landing-proxy:latest image: gitea.netrunner-vpn.com/nineap/netrunner-landing-proxy:latest
container_name: netrunner-ws-proxy container_name: netrunner-ws-proxy
restart: always restart: always
# Не 127.0.0.1 — /metrics (см. proxy-ws/src/main.rs) должен скрейпиться
# удалённым центральным Prometheus. Обязательно зафайрволить на IP
# observability-сервера (см. netrunner-data/docker-compose.observability.yml).
ports: ports:
- "127.0.0.1:3001:3001" - "3001:3001"
environment: environment:
- PB_INTERNAL_URL=http://netrunner-cms:8090 - PB_INTERNAL_URL=http://netrunner-cms:8090
depends_on: depends_on:
+5
View File
@@ -39,10 +39,15 @@ export interface Dictionary {
discount: string; discount: string;
mo: string; mo: string;
billedOnce: string; billedOnce: string;
period1m: string;
period6m: string;
period1y: string;
offlineNotice: string;
}; };
faq: { faq: {
title: string; title: string;
empty: string; empty: string;
loadError: string;
}; };
blog: { blog: {
badge: string; badge: string;
+9
View File
@@ -3,6 +3,15 @@ import "server-only";
const dictionaries = { const dictionaries = {
en: () => import("@/dictionaries/en.json").then((module) => module.default), en: () => import("@/dictionaries/en.json").then((module) => module.default),
ru: () => import("@/dictionaries/ru.json").then((module) => module.default), ru: () => import("@/dictionaries/ru.json").then((module) => module.default),
"zh-CN": () =>
import("@/dictionaries/zh-CN.json").then((module) => module.default),
fa: () => import("@/dictionaries/fa.json").then((module) => module.default),
ar: () => import("@/dictionaries/ar.json").then((module) => module.default),
tr: () => import("@/dictionaries/tr.json").then((module) => module.default),
uk: () => import("@/dictionaries/uk.json").then((module) => module.default),
be: () => import("@/dictionaries/be.json").then((module) => module.default),
vi: () => import("@/dictionaries/vi.json").then((module) => module.default),
uz: () => import("@/dictionaries/uz.json").then((module) => module.default),
}; };
// Исправляем типизацию и доступ // Исправляем типизацию и доступ
+34
View File
@@ -0,0 +1,34 @@
// Единый источник правды для поддерживаемых локалей — используется и в
// edge-мидлваре (proxy.ts), и в клиентских компонентах (language-switcher.tsx).
// Персидский/арабский — RTL, см. RTL_LOCALES и layout.tsx.
export const locales = [
"en",
"ru",
"zh-CN",
"fa",
"ar",
"tr",
"uk",
"be",
"vi",
"uz",
] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export const RTL_LOCALES: Locale[] = ["ar", "fa"];
export const LOCALE_LABELS: Record<Locale, string> = {
en: "English",
ru: "Русский",
"zh-CN": "中文",
fa: "فارسی",
ar: "العربية",
tr: "Türkçe",
uk: "Українська",
be: "Беларуская",
vi: "Tiếng Việt",
uz: "Oʻzbekcha",
};
+350
View File
@@ -0,0 +1,350 @@
"use client";
import React, { useEffect, useRef } from "react";
import { createProgram } from "./gl-utils.js";
/**
* Стеклянный оверлей поверх матрицы, показывается только когда `active`
* (состояние "глаза закрыты" — см. isVpnOn в Hero.tsx). Бить можно кликом/тапом
* в любом месте панели: каждый удар добавляет трещины, растущие из точки
* удара; после нескольких ударов стекло осыпается и через паузу собирается
* заново целым — эффект зациклен, стекло можно бить сколько угодно раз.
*
* Не связан с WASM-движком матрицы: работает на main thread (один
* полноэкранный quad, без инстансинга — GPU-нагрузка пренебрежимо мала),
* это отдельный DOM-канвас, наложенный поверх канваса матрицы обычным
* порядком отрисовки браузера (в отличие от фонового слоя глубины,
* встроенного внутрь matrix_worker.js — см. комментарий у fsBackground в
* shaders.js: слою СВЕРХУ не нужно бороться с alpha:false соседнего канваса,
* это ограничение работает только "сквозь" элемент, а не поверх него).
*/
const vs = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}`.trim();
const fs = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 outColor;
uniform float u_time;
uniform vec2 u_resolution;
uniform vec3 u_impacts[6]; // xy - точка удара (uv, y вверх), z - момент удара (сек), <=0 - слот пуст
uniform float u_shardDisplace; // 0 = стекло целое, 1 = осколки полностью разлетелись/исчезли
uniform float u_paneAlpha; // общая непрозрачность панели (fade-in при пересборке)
// Стекло показывается ТОЛЬКО когда isVpnOn=true (глаза закрыты, см. Hero.tsx:
// active={isVpnOn}), поэтому акцент всегда фиолетовый (та же палитра "vpn on",
// что в fsBackground/атласе капель) — отдельный uniform под циан не нужен.
const vec3 ACCENT = vec3(0.75, 0.1, 1.0);
vec2 hash2(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453123);
}
float hash1(vec2 p) { return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453123); }
void main() {
vec2 uv = v_uv;
vec2 aspect = vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0);
// Фон панели до первого удара — тонкая горизонтальная скан-линия вместо
// "стеклянного" диагонального блика: тот же приём, что CRT-скан-линии в
// postProcessSource, а не типовой шейдерный клише-рефлекс стекла.
float scan = sin(uv.y * u_resolution.y * 1.4 + u_time * 2.0) * 0.5 + 0.5;
vec3 col = ACCENT * scan * 0.02;
float alpha = scan * 0.02 + 0.01;
// --- Ячейки Вороного = осколки стекла ---
vec2 cellSize = vec2(0.085);
vec2 gridUv = uv / cellSize;
vec2 gridId = floor(gridUv);
vec2 gridF = fract(gridUv);
float minDist1 = 8.0, minDist2 = 8.0;
vec2 cellSeed1 = vec2(0.0);
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 neighbor = vec2(float(x), float(y));
vec2 cell = gridId + neighbor;
vec2 point = neighbor + hash2(cell) - gridF;
float d = dot(point, point);
if (d < minDist1) {
minDist2 = minDist1;
minDist1 = d;
cellSeed1 = cell;
} else if (d < minDist2) {
minDist2 = d;
}
}
}
float cellEdge = sqrt(minDist2) - sqrt(minDist1);
// --- Влияние ударов: трещины растут кругом от каждой точки удара во времени ---
float influence = 0.0;
for (int i = 0; i < 6; i++) {
vec3 imp = u_impacts[i];
if (imp.z <= 0.0) continue;
float age = u_time - imp.z;
if (age < 0.0) continue;
// Трещина быстро "выстреливает" до локального радиуса и на нём
// застывает — один удар даёт компактное пятно, а не весь экран;
// полное покрытие достигается только накоплением НЕСКОЛЬКИХ ударов.
float radius = 0.15 * (1.0 - exp(-age * 8.0));
float d = length((uv - imp.xy) * aspect);
influence = max(influence, smoothstep(radius, radius - 0.06, d));
}
// Резкая линия трещины + мягкое фиолетовое свечение вокруг неё (bloom),
// тем же акцентным цветом — на прежних Вороных-гранях, но теперь в
// основной палитре сайта и с лёгким неоновым ореолом вместо плоской линии.
float crackLine = smoothstep(0.05, 0.0, cellEdge) * influence;
float crackGlow = exp(-max(cellEdge, 0.0) / 0.05) * influence * 0.5;
col += ACCENT * crackGlow;
col = mix(col, ACCENT * 1.3, crackLine * 0.85);
alpha = max(alpha, max(crackLine * 0.6, crackGlow * 0.35));
// Едва заметная "мозаика" осколков внутри уже треснувшей зоны.
col += ACCENT * (hash1(cellSeed1) - 0.5) * 0.05 * influence;
alpha += influence * 0.015;
float crack = crackLine;
// --- Разлёт осколков при полном разбитии ---
if (u_shardDisplace > 0.001) {
float fall = u_shardDisplace * u_shardDisplace;
alpha *= max(0.0, 1.0 - fall);
col += ACCENT * fall * 0.3 * step(0.001, crack + influence * 0.3);
}
outColor = vec4(col, clamp(alpha, 0.0, 1.0) * u_paneAlpha);
}`.trim();
const MAX_IMPACTS = 6;
const SHATTER_AFTER_HITS = 6;
const BREAK_DURATION = 0.9;
const REFORM_DURATION = 0.7;
export interface GlassShatterProps {
active?: boolean;
}
export const GlassShatter: React.FC<GlassShatterProps> = ({
active = false,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(active);
const glRef = useRef<WebGL2RenderingContext | null>(null);
const startTimeRef = useRef(0);
// Пул точек удара (как Shockwave-пул в lib.rs): t0 <= 0 значит слот свободен.
const impactsRef = useRef(
Array.from({ length: MAX_IMPACTS }, () => ({ x: 0, y: 0, t0: 0 })),
);
const hitCountRef = useRef(0);
const phaseRef = useRef<"idle" | "breaking" | "reforming">("idle");
const phaseStartRef = useRef(0);
useEffect(() => {
activeRef.current = active;
if (active) {
// Каждое новое появление стекла (переход в состояние "глаза закрыты") —
// свежая целая панель, а не продолжение прошлого разбития.
hitCountRef.current = 0;
phaseRef.current = "idle";
impactsRef.current.forEach((imp) => (imp.t0 = 0));
}
}, [active]);
useEffect(() => {
const container = containerRef.current;
if (!container || typeof window === "undefined") return;
const canvas = document.createElement("canvas");
Object.assign(canvas.style, {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
display: "block",
pointerEvents: "none",
transition: "opacity 0.6s ease-in-out",
opacity: "0",
});
container.appendChild(canvas);
const gl = canvas.getContext("webgl2", {
alpha: true,
premultipliedAlpha: false,
antialias: false,
});
if (!gl) return;
glRef.current = gl;
const program = createProgram(gl, vs, fs);
if (!program) return;
gl.useProgram(program);
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const aPos = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const uniforms = {
time: gl.getUniformLocation(program, "u_time"),
res: gl.getUniformLocation(program, "u_resolution"),
impacts: gl.getUniformLocation(program, "u_impacts[0]"),
shardDisplace: gl.getUniformLocation(program, "u_shardDisplace"),
paneAlpha: gl.getUniformLocation(program, "u_paneAlpha"),
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, container.clientWidth * dpr);
canvas.height = Math.max(1, container.clientHeight * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
};
resize();
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
startTimeRef.current = performance.now();
let visible = false;
let rafId = 0;
let paused = document.hidden;
const draw = (now: number) => {
rafId = requestAnimationFrame(draw);
if (paused) return;
const t = (now - startTimeRef.current) / 1000;
const isActive = activeRef.current;
if (isActive !== visible) {
visible = isActive;
canvas.style.opacity = visible ? "1" : "0";
}
if (!isActive) return;
const phase = phaseRef.current;
let shardDisplace = 0;
let paneAlpha = 1;
if (phase === "breaking") {
const dt = t - phaseStartRef.current;
shardDisplace = Math.min(1, dt / BREAK_DURATION);
if (shardDisplace >= 1) {
phaseRef.current = "reforming";
phaseStartRef.current = t;
impactsRef.current.forEach((imp) => (imp.t0 = 0));
hitCountRef.current = 0;
}
} else if (phase === "reforming") {
const dt = t - phaseStartRef.current;
paneAlpha = Math.min(1, dt / REFORM_DURATION);
if (dt >= REFORM_DURATION) phaseRef.current = "idle";
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(uniforms.time, t);
gl.uniform2f(uniforms.res, canvas.width, canvas.height);
gl.uniform1f(uniforms.shardDisplace, shardDisplace);
gl.uniform1f(uniforms.paneAlpha, paneAlpha);
const flat = new Float32Array(MAX_IMPACTS * 3);
impactsRef.current.forEach((imp, i) => {
flat[i * 3] = imp.x;
flat[i * 3 + 1] = imp.y;
flat[i * 3 + 2] = imp.t0;
});
gl.uniform3fv(uniforms.impacts, flat);
gl.drawArrays(gl.TRIANGLES, 0, 6);
};
rafId = requestAnimationFrame(draw);
const onVisibilityChange = () => {
paused = document.hidden;
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
resizeObserver.disconnect();
cancelAnimationFrame(rafId);
if (container.contains(canvas)) container.removeChild(canvas);
glRef.current = null;
};
}, []);
// Клики/тапы по панели — окно, а не сам канвас (у канваса pointer-events:
// none, чтобы не перехватывать переключатель VPN и кнопки, см. тот же
// приём в NetrunnerMatrix.tsx).
useEffect(() => {
const container = containerRef.current;
if (!container || typeof window === "undefined") return;
const registerImpact = (clientX: number, clientY: number) => {
if (!activeRef.current || phaseRef.current !== "idle") return;
const rect = container.getBoundingClientRect();
const px = clientX - rect.left;
const py = clientY - rect.top;
if (px < 0 || py < 0 || px > rect.width || py > rect.height) return;
const t = (performance.now() - startTimeRef.current) / 1000;
const impacts = impactsRef.current;
let slot = impacts.find((imp) => imp.t0 <= 0);
if (!slot) {
slot = impacts.reduce((oldest, imp) =>
imp.t0 < oldest.t0 ? imp : oldest,
);
}
slot.x = px / rect.width;
slot.y = 1 - py / rect.height;
slot.t0 = t;
hitCountRef.current += 1;
if (hitCountRef.current >= SHATTER_AFTER_HITS) {
phaseRef.current = "breaking";
phaseStartRef.current = t;
}
};
const handlePointerDown = (e: PointerEvent | TouchEvent) => {
if (
e.target instanceof Element &&
e.target.closest("button, a, input, textarea, label")
)
return;
const point = "touches" in e ? e.touches[0] : e;
if (!point) return;
registerImpact(point.clientX, point.clientY);
};
const options = { passive: true };
window.addEventListener("pointerdown", handlePointerDown as EventListener, options);
window.addEventListener("touchstart", handlePointerDown as EventListener, options);
return () => {
window.removeEventListener("pointerdown", handlePointerDown as EventListener);
window.removeEventListener("touchstart", handlePointerDown as EventListener);
};
}, []);
return (
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden pointer-events-none"
></div>
);
};
+1 -1
View File
@@ -5,7 +5,7 @@ import React, { useRef, useEffect, useState, useCallback } from "react";
// Версия движка, подставляется query-параметром при загрузке воркера и WASM // Версия движка, подставляется query-параметром при загрузке воркера и WASM
// модуля (?v=...) — простой cache-busting при выкладке новой сборки // модуля (?v=...) — простой cache-busting при выкладке новой сборки
// matrix-engine, чтобы браузер/CDN не отдавали закэшированный старый файл. // matrix-engine, чтобы браузер/CDN не отдавали закэшированный старый файл.
const ENGINE_VERSION = "3.0.4"; const ENGINE_VERSION = "3.1.5";
export interface NetrunnerMatrixProps { export interface NetrunnerMatrixProps {
isSecure?: boolean; isSecure?: boolean;
+30
View File
@@ -126,3 +126,33 @@ wasm-pack build --target web --release
В Docker-образе аналогичная сборка (`wasm-pack build --target web В Docker-образе аналогичная сборка (`wasm-pack build --target web
--release` + копирование JS-файлов в `public/wasm-matrix/`) выполняется --release` + копирование JS-файлов в `public/wasm-matrix/`) выполняется
отдельным стейджем в корневом `Dockerfile`. отдельным стейджем в корневом `Dockerfile`.
## Дополнительные визуальные слои (депт-сцена и стекло)
Поверх исходной триады "Rust → Worker → WebGL2" добавлены два независимых
визуальных слоя, оба не трогают физику дождя/глаз:
1. **Фон-глубина (дорога/машины)**`fsBackground` в `shaders.js`,
подключён в `matrix_worker.js` (`bgProgram`/`vaoBg`). Рисуется ПЕРВЫМ
внутри `postFramebuffer`, до капель дождя, тем же `vsOverlay`-quad'ом,
что и постпроцесс. Это единственный способ дать дождю "глубину сзади":
канвас матрицы создаётся с `alpha:false` и каждый кадр полностью
перезаписывается, поэтому DOM-слой позади канваса был бы просто не виден
— сцена должна рисоваться внутри того же фреймбуфера, ДО капель, чтобы
дождь (уже блендящийся поверх фона) частично её перекрывал, а не скрывал
целиком. Минималистичная сцена: светящийся горизонт, дорога с
перспективой к точке схода (асфальт — цвет фона с мелким зернистым шумом)
и стилизованные машины, едущие в противоположных полосах (эффект
двустороннего движения). Все формы аспект-корректны (см. `aspect` в
шейдере) — не искажаются при смене соотношения сторон экрана. Цвет
акцента синхронизирован с палитрой капель/глаз (циан выкл / фиолет вкл).
2. **Стекло**`matrix-engine/GlassShatter.tsx`, отдельный React-компонент
ВНЕ воркера и WASM (простой fullscreen-quad на main thread, GPU-нагрузка
пренебрежимо мала). Рендерится, в отличие от фона, обычным DOM-канвасом
ПОВЕРХ канваса матрицы — сюда ограничение `alpha:false` не применяется,
оно работает только "сквозь" элемент, а не поверх него. Показывается
только когда `active` (= `isVpnOn`, "глаза закрыты"). Клик/тап по панели
добавляет трещины (Voronoi-ячейки + growing-радиус вокруг точки удара,
пул из 6 слотов по аналогии с `Shockwave` в `lib.rs`); после
`SHATTER_AFTER_HITS` ударов стекло осыпается и через паузу собирается
заново — эффект зациклен.
+43 -3
View File
@@ -10,6 +10,7 @@ import {
vsGlitch, vsGlitch,
fsMatrix, fsMatrix,
fsGlitch, fsGlitch,
fsBackground,
postProcessSource, postProcessSource,
} from "./shaders.js"; } from "./shaders.js";
import { compileShader, createProgram } from "./gl-utils.js"; import { compileShader, createProgram } from "./gl-utils.js";
@@ -38,7 +39,13 @@ let lastFrameTime = 0;
let scaleCheckTimer = 0; let scaleCheckTimer = 0;
let currentPixelScale = 1.0; let currentPixelScale = 1.0;
let maxPixelScale = 1.0; let maxPixelScale = 1.0;
const version = "3.0.4"; // Значение здесь не важно — build-wasm.sh/Dockerfile перезаписывают эту
// строку (и добавляют ?v=... к импортам shaders.js/gl-utils.js ниже)
// значением ENGINE_VERSION из NetrunnerMatrix.tsx при сборке деплоя, чтобы
// cache-busting воркера, его импортов и wasm-glue были синхронизированы
// одним источником версии. Держим это значение в source просто не пустым
// для локальной разработки без сборки.
const version = "3.1.5";
let vpnTimer = 0.0; let vpnTimer = 0.0;
let uVpnTimerLoc; let uVpnTimerLoc;
@@ -47,11 +54,12 @@ let mouseX = 0;
let mouseY = 0; let mouseY = 0;
let uMouseLoc; let uMouseLoc;
let postProgram, matrixProgram, glitchProgram; let postProgram, matrixProgram, glitchProgram, bgProgram;
let postFramebuffer, postTexture; let postFramebuffer, postTexture;
let instanceBuffer, quadBuffer, overlayQuadBuffer, glitchInstanceBuffer; let instanceBuffer, quadBuffer, overlayQuadBuffer, glitchInstanceBuffer;
let vaoMatrix, vaoOverlay, vaoGlitch; let vaoMatrix, vaoOverlay, vaoGlitch, vaoBg;
let uResLoc, uFontLoc, uAtlasSizeLoc; let uResLoc, uFontLoc, uAtlasSizeLoc;
let uBgTimeLoc, uBgResLoc, uBgDarkLoc, uBgVpnLoc, uBgLowQualityLoc;
let instanceBufferCapacity = 0; let instanceBufferCapacity = 0;
let uPostEyePosLLoc, let uPostEyePosLLoc,
@@ -80,6 +88,7 @@ function initWebGL() {
matrixProgram = createProgram(gl, vsMatrix, fsMatrix); matrixProgram = createProgram(gl, vsMatrix, fsMatrix);
postProgram = createProgram(gl, vsOverlay, postProcessSource); postProgram = createProgram(gl, vsOverlay, postProcessSource);
glitchProgram = createProgram(gl, vsGlitch, fsGlitch); glitchProgram = createProgram(gl, vsGlitch, fsGlitch);
bgProgram = createProgram(gl, vsOverlay, fsBackground);
quadBuffer = gl.createBuffer(); quadBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
@@ -155,6 +164,17 @@ function initWebGL() {
gl.enableVertexAttribArray(aPosLocOver); gl.enableVertexAttribArray(aPosLocOver);
gl.vertexAttribPointer(aPosLocOver, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(aPosLocOver, 2, gl.FLOAT, false, 0, 0);
// Отдельный VAO для фонового прохода (глубина/дорога): та же геометрия
// fullscreen-quad, что и у постпроцесса, но локация атрибута запрашивается
// у bgProgram отдельно — WebGL не гарантирует одинаковые location для
// одноимённого атрибута между разными слинкованными программами.
vaoBg = gl.createVertexArray();
gl.bindVertexArray(vaoBg);
gl.bindBuffer(gl.ARRAY_BUFFER, overlayQuadBuffer);
const aPosLocBg = gl.getAttribLocation(bgProgram, "a_position");
gl.enableVertexAttribArray(aPosLocBg);
gl.vertexAttribPointer(aPosLocBg, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null); gl.bindVertexArray(null);
uResLoc = gl.getUniformLocation(matrixProgram, "u_resolution"); uResLoc = gl.getUniformLocation(matrixProgram, "u_resolution");
@@ -162,6 +182,12 @@ function initWebGL() {
uAtlasSizeLoc = gl.getUniformLocation(matrixProgram, "u_atlasSize"); uAtlasSizeLoc = gl.getUniformLocation(matrixProgram, "u_atlasSize");
uMouseLoc = gl.getUniformLocation(matrixProgram, "u_mouse"); uMouseLoc = gl.getUniformLocation(matrixProgram, "u_mouse");
uBgTimeLoc = gl.getUniformLocation(bgProgram, "u_time");
uBgResLoc = gl.getUniformLocation(bgProgram, "u_resolution");
uBgDarkLoc = gl.getUniformLocation(bgProgram, "u_isDarkMode");
uBgVpnLoc = gl.getUniformLocation(bgProgram, "u_isVpnOn");
uBgLowQualityLoc = gl.getUniformLocation(bgProgram, "u_isLowQuality");
atlasTexture = gl.createTexture(); atlasTexture = gl.createTexture();
uShockwaveLoc = gl.getUniformLocation(postProgram, "u_shockwave"); uShockwaveLoc = gl.getUniformLocation(postProgram, "u_shockwave");
@@ -320,6 +346,20 @@ function render(currentTime) {
gl.clearColor(bgR, bgG, bgB, 1.0); gl.clearColor(bgR, bgG, bgB, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT);
// Фоновая сцена глубины (город/дорога/машины) рисуется ПЕРВОЙ, полностью
// непрозрачной, поверх clear-цвета — капли дождя рисуются следующим шагом
// поверх неё с уже включённым альфа-блендингом (см. initWebGL), так что
// дождь остаётся видуально таким же, но в промежутках между символами
// теперь видна сцена вместо плоского фона.
gl.useProgram(bgProgram);
gl.bindVertexArray(vaoBg);
gl.uniform1f(uBgTimeLoc, now / 1000);
gl.uniform2f(uBgResLoc, width, height);
gl.uniform1f(uBgDarkLoc, isDarkMode ? 1.0 : 0.0);
gl.uniform1f(uBgVpnLoc, isVpnOn ? 1.0 : 0.0);
gl.uniform1f(uBgLowQualityLoc, isLowQuality ? 1.0 : 0.0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
const renderLen = engine.render_len(); const renderLen = engine.render_len();
if (renderLen > 0) { if (renderLen > 0) {
const instanceCount = renderLen / 5; const instanceCount = renderLen / 5;
+137
View File
@@ -169,6 +169,143 @@ void main() {
else { discard; } else { discard; }
}`.trim(); }`.trim();
/**
* Фоновый проход глубины: рисуется ПЕРВЫМ внутри postFramebuffer, до капель
* дождя (см. render() в matrix_worker.js) — минималистичная сцена глубины
* (небо и уходящий к линии горизонта тоннель данных: веер световых линий с
* бегущими по ним в обе стороны частицами-пакетами). Существует отдельным
* проходом, а не
* как часть постпроцесса, по одной причине: канвас матрицы создаётся с
* `alpha: false` и каждый кадр полностью перезаписывается сверху, поэтому
* любой слой, размещённый в DOM ПОЗАДИ канваса, был бы невидим — единственный
* способ дать дождю "глубину сзади" — нарисовать эту сцену первой внутри того
* же фреймбуфера, чтобы капли (которые уже блендятся поверх фона через
* SRC_ALPHA/ONE_MINUS_SRC_ALPHA) частично её перекрывали, а не полностью
* скрывали.
* Использует vsOverlay (тот же полноэкранный quad, что и постпроцесс).
* Переиспользует vsOverlay — отдельный вершинный шейдер не нужен, оба прохода
* рисуют один и тот же fullscreen-quad в clip space.
*/
export const fsBackground = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 outColor;
uniform float u_time;
uniform vec2 u_resolution;
uniform float u_isDarkMode;
uniform float u_isVpnOn;
uniform float u_isLowQuality;
float hash11(float p) { return fract(sin(p * 127.1) * 43758.5453123); }
void main() {
vec2 uv = v_uv; // 0..1, y=0 низ экрана, y=1 верх (см. vsOverlay: v_uv = a_position*0.5+0.5)
bool isLight = u_isDarkMode < 0.5;
// Акцентный цвет синхронизирован с палитрой капель/глаз: циан выкл, фиолет вкл.
vec3 accent = mix(vec3(0.0, 0.85, 1.0), vec3(0.65, 0.15, 1.0), u_isVpnOn);
// Коррекция соотношения сторон (тот же приём, что в vsMatrix/postProcessSource):
// без него линии тоннеля и частицы-пакеты искажаются при смене aspect
// ratio экрана, потому что uv.x и uv.y покрывают разное физическое
// расстояние на пиксель. Умножаем x-компоненту локальных координат на
// aspect.x перед любым distance-вычислением — тогда 1 условная единица по
// x и y соответствует одному и тому же физическому расстоянию независимо
// от формы экрана.
vec2 aspect = vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0);
float horizon = 0.46;
vec3 skyTop = isLight ? vec3(0.85, 0.87, 0.93) : vec3(0.03, 0.035, 0.06);
vec3 skyBottom = isLight ? vec3(0.90, 0.88, 0.90) : vec3(0.05, 0.04, 0.07);
vec3 col = mix(skyBottom, skyTop, smoothstep(horizon, 1.0, uv.y));
// Светящаяся полоса на линии горизонта — приглушена, чтобы не спорить с дождём.
col += accent * exp(-abs(uv.y - horizon) * 60.0) * 0.28;
if (uv.y < horizon) {
// --- Тоннель данных: минималистичная метафора вместо дороги и машин —
// веер сходящихся к горизонту световых линий (несущие "полосы"
// тоннеля), редкие поперечные кольца глубины и бегущие по линиям
// частицы-пакеты в обе стороны (входящий/исходящий трафик). Вся
// геометрия — это abs()/exp()-затухание по расстоянию до линии/точки,
// без SDF-фигур и без per-pixel шума текстуры, поэтому рендерится
// заметно дешевле прежней дороги с машинами.
float t = clamp(1.0 - uv.y / horizon, 0.0, 1.0);
// "Пол" тоннеля — лёгкое затемнение к низу экрана вместо отдельной
// текстуры асфальта.
col = mix(col, skyBottom * 0.6, t * 0.55);
// Веер несущих линий: N_LINES лучей от горизонта по той же кривой
// перспективы pow(t, 1.6), что раньше задавала края дороги.
const int N_LINES = 7;
float spread = mix(0.006, 0.42, pow(t, 1.6));
for (int li = 0; li < N_LINES; li++) {
float lineT = (float(li) / float(N_LINES - 1)) * 2.0 - 1.0;
float xLine = 0.5 + lineT * spread;
float distX = abs(uv.x - xLine) * aspect.x;
float lineWidth = mix(0.0025, 0.006, t);
col += accent * exp(-distX / lineWidth) * 0.16;
}
// Поперечные "кольца глубины" — горизонтальные полосы во всю ширину
// веера, бегущие от горизонта на зрителя (та же идея, что у прежней
// прерывистой осевой линии дороги, но во всю ширину тоннеля). При
// включённом VPN кольца бегут быстрее — метафора возросшей скорости.
float ringSpeed = mix(0.5, 1.1, u_isVpnOn);
float ringPhase = fract(t * 3.0 - u_time * ringSpeed);
float ringProximity = abs(ringPhase - 0.5) * 2.0;
float ringMask = smoothstep(0.06, 0.0, ringProximity) * smoothstep(0.0, 0.12, t);
float distFromCenterX = abs(uv.x - 0.5) * aspect.x;
if (distFromCenterX < spread * aspect.x) {
col += accent * ringMask * 0.22;
}
// --- Пакеты данных: бегут по линиям веера в обе стороны — чётные
// летят НА зрителя (входящий трафик), нечётные — ОТ зрителя
// (исходящий). При включённом VPN пакетов больше и летят быстрее —
// метафора возросшей защищённой пропускной способности. На слабых
// устройствах (u_isLowQuality) активных пакетов вдвое меньше.
float maxPackets = mix(6.0, 10.0, u_isVpnOn) * mix(1.0, 0.5, u_isLowQuality);
float speedMul = mix(1.0, 2.0, u_isVpnOn);
for (int i = 0; i < 10; i++) {
float fi = float(i);
if (fi >= maxPackets) continue;
bool incoming = mod(fi, 2.0) < 0.5;
float speed = (0.14 + hash11(fi * 7.0) * 0.06) * speedMul;
float seed = hash11(fi * 13.0) * 10.0;
float tc = fract(u_time * speed + seed);
if (!incoming) tc = 1.0 - tc;
// Пакет летит вдоль ТОЙ ЖЕ кривой веера, что и несущие линии
// выше, — визуально "едет по рельсу", а не просто где-то рядом.
float lineT = (mod(fi, float(N_LINES)) / float(N_LINES - 1)) * 2.0 - 1.0;
float pSpread = mix(0.006, 0.42, pow(tc, 1.6));
vec2 pos = vec2(0.5 + lineT * pSpread, horizon * (1.0 - tc));
vec2 d = vec2((uv.x - pos.x) * aspect.x, uv.y - pos.y);
float d2 = dot(d, d);
float size = mix(0.0025, 0.012, tc);
// Ранний выход: считаем ядро/блум пакета только рядом с ним самим.
float boundR = size * 5.0;
if (d2 > boundR * boundR) continue;
float fade = smoothstep(0.0, 0.06, tc);
vec3 packetCol = incoming ? mix(vec3(0.9, 0.97, 1.0), accent, 0.35) : accent * 0.75;
float core = (1.0 - smoothstep(size * size * 0.3, size * size, d2)) * fade;
col = mix(col, packetCol, core);
float glow = exp(-d2 / (size * size * 2.5)) * fade * (incoming ? 0.5 : 0.3);
col += packetCol * glow;
}
}
outColor = vec4(col, 1.0);
}
`.trim();
/** /**
* Полноэкранный фрагментный шейдер второго прохода — самый плотный файл * Полноэкранный фрагментный шейдер второго прохода — самый плотный файл
* пайплайна. Читает готовую картинку матрицы+глитчей из postTexture * пайплайна. Читает готовую картинку матрицы+глитчей из postTexture
+20
View File
@@ -0,0 +1,20 @@
# Часть 1/2 — до выпуска сертификата отдаёт только ACME-челлендж и редиректит
# остальное на https. Тот же паттерн, что landing-http.conf/account-http.conf.
#
# cms.netrunner-vpn.com — прямой доступ к PocketBase (админка + /api),
# отдельно от /cms-api/ на основном домене (см. landing-ssl.conf), которым
# пользуется сам лендинг в рантайме. Этот поддомен — для ручного
# администрирования через дашборд PocketBase, поэтому под Basic Auth.
server {
listen 80;
listen [::]:80;
server_name cms.netrunner-vpn.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
+31
View File
@@ -0,0 +1,31 @@
# Часть 2/2 — деплой включает этот файл ТОЛЬКО когда сертификат уже есть
# (см. deploy-nginx.yml). Basic Auth на дашборд ("/"), сам REST API ("/api/")
# открыт без него — так же, как было на старом (французском) origin-сервере,
# откуда сюда перенесены данные PocketBase.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name cms.netrunner-vpn.com;
ssl_certificate /etc/letsencrypt/live/cms.netrunner-vpn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cms.netrunner-vpn.com/privkey.pem;
location /api/ {
proxy_pass http://127.0.0.1:8090;
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;
}
location / {
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:8090;
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;
}
}
+15
View File
@@ -0,0 +1,15 @@
# Часть 1/2 — включается всегда, с самого первого деплоя (см.
# .gitea/workflows/deploy-nginx.yml). ACME-challenge + редирект на HTTPS.
server {
listen 80;
listen [::]:80;
server_name netrunner-vpn.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
+70
View File
@@ -0,0 +1,70 @@
# Часть 2/2 — деплой включает этот файл ТОЛЬКО когда сертификат уже есть.
# ВАЖНО: сертификат выпускается ОДНОЙ командой на оба домена сразу (см.
# .gitea/workflows/deploy-nginx.yml у netrunner-backend, туда же убегает
# первый -d) — папка в /etc/letsencrypt/live называется по ПЕРВОМУ домену,
# account.netrunner-vpn.com, а не netrunner-vpn.com, даже для этого файла.
#
# Реальный IP посетителя — из CF-Connecting-IP, см.
# /etc/nginx/conf.d/cloudflare-real-ip.conf на самом сервере (общий для всех
# доменов на этом VPS, не часть этого репозитория).
#
# Три бэкенда за одним доменом:
# / -> сам лендинг (Next.js, :3000)
# /cms-api/* -> PocketBase (:8090). PB_PUBLIC_URL в docker-compose.yml =
# ".../cms-api" — сам PocketBase JS SDK достраивает к нему
# свой нативный "/api/..." (records, files и т.д.), так что
# реальные запросы всегда выглядят как "/cms-api/api/...".
# Один location с proxy_pass .../api/ тут НЕ работает: nginx
# отрезает только совпавший префикс "/cms-api/" и приклеивает
# остаток к ".../api/", получая задвоенное ".../api/api/..."
# (404 у PocketBase). Поэтому ниже два location: точный
# "/cms-api/api/" — рабочий случай SDK, и общий "/cms-api/"
# с rewrite — просто на случай прямых ссылок без "/api/".
# /api/* -> proxy-ws (:3001) — его собственные роуты уже объявлены
# с префиксом /api (comments, speedtest), никакой замены
# префикса не нужно, только вебсокет-апгрейд заголовки
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name netrunner-vpn.com;
ssl_certificate /etc/letsencrypt/live/account.netrunner-vpn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/account.netrunner-vpn.com/privkey.pem;
location /cms-api/api/ {
proxy_pass http://127.0.0.1:8090/api/;
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;
}
location /cms-api/ {
rewrite ^/cms-api/(.*) /$1 break;
proxy_pass http://127.0.0.1:8090;
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;
}
location /api/ {
proxy_pass http://127.0.0.1:3001;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
+1
View File
@@ -21,6 +21,7 @@
"next": "16.2.4", "next": "16.2.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pocketbase": "^0.26.8", "pocketbase": "^0.26.8",
"prom-client": "^15.1.3",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
+33 -2
View File
@@ -37,13 +37,16 @@ importers:
version: 1.0.0 version: 1.0.0
next: next:
specifier: 16.2.4 specifier: 16.2.4
version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pocketbase: pocketbase:
specifier: ^0.26.8 specifier: ^0.26.8
version: 0.26.8 version: 0.26.8
prom-client:
specifier: ^15.1.3
version: 15.1.3
radix-ui: radix-ui:
specifier: ^1.4.3 specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -648,6 +651,10 @@ packages:
'@open-draft/until@2.1.0': '@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@radix-ui/number@1.1.1': '@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1765,6 +1772,9 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
body-parser@2.2.2: body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3199,6 +3209,10 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
prom-client@15.1.3:
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
engines: {node: ^16 || ^18 || >=20}
prompts@2.4.2: prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -3558,6 +3572,9 @@ packages:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
engines: {node: '>=6'} engines: {node: '>=6'}
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4356,6 +4373,8 @@ snapshots:
'@open-draft/until@2.1.0': {} '@open-draft/until@2.1.0': {}
'@opentelemetry/api@1.9.1': {}
'@radix-ui/number@1.1.1': {} '@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {} '@radix-ui/primitive@1.1.3': {}
@@ -5507,6 +5526,8 @@ snapshots:
baseline-browser-mapping@2.10.19: {} baseline-browser-mapping@2.10.19: {}
bintrees@1.0.2: {}
body-parser@2.2.2: body-parser@2.2.2:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@@ -6805,7 +6826,7 @@ snapshots:
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
'@next/env': 16.2.4 '@next/env': 16.2.4
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
@@ -6824,6 +6845,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.4 '@next/swc-linux-x64-musl': 16.2.4
'@next/swc-win32-arm64-msvc': 16.2.4 '@next/swc-win32-arm64-msvc': 16.2.4
'@next/swc-win32-x64-msvc': 16.2.4 '@next/swc-win32-x64-msvc': 16.2.4
'@opentelemetry/api': 1.9.1
sharp: 0.34.5 sharp: 0.34.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -7027,6 +7049,11 @@ snapshots:
dependencies: dependencies:
parse-ms: 4.0.0 parse-ms: 4.0.0
prom-client@15.1.3:
dependencies:
'@opentelemetry/api': 1.9.1
tdigest: 0.1.2
prompts@2.4.2: prompts@2.4.2:
dependencies: dependencies:
kleur: 3.0.3 kleur: 3.0.3
@@ -7548,6 +7575,10 @@ snapshots:
tapable@2.3.2: {} tapable@2.3.2: {}
tdigest@0.1.2:
dependencies:
bintrees: 1.0.2
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinyglobby@0.2.16: tinyglobby@0.2.16:
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
msw: false
sharp: false
unrs-resolver: false
+372 -12
View File
@@ -137,6 +137,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.5.3" version = "0.5.3"
@@ -472,6 +478,17 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "evmap"
version = "11.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
dependencies = [
"hashbag",
"left-right",
"smallvec",
]
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.27" version = "0.2.27"
@@ -505,6 +522,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -553,6 +576,21 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "generator"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
dependencies = [
"cc",
"cfg-if",
"libc",
"log",
"rustversion",
"windows-link",
"windows-result",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@@ -574,6 +612,18 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.3.27" version = "0.3.27"
@@ -604,6 +654,12 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "hashbag"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -613,6 +669,15 @@ dependencies = [
"ahash", "ahash",
] ]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.0" version = "0.17.0"
@@ -967,6 +1032,17 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "left-right"
version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a"
dependencies = [
"crossbeam-utils",
"loom",
"slab",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@@ -1075,6 +1151,19 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loom"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "macro_rules_attribute" name = "macro_rules_attribute"
version = "0.2.2" version = "0.2.2"
@@ -1097,6 +1186,15 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matchit" name = "matchit"
version = "0.7.3" version = "0.7.3"
@@ -1128,6 +1226,48 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "metrics"
version = "0.24.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2"
dependencies = [
"portable-atomic",
"rapidhash",
]
[[package]]
name = "metrics-exporter-prometheus"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
dependencies = [
"base64 0.22.1",
"evmap",
"indexmap",
"metrics",
"metrics-util",
"quanta",
"thiserror 2.0.18",
]
[[package]]
name = "metrics-util"
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"hashbrown 0.16.1",
"metrics",
"quanta",
"rand 0.9.4",
"rand_xoshiro",
"rapidhash",
"sketches-ddsketch",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -1208,6 +1348,15 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "num-complex" name = "num-complex"
version = "0.4.6" version = "0.4.6"
@@ -1459,6 +1608,8 @@ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"indexmap", "indexmap",
"metrics",
"metrics-exporter-prometheus",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -1469,6 +1620,21 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -1478,6 +1644,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.6" version = "0.8.6"
@@ -1485,8 +1657,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1496,7 +1678,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1505,7 +1697,16 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
] ]
[[package]] [[package]]
@@ -1515,7 +1716,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
dependencies = [ dependencies = [
"num-traits", "num-traits",
"rand", "rand 0.8.6",
]
[[package]]
name = "rand_xoshiro"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rapidhash"
version = "4.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e"
dependencies = [
"rustversion",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.11.1",
] ]
[[package]] [[package]]
@@ -1651,7 +1879,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [ dependencies = [
"cc", "cc",
"cfg-if", "cfg-if",
"getrandom", "getrandom 0.2.17",
"libc", "libc",
"untrusted", "untrusted",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@@ -1745,6 +1973,12 @@ dependencies = [
"regex", "regex",
] ]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -1838,6 +2072,15 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -1860,6 +2103,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "sketches-ddsketch"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -2022,7 +2271,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
] ]
[[package]] [[package]]
@@ -2036,6 +2294,26 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.41" version = "0.3.41"
@@ -2101,7 +2379,7 @@ dependencies = [
"aho-corasick", "aho-corasick",
"derive_builder", "derive_builder",
"esaxx-rs", "esaxx-rs",
"getrandom", "getrandom 0.2.17",
"indicatif", "indicatif",
"itertools 0.12.1", "itertools 0.12.1",
"lazy_static", "lazy_static",
@@ -2110,7 +2388,7 @@ dependencies = [
"monostate", "monostate",
"onig", "onig",
"paste", "paste",
"rand", "rand 0.8.6",
"rayon", "rayon",
"rayon-cond", "rayon-cond",
"regex", "regex",
@@ -2118,7 +2396,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"spm_precompiled", "spm_precompiled",
"thiserror", "thiserror 1.0.69",
"unicode-normalization-alignments", "unicode-normalization-alignments",
"unicode-segmentation", "unicode-segmentation",
"unicode_categories", "unicode_categories",
@@ -2237,6 +2515,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -2368,9 +2676,9 @@ version = "0.21.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "522b7a8131564765af8fa623ab262d6e6b650bc95663d805edca1e9ee94d6ec6" checksum = "522b7a8131564765af8fa623ab262d6e6b650bc95663d805edca1e9ee94d6ec6"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.17",
"log", "log",
"rand", "rand 0.8.6",
"rand_distr", "rand_distr",
"rustfft", "rustfft",
"tract-nnef", "tract-nnef",
@@ -2470,6 +2778,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -2501,6 +2815,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.120" version = "0.2.120"
@@ -2582,6 +2905,22 @@ version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -2591,12 +2930,27 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"
@@ -2764,6 +3118,12 @@ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"
+2
View File
@@ -15,6 +15,8 @@ tower-http = { version = "0.5", features = ["cors"] }
tract-onnx = "0.21.6" tract-onnx = "0.21.6"
tokenizers = "0.19" tokenizers = "0.19"
anyhow = "1.0" anyhow = "1.0"
metrics = "0.24.3"
metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
# Профиль заточен под минимальный размер бинарника и образа (LTO, strip # Профиль заточен под минимальный размер бинарника и образа (LTO, strip
# отладочной информации, отказ от unwind-раскрутки стека при панике). # отладочной информации, отказ от unwind-раскрутки стека при панике).
+42 -6
View File
@@ -1,14 +1,17 @@
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::{DefaultBodyLimit, State}, extract::{DefaultBodyLimit, MatchedPath, Request, State},
http::{header, StatusCode}, http::{header, StatusCode},
response::IntoResponse, middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use metrics_exporter_prometheus::PrometheusBuilder;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use std::time::Instant;
mod classifier; mod classifier;
use classifier::ToxicityClassifier; use classifier::ToxicityClassifier;
@@ -64,13 +67,25 @@ async fn main() {
ai_classifier: classifier, ai_classifier: classifier,
}); });
// Для центрального Prometheus (см. netrunner-data/docker-compose.observability.yml,
// scrape по сети — этот порт нужно будет зафайрволить на его IP так же,
// как /metrics на прокси-нодах).
let recorder_handle = PrometheusBuilder::new()
.install_recorder()
.expect("Failed to install Prometheus recorder");
let app = Router::new() let app = Router::new()
.route("/api/comments", post(create_comment)) .route("/api/comments", post(create_comment))
.route("/api/speedtest/ping", get(speedtest_ping)) .route("/api/speedtest/ping", get(speedtest_ping))
.route("/api/speedtest/download", get(speedtest_download)) .route("/api/speedtest/download", get(speedtest_download))
.route("/api/speedtest/upload", post(speedtest_upload)) .route("/api/speedtest/upload", post(speedtest_upload))
.layer(DefaultBodyLimit::max(100 * 1024 * 1024)) .layer(DefaultBodyLimit::max(100 * 1024 * 1024))
.with_state(state); .with_state(state)
.route(
"/metrics",
get(move || std::future::ready(recorder_handle.render())),
)
.route_layer(middleware::from_fn(track_metrics));
println!("🚀 [READY] Netrunner Gateway started on 0.0.0.0:3001"); println!("🚀 [READY] Netrunner Gateway started on 0.0.0.0:3001");
let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap(); let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
@@ -191,9 +206,7 @@ async fn speedtest_ping() -> impl IntoResponse {
[ [
( (
header::CACHE_CONTROL, header::CACHE_CONTROL,
header::HeaderValue::from_static( header::HeaderValue::from_static("no-store, no-cache, must-revalidate, max-age=0"),
"no-store, no-cache, must-revalidate, max-age=0",
),
), ),
(header::PRAGMA, header::HeaderValue::from_static("no-cache")), (header::PRAGMA, header::HeaderValue::from_static("no-cache")),
], ],
@@ -226,3 +239,26 @@ async fn speedtest_upload(body: Bytes) -> impl IntoResponse {
println!("📊 [SPEEDTEST] Upload received: {} bytes", body.len()); println!("📊 [SPEEDTEST] Upload received: {} bytes", body.len());
(StatusCode::OK, format!("Received {} bytes", body.len())) (StatusCode::OK, format!("Received {} bytes", body.len()))
} }
/// Тот же паттерн, что `api.rs::track_metrics` в netrunner-backend —
/// латентность/число запросов по маршруту+методу+статусу.
async fn track_metrics(req: Request, next: Next) -> Response {
let start = Instant::now();
let path = req
.extensions()
.get::<MatchedPath>()
.map(|m| m.as_str().to_owned())
.unwrap_or_else(|| req.uri().path().to_owned());
let method = req.method().to_string();
let response = next.run(req).await;
let latency = start.elapsed().as_secs_f64();
let status = response.status().as_u16().to_string();
let labels = [("method", method), ("path", path), ("status", status)];
metrics::counter!("http_requests_total", &labels).increment(1);
metrics::histogram!("http_request_duration_seconds", &labels).record(latency);
response
}
+1 -3
View File
@@ -2,9 +2,7 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { match as matchLocale } from "@formatjs/intl-localematcher"; import { match as matchLocale } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator"; import Negotiator from "negotiator";
import { locales, defaultLocale } from "@/lib/locales";
const locales = ["en", "ru"];
const defaultLocale = "en";
// Определяем предпочитаемую локаль браузера по заголовку Accept-Language. // Определяем предпочитаемую локаль браузера по заголовку Accept-Language.
// Negotiator парсит заголовки запроса в список языков с весами, а matchLocale // Negotiator парсит заголовки запроса в список языков с весами, а matchLocale
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 929 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 947 B

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 922 B

After

Width:  |  Height:  |  Size: 664 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+303
View File
@@ -0,0 +1,303 @@
// English counterpart of docs-content.ru.mjs — see that file for sourcing notes.
export const DOCS_EN = [
// ───────────────────────────── CORE ─────────────────────────────
{
title: "Getting started",
category: "Core",
status: "Public",
content: `NETRUNNER // FIRST BOOT
No e-mail or phone signup — account access is designed to link as little data to you as possible.
METHOD 1 — TELEGRAM
Open @ntrnr_vpn_bot and tap "Dashboard". Sign-in is confirmed via the official Telegram Login Widget — the server verifies Telegram's cryptographic signature; your Telegram password never reaches the VPN server.
METHOD 2 — GHOST PROTOCOL (fully anonymous)
If you don't want to link an account even to Telegram, generate a seed phrase right in the dashboard. The seed is the only key to the account: it lives nowhere but with you, and isn't tied to a phone number, e-mail, or Telegram ID.
IMPORTANT: the seed cannot be recovered. Lose it and you lose the account and everything on it. Download the .txt with the seed phrase and store it offline (password manager, paper — anything but a screenshot in shared cloud storage).
NEXT STEPS
1. Download the client for your platform on /download (Windows, Linux, and Android are available now; macOS and iOS are in development).
2. Activate a plan in the dashboard — there's a free trial for accounts linked to Telegram (anti-abuse: Ghost accounts don't get a trial, otherwise it would be farmed by scripts).
3. Connection is configured automatically — nothing to set up by hand.
EOF`,
},
{
title: "Pricing, payments & billing",
category: "Core",
status: "Public",
content: `PAYMENT // TON
Payment is accepted only in TON cryptocurrency — no bank cards, no payment processors that require ID documents.
HOW IT WORKS
1. Pick a plan — the server issues an invoice for the exact TON amount at the current exchange rate.
2. Transfer that amount to the master wallet address with the comment (memo) shown on the invoice — that's exactly how the payment gets matched.
3. The server verifies the transaction directly on the TON blockchain: amount (not less than invoiced), destination address, and memo. The same tx hash can never be credited twice.
4. Status flips to "paid" automatically, no manual review.
WHY TON ONLY
Transparency and verifiability: a payment is confirmed mathematically from blockchain data, not by trusting a processor. The trade-off — blockchain transfers are irreversible, see "Refund Policy" under Legal.
REFERRAL PROGRAM
Your invite link lives in the dashboard. Self-referral (same person as both referrer and referee) is blocked server-side, not just by policy.
EOF`,
},
// ─────────────────────────── SECURITY ───────────────────────────
{
title: "Threat model and encryption",
category: "Security",
status: "Encrypted",
content: `THREAT_MODEL.LOG
WHAT NETRUNNER PROTECTS AGAINST
- Passive traffic interception on the path ISP → destination (café Wi-Fi, carrier, transit networks).
- DPI classification and protocol-based blocking (see "Traffic Obfuscation and DPI Evasion").
- Linking activity to your identity through classic signup channels (e-mail/phone) — via Ghost Protocol.
WHAT NO VPN CAN PROTECT AGAINST
- Compromise of the device itself (malware, keyloggers, physical access).
- De-anonymization by the destination services themselves, if you log into them under your real identity.
- Legal liability for what travels through the tunnel — encrypting the channel doesn't make illegal content legal.
TUNNEL CRYPTOGRAPHY
Every data frame inside the tunnel is protected by ChaCha20-Poly1305 AEAD encryption — simultaneously encryption and integrity checking (auth tag): tampering with even one bit in transit is detected and the frame is dropped.
Key exchange uses X25519 (elliptic-curve Diffie-Hellman); a fresh session key is generated for every tunnel establishment (ephemeral). This gives forward secrecy: even if one session's key ever leaked, it wouldn't decrypt past or future sessions — each has its own one-time key. Final encryption keys are derived from the exchange result via HKDF, never used directly.
Every frame carries an HMAC tag built on a TOTP-like principle (a time component) — replay protection: a captured and resent frame fails verification.
Control frames are padded with random bytes, which complicates traffic analysis by packet length.
EOF`,
},
{
title: "Traffic obfuscation and DPI evasion",
category: "Security",
status: "Encrypted",
content: `STEALTH_LAYER.LOG
DPI (Deep Packet Inspection) is how ISPs and censoring systems tell "regular HTTPS" apart from VPN/proxy traffic and block the latter. Most VPN protocols have a recognizable fingerprint at exactly this layer, not in the encryption itself.
WHAT WE DO
The tunnel's first packet looks like a real TLS handshake (ClientHello/ServerHello) — not approximately, but byte-for-byte matching the order and content of a real browser's cipher suites, TLS extensions, and GREASE values (a JA3/JA4 profile). To a DPI system classifying traffic by handshake fingerprint, our traffic is indistinguishable from an ordinary browser visiting an https site.
The real key exchange (X25519) is physically hidden inside fields of the fake TLS handshake — for example, in the KeyShare extension and in the session_id field, which per spec looks like random bytes but actually carries part of the authentication tag.
BEING HONEST ABOUT THE LIMITS
This is not real TLS: there's no certificate chain, no server authenticity check in the ordinary HTTPS sense, because the goal isn't to secure a web page — it's to hide the very fact of tunneling from traffic-classification systems. Actual data protection is provided by a separate layer (see "Threat Model and Encryption"), not by this obfuscation layer.
If a connection isn't recognized by the server as a legitimate client for some reason (say, someone port-scanning to figure out what's listening), the server quietly "reflects" that attempt onto unrelated content (stealth fallback) — from the outside it looks like an ordinary web server, not a node that answers specifically to VPN requests and can therefore be fingerprinted by scanning.
EOF`,
},
{
title: "Authentication and session storage",
category: "Security",
status: "Encrypted",
content: `SESSION.LOG
NO TOKEN IN LOCALSTORAGE
The dashboard and site session lives exclusively in an HttpOnly cookie — a cookie that browser JavaScript simply cannot read (neither your own script nor an injected XSS script). The token is never in localStorage/sessionStorage: if it were, any script that managed to run on the page could steal it.
A short-lived access token (15 minutes) is issued alongside a rotating refresh token (30 days). When the access token has expired, the session refreshes automatically in the background — no re-login. The database stores not the refresh token itself but only its SHA-256 hash — even a database leak can't be reversed back into a usable token.
If the server ever sees an attempt to reuse an already-revoked (rotated) refresh token, that's an unambiguous compromise signal, and every session on the account is revoked in response, not just that one.
CSRF PROTECTION
For state-changing requests riding on a cookie session (payments, settings changes, etc.), the server additionally checks the Origin/Referer headers against an allow-list of domains — a third-party site can't silently fire requests on your behalf even if your cookie is valid.
TELEGRAM LOGIN
The Telegram Login Widget's signature is verified via HMAC-SHA256 using a constant-time comparison (not a byte-by-byte comparison that leaks via timing) — ruling out both widget-data forgery and timing attacks against the server.
EOF`,
},
{
title: "What we store and what we don't",
category: "Security",
status: "Public",
content: `DATA_RETENTION.LOG
Short version: we store exactly what's needed to run the account and billing, and we don't keep a log of your internet activity.
WHAT'S STORED ON THE SERVER
- Account identifier: Telegram ID (if you signed in via Telegram) OR nothing personal at all (if you signed in via Ghost Protocol — no email, phone, or name).
- Subscription status and payment history (amount, TON blockchain tx hash, date) — needed for billing and payment disputes.
- A hash (not the token itself) of the active session and its creation time — needed to protect against session theft and to support signing out of all devices on request.
- Referral links between accounts (who invited whom) — solely for bonus crediting.
WHAT ISN'T
- A log of sites visited through the VPN, destination IPs, or traffic content — at the tunnel protocol level (NRXP) there's no code that writes anything like that to a database.
- A history of "who connected to which node when" beyond what's needed for an instant health check (is the server alive right now) — that's infrastructure monitoring, not a user activity log.
WHAT'S FAIR TO SAY HONESTLY
Server processes, like any service's, do write operational logs in the moment — errors, connection start/stop — for infrastructure debugging. That's not a structured database of your activity and isn't tied to your identity, but it would be dishonest to claim that literally no process anywhere ever writes a single log line. If this matters for your threat model, use Ghost Protocol and avoid posting data elsewhere that could be linked back to your account.
EOF`,
},
{
title: "Responsible disclosure",
category: "Security",
status: "Public",
content: `BUG_BOUNTY.LOG // RESPONSIBLE DISCLOSURE
Found a vulnerability — in the tunnel, the dashboard, the bot, or billing? Tell us before anyone else finds out.
RULES
1. Don't exploit the finding beyond what's needed for proof-of-concept — no bulk data dumps of other users, no DoS, no attempts to move other people's funds.
2. Give us a reasonable amount of time to fix it before disclosing details publicly.
3. Don't touch other people's accounts or data without their explicit consent.
HOW TO REPORT
Message support via the @ntrnr_vpn_bot Telegram bot with SECURITY in the first message — the fastest way to reach someone who can actually fix the issue.
[A dedicated security-report e-mail and public PGP key will be added here once the operating entity is registered — see also the Legal section.]
We don't currently promise monetary bounties, but we do promise: priority triage, public credit if you want it, and that your report won't be ignored.
EOF`,
},
// ─────────────────────────── NETWORK ───────────────────────────
{
title: "Tunnel architecture",
category: "Network",
status: "Internal",
content: `ARCH.MAP // INTERNALS
Three layers, bottom to top:
1. RAWCAST — the local layer on the client device. Real TCP/UDP connections from apps (browser, messenger, etc.) are described as a compact frame with protocol type, event type, local socket_id, and destination address/port. This frame is a purely internal format — it never travels over the network as-is.
2. NRXP (Netrunner eXchange Protocol) — the application protocol inside the tunnel. A RawCast frame is wrapped into an NRXP frame: a 25-byte header (auth tag, stream id, type, payload length, padding length) plus the payload itself. Many independent streams (say, a dozen simultaneous browser connections) are multiplexed over one tunnel — no need to open a separate connection for each; the NRXP stream id is what tells them apart.
3. TRANSPORT — every NRXP frame is encrypted (ChaCha20-Poly1305) and wrapped as a single TLS ApplicationData record inside the disguised TLS handshake (see "Traffic Obfuscation and DPI Evasion"). From the outside, indistinguishable from a browser's HTTPS session.
ON THE DEVICE
The client intercepts traffic via a TUN interface and its own userspace stack (smoltcp) — it operates at the IP packet level rather than relying on OS proxy settings, which not every app respects.
ON THE SERVER
A node accepts an incoming connection, checks whether it's a legitimate NRXP client (as opposed to a port scanner or a random HTTPS request), and either establishes the tunnel or quietly "reflects" the connection as an ordinary web server (stealth fallback) — never revealing that the port knows anything about VPN traffic.
NODE SELECTION
The list of available servers and routing parameters are handed to an authorized client by the API using a short-lived one-time token — nothing to configure by hand, and access to the node list itself requires an active subscription.
EOF`,
},
{
title: "Clients and platforms",
category: "Network",
status: "Public",
content: `PLATFORMS.LOG
The tunnel engine is written in Rust and reused wholesale across platforms — the same crypto and protocol everywhere, not rewritten separately per OS with its own bugs.
AVAILABLE NOW
- Linux — native client, TUN interface, requires cap_net_admin/cap_net_raw (or root).
- Windows — native desktop client.
- Android — Kotlin app on top of the Rust core, built via UniFFI/JNI for arm64/armv7/x86/x86_64.
IN DEVELOPMENT
- macOS — desktop client in progress.
- iOS — beta testing underway.
Current versions and download links live on /download; it also shows which platform is already "active" versus still in development — status isn't dressed up.
EOF`,
},
// ──────────────────────────── LEGAL ────────────────────────────
{
title: "Privacy Policy",
category: "Legal",
status: "Public",
content: `PRIVACY POLICY
Last updated: [fill in at publication]
1. WHO WE ARE
Netrunner VPN service operator: [legal entity or individual name — to be filled in here once registration is complete; until then this document is informational, not a final legal instrument].
2. WHAT DATA WE COLLECT
- Telegram sign-in: your Telegram ID and whatever data the Telegram Login Widget itself passes on authentication (name, username, photo — where applicable).
- Ghost Protocol sign-in: no personal data is collected — the seed phrase is generated client-side and isn't tied to your identity.
- Payment data: amount, TON blockchain transaction id, date — TON payments are public on the blockchain itself, independent of us.
See "What We Store and What We Don't" under Security for the technical detail.
3. WHY WE USE THE DATA
Solely to provide the service: authentication, granting VPN node access per an active subscription, payment processing, referral crediting, support responses.
4. THIRD-PARTY SHARING
Data isn't sold or shared with third parties for marketing. The only technically necessary sharing happens through the Telegram network itself (for Telegram sign-in) and the public TON blockchain (for payments) — both operate under their own rules, outside our control.
5. YOUR RIGHTS
You can request account deletion and removal of associated data via bot support. Note: a TON blockchain payment record cannot physically be deleted — the blockchain is immutable by design.
6. CONTACT
Support: the @ntrnr_vpn_bot Telegram bot.
A dedicated legal address and e-mail for official inquiries will be published here once the service operator is registered.
EOF`,
},
{
title: "Terms of Use",
category: "Legal",
status: "Public",
content: `TERMS OF USE
Last updated: [fill in at publication]
1. ACCEPTANCE
By using the Netrunner VPN service (site, bot, client apps), you agree to these terms. If you don't agree, don't use the service.
2. ACCEPTABLE USE
The service is provided for lawful use in the jurisdiction you're accessing it from. You may not use it for: attacks against third-party infrastructure (DDoS, brute-forcing, unauthorized scanning), spam, malware distribution, material prohibited under applicable law, or evading sanctions where doing so is explicitly illegal.
We do not technically read your traffic's content (see "What We Store and What We Don't"), but that doesn't relieve you of responsibility for what you do — encrypting the channel doesn't make an illegal act inside it legal.
3. ACCOUNT
You're responsible for safeguarding your credentials — most importantly a Ghost Protocol seed phrase: it can't be recovered, and losing it is equivalent to losing the account. Transferring an account to a third party is discouraged and done at your own risk.
4. SUBSCRIPTION AND PAYMENT
Payment is accepted in TON per the current pricing. Refund rules are covered in a separate "Refund Policy" document.
A free trial period (where available) is granted once per Telegram-linked account; technical measures prevent re-claiming a trial by creating new anonymous accounts.
5. SERVICE AVAILABILITY
We work to keep nodes running continuously but don't guarantee 100% uptime — the network is made of individual servers, and any one node may temporarily go offline; the rest of the network remains available.
6. LIMITATION OF LIABILITY
The service is provided "as is." We aren't liable for consequences of using the service in violation of Section 2, nor for the actions of third parties (ISPs, the TON blockchain network, Telegram) outside our control.
7. CHANGES TO THESE TERMS
We may update these terms; continued use after a new version is published means you accept it.
8. OPERATOR AND JURISDICTION
[The operator's name and applicable jurisdiction will be listed here once legal entity/sole-proprietor registration is complete. Until then this document is informational.]
EOF`,
},
{
title: "Refund Policy",
category: "Legal",
status: "Public",
content: `REFUND POLICY
Payment is accepted in TON cryptocurrency. Because of how blockchains work, confirmed transactions are technically irreversible — neither we nor you can "undo" a transfer once it's confirmed on-chain.
WHEN A REFUND IS POSSIBLE
- Accidental overpayment (you sent more than the invoiced amount) — the excess is refunded manually after contacting support.
- A technical failure on our side that prevented an activated plan from being usable, where re-granting access isn't possible.
In these cases the refund is issued manually to a TON address you provide; the network fee is covered by [party to be finalized in the final version of this policy].
WHEN A REFUND ISN'T AVAILABLE
- You changed your mind after activating a plan and partially using the period.
- The account was banned for violating the Terms of Use.
- You lost access to your account because you lost a Ghost Protocol seed phrase — that's not a service malfunction but an inherent property of the anonymous sign-in model, which is disclosed when the seed is generated.
HOW TO REQUEST A REFUND
Contact support via the @ntrnr_vpn_bot Telegram bot with the transaction's tx hash and a description of the issue. Non-standard cases are resolved at the service operator's discretion.
EOF`,
},
];
+316
View File
@@ -0,0 +1,316 @@
// Контент раздела /ru/docs. Поля соответствуют схеме коллекции PocketBase
// `documents_ru`: title, category (Core|Security|Network|Legal),
// status (Encrypted|Public|Internal|Dynamic), content (обычный текст,
// рендерится через white-space: pre-wrap — без markdown-разметки).
//
// Технические детали (протокол NRXP, ChaCha20-Poly1305, X25519, TLS-маскировка,
// httponly-cookie сессии) сверены с реальной реализацией в netrunner-proxy и
// netrunner-backend на момент написания — не маркетинговые обещания.
//
// Разделы Legal содержат плейсхолдеры в квадратных скобках там, где нужны
// реальные реквизиты оператора сервиса (юрлицо/ИП, юрисдикция, e-mail) —
// на момент написания сервис ещё не зарегистрирован ни в одной юрисдикции.
// Замени плейсхолдеры перед тем, как полагаться на эти документы юридически.
export const DOCS_RU = [
// ───────────────────────────── CORE ─────────────────────────────
{
title: "С чего начать",
category: "Core",
status: "Public",
content: `НЕТРАННЕР // ПЕРВЫЙ ЗАПУСК
Регистрация e-mail или телефона не требуется — вход в аккаунт устроен так, чтобы связывать с вами как можно меньше данных.
СПОСОБ 1 — TELEGRAM
Откройте бота @ntrnr_vpn_bot и нажмите «Личный кабинет». Вход подтверждается официальным Telegram Login Widget — сервер проверяет криптографическую подпись Telegram, ваш пароль от Telegram серверу VPN никогда не передаётся.
СПОСОБ 2 — GHOST PROTOCOL (полностью анонимный)
Если не хотите привязывать аккаунт даже к Telegram — сгенерируйте seed-фразу прямо в личном кабинете. Seed — единственный ключ от аккаунта: он не хранится нигде, кроме как у вас, и не привязан к номеру телефона, e-mail или Telegram ID.
ВАЖНО: seed нельзя восстановить. Потеряли — потеряли аккаунт и всё, что на нём. Обязательно скачайте .txt с seed-фразой и сохраните офлайн (менеджер паролей, бумага — что угодно, кроме скриншота в общем облаке).
ДАЛЬШЕ
1. Скачайте клиент под свою платформу в разделе /download (Windows, Linux и Android доступны сейчас; macOS и iOS — в разработке).
2. В личном кабинете активируйте тариф — есть бесплатный триал для аккаунтов, привязанных к Telegram (анти-абуз: на Ghost-аккаунты триал не выдаётся, иначе его штампуют скриптами).
3. Подключение конфигурируется автоматически — вручную ничего прописывать не нужно.
EOF`,
},
{
title: "Тарифы, оплата и биллинг",
category: "Core",
status: "Public",
content: `ОПЛАТА // TON
Оплата принимается только в криптовалюте TON — без банковских карт и без привязки к платёжным системам, которые требуют паспортные данные.
КАК ЭТО РАБОТАЕТ
1. Выбираете тариф — сервер выставляет счёт (invoice) с точной суммой в TON по актуальному курсу на момент выставления.
2. Переводите указанную сумму на адрес мастер-кошелька с указанным в счёте комментарием (memo) — именно по нему платёж и опознаётся однозначно.
3. Сервер проверяет транзакцию в блокчейне TON напрямую: сверяет сумму (не меньше выставленной), адрес назначения и memo. Один и тот же tx hash не может быть засчитан дважды.
4. Статус меняется на «оплачено» автоматически, без ручной модерации.
ПОЧЕМУ ТОЛЬКО TON
Прозрачность и проверяемость: платёж подтверждается математически по данным блокчейна, а не по факту доверия к процессингу. Обратная сторона — переводы в блокчейне необратимы, см. «Политику возврата средств» в разделе Legal.
РЕФЕРАЛЬНАЯ ПРОГРАММА
Ссылка на приглашение доступна в личном кабинете. Самоприглашение (один и тот же человек как реферер и реферал) заблокировано на сервере технически, а не только на словах.
EOF`,
},
// ─────────────────────────── SECURITY ───────────────────────────
{
title: "Модель угроз и шифрование",
category: "Security",
status: "Encrypted",
content: `THREAT_MODEL.LOG
ОТ ЧЕГО ЗАЩИЩАЕТ NETRUNNER
- Пассивный перехват трафика на пути провайдер → сайт (Wi-Fi в кафе, оператор связи, транзитные сети).
- DPI-классификация и блокировка по протоколу (см. «Маскировка трафика и обход DPI»).
- Привязка активности к личности через классические каналы регистрации (e-mail/телефон) — за счёт Ghost Protocol.
ОТ ЧЕГО НЕ ЗАЩИЩАЕТ И НЕ МОЖЕТ ЗАЩИЩАТЬ НИ ОДИН VPN
- Компрометация самого устройства (вредоносное ПО, кейлоггер, физический доступ).
- Деанонимизация через сами посещаемые сервисы, если вы входите там под своим именем.
- Юридическую ответственность за содержание трафика — шифрование канала не делает законным то, что по нему передаётся.
КРИПТОГРАФИЯ ТУННЕЛЯ
Каждый кадр данных внутри туннеля защищён AEAD-шифрованием ChaCha20-Poly1305 — это одновременно шифрование и проверка целостности (auth tag): подмена или изменение хотя бы одного бита в пути обнаруживается, и кадр отбрасывается.
Обмен ключами — X25519 (эллиптическая криптография Диффи-Хеллмана), ключ для сессии генерируется заново при каждом установлении туннеля (ephemeral). Это даёт forward secrecy: даже если бы ключ одной сессии где-то утёк, это не расшифровывает ни прошлые, ни будущие сессии — у каждой свой одноразовый ключ. Финальные ключи шифрования выводятся из результата обмена через HKDF, а не используются напрямую.
Каждый кадр несёт HMAC-тег, построенный по принципу, похожему на TOTP (временная составляющая) — это защита от replay-атак: перехваченный и повторно отправленный кадр не пройдёт проверку.
Управляющие кадры дополняются случайным паддингом — это усложняет анализ трафика по длинам пакетов.
EOF`,
},
{
title: "Маскировка трафика и обход DPI",
category: "Security",
status: "Encrypted",
content: `STEALTH_LAYER.LOG
DPI (Deep Packet Inspection) — это то, чем провайдеры и цензурирующие системы отличают «обычный HTTPS» от VPN/прокси и блокируют второе. Большинство VPN-протоколов имеют узнаваемый отпечаток именно на этом уровне, а не в самом шифровании.
ЧТО МЫ ДЕЛАЕМ
Первый пакет туннеля выглядит как настоящее TLS-рукопожатие (ClientHello/ServerHello) — не приблизительно, а побайтово повторяя порядок и состав cipher suite'ов, TLS-расширений и GREASE-значений реального браузера (профиль JA3/JA4). Для DPI, который классифицирует трафик по отпечатку хендшейка, наш трафик неотличим от обычного захода в браузер на https-сайт.
Настоящий обмен ключами (X25519) физически спрятан внутри полей поддельного TLS-хендшейка — например, в расширении KeyShare и в поле session_id, которое по стандарту выглядит как случайные байты, а на деле несёт часть аутентификационного тега.
ЧЕСТНО О ГРАНИЦАХ ЭТОГО ПОДХОДА
Это не настоящий TLS: нет цепочки сертификатов, нет проверки подлинности сервера в смысле обычного HTTPS, потому что задача — не защитить веб-страницу, а спрятать сам факт туннелирования от систем классификации трафика. Реальная защита данных обеспечивается отдельным слоем (см. «Модель угроз и шифрование»), а не этим слоем маскировки.
Если соединение по какой-то причине не опознаётся сервером как легитимное (например, кто-то сканирует порт, пытаясь понять, что на нём висит), сервер незаметно «отражает» такую попытку на постороннее содержимое (stealth fallback) — снаружи это выглядит как обычный веб-сервер, а не как узел, который специфично отвечает на VPN-запросы и потому вычисляется сканированием.
EOF`,
},
{
title: "Аутентификация и хранение сессии",
category: "Security",
status: "Encrypted",
content: `SESSION.LOG
НИКАКОГО ТОКЕНА В LOCALSTORAGE
Сессия в личном кабинете и на сайте держится исключительно в HttpOnly-cookie — то есть в cookie, которую браузерный JavaScript в принципе не может прочитать (ни ваш скрипт, ни чужой инжектированный при XSS). Токен нигде не лежит в localStorage/sessionStorage: если бы он там лежал, украсть его мог бы любой скрипт, который вообще смог выполниться на странице.
Короткоживущий access-токен (15 минут) выдаётся вместе с ротируемым refresh-токеном (30 дней). При обращении к API с истёкшим access-токеном сессия обновляется автоматически в фоне, без повторного логина. В базе хранится не сам refresh-токен, а только его SHA-256-хеш — даже при утечке БД восстановить токен из хеша невозможно.
Если сервер видит попытку повторно использовать уже отозванный (ротированный) refresh-токен — это однозначный сигнал компрометации, и в ответ отзываются все сессии аккаунта, а не только эта.
ЗАЩИТА ОТ CSRF
Для запросов, которые меняют состояние (оплата, смена настроек и т.д.) и идут по cookie-сессии, сервер дополнительно сверяет заголовки Origin/Referer со списком разрешённых доменов — сторонний сайт не может незаметно от вашего имени дёрнуть API, даже если ваша cookie действительна.
ВХОД ЧЕРЕЗ TELEGRAM
Подпись данных от Telegram Login Widget проверяется по HMAC-SHA256 постоянным по времени сравнением (без утечки по таймингу через посимвольное сравнение) — так исключается и подделка данных виджета, и атака по времени отклика сервера.
EOF`,
},
{
title: "Что мы храним, а что нет",
category: "Security",
status: "Public",
content: `DATA_RETENTION.LOG
Коротко: мы храним ровно то, что нужно для работы аккаунта и биллинга, и не храним журнал вашей активности в интернете.
ЧТО ХРАНИТСЯ НА СЕРВЕРЕ
- Идентификатор аккаунта: Telegram ID (если вошли через Telegram) ИЛИ ничего личного вообще (если вошли через Ghost Protocol — там нет ни email, ни телефона, ни имени).
- Статус подписки и история платежей (сумма, tx hash в блокчейне TON, дата) — нужно для биллинга и разрешения споров по оплате.
- Хеш (не сам токен) активной сессии и время её создания — нужно для защиты от кражи сессии и выхода со всех устройств по требованию.
- Реферальная связь между аккаунтами (кто кого пригласил) — только для начисления бонусов.
ЧЕГО НЕТ
- Журнала посещённых через VPN сайтов, IP-адресов назначения, содержимого трафика — на уровне протокола туннеля (NRXP) нет кода, который писал бы подобное в базу данных.
- Истории «кто когда подключался к какой ноде» дольше, чем нужно для мгновенного health-check (жив ли сервер сейчас прямо сейчас) — это проверка состояния инфраструктуры, а не журнал активности пользователя.
ЧТО ВАЖНО ПОНИМАТЬ ЧЕСТНО
Серверные процессы, как у любого сервиса, в моменте пишут технический эксплуатационный лог — ошибки, старт/остановка соединений — для отладки инфраструктуры. Это не структурированная база данных вашей активности и не привязывается к личности, но было бы нечестно утверждать, что вообще ни один процесс в мире никогда не пишет ни одной строки в лог. Если для вашей модели угроз это принципиально — используйте Ghost Protocol и не публикуйте на сторонних ресурсах данные, которые можно связать с вашей учётной записью.
EOF`,
},
{
title: "Ответственное раскрытие уязвимостей",
category: "Security",
status: "Public",
content: `BUG_BOUNTY.LOG // RESPONSIBLE DISCLOSURE
Нашли уязвимость — в туннеле, в личном кабинете, в боте или в биллинге? Расскажите нам раньше, чем кому-то ещё.
ПРАВИЛА
1. Не эксплуатируйте находку дальше, чем нужно для доказательства (PoC) — никаких массовых выгрузок чужих данных, DoS, попыток вывода чужих средств.
2. Дайте нам разумное время на исправление, прежде чем публиковать детали публично.
3. Не трогайте чужие аккаунты и данные без явного согласия их владельцев.
КАК СООБЩИТЬ
Напишите в поддержку через Telegram-бота @ntrnr_vpn_bot с пометкой SECURITY в первом сообщении — это самый быстрый способ достучаться до тех, кто реально может исправить проблему.
[Отдельный e-mail для security-репортов и публичный PGP-ключ будут добавлены сюда после регистрации юридического лица — см. также раздел Legal.]
Мы не обещаем денежных вознаграждений (bug bounty в классическом смысле) на данный момент, но обещаем: разбор в приоритете, публичную благодарность (если сами захотите) и что вашу находку не проигнорируют.
EOF`,
},
// ─────────────────────────── NETWORK ───────────────────────────
{
title: "Архитектура туннеля",
category: "Network",
status: "Internal",
content: `ARCH.MAP // ВНУТРЕННЕЕ УСТРОЙСТВО
Три слоя, снизу вверх:
1. RAWCAST — локальный уровень на устройстве клиента. Реальные TCP/UDP-соединения приложений (браузер, мессенджер и т.д.) описываются компактным кадром с полями протокола, типа события, локального socket_id, адреса и порта назначения. Этот кадр — внутренний формат, наружу по сети он не ходит.
2. NRXP (Netrunner eXchange Protocol) — прикладной протокол внутри туннеля. RawCast-кадр упаковывается в кадр NRXP: 25-байтовый заголовок (auth tag, id потока, тип, длина полезной нагрузки, длина паддинга) + сама нагрузка. Множество независимых потоков (например, десяток одновременных соединений вашего браузера) мультиплексируются в одном туннеле — не нужно поднимать отдельное соединение на каждое; id потока в NRXP как раз и различает их.
3. ТРАНСПОРТ — каждый кадр NRXP шифруется (ChaCha20-Poly1305) и упаковывается как одна запись TLS ApplicationData внутри замаскированного TLS-хендшейка (см. «Маскировка трафика и обход DPI»). Снаружи — неотличимо от HTTPS-сессии браузера.
НА УСТРОЙСТВЕ
Клиент перехватывает трафик через TUN-интерфейс и собственный userspace-стек (smoltcp) — работает на уровне IP-пакетов, а не полагается на системные прокси-настройки ОС, которые не все приложения соблюдают.
НА СЕРВЕРЕ
Нода принимает входящее соединение, проверяет, что это легитимный NRXP-клиент (а не сканер порта или случайный HTTPS-запрос), и либо поднимает туннель, либо тихо «отражает» соединение как обычный веб-сервер (stealth fallback), не выдавая, что порт вообще что-то знает про VPN.
ВЫБОР НОДЫ
Список доступных серверов и параметры маршрутизации отдаются авторизованному клиенту сервером API по короткоживущему одноразовому токену — вручную ничего прописывать не требуется, а сама возможность получить список нод завязана на активную подписку.
EOF`,
},
{
title: "Клиенты и платформы",
category: "Network",
status: "Public",
content: `PLATFORMS.LOG
Ядро (движок туннеля) написано на Rust и переиспользуется целиком между платформами — криптография и протокол одни и те же везде, а не переписаны заново под каждую ОС со своими багами.
ГОТОВО И ДОСТУПНО СЕЙЧАС
- Linux — нативный клиент, TUN-интерфейс, требует cap_net_admin/cap_net_raw (или root).
- Windows — нативный десктопный клиент.
- Android — приложение на Kotlin поверх Rust-ядра, собранного через UniFFI/JNI под arm64/armv7/x86/x86_64.
В РАЗРАБОТКЕ
- macOS — десктопный клиент в работе.
- iOS — идёт бета-тестирование.
Актуальные версии и ссылки на скачивание — на странице /download; там же видно, какая платформа уже «активна», а какая ещё в разработке — статус не приукрашивается.
EOF`,
},
// ──────────────────────────── LEGAL ────────────────────────────
{
title: "Политика конфиденциальности",
category: "Legal",
status: "Public",
content: `ПОЛИТИКА КОНФИДЕНЦИАЛЬНОСТИ
Дата последнего обновления: [указать при публикации]
1. КТО МЫ
Оператор сервиса Netrunner VPN: [наименование юридического или физического лица — будет указано здесь после завершения регистрации; до этого момента настоящий документ носит информационный, а не окончательный юридический характер].
2. КАКИЕ ДАННЫЕ МЫ СОБИРАЕМ
- При входе через Telegram: Telegram ID и данные, которые сам Telegram Login Widget передаёт при авторизации (имя, username, фото — если применимо).
- При входе через Ghost Protocol: персональные данные не собираются — seed-фраза генерируется на вашей стороне и не привязана к личности.
- Данные платежей: сумма, идентификатор транзакции в блокчейне TON, дата — платежи в TON публичны в самом блокчейне независимо от нас.
Подробнее о том, что именно хранится и что не хранится на техническом уровне — см. документ «Что мы храним, а что нет» в разделе Security.
3. ДЛЯ ЧЕГО ИСПОЛЬЗУЮТСЯ ДАННЫЕ
Исключительно для предоставления сервиса: аутентификация, выдача доступа к VPN-нодам согласно активной подписке, обработка платежей, реферальные начисления, ответы в поддержке.
4. ПЕРЕДАЧА ТРЕТЬИМ ЛИЦАМ
Данные не продаются и не передаются третьим лицам для маркетинга. Технически необходимая передача происходит только через сеть Telegram (для входа через неё) и публичный блокчейн TON (для платежей) — оба этих канала работают по своим собственным правилам, не под нашим контролем.
5. ВАШИ ПРАВА
Вы можете запросить удаление аккаунта и связанных с ним данных через поддержку в Telegram-боте. Обратите внимание: запись о платеже в блокчейне TON удалить невозможно физически — блокчейн неизменяем по своей природе.
6. КОНТАКТЫ
Поддержка: Telegram-бот @ntrnr_vpn_bot.
Отдельный юридический адрес и e-mail для официальных запросов будут опубликованы здесь после регистрации оператора сервиса.
EOF`,
},
{
title: "Условия использования",
category: "Legal",
status: "Public",
content: `УСЛОВИЯ ИСПОЛЬЗОВАНИЯ
Дата последнего обновления: [указать при публикации]
1. ПРИНЯТИЕ УСЛОВИЙ
Используя сервис Netrunner VPN (сайт, бот, клиентские приложения), вы соглашаетесь с настоящими условиями. Если не согласны — не используйте сервис.
2. ДОПУСТИМОЕ ИСПОЛЬЗОВАНИЕ
Сервис предоставляется для законного использования в юрисдикции, из которой вы к нему обращаетесь. Запрещается использовать сервис для: атак на инфраструктуру третьих лиц (DDoS, брутфорс, сканирование без разрешения), рассылки спама, распространения вредоносного ПО, оборота материалов, запрещённых применимым законодательством, и обхода санкционных ограничений там, где это прямо запрещено законом.
Мы технически не читаем содержимое вашего трафика (см. «Что мы храним, а что нет»), но это не освобождает вас от ответственности за то, что вы делаете, — шифрование канала не делает законным незаконное действие внутри него.
3. АККАУНТ
Вы несёте ответственность за сохранность своих учётных данных — в первую очередь seed-фразы Ghost Protocol: она не восстанавливается, и её утеря равносильна потере аккаунта. Передача аккаунта третьим лицам не рекомендуется и осуществляется на ваш собственный риск.
4. ПОДПИСКА И ОПЛАТА
Оплата принимается в TON согласно действующим тарифам. Правила по возвратам вынесены в отдельный документ «Политика возврата средств».
Бесплатный триал-период (если доступен) предоставляется один раз на аккаунт, привязанный к Telegram; технические меры предотвращают повторное получение триала через создание новых анонимных аккаунтов.
5. ДОСТУПНОСТЬ СЕРВИСА
Мы прилагаем усилия для непрерывной работы нод, но не гарантируем 100% аптайм — сеть состоит из отдельных серверов, и отдельная нода может временно уйти в офлайн; в таком случае доступны остальные ноды сети.
6. ОГРАНИЧЕНИЕ ОТВЕТСТВЕННОСТИ
Сервис предоставляется «как есть». Мы не несём ответственности за последствия использования сервиса в нарушение пункта 2, а также за действия третьих лиц (провайдеров, блокчейн-сети TON, Telegram) вне нашего контроля.
7. ИЗМЕНЕНИЯ УСЛОВИЙ
Мы можем обновлять настоящие условия; продолжение использования сервиса после публикации новой версии означает согласие с ней.
8. ОПЕРАТОР И ЮРИСДИКЦИЯ
[Наименование оператора и применимая юрисдикция будут указаны здесь после завершения регистрации юридического лица/ИП. До этого момента документ носит информационный характер.]
EOF`,
},
{
title: "Политика возврата средств",
category: "Legal",
status: "Public",
content: `ПОЛИТИКА ВОЗВРАТА СРЕДСТВ
Оплата принимается в криптовалюте TON. В силу природы блокчейна подтверждённые транзакции необратимы технически — ни мы, ни вы не можете «отменить» перевод после его подтверждения в сети.
КОГДА ВОЗВРАТ ВОЗМОЖЕН
- Ошибочная переплата (перевели больше суммы, указанной в счёте) — избыток возвращается вручную после обращения в поддержку.
- Технический сбой на нашей стороне, из-за которого оплаченный тариф не был активирован, а исправить это перевыставлением доступа невозможно.
Возврат в этих случаях выполняется вручную на указанный вами TON-адрес; комиссию сети оплачивает [сторона будет уточнена в финальной версии политики].
КОГДА ВОЗВРАТ НЕ ПРЕДУСМОТРЕН
- Вы передумали после активации тарифа и частичного использования периода.
- Аккаунт заблокирован за нарушение «Условий использования».
- Потеря доступа к аккаунту из-за утери seed-фразы Ghost Protocol — это не техническая ошибка сервиса, а особенность анонимной модели входа, о которой предупреждено при генерации seed.
КАК ЗАПРОСИТЬ ВОЗВРАТ
Обратитесь в поддержку через Telegram-бота @ntrnr_vpn_bot, указав tx hash транзакции и суть проблемы. Решение по нестандартным случаям — на усмотрение оператора сервиса.
EOF`,
},
];
+97
View File
@@ -0,0 +1,97 @@
// Наполняет коллекции `documents_ru`/`documents_en` в PocketBase реальным
// контентом раздела /docs (сейчас там только два "заглушечных" документа с
// обрубленным текстом — этот скрипт заменяет их полным набором и добавляет
// остальные документы).
//
// Не хранит и не запрашивает секреты нигде, кроме переменных окружения самого
// оператора — админ-доступ к PocketBase не должен попадать ни в код, ни в чат.
//
// Запуск:
// PB_URL=https://netrunner-vpn.com/cms-api \
// PB_ADMIN_EMAIL=you@example.com \
// PB_ADMIN_PASSWORD='...' \
// node scripts/seed-docs.mjs [--lang=ru|en|all] [--apply]
//
// По умолчанию — dry-run для обоих языков (только печатает, что будет
// сделано). Чтобы реально записать в PocketBase, добавь флаг --apply.
import PocketBase from "pocketbase";
import { DOCS_RU } from "./docs-content.ru.mjs";
import { DOCS_EN } from "./docs-content.en.mjs";
const PB_URL = process.env.PB_URL || "https://netrunner-vpn.com/cms-api";
const APPLY = process.argv.includes("--apply");
const langArg = process.argv.find((a) => a.startsWith("--lang="));
const LANGS = langArg
? langArg.split("=")[1] === "all"
? ["ru", "en"]
: [langArg.split("=")[1]]
: ["ru", "en"];
// Старые "заглушечные" записи в проде названы иначе, чем новые полноценные
// документы, которые их заменяют, — без этой карты скрипт создал бы дубликаты
// вместо замены обрубленных стаб-текстов.
const LEGACY_TITLE_ALIASES = {
ru: {
"Политика конфиденциальности": "Политика конфиденциальности (Privacy Policy)",
"Условия использования": "Условия использования и оплаты (Terms of Use)",
},
en: {
"Privacy Policy": "Privacy Policy",
"Terms of Use": "Terms of Use & Payment",
},
};
const DOCS_BY_LANG = { ru: DOCS_RU, en: DOCS_EN };
async function seedLang(pb, lang) {
const docs = DOCS_BY_LANG[lang];
const aliases = LEGACY_TITLE_ALIASES[lang];
const collection = pb.collection(`documents_${lang}`);
const existing = await collection.getFullList({ requestKey: null });
const byTitle = new Map(existing.map((r) => [r.title, r]));
console.log(`\n=== documents_${lang} ===`);
for (const doc of docs) {
const match = byTitle.get(doc.title) || byTitle.get(aliases[doc.title]);
if (match) {
console.log(`UPDATE "${doc.title}" (id=${match.id})`);
if (APPLY) await collection.update(match.id, doc);
} else {
console.log(`CREATE "${doc.title}"`);
if (APPLY) await collection.create(doc);
}
}
console.log(`Всего документов: ${docs.length}`);
}
async function main() {
const pb = new PocketBase(PB_URL);
if (APPLY) {
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!email || !password) {
console.error(
"Нужны PB_ADMIN_EMAIL и PB_ADMIN_PASSWORD в окружении для записи (--apply).",
);
process.exit(1);
}
await pb.collection("_superusers").authWithPassword(email, password);
} else {
console.log(
"[dry-run] Ничего не записываю. Проверь вывод ниже, затем перезапусти с --apply.",
);
}
for (const lang of LANGS) {
await seedLang(pb, lang);
}
if (!APPLY) console.log("\nЭто был dry-run — добавь --apply, чтобы записать.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+3 -3
View File
@@ -70,14 +70,14 @@ const config = {
}, },
// Tailwind автоматически создаст классы: cursor-net-default, cursor-net-pointer и т.д. // Tailwind автоматически создаст классы: cursor-net-default, cursor-net-pointer и т.д.
cursor: { cursor: {
"net-default": "url('/cursors/default.png') 0 0, auto", "net-default": "url('/cursors/default.png') 1 1, auto",
"net-pointer": "url('/cursors/pointer.png') 14 0, pointer", "net-pointer": "url('/cursors/pointer.png') 13 1, pointer",
"net-text": "url('/cursors/text.png') 16 16, text", "net-text": "url('/cursors/text.png') 16 16, text",
"net-wait": "url('/cursors/wait.png') 16 16, wait", // Переименовал в wait.png для единообразия "net-wait": "url('/cursors/wait.png') 16 16, wait", // Переименовал в wait.png для единообразия
"net-crosshair": "url('/cursors/crosshair.png') 16 16, crosshair", "net-crosshair": "url('/cursors/crosshair.png') 16 16, crosshair",
"net-move": "url('/cursors/move.png') 16 16, move", "net-move": "url('/cursors/move.png') 16 16, move",
"net-not-allowed": "url('/cursors/not-allowed.png') 16 16, not-allowed", "net-not-allowed": "url('/cursors/not-allowed.png') 16 16, not-allowed",
"net-help": "url('/cursors/help.png') 0 0, help", "net-help": "url('/cursors/help.png') 1 1, help",
"net-grab": "url('/cursors/grab.png') 16 16, grab", "net-grab": "url('/cursors/grab.png') 16 16, grab",
"net-grabbing": "url('/cursors/grabbing.png') 16 16, grabbing", "net-grabbing": "url('/cursors/grabbing.png') 16 16, grabbing",
"net-resize-h": "url('/cursors/resize-h.png') 16 16, ew-resize", "net-resize-h": "url('/cursors/resize-h.png') 16 16, ew-resize",