From c844874b0a6f8a855cb47229e47d2498d9b49145 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 25 Aug 2026 17:47:04 +0700 Subject: [PATCH] =?UTF-8?q?feat(launcher):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82?= =?UTF-8?q?=D1=8B=20GEO=20=D0=B8=20blocking=20=D0=B4=D0=BB=D1=8F=20MikroTi?= =?UTF-8?q?k=207.22+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пробы выполняются на роутере через /tool fetch и уходят в те же ingest API. Ежедневный запуск ставит scheduler, который каждый день заново качает .rsc со свежим токеном. Co-authored-by: Cursor --- .gitattributes | 2 + AGENTS.md | 4 + apps/api/scripts/censorcheck/launcher.rsc | 176 +++++++++++ apps/api/scripts/ipregion/launcher.rsc | 293 ++++++++++++++++++ apps/api/src/plugins/auth.ts | 4 +- apps/api/src/routes/launcher.test.ts | 134 ++++++++ apps/api/src/routes/launcher.ts | 64 ++++ .../components/censorcheck/blocking-page.tsx | 25 +- apps/web/src/components/censorcheck/types.ts | 4 + apps/web/src/components/ipregion/geo-page.tsx | 25 +- apps/web/src/components/ipregion/types.ts | 4 + apps/web/src/components/page-header.tsx | 2 +- docs/deploy-traefik.md | 2 +- 13 files changed, 729 insertions(+), 10 deletions(-) create mode 100644 apps/api/scripts/censorcheck/launcher.rsc create mode 100644 apps/api/scripts/ipregion/launcher.rsc diff --git a/.gitattributes b/.gitattributes index 22a3a25..145d5b6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ apps/api/scripts/censorcheck/*.sh text eol=lf +apps/api/scripts/censorcheck/*.rsc text eol=lf apps/api/scripts/ipregion/*.sh text eol=lf +apps/api/scripts/ipregion/*.rsc text eol=lf diff --git a/AGENTS.md b/AGENTS.md index 4885611..8d58d8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,9 +94,11 @@ vps-tracker/ Ручная проверка с VPS: `curl -fsSL https://vt.shnt.top/cc | bash` (тот же контейнер, Traefik dual Host). Раз в сутки: `curl -fsSL https://vt.shnt.top/cc | bash -s -- --daily` (cron, свежий HMAC-токен на каждый запуск). +MikroTik 7.22+: `/tool fetch url="https://vt.shnt.top/cc.rsc" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc` (ежедневно: `?daily=1`, scheduler качает `.rsc` заново). - **Vendor:** `apps/api/scripts/censorcheck/censorcheck.sh` (pin SHA `12c5839`, MIT) - **Launcher:** `GET /cc` минтит HMAC ingest-токен (TTL 20 мин); по `/etc/os-release` ставит `jq`/`dig`/`column` без prompt; прогресс-бар в stderr; `GET /cc/vendor` — скрипт (LF); `--daily` / `--remove-daily` +- **MikroTik:** `GET /cc.rsc` — RouterOS-скрипт (HTTPS GET, без DPI); `?daily=1` / `?remove=daily`; scheduler `vt-cc` - **Ingest:** `POST /api/integrations/censorcheck/runs` (без portal JWT) - **UI:** `/blocking` — текущие прогоны и история, группировка VPS / сервис - Env: `CENSORCHECK_INGEST_SECRET`, `CENSORCHECK_PUBLIC_URL`, `VPS_LAUNCHER_DOMAIN` @@ -105,9 +107,11 @@ vps-tracker/ Ручная проверка с VPS: `curl -fsSL https://vt.shnt.top/ic | bash`. Раз в сутки: `curl -fsSL https://vt.shnt.top/ic | bash -s -- --daily`. +MikroTik 7.22+: `/tool fetch url="https://vt.shnt.top/ic.rsc" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc` (`?daily=1`). - **Vendor:** `apps/api/scripts/ipregion/ipregion.sh` (pin SHA `7d1c25c`, MIT, [vernette/ipregion](https://github.com/vernette/ipregion)) - **Launcher:** `GET /ic` минтит HMAC ingest-токен (тот же `CENSORCHECK_INGEST_SECRET`); `GET /ic/vendor` — pinned скрипт (LF); `--daily` / `--remove-daily` +- **MikroTik:** `GET /ic.rsc` — primary GeoIP JSON + Cloudflare CDN; scheduler `vt-ic` - **Ingest:** `POST /api/integrations/ipregion/runs` (без portal JWT) - **UI:** `/geo` — матрица ISO-стран VPS × сервисы (primary / custom / cdn) diff --git a/apps/api/scripts/censorcheck/launcher.rsc b/apps/api/scripts/censorcheck/launcher.rsc new file mode 100644 index 0000000..6e8e9ec --- /dev/null +++ b/apps/api/scripts/censorcheck/launcher.rsc @@ -0,0 +1,176 @@ +# VPS Tracker — blocking launcher for RouterOS 7.22+ +# /tool fetch url="__VT_API_URL__/cc.rsc" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc +# HTTPS GET only (no DPI/SNI). Ingest: POST /api/integrations/censorcheck/runs + +:local vtApi "__VT_API_URL__" +:local vtToken "__VT_INGEST_TOKEN__" +:local vtDaily "__VT_DAILY__" +:local vtRemove "__VT_REMOVE_DAILY__" +:local launcherVer "ros-1" +:local schedName "vt-cc" +:local dstFile "vt-cc.rsc" + +:local ver [/system resource get version] +:local dot [:find $ver "."] +:if ([:typeof $dot] = "nil") do={ + :error ("Need RouterOS 7.22+ (got " . $ver . ")") +} +:local major [:tonum [:pick $ver 0 $dot]] +:local rest [:pick $ver ($dot + 1) [:len $ver]] +:local cut [:len $rest] +:local d2 [:find $rest "."] +:local sp [:find $rest " "] +:if ([:typeof $d2] != "nil") do={ :set cut $d2 } +:if (([:typeof $sp] != "nil") and (($cut = [:len $rest]) or ($sp < $cut))) do={ :set cut $sp } +:local minor [:tonum [:pick $rest 0 $cut]] +:if (($major < 7) or (($major = 7) and ($minor < 22))) do={ + :error ("Need RouterOS 7.22+ (got " . $ver . ")") +} + +:if ($vtRemove = "yes") do={ + :do { /system scheduler remove [find name=$schedName] } on-error={} + :put ("Ежедневная проверка снята (" . $schedName . ")") +} else={ + +:put ("censorcheck launcher " . $launcherVer . " (RouterOS)") + +:local r +:local publicIp "" +:do { + :set r [/tool fetch url="https://api.ipify.org" output=user as-value] + :if (($r->"status") = "finished") do={ + :set publicIp ($r->"data") + } +} on-error={} +:local cr [:find $publicIp "\r"] +:if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] } +:local lf [:find $publicIp "\n"] +:if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] } +:if ([:len $publicIp] = 0) do={ + :do { + :set r [/tool fetch url="https://ifconfig.me/ip" output=user as-value] + :if (($r->"status") = "finished") do={ + :set publicIp ($r->"data") + } + } on-error={} + :set cr [:find $publicIp "\r"] + :if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] } + :set lf [:find $publicIp "\n"] + :if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] } +} +:if ([:len $publicIp] = 0) do={ + :error "Не удалось определить публичный IP" +} + +:local hoster "" +:do { + :set r [/tool fetch url=("https://ipwho.is/" . $publicIp) output=user as-value] + :if (($r->"status") = "finished") do={ + :local j [:deserialize from=json value=($r->"data")] + :local isp ($j->"connection"->"isp") + :if ([:typeof $isp] = "str") do={ :set hoster $isp } + :if ([:len $hoster] = 0) do={ + :local org ($j->"org") + :if ([:typeof $org] = "str") do={ :set hoster $org } + } + } +} on-error={} + +:local runId ("mt-" . [:rndstr length=16]) +:put ("probe IP: " . $publicIp) +:if ([:len $hoster] > 0) do={ :put ("хостер: " . $hoster) } +:put ("runId: " . $runId) +:put "Проверяю сайты (HTTPS GET, без DPI)..." + +:local hosts {\ + "youtube.com";"redirector.googlevideo.com";"discord.com";"instagram.com";"facebook.com";\ + "x.com";"linkedin.com";"rutracker.org";"digitalocean.com";"amnezia.org";"getoutline.org";\ + "mailfence.com";"flibusta.is";"rezka.ag";"api.telegram.org";"play.google.com";\ + "spotify.com";"netflix.com";"patreon.com";"swagger.io";"snyk.io";"mongodb.com";\ + "autodesk.com";"graylog.org";"redis.io";"copilot.microsoft.com"\ +} + +:local results ({}) +:local code 0 +:local data +:local line +:local nl +:local cr2 +:local sp1 +:local rest2 +:local sp2 +:local codeStr +:local n +:local item +:foreach host in=$hosts do={ + :set code 0 + :do { + :set r [/tool fetch url=("https://" . $host . "/") output=user-with-headers as-value] + :if (($r->"status") != "finished") do={ + :set code -1 + } else={ + :set data ($r->"data") + :set line $data + :set nl [:find $data "\n"] + :if ([:typeof $nl] != "nil") do={ :set line [:pick $data 0 $nl] } + :set cr2 [:find $line "\r"] + :if ([:typeof $cr2] != "nil") do={ :set line [:pick $line 0 $cr2] } + :if ([:pick $line 0 4] = "HTTP") do={ + :set sp1 [:find $line " "] + :if ([:typeof $sp1] != "nil") do={ + :set rest2 [:pick $line ($sp1 + 1) [:len $line]] + :set sp2 [:find $rest2 " "] + :set codeStr $rest2 + :if ([:typeof $sp2] != "nil") do={ :set codeStr [:pick $rest2 0 $sp2] } + :set n [:tonum $codeStr] + :if ([:typeof $n] = "num") do={ :set code $n } else={ :set code 200 } + } else={ :set code 200 } + } else={ :set code 200 } + } + } on-error={ + :set code 0 + } + :put ($host . " " . $code) + :set item { service=$host; raw={ https={ ipv4={ status=$code } } } } + :set ($results->[:len $results]) $item +} + +:local probe { publicIp=$publicIp } +:if ([:len $hoster] > 0) do={ :set ($probe->"hoster") $hoster } + +:local payload {\ + schemaVersion=1;\ + runId=$runId;\ + probe=$probe;\ + launcherVersion=$launcherVer;\ + censorcheck={ version="ros"; mode="https" };\ + results=$results\ +} +:local json [:serialize to=json value=$payload] +:local hdrs ("Content-Type: application/json,Authorization: Bearer " . $vtToken) +:put "Отправляю ingest..." +:do { + :set r [/tool fetch url=($vtApi . "/api/integrations/censorcheck/runs") http-method=post http-header-field=$hdrs http-data=$json output=user as-value] + :put ($r->"data") +} on-error={ + :put "API недоступен (fetch error)" +} + +:if ($vtDaily = "yes") do={ + :local ident [/system identity get name] + :local h ([:len $ident] + [:len [/system resource get architecture-name]]) + :local hour (3 + ($h % 4)) + :local minute (($h * 17) % 60) + :local hh $hour + :local mm $minute + :if ($hour < 10) do={ :set hh ("0" . $hour) } + :if ($minute < 10) do={ :set mm ("0" . $minute) } + :local startTime ($hh . ":" . $mm . ":00") + :local ev ("/tool fetch url=" . $vtApi . "/cc.rsc dst-path=" . $dstFile . "; /import file-name=" . $dstFile) + :do { /system scheduler remove [find name=$schedName] } on-error={} + /system scheduler add name=$schedName interval=1d start-time=$startTime on-event=$ev policy=read,write,test,policy comment="vps-tracker vt-cc" + :put ("Ежедневная проверка: каждый день в " . $startTime . " (" . $schedName . ")") + :put ("Снять: /tool fetch url=" . $vtApi . "/cc.rsc?remove=daily dst-path=" . $dstFile . "; /import file-name=" . $dstFile) +} + +} diff --git a/apps/api/scripts/ipregion/launcher.rsc b/apps/api/scripts/ipregion/launcher.rsc new file mode 100644 index 0000000..a713c60 --- /dev/null +++ b/apps/api/scripts/ipregion/launcher.rsc @@ -0,0 +1,293 @@ +# VPS Tracker — GeoIP launcher for RouterOS 7.22+ +# /tool fetch url="__VT_API_URL__/ic.rsc" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc +# Primary GeoIP JSON + Cloudflare CDN. Ingest: POST /api/integrations/ipregion/runs + +:local vtApi "__VT_API_URL__" +:local vtToken "__VT_INGEST_TOKEN__" +:local vtDaily "__VT_DAILY__" +:local vtRemove "__VT_REMOVE_DAILY__" +:local launcherVer "ros-1" +:local schedName "vt-ic" +:local dstFile "vt-ic.rsc" + +:local ver [/system resource get version] +:local dot [:find $ver "."] +:if ([:typeof $dot] = "nil") do={ + :error ("Need RouterOS 7.22+ (got " . $ver . ")") +} +:local major [:tonum [:pick $ver 0 $dot]] +:local rest [:pick $ver ($dot + 1) [:len $ver]] +:local cut [:len $rest] +:local d2 [:find $rest "."] +:local sp [:find $rest " "] +:if ([:typeof $d2] != "nil") do={ :set cut $d2 } +:if (([:typeof $sp] != "nil") and (($cut = [:len $rest]) or ($sp < $cut))) do={ :set cut $sp } +:local minor [:tonum [:pick $rest 0 $cut]] +:if (($major < 7) or (($major = 7) and ($minor < 22))) do={ + :error ("Need RouterOS 7.22+ (got " . $ver . ")") +} + +:if ($vtRemove = "yes") do={ + :do { /system scheduler remove [find name=$schedName] } on-error={} + :put ("Ежедневная проверка снята (" . $schedName . ")") +} else={ + +:put ("ipregion launcher " . $launcherVer . " (RouterOS)") + +:local r +:local j +:local isp +:local org +:local publicIp "" +:do { + :set r [/tool fetch url="https://api.ipify.org" output=user as-value] + :if (($r->"status") = "finished") do={ + :set publicIp ($r->"data") + } +} on-error={} +:local cr [:find $publicIp "\r"] +:if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] } +:local lf [:find $publicIp "\n"] +:if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] } +:if ([:len $publicIp] = 0) do={ + :do { + :set r [/tool fetch url="https://ifconfig.me/ip" output=user as-value] + :if (($r->"status") = "finished") do={ + :set publicIp ($r->"data") + } + } on-error={} + :set cr [:find $publicIp "\r"] + :if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] } + :set lf [:find $publicIp "\n"] + :if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] } +} +:if ([:len $publicIp] = 0) do={ + :error "Не удалось определить публичный IP" +} + +:local hoster "" +:do { + :set r [/tool fetch url=("https://ipwho.is/" . $publicIp) output=user as-value] + :if (($r->"status") = "finished") do={ + :set j [:deserialize from=json value=($r->"data")] + :set isp ($j->"connection"->"isp") + :if ([:typeof $isp] = "str") do={ :set hoster $isp } + :if ([:len $hoster] = 0) do={ + :set org ($j->"org") + :if ([:typeof $org] = "str") do={ :set hoster $org } + } + } +} on-error={} + +:local runId ("mt-" . [:rndstr length=16]) +:put ("probe IP: " . $publicIp) +:if ([:len $hoster] > 0) do={ :put ("хостер: " . $hoster) } +:put ("runId: " . $runId) +:put "Проверяю GeoIP (JSON, IPv4)..." + +:local names {\ + "maxmind.com";"rdap.db.ripe.net";"ipinfo.io";"cloudflare.com";"ipregistry.co";\ + "ipapi.co";"ifconfig.co";"ip2location.io";"iplocation.com";"country.is";\ + "geoapify.com";"geojs.io";"ipapi.is";"ipbase.com";"ipquery.io";"ipwho.is";\ + "ip-api.com";"cloudflare cdn"\ +} + +:local results ({}) +:local url "" +:local method "get" +:local postData "" +:local iso "N/A" +:local httpCode 0 +:local body "" +:local data +:local line +:local nl +:local cr2 +:local sp1 +:local rest2 +:local sp2 +:local codeStr +:local n +:local sep +:local p +:local plain +:local pcr +:local plf +:local ctry +:local colo +:local iata +:local ci +:local item +:foreach name in=$names do={ + :set url "" + :set method "get" + :set postData "" + :if ($name = "maxmind.com") do={ :set url "https://geoip.maxmind.com/geoip/v2.1/city/me" } + :if ($name = "rdap.db.ripe.net") do={ :set url ("https://rdap.db.ripe.net/ip/" . $publicIp) } + :if ($name = "ipinfo.io") do={ :set url ("https://ipinfo.io/widget/demo/" . $publicIp) } + :if ($name = "cloudflare.com") do={ :set url "https://speed.cloudflare.com/meta" } + :if ($name = "ipregistry.co") do={ :set url ("https://api.ipregistry.co/" . $publicIp . "?hostname=true&key=sb69ksjcajfs4c") } + :if ($name = "ipapi.co") do={ :set url ("https://ipapi.co/" . $publicIp . "/json") } + :if ($name = "ifconfig.co") do={ :set url ("https://ifconfig.co/country-iso?ip=" . $publicIp) } + :if ($name = "ip2location.io") do={ :set url ("https://api.ip2location.io/?ip=" . $publicIp) } + :if ($name = "iplocation.com") do={ + :set url "https://iplocation.com" + :set method "post" + :set postData ("ip=" . $publicIp) + } + :if ($name = "country.is") do={ :set url ("https://api.country.is/" . $publicIp) } + :if ($name = "geoapify.com") do={ :set url ("https://api.geoapify.com/v1/ipinfo?&ip=" . $publicIp . "&apiKey=b8568cb9afc64fad861a69edbddb2658") } + :if ($name = "geojs.io") do={ :set url ("https://get.geojs.io/v1/ip/country.json?ip=" . $publicIp) } + :if ($name = "ipapi.is") do={ :set url ("https://api.ipapi.is/?q=" . $publicIp) } + :if ($name = "ipbase.com") do={ :set url ("https://api.ipbase.com/v2/info?ip=" . $publicIp) } + :if ($name = "ipquery.io") do={ :set url ("https://api.ipquery.io/" . $publicIp) } + :if ($name = "ipwho.is") do={ :set url ("https://ipwho.is/" . $publicIp) } + :if ($name = "ip-api.com") do={ :set url ("https://demo.ip-api.com/json/" . $publicIp . "?fields=countryCode") } + :if ($name = "cloudflare cdn") do={ :set url "https://speed.cloudflare.com/meta" } + + :set iso "N/A" + :set httpCode 0 + :set body "" + :do { + :if ($method = "post") do={ + :set r [/tool fetch url=$url http-method=post http-data=$postData http-header-field="Content-Type: application/x-www-form-urlencoded" output=user-with-headers as-value] + } else={ + :set r [/tool fetch url=$url output=user-with-headers as-value] + } + :if (($r->"status") != "finished") do={ + :set httpCode -1 + } else={ + :set data ($r->"data") + :set line $data + :set nl [:find $data "\n"] + :if ([:typeof $nl] != "nil") do={ :set line [:pick $data 0 $nl] } + :set cr2 [:find $line "\r"] + :if ([:typeof $cr2] != "nil") do={ :set line [:pick $line 0 $cr2] } + :set httpCode 200 + :if ([:pick $line 0 4] = "HTTP") do={ + :set sp1 [:find $line " "] + :if ([:typeof $sp1] != "nil") do={ + :set rest2 [:pick $line ($sp1 + 1) [:len $line]] + :set sp2 [:find $rest2 " "] + :set codeStr $rest2 + :if ([:typeof $sp2] != "nil") do={ :set codeStr [:pick $rest2 0 $sp2] } + :set n [:tonum $codeStr] + :if ([:typeof $n] = "num") do={ :set httpCode $n } + } + } + :set sep "\r\n\r\n" + :set p [:find $data $sep] + :if ([:typeof $p] = "nil") do={ + :set sep "\n\n" + :set p [:find $data $sep] + } + :if ([:typeof $p] != "nil") do={ + :set body [:pick $data ($p + [:len $sep]) [:len $data]] + } else={ + :set body $data + } + } + } on-error={ + :set httpCode 0 + } + + :if (($httpCode = 0) or ($httpCode = -1)) do={ :set iso "N/A" } + :if (($httpCode = 401) or ($httpCode = 403)) do={ :set iso "Denied" } + :if ($httpCode = 429) do={ :set iso "Rate-limit" } + :if ($httpCode >= 500) do={ :set iso "Server error" } + :if (($httpCode >= 200) and ($httpCode < 400)) do={ + :if ($name = "ifconfig.co") do={ + :set plain $body + :set pcr [:find $plain "\r"] + :if ([:typeof $pcr] != "nil") do={ :set plain [:pick $plain 0 $pcr] } + :set plf [:find $plain "\n"] + :if ([:typeof $plf] != "nil") do={ :set plain [:pick $plain 0 $plf] } + :if ([:len $plain] > 0) do={ :set iso $plain } else={ :set iso "N/A" } + } else={ + :do { + :set j [:deserialize from=json value=$body] + :if ($name = "maxmind.com") do={ :set iso [:tostr ($j->"country"->"iso_code")] } + :if ($name = "rdap.db.ripe.net") do={ :set iso [:tostr ($j->"country")] } + :if ($name = "ipinfo.io") do={ + :set iso [:tostr ($j->"data"->"country")] + :if (([:len $iso] = 0) or ($iso = "nil")) do={ :set iso [:tostr ($j->"country")] } + } + :if ($name = "cloudflare.com") do={ :set iso [:tostr ($j->"country")] } + :if ($name = "ipregistry.co") do={ :set iso [:tostr ($j->"location"->"country"->"code")] } + :if ($name = "ipapi.co") do={ :set iso [:tostr ($j->"country")] } + :if ($name = "ip2location.io") do={ :set iso [:tostr ($j->"country_code")] } + :if ($name = "iplocation.com") do={ :set iso [:tostr ($j->"country_code")] } + :if ($name = "country.is") do={ :set iso [:tostr ($j->"country")] } + :if ($name = "geoapify.com") do={ :set iso [:tostr ($j->"country"->"iso_code")] } + :if ($name = "geojs.io") do={ :set iso [:tostr ($j->0->"country")] } + :if ($name = "ipapi.is") do={ :set iso [:tostr ($j->"location"->"country_code")] } + :if ($name = "ipbase.com") do={ :set iso [:tostr ($j->"data"->"location"->"country"->"alpha2")] } + :if ($name = "ipquery.io") do={ :set iso [:tostr ($j->"location"->"country_code")] } + :if ($name = "ipwho.is") do={ :set iso [:tostr ($j->"country_code")] } + :if ($name = "ip-api.com") do={ :set iso [:tostr ($j->"countryCode")] } + :if ($name = "cloudflare cdn") do={ + :set ctry [:tostr ($j->"country")] + :set colo ($j->"colo") + :set iata "" + :if ([:typeof $colo] = "str") do={ :set iata $colo } + :if ([:typeof $colo] = "array") do={ + :set ci ($colo->"iata") + :if ([:typeof $ci] = "str") do={ :set iata $ci } + } + :if (([:len $ctry] > 0) and ($ctry != "nil") and ([:len $iata] > 0)) do={ + :set iso ($ctry . " (" . $iata . ")") + } else={ + :if (([:len $ctry] > 0) and ($ctry != "nil")) do={ :set iso $ctry } else={ :set iso "N/A" } + } + } + :if (([:len $iso] = 0) or ($iso = "nil")) do={ :set iso "N/A" } + } on-error={ + :set iso "Server error" + } + } + } + + :put ($name . " " . $iso) + :set item { service=$name; ipv4=$iso } + :set ($results->[:len $results]) $item +} + +:local probe { publicIp=$publicIp } +:if ([:len $hoster] > 0) do={ :set ($probe->"hoster") $hoster } + +:local payload {\ + schemaVersion=1;\ + runId=$runId;\ + probe=$probe;\ + launcherVersion=$launcherVer;\ + ipregion={ version="ros" };\ + results=$results\ +} +:local json [:serialize to=json value=$payload] +:local hdrs ("Content-Type: application/json,Authorization: Bearer " . $vtToken) +:put "Отправляю ingest..." +:do { + :set r [/tool fetch url=($vtApi . "/api/integrations/ipregion/runs") http-method=post http-header-field=$hdrs http-data=$json output=user as-value] + :put ($r->"data") +} on-error={ + :put "API недоступен (fetch error)" +} + +:if ($vtDaily = "yes") do={ + :local ident [/system identity get name] + :local h ([:len $ident] + [:len [/system resource get architecture-name]]) + :local hour (3 + ($h % 4)) + :local minute (($h * 17) % 60) + :local hh $hour + :local mm $minute + :if ($hour < 10) do={ :set hh ("0" . $hour) } + :if ($minute < 10) do={ :set mm ("0" . $minute) } + :local startTime ($hh . ":" . $mm . ":00") + :local ev ("/tool fetch url=" . $vtApi . "/ic.rsc dst-path=" . $dstFile . "; /import file-name=" . $dstFile) + :do { /system scheduler remove [find name=$schedName] } on-error={} + /system scheduler add name=$schedName interval=1d start-time=$startTime on-event=$ev policy=read,write,test,policy comment="vps-tracker vt-ic" + :put ("Ежедневная проверка: каждый день в " . $startTime . " (" . $schedName . ")") + :put ("Снять: /tool fetch url=" . $vtApi . "/ic.rsc?remove=daily dst-path=" . $dstFile . "; /import file-name=" . $dstFile) +} + +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 920e267..5ffdf3a 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -74,8 +74,8 @@ function isPublicPath(url: string): boolean { const path = url.split('?')[0] ?? url if (path === '/health' || path === '/ready') return true if (path === '/api/auth/config') return true - if (path === '/cc' || path.startsWith('/cc/')) return true - if (path === '/ic' || path.startsWith('/ic/')) return true + if (path === '/cc' || path === '/cc.rsc' || path.startsWith('/cc/')) return true + if (path === '/ic' || path === '/ic.rsc' || path.startsWith('/ic/')) return true if (path.startsWith('/api/integrations/cfdm')) return true if (path.startsWith('/api/integrations/censorcheck')) return true if (path.startsWith('/api/integrations/ipregion')) return true diff --git a/apps/api/src/routes/launcher.test.ts b/apps/api/src/routes/launcher.test.ts index 78fc75e..2b6f7ee 100644 --- a/apps/api/src/routes/launcher.test.ts +++ b/apps/api/src/routes/launcher.test.ts @@ -103,3 +103,137 @@ describe('GET /ic launcher', () => { expect(res.body).not.toContain('\r') }) }) + +describe('GET /cc.rsc RouterOS launcher', () => { + let app: Awaited> + + beforeEach(async () => { + process.env.CENSORCHECK_INGEST_SECRET = 'launcher-secret-key' + process.env.CENSORCHECK_PUBLIC_URL = 'https://vt.shnt.top' + process.env.CENSORCHECK_RATE_LIMIT = '0' + resetTestDb() + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + closeDb() + }) + + it('отдаёт .rsc с токеном и no-store', async () => { + const res = await app.inject({ method: 'GET', url: '/cc.rsc' }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toMatch(/text\/plain/) + expect(res.headers['cache-control']).toMatch(/no-store/) + expect(res.body).toContain('https://vt.shnt.top') + expect(res.body).toContain('/tool fetch') + expect(res.body).toContain('/api/integrations/censorcheck/runs') + expect(res.body).toContain('ros-1') + expect(res.body).toContain(':local vtDaily "no"') + expect(res.body).toContain(':local vtRemove "no"') + expect(res.body).toContain('/system scheduler') + expect(res.body).toContain('youtube.com') + expect(res.body).not.toContain('\r') + expect(res.body).not.toContain('__VT_API_URL__') + expect(res.body).not.toContain('__VT_INGEST_TOKEN__') + expect(res.body).not.toContain('__VT_DAILY__') + expect(res.body).not.toContain('__VT_REMOVE_DAILY__') + }) + + it('?daily=1 включает установку scheduler', async () => { + const res = await app.inject({ method: 'GET', url: '/cc.rsc?daily=1' }) + expect(res.statusCode).toBe(200) + expect(res.body).toContain(':local vtDaily "yes"') + expect(res.body).toContain(':local vtRemove "no"') + expect(res.body).toContain('/system scheduler add') + }) + + it('?remove=daily снимает scheduler без проб', async () => { + const res = await app.inject({ method: 'GET', url: '/cc.rsc?remove=daily' }) + expect(res.statusCode).toBe(200) + expect(res.body).toContain(':local vtRemove "yes"') + expect(res.body).toContain(':local vtDaily "no"') + expect(res.body).toContain('Ежедневная проверка снята') + }) +}) + +describe('GET /ic.rsc RouterOS launcher', () => { + let app: Awaited> + + beforeEach(async () => { + process.env.CENSORCHECK_INGEST_SECRET = 'launcher-secret-key' + process.env.CENSORCHECK_PUBLIC_URL = 'https://vt.shnt.top' + process.env.CENSORCHECK_RATE_LIMIT = '0' + resetTestDb() + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + closeDb() + }) + + it('отдаёт .rsc с токеном и no-store', async () => { + const res = await app.inject({ method: 'GET', url: '/ic.rsc' }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toMatch(/text\/plain/) + expect(res.headers['cache-control']).toMatch(/no-store/) + expect(res.body).toContain('https://vt.shnt.top') + expect(res.body).toContain('/tool fetch') + expect(res.body).toContain('/api/integrations/ipregion/runs') + expect(res.body).toContain('ros-1') + expect(res.body).toContain(':local vtDaily "no"') + expect(res.body).toContain('ipinfo.io') + expect(res.body).toContain('cloudflare cdn') + expect(res.body).not.toContain('\r') + expect(res.body).not.toContain('__VT_API_URL__') + expect(res.body).not.toContain('__VT_INGEST_TOKEN__') + }) + + it('?daily=1 включает установку scheduler', async () => { + const res = await app.inject({ method: 'GET', url: '/ic.rsc?daily=1' }) + expect(res.statusCode).toBe(200) + expect(res.body).toContain(':local vtDaily "yes"') + expect(res.body).toContain('/system scheduler add') + }) +}) + +describe('GET /cc.rsc without ingest secret', () => { + let app: Awaited> + const prevNodeEnv = process.env.NODE_ENV + const prevAuthRequired = process.env.AUTH_REQUIRED + const prevCc = process.env.CENSORCHECK_INGEST_SECRET + const prevAuth = process.env.AUTH_JWT_SECRET + const prevJwt = process.env.JWT_SECRET + + beforeEach(async () => { + process.env.NODE_ENV = 'production' + process.env.AUTH_REQUIRED = 'false' + delete process.env.CENSORCHECK_INGEST_SECRET + delete process.env.AUTH_JWT_SECRET + delete process.env.JWT_SECRET + process.env.CENSORCHECK_RATE_LIMIT = '0' + resetTestDb() + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + closeDb() + process.env.NODE_ENV = prevNodeEnv + if (prevAuthRequired === undefined) delete process.env.AUTH_REQUIRED + else process.env.AUTH_REQUIRED = prevAuthRequired + if (prevCc === undefined) delete process.env.CENSORCHECK_INGEST_SECRET + else process.env.CENSORCHECK_INGEST_SECRET = prevCc + if (prevAuth === undefined) delete process.env.AUTH_JWT_SECRET + else process.env.AUTH_JWT_SECRET = prevAuth + if (prevJwt === undefined) delete process.env.JWT_SECRET + else process.env.JWT_SECRET = prevJwt + }) + + it('отвечает 503', async () => { + const res = await app.inject({ method: 'GET', url: '/cc.rsc' }) + expect(res.statusCode).toBe(503) + expect(res.body).toMatch(/not configured/) + }) +}) diff --git a/apps/api/src/routes/launcher.ts b/apps/api/src/routes/launcher.ts index 69cf676..2d35e73 100644 --- a/apps/api/src/routes/launcher.ts +++ b/apps/api/src/routes/launcher.ts @@ -31,6 +31,10 @@ function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'publi const IPREGION_SCRIPT_DIR = join(__dirname, '..', '..', 'scripts', 'ipregion') +function truthyQuery(value: string | undefined): boolean { + return value === '1' || value === 'true' || value === 'yes' +} + function mintLauncherScript( reply: FastifyReply, secret: string | undefined, @@ -56,6 +60,38 @@ function mintLauncherScript( sendPlain(reply, script, 'no-store') } +type RosQuery = { daily?: string; remove?: string } + +function mintRosLauncher( + reply: FastifyReply, + secret: string | undefined, + scriptDir: string, + missingSecretMessage: string, + query: RosQuery, +): void { + if (!secret) { + void reply.code(503).send(missingSecretMessage) + return + } + const remove = query.remove === 'daily' || truthyQuery(query.remove) + const daily = !remove && truthyQuery(query.daily) + const apiUrl = censorcheckPublicUrl() + const token = mintIngestToken(secret) + let template: string + try { + template = readFileSync(join(scriptDir, 'launcher.rsc'), 'utf8') + } catch { + void reply.code(500).send('launcher template missing\n') + return + } + const script = template + .replaceAll('__VT_API_URL__', apiUrl) + .replaceAll('__VT_INGEST_TOKEN__', token) + .replaceAll('__VT_DAILY__', daily ? 'yes' : 'no') + .replaceAll('__VT_REMOVE_DAILY__', remove ? 'yes' : 'no') + sendPlain(reply, script, 'no-store') +} + function sendVendor(reply: FastifyReply, filePath: string): void { try { const body = readFileSync(filePath, 'utf8') @@ -77,6 +113,20 @@ export const launcherRoutes: FastifyPluginAsync = async (app) => { mintLauncherScript(reply, secret, SCRIPT_DIR, 'censorcheck ingest is not configured\n') }) + app.get<{ Querystring: RosQuery }>( + '/cc.rsc', + ccOpts, + async (request: FastifyRequest<{ Querystring: RosQuery }>, reply: FastifyReply) => { + mintRosLauncher( + reply, + secret, + SCRIPT_DIR, + 'censorcheck ingest is not configured\n', + request.query, + ) + }, + ) + app.get('/cc/vendor', async (_request, reply) => { sendVendor(reply, join(SCRIPT_DIR, 'censorcheck.sh')) }) @@ -85,6 +135,20 @@ export const launcherRoutes: FastifyPluginAsync = async (app) => { mintLauncherScript(reply, secret, IPREGION_SCRIPT_DIR, 'ipregion ingest is not configured\n') }) + app.get<{ Querystring: RosQuery }>( + '/ic.rsc', + ccOpts, + async (request: FastifyRequest<{ Querystring: RosQuery }>, reply: FastifyReply) => { + mintRosLauncher( + reply, + secret, + IPREGION_SCRIPT_DIR, + 'ipregion ingest is not configured\n', + request.query, + ) + }, + ) + app.get('/ic/vendor', async (_request, reply) => { sendVendor(reply, join(IPREGION_SCRIPT_DIR, 'ipregion.sh')) }) diff --git a/apps/web/src/components/censorcheck/blocking-page.tsx b/apps/web/src/components/censorcheck/blocking-page.tsx index 71269e1..6fe5577 100644 --- a/apps/web/src/components/censorcheck/blocking-page.tsx +++ b/apps/web/src/components/censorcheck/blocking-page.tsx @@ -5,6 +5,7 @@ import { CalendarClockIcon, CopyIcon, GlobeIcon, + RouterIcon, ServerIcon, ShieldAlertIcon, } from 'lucide-react' @@ -40,6 +41,8 @@ import { CENSORCHECK_STATUS_LABELS, LAUNCHER_CMD, LAUNCHER_CMD_DAILY, + LAUNCHER_CMD_ROS, + LAUNCHER_CMD_ROS_DAILY, formatCheckedAt, type CensorcheckRunDto, } from './types' @@ -162,6 +165,22 @@ export function BlockingPage() { Раз в день + + ) @@ -169,7 +188,7 @@ export function BlockingPage() { void currentQuery.refetch()} empty={filtered.length === 0} emptyTitle="Пока нет проверок" - emptyDescription={`На VPS выполните: ${LAUNCHER_CMD}`} + emptyDescription={`На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`} emptyAction={copyLauncher} skeleton={} > @@ -290,7 +309,7 @@ export function BlockingPage() { onRowClick={setSelected} emptyState={{ title: 'История пуста', - description: `На VPS выполните: ${LAUNCHER_CMD}`, + description: `На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`, action: copyLauncher, }} /> diff --git a/apps/web/src/components/censorcheck/types.ts b/apps/web/src/components/censorcheck/types.ts index e98c16b..d86b7b8 100644 --- a/apps/web/src/components/censorcheck/types.ts +++ b/apps/web/src/components/censorcheck/types.ts @@ -58,6 +58,10 @@ export const CENSORCHECK_STATUS_LABELS: Record = { export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/cc | bash' export const LAUNCHER_CMD_DAILY = 'curl -fsSL https://vt.shnt.top/cc | bash -s -- --daily' +export const LAUNCHER_CMD_ROS = + '/tool fetch url="https://vt.shnt.top/cc.rsc" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc' +export const LAUNCHER_CMD_ROS_DAILY = + '/tool fetch url="https://vt.shnt.top/cc.rsc?daily=1" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc' export function formatVpsResources(vcpu: number, ramGb: number, diskGb: number): string { return `${vcpu} vCPU / ${ramGb} GB / ${diskGb} GB` diff --git a/apps/web/src/components/ipregion/geo-page.tsx b/apps/web/src/components/ipregion/geo-page.tsx index 5348370..c1aaf13 100644 --- a/apps/web/src/components/ipregion/geo-page.tsx +++ b/apps/web/src/components/ipregion/geo-page.tsx @@ -5,6 +5,7 @@ import { CopyIcon, GlobeIcon, MapPinIcon, + RouterIcon, ServerIcon, ShieldAlertIcon, } from 'lucide-react' @@ -45,6 +46,8 @@ import { IPREGION_STATUS_LABELS, LAUNCHER_CMD, LAUNCHER_CMD_DAILY, + LAUNCHER_CMD_ROS, + LAUNCHER_CMD_ROS_DAILY, formatCheckedAt, type IpregionRunDto, } from './types' @@ -167,6 +170,22 @@ export function GeoPage() { Раз в день + + ) @@ -174,7 +193,7 @@ export function GeoPage() { void currentQuery.refetch()} empty={filtered.length === 0} emptyTitle="Пока нет проверок" - emptyDescription={`На VPS выполните: ${LAUNCHER_CMD}`} + emptyDescription={`На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`} emptyAction={copyLauncher} skeleton={} > @@ -295,7 +314,7 @@ export function GeoPage() { onRowClick={setSelected} emptyState={{ title: 'История пуста', - description: `На VPS выполните: ${LAUNCHER_CMD}`, + description: `На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`, action: copyLauncher, }} /> diff --git a/apps/web/src/components/ipregion/types.ts b/apps/web/src/components/ipregion/types.ts index de0db37..ec0b7cf 100644 --- a/apps/web/src/components/ipregion/types.ts +++ b/apps/web/src/components/ipregion/types.ts @@ -57,6 +57,10 @@ export const IPREGION_STATUS_LABELS: Record = { export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/ic | bash' export const LAUNCHER_CMD_DAILY = 'curl -fsSL https://vt.shnt.top/ic | bash -s -- --daily' +export const LAUNCHER_CMD_ROS = + '/tool fetch url="https://vt.shnt.top/ic.rsc" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc' +export const LAUNCHER_CMD_ROS_DAILY = + '/tool fetch url="https://vt.shnt.top/ic.rsc?daily=1" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc' export function formatVpsResources(vcpu: number, ramGb: number, diskGb: number): string { return `${vcpu} vCPU / ${ramGb} GB / ${diskGb} GB` diff --git a/apps/web/src/components/page-header.tsx b/apps/web/src/components/page-header.tsx index 6dcee33..7a50dbc 100644 --- a/apps/web/src/components/page-header.tsx +++ b/apps/web/src/components/page-header.tsx @@ -16,7 +16,7 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {

{description}

) : null} - {actions ?
{actions}
: null} + {actions ?
{actions}
: null} ) } diff --git a/docs/deploy-traefik.md b/docs/deploy-traefik.md index 3cccad6..ded338a 100644 --- a/docs/deploy-traefik.md +++ b/docs/deploy-traefik.md @@ -84,7 +84,7 @@ nano .env # заполнить секреты и домен | `CF_DNS_API_TOKEN` | Cloudflare token для ACME DNS-01 (env контейнера **Traefik**) | | `LETSENCRYPT_EMAIL` | Email для Let's Encrypt | | `VPS_DOMAIN` | Хост UI в Traefik `Host(…)` (например `vps.example.com`) | -| `VPS_LAUNCHER_DOMAIN` | Короткий хост launcher (`vt.example.com` → тот же контейнер, `GET /cc`) | +| `VPS_LAUNCHER_DOMAIN` | Короткий хост launcher (`vt.example.com` → тот же контейнер, `GET /cc`, `/cc.rsc`, `/ic`, `/ic.rsc`) | Опционально: