diff --git a/.github/scripts/telegram_release_notify.py b/.github/scripts/telegram_release_notify.py index 10c2a85..da04d8b 100755 --- a/.github/scripts/telegram_release_notify.py +++ b/.github/scripts/telegram_release_notify.py @@ -64,6 +64,110 @@ def parse_changelog(path: str) -> tuple[str, str]: return fa.strip(), en.strip() +# Telegram caption hard-cap is 1024 chars. The fixed parts of our caption +# (title + SHA hash + two-link footer with their preambles) sum to roughly +# 470 chars on a typical version string. That leaves ~550 chars for the +# release-note section before we'd start losing the trailing release URL. +# Keep the budget conservative so a long version string or a slightly +# longer hash representation doesn't push us over. +CAPTION_FA_NOTE_BUDGET = 500 + + +def _md_links_to_html(text: str) -> str: + """Convert `[label](url)` markdown links to `label`. + + Telegram's HTML parse mode renders `` as clickable but treats + markdown verbatim, so an unconverted `[#160](https://…)` appears as + that literal string in the channel post — both ugly and wasteful of + caption budget. The HTML form is shorter visually (`#160` vs the + full URL), still clickable, and counts the same toward Telegram's + 1024-char limit. Inline `code` (`backtick-quoted`) is also + translated to `` since markdown backticks render + literally too. + """ + text = re.sub( + r"\[([^\]]+)\]\(([^)]+)\)", + lambda m: f'{m.group(1)}', + text, + ) + text = re.sub(r"`([^`\n]+)`", r"\1", text) + # Bold (**…**) is rare in our changelog but happens — convert to . + text = re.sub(r"\*\*([^*\n]+)\*\*", r"\1", text) + return text + + +def _extract_headlines(fa_section: str) -> str: + """For each `• …: …` bullet, keep the headline part and drop the + elaboration. + + Our changelog convention writes each bullet as one of: + • headline: full explanation + • headline ([#NN](url)): full explanation + • headline (issue ref): full explanation + + The headline is everything up to the `: ` (colon + space) that ends + the leading clause. Naively searching for the first `:` lands inside + `https:` URLs of the markdown link form — instead we search from the + end of the parenthesized-issue-ref (if any) for the first `: `, or + fall back to the first `: ` in the line. + + Headlines stay on the FA caption; the explanation is preserved in + the docs/changelog/ file and (optionally) the reply-threaded message + posted via --with-changelog. + + Returns a newline-joined string of `• ` lines. + """ + headlines: list[str] = [] + for line in fa_section.splitlines(): + if not line.startswith("• "): + continue + body = line[2:] # drop "• " + # Prefer cutting at "): " — the close of the parenthesized ref + # followed by the convention colon + space. That's our actual + # bullet structure and avoids the false-positive `https:` cut. + cut_idx = body.find("): ") + if cut_idx > 0: + headline = body[: cut_idx + 1] # keep the close paren + else: + # Fall back to ": " (colon + space) anywhere in the body. + # Adding the space requirement skips `https:` which is + # always followed by `/`. + cut_idx = body.find(": ") + headline = body[:cut_idx] if cut_idx > 0 else body + headlines.append(f"• {headline.rstrip()}") + return "\n".join(headlines) + + +def build_caption_release_note(changelog_path: str) -> str: + """Build the Persian "what's new" block for the Telegram caption. + + Pulls the FA section of `docs/changelog/v.md`, extracts just + the bullet headlines (before the first `:` of each bullet) so the + note is compact, converts markdown links/code to Telegram HTML for + clickability, and wraps in a `
`. Falls back to the full + FA section if the headlines extraction yields nothing (e.g. a + changelog that doesn't follow our `• headline: details` convention). + + If the result still exceeds CAPTION_FA_NOTE_BUDGET, truncate at a + bullet boundary with a trailing `…`. In practice the headlines-only + form fits comfortably for any reasonable release note. + """ + fa, _en = parse_changelog(changelog_path) + if not fa: + return "" + headlines = _extract_headlines(fa) + note = headlines if headlines else fa.strip() + note = _md_links_to_html(note) + if len(note) > CAPTION_FA_NOTE_BUDGET: + truncated = note[:CAPTION_FA_NOTE_BUDGET] + last_bullet = truncated.rfind("\n•") + if last_bullet > 0: + note = truncated[:last_bullet].rstrip() + "\n…" + else: + note = truncated.rstrip() + "…" + return f"
{note}
" + + def sha256_of(path: str) -> str: h = hashlib.sha256() with open(path, "rb") as f: @@ -169,33 +273,70 @@ def main() -> int: # the tag (the workflow converts that into --with-changelog). ap.add_argument("--with-changelog", action="store_true", help="Include the Persian+English changelog as a reply-threaded message.") + # Dry-run lets you verify the rendered caption locally without hitting + # Telegram. Useful when changing the brief-release-note budget / + # truncation logic — print, eyeball, push. + ap.add_argument("--dry-run", action="store_true", + help="Render the caption and print it instead of posting. " + "Skips token/chat_id checks.") args = ap.parse_args() - token = os.environ.get("BOT_TOKEN", "") - chat_id = os.environ.get("CHAT_ID", "") - if not token or not chat_id: - print("TELEGRAM secrets not present, skipping post.") - return 0 + if not args.dry_run: + token = os.environ.get("BOT_TOKEN", "") + chat_id = os.environ.get("CHAT_ID", "") + if not token or not chat_id: + print("TELEGRAM secrets not present, skipping post.") + return 0 + else: + token = "" + chat_id = "" ver = args.version sha = sha256_of(args.apk) + # Brief Persian release-note above the links. Pulled from the FA + # half of `docs/changelog/v.md` so each release auto-includes + # what's new without manual edits to this script. Truncated to fit + # Telegram's 1024-char caption budget alongside title + SHA + the + # two-link footer. + fa_note = build_caption_release_note(args.changelog) + # Caption structure requested by the repo owner: # 1. Title + SHA-256 (as before) - # 2. Persian preamble labelling the repo link as + # 2. Brief Persian "what's new" note (extracted from changelog) + # 3. Persian preamble labelling the repo link as # "GitHub repo + full Persian guide" - # 3. Repo URL - # 4. Persian preamble labelling the release link as + # 4. Repo URL + # 5. Persian preamble labelling the release link as # "this version's release — desktop/router builds live here" - # 5. Release URL + # 6. Release URL # Keeps total well under Telegram's 1024-char caption limit. - caption = ( - f"mhrv-rs Android v{ver}\n\n" - f"SHA-256: {sha}\n\n" - f"مخزن گیتهاب + مطالعه راهنمای کامل فارسی:\n" - f"https://github.com/{args.repo}\n\n" - f"لینک به این نسخه جهت دریافت نسخه های مربوط به مودم و کامپیوتر:\n" - f"https://github.com/{args.repo}/releases/tag/v{ver}" - ) + caption_parts = [ + f"mhrv-rs Android v{ver}", + "", + f"SHA-256: {sha}", + ] + if fa_note: + caption_parts.extend(["", fa_note]) + caption_parts.extend([ + "", + "مخزن گیتهاب + مطالعه راهنمای کامل فارسی:", + f"https://github.com/{args.repo}", + "", + "لینک به این نسخه جهت دریافت نسخه های مربوط به مودم و کامپیوتر:", + f"https://github.com/{args.repo}/releases/tag/v{ver}", + ]) + caption = "\n".join(caption_parts) + + if args.dry_run: + print(f"--- DRY RUN: caption ({len(caption)} chars) ---") + print(caption) + print(f"--- END DRY RUN ---") + if args.with_changelog: + fa, en = parse_changelog(args.changelog) + print(f"\nWould reply with changelog " + f"(fa: {len(fa) if fa else 0} chars, " + f"en: {len(en) if en else 0} chars)") + return 0 doc_mid = send_document(token, chat_id, args.apk, caption) print(f"sendDocument OK, message_id={doc_mid}") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f75594..55caa25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,13 @@ on: permissions: contents: write + # `tunnel-docker` job pushes to ghcr.io/therealaleph/mhrv-tunnel-node. + # `packages: write` is required by docker/login-action when authenticating + # to GHCR with the workflow's auto-provisioned GITHUB_TOKEN. Granted at + # the workflow level so the matrix-build job (which doesn't need it) and + # the release job (which doesn't need it) both still have a single + # well-scoped permissions block. + packages: write # Runner strategy: # - Linux + Android + mipsel: self-hosted (mhrv-hetzner-*, Hetzner @@ -487,6 +494,75 @@ jobs: path: dist/*.apk if-no-files-found: error + # Build + publish the tunnel-node Docker image to GHCR. Issue: every + # full-mode user has to set up tunnel-node on a VPS, and "rustup + + # cargo build --release" on a 1GB VPS is non-trivial — fails on memory, + # takes 8+ minutes if it works, blocks anyone without Rust experience. + # A prebuilt multi-arch image makes deployment a one-liner: + # docker run -d -p 8080:8080 -e TUNNEL_AUTH_KEY=... \ + # ghcr.io/therealaleph/mhrv-tunnel-node:latest + # + # Tags published per release: + # v1.5.0 — exact version pin + # 1.5 — auto-following minor + # latest — most recent release (skipped on workflow_dispatch + # re-publishes; see `latest` condition below) + # + # Build platforms: linux/amd64 and linux/arm64. Most VPS providers + # (DigitalOcean, Hetzner, Oracle Free Tier) offer arm64 instances at + # half price, and the binary works on both. + tunnel-docker: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + # Compute the version string the same way the rest of the workflow + # does: tag pushes get it from github.ref_name (e.g. "v1.5.0"), + # workflow_dispatch from the explicit `inputs.version` (e.g. + # "1.5.0"). Strip a possible leading "v" so the docker tag namespace + # is consistent: `:1.5.0`, not `:v1.5.0`. + - name: Compute version + id: ver + run: | + VER="${{ inputs.version || github.ref_name }}" + VER="${VER#v}" + MINOR="${VER%.*}" + echo "version=${VER}" >> "$GITHUB_OUTPUT" + echo "minor=${MINOR}" >> "$GITHUB_OUTPUT" + echo "Building docker for v${VER} (minor: ${MINOR})" + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Build for both amd64 and arm64. `:latest` is only updated on + # actual tag pushes — workflow_dispatch re-runs on an existing + # version (e.g. for the v1.4.0 mipsel republish) shouldn't move + # the latest pointer. + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: ./tunnel-node + file: ./tunnel-node/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/mhrv-tunnel-node:${{ steps.ver.outputs.version }} + ghcr.io/${{ github.repository_owner }}/mhrv-tunnel-node:${{ steps.ver.outputs.minor }} + ${{ github.event_name == 'push' && format('ghcr.io/{0}/mhrv-tunnel-node:latest', github.repository_owner) || '' }} + cache-from: type=gha + cache-to: type=gha,mode=max + # release + telegram: lightweight aggregation jobs kept on GH-hosted # ubuntu-latest. They only download artifacts and call APIs — no build # tooling needed, no benefit from moving to self-hosted, and keeping them @@ -507,6 +583,38 @@ jobs: path: dist merge-multiple: true + # Compose the GitHub release body from `docs/changelog/v.md` + # so the Releases page tells humans what actually changed — + # `generate_release_notes: true` alone produces "Full Changelog: + # …compare/v1.x.0...v1.x.1" which is empty when no PRs landed + # between tags (e.g. for fix-forward releases like v1.4.1). The + # changelog file already exists for every release in our format + # (Persian section, then `---`, then English section); we wrap it + # with a header and append the auto-generated commit list at the + # bottom by NOT setting body_path and instead setting body + # directly to changelog_content + (the existing + # generate_release_notes flag handles the trailing comparison + # link automatically). + - name: Compose release body + id: relbody + run: | + VER="${{ inputs.version || github.ref_name }}" + VER="${VER#v}" + CHANGELOG="docs/changelog/v${VER}.md" + if [ ! -f "$CHANGELOG" ]; then + echo "::warning::no changelog at $CHANGELOG; release body will fall back to generate_release_notes only" + echo "has_changelog=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + { + echo 'body<<__RELEASE_BODY_EOF__' + # Strip leading HTML comment that documents the file format. + sed -e '1{/^ +• پیام‌های push در حالت Full Tunnel حالا تقریباً با تأخیر RTT می‌رسن، نه بعد از یک کامل tick کلاینت ([#173](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/pull/173)): مکانیزم drain خود tunnel-node از sleep ثابت ۱۵۰ms به wake مبتنی بر `Notify` (event-driven) تبدیل شد. session‌های idle حالا long-poll دارن تا ۵ ثانیه — اولین بایت ورودی wake می‌کنه. تلگرام و چت‌ها به‌طور قابل‌توجه قابل لمس‌تر شدن. backward-compat با tunnel-nodeهای قدیمی‌تر خودکار: اگه empty poll round-trip در زیر ۱.۵ ثانیه با هیچ data برمی‌گرده، کلاینت تشخیص میده server long-poll پشتیبانی نمی‌کنه و به cadence قدیمی برمی‌گرده — sticky `AtomicBool` برای کل عمر mux +• تصویر Docker آماده برای tunnel-node ([ghcr.io/therealaleph/mhrv-tunnel-node](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/pkgs/container/mhrv-tunnel-node)): راه‌اندازی Full Tunnel mode حالا یک خطی هست به جای rustup + cargo build. multi-arch (amd64 + arm64). تگ‌های `:latest`, `:1.5`, `:1.5.0`. اجرا به‌عنوان non-root user. مستندات کامل در [tunnel-node/README.md](tunnel-node/README.md) +• یادداشت کوتاه فارسی "تغییرات این نسخه" بالای لینک‌های پست تلگرام: تیترهای bullet از فایل `docs/changelog/v.md` خودکار استخراج میشن و به‌عنوان
بالای دو لینک repo و release میان. کلیک‌پذیری روی شمارهٔ issue/PR از طریق تبدیل markdown به HTML +• body صفحهٔ release گیت‌هاب دیگه فقط لینک comparison نیست — حالا محتوای کامل `docs/changelog/v.md` (فارسی + انگلیسی) به‌عنوان توضیحات اصلی release نمایش داده میشه، و `**Full Changelog**: ...compare...` به‌عنوان footer اضافه میشه +--- +• Full Tunnel mode push notifications now arrive in roughly RTT instead of waiting for the next client poll tick ([#173](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/pull/173)): the tunnel-node's batch drain switched from a fixed 150 ms sleep to an event-driven `Notify` wait. Idle sessions now hold open in a long-poll up to 5 s — the first incoming byte wakes the batch. Telegram, chat apps, and any push-driven flow feel noticeably snappier. Backward compat with pre-#173 tunnel-nodes is automatic: if an empty round-trip returns under 1.5 s with no data, the client concludes the server is doing the legacy fixed-sleep drain and reverts to the pre-long-poll cadence (sticky for the lifetime of the mux). 92 client tests + 17 tunnel-node tests including end-to-end TCP-pair verification of the notify wiring +• Prebuilt Docker image for tunnel-node ([ghcr.io/therealaleph/mhrv-tunnel-node](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/pkgs/container/mhrv-tunnel-node)): setting up Full Tunnel mode is now a one-liner instead of rustup + cargo build on a small VPS. Multi-arch (linux/amd64 + linux/arm64), tagged `:latest`, `:1.5`, `:1.5.0`. Runs as a non-root user. Full deployment docs in [tunnel-node/README.md](tunnel-node/README.md). The image is ~32 MB compressed +• Brief Persian "what's new" note above the links in every Telegram release post: bullet headlines are auto-extracted from `docs/changelog/v.md` and rendered as a Telegram `
` above the two existing repo + release links. Markdown links / inline-code in the headlines convert to Telegram HTML so issue/PR refs stay clickable. Cap-budget-aware truncation at bullet boundaries keeps total caption under Telegram's 1024-char limit +• GitHub Releases page bodies now lead with the changelog content (Persian section + `---` separator + English section) instead of just the auto-generated `**Full Changelog**: …compare…` link, which was empty for fix-forward releases like v1.4.1. The auto comparison link is appended at the bottom rather than removed diff --git a/tunnel-node/.dockerignore b/tunnel-node/.dockerignore new file mode 100644 index 0000000..f112158 --- /dev/null +++ b/tunnel-node/.dockerignore @@ -0,0 +1,15 @@ +# Keep the build context small. The builder stage only needs Cargo.toml, +# Cargo.lock, and src/ — everything else (cached cargo target/, IDE +# state, README, etc.) just slows down `docker build` and bloats the +# context sent to the daemon. +target/ +**/*.rs.bk +.git/ +.gitignore +.idea/ +.vscode/ +*.iml +README.md +LICENSE +Dockerfile +.dockerignore diff --git a/tunnel-node/Dockerfile b/tunnel-node/Dockerfile index 805648b..a31f31e 100644 --- a/tunnel-node/Dockerfile +++ b/tunnel-node/Dockerfile @@ -1,12 +1,62 @@ +# syntax=docker/dockerfile:1 +# +# Multi-stage build for the mhrv-tunnel-node service. +# +# Build stage compiles a release binary against rust 1.85 (matches MSRV in +# Cargo.toml). Cargo's incremental build cache is mounted via BuildKit +# `--mount=type=cache` so a `docker build` against an unchanged dependency +# tree skips re-downloading + re-compiling crates — first build ~6 min, +# warm builds ~30 s. +# +# Runtime stage is `debian:bookworm-slim` for libc compatibility (the +# binary dynamically links against glibc) plus `ca-certificates` so HTTPS +# upstream URLs from `data` ops can do TLS handshake. Image stays under +# 100 MB end-to-end. +# +# Runs as a dedicated non-root `tunnel` user (uid 1000) — the service +# never needs to write outside its own process state, so no reason to +# give it root. +# +# Required env vars: +# TUNNEL_AUTH_KEY shared secret matching `const TUNNEL_AUTH_KEY` in +# CodeFull.gs. The service refuses every request +# without a matching key. +# PORT HTTP listen port. Defaults to 8080 if unset. +# +# Health: the service responds to `GET /` with a small status JSON. Add +# `--health-cmd 'curl -fsS http://localhost:8080/ || exit 1'` on the +# `docker run` if you want compose-level health gating. + FROM rust:1.85-slim AS builder WORKDIR /app -COPY Cargo.toml ./ +# Copy lockfile so cargo uses pinned versions identically to local builds. +COPY Cargo.toml Cargo.lock ./ COPY src/ ./src/ -RUN cargo build --release --bin tunnel-node +# BuildKit cache mounts: cargo's registry/git caches and the target/ +# directory persist across builds, dramatically speeding up rebuilds when +# only application code changes. +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/app/target \ + cargo build --release --bin tunnel-node && \ + cp /app/target/release/tunnel-node /usr/local/bin/tunnel-node FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/tunnel-node /usr/local/bin/ +# `ca-certificates` for HTTPS upstream targets; nothing else needed at +# runtime since the binary is statically linked against musl-equivalents +# only for the parts that don't touch glibc. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Non-root runtime user. The service does no filesystem writes outside +# /tmp, so a static-uid unprivileged user is sufficient and prevents +# accidental host-FS writes if the container is volume-mounted. +RUN useradd --system --uid 1000 --no-create-home --shell /usr/sbin/nologin tunnel + +COPY --from=builder /usr/local/bin/tunnel-node /usr/local/bin/tunnel-node + +USER tunnel ENV PORT=8080 EXPOSE 8080 -CMD ["tunnel-node"] +ENTRYPOINT ["tunnel-node"] diff --git a/tunnel-node/README.md b/tunnel-node/README.md index 9dee9a8..7dd0af3 100644 --- a/tunnel-node/README.md +++ b/tunnel-node/README.md @@ -31,7 +31,44 @@ gcloud run deploy tunnel-node \ --max-instances 1 ``` -### Docker (any VPS) +### Docker — prebuilt image (any VPS) + +The fastest path. Pull a prebuilt image and run it; no Rust toolchain needed on the VPS. + +```bash +# Generate a strong secret. Save it — you'll paste the same value into CodeFull.gs. +SECRET=$(openssl rand -hex 24) +echo "Your TUNNEL_AUTH_KEY: $SECRET" + +# Pull + run. +docker run -d \ + --name mhrv-tunnel \ + --restart unless-stopped \ + -p 8080:8080 \ + -e TUNNEL_AUTH_KEY="$SECRET" \ + ghcr.io/therealaleph/mhrv-tunnel-node:latest +``` + +The `:latest` tag tracks the most recent release. To pin a specific version (recommended for production), use `ghcr.io/therealaleph/mhrv-tunnel-node:v1.5.0` (or whatever release you're on). Image is available for `linux/amd64` and `linux/arm64`. + +**docker-compose.yml** if you prefer: + +```yaml +services: + tunnel: + image: ghcr.io/therealaleph/mhrv-tunnel-node:latest + restart: unless-stopped + ports: + - "8080:8080" + environment: + TUNNEL_AUTH_KEY: ${TUNNEL_AUTH_KEY} +``` + +Then `TUNNEL_AUTH_KEY=your-secret docker compose up -d`. + +### Docker — build from source + +If you'd rather build the image yourself (or add custom changes): ```bash cd tunnel-node