From c212305ef50c453dd2f7883e6605c02a11fc350e Mon Sep 17 00:00:00 2001 From: Folist Date: Sun, 11 Oct 2026 19:49:05 +0300 Subject: [PATCH] =?UTF-8?q?1.3.3-beta.9:=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82?= =?UTF-8?q?=20Let's=20Encrypt=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=BE=D1=8F=D1=89=D0=B5=D0=B3=D0=BE=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D0=B7=D0=B0=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B5=20=D0=BF=D1=80=D1=8F=D1=87=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=D1=83=20=E2=80=94=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B5=D1=91=20(=D0=BF=D0=BE=D1=80=D1=82=D1=8B,=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=BB=D1=8B=D0=B9=20IP,=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?)=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=C2=AB=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B6=D0=B4=D0=B8=D1=82=D0=B5=20=D1=87=D0=B0?= =?UTF-8?q?=D1=81=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- files-service/reconcile.sh | 17 +++++++++++++---- package-lock.json | 4 ++-- package.json | 2 +- src/bridge/fileShare.ts | 5 +++++ tests/fileShare.test.ts | 5 +++++ 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/files-service/reconcile.sh b/files-service/reconcile.sh index 6f75c73..4f986c2 100644 --- a/files-service/reconcile.sh +++ b/files-service/reconcile.sh @@ -264,21 +264,30 @@ healthy() { curl -fsS --max-time 8 "$BASE_URL/health" >/dev/null 2>&1; } # a home server whose router didn't forward 80/443) without the one thing to fix. acme_error() { [ "$EXTERNAL" = 0 ] || return 0 - local c line type detail + local c lines line type detail limited="" c=$(docker ps -q --filter "label=com.docker.compose.project=$PROJECT" --filter "label=com.docker.compose.service=caddy" | head -1) [ -n "$c" ] || return 0 # Error-level lines that carry an ACME error type. Not any line mentioning «challenge»: «trying to # solve challenge» is Caddy at work, and taken for a failure it failed a fresh start 9 s in, before # Let's Encrypt even answered (live test, 2026-10-11). - line=$(docker logs --tail 80 "$c" 2>&1 | grep -F '"level":"error"' | grep -F 'urn:ietf:params:acme:error:' | tail -1) - [ -n "$line" ] || return 0 + lines=$(docker logs --tail 120 "$c" 2>&1 | grep -F '"level":"error"' | grep -F 'urn:ietf:params:acme:error:' || true) + [ -n "$lines" ] || return 0 + # A rate limit is a consequence: Caddy's quick retry after a real failure hits it within seconds and + # used to hide the cause (closed ports read as «подождите час» — live test). Name the cause; flag + # the limit next to the type («…:connection+rateLimited»), where a 200-character cut can't drop it. + line=$(printf '%s\n' "$lines" | grep -v 'acme:error:rateLimited' | tail -1) + if [ -z "$line" ]; then + line=$(printf '%s\n' "$lines" | tail -1) + elif printf '%s\n' "$lines" | grep -q 'acme:error:rateLimited'; then + limited="+rateLimited" + fi # «urn:…: - »: the type first (it decides the message), then the human detail. Caddy # puts it in a nested «problem» object on «challenge failed» ("type", "detail") and inside the error # string elsewhere — and that string holds escaped quotes (\"1.2.3.4\"), so no "[^"]*" matching. type=$(printf '%s' "$line" | grep -oE 'urn:ietf:params:acme:error:[A-Za-z]+' | head -1) detail=$(printf '%s' "$line" | grep -oE '"detail":"[^"]*"' | head -1 | cut -d'"' -f4) [ -n "$detail" ] || detail=$(printf '%s' "$line" | sed -E 's/.*urn:ietf:params:acme:error:[A-Za-z]+ - //; s/\\"//g; s/"[,}].*//' | cut -c1-140) - printf '%s - %s' "$type" "$detail" + printf '%s%s - %s' "$type" "$limited" "$detail" } ACME_ERR="" if [ "$WAS_RUNNING" = 0 ]; then diff --git a/package-lock.json b/package-lock.json index 339e8d3..b6f3d71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "telemax", - "version": "1.3.3-beta.8", + "version": "1.3.3-beta.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "telemax", - "version": "1.3.3-beta.8", + "version": "1.3.3-beta.9", "license": "AGPL-3.0-only", "dependencies": { "dotenv": "^17.2.3", diff --git a/package.json b/package.json index 827f8d8..26df07a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "telemax", "private": true, "license": "AGPL-3.0-only", - "version": "1.3.3-beta.8", + "version": "1.3.3-beta.9", "type": "module", "scripts": { "dev": "tsx watch src/server/app.ts", diff --git a/src/bridge/fileShare.ts b/src/bridge/fileShare.ts index 8947721..190d552 100644 --- a/src/bridge/fileShare.ts +++ b/src/bridge/fileShare.ts @@ -138,6 +138,7 @@ const UPLOAD_LOCK_STALE_MS = 12 * 3600_000; * Callers write «… не удалось — .», so it ends without a full stop. */ const explain = (cause: string, why: string, todo: string) => `${cause}.\n\nПочему это мешает: ${why}.\n\nЧто сделать: ${todo}`; +const explainBase = explain; const WHY_PAGE = 'большие файлы передаются через страницу на вашем сервере'; /** What the page needs from the server — the yardstick every cause below is measured against. */ const WHY_REACH = `${WHY_PAGE}, и она должна открываться из интернета — по белому IP-адресу сервера, на портах 80 и 443`; @@ -565,6 +566,10 @@ export function describeServiceProblem(st: { state?: string; detail?: string }): const detail = (st.detail ?? '').trim(); if (/^acme:/i.test(detail)) { const behindNat = /^acme:\s*nat:/i.test(detail); + // reconcile.sh flags a rate limit that followed the real failure («…:connection+rateLimited»): + // the cause stays the message, the limit becomes a note on when to try again. + const limitNote = /\+ratelimited/i.test(detail) ? ". Let's Encrypt к тому же временно ограничил попытки для этого адреса — после того как исправите, подождите час" : ''; + const explain = (cause: string, why: string, todo: string) => explainBase(cause, why, todo + limitNote); const unreachable = /acme:error:(connection|tls)\b|timeout during connect|firewall|connection refused|connection reset|no route to host|network unreachable/i.test(detail); const wrongAnswer = /acme:error:(unauthorized|incorrectresponse)\b|invalid response|status code 404|403 forbidden/i.test(detail); // A home server: no white IP of its own — the fix depends on the router and the provider. diff --git a/tests/fileShare.test.ts b/tests/fileShare.test.ts index 65cf0a5..a49fd6e 100644 --- a/tests/fileShare.test.ts +++ b/tests/fileShare.test.ts @@ -302,6 +302,11 @@ describe('files service problems in plain words', () => { expect(describeServiceProblem({ state: 'error', detail: 'acme: urn:ietf:params:acme:error:connection - 1.2.3.4: Ti' })).toContain('файрвол хостинга'); expect(describeServiceProblem({ state: 'error', detail: 'acme: urn:ietf:params:acme:error:tls - 1.2.3.4: rem' })).toContain('файрвол хостинга'); expect(describeServiceProblem({ state: 'ports-busy' })).toContain('FILES_PUBLIC_URL'); + // A rate limit right after the real failure (live test): the cause stays, the limit becomes a note. + const both = describeServiceProblem({ state: 'error', detail: 'acme: urn:ietf:params:acme:error:connection+rateLimited - 1.2.3.4: Timeout during connect' }); + expect(both.startsWith('закрыты порты 80 и 443.')).toBe(true); + expect(both).toContain('подождите час'); + expect(describeServiceProblem({ state: 'error', detail: 'acme: nat: urn:ietf:params:acme:error:connection+rateLimited - x' }).startsWith('у сервера нет белого IP-адреса.')).toBe(true); expect(describeServiceProblem({ state: 'error', detail: 'bind: address already in use' })).toBe('служба файлов не запустилась: bind: address already in use'); expect(SERVICE_TIMEOUT_TEXT).toContain('порты 80 и 443'); });