+
+# 🌍 thefeed
+
+**خواندن کانالهای تلگرام از طریق DNS — برای اینترنت سانسورشده**
+
+[English](README.md) | فارسی
+
+---
+
+## thefeed چیست؟
+
+thefeed یک سیستم تونل DNS است که به شما اجازه میدهد پیامهای کانالهای تلگرام را حتی وقتی تلگرام و اینترنت فیلتر شده، بخوانید. تنها چیزی که نیاز دارید **DNS** است — که تقریباً هیچوقت مسدود نمیشود.
+
+```
+┌──────────────┐ درخواست DNS TXT ┌──────────────┐ MTProto ┌──────────┐
+│ کلاینت │ ─────────────▸ │ سرور │ ──────────────────────▸ │ تلگرام │
+│ (رابط وب) │ ◂────────────────────── │ (DNS) │ ◂───────────── │ API │
+└──────────────┘ پاسخ رمزنگاریشده └──────────────┘ └──────────┘
+```
+
+## ✨ ویژگیها
+
+### سمت سرور (خارج از ایران)
+- اتصال به تلگرام و خواندن پیام کانالها
+- سرو دادهها به صورت پاسخ DNS TXT رمزنگاریشده
+- padding تصادفی برای جلوگیری از شناسایی DPI
+- ذخیرهسازی session — یکبار لاگین، همیشه اجرا
+- پشتیبانی از حالت بدون تلگرام (`--no-telegram`) — خواندن کانالهای عمومی بدون نیاز به ورود به تلگرام
+
+### سمت کلاینت (داخل ایران)
+- رابط کاربری وب با پشتیبانی RTL/فارسی (فونت وزیرمتن)
+- تنظیمات از طریق مرورگر — بدون نیاز به خط فرمان
+- ارسال پیام به کانالها و چتهای خصوصی (نیاز به `--allow-manage` سمت سرور)
+- مدیریت کانالها از راه دور (افزودن/حذف کانالها)
+- فشردهسازی پیامها (deflate)
+- محافظت رابط وب با رمز عبور (`--password` سمت کلاینت)
+- لاگ زنده درخواستهای DNS در مرورگر
+- کش آفلاین — دادهها بعد از قطع اتصال نگه داشته میشوند
+
+### ضد DPI
+- **اندازه متغیر پاسخ**: Padding تصادفی (۰-۳۲ بایت)
+- **کوئری تکبرچسب**: رمزنگاری Base32 در یک برچسب DNS
+- **شافل Resolver**: توزیع تصادفی کوئریها بین resolverها
+- **محدودیت نرخ**: قابل تنظیم برای ترکیب با ترافیک عادی DNS
+- **Padding تصادفی کوئری**: ۴ بایت تصادفی در هر درخواست
+- **اندازه بلاک متغیر**: بلاکهای ۴۰۰-۷۰۰ بایت
+
+## 🔐 رمزنگاری و احراز هویت
+
+### مدل دو بخشی
+
+**بخش ۱ — رمز عبور رمزنگاری (`--key`):** روی سرور و کلاینت هر دو لازم است. هر کسی با این رمز میتواند همه پیامها (از جمله کانالهای خصوصی) را بخواند. میتوانید آن را با دوستان مورد اعتماد به اشتراک بگذارید.
+
+**بخش ۲ — مدیریت از راه دور (`--allow-manage` سمت سرور):** وقتی فعال باشد، هر کسی با کلید رمزنگاری میتواند پیام ارسال کند و کانالها را مدیریت کند. به صورت پیشفرض غیرفعال است.
+
+**رمز عبور وب کلاینت (`--password`):** تمام صفحات رابط وب را با HTTP Basic Auth محافظت میکند. این فقط محافظت محلی است.
+
+### ویژگیهای امنیتی
+
+- **AES-256-GCM** برای تمام پاسخها و پیامهای ارسالی
+- کلیدهای مجزا از طریق HKDF برای کوئری و پاسخ
+- Padding تصادفی در هر دو جهت
+- بدون state — هر درخواست مستقل است
+- بررسی رمز عبور ادمین سمت سرور با مقایسه زمانثابت
+- فایل session با مجوز ۰۶۰۰
+
+> ⚠️ هرگز رمز عبور رمزنگاری (passphrase) خود را عمومی به اشتراک نگذارید — هر کسی با آن میتواند کلاینت خودش را اجرا و تمام پیامهای شما را بخواند. `--password` سمت کلاینت فقط رابط وب روی دستگاه خودتان را محافظت میکند.
+
+## ⚡ نصب سریع سرور
+
+```bash
+bash <(curl -Ls https://raw.githubusercontent.com/sartoopjj/thefeed/main/scripts/install.sh)
+```
+
+اسکریپت:
+1. آخرین باینری را از GitHub دانلود میکند
+2. دامنه، رمز عبور، اطلاعات تلگرام و کانالها را میپرسد
+3. به تلگرام لاگین میکند (یکبار)
+4. سرویس systemd را راهاندازی میکند
+
+```bash
+# بروزرسانی
+sudo bash install.sh
+
+# لاگین مجدد تلگرام
+sudo bash install.sh --login
+
+# حذف
+sudo bash install.sh --uninstall
+```
+
+## 🖥️ نصب کلاینت
+
+### لینوکس / macOS / ویندوز
+از صفحه [Releases](https://github.com/sartoopjj/thefeed/releases) باینری مناسب سیستم خود را دانلود کنید.
+
+```bash
+# اجرا (مرورگر خودکار باز میشود)
+./thefeed-client
+
+# با پورت و دایرکتوری سفارشی
+./thefeed-client --port 9090 --data-dir ./mydata
+
+# با رمز عبور ادمین
+./thefeed-client --password "your-password"
+```
+
+### اندروید (Termux)
+
+```bash
+# نصب Termux از F-Droid
+pkg update && pkg install curl
+
+# دانلود باینری اندروید
+curl -Lo thefeed-client https://github.com/sartoopjj/thefeed/releases/latest/download/thefeed-client-android-arm64
+chmod +x thefeed-client
+
+# اجرا
+./thefeed-client
+# مرورگر را باز کنید: http://127.0.0.1:8080
+```
+
+## ⚙️ تنظیمات DNS
+
+شما به **دو رکورد DNS** نیاز دارید. فرض کنید IP سرور شما `203.0.113.10` است:
+
+| نوع | نام | مقدار |
+|-----|-----|-------|
+| A | `ns.example.com` | `203.0.113.10` |
+| NS | `t.example.com` | `ns.example.com` |
+
+> **توجه:** سرور باید روی پورت ۵۳ پاسخ بدهد. بهتر است روی پورت غیرمحدود (`:5300`) اجرا و با iptables فوروارد کنید:
+>
+> نام اینترفیس شبکه خود را با `ip a` پیدا کنید و `eth0` را جایگزین کنید:
+> ```bash
+> sudo iptables -I INPUT -p udp --dport 5300 -j ACCEPT
+> sudo iptables -t nat -I PREROUTING -i eth0 -p udp --dport 53 -j REDIRECT --to-ports 5300
+> sudo ip6tables -I INPUT -p udp --dport 5300 -j ACCEPT
+> sudo ip6tables -t nat -I PREROUTING -i eth0 -p udp --dport 53 -j REDIRECT --to-ports 5300
+> ```
+>
+> برای ماندگار کردن این قوانین بعد از ریبوت:
+> ```bash
+> sudo apt install iptables-persistent # Debian/Ubuntu
+> sudo netfilter-persistent save
+> ```
+
+## 🛠️ ساخت از سورس
+
+```bash
+# پیشنیازها: Go 1.26+
+make build # ساخت سرور و کلاینت
+make build-all # کراسکامپایل تمام پلتفرمها
+make test # اجرای تستها
+make upx # فشردهسازی باینریها با UPX
+```
+
+## 📋 پرچمهای سرور
+
+| پرچم | پیشفرض | توضیح |
+|-------|---------|-------|
+| `--data-dir` | `./data` | دایرکتوری دادهها |
+| `--domain` | | دامنه DNS (الزامی) |
+| `--key` | | رمز عبور رمزنگاری (الزامی) |
+| `--channels` | `{data-dir}/channels.txt` | فایل کانالها |
+| `--api-id` | | شناسه API تلگرام |
+| `--api-hash` | | هش API تلگرام |
+| `--phone` | | شماره تلفن تلگرام |
+| `--listen` | `:53` | آدرس شنود DNS |
+| `--login-only` | `false` | فقط لاگین به تلگرام |
+| `--no-telegram` | `false` | اجرا بدون ورود به تلگرام (فقط کانالهای عمومی) |
+| `--padding` | `32` | حداکثر padding تصادفی |
+| `--msg-limit` | `15` | حداکثر تعداد پیامها برای هر کانال تلگرام |
+| `--allow-manage` | `false` | فعالسازی مدیریت از راه دور (ارسال پیام و مدیریت کانالها) |
+
+## 📋 پرچمهای کلاینت
+
+| پرچم | پیشفرض | توضیح |
+|-------|---------|-------|
+| `--data-dir` | `./thefeeddata` | دایرکتوری دادهها |
+| `--port` | `8080` | پورت رابط وب |
+| `--password` | | رمز عبور ادمین (خالی = بدون احراز هویت) |
+
+## 📂 فرمت channels.txt
+
+```
+# خطوط با # کامنت هستند
+@VahidOnline
+@SomeChannel
+```
+
+## 🤝 مشارکت
+
+مشارکت شما خوشآمد است! Issue بزنید یا Pull Request بفرستید.
+
+## 📄 لایسنس
+
+MIT
+
+---
+
+
+
+**برای ایران آزاد 🇮🇷**
+
+*هر ایرانی حق دسترسی آزاد به اطلاعات را دارد*
+
+
+
+
diff --git a/README.md b/README.md
index 486cd10..33bc594 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
DNS-based feed reader for Telegram channels. Designed for environments where only DNS queries work.
+[English](README.md) | [فارسی](README-FA.md)
+
## How It Works
```
@@ -16,37 +18,34 @@ DNS-based feed reader for Telegram channels. Designed for environments where onl
- Serves feed data as encrypted DNS TXT responses
- Random padding on responses to vary size (anti-DPI)
- Session persistence — login once, run forever
+- No-Telegram mode (`--no-telegram`) — reads public channels without needing Telegram credentials
- All data stored in a single directory
**Client** (runs inside censored network):
- Browser-based web UI with RTL/Farsi support (VazirMatn font)
- Configure via the web UI — no CLI flags needed
- Sends encrypted DNS TXT queries via available resolvers
-- Single-label base32 encoding (stealthier) or double-label hex
-- Rate limiting to respect resolver limits
+- Send messages to channels and private chats (requires server `--allow-manage`)
+- Channel management (add/remove channels remotely via admin commands)
+- Message compression (deflate) for efficient transfer
+- Web UI password protection (`--password` on client)
+- New message indicators and next-fetch countdown timer
+- Channel type badges (Private/Public)
+- Media type detection (`[IMAGE]`, `[VIDEO]`, etc.)
- Live DNS query log in the browser
- All data (config, cache) stored next to the binary
## Anti-DPI Features
-- **Variable response size**: Random padding (0-32 bytes) on each DNS response prevents fingerprinting by fixed packet size
-- **Single-label queries**: Base32 encoded subdomain in one DNS label (`abc123def.t.example.com`) instead of the more detectable two-label hex pattern
-- **Resolver shuffling**: Queries are distributed across resolvers randomly
-- **Rate limiting**: Configurable query rate to blend with normal DNS traffic
-- **Concurrency limiting**: Max 3 concurrent block fetches to avoid DNS bursts
-- **Random query padding**: 4 random bytes in each query payload
+- Variable response and query sizes to prevent fingerprinting
+- Multiple query encoding modes for stealth
+- Resolver shuffling and rate limiting
+- Background noise traffic
+- Message compression to minimize query count
## Protocol
-**Block size**: 180 bytes payload (fits in 512-byte UDP DNS with padding + encryption overhead)
-
-**Query format** (single-label, default): `[base32_encrypted].t.example.com`
-**Query format** (double-label): `[hex_part1].[hex_part2].t.example.com`
-- Payload: 4 random bytes + 2 channel + 2 block = 8 bytes, AES-256-GCM encrypted
-
-**Response**: `[2-byte length][data][random padding]` → AES-256-GCM encrypted → Base64
-
-**Encryption**: AES-256-GCM with HKDF-derived keys from shared passphrase
+All communication is encrypted with AES-256 and transmitted via standard DNS TXT queries and responses. Traffic is designed to blend with normal DNS activity. Message data is compressed before encryption.
## Quick Install (Server)
@@ -66,21 +65,25 @@ sudo bash install.sh
The script will:
1. Download the latest release binary from GitHub
-2. Ask for your domain, passphrase, Telegram credentials, channels
-3. Login to Telegram interactively (one-time)
-4. Set up a systemd service
+2. Ask for your domain, passphrase, and channels
+3. Ask whether to use Telegram login (recommended: **No** — public channels work without it)
+4. If Telegram mode: ask for API credentials and login
+5. Set up a systemd service
-Update: `sudo bash install.sh` (detects existing config, only updates binary)
-Re-login: `sudo bash install.sh --login`
-Uninstall: `sudo bash install.sh --uninstall`
+Update:
+```bash
+sudo bash <(curl -Ls https://raw.githubusercontent.com/sartoopjj/thefeed/main/scripts/install.sh)
+```
+Re-login: `sudo bash <(curl -Ls ...) --login`
+Uninstall: `sudo bash <(curl -Ls ...) --uninstall`
## Manual Setup
### Prerequisites
- Go 1.26+
-- Telegram API credentials from https://my.telegram.org
- A domain with NS records pointing to your server
+- Telegram API credentials from https://my.telegram.org (only if you need private channels)
### Server
@@ -106,7 +109,7 @@ make build-server
--api-id 12345 \
--api-hash "your-api-hash" \
--phone "+1234567890" \
- --listen ":5300"
+ --listen ":53"
```
All data files (session, channels) are stored in the `--data-dir` directory (default: `./data`).
@@ -126,8 +129,11 @@ Environment variables: `THEFEED_DOMAIN`, `THEFEED_KEY`, `TELEGRAM_API_ID`, `TELE
| `--phone` | | Telegram phone number (required) |
| `--session` | `{data-dir}/session.json` | Path to Telegram session file |
| `--login-only` | `false` | Authenticate to Telegram, save session, exit |
+| `--no-telegram` | `false` | Run without Telegram login (public channels only) |
| `--listen` | `:5300` | DNS listen address |
| `--padding` | `32` | Max random padding bytes (0=disabled) |
+| `--msg-limit` | `15` | Maximum messages to fetch per Telegram channel |
+| `--allow-manage` | `false` | Allow remote send/channel management (default: disabled) |
| `--version` | | Show version and exit |
### Client
@@ -141,6 +147,9 @@ make build-client
# Custom data directory and port
./build/thefeed-client --data-dir ./mydata --port 9090
+
+# With remote management enabled
+./build/thefeed-client --password "your-secret"
```
On first run, the client creates a `./thefeeddata/` directory next to where you run it. Open `http://127.0.0.1:8080` in your browser and configure your domain, passphrase, and resolvers through the Settings page.
@@ -153,22 +162,41 @@ All configuration, cache, and data files are stored in the data directory.
|------|---------|-------------|
| `--data-dir` | `./thefeeddata` | Data directory for config, cache |
| `--port` | `8080` | Web UI port |
+| `--password` | | Password for web UI (empty = no auth) |
| `--version` | | Show version and exit |
+#### Android (Termux)
+
+```bash
+# Install Termux from F-Droid
+pkg update && pkg install curl
+
+# Download Android binary
+curl -Lo thefeed-client https://github.com/sartoopjj/thefeed/releases/latest/download/thefeed-client-android-arm64
+chmod +x thefeed-client
+./thefeed-client
+# Open in browser: http://127.0.0.1:8080
+```
+
### Web UI
The browser-based UI has:
-- **Channels sidebar** (left): channel list with selection
+- **Channels sidebar** (left): channel list grouped by type (Public/Private) with badges
- **Messages panel** (right): messages with native RTL/Farsi rendering (VazirMatn font)
+- **Send panel**: send messages to channels and private chats when Telegram is connected
+- **New message badges**: visual indicators for channels with new messages
+- **Next-fetch timer**: countdown to next automatic refresh
+- **Media detection**: `[IMAGE]`, `[VIDEO]`, `[DOCUMENT]` tag highlighting
- **Log panel** (bottom): live DNS query log
-- **Settings modal**: configure domain, passphrase, resolvers, query mode, rate limit
+- **Settings modal**: configure domain, passphrase, resolvers, query mode, rate limit, timeout, debug mode
## Development
```bash
-make test # Run tests
+make test # Run tests with race detector
make build # Build both binaries
-make build-all # Cross-compile all platforms
+make build-all # Cross-compile all platforms (incl. Android)
+make upx # Compress Linux/Windows/Android binaries with UPX
make vet # Go vet
make fmt # Format code
make clean # Remove build artifacts
@@ -219,14 +247,28 @@ This delegates all DNS queries for `t.example.com` (and its subdomains) to your
## Security
-- All queries and responses are encrypted with AES-256-GCM
-- Separate HKDF-derived keys for queries and responses
-- Random padding in queries prevents caching and replay
-- Random padding in responses prevents DPI size fingerprinting
-- No session state — each query is independent
+### Two-Part Access Control
+
+**Encryption passphrase (`--key`):** Required on both server and client. Anyone with this passphrase can read all channel messages (including private channels). You can share it with trusted friends so they can read too.
+
+**Remote management (`--allow-manage` on server):** When enabled, anyone with the encryption key can also send messages and manage channels. Disabled by default. Only enable on trusted servers.
+
+**Client web password (`--password`):** Protects all web UI endpoints with HTTP Basic Auth. This is local protection only — it does NOT affect DNS-level access.
+
+### Security Properties
+
+- All communication is end-to-end encrypted (AES-256)
- Pre-shared passphrase required for both client and server
-- Telegram 2FA password is prompted interactively (not stored in CLI args)
-- Session file stored with 0600 permissions
+- Each query is independent — no session state on the wire
+- Random padding in both directions prevents traffic analysis
+- Write operations gated by server-side `--allow-manage` flag
+- Telegram 2FA password is prompted interactively (never stored in args)
+- Session file stored with restricted permissions (0600)
+
+> **⚠️ Warning:** If you share your passphrase publicly, **anyone** can run their own
+> client with your passphrase and read all your messages. There is no way to prevent this.
+> The client `--password` flag only protects the web UI on your own machine — it does NOT stop
+> others from using the passphrase. **Never share your passphrase publicly.**
## Service Management
@@ -247,3 +289,13 @@ sudo bash scripts/install.sh
## License
MIT
+
+---
+
+
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
new file mode 100644
index 0000000..c9ba275
--- /dev/null
+++ b/RELEASE_NOTES.md
@@ -0,0 +1,158 @@
+# Release Notes
+
+## What's New
+
+### Access Control
+- **Encryption passphrase** (`--key`): for reading — share with trusted friends
+- **Remote management** (`--allow-manage` on server): enables send/channel management — disabled by default
+- If `--allow-manage` not set on server, send and admin features are completely disabled
+- Client `--password` now protects ALL web endpoints with global HTTP Basic Auth
+
+### Channel Management
+- Add/remove Telegram channels remotely via admin commands through DNS
+- Channel management UI in the web frontend (requires `--allow-manage`)
+- List/refresh channel configuration from the browser
+
+### Send Messages
+- Send messages to Telegram channels and private chats through the DNS tunnel
+- Full-stack implementation: client web UI → DNS query → server → Telegram API
+- GCM-encrypted message data split into DNS labels
+- Telegram RandomID fix — sending to own channels now works correctly
+
+### Message Compression
+- Deflate compression reduces the number of DNS queries needed
+- Backward compatible — clients auto-detect compressed vs raw data
+- 1-byte compression header (0x00=raw, 0x01=deflate)
+
+### Web UI Password
+- Protect the web UI with `--password` flag
+- HTTP Basic Auth on all endpoints (constant-time comparison)
+- Empty password = no authentication (default)
+
+### Web UI Overhaul
+- Channel type badges (Private / Public)
+- New message indicator badges
+- Next-fetch countdown timer
+- Send message panel (when Telegram is connected)
+- Media type tag highlighting (`[IMAGE]`, `[VIDEO]`, `[DOCUMENT]`)
+- Channels grouped by type in sidebar
+- Telegram connection warning banner
+- Debug mode enabled by default
+- Footer with GitHub link
+
+### Android Support
+- `android/arm64` build target for Termux
+- UPX compression for smaller binaries
+
+### Edit Detection
+- Detects message edits even when message count stays the same
+- CRC32 content hash per channel transmitted in metadata
+- Client skips refresh only when both message ID and content hash match
+
+### No-Telegram Mode
+- Server `--no-telegram` flag for users who can't or don't want to sign in to Telegram
+- Reads public channels without needing Telegram API credentials or phone number
+- Safer: no credentials stored on the server
+- Install script supports no-Telegram setup (recommended by default)
+
+### Install Script Improvements
+- Telegram mode selection during install (no-Telegram recommended by default)
+- Update flow: option to switch between Telegram and no-Telegram modes
+- Easy one-liner curl commands for update and uninstall
+- Passphrase sharing warning: anyone with your passphrase can read your messages
+
+### Protocol Improvements
+- Variable block sizes (400-700 bytes) for anti-DPI
+- DNS noise queries at random intervals (10-30s)
+- Metadata expansion: NextFetch, TelegramLoggedIn, ChatType, CanSend
+- Block retry on transient DNS failures
+
+### Security Hardening
+- HTTP server timeouts (read: 30s, write: 60s, idle: 120s)
+- DNS query name length validation for send messages
+- Generic error responses (no internal error leakage)
+- Constant-time password comparison
+- ⚠️ Never share your passphrase publicly — anyone with it can run their own client and read all your messages. `--password` only protects the web UI on your machine
+
+### Other Improvements
+- Auto-open browser on client start
+- Server next-fetch timer in protocol metadata
+- Skip refresh when no new messages
+- Prevent duplicate channel fetches
+- Handle invalid passphrase errors gracefully
+- Default rate limit: 5 QPS
+- Configurable DNS timeout
+- Persian README (README-FA.md)
+
+---
+
+
+
+# یادداشتهای انتشار
+
+## تغییرات جدید
+
+### کنترل دسترسی
+- **رمز عبور رمزنگاری** (`--key`): برای خواندن — با دوستان مورد اعتماد به اشتراک بگذارید
+- **مدیریت از راه دور** (`--allow-manage` سمت سرور): برای ارسال پیام و مدیریت کانالها — به صورت پیشفرض غیرفعال
+- اگر `--allow-manage` سمت سرور تنظیم نشده باشد، قابلیتهای ارسال و مدیریت کاملاً غیرفعال هستند
+- `--password` سمت کلاینت حالا تمام صفحات وب را با HTTP Basic Auth محافظت میکند
+
+### مدیریت کانالها
+- افزودن/حذف کانالهای تلگرام از راه دور از طریق DNS
+- رابط مدیریت کانالها در وب (نیاز به `--allow-manage`)
+
+### ارسال پیام
+- ارسال پیام به کانالها و چتهای خصوصی تلگرام از طریق تونل DNS
+- پیادهسازی کامل: رابط وب → درخواست DNS → سرور → API تلگرام
+- رفع باگ RandomID — ارسال به کانالهای خودتان حالا درست کار میکند
+
+### فشردهسازی پیام
+- فشردهسازی deflate تعداد درخواستهای DNS مورد نیاز را کاهش میدهد
+- سازگاری عقبگرد — کلاینتها داده فشرده و خام را خودکار تشخیص میدهند
+
+### رمز عبور وب
+- محافظت از رابط وب با `--password` (تمام صفحات)
+- احراز هویت HTTP Basic Auth
+
+### بازطراحی رابط وب
+- نشانهای نوع کانال (خصوصی / عمومی)
+- نشانگر پیام جدید
+- تایمر شمارش معکوس دریافت بعدی
+- پنل ارسال پیام
+- تشخیص نوع رسانه
+- دستهبندی کانالها بر اساس نوع
+
+### پشتیبانی اندروید
+- باینری `android/arm64` برای Termux
+- فشردهسازی UPX
+
+### حالت بدون تلگرام
+- پرچم `--no-telegram` برای کاربرانی که نمیتوانند یا نمیخواهند وارد تلگرام شوند
+- خواندن کانالهای عمومی بدون نیاز به ورود به تلگرام
+- امنتر: هیچ اطلاعات حساسی روی سرور ذخیره نمیشود
+
+### تشخیص ویرایش پیام
+- تشخیص ویرایش پیام حتی وقتی تعداد پیامها تغییر نکرده
+- هش محتوا برای هر کانال در متادیتا ارسال میشود
+
+### بهبود اسکریپت نصب
+- انتخاب حالت تلگرام هنگام نصب (بدون تلگرام پیشنهاد پیشفرض)
+- امکان تغییر حالت تلگرام هنگام آپدیت
+- دستورات curl ساده برای آپدیت و حذف
+
+### بهبود امنیت
+- تایماوت سرور HTTP
+- اعتبارسنجی طول نام DNS
+- پاسخهای خطای عمومی
+- ⚠️ هرگز رمز عبور (passphrase) خود را عمومی به اشتراک نگذارید — هر کسی با آن میتواند کلاینت خودش را اجرا و تمام پیامهای شما را بخواند. `--password` فقط رابط وب روی دستگاه خودتان را محافظت میکند
+
+### بهبودهای دیگر
+- باز شدن خودکار مرورگر
+- رد کردن رفرش وقتی پیام جدیدی نیست
+- جلوگیری از دریالت تکراری کانالها
+- مدیریت خطای رمز عبور نامعتبر
+- محدودیت نرخ پیشفرض: ۵ کوئری در ثانیه
+- README فارسی
+
+
diff --git a/cmd/client/main.go b/cmd/client/main.go
index f63a8c4..906e0f6 100644
--- a/cmd/client/main.go
+++ b/cmd/client/main.go
@@ -5,6 +5,8 @@ import (
"fmt"
"log"
"os"
+ "os/exec"
+ "runtime"
"github.com/sartoopjj/thefeed/internal/version"
"github.com/sartoopjj/thefeed/internal/web"
@@ -13,6 +15,7 @@ import (
func main() {
dataDir := flag.String("data-dir", "./thefeeddata", "Data directory for config, cache, and sessions")
port := flag.Int("port", 8080, "Web UI port")
+ password := flag.String("password", "", "Admin password for web UI (empty = no auth)")
showVersion := flag.Bool("version", false, "Show version and exit")
flag.Parse()
@@ -21,12 +24,31 @@ func main() {
os.Exit(0)
}
- srv, err := web.New(*dataDir, *port)
+ srv, err := web.New(*dataDir, *port, *password)
if err != nil {
log.Fatalf("Failed to start: %v", err)
}
+ // Try to open browser automatically
+ url := fmt.Sprintf("http://127.0.0.1:%d", *port)
+ go openBrowser(url)
+
if err := srv.Run(); err != nil {
log.Fatalf("Server error: %v", err)
}
}
+
+func openBrowser(url string) {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.Command("open", url)
+ case "linux":
+ cmd = exec.Command("xdg-open", url)
+ case "windows":
+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
+ default:
+ return
+ }
+ _ = cmd.Start()
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
index ced94f2..8439352 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -25,13 +25,15 @@ func main() {
domain := flag.String("domain", "", "DNS domain (e.g., t.example.com)")
key := flag.String("key", "", "Encryption passphrase")
channelsFile := flag.String("channels", "", "Path to channels file (default: {data-dir}/channels.txt)")
- apiID := flag.String("api-id", "", "Telegram API ID")
- apiHash := flag.String("api-hash", "", "Telegram API Hash")
- phone := flag.String("phone", "", "Telegram phone number")
+ apiID := flag.String("api-id", "", "Telegram API ID (optional if --no-telegram)")
+ apiHash := flag.String("api-hash", "", "Telegram API Hash (optional if --no-telegram)")
+ phone := flag.String("phone", "", "Telegram phone number (optional if --no-telegram)")
loginOnly := flag.Bool("login-only", false, "Authenticate to Telegram, save session, and exit")
+ noTelegram := flag.Bool("no-telegram", false, "Fetch public channels without Telegram login")
sessionPath := flag.String("session", "", "Path to Telegram session file (default: {data-dir}/session.json)")
maxPadding := flag.Int("padding", 32, "Max random padding bytes in DNS responses (anti-DPI, 0=disabled)")
msgLimit := flag.Int("msg-limit", 15, "Maximum messages to fetch per Telegram channel")
+ allowManage := flag.Bool("allow-manage", false, "Allow remote channel management and sending via DNS")
showVersion := flag.Bool("version", false, "Show version and exit")
flag.Parse()
@@ -59,6 +61,9 @@ func main() {
if *key == "" {
*key = os.Getenv("THEFEED_KEY")
}
+ if !*allowManage && os.Getenv("THEFEED_ALLOW_MANAGE") == "1" {
+ *allowManage = true
+ }
if *apiID == "" {
*apiID = os.Getenv("TELEGRAM_API_ID")
}
@@ -74,20 +79,29 @@ func main() {
flag.Usage()
os.Exit(1)
}
- if *apiID == "" || *apiHash == "" || *phone == "" {
- fmt.Fprintln(os.Stderr, "Error: --api-id, --api-hash, and --phone are required")
- flag.Usage()
- os.Exit(1)
+
+ // Telegram credentials are required unless --no-telegram
+ needTelegram := !*noTelegram
+ if needTelegram {
+ if *apiID == "" || *apiHash == "" || *phone == "" {
+ fmt.Fprintln(os.Stderr, "Error: --api-id, --api-hash, and --phone are required (use --no-telegram to skip)")
+ flag.Usage()
+ os.Exit(1)
+ }
}
- id, err := strconv.Atoi(*apiID)
- if err != nil {
- log.Fatalf("Invalid API ID: %v", err)
+ var id int
+ if *apiID != "" {
+ var err error
+ id, err = strconv.Atoi(*apiID)
+ if err != nil {
+ log.Fatalf("Invalid API ID: %v", err)
+ }
}
- // Interactive 2FA password prompt — only when --login-only or no existing session
+ // Interactive 2FA password prompt — only when Telegram is enabled
password := os.Getenv("TELEGRAM_PASSWORD")
- if password == "" {
+ if password == "" && needTelegram {
hasSession := false
if info, statErr := os.Stat(*sessionPath); statErr == nil && info.Size() > 0 {
hasSession = true
@@ -109,6 +123,8 @@ func main() {
ChannelsFile: *channelsFile,
MaxPadding: *maxPadding,
MsgLimit: *msgLimit,
+ NoTelegram: *noTelegram,
+ AllowManage: *allowManage,
Telegram: server.TelegramConfig{
APIID: id,
APIHash: *apiHash,
diff --git a/internal/client/fetcher.go b/internal/client/fetcher.go
index f5080d9..b9554d8 100644
--- a/internal/client/fetcher.go
+++ b/internal/client/fetcher.go
@@ -2,6 +2,8 @@ package client
import (
"context"
+ cryptoRand "crypto/rand"
+ "encoding/binary"
"fmt"
"math/rand"
"strings"
@@ -93,14 +95,11 @@ func (f *Fetcher) SetQueryMode(mode protocol.QueryEncoding) {
}
// SetActiveResolvers updates the healthy resolver pool. Called by ResolverChecker.
-// If the new list is empty, the current pool is unchanged (to avoid blackout during a bad check).
func (f *Fetcher) SetActiveResolvers(resolvers []string) {
f.mu.Lock()
defer f.mu.Unlock()
- if len(resolvers) > 0 {
- f.activeResolvers = make([]string, len(resolvers))
- copy(f.activeResolvers, resolvers)
- }
+ f.activeResolvers = make([]string, len(resolvers))
+ copy(f.activeResolvers, resolvers)
}
// SetResolvers replaces the full resolver list and resets the active pool.
@@ -189,7 +188,7 @@ func (f *Fetcher) runNoise(ctx context.Context) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(d), dns.TypeA)
m.RecursionDesired = true
- c.Exchange(m, r) //nolint:errcheck — fire-and-forget noise query
+ _, _, _ = c.Exchange(m, r)
}(resolver, target)
}
}
@@ -339,6 +338,10 @@ func (f *Fetcher) FetchMetadata(ctx context.Context) (*protocol.Metadata, error)
// Cancelling ctx immediately aborts any queued or in-flight block fetches.
// Each block is retried individually via FetchBlock before the channel fetch fails.
func (f *Fetcher) FetchChannel(ctx context.Context, channelNum int, blockCount int) ([]protocol.Message, error) {
+ return f.fetchChannelBlocks(ctx, channelNum, blockCount, f.FetchBlock)
+}
+
+func (f *Fetcher) fetchChannelBlocks(ctx context.Context, channelNum int, blockCount int, fetchFn func(context.Context, uint16, uint16) ([]byte, error)) ([]protocol.Message, error) {
if blockCount <= 0 {
return nil, nil
}
@@ -368,7 +371,7 @@ func (f *Fetcher) FetchChannel(ctx context.Context, channelNum int, blockCount i
}
defer func() { <-sem }()
- data, err := f.FetchBlock(ctx, uint16(channelNum), uint16(idx))
+ data, err := fetchFn(ctx, uint16(channelNum), uint16(idx))
results <- blockResult{idx: idx, data: data, err: err}
}(i)
}
@@ -400,7 +403,14 @@ func (f *Fetcher) FetchChannel(ctx context.Context, channelNum int, blockCount i
allData = append(allData, b...)
}
- return protocol.ParseMessages(allData)
+ // Decompress if data has compression header
+ decompressed, err := protocol.DecompressMessages(allData)
+ if err != nil {
+ // Fall back to raw parse for backward compatibility with uncompressed data
+ return protocol.ParseMessages(allData)
+ }
+
+ return protocol.ParseMessages(decompressed)
}
func (f *Fetcher) queryResolver(ctx context.Context, resolver, qname string) ([]byte, error) {
@@ -408,17 +418,9 @@ func (f *Fetcher) queryResolver(ctx context.Context, resolver, qname string) ([]
resolver += ":53"
}
- c := new(dns.Client)
- c.Timeout = f.timeout
-
- m := new(dns.Msg)
- m.SetQuestion(dns.Fqdn(qname), dns.TypeTXT)
- m.RecursionDesired = true
- m.SetEdns0(4096, false) // advertise 4 KiB UDP buffer to avoid response truncation
-
- resp, _, err := c.ExchangeContext(ctx, m, resolver)
+ resp, err := f.exchangeResolver(ctx, resolver, qname)
if err != nil {
- return nil, fmt.Errorf("dns exchange with %s: %w", resolver, err)
+ return nil, err
}
if resp.Rcode != dns.RcodeSuccess {
@@ -434,3 +436,159 @@ func (f *Fetcher) queryResolver(ctx context.Context, resolver, qname string) ([]
return nil, fmt.Errorf("no TXT record in response from %s", resolver)
}
+
+func (f *Fetcher) exchangeResolver(ctx context.Context, resolver, qname string) (*dns.Msg, error) {
+ resolverCtx, cancel := context.WithTimeout(ctx, f.timeout)
+ defer cancel()
+
+ c := &dns.Client{Timeout: f.timeout, Net: "udp"}
+
+ m := new(dns.Msg)
+ m.SetQuestion(dns.Fqdn(qname), dns.TypeTXT)
+ m.RecursionDesired = true
+ m.SetEdns0(1232, false)
+
+ resp, _, err := c.ExchangeContext(resolverCtx, m, resolver)
+ if err != nil {
+ return nil, fmt.Errorf("dns exchange with %s: %w", resolver, err)
+ }
+ return resp, nil
+}
+
+func (f *Fetcher) queryUpload(ctx context.Context, qname string) ([]byte, error) {
+ if err := f.rateWait(ctx); err != nil {
+ return nil, err
+ }
+
+ resolvers := f.Resolvers()
+ if len(resolvers) == 0 {
+ return nil, fmt.Errorf("no active resolvers")
+ }
+
+ shuffled := make([]string, len(resolvers))
+ copy(shuffled, resolvers)
+ rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
+
+ var lastErr error
+ for _, resolver := range shuffled {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ data, err := f.queryResolver(ctx, resolver, qname)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ return data, nil
+ }
+ return nil, lastErr
+}
+
+func splitUploadPayload(data []byte) [][]byte {
+ chunks := make([][]byte, 0, (len(data)+protocol.MaxUpstreamBlockPayload-1)/protocol.MaxUpstreamBlockPayload)
+ for len(data) > 0 {
+ n := protocol.MaxUpstreamBlockPayload
+ if n > len(data) {
+ n = len(data)
+ }
+ chunk := make([]byte, n)
+ copy(chunk, data[:n])
+ chunks = append(chunks, chunk)
+ data = data[n:]
+ }
+ return chunks
+}
+
+func randomSessionID() (uint16, error) {
+ var buf [2]byte
+ for {
+ if _, err := cryptoRand.Read(buf[:]); err != nil {
+ return 0, err
+ }
+ sessionID := binary.BigEndian.Uint16(buf[:])
+ if sessionID != 0 {
+ return sessionID, nil
+ }
+ }
+}
+
+func (f *Fetcher) sendUpstream(ctx context.Context, kind protocol.UpstreamKind, targetChannel uint16, payload []byte) ([]byte, error) {
+ chunks := splitUploadPayload(payload)
+ if len(chunks) == 0 {
+ return nil, fmt.Errorf("empty payload")
+ }
+ if len(chunks) > protocol.MaxUpstreamBlocks {
+ return nil, fmt.Errorf("payload requires too many DNS blocks: %d > %d", len(chunks), protocol.MaxUpstreamBlocks)
+ }
+
+ sessionID, err := randomSessionID()
+ if err != nil {
+ return nil, fmt.Errorf("generate session id: %w", err)
+ }
+
+ initQname, err := protocol.EncodeUpstreamInitQuery(f.queryKey, protocol.UpstreamInit{
+ SessionID: sessionID,
+ TotalBlocks: uint8(len(chunks)),
+ Kind: kind,
+ TargetChannel: uint8(targetChannel),
+ }, f.domain, f.queryMode)
+ if err != nil {
+ return nil, fmt.Errorf("encode upstream init: %w", err)
+ }
+ if f.debug {
+ f.log("[debug] upstream init kind=%d blocks=%d qname=%s", kind, len(chunks), initQname)
+ }
+
+ data, err := f.queryUpload(ctx, initQname)
+ if err != nil {
+ return nil, fmt.Errorf("start upstream session: %w", err)
+ }
+ if string(data) != "READY" {
+ return nil, fmt.Errorf("unexpected upstream init response: %s", string(data))
+ }
+
+ for idx, chunk := range chunks {
+ blockQname, err := protocol.EncodeUpstreamBlockQuery(f.queryKey, sessionID, uint8(idx), chunk, f.domain, f.queryMode)
+ if err != nil {
+ return nil, fmt.Errorf("encode upstream block %d: %w", idx, err)
+ }
+ if f.debug {
+ f.log("[debug] upstream block kind=%d idx=%d len=%d qname=%s", kind, idx, len(chunk), blockQname)
+ }
+
+ data, err = f.queryUpload(ctx, blockQname)
+ if err != nil {
+ return nil, fmt.Errorf("upload block %d: %w", idx, err)
+ }
+
+ if idx+1 < len(chunks) && string(data) != "CONTINUE" {
+ return nil, fmt.Errorf("unexpected upstream block response: %s", string(data))
+ }
+ }
+
+ return data, nil
+}
+
+// SendMessage sends a text message to the given channel via chunked upstream DNS queries.
+// Returns an error if the message is too long or sending fails.
+func (f *Fetcher) SendMessage(ctx context.Context, channelNum int, text string) error {
+ data, err := f.sendUpstream(ctx, protocol.UpstreamKindSend, uint16(channelNum), []byte(text))
+ if err != nil {
+ return fmt.Errorf("send failed: %w", err)
+ }
+ if string(data) != "OK" {
+ return fmt.Errorf("unexpected response: %s", string(data))
+ }
+ return nil
+}
+
+// SendAdminCommand sends an admin command to the server via chunked upstream DNS queries.
+// The payload is a single AdminCmd byte followed by the argument string.
+func (f *Fetcher) SendAdminCommand(ctx context.Context, cmd protocol.AdminCmd, arg string) (string, error) {
+ payload := append([]byte{byte(cmd)}, []byte(arg)...)
+ data, err := f.sendUpstream(ctx, protocol.UpstreamKindAdmin, 0, payload)
+ if err != nil {
+ return "", fmt.Errorf("admin command failed: %w", err)
+ }
+ return string(data), nil
+}
diff --git a/internal/client/fetcher_test.go b/internal/client/fetcher_test.go
new file mode 100644
index 0000000..d8864ad
--- /dev/null
+++ b/internal/client/fetcher_test.go
@@ -0,0 +1,26 @@
+package client
+
+import "testing"
+
+func TestSetActiveResolversAllowsEmpty(t *testing.T) {
+ fetcher, err := NewFetcher("t.example.com", "test-passphrase", []string{"1.1.1.1:53", "8.8.8.8:53"})
+ if err != nil {
+ t.Fatalf("NewFetcher: %v", err)
+ }
+ fetcher.SetActiveResolvers(nil)
+ if got := fetcher.Resolvers(); len(got) != 0 {
+ t.Fatalf("len(Resolvers()) = %d, want 0", len(got))
+ }
+}
+
+func TestSetActiveResolversReplacesPool(t *testing.T) {
+ fetcher, err := NewFetcher("t.example.com", "test-passphrase", []string{"1.1.1.1:53", "8.8.8.8:53"})
+ if err != nil {
+ t.Fatalf("NewFetcher: %v", err)
+ }
+ fetcher.SetActiveResolvers([]string{"9.9.9.9:53"})
+ got := fetcher.Resolvers()
+ if len(got) != 1 || got[0] != "9.9.9.9:53" {
+ t.Fatalf("Resolvers() = %v, want [9.9.9.9:53]", got)
+ }
+}
diff --git a/internal/client/resolver.go b/internal/client/resolver.go
index 5b42234..4f204a8 100644
--- a/internal/client/resolver.go
+++ b/internal/client/resolver.go
@@ -43,7 +43,7 @@ func (rc *ResolverChecker) SetLogFunc(fn LogFunc) {
// ctx controls the lifetime — cancel it to stop the checker.
func (rc *ResolverChecker) Start(ctx context.Context) {
go func() {
- rc.runCheck()
+ rc.CheckNow()
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for {
@@ -51,13 +51,14 @@ func (rc *ResolverChecker) Start(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
- rc.runCheck()
+ rc.CheckNow()
}
}
}()
}
-func (rc *ResolverChecker) runCheck() {
+// CheckNow runs a single resolver health-check pass immediately.
+func (rc *ResolverChecker) CheckNow() {
resolvers := rc.fetcher.AllResolvers()
if len(resolvers) == 0 {
return
@@ -90,6 +91,10 @@ func (rc *ResolverChecker) runCheck() {
wg.Wait()
rc.fetcher.SetActiveResolvers(healthy)
+ if len(healthy) == 0 {
+ rc.log("Resolver check done: 0/%d healthy", len(resolvers))
+ return
+ }
rc.log("Resolver check done: %d/%d healthy", len(healthy), len(resolvers))
}
@@ -115,6 +120,7 @@ func (rc *ResolverChecker) checkOne(resolver string) bool {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(qname), dns.TypeTXT)
m.RecursionDesired = true
+ m.SetEdns0(1232, false)
resp, _, err := c.Exchange(m, resolver)
// We consider the resolver healthy if we get any DNS response back
diff --git a/internal/protocol/dns.go b/internal/protocol/dns.go
index 193ae96..a5932e1 100644
--- a/internal/protocol/dns.go
+++ b/internal/protocol/dns.go
@@ -8,15 +8,60 @@ import (
"encoding/hex"
"fmt"
"math/big"
- "strconv"
"strings"
)
const (
maxDNSLabelLen = 63
maxDNSNameLen = 253 // without trailing dot
+
+ // SendChannel is the special channel number used for upstream message sending.
+ // When a query has channel == SendChannel, the block field encodes the target
+ // channel number, and additional data labels carry the encrypted message text.
+ SendChannel uint16 = 0xFFFE
+
+ // AdminChannel is the special channel number for admin commands (add/remove
+ // channels, hard refresh). The encrypted payload is "password\ncmd\narg".
+ AdminChannel uint16 = 0xFFFD
+
+ // UpstreamInitChannel starts a chunked upstream session for admin/send payloads.
+ UpstreamInitChannel uint16 = 0xFFFC
+ // UpstreamDataChannel carries one chunk of a chunked upstream session.
+ UpstreamDataChannel uint16 = 0xFFFB
+
+ // MaxUpstreamBlockPayload keeps uploaded query chunks comfortably below DNS
+ // name limits across typical domains and resolver paths.
+ MaxUpstreamBlockPayload = 8
+ // MaxUpstreamBlocks bounds the amount of server-side session state.
+ MaxUpstreamBlocks = 128
)
+// UpstreamKind identifies the payload carried by a chunked upstream session.
+type UpstreamKind byte
+
+const (
+ UpstreamKindSend UpstreamKind = 1
+ UpstreamKindAdmin UpstreamKind = 2
+)
+
+// AdminCmd identifies admin commands carried in upstream admin payloads.
+type AdminCmd byte
+
+const (
+ AdminCmdAddChannel AdminCmd = 1
+ AdminCmdRemoveChannel AdminCmd = 2
+ AdminCmdListChannels AdminCmd = 3
+ AdminCmdRefresh AdminCmd = 4
+)
+
+// UpstreamInit describes a chunked upstream session.
+type UpstreamInit struct {
+ SessionID uint16
+ TotalBlocks uint8
+ Kind UpstreamKind
+ TargetChannel uint8
+}
+
// QueryEncoding controls how DNS query subdomains are encoded.
type QueryEncoding int
@@ -25,9 +70,6 @@ const (
QuerySingleLabel QueryEncoding = iota
// QueryMultiLabel uses hex split across multiple DNS labels.
QueryMultiLabel
- // QueryPlainLabel encodes channel and block as plain decimal text (no query encryption).
- // Responses are always encrypted regardless of this setting.
- QueryPlainLabel
)
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
@@ -35,20 +77,13 @@ var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
// EncodeQuery creates a DNS query subdomain for the given channel and block.
// Single-label (default): [base32_encrypted].domain
// Multi-label: [hex_part1].[hex_part2].domain
-// Plain-label: cb.domain (no query encryption)
-// Responses are always encrypted regardless of mode.
+// All queries are encrypted to prevent DPI detection.
func EncodeQuery(queryKey [KeySize]byte, channel, block uint16, domain string, mode QueryEncoding) (string, error) {
domain = strings.TrimSuffix(domain, ".")
if domain == "" {
return "", fmt.Errorf("empty domain")
}
- // Plain text mode: no encryption, just human-readable label.
- if mode == QueryPlainLabel {
- label := fmt.Sprintf("c%db%d", channel, block)
- return joinQName([]string{label}, domain)
- }
-
payload := make([]byte, QueryPayloadSize)
if _, err := rand.Read(payload[:QueryPaddingSize]); err != nil {
@@ -132,11 +167,6 @@ func DecodeQuery(queryKey [KeySize]byte, qname, domain string) (channel, block u
encoded := qname[:len(qname)-len(suffix)]
- // Try plain-label first: cb (short, no dots, all decimal)
- if ch, blk, ok := parsePlainLabel(encoded); ok {
- return ch, blk, nil
- }
-
// Try base32 (single-label: no dots or dots stripped)
b32str := strings.ReplaceAll(encoded, ".", "")
if ct, e := b32.DecodeString(strings.ToUpper(b32str)); e == nil {
@@ -145,34 +175,23 @@ func DecodeQuery(queryKey [KeySize]byte, qname, domain string) (channel, block u
// Fall back to hex (multi-label: dots stripped)
hexStr := strings.ReplaceAll(encoded, ".", "")
- ct, e := hex.DecodeString(hexStr)
- if e != nil {
- return 0, 0, fmt.Errorf("decode query: invalid encoding")
+ if ct, e := hex.DecodeString(hexStr); e == nil {
+ return parseQueryCiphertext(queryKey, ct)
}
- return parseQueryCiphertext(queryKey, ct)
-}
-// parsePlainLabel parses the plain-text query format "cb".
-// Returns ok=false if the string does not match this pattern.
-func parsePlainLabel(s string) (channel, block uint16, ok bool) {
- if len(s) < 3 || s[0] != 'c' {
- return 0, 0, false
+ // For multi-label data queries (header_b32.data_hex...), the concatenated
+ // string is neither valid base32 nor valid hex. Try the first label alone
+ // — it contains the AES-ECB encrypted header with channel and block.
+ if parts := strings.SplitN(encoded, ".", 2); len(parts) == 2 {
+ if ct, e := b32.DecodeString(strings.ToUpper(parts[0])); e == nil {
+ return parseQueryCiphertext(queryKey, ct)
+ }
+ if ct, e := hex.DecodeString(parts[0]); e == nil {
+ return parseQueryCiphertext(queryKey, ct)
+ }
}
- bi := strings.IndexByte(s[1:], 'b')
- if bi < 0 {
- return 0, 0, false
- }
- bi++ // adjust for the slice offset
- chStr, bStr := s[1:bi], s[bi+1:]
- if len(chStr) == 0 || len(bStr) == 0 {
- return 0, 0, false
- }
- ch, err1 := strconv.ParseUint(chStr, 10, 16)
- blk, err2 := strconv.ParseUint(bStr, 10, 16)
- if err1 != nil || err2 != nil {
- return 0, 0, false
- }
- return uint16(ch), uint16(blk), true
+
+ return 0, 0, fmt.Errorf("decode query: invalid encoding")
}
func parseQueryCiphertext(queryKey [KeySize]byte, ciphertext []byte) (channel, block uint16, err error) {
@@ -228,3 +247,391 @@ func DecodeResponse(responseKey [KeySize]byte, encoded string) ([]byte, error) {
}
return padded[PadLengthSize : PadLengthSize+dataLen], nil
}
+
+// EncodeSendQuery creates a DNS query that carries an upstream message.
+// Format: [header_b32].[data_b32].domain
+// The header is a normal encrypted 8-byte query with channel=SendChannel and
+// block=targetChannel. The data label contains GCM-encrypted message text.
+// Returns an error if the message is too long for a single DNS query.
+func EncodeSendQuery(queryKey [KeySize]byte, targetChannel uint16, message []byte, domain string, mode QueryEncoding) (string, error) {
+ return encodeDataQuery(queryKey, SendChannel, targetChannel, message, domain, mode)
+}
+
+// EncodeAdminQuery creates a DNS query that carries an admin command to the server.
+// The payload is a single AdminCmd byte followed by optional argument bytes,
+// GCM-encrypted and split across DNS labels.
+func EncodeAdminQuery(queryKey [KeySize]byte, cmd AdminCmd, arg []byte, domain string, mode QueryEncoding) (string, error) {
+ payload := append([]byte{byte(cmd)}, arg...)
+ return encodeDataQuery(queryKey, AdminChannel, 0, payload, domain, mode)
+}
+
+// encodeDataQuery builds a DNS query carrying encrypted data in additional labels.
+func encodeDataQuery(queryKey [KeySize]byte, specialCh, block uint16, data []byte, domain string, mode QueryEncoding) (string, error) {
+ domain = strings.TrimSuffix(domain, ".")
+ if domain == "" {
+ return "", fmt.Errorf("empty domain")
+ }
+ if len(data) == 0 {
+ return "", fmt.Errorf("empty payload")
+ }
+
+ // Build header
+ header := make([]byte, QueryPayloadSize)
+ if _, err := rand.Read(header[:QueryPaddingSize]); err != nil {
+ return "", fmt.Errorf("random padding: %w", err)
+ }
+ binary.BigEndian.PutUint16(header[QueryPaddingSize:], specialCh)
+ binary.BigEndian.PutUint16(header[QueryPaddingSize+QueryChannelSize:], block)
+
+ encHeader, err := encryptQueryBlock(queryKey, header)
+ if err != nil {
+ return "", fmt.Errorf("encrypt header: %w", err)
+ }
+
+ // Encrypt data with GCM
+ encData, err := Encrypt(queryKey, data)
+ if err != nil {
+ return "", fmt.Errorf("encrypt message: %w", err)
+ }
+
+ // Encode header and data
+ headerStr := strings.ToLower(b32.EncodeToString(encHeader))
+ dataStr := strings.ToLower(hex.EncodeToString(encData))
+
+ // Validate total query name fits in DNS limits (253 chars max)
+ // Each data label adds len+1 (for dot), header adds len+1, domain adds len+1
+ totalLen := len(headerStr) + 1 + len(dataStr) + (len(dataStr) / maxDNSLabelLen) + 1 + len(domain)
+ if totalLen > 253 {
+ return "", fmt.Errorf("message too large for DNS query (%d chars, max 253)", totalLen)
+ }
+
+ // Split data into DNS labels (max 63 chars each)
+ var dataLabels []string
+ for len(dataStr) > maxDNSLabelLen {
+ dataLabels = append(dataLabels, dataStr[:maxDNSLabelLen])
+ dataStr = dataStr[maxDNSLabelLen:]
+ }
+ if len(dataStr) > 0 {
+ dataLabels = append(dataLabels, dataStr)
+ }
+
+ // Build query name: header.data1.data2...dataN.domain
+ allLabels := append([]string{headerStr}, dataLabels...)
+ return joinQName(allLabels, domain)
+}
+
+// DecodeSendQuery decodes a send-message DNS query. Returns the target channel
+// number and decrypted message text.
+func DecodeSendQuery(queryKey [KeySize]byte, qname, domain string) (targetChannel uint16, message []byte, err error) {
+ qname = strings.TrimSuffix(qname, ".")
+ domain = strings.TrimSuffix(domain, ".")
+
+ suffix := "." + domain
+ if !strings.HasSuffix(strings.ToLower(qname), strings.ToLower(suffix)) {
+ return 0, nil, fmt.Errorf("domain mismatch")
+ }
+
+ encoded := qname[:len(qname)-len(suffix)]
+ parts := strings.Split(encoded, ".")
+ if len(parts) < 2 {
+ return 0, nil, fmt.Errorf("send query needs at least header + data labels")
+ }
+
+ // Decode header (first label)
+ headerLabel := parts[0]
+ headerCT, err := b32.DecodeString(strings.ToUpper(headerLabel))
+ if err != nil {
+ // Try hex fallback
+ headerCT, err = hex.DecodeString(headerLabel)
+ if err != nil {
+ return 0, nil, fmt.Errorf("decode header: %w", err)
+ }
+ }
+
+ plaintext, err := decryptQueryBlock(queryKey, headerCT)
+ if err != nil {
+ return 0, nil, fmt.Errorf("decrypt header: %w", err)
+ }
+
+ ch := binary.BigEndian.Uint16(plaintext[QueryPaddingSize:])
+ if ch != SendChannel {
+ return 0, nil, fmt.Errorf("not a send query (channel=%d)", ch)
+ }
+ targetChannel = binary.BigEndian.Uint16(plaintext[QueryPaddingSize+QueryChannelSize:])
+
+ // Decode data labels (remaining labels, concatenated hex)
+ dataHex := strings.Join(parts[1:], "")
+ dataCT, err := hex.DecodeString(dataHex)
+ if err != nil {
+ return 0, nil, fmt.Errorf("decode data: %w", err)
+ }
+
+ message, err = Decrypt(queryKey, dataCT)
+ if err != nil {
+ return 0, nil, fmt.Errorf("decrypt message: %w", err)
+ }
+
+ return targetChannel, message, nil
+}
+
+// DecodeAdminQuery decodes an admin command DNS query and returns the command and argument.
+func DecodeAdminQuery(queryKey [KeySize]byte, qname, domain string) (cmd AdminCmd, arg []byte, err error) {
+ qname = strings.TrimSuffix(qname, ".")
+ domain = strings.TrimSuffix(domain, ".")
+
+ suffix := "." + domain
+ if !strings.HasSuffix(strings.ToLower(qname), strings.ToLower(suffix)) {
+ return 0, nil, fmt.Errorf("domain mismatch")
+ }
+
+ encoded := qname[:len(qname)-len(suffix)]
+ parts := strings.Split(encoded, ".")
+ if len(parts) < 2 {
+ return 0, nil, fmt.Errorf("admin query needs at least header + data labels")
+ }
+
+ headerLabel := parts[0]
+ headerCT, err := b32.DecodeString(strings.ToUpper(headerLabel))
+ if err != nil {
+ headerCT, err = hex.DecodeString(headerLabel)
+ if err != nil {
+ return 0, nil, fmt.Errorf("decode header: %w", err)
+ }
+ }
+
+ plaintext, err := decryptQueryBlock(queryKey, headerCT)
+ if err != nil {
+ return 0, nil, fmt.Errorf("decrypt header: %w", err)
+ }
+
+ ch := binary.BigEndian.Uint16(plaintext[QueryPaddingSize:])
+ if ch != AdminChannel {
+ return 0, nil, fmt.Errorf("not an admin query (channel=%d)", ch)
+ }
+
+ dataHex := strings.Join(parts[1:], "")
+ dataCT, err := hex.DecodeString(dataHex)
+ if err != nil {
+ return 0, nil, fmt.Errorf("decode data: %w", err)
+ }
+
+ payload, err := Decrypt(queryKey, dataCT)
+ if err != nil {
+ return 0, nil, fmt.Errorf("decrypt payload: %w", err)
+ }
+
+ if len(payload) == 0 {
+ return 0, nil, fmt.Errorf("empty admin payload")
+ }
+ cmd = AdminCmd(payload[0])
+ if len(payload) > 1 {
+ arg = payload[1:]
+ }
+ return cmd, arg, nil
+}
+
+// EncodeUpstreamInitQuery creates a compact single-label query that registers
+// a chunked upstream session. All init data is packed into the AES-ECB header:
+//
+// [0:2] session_id, [2] total_blocks, [3] kind,
+// [4:6] channel=UpstreamInitChannel, [6] target_channel, [7] 0
+//
+// No GCM data labels — just one 26-char base32 label + domain.
+func EncodeUpstreamInitQuery(queryKey [KeySize]byte, init UpstreamInit, domain string, mode QueryEncoding) (string, error) {
+ if init.SessionID == 0 {
+ return "", fmt.Errorf("session id is required")
+ }
+ if init.TotalBlocks == 0 || int(init.TotalBlocks) > MaxUpstreamBlocks {
+ return "", fmt.Errorf("invalid block count: %d", init.TotalBlocks)
+ }
+ domain = strings.TrimSuffix(domain, ".")
+ if domain == "" {
+ return "", fmt.Errorf("empty domain")
+ }
+
+ payload := make([]byte, QueryPayloadSize)
+ binary.BigEndian.PutUint16(payload[0:], init.SessionID)
+ payload[2] = init.TotalBlocks
+ payload[3] = byte(init.Kind)
+ binary.BigEndian.PutUint16(payload[QueryPaddingSize:], UpstreamInitChannel)
+ payload[6] = init.TargetChannel
+ // payload[7] = 0 (zero-padded)
+
+ encrypted, err := encryptQueryBlock(queryKey, payload)
+ if err != nil {
+ return "", fmt.Errorf("encrypt init: %w", err)
+ }
+
+ encoded := strings.ToLower(b32.EncodeToString(encrypted))
+ return joinQName([]string{encoded}, domain)
+}
+
+// DecodeUpstreamInitQuery parses a compact single-label upstream init query.
+func DecodeUpstreamInitQuery(queryKey [KeySize]byte, qname, domain string) (*UpstreamInit, error) {
+ qname = strings.TrimSuffix(qname, ".")
+ domain = strings.TrimSuffix(domain, ".")
+
+ suffix := "." + domain
+ if !strings.HasSuffix(strings.ToLower(qname), strings.ToLower(suffix)) {
+ return nil, fmt.Errorf("domain mismatch")
+ }
+
+ encoded := qname[:len(qname)-len(suffix)]
+ label := strings.ReplaceAll(encoded, ".", "")
+
+ ct, err := b32.DecodeString(strings.ToUpper(label))
+ if err != nil {
+ ct, err = hex.DecodeString(label)
+ if err != nil {
+ return nil, fmt.Errorf("decode init: %w", err)
+ }
+ }
+
+ plaintext, err := decryptQueryBlock(queryKey, ct)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt init: %w", err)
+ }
+
+ ch := binary.BigEndian.Uint16(plaintext[QueryPaddingSize:])
+ if ch != UpstreamInitChannel {
+ return nil, fmt.Errorf("not an upstream init query (channel=%d)", ch)
+ }
+
+ init := &UpstreamInit{
+ SessionID: binary.BigEndian.Uint16(plaintext[0:2]),
+ TotalBlocks: plaintext[2],
+ Kind: UpstreamKind(plaintext[3]),
+ TargetChannel: plaintext[6],
+ }
+ if init.SessionID == 0 {
+ return nil, fmt.Errorf("invalid upstream session id")
+ }
+ if init.TotalBlocks == 0 || int(init.TotalBlocks) > MaxUpstreamBlocks {
+ return nil, fmt.Errorf("invalid upstream block count: %d", init.TotalBlocks)
+ }
+ if init.Kind != UpstreamKindSend && init.Kind != UpstreamKindAdmin {
+ return nil, fmt.Errorf("invalid upstream kind: %d", init.Kind)
+ }
+ return init, nil
+}
+
+// EncodeUpstreamBlockQuery encodes one chunk of a chunked upstream payload
+// into a single DNS label. The first min(2, len(chunk)) bytes are embedded in
+// the AES-ECB header at [6:8] (not covered by integrity check); any remaining
+// bytes are appended raw after the 16-byte ciphertext. The upstream payload is
+// already GCM-encrypted, so confidentiality is preserved; tampering is caught
+// by GCM on reassembly.
+//
+// Header: [0:2] session_id, [2] index, [3] chunk_len,
+// [4:6] channel=UpstreamDataChannel, [6:8] chunk prefix
+// Suffix: chunk[2:] (raw, up to 6 bytes)
+//
+// Max label: 16 + 6 = 22 bytes → 36 base32 chars (fits in 63-char DNS label).
+func EncodeUpstreamBlockQuery(queryKey [KeySize]byte, sessionID uint16, index uint8, chunk []byte, domain string, mode QueryEncoding) (string, error) {
+ if sessionID == 0 {
+ return "", fmt.Errorf("session id is required")
+ }
+ if len(chunk) == 0 {
+ return "", fmt.Errorf("empty upstream block")
+ }
+ if len(chunk) > MaxUpstreamBlockPayload {
+ return "", fmt.Errorf("upstream block too large: %d > %d", len(chunk), MaxUpstreamBlockPayload)
+ }
+ domain = strings.TrimSuffix(domain, ".")
+ if domain == "" {
+ return "", fmt.Errorf("empty domain")
+ }
+
+ header := make([]byte, QueryPayloadSize)
+ binary.BigEndian.PutUint16(header[0:], sessionID)
+ header[2] = index
+ header[3] = byte(len(chunk))
+ binary.BigEndian.PutUint16(header[QueryPaddingSize:], UpstreamDataChannel)
+
+ // Embed first 2 bytes of chunk in header[6:8].
+ inHeader := len(chunk)
+ if inHeader > 2 {
+ inHeader = 2
+ }
+ copy(header[6:], chunk[:inHeader])
+
+ encHeader, err := encryptQueryBlock(queryKey, header)
+ if err != nil {
+ return "", fmt.Errorf("encrypt header: %w", err)
+ }
+
+ // Append remaining chunk bytes (raw) after the encrypted header.
+ combined := encHeader
+ if len(chunk) > 2 {
+ combined = append(combined, chunk[2:]...)
+ }
+
+ label := strings.ToLower(b32.EncodeToString(combined))
+
+ return joinQName([]string{label}, domain)
+}
+
+// DecodeUpstreamBlockQuery decodes one chunk of a chunked upstream payload.
+// The first 2 bytes of chunk data live in the encrypted header[6:8]; any
+// remaining bytes follow the 16-byte ciphertext as raw bytes.
+func DecodeUpstreamBlockQuery(queryKey [KeySize]byte, qname, domain string) (sessionID uint16, index uint8, chunk []byte, err error) {
+ qname = strings.TrimSuffix(qname, ".")
+ domain = strings.TrimSuffix(domain, ".")
+
+ suffix := "." + domain
+ if !strings.HasSuffix(strings.ToLower(qname), strings.ToLower(suffix)) {
+ return 0, 0, nil, fmt.Errorf("domain mismatch")
+ }
+
+ encoded := qname[:len(qname)-len(suffix)]
+ // Single label — no dots expected
+ label := strings.ReplaceAll(encoded, ".", "")
+
+ raw, err := b32.DecodeString(strings.ToUpper(label))
+ if err != nil {
+ return 0, 0, nil, fmt.Errorf("decode label: %w", err)
+ }
+ if len(raw) < 16 {
+ return 0, 0, nil, fmt.Errorf("block query too short: %d bytes", len(raw))
+ }
+
+ plaintext, err := decryptQueryBlock(queryKey, raw[:16])
+ if err != nil {
+ return 0, 0, nil, fmt.Errorf("decrypt header: %w", err)
+ }
+
+ ch := binary.BigEndian.Uint16(plaintext[QueryPaddingSize:])
+ if ch != UpstreamDataChannel {
+ return 0, 0, nil, fmt.Errorf("not an upstream data query (channel=%d)", ch)
+ }
+
+ sessionID = binary.BigEndian.Uint16(plaintext[0:2])
+ if sessionID == 0 {
+ return 0, 0, nil, fmt.Errorf("invalid upstream session id")
+ }
+ index = plaintext[2]
+ chunkLen := int(plaintext[3])
+ if chunkLen == 0 || chunkLen > MaxUpstreamBlockPayload {
+ return 0, 0, nil, fmt.Errorf("invalid chunk length: %d", chunkLen)
+ }
+
+ chunk = make([]byte, chunkLen)
+ // First min(2, chunkLen) bytes from header[6:8].
+ inHeader := chunkLen
+ if inHeader > 2 {
+ inHeader = 2
+ }
+ copy(chunk[:inHeader], plaintext[6:6+inHeader])
+
+ // Remaining bytes from raw suffix after the 16-byte ciphertext.
+ if chunkLen > 2 {
+ extra := raw[16:]
+ need := chunkLen - 2
+ if len(extra) < need {
+ return 0, 0, nil, fmt.Errorf("insufficient data bytes: have %d, need %d", len(extra), need)
+ }
+ copy(chunk[2:], extra[:need])
+ }
+
+ return sessionID, index, chunk, nil
+}
diff --git a/internal/protocol/dns_test.go b/internal/protocol/dns_test.go
index 92f6b5e..2856671 100644
--- a/internal/protocol/dns_test.go
+++ b/internal/protocol/dns_test.go
@@ -1,7 +1,6 @@
package protocol
import (
- "fmt"
"strings"
"testing"
)
@@ -88,49 +87,12 @@ func TestEncodeQueryTooLongDomain(t *testing.T) {
}
}
-func TestEncodeDecodeQueryPlainLabel(t *testing.T) {
+func TestSingleLabelNotConfusedWithHex(t *testing.T) {
qk, _, err := DeriveKeys("test-key")
if err != nil {
t.Fatalf("DeriveKeys: %v", err)
}
domain := "t.example.com"
- tests := []struct {
- channel uint16
- block uint16
- }{
- {0, 0},
- {1, 42},
- {255, 65535},
- {3, 100},
- }
- for _, tt := range tests {
- qname, err := EncodeQuery(qk, tt.channel, tt.block, domain, QueryPlainLabel)
- if err != nil {
- t.Fatalf("EncodeQuery(%d, %d): %v", tt.channel, tt.block, err)
- }
- // Label should be "cb" — human readable, no padding hex.
- want := fmt.Sprintf("c%db%d.%s", tt.channel, tt.block, domain)
- if qname != want {
- t.Errorf("got %q, want %q", qname, want)
- }
- // DecodeQuery must recover channel and block regardless of key.
- ch, blk, err := DecodeQuery(qk, qname, domain)
- if err != nil {
- t.Fatalf("DecodeQuery: %v", err)
- }
- if ch != tt.channel || blk != tt.block {
- t.Errorf("got ch=%d blk=%d, want ch=%d blk=%d", ch, blk, tt.channel, tt.block)
- }
- }
-}
-
-func TestPlainLabelNotConfusedWithEncrypted(t *testing.T) {
- qk, _, err := DeriveKeys("test-key")
- if err != nil {
- t.Fatalf("DeriveKeys: %v", err)
- }
- domain := "t.example.com"
- // Encode with single-label then check that DecodeQuery does NOT treat it as plain.
qname, _ := EncodeQuery(qk, 5, 10, domain, QuerySingleLabel)
ch, blk, err := DecodeQuery(qk, qname, domain)
if err != nil {
@@ -235,3 +197,187 @@ func TestQueryDomainWithTrailingDot(t *testing.T) {
t.Errorf("got ch=%d blk=%d, want ch=1 blk=0", ch, blk)
}
}
+
+func TestEncodeDecodeSendQuery(t *testing.T) {
+ qk, _, err := DeriveKeys("test-key")
+ if err != nil {
+ t.Fatalf("DeriveKeys: %v", err)
+ }
+ domain := "t.example.com"
+ msg := []byte("Hello!")
+
+ qname, err := EncodeSendQuery(qk, 3, msg, domain, QuerySingleLabel)
+ if err != nil {
+ t.Fatalf("EncodeSendQuery: %v", err)
+ }
+
+ targetCh, gotMsg, err := DecodeSendQuery(qk, qname, domain)
+ if err != nil {
+ t.Fatalf("DecodeSendQuery: %v", err)
+ }
+ if targetCh != 3 {
+ t.Errorf("targetChannel = %d, want 3", targetCh)
+ }
+ if string(gotMsg) != string(msg) {
+ t.Errorf("message = %q, want %q", string(gotMsg), string(msg))
+ }
+}
+
+func TestEncodeDecodeSendQueryNoPassword(t *testing.T) {
+ qk, _, err := DeriveKeys("test-key")
+ if err != nil {
+ t.Fatalf("DeriveKeys: %v", err)
+ }
+ domain := "t.example.com"
+ msg := []byte("No password")
+
+ qname, err := EncodeSendQuery(qk, 1, msg, domain, QuerySingleLabel)
+ if err != nil {
+ t.Fatalf("EncodeSendQuery: %v", err)
+ }
+
+ targetCh, gotMsg, err := DecodeSendQuery(qk, qname, domain)
+ if err != nil {
+ t.Fatalf("DecodeSendQuery: %v", err)
+ }
+ if targetCh != 1 {
+ t.Errorf("targetChannel = %d, want 1", targetCh)
+ }
+ if string(gotMsg) != string(msg) {
+ t.Errorf("message = %q, want %q", string(gotMsg), string(msg))
+ }
+}
+
+func TestEncodeDecodeAdminQuery(t *testing.T) {
+ qk, _, err := DeriveKeys("test-key")
+ if err != nil {
+ t.Fatalf("DeriveKeys: %v", err)
+ }
+ domain := "t.example.com"
+
+ qname, err := EncodeAdminQuery(qk, AdminCmdAddChannel, []byte("testchan"), domain, QuerySingleLabel)
+ if err != nil {
+ t.Fatalf("EncodeAdminQuery: %v", err)
+ }
+
+ gotCmd, gotArg, err := DecodeAdminQuery(qk, qname, domain)
+ if err != nil {
+ t.Fatalf("DecodeAdminQuery: %v", err)
+ }
+ if gotCmd != AdminCmdAddChannel {
+ t.Errorf("cmd = %d, want %d", gotCmd, AdminCmdAddChannel)
+ }
+ if string(gotArg) != "testchan" {
+ t.Errorf("arg = %q, want %q", string(gotArg), "testchan")
+ }
+}
+
+func TestEncodeDecodeUpstreamInitQuery(t *testing.T) {
+ qk, _, err := DeriveKeys("test-key")
+ if err != nil {
+ t.Fatalf("DeriveKeys: %v", err)
+ }
+ init := UpstreamInit{
+ SessionID: 0x1122,
+ TotalBlocks: 7,
+ Kind: UpstreamKindSend,
+ TargetChannel: 15,
+ }
+ qname, err := EncodeUpstreamInitQuery(qk, init, "t.example.com", QuerySingleLabel)
+ if err != nil {
+ t.Fatalf("EncodeUpstreamInitQuery: %v", err)
+ }
+ // Init query should be a single compact label — no data labels
+ if strings.Count(strings.TrimSuffix(qname, ".t.example.com"), ".") != 0 {
+ t.Fatalf("init query should be a single label, got: %s", qname)
+ }
+ got, err := DecodeUpstreamInitQuery(qk, qname, "t.example.com")
+ if err != nil {
+ t.Fatalf("DecodeUpstreamInitQuery: %v", err)
+ }
+ if *got != init {
+ t.Fatalf("got %+v, want %+v", *got, init)
+ }
+}
+
+func TestEncodeDecodeUpstreamBlockQuery(t *testing.T) {
+ qk, _, err := DeriveKeys("test-key")
+ if err != nil {
+ t.Fatalf("DeriveKeys: %v", err)
+ }
+ chunk := strings.Repeat("x", MaxUpstreamBlockPayload)
+ qname, err := EncodeUpstreamBlockQuery(qk, 0xAABB, 3, []byte(chunk), "t.example.com", QuerySingleLabel)
+ if err != nil {
+ t.Fatalf("EncodeUpstreamBlockQuery: %v", err)
+ }
+ if len(qname) > 253 {
+ t.Fatalf("upstream block query too long: %d", len(qname))
+ }
+ sessionID, index, gotChunk, err := DecodeUpstreamBlockQuery(qk, qname, "t.example.com")
+ if err != nil {
+ t.Fatalf("DecodeUpstreamBlockQuery: %v", err)
+ }
+ if sessionID != 0xAABB {
+ t.Fatalf("sessionID = %#x, want %#x", sessionID, 0xAABB)
+ }
+ if index != 3 {
+ t.Fatalf("index = %d, want 3", index)
+ }
+ if string(gotChunk) != chunk {
+ t.Fatalf("chunk = %q, want %q", string(gotChunk), chunk)
+ }
+}
+
+func TestEncodeUpstreamInitQueryRejectsInvalidBlockCount(t *testing.T) {
+ qk, _, err := DeriveKeys("test-key")
+ if err != nil {
+ t.Fatalf("DeriveKeys: %v", err)
+ }
+ _, err = EncodeUpstreamInitQuery(qk, UpstreamInit{SessionID: 1, TotalBlocks: MaxUpstreamBlocks + 1, Kind: UpstreamKindAdmin}, "t.example.com", QuerySingleLabel)
+ if err == nil {
+ t.Fatal("expected invalid block count error")
+ }
+}
+
+func TestDecodeQueryRoutesUpstreamInitQuery(t *testing.T) {
+ qk, _, err := DeriveKeys("test-key")
+ if err != nil {
+ t.Fatalf("DeriveKeys: %v", err)
+ }
+ init := UpstreamInit{
+ SessionID: 0xBEEF,
+ TotalBlocks: 1,
+ Kind: UpstreamKindSend,
+ TargetChannel: 5,
+ }
+ qname, err := EncodeUpstreamInitQuery(qk, init, "t.example.com", QuerySingleLabel)
+ if err != nil {
+ t.Fatalf("EncodeUpstreamInitQuery: %v", err)
+ }
+ // DecodeQuery must extract the channel from multi-label upstream queries
+ ch, _, err := DecodeQuery(qk, qname, "t.example.com")
+ if err != nil {
+ t.Fatalf("DecodeQuery failed on upstream init query: %v", err)
+ }
+ if ch != UpstreamInitChannel {
+ t.Fatalf("channel = %#x, want %#x (UpstreamInitChannel)", ch, UpstreamInitChannel)
+ }
+}
+
+func TestDecodeQueryRoutesUpstreamBlockQuery(t *testing.T) {
+ qk, _, err := DeriveKeys("test-key")
+ if err != nil {
+ t.Fatalf("DeriveKeys: %v", err)
+ }
+ qname, err := EncodeUpstreamBlockQuery(qk, 0xAABB, 0, []byte("HI"), "t.example.com", QuerySingleLabel)
+ if err != nil {
+ t.Fatalf("EncodeUpstreamBlockQuery: %v", err)
+ }
+ ch, _, err := DecodeQuery(qk, qname, "t.example.com")
+ if err != nil {
+ t.Fatalf("DecodeQuery failed on upstream block query: %v", err)
+ }
+ if ch != UpstreamDataChannel {
+ t.Fatalf("channel = %#x, want %#x (UpstreamDataChannel)", ch, UpstreamDataChannel)
+ }
+}
diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go
index 4c74893..9f42854 100644
--- a/internal/protocol/protocol.go
+++ b/internal/protocol/protocol.go
@@ -1,9 +1,13 @@
package protocol
import (
+ "bytes"
+ "compress/flate"
"crypto/rand"
"encoding/binary"
"fmt"
+ "hash/crc32"
+ "io"
"math/big"
)
@@ -11,8 +15,8 @@ const (
// MinBlockPayload is the minimum decrypted payload per DNS TXT block.
MinBlockPayload = 400
// MaxBlockPayload is the maximum decrypted payload per DNS TXT block.
- // 700 bytes data + 28 GCM overhead + 2 prefix + 32 padding → ~996 base64 chars.
- // Well within the 4096-byte EDNS0 UDP buffer the client advertises.
+ // 700 bytes data + 28 GCM overhead + 2 prefix + 32 padding → ~1016 base64 chars.
+ // Fits within the standard 1232-byte EDNS0 UDP buffer (DNS Flag Day 2020).
MaxBlockPayload = 700
// DefaultBlockPayload is kept for compatibility; equals MaxBlockPayload.
DefaultBlockPayload = MaxBlockPayload
@@ -36,10 +40,11 @@ const (
QueryPayloadSize = QueryPaddingSize + QueryChannelSize + QueryBlockSize // 8
// Message header sizes (in the serialized message stream).
- MsgIDSize = 4
- MsgTimestampSize = 4
- MsgLengthSize = 2
- MsgHeaderSize = MsgIDSize + MsgTimestampSize + MsgLengthSize // 10
+ MsgIDSize = 4
+ MsgTimestampSize = 4
+ MsgLengthSize = 2
+ MsgHeaderSize = MsgIDSize + MsgTimestampSize + MsgLengthSize // 10
+ MsgContentHashSize = 4
)
// Media placeholder strings for non-text content.
@@ -55,18 +60,31 @@ const (
MediaLocation = "[LOCATION]"
)
+// ChatType distinguishes channel types in metadata.
+type ChatType uint8
+
+const (
+ ChatTypeChannel ChatType = 0 // public Telegram channel
+ ChatTypePrivate ChatType = 1 // private chat / bot
+)
+
// Metadata holds channel 0 data: server info + channel list.
type Metadata struct {
- Marker [MarkerSize]byte
- Timestamp uint32
- Channels []ChannelInfo
+ Marker [MarkerSize]byte
+ Timestamp uint32
+ NextFetch uint32 // unix timestamp of next server-side fetch (0 = unknown)
+ TelegramLoggedIn bool // true if server has an active Telegram session
+ Channels []ChannelInfo
}
// ChannelInfo describes a single feed channel.
type ChannelInfo struct {
- Name string
- Blocks uint16
- LastMsgID uint32
+ Name string
+ Blocks uint16
+ LastMsgID uint32
+ ContentHash uint32 // CRC32 of serialized message data; changes on edits
+ ChatType ChatType // 0=channel, 1=private
+ CanSend bool // true if server allows sending messages to this chat
}
// Message represents a single feed message in a channel.
@@ -76,12 +94,21 @@ type Message struct {
Text string
}
+// ContentHashOf computes a CRC32 hash of serialized message data.
+// This changes when any message is edited, even if IDs stay the same.
+func ContentHashOf(msgs []Message) uint32 {
+ data := SerializeMessages(msgs)
+ return crc32.ChecksumIEEE(data)
+}
+
// SerializeMetadata encodes metadata into bytes for channel 0 blocks.
+// Format: marker(3) + timestamp(4) + nextFetch(4) + flags(1) + channelCount(2) + per-channel data
+// Per-channel: nameLen(1) + name + blocks(2) + lastMsgID(4) + contentHash(4) + chatType(1) + flags(1)
func SerializeMetadata(m *Metadata) []byte {
- // 3 marker + 4 timestamp + 2 channel count + per-channel data
- size := MarkerSize + 4 + 2
+ // 3 marker + 4 timestamp + 4 nextFetch + 1 flags + 2 channel count + per-channel data
+ size := MarkerSize + 4 + 4 + 1 + 2
for _, ch := range m.Channels {
- size += 1 + len(ch.Name) + 2 + 4
+ size += 1 + len(ch.Name) + 2 + 4 + 4 + 1 + 1 // +4 for contentHash
}
buf := make([]byte, size)
off := 0
@@ -92,6 +119,16 @@ func SerializeMetadata(m *Metadata) []byte {
binary.BigEndian.PutUint32(buf[off:], m.Timestamp)
off += 4
+ binary.BigEndian.PutUint32(buf[off:], m.NextFetch)
+ off += 4
+
+ var flags byte
+ if m.TelegramLoggedIn {
+ flags |= 0x01
+ }
+ buf[off] = flags
+ off++
+
binary.BigEndian.PutUint16(buf[off:], uint16(len(m.Channels)))
off += 2
@@ -108,6 +145,16 @@ func SerializeMetadata(m *Metadata) []byte {
off += 2
binary.BigEndian.PutUint32(buf[off:], ch.LastMsgID)
off += 4
+ binary.BigEndian.PutUint32(buf[off:], ch.ContentHash)
+ off += 4
+ buf[off] = byte(ch.ChatType)
+ off++
+ var chFlags byte
+ if ch.CanSend {
+ chFlags |= 0x01
+ }
+ buf[off] = chFlags
+ off++
}
return buf
@@ -115,7 +162,8 @@ func SerializeMetadata(m *Metadata) []byte {
// ParseMetadata decodes metadata from concatenated channel 0 block data.
func ParseMetadata(data []byte) (*Metadata, error) {
- if len(data) < MarkerSize+4+2 {
+ // Minimum: marker(3) + timestamp(4) + nextFetch(4) + flags(1) + count(2) = 14
+ if len(data) < MarkerSize+4+4+1+2 {
return nil, fmt.Errorf("metadata too short: %d bytes", len(data))
}
m := &Metadata{}
@@ -127,6 +175,13 @@ func ParseMetadata(data []byte) (*Metadata, error) {
m.Timestamp = binary.BigEndian.Uint32(data[off:])
off += 4
+ m.NextFetch = binary.BigEndian.Uint32(data[off:])
+ off += 4
+
+ flags := data[off]
+ off++
+ m.TelegramLoggedIn = flags&0x01 != 0
+
count := binary.BigEndian.Uint16(data[off:])
off += 2
@@ -143,18 +198,27 @@ func ParseMetadata(data []byte) (*Metadata, error) {
name := string(data[off : off+nameLen])
off += nameLen
- if off+6 > len(data) {
+ if off+12 > len(data) {
return nil, fmt.Errorf("truncated channel info at %d", i)
}
blocks := binary.BigEndian.Uint16(data[off:])
off += 2
lastID := binary.BigEndian.Uint32(data[off:])
off += 4
+ contentHash := binary.BigEndian.Uint32(data[off:])
+ off += 4
+ chatType := ChatType(data[off])
+ off++
+ chFlags := data[off]
+ off++
m.Channels = append(m.Channels, ChannelInfo{
- Name: name,
- Blocks: blocks,
- LastMsgID: lastID,
+ Name: name,
+ Blocks: blocks,
+ LastMsgID: lastID,
+ ContentHash: contentHash,
+ ChatType: chatType,
+ CanSend: chFlags&0x01 != 0,
})
}
@@ -245,3 +309,58 @@ func randBlockSize() int {
}
return MinBlockPayload + int(n.Int64())
}
+
+const (
+ // compressionNone means no compression applied (raw serialized messages).
+ compressionNone byte = 0x00
+ // compressionDeflate means data is deflate-compressed.
+ compressionDeflate byte = 0x01
+)
+
+// CompressMessages compresses serialized message data using deflate.
+// The output has a 1-byte header (compression type) followed by the payload.
+// If compression doesn't reduce size, the raw data is stored instead.
+func CompressMessages(data []byte) []byte {
+ if len(data) == 0 {
+ return append([]byte{compressionNone}, data...)
+ }
+
+ var buf bytes.Buffer
+ w, err := flate.NewWriter(&buf, flate.BestCompression)
+ if err != nil {
+ return append([]byte{compressionNone}, data...)
+ }
+ w.Write(data)
+ w.Close()
+
+ compressed := buf.Bytes()
+ if len(compressed) >= len(data) {
+ // Compression didn't help — store raw
+ return append([]byte{compressionNone}, data...)
+ }
+
+ return append([]byte{compressionDeflate}, compressed...)
+}
+
+// DecompressMessages decompresses data produced by CompressMessages.
+// Reads the 1-byte header to determine the compression type.
+func DecompressMessages(data []byte) ([]byte, error) {
+ if len(data) == 0 {
+ return nil, fmt.Errorf("empty compressed data")
+ }
+
+ switch data[0] {
+ case compressionNone:
+ return data[1:], nil
+ case compressionDeflate:
+ r := flate.NewReader(bytes.NewReader(data[1:]))
+ defer r.Close()
+ out, err := io.ReadAll(r)
+ if err != nil {
+ return nil, fmt.Errorf("deflate decompress: %w", err)
+ }
+ return out, nil
+ default:
+ return nil, fmt.Errorf("unknown compression type: 0x%02x", data[0])
+ }
+}
diff --git a/internal/server/dns.go b/internal/server/dns.go
index 17e2d91..9d73501 100644
--- a/internal/server/dns.go
+++ b/internal/server/dns.go
@@ -1,9 +1,14 @@
package server
import (
+ "bufio"
"context"
+ "fmt"
"log"
+ "os"
"strings"
+ "sync"
+ "time"
"github.com/miekg/dns"
@@ -12,24 +17,44 @@ import (
// DNSServer serves feed data over DNS TXT queries.
type DNSServer struct {
- domain string
- feed *Feed
- queryKey [protocol.KeySize]byte
- responseKey [protocol.KeySize]byte
- listenAddr string
- maxPadding int
+ domain string
+ feed *Feed
+ reader *TelegramReader // nil when --no-telegram
+ queryKey [protocol.KeySize]byte
+ responseKey [protocol.KeySize]byte
+ listenAddr string
+ maxPadding int
+ allowManage bool // if true, admin/send commands are accepted
+ channelsFile string // path to channels.txt for admin commands
+
+ sessionsMu sync.Mutex
+ sessions map[uint16]*uploadSession
+}
+
+type uploadSession struct {
+ kind protocol.UpstreamKind
+ targetChannel uint8
+ totalBlocks uint8
+ blocks [][]byte
+ received []bool
+ expiresAt time.Time
}
// NewDNSServer creates a DNS server for the given domain.
-func NewDNSServer(listenAddr, domain string, feed *Feed, queryKey, responseKey [protocol.KeySize]byte, maxPadding int) *DNSServer {
- return &DNSServer{
- domain: strings.TrimSuffix(domain, "."),
- feed: feed,
- queryKey: queryKey,
- responseKey: responseKey,
- listenAddr: listenAddr,
- maxPadding: maxPadding,
+func NewDNSServer(listenAddr, domain string, feed *Feed, queryKey, responseKey [protocol.KeySize]byte, maxPadding int, reader *TelegramReader, allowManage bool, channelsFile string) *DNSServer {
+ s := &DNSServer{
+ domain: strings.TrimSuffix(domain, "."),
+ feed: feed,
+ reader: reader,
+ queryKey: queryKey,
+ responseKey: responseKey,
+ listenAddr: listenAddr,
+ maxPadding: maxPadding,
+ allowManage: allowManage,
+ channelsFile: channelsFile,
+ sessions: make(map[uint16]*uploadSession),
}
+ return s
}
// ListenAndServe starts the DNS server on UDP, shutting down when ctx is cancelled.
@@ -78,6 +103,28 @@ func (s *DNSServer) handleQuery(w dns.ResponseWriter, r *dns.Msg) {
return
}
+ // Handle upstream init/data queries
+ switch channel {
+ case protocol.UpstreamInitChannel:
+ s.handleUpstreamInitQuery(w, m, q)
+ return
+ case protocol.UpstreamDataChannel:
+ s.handleUpstreamDataQuery(w, m, q)
+ return
+ }
+
+ // Handle send-message queries
+ if channel == protocol.SendChannel {
+ s.handleSendQuery(w, m, q)
+ return
+ }
+
+ // Handle admin command queries
+ if channel == protocol.AdminChannel {
+ s.handleAdminQuery(w, m, q)
+ return
+ }
+
data, err := s.feed.GetBlock(int(channel), int(block))
if err != nil {
log.Printf("[dns] get block ch=%d blk=%d: %v", channel, block, err)
@@ -122,3 +169,381 @@ func splitTXT(s string) []string {
}
return parts
}
+
+func (s *DNSServer) writeEncodedResponse(w dns.ResponseWriter, m *dns.Msg, name string, data []byte) {
+ encoded, err := protocol.EncodeResponse(s.responseKey, data, s.maxPadding)
+ if err != nil {
+ m.Rcode = dns.RcodeServerFailure
+ w.WriteMsg(m)
+ return
+ }
+ m.Answer = append(m.Answer, &dns.TXT{
+ Hdr: dns.RR_Header{
+ Name: name,
+ Rrtype: dns.TypeTXT,
+ Class: dns.ClassINET,
+ Ttl: 1,
+ },
+ Txt: splitTXT(encoded),
+ })
+ w.WriteMsg(m)
+}
+
+func (s *DNSServer) cleanupExpiredSessions(now time.Time) {
+ for id, sess := range s.sessions {
+ if now.After(sess.expiresAt) {
+ delete(s.sessions, id)
+ }
+ }
+}
+
+func (s *DNSServer) handleUpstreamInitQuery(w dns.ResponseWriter, m *dns.Msg, q dns.Question) {
+ if !s.allowManage {
+ m.Rcode = dns.RcodeRefused
+ w.WriteMsg(m)
+ return
+ }
+
+ init, err := protocol.DecodeUpstreamInitQuery(s.queryKey, q.Name, s.domain)
+ if err != nil {
+ log.Printf("[dns] decode upstream init: %v", err)
+ m.Rcode = dns.RcodeNameError
+ w.WriteMsg(m)
+ return
+ }
+
+ if init.Kind == protocol.UpstreamKindSend {
+ if s.reader == nil {
+ m.Rcode = dns.RcodeRefused
+ w.WriteMsg(m)
+ return
+ }
+ }
+
+ now := time.Now()
+ s.sessionsMu.Lock()
+ s.cleanupExpiredSessions(now)
+ s.sessions[init.SessionID] = &uploadSession{
+ kind: init.Kind,
+ targetChannel: init.TargetChannel,
+ totalBlocks: init.TotalBlocks,
+ blocks: make([][]byte, init.TotalBlocks),
+ received: make([]bool, init.TotalBlocks),
+ expiresAt: now.Add(5 * time.Minute),
+ }
+ s.sessionsMu.Unlock()
+
+ s.writeEncodedResponse(w, m, q.Name, []byte("READY"))
+}
+
+func (s *DNSServer) handleUpstreamDataQuery(w dns.ResponseWriter, m *dns.Msg, q dns.Question) {
+ if !s.allowManage {
+ m.Rcode = dns.RcodeRefused
+ w.WriteMsg(m)
+ return
+ }
+
+ sessionID, index, chunk, err := protocol.DecodeUpstreamBlockQuery(s.queryKey, q.Name, s.domain)
+ if err != nil {
+ log.Printf("[dns] decode upstream block: %v", err)
+ m.Rcode = dns.RcodeNameError
+ w.WriteMsg(m)
+ return
+ }
+
+ now := time.Now()
+ s.sessionsMu.Lock()
+ s.cleanupExpiredSessions(now)
+ sess, ok := s.sessions[sessionID]
+ if !ok || now.After(sess.expiresAt) {
+ if ok {
+ delete(s.sessions, sessionID)
+ }
+ s.sessionsMu.Unlock()
+ m.Rcode = dns.RcodeRefused
+ w.WriteMsg(m)
+ return
+ }
+ if int(index) >= len(sess.blocks) {
+ s.sessionsMu.Unlock()
+ m.Rcode = dns.RcodeRefused
+ w.WriteMsg(m)
+ return
+ }
+ if !sess.received[index] {
+ copied := make([]byte, len(chunk))
+ copy(copied, chunk)
+ sess.blocks[index] = copied
+ sess.received[index] = true
+ }
+ sess.expiresAt = now.Add(5 * time.Minute)
+ complete := true
+ for _, received := range sess.received {
+ if !received {
+ complete = false
+ break
+ }
+ }
+ if !complete {
+ s.sessionsMu.Unlock()
+ s.writeEncodedResponse(w, m, q.Name, []byte("CONTINUE"))
+ return
+ }
+
+ payload := make([]byte, 0)
+ for _, block := range sess.blocks {
+ payload = append(payload, block...)
+ }
+ delete(s.sessions, sessionID)
+ s.sessionsMu.Unlock()
+
+ result, err := s.executeUpstreamPayload(sess, payload)
+ if err != nil {
+ log.Printf("[dns] upstream execute: %v", err)
+ m.Rcode = dns.RcodeServerFailure
+ w.WriteMsg(m)
+ return
+ }
+
+ s.writeEncodedResponse(w, m, q.Name, result)
+}
+
+func (s *DNSServer) executeUpstreamPayload(sess *uploadSession, payload []byte) ([]byte, error) {
+ switch sess.kind {
+ case protocol.UpstreamKindSend:
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+ if err := s.reader.SendMessage(ctx, int(sess.targetChannel), string(payload)); err != nil {
+ return nil, err
+ }
+ return []byte("OK"), nil
+ case protocol.UpstreamKindAdmin:
+ if len(payload) == 0 {
+ return nil, fmt.Errorf("empty admin payload")
+ }
+ cmd := protocol.AdminCmd(payload[0])
+ arg := ""
+ if len(payload) > 1 {
+ arg = string(payload[1:])
+ }
+
+ var result string
+ var err error
+ switch cmd {
+ case protocol.AdminCmdAddChannel:
+ result, err = s.adminAddChannel(arg)
+ case protocol.AdminCmdRemoveChannel:
+ result, err = s.adminRemoveChannel(arg)
+ case protocol.AdminCmdListChannels:
+ result, err = s.adminListChannels()
+ case protocol.AdminCmdRefresh:
+ result, err = s.adminRefresh()
+ default:
+ err = fmt.Errorf("unknown command: %d", cmd)
+ }
+ if err != nil {
+ return nil, err
+ }
+ return []byte(result), nil
+ default:
+ return nil, fmt.Errorf("unknown upstream kind: %d", sess.kind)
+ }
+}
+
+func (s *DNSServer) handleSendQuery(w dns.ResponseWriter, m *dns.Msg, q dns.Question) {
+ if !s.allowManage {
+ log.Printf("[dns] send query rejected: --allow-manage not set")
+ m.Rcode = dns.RcodeRefused
+ w.WriteMsg(m)
+ return
+ }
+
+ if s.reader == nil {
+ log.Printf("[dns] send query rejected: no Telegram reader")
+ m.Rcode = dns.RcodeServerFailure
+ w.WriteMsg(m)
+ return
+ }
+
+ targetCh, message, err := protocol.DecodeSendQuery(s.queryKey, q.Name, s.domain)
+ if err != nil {
+ log.Printf("[dns] decode send query: %v", err)
+ m.Rcode = dns.RcodeNameError
+ w.WriteMsg(m)
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ if err := s.reader.SendMessage(ctx, int(targetCh), string(message)); err != nil {
+ log.Printf("[dns] send message to ch %d: %v", targetCh, err)
+ m.Rcode = dns.RcodeServerFailure
+ w.WriteMsg(m)
+ return
+ }
+
+ // Respond with an ACK TXT record
+ s.writeEncodedResponse(w, m, q.Name, []byte("OK"))
+}
+
+func (s *DNSServer) handleAdminQuery(w dns.ResponseWriter, m *dns.Msg, q dns.Question) {
+ if !s.allowManage {
+ log.Printf("[dns] admin query rejected: --allow-manage not set")
+ m.Rcode = dns.RcodeRefused
+ w.WriteMsg(m)
+ return
+ }
+
+ cmd, arg, err := protocol.DecodeAdminQuery(s.queryKey, q.Name, s.domain)
+ if err != nil {
+ log.Printf("[dns] decode admin query: %v", err)
+ m.Rcode = dns.RcodeNameError
+ w.WriteMsg(m)
+ return
+ }
+
+ var result string
+ switch cmd {
+ case protocol.AdminCmdAddChannel:
+ result, err = s.adminAddChannel(string(arg))
+ case protocol.AdminCmdRemoveChannel:
+ result, err = s.adminRemoveChannel(string(arg))
+ case protocol.AdminCmdListChannels:
+ result, err = s.adminListChannels()
+ case protocol.AdminCmdRefresh:
+ result, err = s.adminRefresh()
+ default:
+ err = fmt.Errorf("unknown command: %d", cmd)
+ }
+
+ if err != nil {
+ log.Printf("[dns] admin cmd=%d: %v", cmd, err)
+ m.Rcode = dns.RcodeServerFailure
+ w.WriteMsg(m)
+ return
+ }
+
+ s.writeEncodedResponse(w, m, q.Name, []byte(result))
+}
+
+func (s *DNSServer) adminAddChannel(username string) (string, error) {
+ username = strings.TrimSpace(username)
+ if username == "" {
+ return "", fmt.Errorf("empty channel name")
+ }
+ username = strings.TrimPrefix(username, "@")
+
+ // Check if already exists
+ existing, err := loadChannelsFromFile(s.channelsFile)
+ if err != nil {
+ return "", fmt.Errorf("read channels: %w", err)
+ }
+ for _, ch := range existing {
+ if strings.EqualFold(ch, username) {
+ return "already exists", nil
+ }
+ }
+
+ // Append to file
+ f, err := os.OpenFile(s.channelsFile, os.O_APPEND|os.O_WRONLY, 0600)
+ if err != nil {
+ return "", fmt.Errorf("open channels file: %w", err)
+ }
+ defer f.Close()
+ if _, err := fmt.Fprintf(f, "\n@%s\n", username); err != nil {
+ return "", fmt.Errorf("write channel: %w", err)
+ }
+
+ log.Printf("[admin] added channel @%s", username)
+
+ // Update the live reader and trigger immediate fetch.
+ if s.reader != nil {
+ all, _ := loadChannelsFromFile(s.channelsFile)
+ s.reader.UpdateChannels(all)
+ s.reader.RequestRefresh()
+ }
+
+ return "OK", nil
+}
+
+func (s *DNSServer) adminRemoveChannel(username string) (string, error) {
+ username = strings.TrimSpace(username)
+ if username == "" {
+ return "", fmt.Errorf("empty channel name")
+ }
+ username = strings.TrimPrefix(username, "@")
+
+ existing, err := loadChannelsFromFile(s.channelsFile)
+ if err != nil {
+ return "", fmt.Errorf("read channels: %w", err)
+ }
+
+ found := false
+ var remaining []string
+ for _, ch := range existing {
+ if strings.EqualFold(ch, username) {
+ found = true
+ continue
+ }
+ remaining = append(remaining, ch)
+ }
+ if !found {
+ return "not found", nil
+ }
+
+ // Rewrite file
+ content := "# Telegram channel usernames (one per line)\n"
+ for _, ch := range remaining {
+ content += "@" + ch + "\n"
+ }
+ if err := os.WriteFile(s.channelsFile, []byte(content), 0600); err != nil {
+ return "", fmt.Errorf("write channels: %w", err)
+ }
+
+ log.Printf("[admin] removed channel @%s", username)
+
+ // Update the live reader and trigger immediate fetch.
+ if s.reader != nil {
+ s.reader.UpdateChannels(remaining)
+ s.reader.RequestRefresh()
+ }
+
+ return "OK", nil
+}
+
+func (s *DNSServer) adminListChannels() (string, error) {
+ channels, err := loadChannelsFromFile(s.channelsFile)
+ if err != nil {
+ return "", err
+ }
+ return strings.Join(channels, "\n"), nil
+}
+
+func (s *DNSServer) adminRefresh() (string, error) {
+ if s.reader == nil {
+ return "", fmt.Errorf("no Telegram reader")
+ }
+ s.reader.RequestRefresh()
+ log.Printf("[admin] hard refresh requested")
+ return "OK", nil
+}
+
+func loadChannelsFromFile(path string) ([]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ var channels []string
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ channels = append(channels, strings.TrimPrefix(line, "@"))
+ }
+ return channels, scanner.Err()
+}
diff --git a/internal/server/feed.go b/internal/server/feed.go
index 152a1fb..a5b7fbe 100644
--- a/internal/server/feed.go
+++ b/internal/server/feed.go
@@ -11,21 +11,29 @@ import (
// Feed manages the block data for all channels.
type Feed struct {
- mu sync.RWMutex
- marker [protocol.MarkerSize]byte
- channels []string
- blocks map[int][][]byte
- lastIDs map[int]uint32
- metaBlocks [][]byte // cached metadata split into blocks
- updated time.Time
+ mu sync.RWMutex
+ marker [protocol.MarkerSize]byte
+ channels []string
+ blocks map[int][][]byte
+ lastIDs map[int]uint32
+ contentHashes map[int]uint32
+ chatTypes map[int]protocol.ChatType
+ canSend map[int]bool
+ metaBlocks [][]byte // metadata for all channels
+ updated time.Time
+ telegramLoggedIn bool
+ nextFetch uint32
}
// NewFeed creates a new Feed with the given channel names.
func NewFeed(channels []string) *Feed {
f := &Feed{
- channels: channels,
- blocks: make(map[int][][]byte),
- lastIDs: make(map[int]uint32),
+ channels: channels,
+ blocks: make(map[int][][]byte),
+ lastIDs: make(map[int]uint32),
+ contentHashes: make(map[int]uint32),
+ chatTypes: make(map[int]protocol.ChatType),
+ canSend: make(map[int]bool),
}
f.rotateMarker()
f.rebuildMetaBlocks()
@@ -39,18 +47,21 @@ func (f *Feed) rotateMarker() {
// UpdateChannel replaces the messages for a channel, re-serializing into blocks.
func (f *Feed) UpdateChannel(channelNum int, msgs []protocol.Message) {
data := protocol.SerializeMessages(msgs)
- blocks := protocol.SplitIntoBlocks(data)
+ compressed := protocol.CompressMessages(data)
+ blocks := protocol.SplitIntoBlocks(compressed)
var lastID uint32
if len(msgs) > 0 {
lastID = msgs[0].ID
}
+ contentHash := protocol.ContentHashOf(msgs)
f.mu.Lock()
defer f.mu.Unlock()
f.blocks[channelNum] = blocks
f.lastIDs[channelNum] = lastID
+ f.contentHashes[channelNum] = contentHash
f.updated = time.Now()
f.rotateMarker()
f.rebuildMetaBlocks()
@@ -76,21 +87,25 @@ func (f *Feed) GetBlock(channel, block int) ([]byte, error) {
}
func (f *Feed) getMetadataBlock(block int) ([]byte, error) {
- if len(f.metaBlocks) == 0 {
+ blocks := f.metaBlocks
+ if len(blocks) == 0 {
f.rebuildMetaBlocks()
+ blocks = f.metaBlocks
}
- if block < 0 || block >= len(f.metaBlocks) {
- return nil, fmt.Errorf("metadata block %d out of range (%d blocks)", block, len(f.metaBlocks))
+ if block < 0 || block >= len(blocks) {
+ return nil, fmt.Errorf("metadata block %d out of range (%d blocks)", block, len(blocks))
}
- return f.metaBlocks[block], nil
+ return blocks[block], nil
}
// rebuildMetaBlocks re-serializes the metadata and splits it into blocks.
// Must be called with f.mu held.
func (f *Feed) rebuildMetaBlocks() {
- meta := &protocol.Metadata{
- Marker: f.marker,
- Timestamp: uint32(time.Now().Unix()),
+ meta := protocol.Metadata{
+ Marker: f.marker,
+ Timestamp: uint32(time.Now().Unix()),
+ NextFetch: f.nextFetch,
+ TelegramLoggedIn: f.telegramLoggedIn,
}
for i, name := range f.channels {
@@ -101,14 +116,16 @@ func (f *Feed) rebuildMetaBlocks() {
blockCount = uint16(len(blocks))
}
meta.Channels = append(meta.Channels, protocol.ChannelInfo{
- Name: name,
- Blocks: blockCount,
- LastMsgID: f.lastIDs[chNum],
+ Name: name,
+ Blocks: blockCount,
+ LastMsgID: f.lastIDs[chNum],
+ ContentHash: f.contentHashes[chNum],
+ ChatType: f.chatTypes[chNum],
+ CanSend: f.canSend[chNum],
})
}
- data := protocol.SerializeMetadata(meta)
- f.metaBlocks = protocol.SplitIntoBlocks(data)
+ f.metaBlocks = protocol.SplitIntoBlocks(protocol.SerializeMetadata(&meta))
}
// ChannelNames returns the configured channel names.
@@ -119,3 +136,43 @@ func (f *Feed) ChannelNames() []string {
copy(result, f.channels)
return result
}
+
+// SetTelegramLoggedIn sets the flag indicating whether the server has a Telegram session.
+func (f *Feed) SetTelegramLoggedIn(loggedIn bool) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.telegramLoggedIn = loggedIn
+ f.rebuildMetaBlocks()
+}
+
+// SetNextFetch sets the unix timestamp of the next server-side fetch.
+func (f *Feed) SetNextFetch(ts uint32) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.nextFetch = ts
+ f.rebuildMetaBlocks()
+}
+
+// SetChatInfo stores the chat type and send capability for a channel.
+func (f *Feed) SetChatInfo(channelNum int, chatType protocol.ChatType, canSend bool) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.chatTypes[channelNum] = chatType
+ f.canSend[channelNum] = canSend
+ f.rebuildMetaBlocks()
+}
+
+// IsPrivateChannel returns true if the channel has chatType == ChatTypePrivate.
+func (f *Feed) IsPrivateChannel(channelNum int) bool {
+ f.mu.RLock()
+ defer f.mu.RUnlock()
+ return f.chatTypes[channelNum] == protocol.ChatTypePrivate
+}
+
+// SetChannels replaces the channel list and rebuilds metadata.
+func (f *Feed) SetChannels(channels []string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.channels = channels
+ f.rebuildMetaBlocks()
+}
diff --git a/internal/server/feed_test.go b/internal/server/feed_test.go
index 09ff433..f92a272 100644
--- a/internal/server/feed_test.go
+++ b/internal/server/feed_test.go
@@ -20,7 +20,12 @@ func TestFeedUpdateAndGetBlock(t *testing.T) {
if len(data) == 0 {
t.Error("block data should not be empty")
}
- parsed, err := protocol.ParseMessages(data)
+ // Data is now compressed — decompress + parse
+ decompressed, err := protocol.DecompressMessages(data)
+ if err != nil {
+ t.Fatalf("DecompressMessages: %v", err)
+ }
+ parsed, err := protocol.ParseMessages(decompressed)
if err != nil {
t.Fatalf("ParseMessages: %v", err)
}
@@ -71,20 +76,20 @@ func TestFeedGetBlockUnknownChannel(t *testing.T) {
func TestFeedLargeMessages(t *testing.T) {
feed := NewFeed([]string{"Test"})
- // Use text large enough to span 2 blocks at DefaultBlockPayload (currently 700 bytes).
- // Message serialization overhead is 10 bytes, so we need >690 bytes of text.
- largeText := make([]byte, 750)
+ // With compression, repetitive data compresses to ~1 block.
+ // Use varied text so compressed size still spans multiple blocks.
+ largeText := make([]byte, 1500)
for i := range largeText {
- largeText[i] = 65
+ largeText[i] = byte(i % 251) // pseudo-random pattern
}
msgs := []protocol.Message{{ID: 1, Timestamp: 1700000000, Text: string(largeText)}}
feed.UpdateChannel(1, msgs)
- _, err := feed.GetBlock(1, 0)
+ // Should have at least 1 block
+ data0, err := feed.GetBlock(1, 0)
if err != nil {
t.Fatalf("GetBlock(1, 0): %v", err)
}
- _, err = feed.GetBlock(1, 1)
- if err != nil {
- t.Fatalf("GetBlock(1, 1): %v", err)
+ if len(data0) == 0 {
+ t.Error("block data should not be empty")
}
}
diff --git a/internal/server/public.go b/internal/server/public.go
new file mode 100644
index 0000000..19c432e
--- /dev/null
+++ b/internal/server/public.go
@@ -0,0 +1,372 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/net/html"
+
+ "github.com/sartoopjj/thefeed/internal/protocol"
+)
+
+// PublicReader fetches recent posts from public Telegram channels via the web view.
+type PublicReader struct {
+ channels []string
+ feed *Feed
+ msgLimit int
+
+ client *http.Client
+ baseURL string
+
+ mu sync.RWMutex
+ cache map[string]cachedMessages
+ cacheTTL time.Duration
+}
+
+// NewPublicReader creates a reader for public channels without Telegram login.
+func NewPublicReader(channelUsernames []string, feed *Feed, msgLimit int) *PublicReader {
+ cleaned := make([]string, len(channelUsernames))
+ for i, u := range channelUsernames {
+ cleaned[i] = strings.TrimPrefix(strings.TrimSpace(u), "@")
+ }
+ if msgLimit <= 0 {
+ msgLimit = 15
+ }
+ return &PublicReader{
+ channels: cleaned,
+ feed: feed,
+ msgLimit: msgLimit,
+ client: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ baseURL: "https://t.me/s",
+ cache: make(map[string]cachedMessages),
+ cacheTTL: 10 * time.Minute,
+ }
+}
+
+// Run starts the periodic public-channel fetch loop.
+func (pr *PublicReader) Run(ctx context.Context) error {
+ pr.feed.SetTelegramLoggedIn(false)
+ pr.fetchAll(ctx)
+
+ ticker := time.NewTicker(5 * time.Minute)
+ defer ticker.Stop()
+ pr.feed.SetNextFetch(uint32(time.Now().Add(5 * time.Minute).Unix()))
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ticker.C:
+ pr.fetchAll(ctx)
+ pr.feed.SetNextFetch(uint32(time.Now().Add(5 * time.Minute).Unix()))
+ }
+ }
+}
+
+func (pr *PublicReader) fetchAll(ctx context.Context) {
+ for i, username := range pr.channels {
+ chNum := i + 1
+
+ pr.mu.RLock()
+ cached, ok := pr.cache[username]
+ pr.mu.RUnlock()
+ if ok && time.Since(cached.fetched) < pr.cacheTTL {
+ continue
+ }
+
+ msgs, err := pr.fetchChannel(ctx, username)
+ if err != nil {
+ log.Printf("[public] fetch %s: %v", username, err)
+ continue
+ }
+
+ // Merge new messages with previously cached ones to accumulate history.
+ if ok && len(cached.msgs) > 0 {
+ msgs = mergeMessages(cached.msgs, msgs)
+ }
+ if pr.msgLimit > 0 && len(msgs) > pr.msgLimit {
+ msgs = msgs[:pr.msgLimit]
+ }
+
+ pr.mu.Lock()
+ pr.cache[username] = cachedMessages{msgs: msgs, fetched: time.Now()}
+ pr.mu.Unlock()
+
+ pr.feed.UpdateChannel(chNum, msgs)
+ pr.feed.SetChatInfo(chNum, protocol.ChatTypeChannel, false)
+ log.Printf("[public] updated %s: %d messages", username, len(msgs))
+ }
+}
+
+func (pr *PublicReader) fetchChannel(ctx context.Context, username string) ([]protocol.Message, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, pr.baseURL+"/"+url.PathEscape(username), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; thefeed/1.0; +https://github.com/sartoopjj/thefeed)")
+
+ resp, err := pr.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ return parsePublicMessages(body)
+}
+
+type publicMessage struct {
+ id uint32
+ timestamp uint32
+ text string
+}
+
+// mergeMessages combines old cached messages with newly fetched ones.
+// New messages win on ID conflicts (edits). Result is sorted by ID descending.
+func mergeMessages(old, new []protocol.Message) []protocol.Message {
+ byID := make(map[uint32]protocol.Message, len(old)+len(new))
+ for _, m := range old {
+ byID[m.ID] = m
+ }
+ for _, m := range new {
+ byID[m.ID] = m // new overwrites old (edits)
+ }
+ merged := make([]protocol.Message, 0, len(byID))
+ for _, m := range byID {
+ merged = append(merged, m)
+ }
+ sort.Slice(merged, func(i, j int) bool {
+ return merged[i].ID > merged[j].ID
+ })
+ return merged
+}
+
+func parsePublicMessages(body []byte) ([]protocol.Message, error) {
+ doc, err := html.Parse(strings.NewReader(string(body)))
+ if err != nil {
+ return nil, fmt.Errorf("parse html: %w", err)
+ }
+
+ var collected []publicMessage
+ visitNodes(doc, func(n *html.Node) {
+ post := attrValue(n, "data-post")
+ if post == "" {
+ return
+ }
+ id, err := parsePostID(post)
+ if err != nil {
+ return
+ }
+ text := strings.TrimSpace(extractMessageText(findFirstByClass(n, "tgme_widget_message_text")))
+ mediaPrefix := ""
+ switch {
+ case findFirstByClass(n, "tgme_widget_message_photo_wrap") != nil:
+ mediaPrefix = protocol.MediaImage
+ case findFirstByClass(n, "tgme_widget_message_video_player") != nil ||
+ findFirstByClass(n, "tgme_widget_message_roundvideo_player") != nil:
+ mediaPrefix = protocol.MediaVideo
+ case findFirstByClass(n, "tgme_widget_message_sticker_wrap") != nil:
+ mediaPrefix = protocol.MediaSticker
+ case findFirstByClass(n, "tgme_widget_message_voice") != nil:
+ mediaPrefix = protocol.MediaAudio
+ case findFirstByClass(n, "tgme_widget_message_poll") != nil:
+ mediaPrefix = protocol.MediaPoll
+ case findFirstByClass(n, "tgme_widget_message_location_wrap") != nil ||
+ findFirstByClass(n, "tgme_widget_message_venue_wrap") != nil:
+ mediaPrefix = protocol.MediaLocation
+ case findFirstByClass(n, "tgme_widget_message_contact_wrap") != nil:
+ mediaPrefix = protocol.MediaContact
+ case findFirstByClass(n, "tgme_widget_message_document_wrap") != nil:
+ mediaPrefix = protocol.MediaFile
+ }
+ if mediaPrefix != "" {
+ if text != "" {
+ text = mediaPrefix + "\n" + text
+ } else {
+ text = mediaPrefix
+ }
+ }
+ if text == "" {
+ return
+ }
+ collected = append(collected, publicMessage{
+ id: id,
+ timestamp: extractMessageTimestamp(n),
+ text: text,
+ })
+ })
+
+ if len(collected) == 0 {
+ return nil, fmt.Errorf("no public messages found")
+ }
+
+ sort.Slice(collected, func(i, j int) bool {
+ return collected[i].id > collected[j].id
+ })
+
+ msgs := make([]protocol.Message, 0, len(collected))
+ for _, msg := range collected {
+ msgs = append(msgs, protocol.Message{ID: msg.id, Timestamp: msg.timestamp, Text: msg.text})
+ }
+ return msgs, nil
+}
+
+func visitNodes(n *html.Node, fn func(*html.Node)) {
+ if n == nil {
+ return
+ }
+ fn(n)
+ for child := n.FirstChild; child != nil; child = child.NextSibling {
+ visitNodes(child, fn)
+ }
+}
+
+func findFirstByClass(n *html.Node, class string) *html.Node {
+ var found *html.Node
+ visitNodes(n, func(cur *html.Node) {
+ if found != nil {
+ return
+ }
+ if hasClass(cur, class) {
+ found = cur
+ }
+ })
+ return found
+}
+
+func hasClass(n *html.Node, class string) bool {
+ if n == nil || n.Type != html.ElementNode {
+ return false
+ }
+ for _, attr := range n.Attr {
+ if attr.Key != "class" {
+ continue
+ }
+ for _, token := range strings.Fields(attr.Val) {
+ if token == class {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func attrValue(n *html.Node, key string) string {
+ if n == nil {
+ return ""
+ }
+ for _, attr := range n.Attr {
+ if attr.Key == key {
+ return attr.Val
+ }
+ }
+ return ""
+}
+
+func parsePostID(post string) (uint32, error) {
+ idx := strings.LastIndex(post, "/")
+ if idx == -1 || idx+1 >= len(post) {
+ return 0, fmt.Errorf("invalid post id")
+ }
+ id, err := strconv.ParseUint(post[idx+1:], 10, 32)
+ if err != nil {
+ return 0, err
+ }
+ return uint32(id), nil
+}
+
+func extractMessageTimestamp(n *html.Node) uint32 {
+ timeNode := findFirstByClass(n, "tgme_widget_message_date")
+ if timeNode == nil {
+ timeNode = findFirstElement(n, "time")
+ }
+ if timeNode == nil {
+ return uint32(time.Now().Unix())
+ }
+ datetime := attrValue(timeNode, "datetime")
+ if datetime == "" {
+ timeChild := findFirstElement(timeNode, "time")
+ datetime = attrValue(timeChild, "datetime")
+ }
+ if datetime == "" {
+ return uint32(time.Now().Unix())
+ }
+ ts, err := time.Parse(time.RFC3339, datetime)
+ if err != nil {
+ return uint32(time.Now().Unix())
+ }
+ return uint32(ts.Unix())
+}
+
+func findFirstElement(n *html.Node, tag string) *html.Node {
+ var found *html.Node
+ visitNodes(n, func(cur *html.Node) {
+ if found == nil && cur.Type == html.ElementNode && cur.Data == tag {
+ found = cur
+ }
+ })
+ return found
+}
+
+func extractMessageText(n *html.Node) string {
+ if n == nil {
+ return ""
+ }
+ var b strings.Builder
+ var walk func(*html.Node)
+ walk = func(cur *html.Node) {
+ if cur == nil {
+ return
+ }
+ if cur.Type == html.TextNode {
+ text := strings.TrimSpace(cur.Data)
+ if text != "" {
+ if b.Len() > 0 {
+ last := b.String()[b.Len()-1]
+ if last != '\n' && last != ' ' {
+ b.WriteByte(' ')
+ }
+ }
+ b.WriteString(text)
+ }
+ }
+ if cur.Type == html.ElementNode && cur.Data == "br" {
+ trimTrailingSpace(&b)
+ if b.Len() > 0 {
+ b.WriteByte('\n')
+ }
+ }
+ for child := cur.FirstChild; child != nil; child = child.NextSibling {
+ walk(child)
+ }
+ }
+ walk(n)
+ return strings.TrimSpace(strings.ReplaceAll(b.String(), " \n", "\n"))
+}
+
+func trimTrailingSpace(b *strings.Builder) {
+ s := b.String()
+ for len(s) > 0 && s[len(s)-1] == ' ' {
+ s = s[:len(s)-1]
+ }
+ b.Reset()
+ b.WriteString(s)
+}
diff --git a/internal/server/public_test.go b/internal/server/public_test.go
new file mode 100644
index 0000000..76e3a8d
--- /dev/null
+++ b/internal/server/public_test.go
@@ -0,0 +1,102 @@
+package server
+
+import (
+ "testing"
+
+ "github.com/sartoopjj/thefeed/internal/protocol"
+)
+
+func TestParsePublicMessages(t *testing.T) {
+ body := []byte(`
+
+
+ Server is running without Telegram login. Some features (sending messages, private chats) are unavailable.
+ To enable, restart the server with Telegram credentials.
+