fix(relay): explain the 404 — the API lives under the panel's secret webpath

A relay configured with upstream_api_url = "http://HOST:9090" fails every key
sync with a bare "API returned HTTP 404", which reads like the server is down
or the token is wrong. Neither is true: the management API is nested under the
target server's api.webpath (create_api_router mounts it at
"/{webpath}/api"), because that secret segment is what keeps the panel from
being discoverable by scanners. A bare host:port therefore resolves to a route
that does not exist and the token is never even looked at.

Nothing said so — the config template, the wizard prompt and the shipped
example all suggested exactly the host:port form that cannot work.

  - sync_keys now reports the full URL and, for 404 specifically, states that
    the webpath must be included and what the URL should look like. 401 is
    called out separately as a token mismatch, since the two are otherwise
    indistinguishable from the log.
  - The relay config template, the shipped example and the wizard prompt now
    show the path-bearing form, and the wizard warns when the URL entered has
    no path segment rather than letting it fail later.

Docs under docs/ and the wiki are being rewritten concurrently and are left
alone here.
This commit is contained in:
ospab 2026-07-31 20:00:29 +03:00
parent 365b4ccbf5
commit a1c146aff3
3 changed files with 59 additions and 7 deletions

View File

@ -20,9 +20,15 @@
// Адрес следующего узла в цепочке UDP
"upstream_udp": "TARGET_SERVER_IP:50000",
// URL API конечного (целевого) сервера для синхронизации access_keys
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель)
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
// URL API конечного (целевого) сервера для синхронизации access_keys.
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель).
//
// ВАЖНО: URL обязан включать секретный путь панели (api.webpath целевого
// сервера). Management API смонтирован ВНУТРИ этого пути именно он скрывает
// панель от сканеров, поэтому голый host:port попадает в несуществующий
// маршрут, и синхронизация падает с 404 ещё до проверки токена.
// Это тот же адрес, по которому вы открываете веб-панель.
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
// Bearer-токен для доступа к API целевого сервера
// Должен совпадать с api.token в конфиге target-сервера

View File

@ -95,7 +95,29 @@ async fn sync_keys(cfg: &RelayConfig, shared_keys: &SharedKeys) -> Result<usize>
let resp = req.send().await?;
if !resp.status().is_success() {
anyhow::bail!("API returned HTTP {}", resp.status());
// 404 here almost always means the URL is missing the panel's secret
// path segment rather than the server being down or the token being
// wrong. The management API is not served at /api — it is nested under
// the configured `api.webpath` (see create_api_router), which exists to
// keep the panel from being discoverable by scanners. A bare
// host:port therefore resolves to a route that does not exist, and the
// token is never even looked at, which makes "404" a deeply misleading
// thing to report on its own.
if resp.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!(
"API returned HTTP 404 for {url}. The management API is served under the \
target server's secret `api.webpath`, not at /api set upstream_api_url \
to include it, e.g. \"http://HOST:9090/<webpath>\" (the same path you open \
the web panel at). Check `api.webpath` in the target server's config."
);
}
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
anyhow::bail!(
"API returned HTTP 401 for {url}: upstream_api_token does not match the \
target server's `api.token`."
);
}
anyhow::bail!("API returned HTTP {} for {url}", resp.status());
}
#[derive(serde::Deserialize)]

View File

@ -799,8 +799,26 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
let upstream = wizard_prompt("Upstream server address (host:port)", "");
if upstream.is_empty() { anyhow::bail!("Upstream address cannot be empty."); }
let api_url = wizard_prompt("Upstream server API URL (e.g. http://1.2.3.4:9090)", "");
let api_token = wizard_prompt("Upstream API token (leave blank if none)", "");
let api_url = wizard_prompt(
"Upstream API URL, including the panel's secret path (e.g. http://1.2.3.4:9090/bNAzr8Ss)",
"",
);
// The management API lives under the target server's api.webpath, so
// a bare host:port 404s on every key sync without ever checking the
// token. Catch that here instead of leaving it to be debugged from
// relay logs.
let has_path = api_url
.split("://")
.nth(1)
.map(|rest| rest.contains('/') && !rest.trim_end_matches('/').split('/').nth(1).unwrap_or("").is_empty())
.unwrap_or(false);
if !api_url.is_empty() && !has_path {
wizard_warn(
"This URL has no path segment. The API is served under the target server's \
api.webpath - key sync will fail with 404 unless you append it.",
);
}
let api_token = wizard_prompt("Upstream API token (must equal api.token on the target server)", "");
wizard_step(2, TOTAL, "Saving configuration");
let relay_json = serde_json::json!({
@ -1213,7 +1231,13 @@ async fn run_app() -> Result<()> {
"listen": "0.0.0.0:50000",
"upstream_tcp": "TARGET_SERVER_IP:50000",
"upstream_udp": "TARGET_SERVER_IP:50000",
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
// MUST include the target server's secret api.webpath. The management API is
// nested under it (that path is what hides the panel from scanners), so a
// bare host:port hits a route that does not exist and key sync fails with 404
// before the token is ever checked. This is the same URL you open the panel
// at, e.g. "http://1.2.3.4:9090/bNAzr8Ss".
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
// Must equal api.token on the target server (NOT the panel password).
"upstream_api_token": "YOUR_API_TOKEN_HERE",
"sync_interval_secs": 30,
"debug": false