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>
This commit is contained in:
therealaleph
2026-04-25 11:54:45 +03:00
parent 7efd12d8d3
commit fb552c227d
10 changed files with 412 additions and 28 deletions
+158 -17
View File
@@ -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 `<a href="url">label</a>`.
Telegram's HTML parse mode renders `<a>` 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 `<code>…</code>` since markdown backticks render
literally too.
"""
text = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>',
text,
)
text = re.sub(r"`([^`\n]+)`", r"<code>\1</code>", text)
# Bold (**…**) is rare in our changelog but happens — convert to <b>.
text = re.sub(r"\*\*([^*\n]+)\*\*", r"<b>\1</b>", 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 `• <headline>` 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<ver>.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 `<blockquote>`. 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"<blockquote>{note}</blockquote>"
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<ver>.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"<b>mhrv-rs Android v{ver}</b>\n\n"
f"SHA-256: <code>{sha}</code>\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"<b>mhrv-rs Android v{ver}</b>",
"",
f"SHA-256: <code>{sha}</code>",
]
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}")
+115
View File
@@ -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<ver>.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{/^<!--/d;}' "$CHANGELOG"
echo
echo '__RELEASE_BODY_EOF__'
} >> "$GITHUB_OUTPUT"
echo "has_changelog=true" >> "$GITHUB_OUTPUT"
- name: Release
uses: softprops/action-gh-release@v2
with:
@@ -519,6 +627,13 @@ jobs:
# tags are `v1.4.0`, not `1.4.0`).
tag_name: ${{ inputs.version && format('v{0}', inputs.version) || github.ref_name }}
files: dist/*
# Append auto-generated comparison link AFTER our changelog
# body — `append_body: true` puts our body first, then the
# auto notes. If no changelog file existed, body is empty and
# the auto notes carry the whole release-page content (same
# behavior as before this change).
body: ${{ steps.relbody.outputs.body }}
append_body: true
generate_release_notes: true
# Notify the Persian-speaking Telegram channel with the CI-built
Generated
+1 -1
View File
@@ -2186,7 +2186,7 @@ dependencies = [
[[package]]
name = "mhrv-rs"
version = "1.4.1"
version = "1.5.0"
dependencies = [
"base64 0.22.1",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mhrv-rs"
version = "1.4.1"
version = "1.5.0"
edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT"
+17 -1
View File
@@ -297,7 +297,13 @@ More deployments = more total concurrency = lower per-session latency. Each batc
- **Solo use** → 12 accounts is plenty
- **Shared with ~3 people** → 3 accounts
- **Shared with a group** → one account per heavy user
2. Deploy the [tunnel-node](tunnel-node/) on a VPS
2. Deploy the [tunnel-node](tunnel-node/) on a VPS. The fastest path is the prebuilt Docker image:
```bash
docker run -d --name mhrv-tunnel --restart unless-stopped \
-p 8080:8080 -e TUNNEL_AUTH_KEY=your-strong-secret \
ghcr.io/therealaleph/mhrv-tunnel-node:latest
```
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.
3. Set `"mode": "full"` in your config with all deployment IDs:
```json
@@ -628,6 +634,16 @@ Donations cover hosting, self-hosted CI runner costs, and continued maintenance.
حالت `"mode": "full"` **تمام** ترافیک را سرتاسر از طریق `Apps Script` و یک [tunnel-node](tunnel-node/) روی سرور شما عبور می‌دهد — **بدون نیاز به نصب گواهی `MITM`**. تنها هزینه‌اش تأخیر بیشتر است (هر بایت از مسیر `Apps Script → tunnel-node → مقصد` می‌رود)، اما برای هر پروتکل و هر برنامه بدون نصب `CA` کار می‌کند.
**سریع‌ترین راه راه‌اندازی `tunnel-node` روی `VPS`:** ایمیج آمادهٔ `Docker`:
```bash
docker run -d --name mhrv-tunnel --restart unless-stopped \
-p 8080:8080 -e TUNNEL_AUTH_KEY=رمز_قوی_شما \
ghcr.io/therealaleph/mhrv-tunnel-node:latest
```
`multi-arch` (هم `linux/amd64` و هم `linux/arm64`)، اجرا با کاربر غیر `root`، حدود ۳۲ مگابایت فشرده. برای محیط production نسخهٔ مشخص (`:1.5.0`) را pin کنید. راهنمای کامل (شامل `Cloud Run`، `docker-compose`، و بیلد از سورس) در [`tunnel-node/README.md`](tunnel-node/README.md) هست.
#### چرا تعداد `Deployment ID` مهم است؟
هر درخواست دسته‌ای (`batch`) به `Apps Script` حدود ۲ ثانیه طول می‌کشد. در حالت `full`، برنامه یک **لولهٔ موازی** (`pipeline`) اجرا می‌کند که چند درخواست دسته‌ای را همزمان می‌فرستد بدون اینکه منتظر پاسخ قبلی بماند. هر `Deployment ID` (= یک حساب گوگل) حوضچهٔ همزمانی مخصوص خودش با **۳۰ درخواست همزمان** دارد — مطابق سقف اجرای همزمان `Apps Script` به ازای هر حساب.
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.therealaleph.mhrv"
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
targetSdk = 34
versionCode = 137
versionName = "1.4.1"
versionCode = 138
versionName = "1.5.0"
// Ship all four mainstream Android ABIs:
// - arm64-v8a — 95%+ of real-world Android phones since 2019
+10
View File
@@ -0,0 +1,10 @@
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
• پیام‌های 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<ver>.md` خودکار استخراج میشن و به‌عنوان <blockquote> بالای دو لینک repo و release میان. کلیک‌پذیری روی شمارهٔ issue/PR از طریق تبدیل markdown به HTML
• body صفحهٔ release گیت‌هاب دیگه فقط لینک comparison نیست — حالا محتوای کامل `docs/changelog/v<ver>.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<ver>.md` and rendered as a Telegram `<blockquote>` 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
+15
View File
@@ -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
+55 -5
View File
@@ -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"]
+38 -1
View File
@@ -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