Skip to content

Commit 5ff9ac4

Browse files
therealalephclaude
andcommitted
v1.5.0: long-poll Full Tunnel + Docker tunnel-node + brief FA release notes
Ships PR #173 (event-driven drain) plus three operational improvements: PR #173 — long-poll tunnel mode. The tunnel-node's batch drain switched from a fixed 150 ms sleep to an event-driven Notify wait; idle sessions long-poll up to 5 s and wake on the first byte from upstream. Push notifications and chat messages now arrive in roughly RTT instead of waiting for the next client poll tick. Backward compat with pre-#173 tunnel-nodes is automatic via a sticky AtomicBool that detects fast empty replies and reverts to the legacy cadence. 92 client tests + 17 tunnel-node tests pass, including end-to-end TCP-pair verification of the notify wiring. Docker image for tunnel-node. Adds a hardened Dockerfile (BuildKit cache mounts, non-root runtime user, ca-certificates for HTTPS upstreams) and a .dockerignore to keep build context small. New `tunnel-docker` job in the release workflow builds + pushes multi-arch (linux/amd64 + linux/arm64) to ghcr.io/therealaleph/mhrv-tunnel-node with `:latest`, `:1.5`, and `:1.5.0` tags on every release. Setting up Full Tunnel mode goes from "rustup + cargo build on a 1 GB VPS" (which fails on memory half the time) to a one-liner. tunnel-node/README.md updated with prebuilt-image + docker-compose recipes. Brief Persian release note in Telegram caption. The release-post caption now leads with a `<blockquote>`-wrapped FA bullet headlines extracted from `docs/changelog/v<ver>.md`, above the existing two links (repo + release). Markdown links → Telegram HTML <a> for clickability. Cap-budget-aware truncation at bullet boundaries keeps total caption under Telegram's 1024-char limit. Headlines-only rather than full bullets so multiple "what's new" items fit comfortably (the full bullets remain on the GH release page and as the optional --with-changelog reply-threaded message). GitHub Releases page bodies now lead with the changelog content (Persian section + `---` + English) instead of just a Full Changelog comparison link. The auto comparison link is appended at the bottom via `append_body: true` rather than removed. Workflow changes: - New `permissions: packages: write` at the workflow level (required for ghcr push via docker/login-action). - New `tunnel-docker` job needs `build` (not the full matrix) to serialize the QEMU buildx layer with the matrix cache. - Release job composes the body from `docs/changelog/v${VER}.md` in a pre-step that handles both tag-push and workflow_dispatch paths (uses inputs.version || github.ref_name like the rest of the workflow). Tested locally: - `cargo test` — 92 lib tests pass - `cargo test -p mhrv-tunnel-node` — 17 tests pass - `docker build` of tunnel-node Dockerfile — 32 MB image, runs as non-root, /health returns "ok", auth rejection works correctly, legitimate requests open sessions to remote hosts - Telegram script `--dry-run` mode added; rendered captions for v1.4.0, v1.4.1, v1.5.0 all fit under 900 chars Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c392a33 commit 5ff9ac4

10 files changed

Lines changed: 412 additions & 28 deletions

File tree

.github/scripts/telegram_release_notify.py

Lines changed: 158 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,110 @@ def parse_changelog(path: str) -> tuple[str, str]:
6464
return fa.strip(), en.strip()
6565

6666

67+
# Telegram caption hard-cap is 1024 chars. The fixed parts of our caption
68+
# (title + SHA hash + two-link footer with their preambles) sum to roughly
69+
# 470 chars on a typical version string. That leaves ~550 chars for the
70+
# release-note section before we'd start losing the trailing release URL.
71+
# Keep the budget conservative so a long version string or a slightly
72+
# longer hash representation doesn't push us over.
73+
CAPTION_FA_NOTE_BUDGET = 500
74+
75+
76+
def _md_links_to_html(text: str) -> str:
77+
"""Convert `[label](url)` markdown links to `<a href="url">label</a>`.
78+
79+
Telegram's HTML parse mode renders `<a>` as clickable but treats
80+
markdown verbatim, so an unconverted `[#160](https://…)` appears as
81+
that literal string in the channel post — both ugly and wasteful of
82+
caption budget. The HTML form is shorter visually (`#160` vs the
83+
full URL), still clickable, and counts the same toward Telegram's
84+
1024-char limit. Inline `code` (`backtick-quoted`) is also
85+
translated to `<code>…</code>` since markdown backticks render
86+
literally too.
87+
"""
88+
text = re.sub(
89+
r"\[([^\]]+)\]\(([^)]+)\)",
90+
lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>',
91+
text,
92+
)
93+
text = re.sub(r"`([^`\n]+)`", r"<code>\1</code>", text)
94+
# Bold (**…**) is rare in our changelog but happens — convert to <b>.
95+
text = re.sub(r"\*\*([^*\n]+)\*\*", r"<b>\1</b>", text)
96+
return text
97+
98+
99+
def _extract_headlines(fa_section: str) -> str:
100+
"""For each `• …: …` bullet, keep the headline part and drop the
101+
elaboration.
102+
103+
Our changelog convention writes each bullet as one of:
104+
• headline: full explanation
105+
• headline ([#NN](url)): full explanation
106+
• headline (issue ref): full explanation
107+
108+
The headline is everything up to the `: ` (colon + space) that ends
109+
the leading clause. Naively searching for the first `:` lands inside
110+
`https:` URLs of the markdown link form — instead we search from the
111+
end of the parenthesized-issue-ref (if any) for the first `: `, or
112+
fall back to the first `: ` in the line.
113+
114+
Headlines stay on the FA caption; the explanation is preserved in
115+
the docs/changelog/ file and (optionally) the reply-threaded message
116+
posted via --with-changelog.
117+
118+
Returns a newline-joined string of `• <headline>` lines.
119+
"""
120+
headlines: list[str] = []
121+
for line in fa_section.splitlines():
122+
if not line.startswith("• "):
123+
continue
124+
body = line[2:] # drop "• "
125+
# Prefer cutting at "): " — the close of the parenthesized ref
126+
# followed by the convention colon + space. That's our actual
127+
# bullet structure and avoids the false-positive `https:` cut.
128+
cut_idx = body.find("): ")
129+
if cut_idx > 0:
130+
headline = body[: cut_idx + 1] # keep the close paren
131+
else:
132+
# Fall back to ": " (colon + space) anywhere in the body.
133+
# Adding the space requirement skips `https:` which is
134+
# always followed by `/`.
135+
cut_idx = body.find(": ")
136+
headline = body[:cut_idx] if cut_idx > 0 else body
137+
headlines.append(f"• {headline.rstrip()}")
138+
return "\n".join(headlines)
139+
140+
141+
def build_caption_release_note(changelog_path: str) -> str:
142+
"""Build the Persian "what's new" block for the Telegram caption.
143+
144+
Pulls the FA section of `docs/changelog/v<ver>.md`, extracts just
145+
the bullet headlines (before the first `:` of each bullet) so the
146+
note is compact, converts markdown links/code to Telegram HTML for
147+
clickability, and wraps in a `<blockquote>`. Falls back to the full
148+
FA section if the headlines extraction yields nothing (e.g. a
149+
changelog that doesn't follow our `• headline: details` convention).
150+
151+
If the result still exceeds CAPTION_FA_NOTE_BUDGET, truncate at a
152+
bullet boundary with a trailing `…`. In practice the headlines-only
153+
form fits comfortably for any reasonable release note.
154+
"""
155+
fa, _en = parse_changelog(changelog_path)
156+
if not fa:
157+
return ""
158+
headlines = _extract_headlines(fa)
159+
note = headlines if headlines else fa.strip()
160+
note = _md_links_to_html(note)
161+
if len(note) > CAPTION_FA_NOTE_BUDGET:
162+
truncated = note[:CAPTION_FA_NOTE_BUDGET]
163+
last_bullet = truncated.rfind("\n•")
164+
if last_bullet > 0:
165+
note = truncated[:last_bullet].rstrip() + "\n…"
166+
else:
167+
note = truncated.rstrip() + "…"
168+
return f"<blockquote>{note}</blockquote>"
169+
170+
67171
def sha256_of(path: str) -> str:
68172
h = hashlib.sha256()
69173
with open(path, "rb") as f:
@@ -169,33 +273,70 @@ def main() -> int:
169273
# the tag (the workflow converts that into --with-changelog).
170274
ap.add_argument("--with-changelog", action="store_true",
171275
help="Include the Persian+English changelog as a reply-threaded message.")
276+
# Dry-run lets you verify the rendered caption locally without hitting
277+
# Telegram. Useful when changing the brief-release-note budget /
278+
# truncation logic — print, eyeball, push.
279+
ap.add_argument("--dry-run", action="store_true",
280+
help="Render the caption and print it instead of posting. "
281+
"Skips token/chat_id checks.")
172282
args = ap.parse_args()
173283

174-
token = os.environ.get("BOT_TOKEN", "")
175-
chat_id = os.environ.get("CHAT_ID", "")
176-
if not token or not chat_id:
177-
print("TELEGRAM secrets not present, skipping post.")
178-
return 0
284+
if not args.dry_run:
285+
token = os.environ.get("BOT_TOKEN", "")
286+
chat_id = os.environ.get("CHAT_ID", "")
287+
if not token or not chat_id:
288+
print("TELEGRAM secrets not present, skipping post.")
289+
return 0
290+
else:
291+
token = ""
292+
chat_id = ""
179293

180294
ver = args.version
181295
sha = sha256_of(args.apk)
296+
# Brief Persian release-note above the links. Pulled from the FA
297+
# half of `docs/changelog/v<ver>.md` so each release auto-includes
298+
# what's new without manual edits to this script. Truncated to fit
299+
# Telegram's 1024-char caption budget alongside title + SHA + the
300+
# two-link footer.
301+
fa_note = build_caption_release_note(args.changelog)
302+
182303
# Caption structure requested by the repo owner:
183304
# 1. Title + SHA-256 (as before)
184-
# 2. Persian preamble labelling the repo link as
305+
# 2. Brief Persian "what's new" note (extracted from changelog)
306+
# 3. Persian preamble labelling the repo link as
185307
# "GitHub repo + full Persian guide"
186-
# 3. Repo URL
187-
# 4. Persian preamble labelling the release link as
308+
# 4. Repo URL
309+
# 5. Persian preamble labelling the release link as
188310
# "this version's release — desktop/router builds live here"
189-
# 5. Release URL
311+
# 6. Release URL
190312
# Keeps total well under Telegram's 1024-char caption limit.
191-
caption = (
192-
f"<b>mhrv-rs Android v{ver}</b>\n\n"
193-
f"SHA-256: <code>{sha}</code>\n\n"
194-
f"مخزن گیتهاب + مطالعه راهنمای کامل فارسی:\n"
195-
f"https://github.com/{args.repo}\n\n"
196-
f"لینک به این نسخه جهت دریافت نسخه های مربوط به مودم و کامپیوتر:\n"
197-
f"https://github.com/{args.repo}/releases/tag/v{ver}"
198-
)
313+
caption_parts = [
314+
f"<b>mhrv-rs Android v{ver}</b>",
315+
"",
316+
f"SHA-256: <code>{sha}</code>",
317+
]
318+
if fa_note:
319+
caption_parts.extend(["", fa_note])
320+
caption_parts.extend([
321+
"",
322+
"مخزن گیتهاب + مطالعه راهنمای کامل فارسی:",
323+
f"https://github.com/{args.repo}",
324+
"",
325+
"لینک به این نسخه جهت دریافت نسخه های مربوط به مودم و کامپیوتر:",
326+
f"https://github.com/{args.repo}/releases/tag/v{ver}",
327+
])
328+
caption = "\n".join(caption_parts)
329+
330+
if args.dry_run:
331+
print(f"--- DRY RUN: caption ({len(caption)} chars) ---")
332+
print(caption)
333+
print(f"--- END DRY RUN ---")
334+
if args.with_changelog:
335+
fa, en = parse_changelog(args.changelog)
336+
print(f"\nWould reply with changelog "
337+
f"(fa: {len(fa) if fa else 0} chars, "
338+
f"en: {len(en) if en else 0} chars)")
339+
return 0
199340

200341
doc_mid = send_document(token, chat_id, args.apk, caption)
201342
print(f"sendDocument OK, message_id={doc_mid}")

.github/workflows/release.yml

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ on:
2222

2323
permissions:
2424
contents: write
25+
# `tunnel-docker` job pushes to ghcr.io/therealaleph/mhrv-tunnel-node.
26+
# `packages: write` is required by docker/login-action when authenticating
27+
# to GHCR with the workflow's auto-provisioned GITHUB_TOKEN. Granted at
28+
# the workflow level so the matrix-build job (which doesn't need it) and
29+
# the release job (which doesn't need it) both still have a single
30+
# well-scoped permissions block.
31+
packages: write
2532

2633
# Runner strategy:
2734
# - Linux + Android + mipsel: self-hosted (mhrv-hetzner-*, Hetzner
@@ -487,6 +494,75 @@ jobs:
487494
path: dist/*.apk
488495
if-no-files-found: error
489496

497+
# Build + publish the tunnel-node Docker image to GHCR. Issue: every
498+
# full-mode user has to set up tunnel-node on a VPS, and "rustup +
499+
# cargo build --release" on a 1GB VPS is non-trivial — fails on memory,
500+
# takes 8+ minutes if it works, blocks anyone without Rust experience.
501+
# A prebuilt multi-arch image makes deployment a one-liner:
502+
# docker run -d -p 8080:8080 -e TUNNEL_AUTH_KEY=... \
503+
# ghcr.io/therealaleph/mhrv-tunnel-node:latest
504+
#
505+
# Tags published per release:
506+
# v1.5.0 — exact version pin
507+
# 1.5 — auto-following minor
508+
# latest — most recent release (skipped on workflow_dispatch
509+
# re-publishes; see `latest` condition below)
510+
#
511+
# Build platforms: linux/amd64 and linux/arm64. Most VPS providers
512+
# (DigitalOcean, Hetzner, Oracle Free Tier) offer arm64 instances at
513+
# half price, and the binary works on both.
514+
tunnel-docker:
515+
needs: build
516+
runs-on: ubuntu-latest
517+
permissions:
518+
contents: read
519+
packages: write
520+
steps:
521+
- uses: actions/checkout@v4
522+
523+
# Compute the version string the same way the rest of the workflow
524+
# does: tag pushes get it from github.ref_name (e.g. "v1.5.0"),
525+
# workflow_dispatch from the explicit `inputs.version` (e.g.
526+
# "1.5.0"). Strip a possible leading "v" so the docker tag namespace
527+
# is consistent: `:1.5.0`, not `:v1.5.0`.
528+
- name: Compute version
529+
id: ver
530+
run: |
531+
VER="${{ inputs.version || github.ref_name }}"
532+
VER="${VER#v}"
533+
MINOR="${VER%.*}"
534+
echo "version=${VER}" >> "$GITHUB_OUTPUT"
535+
echo "minor=${MINOR}" >> "$GITHUB_OUTPUT"
536+
echo "Building docker for v${VER} (minor: ${MINOR})"
537+
538+
- uses: docker/setup-qemu-action@v3
539+
- uses: docker/setup-buildx-action@v3
540+
541+
- name: Log in to GHCR
542+
uses: docker/login-action@v3
543+
with:
544+
registry: ghcr.io
545+
username: ${{ github.actor }}
546+
password: ${{ secrets.GITHUB_TOKEN }}
547+
548+
# Build for both amd64 and arm64. `:latest` is only updated on
549+
# actual tag pushes — workflow_dispatch re-runs on an existing
550+
# version (e.g. for the v1.4.0 mipsel republish) shouldn't move
551+
# the latest pointer.
552+
- name: Build and push image
553+
uses: docker/build-push-action@v6
554+
with:
555+
context: ./tunnel-node
556+
file: ./tunnel-node/Dockerfile
557+
platforms: linux/amd64,linux/arm64
558+
push: true
559+
tags: |
560+
ghcr.io/${{ github.repository_owner }}/mhrv-tunnel-node:${{ steps.ver.outputs.version }}
561+
ghcr.io/${{ github.repository_owner }}/mhrv-tunnel-node:${{ steps.ver.outputs.minor }}
562+
${{ github.event_name == 'push' && format('ghcr.io/{0}/mhrv-tunnel-node:latest', github.repository_owner) || '' }}
563+
cache-from: type=gha
564+
cache-to: type=gha,mode=max
565+
490566
# release + telegram: lightweight aggregation jobs kept on GH-hosted
491567
# ubuntu-latest. They only download artifacts and call APIs — no build
492568
# tooling needed, no benefit from moving to self-hosted, and keeping them
@@ -507,6 +583,38 @@ jobs:
507583
path: dist
508584
merge-multiple: true
509585

586+
# Compose the GitHub release body from `docs/changelog/v<ver>.md`
587+
# so the Releases page tells humans what actually changed —
588+
# `generate_release_notes: true` alone produces "Full Changelog:
589+
# …compare/v1.x.0...v1.x.1" which is empty when no PRs landed
590+
# between tags (e.g. for fix-forward releases like v1.4.1). The
591+
# changelog file already exists for every release in our format
592+
# (Persian section, then `---`, then English section); we wrap it
593+
# with a header and append the auto-generated commit list at the
594+
# bottom by NOT setting body_path and instead setting body
595+
# directly to changelog_content + (the existing
596+
# generate_release_notes flag handles the trailing comparison
597+
# link automatically).
598+
- name: Compose release body
599+
id: relbody
600+
run: |
601+
VER="${{ inputs.version || github.ref_name }}"
602+
VER="${VER#v}"
603+
CHANGELOG="docs/changelog/v${VER}.md"
604+
if [ ! -f "$CHANGELOG" ]; then
605+
echo "::warning::no changelog at $CHANGELOG; release body will fall back to generate_release_notes only"
606+
echo "has_changelog=false" >> "$GITHUB_OUTPUT"
607+
exit 0
608+
fi
609+
{
610+
echo 'body<<__RELEASE_BODY_EOF__'
611+
# Strip leading HTML comment that documents the file format.
612+
sed -e '1{/^<!--/d;}' "$CHANGELOG"
613+
echo
614+
echo '__RELEASE_BODY_EOF__'
615+
} >> "$GITHUB_OUTPUT"
616+
echo "has_changelog=true" >> "$GITHUB_OUTPUT"
617+
510618
- name: Release
511619
uses: softprops/action-gh-release@v2
512620
with:
@@ -519,6 +627,13 @@ jobs:
519627
# tags are `v1.4.0`, not `1.4.0`).
520628
tag_name: ${{ inputs.version && format('v{0}', inputs.version) || github.ref_name }}
521629
files: dist/*
630+
# Append auto-generated comparison link AFTER our changelog
631+
# body — `append_body: true` puts our body first, then the
632+
# auto notes. If no changelog file existed, body is empty and
633+
# the auto notes carry the whole release-page content (same
634+
# behavior as before this change).
635+
body: ${{ steps.relbody.outputs.body }}
636+
append_body: true
522637
generate_release_notes: true
523638

524639
# Notify the Persian-speaking Telegram channel with the CI-built

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mhrv-rs"
3-
version = "1.4.1"
3+
version = "1.5.0"
44
edition = "2021"
55
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
66
license = "MIT"

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,13 @@ More deployments = more total concurrency = lower per-session latency. Each batc
297297
- **Solo use** → 1–2 accounts is plenty
298298
- **Shared with ~3 people** → 3 accounts
299299
- **Shared with a group** → one account per heavy user
300-
2. Deploy the [tunnel-node](tunnel-node/) on a VPS
300+
2. Deploy the [tunnel-node](tunnel-node/) on a VPS. The fastest path is the prebuilt Docker image:
301+
```bash
302+
docker run -d --name mhrv-tunnel --restart unless-stopped \
303+
-p 8080:8080 -e TUNNEL_AUTH_KEY=your-strong-secret \
304+
ghcr.io/therealaleph/mhrv-tunnel-node:latest
305+
```
306+
Multi-arch (linux/amd64 + linux/arm64), runs as a non-root user, ~32 MB compressed. Pin a version tag (`:1.5.0`) for production. See [tunnel-node/README.md](tunnel-node/README.md) for Cloud Run, docker-compose, and source-build alternatives.
301307
3. Set `"mode": "full"` in your config with all deployment IDs:
302308

303309
```json
@@ -628,6 +634,16 @@ Donations cover hosting, self-hosted CI runner costs, and continued maintenance.
628634

629635
حالت `"mode": "full"` **تمام** ترافیک را سرتاسر از طریق `Apps Script` و یک [tunnel-node](tunnel-node/) روی سرور شما عبور می‌دهد — **بدون نیاز به نصب گواهی `MITM`**. تنها هزینه‌اش تأخیر بیشتر است (هر بایت از مسیر `Apps Script → tunnel-node → مقصد` می‌رود)، اما برای هر پروتکل و هر برنامه بدون نصب `CA` کار می‌کند.
630636

637+
**سریع‌ترین راه راه‌اندازی `tunnel-node` روی `VPS`:** ایمیج آمادهٔ `Docker`:
638+
639+
```bash
640+
docker run -d --name mhrv-tunnel --restart unless-stopped \
641+
-p 8080:8080 -e TUNNEL_AUTH_KEY=رمز_قوی_شما \
642+
ghcr.io/therealaleph/mhrv-tunnel-node:latest
643+
```
644+
645+
`multi-arch` (هم `linux/amd64` و هم `linux/arm64`)، اجرا با کاربر غیر `root`، حدود ۳۲ مگابایت فشرده. برای محیط production نسخهٔ مشخص (`:1.5.0`) را pin کنید. راهنمای کامل (شامل `Cloud Run`، `docker-compose`، و بیلد از سورس) در [`tunnel-node/README.md`](tunnel-node/README.md) هست.
646+
631647
#### چرا تعداد `Deployment ID` مهم است؟
632648

633649
هر درخواست دسته‌ای (`batch`) به `Apps Script` حدود ۲ ثانیه طول می‌کشد. در حالت `full`، برنامه یک **لولهٔ موازی** (`pipeline`) اجرا می‌کند که چند درخواست دسته‌ای را همزمان می‌فرستد بدون اینکه منتظر پاسخ قبلی بماند. هر `Deployment ID` (= یک حساب گوگل) حوضچهٔ همزمانی مخصوص خودش با **۳۰ درخواست همزمان** دارد — مطابق سقف اجرای همزمان `Apps Script` به ازای هر حساب.

0 commit comments

Comments
 (0)