Compare commits
2
Commits
0aa440477e
...
50fe3df7e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50fe3df7e6 | ||
|
|
b1839c5ebe |
No files matched your search
+22
-3
@@ -13,7 +13,7 @@
|
||||
9. [Проверка](#проверка)
|
||||
10. [Обновление и CI/CD](#обновление-и-cicd)
|
||||
11. [Устранение неполадок](#устранение-неполадок)
|
||||
12. [Mihomo (external-controller)](#mihomo-external-controller)
|
||||
12. [Mihomo (external-controller)](#mihomo-external-controller) — при **400** на REST: [отладка](#mihomo-debug-400)
|
||||
|
||||
## Назначение
|
||||
|
||||
@@ -112,9 +112,27 @@ services:
|
||||
|
||||
и в `config.yaml` для нужного сервера: `mihomo_base_url_env: MIHOMO_CONTROLLER_URL`, `mihomo_authorization_env: TELEMT_MIHOMO_AUTH`.
|
||||
|
||||
За **reverse proxy** (nginx) перед панелью убедитесь, что для WebSocket проксируются заголовки `Upgrade` и `Connection`.
|
||||
За **reverse proxy** (nginx) перед панелью убедитесь, что для WebSocket проксируются заголовки `Upgrade` и `Connection`. Панель Mihomo тянет трафик и память **потоковым HTTP** (не WS); при задержке или «залипании» графиков за nginx включите для upstream шлюза `proxy_buffering off` (или эквивалент), чтобы не буферизовать длинный ответ.
|
||||
|
||||
Если раздел Mihomo отдаёт **400** и в браузере «Ответ не JSON»: проверьте, что `mihomo_authorization_env` — это **имя** env (например `TELEMT_MIHOMO_AUTH`), а полный заголовок `Bearer …` задан в **environment** контейнера gateway (не в YAML). После обновления шлюза прокси Mihomo использует `Rewrite` и сбрасывает `RequestURI` на исходящем запросе — без этого строгий upstream может отвечать 400.
|
||||
<a id="mihomo-debug-400"></a>
|
||||
|
||||
#### Отладка 400 на маршрутах Mihomo (`/api/{alias}/mihomo/…`)
|
||||
|
||||
Если **напрямую** к контроллеру всё ок (`curl -H "Authorization: Bearer …" http://mihomo:9090/version` → JSON), а **через шлюз** те же пути дают **400** и в логах шлюза `duration_ms` около **1** — проблема в **сборке исходящего HTTP на шлюзе**, а не в секрете Mihomo.
|
||||
|
||||
Что зафиксировано в коде и не стоит «упрощать» назад без причины:
|
||||
|
||||
| Что | Зачем |
|
||||
|-----|--------|
|
||||
| **REST** (GET `/version`, `/proxies`, `/connections`, POST к API контроллера и т.д.) | Тот же путь, что и прокси к Telemt: `http.NewRequest` + `RoundTrip` (`internal/proxy/forward.go`, общая логика `newAliasForward`). Так совпадает с рабочим `curl` к upstream. |
|
||||
| **Не** единый `httputil.ReverseProxy` на весь Mihomo | Для обычного HTTP `ReverseProxy` нередко даёт **400** у строгих upstream при том, что прямой запрос работает. |
|
||||
| **WebSocket** (`/traffic`, `/memory`, …) | На upstream — отдельно `httputil.ReverseProxy` + `Rewrite` (`internal/proxy/mihomo.go`). **Веб-панель** для графиков скорости и памяти использует **потоковый HTTP GET** к тем же путям (как в Mihomo без `Upgrade`): так же проходит через шлюз, что и `/connections`, без WebSocket за nginx. |
|
||||
| Заголовок **Host** | Не пересылается с клиента; на upstream уходит authority из `mihomo_base_url` (как для Telemt и `base_url`). |
|
||||
|
||||
Проверки конфигурации:
|
||||
|
||||
- `mihomo_authorization_env` в YAML — это **имя** переменной; **полная** строка `Authorization` (например `Bearer <secret>`) задаётся в **environment** контейнера gateway, не в YAML.
|
||||
- После изменений в этом месте пересоберите образ / задеплойте актуальный бинарь — иначе будет старое поведение.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
@@ -228,6 +246,7 @@ docker compose down
|
||||
- **Nginx с `location /api/` и `proxy_pass http://…:9091/;` (со слэшем в конце)** на бэкенд уходит путь **без** префикса `/api/` (например запрос к nginx `GET /api/v1/users` превращается в `GET /v1/users` на Telemt). Шлюз при `base_url: https://gt2.example/api/` должен запрашивать именно **`/api/v1/…`** на стороне nginx. Если в `base_url` нет пути `/api/` (только `https://gt2.example`), шлюз обратится к `https://gt2.example/v1/…` — часто это **не** попадает в `location /api/`, и nginx отдаёт **чужой vhost / заглушку**. Задавайте `base_url` с завершающим слэшем: `https://gt2.example/api/`.
|
||||
- **Заголовок `Host`**: шлюз выставляет `Host` равным хосту из `base_url` (как у обычного клиента к этому имени). Если после обновления образа проблема остаётся, с хоста шлюза проверьте: `curl -sv -o /dev/null https://gt2…/api/v1/health` и сравните с запросом через шлюз.
|
||||
- **`400` на `/api/{alias}/…` при локальном `base_url` (например `http://172.20.0.3:9091`), хотя `curl` к `:9091/v1/…` даёт `200`**: частая причина — **несовпадение заголовка `Host`**: браузер шлёт `Host: публичное_имя:8888`, а при прямом `curl` к IP в `Host` попадает `172.20.0.3:9091`. Строгий upstream (часто hyper/Rust) отвечает `400`, если `Host` не совпадает с ожидаемым authority. Шлюз при проксировании **не пересылает** клиентский `Host` и выставляет authority из `base_url` (как серверные запросы агрегатора). Убедитесь также, что в YAML **нет пробела/переноса** в конце `base_url` (поля обрезаются `TrimSpace`), и в URL нет лишнего `/api//…` (путь под `/api` нормализуется).
|
||||
- **`400` только на `/api/{alias}/mihomo/…`, при этом прямой `curl` к контроллеру Mihomo ок**: см. [Отладка 400 (Mihomo)](#mihomo-debug-400) в разделе [Mihomo](#mihomo-external-controller) — REST к Mihomo идёт через тот же механизм, что и к Telemt; не путать с отдельным WebSocket-прокси.
|
||||
- **Список пользователей через шлюз**: запрос **`GET` или `HEAD`** на **`/api/{alias}/users`** шлюз перенаправляет на upstream **`GET/HEAD /v1/stats/users`** (как и агрегатор). Так совместимы сборки Telemt, где прямой **`GET /v1/users`** даёт ошибку (например `400`), а **`/v1/stats/users`** работает. **`POST /api/{alias}/users`** (создание) и **`GET /api/{alias}/users/{username}`** по-прежнему идут на **`/v1/users`** и **`/v1/users/{username}`**. Явный путь **`/api/{alias}/stats/users`** не меняется. См. [API.md](API.md).
|
||||
- **`docker pull`: `unauthorized` / `denied`**: выполните `docker login git.shts.su` с учётной записью Gitea и PAT с **`read:package`**.
|
||||
- **`403 forbidden` с хоста при `allow_all: false`**: добавьте CIDR клиента в `whitelist_cidrs`. Запросы из контейнера к самому себе идут с `127.0.0.1` — при необходимости добавьте `127.0.0.1/32`.
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
// NewMihomoForward proxies /api/{alias}/mihomo/... to Mihomo external-controller.
|
||||
//
|
||||
// REST и обычные GET/POST идут тем же путём, что и NewAliasForward (http.NewRequest + RoundTrip):
|
||||
// httputil.ReverseProxy в таком режиме часто даёт 400 на строгих upstream (в т.ч. Mihomo), хотя curl к ним работает.
|
||||
// httputil.ReverseProxy для всего Mihomo часто давал 400 на upstream при том, что curl к контроллеру работал.
|
||||
// Только WebSocket (traffic, memory, …) остаётся на ReverseProxy + Rewrite.
|
||||
// Чеклист при повторении проблемы: docs/GATEWAY_RUN.md#mihomo-debug-400
|
||||
func NewMihomoForward(
|
||||
target *url.URL,
|
||||
stripPrefix string,
|
||||
|
||||
@@ -20,7 +20,10 @@ export function mihomoUrl(alias: string, path: string): string {
|
||||
return `${gatewayBase()}/api/${encodeURIComponent(alias)}/mihomo/${p}`;
|
||||
}
|
||||
|
||||
/** WebSocket к тому же origin (ws / wss). */
|
||||
/**
|
||||
* WebSocket к тому же origin (ws / wss).
|
||||
* Для /traffic и /memory во фронте предпочтительнее потоковый GET через `mihomoUrl` — тот же прокси, что и для REST; WS из браузера часто рвётся за nginx.
|
||||
*/
|
||||
export function mihomoWsUrl(alias: string, path: string): string {
|
||||
const p = path.replace(/^\/+/, '');
|
||||
const base = gatewayBase();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
fetchMihomoJson,
|
||||
fetchMihomoMeta,
|
||||
mihomoPut,
|
||||
mihomoWsUrl
|
||||
mihomoUrl
|
||||
} from '$lib/api/client.js';
|
||||
import type {
|
||||
MihomoConnectionsResponse,
|
||||
@@ -48,8 +48,6 @@
|
||||
const MAX_POINTS = 72;
|
||||
let lastTick = $state<number | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let wsTraffic: WebSocket | null = null;
|
||||
let wsMemory: WebSocket | null = null;
|
||||
|
||||
let proxiesLoading = $state(false);
|
||||
let proxiesErr = $state<string | null>(null);
|
||||
@@ -58,8 +56,17 @@
|
||||
let testing = $state<string | null>(null);
|
||||
let testingGroup = $state<string | null>(null);
|
||||
|
||||
function recordRates(now: number, up: number, down: number) {
|
||||
if (lastTick != null) {
|
||||
/** Mihomo отдаёт upTotal/downTotal в потоке /traffic — используем их; иначе накапливаем из up/down. */
|
||||
function recordRates(
|
||||
now: number,
|
||||
up: number,
|
||||
down: number,
|
||||
totals?: { upTotal: number; downTotal: number }
|
||||
) {
|
||||
if (totals) {
|
||||
totalUp = totals.upTotal;
|
||||
totalDown = totals.downTotal;
|
||||
} else if (lastTick != null) {
|
||||
const dt = (now - lastTick) / 1000;
|
||||
if (dt > 0 && dt < 5) {
|
||||
totalUp += up * dt;
|
||||
@@ -90,38 +97,89 @@
|
||||
}
|
||||
}
|
||||
|
||||
function connectWs(a: string) {
|
||||
if (!browser) return;
|
||||
wsTraffic?.close();
|
||||
wsMemory?.close();
|
||||
lastTick = null;
|
||||
/**
|
||||
* Потоковый GET /traffic и /memory (как в hub/route/server.go Mihomo: без Upgrade — chunked JSON/s).
|
||||
* Работает через тот же HTTP-прокси, что и /connections (Authorization на шлюзе).
|
||||
* WebSocket с браузера часто рвётся за nginx или без заголовков — метрики остаются нулями.
|
||||
*/
|
||||
async function pumpMihomoTrafficStream(a: string, signal: AbortSignal) {
|
||||
try {
|
||||
wsTraffic = new WebSocket(mihomoWsUrl(a, 'traffic'));
|
||||
wsTraffic.onmessage = (ev) => {
|
||||
try {
|
||||
const o = JSON.parse(ev.data as string) as { up?: number; down?: number };
|
||||
recordRates(performance.now(), Number(o.up) || 0, Number(o.down) || 0);
|
||||
} catch {
|
||||
/* ignore */
|
||||
const res = await fetch(mihomoUrl(a, 'traffic'), { signal });
|
||||
if (!res.ok || !res.body) return;
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
for (;;) {
|
||||
const nl = buf.indexOf('\n');
|
||||
if (nl < 0) break;
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const o = JSON.parse(line) as {
|
||||
up?: number;
|
||||
down?: number;
|
||||
upTotal?: number;
|
||||
downTotal?: number;
|
||||
};
|
||||
const up = Number(o.up) || 0;
|
||||
const down = Number(o.down) || 0;
|
||||
const totals =
|
||||
typeof o.upTotal === 'number' && typeof o.downTotal === 'number'
|
||||
? { upTotal: o.upTotal, downTotal: o.downTotal }
|
||||
: undefined;
|
||||
recordRates(performance.now(), up, down, totals);
|
||||
} catch {
|
||||
/* ignore line */
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
/* aborted or network */
|
||||
}
|
||||
}
|
||||
|
||||
async function pumpMihomoMemoryStream(a: string, signal: AbortSignal) {
|
||||
try {
|
||||
wsMemory = new WebSocket(mihomoWsUrl(a, 'memory'));
|
||||
wsMemory.onmessage = (ev) => {
|
||||
try {
|
||||
const o = JSON.parse(ev.data as string) as { inuse?: number };
|
||||
memKB = Number(o.inuse) || 0;
|
||||
histMem = [...histMem, memKB];
|
||||
while (histMem.length > MAX_POINTS) histMem.shift();
|
||||
} catch {
|
||||
/* ignore */
|
||||
const res = await fetch(mihomoUrl(a, 'memory'), { signal });
|
||||
if (!res.ok || !res.body) return;
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
for (;;) {
|
||||
const nl = buf.indexOf('\n');
|
||||
if (nl < 0) break;
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const o = JSON.parse(line) as { inuse?: number };
|
||||
memKB = Number(o.inuse) || 0;
|
||||
histMem = [...histMem, memKB];
|
||||
while (histMem.length > MAX_POINTS) histMem.shift();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
/* aborted or network */
|
||||
}
|
||||
}
|
||||
|
||||
async function runTrafficMemoryStreams(a: string, signal: AbortSignal) {
|
||||
while (!signal.aborted) {
|
||||
await Promise.all([pumpMihomoTrafficStream(a, signal), pumpMihomoMemoryStream(a, signal)]);
|
||||
if (signal.aborted) break;
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,20 +222,27 @@
|
||||
$effect(() => {
|
||||
const a = alias;
|
||||
if (!a || !browser) return;
|
||||
lastTick = null;
|
||||
totalUp = 0;
|
||||
totalDown = 0;
|
||||
upBps = 0;
|
||||
downBps = 0;
|
||||
memKB = 0;
|
||||
histUp = [];
|
||||
histDown = [];
|
||||
histMem = [];
|
||||
void loadMeta(a);
|
||||
connectWs(a);
|
||||
const streamAc = new AbortController();
|
||||
void runTrafficMemoryStreams(a, streamAc.signal);
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(() => void pollConnections(a), 2000);
|
||||
void pollConnections(a);
|
||||
return () => {
|
||||
streamAc.abort();
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
wsTraffic?.close();
|
||||
wsMemory?.close();
|
||||
wsTraffic = null;
|
||||
wsMemory = null;
|
||||
};
|
||||
});
|
||||
|
||||
@@ -202,8 +267,12 @@
|
||||
void loadProxies();
|
||||
});
|
||||
|
||||
function isSelectableGroup(t?: string) {
|
||||
return t === 'Selector' || t === 'URLTest';
|
||||
/** Группы, у которых есть `all` + `now` и PUT /proxies/{name} переключает узел (как в Clash/Mihomo). */
|
||||
function isSelectableGroup(p: MihomoProxyEntry) {
|
||||
const t = (p.type ?? '').toLowerCase();
|
||||
if (t === 'selector' || t === 'urltest' || t === 'fallback') return true;
|
||||
if (t === 'loadbalance' && Array.isArray(p.all) && p.all.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function lastDelay(p: MihomoProxyEntry): number | null {
|
||||
@@ -265,8 +334,6 @@
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
wsTraffic?.close();
|
||||
wsMemory?.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -386,7 +453,7 @@
|
||||
{#snippet children()}
|
||||
{#if proxiesData?.proxies}
|
||||
<div class="flex flex-col gap-8">
|
||||
{#each Object.entries(proxiesData.proxies).filter(([_, v]) => isSelectableGroup(v.type)) as [gName, group] (gName)}
|
||||
{#each Object.entries(proxiesData.proxies).filter(([_, v]) => isSelectableGroup(v)) as [gName, group] (gName)}
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
|
||||
Reference in new issue
Block a user