feat(launcher): добавить скрипты GEO и blocking для MikroTik 7.22+
Docker / build (push) Failing after 27s
Docker / build (push) Failing after 27s
Пробы выполняются на роутере через /tool fetch и уходят в те же ingest API. Ежедневный запуск ставит scheduler, который каждый день заново качает .rsc со свежим токеном. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -103,3 +103,137 @@ describe('GET /ic launcher', () => {
|
||||
expect(res.body).not.toContain('\r')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /cc.rsc RouterOS launcher', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
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<ReturnType<typeof buildApp>>
|
||||
|
||||
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<ReturnType<typeof buildApp>>
|
||||
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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
<CalendarClockIcon data-icon="inline-start" />
|
||||
Раз в день
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void copyText(LAUNCHER_CMD_ROS, 'Команда MikroTik скопирована')}
|
||||
>
|
||||
<RouterIcon data-icon="inline-start" />
|
||||
MikroTik
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void copyText(LAUNCHER_CMD_ROS_DAILY, 'Команда MikroTik для ежедневного запуска скопирована')}
|
||||
>
|
||||
<RouterIcon data-icon="inline-start" />
|
||||
MikroTik · день
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -169,7 +188,7 @@ export function BlockingPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Статус блокировок"
|
||||
description="Проверки DPI и геоблокировок с VPS через censorcheck."
|
||||
description="Проверки DPI и геоблокировок с VPS или MikroTik 7.22+."
|
||||
actions={copyLauncher}
|
||||
/>
|
||||
<KpiStatGrid
|
||||
@@ -253,7 +272,7 @@ export function BlockingPage() {
|
||||
onRetry={() => 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={<TableSkeleton />}
|
||||
>
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -58,6 +58,10 @@ export const CENSORCHECK_STATUS_LABELS: Record<string, string> = {
|
||||
|
||||
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`
|
||||
|
||||
@@ -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() {
|
||||
<CalendarClockIcon data-icon="inline-start" />
|
||||
Раз в день
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void copyText(LAUNCHER_CMD_ROS, 'Команда MikroTik скопирована')}
|
||||
>
|
||||
<RouterIcon data-icon="inline-start" />
|
||||
MikroTik
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void copyText(LAUNCHER_CMD_ROS_DAILY, 'Команда MikroTik для ежедневного запуска скопирована')}
|
||||
>
|
||||
<RouterIcon data-icon="inline-start" />
|
||||
MikroTik · день
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -174,7 +193,7 @@ export function GeoPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="GeoIP"
|
||||
description="Страны по GeoIP-сервисам с VPS через ipregion."
|
||||
description="Страны по GeoIP-сервисам с VPS или MikroTik 7.22+."
|
||||
actions={copyLauncher}
|
||||
/>
|
||||
<KpiStatGrid
|
||||
@@ -258,7 +277,7 @@ export function GeoPage() {
|
||||
onRetry={() => 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={<TableSkeleton />}
|
||||
>
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -57,6 +57,10 @@ export const IPREGION_STATUS_LABELS: Record<string, string> = {
|
||||
|
||||
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`
|
||||
|
||||
@@ -16,7 +16,7 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
Опционально:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user