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