mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 06:44:35 +03:00
feat: 🎉 first version
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
- name: Test
|
||||
run: go test -race -count=1 ./...
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
|
||||
- name: Build Server
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: '0'
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME:-dev}
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS="-s -w -X github.com/sartoopjj/thefeed/internal/version.Version=${VERSION} -X github.com/sartoopjj/thefeed/internal/version.Commit=${COMMIT} -X github.com/sartoopjj/thefeed/internal/version.Date=${DATE}"
|
||||
ext=""
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
|
||||
go build -trimpath -ldflags="${LDFLAGS}" -o build/thefeed-server-${{ matrix.goos }}-${{ matrix.goarch }}${ext} ./cmd/server
|
||||
|
||||
- name: Build Client
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: '0'
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME:-dev}
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS="-s -w -X github.com/sartoopjj/thefeed/internal/version.Version=${VERSION} -X github.com/sartoopjj/thefeed/internal/version.Commit=${COMMIT} -X github.com/sartoopjj/thefeed/internal/version.Date=${DATE}"
|
||||
ext=""
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
|
||||
go build -trimpath -ldflags="${LDFLAGS}" -o build/thefeed-client-${{ matrix.goos }}-${{ matrix.goarch }}${ext} ./cmd/client
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: build/
|
||||
|
||||
release:
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: artifacts/*
|
||||
generate_release_notes: true
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Build outputs
|
||||
/build/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Cache
|
||||
.thefeed/
|
||||
|
||||
# Session data
|
||||
session.json
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
@@ -0,0 +1,72 @@
|
||||
.PHONY: all build build-server build-client test clean lint fmt vet
|
||||
|
||||
BINARY_SERVER = thefeed-server
|
||||
BINARY_CLIENT = thefeed-client
|
||||
BUILD_DIR = build
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS = -s -w \
|
||||
-X github.com/sartoopjj/thefeed/internal/version.Version=$(VERSION) \
|
||||
-X github.com/sartoopjj/thefeed/internal/version.Commit=$(COMMIT) \
|
||||
-X github.com/sartoopjj/thefeed/internal/version.Date=$(DATE)
|
||||
|
||||
GOFLAGS = -trimpath -ldflags="$(LDFLAGS)"
|
||||
export CGO_ENABLED = 0
|
||||
|
||||
all: test build
|
||||
|
||||
build: build-server build-client
|
||||
|
||||
build-server:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER) ./cmd/server
|
||||
|
||||
build-client:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT) ./cmd/client
|
||||
|
||||
test:
|
||||
go test -race -count=1 ./...
|
||||
|
||||
lint: vet
|
||||
@command -v golangci-lint >/dev/null 2>&1 || echo "golangci-lint not found, skipping"
|
||||
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run ./... || true
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
gofmt -s -w .
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
# Cross-compilation targets
|
||||
build-all: build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-arm64 build-windows-amd64
|
||||
|
||||
build-linux-amd64:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-linux-amd64 ./cmd/server
|
||||
GOOS=linux GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-linux-amd64 ./cmd/client
|
||||
|
||||
build-linux-arm64:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-linux-arm64 ./cmd/server
|
||||
GOOS=linux GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-linux-arm64 ./cmd/client
|
||||
|
||||
build-darwin-amd64:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=darwin GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-darwin-amd64 ./cmd/server
|
||||
GOOS=darwin GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-darwin-amd64 ./cmd/client
|
||||
|
||||
build-darwin-arm64:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-darwin-arm64 ./cmd/server
|
||||
GOOS=darwin GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-darwin-arm64 ./cmd/client
|
||||
|
||||
build-windows-amd64:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=windows GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-windows-amd64.exe ./cmd/server
|
||||
GOOS=windows GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-windows-amd64.exe ./cmd/client
|
||||
@@ -0,0 +1,249 @@
|
||||
# thefeed
|
||||
|
||||
DNS-based feed reader for Telegram channels. Designed for environments where only DNS queries work.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌──────────────┐ DNS TXT Query ┌──────────────┐ MTProto ┌──────────┐
|
||||
│ Client │ ──────────────────────▸ │ Server │ ──────────────▸ │ Telegram │
|
||||
│ (TUI app) │ ◂────────────────────── │ (DNS auth) │ ◂────────────── │ API │
|
||||
└──────────────┘ Encrypted TXT └──────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
**Server** (runs outside censored network):
|
||||
- Connects to Telegram, reads messages from configured channels
|
||||
- Serves feed data as encrypted DNS TXT responses
|
||||
- Random padding on responses to vary size (anti-DPI)
|
||||
- Session persistence — login once, run forever
|
||||
|
||||
**Client** (runs inside censored network):
|
||||
- Sends encrypted DNS TXT queries via available resolvers
|
||||
- Single-label base32 encoding (stealthier) or double-label hex
|
||||
- Rate limiting to respect resolver limits
|
||||
- TUI with RTL/Farsi support, log panel showing DNS queries
|
||||
- Built-in resolver scanner (file with IPs/CIDRs or single CIDR)
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
## Quick Install (Server)
|
||||
|
||||
```bash
|
||||
# One-line install (downloads latest release from GitHub)
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/sartoopjj/thefeed/main/scripts/install.sh)
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
# On your server (Linux with systemd)
|
||||
curl -Ls https://raw.githubusercontent.com/sartoopjj/thefeed/main/scripts/install.sh -o install.sh
|
||||
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
|
||||
|
||||
Update: `sudo bash install.sh` (detects existing config, only updates binary)
|
||||
Re-login: `sudo bash install.sh --login`
|
||||
Uninstall: `sudo bash install.sh --uninstall`
|
||||
|
||||
## Manual Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.26+
|
||||
- Telegram API credentials from https://my.telegram.org
|
||||
- A domain with NS records pointing to your server
|
||||
|
||||
### Server
|
||||
|
||||
```bash
|
||||
# Build
|
||||
make build-server
|
||||
|
||||
# First run: login to Telegram and save session
|
||||
./build/thefeed-server \
|
||||
--login-only \
|
||||
--domain t.example.com \
|
||||
--key "your-secret-passphrase" \
|
||||
--channels configs/channels.txt \
|
||||
--api-id 12345 \
|
||||
--api-hash "your-api-hash" \
|
||||
--phone "+1234567890" \
|
||||
--session session.json
|
||||
|
||||
# Normal run (uses saved session)
|
||||
./build/thefeed-server \
|
||||
--domain t.example.com \
|
||||
--key "your-secret-passphrase" \
|
||||
--channels configs/channels.txt \
|
||||
--api-id 12345 \
|
||||
--api-hash "your-api-hash" \
|
||||
--phone "+1234567890" \
|
||||
--session session.json \
|
||||
--listen ":5300"
|
||||
```
|
||||
|
||||
Environment variables: `THEFEED_DOMAIN`, `THEFEED_KEY`, `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_PHONE`, `TELEGRAM_PASSWORD`
|
||||
|
||||
#### Server Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--domain` | | DNS domain (required) |
|
||||
| `--key` | | Encryption passphrase (required) |
|
||||
| `--channels` | `channels.txt` | Path to channels file |
|
||||
| `--api-id` | | Telegram API ID (required) |
|
||||
| `--api-hash` | | Telegram API Hash (required) |
|
||||
| `--phone` | | Telegram phone number (required) |
|
||||
| `--session` | `session.json` | Path to Telegram session file |
|
||||
| `--login-only` | `false` | Authenticate to Telegram, save session, exit |
|
||||
| `--listen` | `:5300` | DNS listen address |
|
||||
| `--padding` | `32` | Max random padding bytes (0=disabled) |
|
||||
| `--version` | | Show version and exit |
|
||||
|
||||
### Client
|
||||
|
||||
```bash
|
||||
# Build
|
||||
make build-client
|
||||
|
||||
# Basic usage
|
||||
./build/thefeed-client \
|
||||
--domain t.example.com \
|
||||
--key "your-secret-passphrase" \
|
||||
--resolvers "8.8.8.8,1.1.1.1"
|
||||
|
||||
# With resolver scanning from file
|
||||
./build/thefeed-client \
|
||||
--domain t.example.com \
|
||||
--key "your-secret-passphrase" \
|
||||
--scan configs/resolvers.txt \
|
||||
--rate 5
|
||||
|
||||
# Scan a CIDR range
|
||||
./build/thefeed-client \
|
||||
--domain t.example.com \
|
||||
--key "your-secret-passphrase" \
|
||||
--scan "8.8.8.0/24" \
|
||||
--resolvers configs/resolvers.txt
|
||||
```
|
||||
|
||||
#### Client Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--domain` | | DNS domain (required) |
|
||||
| `--key` | | Encryption passphrase (required) |
|
||||
| `--resolvers` | | Comma-separated IPs or path to file |
|
||||
| `--scan` | | File with IPs/CIDRs or single CIDR to scan |
|
||||
| `--scan-workers` | `50` | Concurrent scanner workers |
|
||||
| `--rate` | `0` | Max DNS queries/sec (0=unlimited) |
|
||||
| `--query-mode` | `single` | `single` (base32) or `double` (hex) |
|
||||
| `--cache` | `~/.thefeed/cache` | Cache directory |
|
||||
| `--version` | | Show version and exit |
|
||||
|
||||
### TUI Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Tab` / `←` / `→` | Cycle panels (channels → messages → log) |
|
||||
| `j` / `k` / `↑` / `↓` | Navigate up/down |
|
||||
| `r` | Refresh feed |
|
||||
| `PgUp` / `PgDn` | Scroll content |
|
||||
| `q` / `Ctrl+C` | Quit |
|
||||
|
||||
The TUI has three panels:
|
||||
- **Channels** (left): channel list with selection
|
||||
- **Messages** (right): messages with RTL/Farsi support
|
||||
- **Log** (bottom): DNS queries being sent (debug)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make test # Run tests
|
||||
make build # Build both binaries
|
||||
make build-all # Cross-compile all platforms
|
||||
make vet # Go vet
|
||||
make fmt # Format code
|
||||
make clean # Remove build artifacts
|
||||
```
|
||||
|
||||
## DNS Setup
|
||||
|
||||
1. Register a domain (e.g., `example.com`)
|
||||
2. Add NS record: `t.example.com NS your-server-ip`
|
||||
3. Or add a glue record pointing `ns.example.com` to your server IP, then `t.example.com NS ns.example.com`
|
||||
4. Run the server on port 53 (or 5300 and redirect with iptables)
|
||||
|
||||
## channels.txt Format
|
||||
|
||||
```
|
||||
# Comments start with #
|
||||
@VahidOnline
|
||||
@kianmeli1
|
||||
```
|
||||
|
||||
## Resolver File Format
|
||||
|
||||
```
|
||||
# One IP or CIDR per line
|
||||
8.8.8.8
|
||||
1.1.1.1
|
||||
9.9.9.9
|
||||
208.67.222.0/24
|
||||
```
|
||||
|
||||
## 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
|
||||
- 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
|
||||
|
||||
## Service Management
|
||||
|
||||
```bash
|
||||
# After install.sh
|
||||
systemctl status thefeed-server
|
||||
systemctl restart thefeed-server
|
||||
journalctl -u thefeed-server -f
|
||||
|
||||
# Update channels
|
||||
sudo vi /etc/thefeed/channels.txt
|
||||
sudo systemctl restart thefeed-server
|
||||
|
||||
# Update binary
|
||||
cd thefeed && git pull && sudo bash scripts/install.sh
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/client"
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
"github.com/sartoopjj/thefeed/internal/tui"
|
||||
"github.com/sartoopjj/thefeed/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
domain := flag.String("domain", "", "DNS domain (e.g., t.example.com)")
|
||||
key := flag.String("key", "", "Encryption passphrase")
|
||||
resolvers := flag.String("resolvers", "", "Comma-separated resolver IPs or path to resolvers file")
|
||||
scanPath := flag.String("scan", "", "File with IPs/CIDRs to scan for resolvers, or a single CIDR (e.g., 8.8.8.0/24)")
|
||||
cacheDir := flag.String("cache", "", "Cache directory (default: ~/.thefeed/cache)")
|
||||
scanWorkers := flag.Int("scan-workers", 50, "Concurrent scanner workers")
|
||||
rateLimit := flag.Float64("rate", 0, "Max DNS queries per second (0 = unlimited)")
|
||||
queryMode := flag.String("query-mode", "single", "DNS query encoding: single (base32) or double (hex)")
|
||||
showVersion := flag.Bool("version", false, "Show version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("thefeed-client %s (commit: %s, built: %s)\n", version.Version, version.Commit, version.Date)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *domain == "" {
|
||||
*domain = os.Getenv("THEFEED_DOMAIN")
|
||||
}
|
||||
if *key == "" {
|
||||
*key = os.Getenv("THEFEED_KEY")
|
||||
}
|
||||
|
||||
if *domain == "" || *key == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --domain and --key are required")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *cacheDir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Fatalf("Get home dir: %v", err)
|
||||
}
|
||||
*cacheDir = filepath.Join(home, ".thefeed", "cache")
|
||||
}
|
||||
|
||||
cache, err := client.NewCache(*cacheDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Create cache: %v", err)
|
||||
}
|
||||
|
||||
resolverList := parseResolvers(*resolvers)
|
||||
|
||||
fetcher, err := client.NewFetcher(*domain, *key, resolverList)
|
||||
if err != nil {
|
||||
log.Fatalf("Create fetcher: %v", err)
|
||||
}
|
||||
|
||||
// Set query encoding mode
|
||||
if *queryMode == "double" {
|
||||
fetcher.SetQueryMode(protocol.QueryDoubleLabel)
|
||||
}
|
||||
|
||||
// Set rate limit
|
||||
if *rateLimit > 0 {
|
||||
fetcher.SetRateLimit(*rateLimit)
|
||||
fmt.Printf("Rate limit: %.1f queries/sec\n", *rateLimit)
|
||||
}
|
||||
|
||||
// Scan for resolvers (supports file with IPs/CIDRs or a single CIDR)
|
||||
if *scanPath != "" {
|
||||
var mu sync.Mutex
|
||||
var found []string
|
||||
|
||||
scanner := client.NewResolverScanner(fetcher, *scanWorkers)
|
||||
|
||||
// Check if it's a file
|
||||
if _, statErr := os.Stat(*scanPath); statErr == nil {
|
||||
fmt.Printf("Scanning resolvers from file %s...\n", *scanPath)
|
||||
err := scanner.ScanFile(*scanPath, func(ip string) {
|
||||
mu.Lock()
|
||||
found = append(found, ip)
|
||||
mu.Unlock()
|
||||
fmt.Printf(" Found: %s\n", ip)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Scan warning: %v\n", err)
|
||||
}
|
||||
} else if strings.Contains(*scanPath, "/") {
|
||||
// Treat as CIDR
|
||||
fmt.Printf("Scanning %s for DNS resolvers...\n", *scanPath)
|
||||
err := scanner.ScanCIDR(*scanPath, func(ip string) {
|
||||
mu.Lock()
|
||||
found = append(found, ip)
|
||||
mu.Unlock()
|
||||
fmt.Printf(" Found: %s\n", ip)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Scan warning: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: --scan value %q is not a file or CIDR\n", *scanPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(found) > 0 {
|
||||
all := append(resolverList, found...)
|
||||
fetcher.SetResolvers(all)
|
||||
fmt.Printf("Using %d resolvers\n", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
if len(fetcher.Resolvers()) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error: no resolvers available. Use --resolvers or --scan")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := tui.Run(fetcher, cache); err != nil {
|
||||
log.Fatalf("TUI error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseResolvers(input string) []string {
|
||||
if input == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(input); err == nil {
|
||||
resolvers, err := client.LoadResolversFile(input)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not load resolvers file %s: %v", input, err)
|
||||
} else {
|
||||
return resolvers
|
||||
}
|
||||
}
|
||||
|
||||
var resolvers []string
|
||||
for _, r := range strings.Split(input, ",") {
|
||||
r = strings.TrimSpace(r)
|
||||
if r != "" {
|
||||
resolvers = append(resolvers, r)
|
||||
}
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/server"
|
||||
"github.com/sartoopjj/thefeed/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", ":5300", "DNS listen address (host:port)")
|
||||
domain := flag.String("domain", "", "DNS domain (e.g., t.example.com)")
|
||||
key := flag.String("key", "", "Encryption passphrase")
|
||||
channelsFile := flag.String("channels", "channels.txt", "Path to channels file")
|
||||
apiID := flag.String("api-id", "", "Telegram API ID")
|
||||
apiHash := flag.String("api-hash", "", "Telegram API Hash")
|
||||
phone := flag.String("phone", "", "Telegram phone number")
|
||||
loginOnly := flag.Bool("login-only", false, "Authenticate to Telegram, save session, and exit")
|
||||
sessionPath := flag.String("session", "session.json", "Path to Telegram session file")
|
||||
maxPadding := flag.Int("padding", 32, "Max random padding bytes in DNS responses (anti-DPI, 0=disabled)")
|
||||
showVersion := flag.Bool("version", false, "Show version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("thefeed-server %s (commit: %s, built: %s)\n", version.Version, version.Commit, version.Date)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *domain == "" {
|
||||
*domain = os.Getenv("THEFEED_DOMAIN")
|
||||
}
|
||||
if *key == "" {
|
||||
*key = os.Getenv("THEFEED_KEY")
|
||||
}
|
||||
if *apiID == "" {
|
||||
*apiID = os.Getenv("TELEGRAM_API_ID")
|
||||
}
|
||||
if *apiHash == "" {
|
||||
*apiHash = os.Getenv("TELEGRAM_API_HASH")
|
||||
}
|
||||
if *phone == "" {
|
||||
*phone = os.Getenv("TELEGRAM_PHONE")
|
||||
}
|
||||
|
||||
if *domain == "" || *key == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --domain and --key are required")
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
password := os.Getenv("TELEGRAM_PASSWORD")
|
||||
if password == "" {
|
||||
hasSession := false
|
||||
if info, statErr := os.Stat(*sessionPath); statErr == nil && info.Size() > 0 {
|
||||
hasSession = true
|
||||
}
|
||||
if *loginOnly || !hasSession {
|
||||
fmt.Print("Telegram 2FA password (press Enter if none): ")
|
||||
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
if err == nil && len(pwBytes) > 0 {
|
||||
password = string(pwBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg := server.Config{
|
||||
ListenAddr: *listen,
|
||||
Domain: *domain,
|
||||
Passphrase: *key,
|
||||
ChannelsFile: *channelsFile,
|
||||
MaxPadding: *maxPadding,
|
||||
Telegram: server.TelegramConfig{
|
||||
APIID: id,
|
||||
APIHash: *apiHash,
|
||||
Phone: *phone,
|
||||
Password: password,
|
||||
SessionPath: *sessionPath,
|
||||
LoginOnly: *loginOnly,
|
||||
CodePrompt: func(ctx context.Context) (string, error) {
|
||||
fmt.Print("Enter Telegram auth code: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
code, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(code), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
srv, err := server.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Create server: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
log.Printf("Starting thefeed server %s on %s for domain %s", version.Version, cfg.ListenAddr, cfg.Domain)
|
||||
if err := srv.Run(ctx); err != nil && ctx.Err() == nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
log.Println("Server stopped")
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Telegram channel usernames (one per line, with or without @)
|
||||
# Channel numbers are assigned in order: first = channel 1, second = channel 2, etc.
|
||||
# Lines starting with # are comments
|
||||
@VahidOnline
|
||||
@iliaen
|
||||
@IranintlTV
|
||||
@ircfspace
|
||||
@wiki_tajrobe
|
||||
@MatinSenPaii
|
||||
@jadivarlog
|
||||
@mahsa_net
|
||||
@mahsa_alert
|
||||
@@ -0,0 +1,39 @@
|
||||
# DNS resolver IPs (one per line)
|
||||
# Supports single IPs and CIDR notation
|
||||
# Lines starting with # are comments
|
||||
|
||||
# Mci & Rightel
|
||||
|
||||
2.188.21.120
|
||||
2.188.21.240
|
||||
2.188.21.230
|
||||
2.188.21.90
|
||||
2.188.21.190
|
||||
2.188.21.20
|
||||
2.188.21.100
|
||||
|
||||
# Irancell
|
||||
|
||||
94.183.126.175
|
||||
94.183.124.45
|
||||
37.202.225.135
|
||||
37.202.225.137
|
||||
87.248.130.22
|
||||
193.84.255.67
|
||||
185.24.253.8
|
||||
188.213.65.54
|
||||
|
||||
# Cloudflare
|
||||
1.1.1.1
|
||||
1.0.0.1
|
||||
|
||||
# Google
|
||||
8.8.8.8
|
||||
8.8.4.4
|
||||
|
||||
# Quad9
|
||||
9.9.9.9
|
||||
|
||||
# OpenDNS
|
||||
208.67.222.222
|
||||
208.67.220.220
|
||||
@@ -0,0 +1,67 @@
|
||||
module github.com/sartoopjj/thefeed
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/gotd/td v0.142.0
|
||||
github.com/miekg/dns v1.1.72
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/term v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-faster/jx v1.2.0 // indirect
|
||||
github.com/go-faster/xor v1.0.0 // indirect
|
||||
github.com/go-faster/yaml v0.4.6 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gotd/ige v0.2.2 // indirect
|
||||
github.com/gotd/neo v0.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ogen-go/ogen v1.19.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=
|
||||
github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=
|
||||
github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
|
||||
github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
|
||||
github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
|
||||
github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=
|
||||
github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
|
||||
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
|
||||
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
|
||||
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
|
||||
github.com/gotd/td v0.142.0 h1:hsH8zM7Pv98CkSMvrAEzVJurhntUziqKgf4VEofv5Zg=
|
||||
github.com/gotd/td v0.142.0/go.mod h1:UHO5Gpwce9mH4zplp2qWo6AdzDjFVg7gK+ANMCztsi8=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ogen-go/ogen v1.19.0 h1:YvdNpeQJ8A8dLLpS6Vs4WxXL53BT6tBPxH0VSjfALhA=
|
||||
github.com/ogen-go/ogen v1.19.0/go.mod h1:DeShwO+TEpLYXNCuZliSAedphphXsJaTGGbmSomWUjE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
@@ -0,0 +1,122 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
)
|
||||
|
||||
// Cache provides file-based caching for channel data.
|
||||
type Cache struct {
|
||||
dir string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type cachedChannel struct {
|
||||
Messages []protocol.Message `json:"messages"`
|
||||
FetchedAt int64 `json:"fetched_at"`
|
||||
}
|
||||
|
||||
type cachedMeta struct {
|
||||
Metadata *protocol.Metadata `json:"metadata"`
|
||||
FetchedAt int64 `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// NewCache creates a file cache in the given directory.
|
||||
func NewCache(dir string) (*Cache, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create cache dir: %w", err)
|
||||
}
|
||||
return &Cache{dir: dir}, nil
|
||||
}
|
||||
|
||||
// GetMessages returns cached messages for a channel, or nil if expired.
|
||||
func (c *Cache) GetMessages(channelNum int, maxAge time.Duration) []protocol.Message {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
path := c.channelPath(channelNum)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cached cachedChannel
|
||||
if err := json.Unmarshal(data, &cached); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if maxAge > 0 && time.Since(time.Unix(cached.FetchedAt, 0)) > maxAge {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cached.Messages
|
||||
}
|
||||
|
||||
// PutMessages stores messages for a channel.
|
||||
func (c *Cache) PutMessages(channelNum int, msgs []protocol.Message) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
cached := cachedChannel{
|
||||
Messages: msgs,
|
||||
FetchedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cached)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(c.channelPath(channelNum), data, 0600)
|
||||
}
|
||||
|
||||
// GetMetadata returns cached metadata, or nil if expired.
|
||||
func (c *Cache) GetMetadata(maxAge time.Duration) *protocol.Metadata {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
path := filepath.Join(c.dir, "metadata.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cached cachedMeta
|
||||
if err := json.Unmarshal(data, &cached); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if maxAge > 0 && time.Since(time.Unix(cached.FetchedAt, 0)) > maxAge {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cached.Metadata
|
||||
}
|
||||
|
||||
// PutMetadata stores metadata.
|
||||
func (c *Cache) PutMetadata(meta *protocol.Metadata) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
cached := cachedMeta{
|
||||
Metadata: meta,
|
||||
FetchedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cached)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filepath.Join(c.dir, "metadata.json"), data, 0600)
|
||||
}
|
||||
|
||||
func (c *Cache) channelPath(channelNum int) string {
|
||||
return filepath.Join(c.dir, fmt.Sprintf("channel_%d.json", channelNum))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
)
|
||||
|
||||
func TestCacheMessages(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cache, err := NewCache(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCache: %v", err)
|
||||
}
|
||||
msgs := []protocol.Message{
|
||||
{ID: 1, Timestamp: 1700000000, Text: "Hello"},
|
||||
{ID: 2, Timestamp: 1700000060, Text: "World"},
|
||||
}
|
||||
if err := cache.PutMessages(1, msgs); err != nil {
|
||||
t.Fatalf("PutMessages: %v", err)
|
||||
}
|
||||
cached := cache.GetMessages(1, 1*time.Hour)
|
||||
if cached == nil {
|
||||
t.Fatal("expected cached messages")
|
||||
}
|
||||
if len(cached) != 2 {
|
||||
t.Fatalf("got %d messages, want 2", len(cached))
|
||||
}
|
||||
if cached[0].Text != "Hello" || cached[1].Text != "World" {
|
||||
t.Error("cached message text mismatch")
|
||||
}
|
||||
if cache.GetMessages(2, 1*time.Hour) != nil {
|
||||
t.Error("expected nil for uncached channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cache, err := NewCache(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := &protocol.Metadata{
|
||||
Marker: [3]byte{1, 2, 3},
|
||||
Timestamp: 1700000000,
|
||||
Channels: []protocol.ChannelInfo{
|
||||
{Name: "test", Blocks: 5, LastMsgID: 100},
|
||||
},
|
||||
}
|
||||
if err := cache.PutMetadata(meta); err != nil {
|
||||
t.Fatalf("PutMetadata: %v", err)
|
||||
}
|
||||
cached := cache.GetMetadata(1 * time.Hour)
|
||||
if cached == nil {
|
||||
t.Fatal("expected cached metadata")
|
||||
}
|
||||
if cached.Timestamp != 1700000000 {
|
||||
t.Errorf("timestamp: got %d, want 1700000000", cached.Timestamp)
|
||||
}
|
||||
if len(cached.Channels) != 1 || cached.Channels[0].Name != "test" {
|
||||
t.Error("metadata channel mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheDirCreation(t *testing.T) {
|
||||
dir := t.TempDir() + "/sub/dir"
|
||||
_, err := NewCache(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCache should create dirs: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
t.Error("cache dir should be created")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
)
|
||||
|
||||
// LogFunc is a callback for logging DNS queries (for debug/TUI).
|
||||
type LogFunc func(msg string)
|
||||
|
||||
// Fetcher fetches feed blocks over DNS.
|
||||
type Fetcher struct {
|
||||
domain string
|
||||
queryKey [protocol.KeySize]byte
|
||||
responseKey [protocol.KeySize]byte
|
||||
queryMode protocol.QueryEncoding
|
||||
|
||||
mu sync.RWMutex
|
||||
resolvers []string
|
||||
timeout time.Duration
|
||||
|
||||
// Rate limiting
|
||||
rateMu sync.Mutex
|
||||
queryDelay time.Duration
|
||||
lastQuery time.Time
|
||||
|
||||
// Debug logging
|
||||
logFunc LogFunc
|
||||
}
|
||||
|
||||
// NewFetcher creates a new DNS block fetcher.
|
||||
func NewFetcher(domain, passphrase string, resolvers []string) (*Fetcher, error) {
|
||||
qk, rk, err := protocol.DeriveKeys(passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive keys: %w", err)
|
||||
}
|
||||
|
||||
return &Fetcher{
|
||||
domain: strings.TrimSuffix(domain, "."),
|
||||
queryKey: qk,
|
||||
responseKey: rk,
|
||||
queryMode: protocol.QuerySingleLabel,
|
||||
resolvers: resolvers,
|
||||
timeout: 5 * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetRateLimit sets the maximum queries per second (0 = unlimited).
|
||||
func (f *Fetcher) SetRateLimit(qps float64) {
|
||||
if qps <= 0 {
|
||||
f.queryDelay = 0
|
||||
return
|
||||
}
|
||||
f.queryDelay = time.Duration(float64(time.Second) / qps)
|
||||
}
|
||||
|
||||
// SetLogFunc sets the debug log callback.
|
||||
func (f *Fetcher) SetLogFunc(fn LogFunc) {
|
||||
f.logFunc = fn
|
||||
}
|
||||
|
||||
// SetQueryMode sets the DNS query encoding mode.
|
||||
func (f *Fetcher) SetQueryMode(mode protocol.QueryEncoding) {
|
||||
f.queryMode = mode
|
||||
}
|
||||
|
||||
func (f *Fetcher) log(format string, args ...any) {
|
||||
if f.logFunc != nil {
|
||||
f.logFunc(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) rateWait() {
|
||||
if f.queryDelay <= 0 {
|
||||
return
|
||||
}
|
||||
f.rateMu.Lock()
|
||||
defer f.rateMu.Unlock()
|
||||
elapsed := time.Since(f.lastQuery)
|
||||
if elapsed < f.queryDelay {
|
||||
time.Sleep(f.queryDelay - elapsed)
|
||||
}
|
||||
f.lastQuery = time.Now()
|
||||
}
|
||||
|
||||
// SetResolvers replaces the resolver list.
|
||||
func (f *Fetcher) SetResolvers(resolvers []string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.resolvers = resolvers
|
||||
}
|
||||
|
||||
// Resolvers returns the current resolver list.
|
||||
func (f *Fetcher) Resolvers() []string {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
result := make([]string, len(f.resolvers))
|
||||
copy(result, f.resolvers)
|
||||
return result
|
||||
}
|
||||
|
||||
// FetchBlock fetches a single block from a channel.
|
||||
func (f *Fetcher) FetchBlock(channel, block uint16) ([]byte, error) {
|
||||
f.rateWait()
|
||||
|
||||
qname, err := protocol.EncodeQuery(f.queryKey, channel, block, f.domain, f.queryMode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode query: %w", err)
|
||||
}
|
||||
|
||||
f.log("Q ch=%d blk=%d → %s", channel, block, qname)
|
||||
|
||||
resolvers := f.Resolvers()
|
||||
if len(resolvers) == 0 {
|
||||
return nil, fmt.Errorf("no resolvers configured")
|
||||
}
|
||||
|
||||
// Shuffle resolvers to distribute load
|
||||
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 {
|
||||
data, err := f.queryResolver(resolver, qname)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all resolvers failed, last error: %w", lastErr)
|
||||
}
|
||||
|
||||
// FetchMetadata fetches and parses metadata (channel 0).
|
||||
func (f *Fetcher) FetchMetadata() (*protocol.Metadata, error) {
|
||||
data, err := f.FetchBlock(protocol.MetadataChannel, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch metadata block 0: %w", err)
|
||||
}
|
||||
|
||||
meta, err := protocol.ParseMetadata(data)
|
||||
if err == nil {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// Metadata might span multiple blocks
|
||||
allData := make([]byte, len(data))
|
||||
copy(allData, data)
|
||||
|
||||
for blk := uint16(1); blk < 10; blk++ {
|
||||
block, fetchErr := f.FetchBlock(protocol.MetadataChannel, blk)
|
||||
if fetchErr != nil {
|
||||
break
|
||||
}
|
||||
allData = append(allData, block...)
|
||||
meta, parseErr := protocol.ParseMetadata(allData)
|
||||
if parseErr == nil {
|
||||
return meta, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("could not parse metadata: %w", err)
|
||||
}
|
||||
|
||||
// FetchChannel fetches all blocks for a channel and parses messages.
|
||||
func (f *Fetcher) FetchChannel(channelNum int, blockCount int) ([]protocol.Message, error) {
|
||||
if blockCount <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type result struct {
|
||||
idx int
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
results := make(chan result, blockCount)
|
||||
// Limit concurrency to 3 to reduce DNS burst traffic
|
||||
sem := make(chan struct{}, 3)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < blockCount; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
data, err := f.FetchBlock(uint16(channelNum), uint16(idx))
|
||||
results <- result{idx: idx, data: data, err: err}
|
||||
}(i)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
ordered := make([][]byte, blockCount)
|
||||
for r := range results {
|
||||
if r.err != nil {
|
||||
return nil, fmt.Errorf("fetch block %d: %w", r.idx, r.err)
|
||||
}
|
||||
ordered[r.idx] = r.data
|
||||
}
|
||||
|
||||
var allData []byte
|
||||
for _, block := range ordered {
|
||||
allData = append(allData, block...)
|
||||
}
|
||||
|
||||
return protocol.ParseMessages(allData)
|
||||
}
|
||||
|
||||
func (f *Fetcher) queryResolver(resolver, qname string) ([]byte, error) {
|
||||
if !strings.Contains(resolver, ":") {
|
||||
resolver = resolver + ":53"
|
||||
}
|
||||
|
||||
c := new(dns.Client)
|
||||
c.Timeout = f.timeout
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(qname), dns.TypeTXT)
|
||||
m.RecursionDesired = true
|
||||
|
||||
resp, _, err := c.Exchange(m, resolver)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dns exchange with %s: %w", resolver, err)
|
||||
}
|
||||
|
||||
if resp.Rcode != dns.RcodeSuccess {
|
||||
return nil, fmt.Errorf("dns error from %s: %s", resolver, dns.RcodeToString[resp.Rcode])
|
||||
}
|
||||
|
||||
for _, ans := range resp.Answer {
|
||||
if txt, ok := ans.(*dns.TXT); ok {
|
||||
encoded := strings.Join(txt.Txt, "")
|
||||
return protocol.DecodeResponse(f.responseKey, encoded)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no TXT record in response from %s", resolver)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResolverScanner scans CIDR ranges to find working DNS resolvers.
|
||||
type ResolverScanner struct {
|
||||
fetcher *Fetcher
|
||||
concurrency int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewResolverScanner creates a resolver scanner.
|
||||
func NewResolverScanner(fetcher *Fetcher, concurrency int) *ResolverScanner {
|
||||
if concurrency <= 0 {
|
||||
concurrency = 50
|
||||
}
|
||||
return &ResolverScanner{
|
||||
fetcher: fetcher,
|
||||
concurrency: concurrency,
|
||||
timeout: 3 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// ScanCIDR scans a CIDR range for working DNS resolvers.
|
||||
func (rs *ResolverScanner) ScanCIDR(cidr string, onFound func(ip string)) error {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse CIDR %q: %w", cidr, err)
|
||||
}
|
||||
|
||||
ips := expandCIDR(ipNet)
|
||||
return rs.scanIPs(ips, onFound)
|
||||
}
|
||||
|
||||
// ScanFile scans resolver IPs from a file (one per line, supports CIDR notation).
|
||||
func (rs *ResolverScanner) ScanFile(path string, onFound func(ip string)) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var ips []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "/") {
|
||||
_, ipNet, err := net.ParseCIDR(line)
|
||||
if err != nil {
|
||||
log.Printf("[resolver] skip invalid CIDR: %s", line)
|
||||
continue
|
||||
}
|
||||
ips = append(ips, expandCIDR(ipNet)...)
|
||||
} else {
|
||||
ips = append(ips, line)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rs.scanIPs(ips, onFound)
|
||||
}
|
||||
|
||||
// CheckResolver tests if a single resolver works by querying metadata.
|
||||
func (rs *ResolverScanner) CheckResolver(ip string) bool {
|
||||
if !strings.Contains(ip, ":") {
|
||||
ip = ip + ":53"
|
||||
}
|
||||
|
||||
// Create a new fetcher with only this resolver to avoid copying the lock.
|
||||
tmpFetcher := &Fetcher{
|
||||
domain: rs.fetcher.domain,
|
||||
queryKey: rs.fetcher.queryKey,
|
||||
responseKey: rs.fetcher.responseKey,
|
||||
resolvers: []string{ip},
|
||||
timeout: rs.timeout,
|
||||
}
|
||||
|
||||
_, err := tmpFetcher.FetchBlock(0, 0)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (rs *ResolverScanner) scanIPs(ips []string, onFound func(ip string)) error {
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("no IPs to scan")
|
||||
}
|
||||
|
||||
var found atomic.Int32
|
||||
sem := make(chan struct{}, rs.concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, ip := range ips {
|
||||
wg.Add(1)
|
||||
go func(ip string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
if rs.CheckResolver(ip) {
|
||||
found.Add(1)
|
||||
if onFound != nil {
|
||||
onFound(ip)
|
||||
}
|
||||
}
|
||||
}(ip)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if found.Load() == 0 {
|
||||
return fmt.Errorf("no working resolvers found among %d IPs", len(ips))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadResolversFile loads resolver IPs from a file (one per line).
|
||||
func LoadResolversFile(path string) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var resolvers []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
resolvers = append(resolvers, line)
|
||||
}
|
||||
return resolvers, scanner.Err()
|
||||
}
|
||||
|
||||
func expandCIDR(ipNet *net.IPNet) []string {
|
||||
var ips []string
|
||||
ip := ipNet.IP.Mask(ipNet.Mask)
|
||||
|
||||
for ip := cloneIP(ip); ipNet.Contains(ip); incIP(ip) {
|
||||
// Skip network and broadcast addresses for /24 and smaller
|
||||
ones, bits := ipNet.Mask.Size()
|
||||
if bits-ones <= 8 {
|
||||
last := ip[len(ip)-1]
|
||||
if last == 0 || last == 255 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
ips = append(ips, ip.String())
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
func cloneIP(ip net.IP) net.IP {
|
||||
dup := make(net.IP, len(ip))
|
||||
copy(dup, ip)
|
||||
return dup
|
||||
}
|
||||
|
||||
func incIP(ip net.IP) {
|
||||
for j := len(ip) - 1; j >= 0; j-- {
|
||||
ip[j]++
|
||||
if ip[j] > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
const (
|
||||
KeySize = 32 // AES-256
|
||||
NonceSize = 12 // GCM nonce
|
||||
)
|
||||
|
||||
// DeriveKeys derives separate query and response AES-256 keys from a passphrase using HKDF.
|
||||
func DeriveKeys(passphrase string) (queryKey, responseKey [KeySize]byte, err error) {
|
||||
master := sha256.Sum256([]byte(passphrase))
|
||||
|
||||
qr := hkdf.New(sha256.New, master[:], nil, []byte("thefeed-query"))
|
||||
if _, err = io.ReadFull(qr, queryKey[:]); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rr := hkdf.New(sha256.New, master[:], nil, []byte("thefeed-response"))
|
||||
_, err = io.ReadFull(rr, responseKey[:])
|
||||
return
|
||||
}
|
||||
|
||||
func newGCM(key [KeySize]byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext using AES-256-GCM. Returns nonce+ciphertext+tag.
|
||||
func Encrypt(key [KeySize]byte, plaintext []byte) ([]byte, error) {
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts AES-256-GCM ciphertext (nonce+ciphertext+tag).
|
||||
func Decrypt(key [KeySize]byte, ciphertext []byte) ([]byte, error) {
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ciphertext) < gcm.NonceSize()+gcm.Overhead() {
|
||||
return nil, fmt.Errorf("ciphertext too short: %d bytes", len(ciphertext))
|
||||
}
|
||||
|
||||
nonce := ciphertext[:gcm.NonceSize()]
|
||||
return gcm.Open(nil, nonce, ciphertext[gcm.NonceSize():], nil)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveKeys(t *testing.T) {
|
||||
qk1, rk1, err := DeriveKeys("test-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKeys: %v", err)
|
||||
}
|
||||
qk2, rk2, err := DeriveKeys("test-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKeys: %v", err)
|
||||
}
|
||||
if qk1 != qk2 || rk1 != rk2 {
|
||||
t.Error("same passphrase should produce same keys")
|
||||
}
|
||||
qk3, rk3, err := DeriveKeys("different-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKeys: %v", err)
|
||||
}
|
||||
if qk1 == qk3 || rk1 == rk3 {
|
||||
t.Error("different passphrase should produce different keys")
|
||||
}
|
||||
if qk1 == rk1 {
|
||||
t.Error("query and response keys should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
key := [KeySize]byte{}
|
||||
copy(key[:], "test-key-32-bytes-long-xxxxxxxx")
|
||||
plaintext := []byte("Hello, World!")
|
||||
ciphertext, err := Encrypt(key, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
if bytes.Equal(ciphertext, plaintext) {
|
||||
t.Error("ciphertext should differ from plaintext")
|
||||
}
|
||||
decrypted, err := Decrypt(key, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Errorf("decrypted: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWrongKey(t *testing.T) {
|
||||
key1 := [KeySize]byte{}
|
||||
key2 := [KeySize]byte{}
|
||||
copy(key1[:], "key-one-32-bytes-long-xxxxxxxxx")
|
||||
copy(key2[:], "key-two-32-bytes-long-xxxxxxxxx")
|
||||
ciphertext, _ := Encrypt(key1, []byte("secret"))
|
||||
_, err := Decrypt(key2, ciphertext)
|
||||
if err == nil {
|
||||
t.Error("expected error when decrypting with wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptTooShort(t *testing.T) {
|
||||
key := [KeySize]byte{}
|
||||
_, err := Decrypt(key, []byte{0x01, 0x02})
|
||||
if err == nil {
|
||||
t.Error("expected error for short ciphertext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||
key := [KeySize]byte{}
|
||||
copy(key[:], "test-key-32-bytes-long-xxxxxxxx")
|
||||
ct1, _ := Encrypt(key, []byte("same data"))
|
||||
ct2, _ := Encrypt(key, []byte("same data"))
|
||||
if bytes.Equal(ct1, ct2) {
|
||||
t.Error("two encryptions should produce different ciphertexts")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QueryEncoding controls how DNS query subdomains are encoded.
|
||||
type QueryEncoding int
|
||||
|
||||
const (
|
||||
// QuerySingleLabel uses base32 in a single DNS label (default, stealthier).
|
||||
QuerySingleLabel QueryEncoding = iota
|
||||
// QueryDoubleLabel uses hex split across two DNS labels.
|
||||
QueryDoubleLabel
|
||||
)
|
||||
|
||||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// EncodeQuery creates an encrypted DNS query subdomain.
|
||||
// Single-label (default): [base32_encrypted].domain
|
||||
// Double-label: [hex_part1].[hex_part2].domain
|
||||
// Payload: 4 random + 2 channel + 2 block = 8 bytes, encrypted with AES-GCM.
|
||||
func EncodeQuery(queryKey [KeySize]byte, channel, block uint16, domain string, mode QueryEncoding) (string, error) {
|
||||
payload := make([]byte, QueryPayloadSize)
|
||||
|
||||
if _, err := rand.Read(payload[:QueryPaddingSize]); err != nil {
|
||||
return "", fmt.Errorf("random padding: %w", err)
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint16(payload[QueryPaddingSize:], channel)
|
||||
binary.BigEndian.PutUint16(payload[QueryPaddingSize+QueryChannelSize:], block)
|
||||
|
||||
encrypted, err := Encrypt(queryKey, payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt query: %w", err)
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case QueryDoubleLabel:
|
||||
h := hex.EncodeToString(encrypted)
|
||||
mid := len(h) / 2
|
||||
return fmt.Sprintf("%s.%s.%s", h[:mid], h[mid:], domain), nil
|
||||
default:
|
||||
encoded := strings.ToLower(b32.EncodeToString(encrypted))
|
||||
return fmt.Sprintf("%s.%s", encoded, domain), nil
|
||||
}
|
||||
}
|
||||
|
||||
// DecodeQuery parses and decrypts a DNS query subdomain.
|
||||
// Auto-detects single-label (base32) or double-label (hex) encoding.
|
||||
func DecodeQuery(queryKey [KeySize]byte, qname, domain string) (channel, block uint16, err error) {
|
||||
qname = strings.TrimSuffix(qname, ".")
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
|
||||
suffix := "." + domain
|
||||
if !strings.HasSuffix(strings.ToLower(qname), strings.ToLower(suffix)) {
|
||||
return 0, 0, fmt.Errorf("domain mismatch: %q does not end with %q", qname, suffix)
|
||||
}
|
||||
|
||||
encoded := qname[:len(qname)-len(suffix)]
|
||||
|
||||
// Try base32 first (single label, no dots, or dots stripped)
|
||||
b32str := strings.ReplaceAll(encoded, ".", "")
|
||||
ciphertext, err := b32.DecodeString(strings.ToUpper(b32str))
|
||||
if err == nil {
|
||||
return decryptQuery(queryKey, ciphertext)
|
||||
}
|
||||
|
||||
// Fall back to hex (double-label)
|
||||
hexStr := strings.ReplaceAll(encoded, ".", "")
|
||||
ciphertext, err = hex.DecodeString(hexStr)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("decode query: invalid encoding")
|
||||
}
|
||||
return decryptQuery(queryKey, ciphertext)
|
||||
}
|
||||
|
||||
func decryptQuery(queryKey [KeySize]byte, ciphertext []byte) (channel, block uint16, err error) {
|
||||
plaintext, err := Decrypt(queryKey, ciphertext)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
|
||||
if len(plaintext) != QueryPayloadSize {
|
||||
return 0, 0, fmt.Errorf("invalid payload size: %d", len(plaintext))
|
||||
}
|
||||
|
||||
channel = binary.BigEndian.Uint16(plaintext[QueryPaddingSize:])
|
||||
block = binary.BigEndian.Uint16(plaintext[QueryPaddingSize+QueryChannelSize:])
|
||||
return channel, block, nil
|
||||
}
|
||||
|
||||
// EncodeResponse encrypts and base64-encodes a block payload for a DNS TXT response.
|
||||
// Adds a 2-byte length prefix and random padding to vary response size for anti-DPI.
|
||||
func EncodeResponse(responseKey [KeySize]byte, data []byte, maxPadding int) (string, error) {
|
||||
padLen := 0
|
||||
if maxPadding > 0 {
|
||||
buf := make([]byte, 1)
|
||||
rand.Read(buf)
|
||||
padLen = int(buf[0]) % (maxPadding + 1)
|
||||
}
|
||||
|
||||
padded := make([]byte, PadLengthSize+len(data)+padLen)
|
||||
binary.BigEndian.PutUint16(padded, uint16(len(data)))
|
||||
copy(padded[PadLengthSize:], data)
|
||||
if padLen > 0 {
|
||||
rand.Read(padded[PadLengthSize+len(data):])
|
||||
}
|
||||
|
||||
encrypted, err := Encrypt(responseKey, padded)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt response: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
// DecodeResponse base64-decodes and decrypts a DNS TXT response, stripping padding.
|
||||
func DecodeResponse(responseKey [KeySize]byte, encoded string) ([]byte, error) {
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode: %w", err)
|
||||
}
|
||||
padded, err := Decrypt(responseKey, ciphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(padded) < PadLengthSize {
|
||||
return nil, fmt.Errorf("response too short")
|
||||
}
|
||||
dataLen := int(binary.BigEndian.Uint16(padded))
|
||||
if dataLen > len(padded)-PadLengthSize {
|
||||
return nil, fmt.Errorf("invalid data length in response: %d", dataLen)
|
||||
}
|
||||
return padded[PadLengthSize : PadLengthSize+dataLen], nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodeDecodeQuerySingleLabel(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, 0},
|
||||
{1, 5},
|
||||
{255, 99},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
qname, err := EncodeQuery(qk, tt.channel, tt.block, domain, QuerySingleLabel)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeQuery(%d, %d): %v", tt.channel, tt.block, err)
|
||||
}
|
||||
if !strings.HasSuffix(qname, "."+domain) {
|
||||
t.Errorf("query %q should end with .%s", qname, domain)
|
||||
}
|
||||
// Single label: only one dot before domain
|
||||
subdomain := qname[:len(qname)-len(domain)-1]
|
||||
if strings.Contains(subdomain, ".") {
|
||||
t.Errorf("single-label query should not have dots in subdomain, got %q", subdomain)
|
||||
}
|
||||
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 TestEncodeDecodeQueryDoubleLabel(t *testing.T) {
|
||||
qk, _, err := DeriveKeys("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKeys: %v", err)
|
||||
}
|
||||
domain := "t.example.com"
|
||||
qname, err := EncodeQuery(qk, 3, 7, domain, QueryDoubleLabel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Double label: two hex labels before domain
|
||||
subdomain := qname[:len(qname)-len(domain)-1]
|
||||
parts := strings.Split(subdomain, ".")
|
||||
if len(parts) != 2 {
|
||||
t.Errorf("double-label query should have 2 parts, got %d: %q", len(parts), subdomain)
|
||||
}
|
||||
ch, blk, err := DecodeQuery(qk, qname, domain)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeQuery: %v", err)
|
||||
}
|
||||
if ch != 3 || blk != 7 {
|
||||
t.Errorf("got ch=%d blk=%d, want ch=3 blk=7", ch, blk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeQueryWrongKey(t *testing.T) {
|
||||
qk1, _, _ := DeriveKeys("key1")
|
||||
qk2, _, _ := DeriveKeys("key2")
|
||||
qname, _ := EncodeQuery(qk1, 1, 0, "t.example.com", QuerySingleLabel)
|
||||
_, _, err := DecodeQuery(qk2, qname, "t.example.com")
|
||||
if err == nil {
|
||||
t.Error("expected error when decoding with wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeQueryWrongDomain(t *testing.T) {
|
||||
qk, _, _ := DeriveKeys("key")
|
||||
qname, _ := EncodeQuery(qk, 1, 0, "t.example.com", QuerySingleLabel)
|
||||
_, _, err := DecodeQuery(qk, qname, "t.other.com")
|
||||
if err == nil {
|
||||
t.Error("expected error for wrong domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeResponse(t *testing.T) {
|
||||
_, rk, err := DeriveKeys("test-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := []byte("Hello World!")
|
||||
encoded, err := EncodeResponse(rk, data, DefaultMaxPadding)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeResponse: %v", err)
|
||||
}
|
||||
decoded, err := DecodeResponse(rk, encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeResponse: %v", err)
|
||||
}
|
||||
if string(decoded) != string(data) {
|
||||
t.Errorf("got %q, want %q", decoded, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeResponseNoPadding(t *testing.T) {
|
||||
_, rk, err := DeriveKeys("test-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := []byte("No padding test")
|
||||
encoded, err := EncodeResponse(rk, data, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeResponse: %v", err)
|
||||
}
|
||||
decoded, err := DecodeResponse(rk, encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeResponse: %v", err)
|
||||
}
|
||||
if string(decoded) != string(data) {
|
||||
t.Errorf("got %q, want %q", decoded, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseVaryingSize(t *testing.T) {
|
||||
_, rk, _ := DeriveKeys("test-key")
|
||||
data := []byte("fixed data")
|
||||
sizes := make(map[int]bool)
|
||||
for i := 0; i < 50; i++ {
|
||||
encoded, err := EncodeResponse(rk, data, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sizes[len(encoded)] = true
|
||||
}
|
||||
if len(sizes) < 2 {
|
||||
t.Error("expected varying response sizes with padding, got uniform")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeResponseWrongKey(t *testing.T) {
|
||||
_, rk1, _ := DeriveKeys("key1")
|
||||
_, rk2, _ := DeriveKeys("key2")
|
||||
encoded, _ := EncodeResponse(rk1, []byte("data"), 0)
|
||||
_, err := DecodeResponse(rk2, encoded)
|
||||
if err == nil {
|
||||
t.Error("expected error for wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDomainWithTrailingDot(t *testing.T) {
|
||||
qk, _, _ := DeriveKeys("key")
|
||||
qname, _ := EncodeQuery(qk, 1, 0, "t.example.com", QuerySingleLabel)
|
||||
ch, blk, err := DecodeQuery(qk, qname+".", "t.example.com.")
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeQuery with trailing dot: %v", err)
|
||||
}
|
||||
if ch != 1 || blk != 0 {
|
||||
t.Errorf("got ch=%d blk=%d, want ch=1 blk=0", ch, blk)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultBlockPayload is the decrypted payload per DNS TXT block.
|
||||
// Calculated to stay within 512-byte UDP DNS limit after encryption + base64 + padding overhead.
|
||||
DefaultBlockPayload = 180
|
||||
|
||||
// DefaultMaxPadding is the default random padding added to responses to vary DNS response size.
|
||||
DefaultMaxPadding = 32
|
||||
|
||||
// PadLengthSize is the 2-byte length prefix added before real data when padding is used.
|
||||
PadLengthSize = 2
|
||||
|
||||
// MetadataChannel is the special channel number for server metadata.
|
||||
MetadataChannel = 0
|
||||
|
||||
// MarkerSize is the random marker in metadata to verify data freshness.
|
||||
MarkerSize = 3
|
||||
|
||||
// Query payload structure sizes.
|
||||
QueryPaddingSize = 4
|
||||
QueryChannelSize = 2
|
||||
QueryBlockSize = 2
|
||||
QueryPayloadSize = QueryPaddingSize + QueryChannelSize + QueryBlockSize // 8
|
||||
|
||||
// Message header sizes (in the serialized message stream).
|
||||
MsgIDSize = 4
|
||||
MsgTimestampSize = 4
|
||||
MsgLengthSize = 2
|
||||
MsgHeaderSize = MsgIDSize + MsgTimestampSize + MsgLengthSize // 10
|
||||
)
|
||||
|
||||
// Media placeholder strings for non-text content.
|
||||
const (
|
||||
MediaImage = "[IMAGE]"
|
||||
MediaVideo = "[VIDEO]"
|
||||
MediaFile = "[FILE]"
|
||||
MediaAudio = "[AUDIO]"
|
||||
MediaSticker = "[STICKER]"
|
||||
MediaGIF = "[GIF]"
|
||||
MediaPoll = "[POLL]"
|
||||
MediaContact = "[CONTACT]"
|
||||
MediaLocation = "[LOCATION]"
|
||||
)
|
||||
|
||||
// Metadata holds channel 0 data: server info + channel list.
|
||||
type Metadata struct {
|
||||
Marker [MarkerSize]byte
|
||||
Timestamp uint32
|
||||
Channels []ChannelInfo
|
||||
}
|
||||
|
||||
// ChannelInfo describes a single feed channel.
|
||||
type ChannelInfo struct {
|
||||
Name string
|
||||
Blocks uint16
|
||||
LastMsgID uint32
|
||||
}
|
||||
|
||||
// Message represents a single feed message in a channel.
|
||||
type Message struct {
|
||||
ID uint32
|
||||
Timestamp uint32
|
||||
Text string
|
||||
}
|
||||
|
||||
// SerializeMetadata encodes metadata into bytes for channel 0 blocks.
|
||||
func SerializeMetadata(m *Metadata) []byte {
|
||||
// 3 marker + 4 timestamp + 2 channel count + per-channel data
|
||||
size := MarkerSize + 4 + 2
|
||||
for _, ch := range m.Channels {
|
||||
size += 1 + len(ch.Name) + 2 + 4
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
off := 0
|
||||
|
||||
copy(buf[off:], m.Marker[:])
|
||||
off += MarkerSize
|
||||
|
||||
binary.BigEndian.PutUint32(buf[off:], m.Timestamp)
|
||||
off += 4
|
||||
|
||||
binary.BigEndian.PutUint16(buf[off:], uint16(len(m.Channels)))
|
||||
off += 2
|
||||
|
||||
for _, ch := range m.Channels {
|
||||
nameBytes := []byte(ch.Name)
|
||||
if len(nameBytes) > 255 {
|
||||
nameBytes = nameBytes[:255]
|
||||
}
|
||||
buf[off] = byte(len(nameBytes))
|
||||
off++
|
||||
copy(buf[off:], nameBytes)
|
||||
off += len(nameBytes)
|
||||
binary.BigEndian.PutUint16(buf[off:], ch.Blocks)
|
||||
off += 2
|
||||
binary.BigEndian.PutUint32(buf[off:], ch.LastMsgID)
|
||||
off += 4
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// ParseMetadata decodes metadata from concatenated channel 0 block data.
|
||||
func ParseMetadata(data []byte) (*Metadata, error) {
|
||||
if len(data) < MarkerSize+4+2 {
|
||||
return nil, fmt.Errorf("metadata too short: %d bytes", len(data))
|
||||
}
|
||||
m := &Metadata{}
|
||||
off := 0
|
||||
|
||||
copy(m.Marker[:], data[off:off+MarkerSize])
|
||||
off += MarkerSize
|
||||
|
||||
m.Timestamp = binary.BigEndian.Uint32(data[off:])
|
||||
off += 4
|
||||
|
||||
count := binary.BigEndian.Uint16(data[off:])
|
||||
off += 2
|
||||
|
||||
m.Channels = make([]ChannelInfo, 0, count)
|
||||
for i := 0; i < int(count); i++ {
|
||||
if off >= len(data) {
|
||||
return nil, fmt.Errorf("truncated metadata at channel %d", i)
|
||||
}
|
||||
nameLen := int(data[off])
|
||||
off++
|
||||
if off+nameLen > len(data) {
|
||||
return nil, fmt.Errorf("truncated channel name at %d", i)
|
||||
}
|
||||
name := string(data[off : off+nameLen])
|
||||
off += nameLen
|
||||
|
||||
if off+6 > 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
|
||||
|
||||
m.Channels = append(m.Channels, ChannelInfo{
|
||||
Name: name,
|
||||
Blocks: blocks,
|
||||
LastMsgID: lastID,
|
||||
})
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// SerializeMessages encodes messages into a byte stream for data channel blocks.
|
||||
func SerializeMessages(msgs []Message) []byte {
|
||||
size := 0
|
||||
for _, msg := range msgs {
|
||||
size += MsgHeaderSize + len(msg.Text)
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
off := 0
|
||||
|
||||
for _, msg := range msgs {
|
||||
textBytes := []byte(msg.Text)
|
||||
binary.BigEndian.PutUint32(buf[off:], msg.ID)
|
||||
off += MsgIDSize
|
||||
binary.BigEndian.PutUint32(buf[off:], msg.Timestamp)
|
||||
off += MsgTimestampSize
|
||||
binary.BigEndian.PutUint16(buf[off:], uint16(len(textBytes)))
|
||||
off += MsgLengthSize
|
||||
copy(buf[off:], textBytes)
|
||||
off += len(textBytes)
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// ParseMessages decodes messages from concatenated data channel block data.
|
||||
func ParseMessages(data []byte) ([]Message, error) {
|
||||
var msgs []Message
|
||||
off := 0
|
||||
|
||||
for off < len(data) {
|
||||
if off+MsgHeaderSize > len(data) {
|
||||
break // incomplete message header, stop
|
||||
}
|
||||
id := binary.BigEndian.Uint32(data[off:])
|
||||
off += MsgIDSize
|
||||
ts := binary.BigEndian.Uint32(data[off:])
|
||||
off += MsgTimestampSize
|
||||
textLen := int(binary.BigEndian.Uint16(data[off:]))
|
||||
off += MsgLengthSize
|
||||
|
||||
if off+textLen > len(data) {
|
||||
break // incomplete message text, stop
|
||||
}
|
||||
text := string(data[off : off+textLen])
|
||||
off += textLen
|
||||
|
||||
msgs = append(msgs, Message{
|
||||
ID: id,
|
||||
Timestamp: ts,
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// SplitIntoBlocks splits data into blocks of DefaultBlockPayload size.
|
||||
func SplitIntoBlocks(data []byte) [][]byte {
|
||||
if len(data) == 0 {
|
||||
return [][]byte{{}}
|
||||
}
|
||||
var blocks [][]byte
|
||||
for i := 0; i < len(data); i += DefaultBlockPayload {
|
||||
end := i + DefaultBlockPayload
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
block := make([]byte, end-i)
|
||||
copy(block, data[i:end])
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSerializeParseMetadata(t *testing.T) {
|
||||
original := &Metadata{
|
||||
Marker: [3]byte{0xAA, 0xBB, 0xCC},
|
||||
Timestamp: 1700000000,
|
||||
Channels: []ChannelInfo{
|
||||
{Name: "VahidOnline", Blocks: 5, LastMsgID: 1234},
|
||||
{Name: "kianmeli1", Blocks: 3, LastMsgID: 5678},
|
||||
},
|
||||
}
|
||||
data := SerializeMetadata(original)
|
||||
parsed, err := ParseMetadata(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMetadata: %v", err)
|
||||
}
|
||||
if parsed.Marker != original.Marker {
|
||||
t.Errorf("marker: got %v, want %v", parsed.Marker, original.Marker)
|
||||
}
|
||||
if parsed.Timestamp != original.Timestamp {
|
||||
t.Errorf("timestamp: got %d, want %d", parsed.Timestamp, original.Timestamp)
|
||||
}
|
||||
if len(parsed.Channels) != len(original.Channels) {
|
||||
t.Fatalf("channels: got %d, want %d", len(parsed.Channels), len(original.Channels))
|
||||
}
|
||||
for i := range original.Channels {
|
||||
if parsed.Channels[i] != original.Channels[i] {
|
||||
t.Errorf("channel %d mismatch", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeParseMessages(t *testing.T) {
|
||||
original := []Message{
|
||||
{ID: 100, Timestamp: 1700000000, Text: "Hello world"},
|
||||
{ID: 101, Timestamp: 1700000060, Text: "Test farsi"},
|
||||
{ID: 102, Timestamp: 1700000120, Text: "[IMAGE] Caption"},
|
||||
}
|
||||
data := SerializeMessages(original)
|
||||
parsed, err := ParseMessages(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMessages: %v", err)
|
||||
}
|
||||
if len(parsed) != len(original) {
|
||||
t.Fatalf("messages: got %d, want %d", len(parsed), len(original))
|
||||
}
|
||||
for i := range original {
|
||||
if parsed[i] != original[i] {
|
||||
t.Errorf("message %d mismatch", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitIntoBlocks(t *testing.T) {
|
||||
data := bytes.Repeat([]byte("A"), DefaultBlockPayload*3+50)
|
||||
blocks := SplitIntoBlocks(data)
|
||||
if len(blocks) != 4 {
|
||||
t.Fatalf("blocks: got %d, want 4", len(blocks))
|
||||
}
|
||||
for i, b := range blocks {
|
||||
if i < 3 && len(b) != DefaultBlockPayload {
|
||||
t.Errorf("block %d: size %d, want %d", i, len(b), DefaultBlockPayload)
|
||||
}
|
||||
}
|
||||
if len(blocks[3]) != 50 {
|
||||
t.Errorf("last block: size %d, want 50", len(blocks[3]))
|
||||
}
|
||||
var reassembled []byte
|
||||
for _, b := range blocks {
|
||||
reassembled = append(reassembled, b...)
|
||||
}
|
||||
if !bytes.Equal(reassembled, data) {
|
||||
t.Error("reassembled data does not match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitIntoBlocksEmpty(t *testing.T) {
|
||||
blocks := SplitIntoBlocks(nil)
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("empty should produce 1 block, got %d", len(blocks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageRoundtripThroughBlocks(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{ID: 1, Timestamp: 1700000000, Text: "Short"},
|
||||
{ID: 2, Timestamp: 1700000001, Text: string(bytes.Repeat([]byte("X"), 300))},
|
||||
{ID: 3, Timestamp: 1700000002, Text: "End"},
|
||||
}
|
||||
data := SerializeMessages(msgs)
|
||||
blocks := SplitIntoBlocks(data)
|
||||
var reassembled []byte
|
||||
for _, b := range blocks {
|
||||
reassembled = append(reassembled, b...)
|
||||
}
|
||||
parsed, err := ParseMessages(reassembled)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMessages: %v", err)
|
||||
}
|
||||
if len(parsed) != len(msgs) {
|
||||
t.Fatalf("got %d messages, want %d", len(parsed), len(msgs))
|
||||
}
|
||||
for i := range msgs {
|
||||
if parsed[i] != msgs[i] {
|
||||
t.Errorf("message %d mismatch", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadataTooShort(t *testing.T) {
|
||||
_, err := ParseMetadata([]byte{0x01, 0x02})
|
||||
if err == nil {
|
||||
t.Error("expected error for short metadata")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe starts the DNS server on UDP, shutting down when ctx is cancelled.
|
||||
func (s *DNSServer) ListenAndServe(ctx context.Context) error {
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc(s.domain+".", s.handleQuery)
|
||||
|
||||
server := &dns.Server{
|
||||
Addr: s.listenAddr,
|
||||
Net: "udp",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
log.Println("[dns] shutting down...")
|
||||
server.Shutdown()
|
||||
}()
|
||||
|
||||
log.Printf("[dns] listening on %s (domain: %s)", s.listenAddr, s.domain)
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *DNSServer) handleQuery(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Authoritative = true
|
||||
|
||||
if len(r.Question) == 0 {
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.Question[0]
|
||||
if q.Qtype != dns.TypeTXT {
|
||||
m.Rcode = dns.RcodeNameError
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
|
||||
channel, block, err := protocol.DecodeQuery(s.queryKey, q.Name, s.domain)
|
||||
if err != nil {
|
||||
log.Printf("[dns] decode query: %v", err)
|
||||
m.Rcode = dns.RcodeNameError
|
||||
w.WriteMsg(m)
|
||||
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)
|
||||
m.Rcode = dns.RcodeNameError
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := protocol.EncodeResponse(s.responseKey, data, s.maxPadding)
|
||||
if err != nil {
|
||||
log.Printf("[dns] encode response: %v", err)
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
|
||||
// Split base64 string into 255-byte TXT chunks
|
||||
txtParts := splitTXT(encoded)
|
||||
|
||||
m.Answer = append(m.Answer, &dns.TXT{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: q.Name,
|
||||
Rrtype: dns.TypeTXT,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: 1,
|
||||
},
|
||||
Txt: txtParts,
|
||||
})
|
||||
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
// splitTXT splits a string into 255-byte chunks for DNS TXT records.
|
||||
func splitTXT(s string) []string {
|
||||
var parts []string
|
||||
for len(s) > 255 {
|
||||
parts = append(parts, s[:255])
|
||||
s = s[255:]
|
||||
}
|
||||
if len(s) > 0 {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
)
|
||||
|
||||
// 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
|
||||
updated time.Time
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
f.rotateMarker()
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *Feed) rotateMarker() {
|
||||
rand.Read(f.marker[:])
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
var lastID uint32
|
||||
if len(msgs) > 0 {
|
||||
lastID = msgs[0].ID
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
f.blocks[channelNum] = blocks
|
||||
f.lastIDs[channelNum] = lastID
|
||||
f.updated = time.Now()
|
||||
f.rotateMarker()
|
||||
}
|
||||
|
||||
// GetBlock returns the block data for a given channel and block number.
|
||||
func (f *Feed) GetBlock(channel, block int) ([]byte, error) {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
|
||||
if channel == protocol.MetadataChannel {
|
||||
return f.getMetadataBlock(block)
|
||||
}
|
||||
|
||||
ch, ok := f.blocks[channel]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("channel %d not found", channel)
|
||||
}
|
||||
if block < 0 || block >= len(ch) {
|
||||
return nil, fmt.Errorf("block %d out of range (channel %d has %d blocks)", block, channel, len(ch))
|
||||
}
|
||||
return ch[block], nil
|
||||
}
|
||||
|
||||
func (f *Feed) getMetadataBlock(block int) ([]byte, error) {
|
||||
meta := &protocol.Metadata{
|
||||
Marker: f.marker,
|
||||
Timestamp: uint32(time.Now().Unix()),
|
||||
}
|
||||
|
||||
for i, name := range f.channels {
|
||||
chNum := i + 1
|
||||
blocks, ok := f.blocks[chNum]
|
||||
blockCount := uint16(0)
|
||||
if ok {
|
||||
blockCount = uint16(len(blocks))
|
||||
}
|
||||
meta.Channels = append(meta.Channels, protocol.ChannelInfo{
|
||||
Name: name,
|
||||
Blocks: blockCount,
|
||||
LastMsgID: f.lastIDs[chNum],
|
||||
})
|
||||
}
|
||||
|
||||
data := protocol.SerializeMetadata(meta)
|
||||
metaBlocks := protocol.SplitIntoBlocks(data)
|
||||
|
||||
if block < 0 || block >= len(metaBlocks) {
|
||||
return nil, fmt.Errorf("metadata block %d out of range (%d blocks)", block, len(metaBlocks))
|
||||
}
|
||||
return metaBlocks[block], nil
|
||||
}
|
||||
|
||||
// ChannelNames returns the configured channel names.
|
||||
func (f *Feed) ChannelNames() []string {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
result := make([]string, len(f.channels))
|
||||
copy(result, f.channels)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
)
|
||||
|
||||
func TestFeedUpdateAndGetBlock(t *testing.T) {
|
||||
feed := NewFeed([]string{"TestChannel"})
|
||||
msgs := []protocol.Message{
|
||||
{ID: 1, Timestamp: 1700000000, Text: "First message"},
|
||||
{ID: 2, Timestamp: 1700000060, Text: "Second message"},
|
||||
}
|
||||
feed.UpdateChannel(1, msgs)
|
||||
data, err := feed.GetBlock(1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlock(1, 0): %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("block data should not be empty")
|
||||
}
|
||||
parsed, err := protocol.ParseMessages(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMessages: %v", err)
|
||||
}
|
||||
if len(parsed) != 2 {
|
||||
t.Errorf("got %d messages, want 2", len(parsed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedMetadataBlock(t *testing.T) {
|
||||
feed := NewFeed([]string{"Channel1", "Channel2"})
|
||||
msgs := []protocol.Message{{ID: 10, Timestamp: 1700000000, Text: "Hello"}}
|
||||
feed.UpdateChannel(1, msgs)
|
||||
data, err := feed.GetBlock(protocol.MetadataChannel, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlock(0, 0): %v", err)
|
||||
}
|
||||
meta, err := protocol.ParseMetadata(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMetadata: %v", err)
|
||||
}
|
||||
if len(meta.Channels) != 2 {
|
||||
t.Fatalf("channels: got %d, want 2", len(meta.Channels))
|
||||
}
|
||||
if meta.Channels[0].Name != "Channel1" {
|
||||
t.Errorf("name: got %q, want Channel1", meta.Channels[0].Name)
|
||||
}
|
||||
if meta.Channels[0].Blocks != 1 {
|
||||
t.Errorf("blocks: got %d, want 1", meta.Channels[0].Blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGetBlockOutOfRange(t *testing.T) {
|
||||
feed := NewFeed([]string{"Test"})
|
||||
feed.UpdateChannel(1, []protocol.Message{{ID: 1, Timestamp: 1, Text: "x"}})
|
||||
_, err := feed.GetBlock(1, 999)
|
||||
if err == nil {
|
||||
t.Error("expected error for out-of-range block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGetBlockUnknownChannel(t *testing.T) {
|
||||
feed := NewFeed([]string{"Test"})
|
||||
_, err := feed.GetBlock(99, 0)
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedLargeMessages(t *testing.T) {
|
||||
feed := NewFeed([]string{"Test"})
|
||||
largeText := make([]byte, 500)
|
||||
for i := range largeText {
|
||||
largeText[i] = 65
|
||||
}
|
||||
msgs := []protocol.Message{{ID: 1, Timestamp: 1700000000, Text: string(largeText)}}
|
||||
feed.UpdateChannel(1, msgs)
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
)
|
||||
|
||||
// Config holds server configuration.
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
Domain string
|
||||
Passphrase string
|
||||
ChannelsFile string
|
||||
MaxPadding int
|
||||
Telegram TelegramConfig
|
||||
}
|
||||
|
||||
// Server orchestrates the DNS server and Telegram reader.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
feed *Feed
|
||||
}
|
||||
|
||||
// New creates a new Server.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
channels, err := loadChannels(cfg.ChannelsFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load channels: %w", err)
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
return nil, fmt.Errorf("no channels configured in %s", cfg.ChannelsFile)
|
||||
}
|
||||
|
||||
log.Printf("[server] loaded %d channels: %v", len(channels), channels)
|
||||
|
||||
feed := NewFeed(channels)
|
||||
return &Server{cfg: cfg, feed: feed}, nil
|
||||
}
|
||||
|
||||
// Run starts both the DNS server and the Telegram reader.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
queryKey, responseKey, err := protocol.DeriveKeys(s.cfg.Passphrase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive keys: %w", err)
|
||||
}
|
||||
|
||||
// Handle login-only mode
|
||||
if s.cfg.Telegram.LoginOnly {
|
||||
reader := NewTelegramReader(s.cfg.Telegram, s.feed.ChannelNames(), s.feed)
|
||||
return reader.Run(ctx)
|
||||
}
|
||||
|
||||
// Start Telegram reader in background
|
||||
reader := NewTelegramReader(s.cfg.Telegram, s.feed.ChannelNames(), s.feed)
|
||||
go func() {
|
||||
if err := reader.Run(ctx); err != nil {
|
||||
log.Printf("[telegram] error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start DNS server (blocking, respects ctx cancellation)
|
||||
maxPad := s.cfg.MaxPadding
|
||||
if maxPad == 0 {
|
||||
maxPad = protocol.DefaultMaxPadding
|
||||
}
|
||||
dnsServer := NewDNSServer(s.cfg.ListenAddr, s.cfg.Domain, s.feed, queryKey, responseKey, maxPad)
|
||||
return dnsServer.ListenAndServe(ctx)
|
||||
}
|
||||
|
||||
func loadChannels(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
|
||||
}
|
||||
// Strip @ prefix
|
||||
name := strings.TrimPrefix(line, "@")
|
||||
channels = append(channels, name)
|
||||
}
|
||||
return channels, scanner.Err()
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/session"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/auth"
|
||||
"github.com/gotd/td/tg"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
)
|
||||
|
||||
// TelegramConfig holds Telegram API credentials.
|
||||
type TelegramConfig struct {
|
||||
APIID int
|
||||
APIHash string
|
||||
Phone string
|
||||
Password string // 2FA password, empty if not used
|
||||
SessionPath string
|
||||
LoginOnly bool // if true, authenticate and exit
|
||||
CodePrompt func(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// fileSessionStorage persists gotd session to a JSON file.
|
||||
type fileSessionStorage struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (f *fileSessionStorage) LoadSession(_ context.Context) ([]byte, error) {
|
||||
data, err := os.ReadFile(f.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, session.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (f *fileSessionStorage) StoreSession(_ context.Context, data []byte) error {
|
||||
dir := filepath.Dir(f.path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(f.path, data, 0600)
|
||||
}
|
||||
|
||||
// TelegramReader fetches messages from Telegram channels.
|
||||
type TelegramReader struct {
|
||||
cfg TelegramConfig
|
||||
channels []string // channel usernames without @
|
||||
feed *Feed
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]cachedMessages
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
type cachedMessages struct {
|
||||
msgs []protocol.Message
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
// NewTelegramReader creates a reader for the given channel usernames.
|
||||
func NewTelegramReader(cfg TelegramConfig, channelUsernames []string, feed *Feed) *TelegramReader {
|
||||
cleaned := make([]string, len(channelUsernames))
|
||||
for i, u := range channelUsernames {
|
||||
cleaned[i] = strings.TrimPrefix(strings.TrimSpace(u), "@")
|
||||
}
|
||||
return &TelegramReader{
|
||||
cfg: cfg,
|
||||
channels: cleaned,
|
||||
feed: feed,
|
||||
cache: make(map[string]cachedMessages),
|
||||
cacheTTL: 1 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the Telegram client, authenticates, and periodically fetches messages.
|
||||
func (tr *TelegramReader) Run(ctx context.Context) error {
|
||||
opts := telegram.Options{}
|
||||
|
||||
// Persist session to file if path is configured
|
||||
if tr.cfg.SessionPath != "" {
|
||||
opts.SessionStorage = &fileSessionStorage{path: tr.cfg.SessionPath}
|
||||
}
|
||||
|
||||
client := telegram.NewClient(tr.cfg.APIID, tr.cfg.APIHash, opts)
|
||||
|
||||
return client.Run(ctx, func(ctx context.Context) error {
|
||||
// Authenticate
|
||||
if err := tr.authenticate(ctx, client); err != nil {
|
||||
return fmt.Errorf("telegram auth: %w", err)
|
||||
}
|
||||
|
||||
log.Println("[telegram] authenticated successfully")
|
||||
|
||||
// Login-only mode: just authenticate and return
|
||||
if tr.cfg.LoginOnly {
|
||||
log.Println("[telegram] login-only mode, session saved, exiting")
|
||||
return nil
|
||||
}
|
||||
|
||||
api := client.API()
|
||||
|
||||
// Initial fetch
|
||||
tr.fetchAll(ctx, api)
|
||||
|
||||
// Periodic fetch loop
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
tr.fetchAll(ctx, api)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (tr *TelegramReader) authenticate(ctx context.Context, client *telegram.Client) error {
|
||||
status, err := client.Auth().Status(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Authorized {
|
||||
return nil
|
||||
}
|
||||
|
||||
codeAuth := auth.CodeAuthenticatorFunc(func(ctx context.Context, _ *tg.AuthSentCode) (string, error) {
|
||||
if tr.cfg.CodePrompt != nil {
|
||||
return tr.cfg.CodePrompt(ctx)
|
||||
}
|
||||
return "", fmt.Errorf("no code prompt configured")
|
||||
})
|
||||
|
||||
var authConv auth.UserAuthenticator
|
||||
if tr.cfg.Password != "" {
|
||||
authConv = auth.Constant(tr.cfg.Phone, tr.cfg.Password, codeAuth)
|
||||
} else {
|
||||
authConv = auth.Constant(tr.cfg.Phone, "", codeAuth)
|
||||
}
|
||||
|
||||
flow := auth.NewFlow(authConv, auth.SendCodeOptions{})
|
||||
return client.Auth().IfNecessary(ctx, flow)
|
||||
}
|
||||
|
||||
func (tr *TelegramReader) fetchAll(ctx context.Context, api *tg.Client) {
|
||||
for i, username := range tr.channels {
|
||||
chNum := i + 1
|
||||
|
||||
// Check cache
|
||||
tr.mu.RLock()
|
||||
cached, ok := tr.cache[username]
|
||||
tr.mu.RUnlock()
|
||||
if ok && time.Since(cached.fetched) < tr.cacheTTL {
|
||||
continue
|
||||
}
|
||||
|
||||
msgs, err := tr.fetchChannel(ctx, api, username)
|
||||
if err != nil {
|
||||
log.Printf("[telegram] fetch %s: %v", username, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update cache
|
||||
tr.mu.Lock()
|
||||
tr.cache[username] = cachedMessages{msgs: msgs, fetched: time.Now()}
|
||||
tr.mu.Unlock()
|
||||
|
||||
// Update feed
|
||||
tr.feed.UpdateChannel(chNum, msgs)
|
||||
log.Printf("[telegram] updated %s: %d messages", username, len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *TelegramReader) fetchChannel(ctx context.Context, api *tg.Client, username string) ([]protocol.Message, error) {
|
||||
resolved, err := api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
|
||||
Username: username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve %s: %w", username, err)
|
||||
}
|
||||
|
||||
var channel *tg.Channel
|
||||
for _, chat := range resolved.Chats {
|
||||
if ch, ok := chat.(*tg.Channel); ok {
|
||||
channel = ch
|
||||
break
|
||||
}
|
||||
}
|
||||
if channel == nil {
|
||||
return nil, fmt.Errorf("channel %s not found in resolved chats", username)
|
||||
}
|
||||
|
||||
peer := &tg.InputPeerChannel{
|
||||
ChannelID: channel.ID,
|
||||
AccessHash: channel.AccessHash,
|
||||
}
|
||||
|
||||
hist, err := api.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
|
||||
Peer: peer,
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get history %s: %w", username, err)
|
||||
}
|
||||
|
||||
return tr.extractMessages(hist)
|
||||
}
|
||||
|
||||
func (tr *TelegramReader) extractMessages(hist tg.MessagesMessagesClass) ([]protocol.Message, error) {
|
||||
var tgMsgs []tg.MessageClass
|
||||
|
||||
switch h := hist.(type) {
|
||||
case *tg.MessagesMessages:
|
||||
tgMsgs = h.Messages
|
||||
case *tg.MessagesMessagesSlice:
|
||||
tgMsgs = h.Messages
|
||||
case *tg.MessagesChannelMessages:
|
||||
tgMsgs = h.Messages
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected messages type: %T", hist)
|
||||
}
|
||||
|
||||
var msgs []protocol.Message
|
||||
for _, raw := range tgMsgs {
|
||||
msg, ok := raw.(*tg.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
text := tr.extractText(msg)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
msgs = append(msgs, protocol.Message{
|
||||
ID: uint32(msg.ID),
|
||||
Timestamp: uint32(msg.Date),
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
func (tr *TelegramReader) extractText(msg *tg.Message) string {
|
||||
text := msg.Message
|
||||
|
||||
mediaPrefix := ""
|
||||
if msg.Media != nil {
|
||||
switch msg.Media.(type) {
|
||||
case *tg.MessageMediaPhoto:
|
||||
mediaPrefix = protocol.MediaImage
|
||||
case *tg.MessageMediaDocument:
|
||||
mediaPrefix = tr.classifyDocument(msg.Media.(*tg.MessageMediaDocument))
|
||||
case *tg.MessageMediaGeo, *tg.MessageMediaGeoLive, *tg.MessageMediaVenue:
|
||||
mediaPrefix = protocol.MediaLocation
|
||||
case *tg.MessageMediaContact:
|
||||
mediaPrefix = protocol.MediaContact
|
||||
case *tg.MessageMediaPoll:
|
||||
mediaPrefix = protocol.MediaPoll
|
||||
}
|
||||
}
|
||||
|
||||
if mediaPrefix != "" {
|
||||
if text != "" {
|
||||
return mediaPrefix + "\n" + text
|
||||
}
|
||||
return mediaPrefix
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
func (tr *TelegramReader) classifyDocument(media *tg.MessageMediaDocument) string {
|
||||
doc, ok := media.Document.(*tg.Document)
|
||||
if !ok {
|
||||
return protocol.MediaFile
|
||||
}
|
||||
|
||||
for _, attr := range doc.Attributes {
|
||||
switch attr.(type) {
|
||||
case *tg.DocumentAttributeVideo:
|
||||
return protocol.MediaVideo
|
||||
case *tg.DocumentAttributeAudio:
|
||||
return protocol.MediaAudio
|
||||
case *tg.DocumentAttributeSticker:
|
||||
return protocol.MediaSticker
|
||||
case *tg.DocumentAttributeAnimated:
|
||||
return protocol.MediaGIF
|
||||
}
|
||||
}
|
||||
|
||||
return protocol.MediaFile
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/client"
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
"github.com/sartoopjj/thefeed/internal/version"
|
||||
)
|
||||
|
||||
var (
|
||||
channelListStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("62")).
|
||||
Padding(0, 1)
|
||||
|
||||
messageViewStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("62")).
|
||||
Padding(0, 1)
|
||||
|
||||
logViewStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
Padding(0, 1)
|
||||
|
||||
selectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(true)
|
||||
|
||||
normalStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252"))
|
||||
|
||||
statusStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Background(lipgloss.Color("236")).
|
||||
Padding(0, 1)
|
||||
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
timestampStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241"))
|
||||
|
||||
msgIDStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("69"))
|
||||
|
||||
logDimStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
)
|
||||
|
||||
type focus int
|
||||
|
||||
const (
|
||||
focusChannels focus = iota
|
||||
focusMessages
|
||||
focusLog
|
||||
)
|
||||
|
||||
const maxLogLines = 200
|
||||
|
||||
// logBuffer is a shared log buffer that survives bubbletea model copies.
|
||||
type logBuffer struct {
|
||||
lines []string
|
||||
}
|
||||
|
||||
func (lb *logBuffer) append(msg string) {
|
||||
ts := time.Now().Format("15:04:05")
|
||||
line := fmt.Sprintf("%s %s", logDimStyle.Render(ts), msg)
|
||||
lb.lines = append(lb.lines, line)
|
||||
if len(lb.lines) > maxLogLines {
|
||||
lb.lines = lb.lines[len(lb.lines)-maxLogLines:]
|
||||
}
|
||||
}
|
||||
|
||||
// Model is the TUI state.
|
||||
type Model struct {
|
||||
fetcher *client.Fetcher
|
||||
cache *client.Cache
|
||||
channels []protocol.ChannelInfo
|
||||
messages map[int][]protocol.Message
|
||||
|
||||
selectedChan int
|
||||
focus focus
|
||||
viewport viewport.Model
|
||||
logViewport viewport.Model
|
||||
|
||||
width, height int
|
||||
status string
|
||||
loading bool
|
||||
forceRefresh bool
|
||||
err error
|
||||
lastUpdate time.Time
|
||||
serverTimestamp uint32
|
||||
marker [protocol.MarkerSize]byte
|
||||
resolverInfo string
|
||||
logBuf *logBuffer
|
||||
|
||||
autoRefreshInterval time.Duration
|
||||
}
|
||||
|
||||
type (
|
||||
metadataMsg struct {
|
||||
meta *protocol.Metadata
|
||||
err error
|
||||
}
|
||||
channelDataMsg struct {
|
||||
channelNum int
|
||||
msgs []protocol.Message
|
||||
err error
|
||||
}
|
||||
tickMsg struct{}
|
||||
logMsg string
|
||||
)
|
||||
|
||||
// New creates a new TUI model.
|
||||
func New(fetcher *client.Fetcher, cache *client.Cache) Model {
|
||||
vp := viewport.New(0, 0)
|
||||
lv := viewport.New(0, 0)
|
||||
lb := &logBuffer{}
|
||||
m := Model{
|
||||
fetcher: fetcher,
|
||||
cache: cache,
|
||||
messages: make(map[int][]protocol.Message),
|
||||
viewport: vp,
|
||||
logViewport: lv,
|
||||
logBuf: lb,
|
||||
autoRefreshInterval: 30 * time.Second,
|
||||
status: "Starting...",
|
||||
resolverInfo: strings.Join(fetcher.Resolvers(), ", "),
|
||||
}
|
||||
|
||||
// Set up fetcher log callback — uses shared pointer so it works
|
||||
// even after bubbletea copies the Model struct.
|
||||
fetcher.SetLogFunc(func(msg string) {
|
||||
lb.append(msg)
|
||||
})
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Init starts the TUI.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.fetchMetadata(),
|
||||
m.tickCmd(),
|
||||
)
|
||||
}
|
||||
|
||||
// Update handles messages.
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.updateViewportSize()
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "tab", "left", "right":
|
||||
// Cycle: channels → messages → log → channels
|
||||
switch m.focus {
|
||||
case focusChannels:
|
||||
m.focus = focusMessages
|
||||
case focusMessages:
|
||||
m.focus = focusLog
|
||||
case focusLog:
|
||||
m.focus = focusChannels
|
||||
}
|
||||
case "up", "k":
|
||||
if m.focus == focusChannels {
|
||||
if m.selectedChan > 0 {
|
||||
m.selectedChan--
|
||||
cmds = append(cmds, m.loadChannel(m.selectedChan+1))
|
||||
}
|
||||
} else if m.focus == focusMessages {
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
} else {
|
||||
var cmd tea.Cmd
|
||||
m.logViewport, cmd = m.logViewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
case "down", "j":
|
||||
if m.focus == focusChannels {
|
||||
if m.selectedChan < len(m.channels)-1 {
|
||||
m.selectedChan++
|
||||
cmds = append(cmds, m.loadChannel(m.selectedChan+1))
|
||||
}
|
||||
} else if m.focus == focusMessages {
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
} else {
|
||||
var cmd tea.Cmd
|
||||
m.logViewport, cmd = m.logViewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
case "r":
|
||||
m.status = "Refreshing..."
|
||||
m.loading = true
|
||||
m.forceRefresh = true
|
||||
cmds = append(cmds, m.fetchMetadata())
|
||||
case "pgup", "pgdown", "home", "end":
|
||||
if m.focus == focusLog {
|
||||
var cmd tea.Cmd
|
||||
m.logViewport, cmd = m.logViewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
} else {
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
case metadataMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
m.status = fmt.Sprintf("Error: %v", msg.err)
|
||||
} else {
|
||||
m.channels = msg.meta.Channels
|
||||
m.serverTimestamp = msg.meta.Timestamp
|
||||
m.marker = msg.meta.Marker
|
||||
m.lastUpdate = time.Now()
|
||||
m.err = nil
|
||||
m.status = fmt.Sprintf("Updated %s | Server: %s",
|
||||
time.Now().Format("15:04:05"),
|
||||
time.Unix(int64(m.serverTimestamp), 0).Format("15:04:05"))
|
||||
|
||||
_ = m.cache.PutMetadata(msg.meta)
|
||||
|
||||
if len(m.channels) > 0 {
|
||||
// Always fetch fresh data after metadata update
|
||||
cmds = append(cmds, m.loadChannelFresh(m.selectedChan+1))
|
||||
}
|
||||
m.forceRefresh = false
|
||||
}
|
||||
|
||||
case channelDataMsg:
|
||||
if msg.err != nil {
|
||||
m.status = fmt.Sprintf("Channel error: %v", msg.err)
|
||||
} else {
|
||||
m.messages[msg.channelNum] = msg.msgs
|
||||
_ = m.cache.PutMessages(msg.channelNum, msg.msgs)
|
||||
m.updateViewportContent()
|
||||
}
|
||||
|
||||
case tickMsg:
|
||||
cmds = append(cmds, m.tickCmd())
|
||||
// Update log viewport content
|
||||
m.updateLogContent()
|
||||
if time.Since(m.lastUpdate) > m.autoRefreshInterval && !m.loading {
|
||||
m.loading = true
|
||||
m.status = "Auto-refreshing..."
|
||||
cmds = append(cmds, m.fetchMetadata())
|
||||
}
|
||||
|
||||
case logMsg:
|
||||
m.logBuf.append(string(msg))
|
||||
m.updateLogContent()
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// View renders the TUI.
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
channelWidth := m.width / 4
|
||||
if channelWidth < 20 {
|
||||
channelWidth = 20
|
||||
}
|
||||
if channelWidth > 40 {
|
||||
channelWidth = 40
|
||||
}
|
||||
messageWidth := m.width - channelWidth - 4
|
||||
|
||||
// Split height: messages get 80%, log panel gets 20% of content area
|
||||
contentHeight := m.height - 3
|
||||
if contentHeight < 10 {
|
||||
contentHeight = 10
|
||||
}
|
||||
msgHeight := contentHeight * 8 / 10
|
||||
logHeight := contentHeight - msgHeight
|
||||
if logHeight < 3 {
|
||||
logHeight = 3
|
||||
msgHeight = contentHeight - logHeight
|
||||
}
|
||||
|
||||
channelContent := m.renderChannelList(channelWidth-4, msgHeight-2)
|
||||
borderColor := "62"
|
||||
if m.focus == focusChannels {
|
||||
borderColor = "229"
|
||||
}
|
||||
channelPanel := channelListStyle.
|
||||
BorderForeground(lipgloss.Color(borderColor)).
|
||||
Width(channelWidth - 2).
|
||||
Height(msgHeight).
|
||||
Render(channelContent)
|
||||
|
||||
m.updateViewportContent()
|
||||
messageTitle := " Messages "
|
||||
if m.selectedChan < len(m.channels) {
|
||||
ch := m.channels[m.selectedChan]
|
||||
messageTitle = fmt.Sprintf(" %s (%d blocks) ", ch.Name, ch.Blocks)
|
||||
}
|
||||
messageContent := titleStyle.Render(messageTitle) + "\n" + m.viewport.View()
|
||||
msgBorderColor := "62"
|
||||
if m.focus == focusMessages {
|
||||
msgBorderColor = "229"
|
||||
}
|
||||
messagePanel := messageViewStyle.
|
||||
BorderForeground(lipgloss.Color(msgBorderColor)).
|
||||
Width(messageWidth - 2).
|
||||
Height(msgHeight).
|
||||
Render(messageContent)
|
||||
|
||||
topContent := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, messagePanel)
|
||||
|
||||
// Log panel (full width)
|
||||
m.updateLogContent()
|
||||
logBorderColor := "240"
|
||||
if m.focus == focusLog {
|
||||
logBorderColor = "229"
|
||||
}
|
||||
logTitle := titleStyle.Render(" Log ")
|
||||
logContent := logTitle + "\n" + m.logViewport.View()
|
||||
logPanel := logViewStyle.
|
||||
BorderForeground(lipgloss.Color(logBorderColor)).
|
||||
Width(m.width - 4).
|
||||
Height(logHeight).
|
||||
Render(logContent)
|
||||
|
||||
// Status bar
|
||||
statusLeft := m.status
|
||||
if m.loading {
|
||||
statusLeft = "... " + statusLeft
|
||||
}
|
||||
resolverStr := ""
|
||||
if m.resolverInfo != "" {
|
||||
resolverStr = " | DNS: " + truncateStr(m.resolverInfo, 30)
|
||||
}
|
||||
versionStr := ""
|
||||
if version.Version != "" {
|
||||
versionStr = " v" + version.Version
|
||||
}
|
||||
statusRight := fmt.Sprintf("Tab/←→:switch j/k:nav r:refresh q:quit%s%s", resolverStr, versionStr)
|
||||
|
||||
gap := m.width - utf8.RuneCountInString(statusLeft) - utf8.RuneCountInString(statusRight) - 2
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
statusBar := statusStyle.Width(m.width).Render(
|
||||
statusLeft + strings.Repeat(" ", gap) + statusRight,
|
||||
)
|
||||
|
||||
return topContent + "\n" + logPanel + "\n" + statusBar
|
||||
}
|
||||
|
||||
func (m Model) renderChannelList(width, height int) string {
|
||||
title := titleStyle.Render(" Channels ")
|
||||
var lines []string
|
||||
lines = append(lines, title)
|
||||
|
||||
if len(m.channels) == 0 {
|
||||
lines = append(lines, " No channels")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
for i, ch := range m.channels {
|
||||
prefix := " "
|
||||
style := normalStyle
|
||||
if i == m.selectedChan {
|
||||
prefix = "> "
|
||||
if m.focus == focusChannels {
|
||||
style = selectedStyle
|
||||
} else {
|
||||
style = lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Bold(true)
|
||||
}
|
||||
}
|
||||
name := truncateStr(ch.Name, width-4)
|
||||
line := style.Render(fmt.Sprintf("%s%d. %s", prefix, i+1, name))
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
for len(lines) < height {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// wrapText wraps a line to fit within maxWidth, returning multiple lines.
|
||||
func wrapText(s string, maxWidth int) []string {
|
||||
if maxWidth <= 0 {
|
||||
return []string{s}
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxWidth {
|
||||
return []string{s}
|
||||
}
|
||||
var lines []string
|
||||
for len(runes) > maxWidth {
|
||||
// Try to break at a space within the last portion
|
||||
breakAt := maxWidth
|
||||
for i := maxWidth; i > maxWidth/2; i-- {
|
||||
if runes[i] == ' ' {
|
||||
breakAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
lines = append(lines, string(runes[:breakAt]))
|
||||
runes = runes[breakAt:]
|
||||
// Skip leading space on next line
|
||||
if len(runes) > 0 && runes[0] == ' ' {
|
||||
runes = runes[1:]
|
||||
}
|
||||
}
|
||||
if len(runes) > 0 {
|
||||
lines = append(lines, string(runes))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m *Model) updateViewportContent() {
|
||||
chNum := m.selectedChan + 1
|
||||
msgs, ok := m.messages[chNum]
|
||||
if !ok {
|
||||
cached := m.cache.GetMessages(chNum, 5*time.Minute)
|
||||
if cached != nil {
|
||||
m.messages[chNum] = cached
|
||||
msgs = cached
|
||||
}
|
||||
}
|
||||
|
||||
if len(msgs) == 0 {
|
||||
m.viewport.SetContent(" No messages yet. Press r to refresh.")
|
||||
return
|
||||
}
|
||||
|
||||
wrapWidth := m.viewport.Width - 4
|
||||
if wrapWidth < 20 {
|
||||
wrapWidth = 20
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, msg := range msgs {
|
||||
ts := time.Unix(int64(msg.Timestamp), 0).Format("15:04 Jan 02")
|
||||
header := fmt.Sprintf("%s %s",
|
||||
timestampStyle.Render(ts),
|
||||
msgIDStyle.Render(fmt.Sprintf("#%d", msg.ID)))
|
||||
|
||||
lines = append(lines, header)
|
||||
for _, textLine := range strings.Split(msg.Text, "\n") {
|
||||
wrapped := wrapText(textLine, wrapWidth-2)
|
||||
for _, wl := range wrapped {
|
||||
lines = append(lines, " "+wl)
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
m.viewport.SetContent(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (m *Model) updateLogContent() {
|
||||
if len(m.logBuf.lines) == 0 {
|
||||
m.logViewport.SetContent(" Waiting for DNS queries...")
|
||||
return
|
||||
}
|
||||
content := strings.Join(m.logBuf.lines, "\n")
|
||||
m.logViewport.SetContent(content)
|
||||
m.logViewport.GotoBottom()
|
||||
}
|
||||
|
||||
func (m *Model) updateViewportSize() {
|
||||
channelWidth := m.width / 4
|
||||
if channelWidth < 20 {
|
||||
channelWidth = 20
|
||||
}
|
||||
if channelWidth > 40 {
|
||||
channelWidth = 40
|
||||
}
|
||||
messageWidth := m.width - channelWidth - 4
|
||||
|
||||
contentHeight := m.height - 3
|
||||
if contentHeight < 10 {
|
||||
contentHeight = 10
|
||||
}
|
||||
msgHeight := contentHeight * 8 / 10
|
||||
logHeight := contentHeight - msgHeight
|
||||
if logHeight < 3 {
|
||||
logHeight = 3
|
||||
msgHeight = contentHeight - logHeight
|
||||
}
|
||||
|
||||
m.viewport.Width = messageWidth - 4
|
||||
m.viewport.Height = msgHeight - 4
|
||||
if m.viewport.Height < 1 {
|
||||
m.viewport.Height = 1
|
||||
}
|
||||
|
||||
m.logViewport.Width = m.width - 8
|
||||
m.logViewport.Height = logHeight - 4
|
||||
if m.logViewport.Height < 1 {
|
||||
m.logViewport.Height = 1
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) fetchMetadata() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
meta, err := m.fetcher.FetchMetadata()
|
||||
return metadataMsg{meta: meta, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) loadChannel(channelNum int) tea.Cmd {
|
||||
if !m.forceRefresh {
|
||||
if msgs := m.cache.GetMessages(channelNum, 1*time.Minute); msgs != nil {
|
||||
return func() tea.Msg {
|
||||
return channelDataMsg{channelNum: channelNum, msgs: msgs}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blockCount := 0
|
||||
idx := channelNum - 1
|
||||
if idx >= 0 && idx < len(m.channels) {
|
||||
blockCount = int(m.channels[idx].Blocks)
|
||||
}
|
||||
|
||||
return func() tea.Msg {
|
||||
msgs, err := m.fetcher.FetchChannel(channelNum, blockCount)
|
||||
return channelDataMsg{channelNum: channelNum, msgs: msgs, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) loadChannelFresh(channelNum int) tea.Cmd {
|
||||
blockCount := 0
|
||||
idx := channelNum - 1
|
||||
if idx >= 0 && idx < len(m.channels) {
|
||||
blockCount = int(m.channels[idx].Blocks)
|
||||
}
|
||||
|
||||
return func() tea.Msg {
|
||||
msgs, err := m.fetcher.FetchChannel(channelNum, blockCount)
|
||||
return channelDataMsg{channelNum: channelNum, msgs: msgs, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) tickCmd() tea.Cmd {
|
||||
return tea.Tick(5*time.Second, func(time.Time) tea.Msg {
|
||||
return tickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
if utf8.RuneCountInString(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:maxLen-1]) + "..."
|
||||
}
|
||||
|
||||
// Run starts the TUI application.
|
||||
func Run(fetcher *client.Fetcher, cache *client.Cache) error {
|
||||
m := New(fetcher, cache)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package version
|
||||
|
||||
// Set via ldflags at build time.
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
Date = "unknown"
|
||||
)
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/bin/bash
|
||||
|
||||
red='\033[0;31m'
|
||||
green='\033[0;32m'
|
||||
blue='\033[0;34m'
|
||||
yellow='\033[0;33m'
|
||||
plain='\033[0m'
|
||||
|
||||
GITHUB_REPO="sartoopjj/thefeed"
|
||||
INSTALL_DIR="/opt/thefeed"
|
||||
CONFIG_DIR="/etc/thefeed"
|
||||
SESSION_DIR="/var/lib/thefeed"
|
||||
SERVICE_FILE="/etc/systemd/system/thefeed-server.service"
|
||||
|
||||
# check root
|
||||
[[ $EUID -ne 0 ]] && echo -e "${red}Fatal error:${plain} Please run this script with root privilege" && exit 1
|
||||
|
||||
# Check OS and set release variable
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
source /etc/os-release
|
||||
release=$ID
|
||||
elif [[ -f /usr/lib/os-release ]]; then
|
||||
source /usr/lib/os-release
|
||||
release=$ID
|
||||
else
|
||||
echo -e "${red}Failed to check the system OS, please contact the author!${plain}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo -e "OS: ${green}$release${plain}"
|
||||
|
||||
arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64 | x64 | amd64) echo 'amd64' ;;
|
||||
armv8* | armv8 | arm64 | aarch64) echo 'arm64' ;;
|
||||
*) echo -e "${red}Unsupported CPU architecture: $(uname -m)${plain}" && exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo -e "Arch: ${green}$(arch)${plain}"
|
||||
|
||||
install_base() {
|
||||
echo -e "${green}Installing base dependencies...${plain}"
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
apt-get update && apt-get install -y -q curl tar ca-certificates
|
||||
;;
|
||||
fedora | amzn | rhel | almalinux | rocky | ol)
|
||||
dnf -y update && dnf install -y -q curl tar ca-certificates
|
||||
;;
|
||||
centos)
|
||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||
yum -y update && yum install -y curl tar ca-certificates
|
||||
else
|
||||
dnf -y update && dnf install -y -q curl tar ca-certificates
|
||||
fi
|
||||
;;
|
||||
arch | manjaro | parch)
|
||||
pacman -Syu --noconfirm curl tar ca-certificates
|
||||
;;
|
||||
alpine)
|
||||
apk update && apk add curl tar ca-certificates bash
|
||||
;;
|
||||
*)
|
||||
apt-get update && apt-get install -y -q curl tar ca-certificates
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_latest_version() {
|
||||
local version
|
||||
version=$(curl -Ls "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if [[ -z "$version" ]]; then
|
||||
echo -e "${yellow}Trying with IPv4...${plain}" >&2
|
||||
version=$(curl -4 -Ls "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
fi
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local version="$1"
|
||||
local arch_name
|
||||
arch_name=$(arch)
|
||||
local binary_name="thefeed-server-linux-${arch_name}"
|
||||
local url="https://github.com/${GITHUB_REPO}/releases/download/${version}/${binary_name}"
|
||||
|
||||
echo -e "${green}Downloading thefeed-server ${version} for linux/${arch_name}...${plain}"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
curl -4fLo "${INSTALL_DIR}/thefeed-server" "$url"
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Failed to download binary from:${plain}"
|
||||
echo -e "${red} ${url}${plain}"
|
||||
echo -e "${yellow}Please check that the version exists and your server can reach GitHub${plain}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod 755 "${INSTALL_DIR}/thefeed-server"
|
||||
echo -e "${green}Binary installed to ${INSTALL_DIR}/thefeed-server${plain}"
|
||||
}
|
||||
|
||||
setup_config() {
|
||||
mkdir -p "$CONFIG_DIR" "$SESSION_DIR"
|
||||
|
||||
# Channels file
|
||||
if [[ ! -f "$CONFIG_DIR/channels.txt" ]]; then
|
||||
echo -e "\n${green}Setting up channels...${plain}"
|
||||
echo "# Telegram channel usernames (one per line)" > "$CONFIG_DIR/channels.txt"
|
||||
echo "# Lines starting with # are comments" >> "$CONFIG_DIR/channels.txt"
|
||||
|
||||
echo ""
|
||||
echo -e "${yellow}Enter Telegram channel usernames (one per line, empty line to finish):${plain}"
|
||||
while true; do
|
||||
read -rp " Channel: " channel
|
||||
if [[ -z "$channel" ]]; then
|
||||
break
|
||||
fi
|
||||
channel="${channel#@}"
|
||||
echo "@$channel" >> "$CONFIG_DIR/channels.txt"
|
||||
echo -e " ${green}Added @${channel}${plain}"
|
||||
done
|
||||
else
|
||||
echo -e "${yellow}Channels file already exists: ${CONFIG_DIR}/channels.txt${plain}"
|
||||
fi
|
||||
|
||||
# Environment file
|
||||
if [[ ! -f "$CONFIG_DIR/thefeed.env" ]]; then
|
||||
echo -e "\n${green}═══════════════════════════════════════${plain}"
|
||||
echo -e "${green} Server Configuration${plain}"
|
||||
echo -e "${green}═══════════════════════════════════════${plain}"
|
||||
echo ""
|
||||
|
||||
local domain=""
|
||||
while true; do
|
||||
read -rp "DNS domain (e.g., t.example.com): " domain
|
||||
if [[ -n "$domain" ]]; then break; fi
|
||||
echo -e "${red}Domain cannot be empty${plain}"
|
||||
done
|
||||
|
||||
local passkey=""
|
||||
while true; do
|
||||
read -rp "Encryption passphrase: " passkey
|
||||
if [[ -n "$passkey" ]]; then break; fi
|
||||
echo -e "${red}Passphrase cannot be empty${plain}"
|
||||
done
|
||||
|
||||
local api_id=""
|
||||
while true; do
|
||||
read -rp "Telegram API ID: " api_id
|
||||
if [[ "$api_id" =~ ^[0-9]+$ ]]; then break; fi
|
||||
echo -e "${red}API ID must be a number${plain}"
|
||||
done
|
||||
|
||||
local api_hash=""
|
||||
while true; do
|
||||
read -rp "Telegram API Hash: " api_hash
|
||||
if [[ -n "$api_hash" ]]; then break; fi
|
||||
echo -e "${red}API Hash cannot be empty${plain}"
|
||||
done
|
||||
|
||||
local phone=""
|
||||
while true; do
|
||||
read -rp "Telegram phone number (e.g., +1234567890): " phone
|
||||
if [[ -n "$phone" ]]; then break; fi
|
||||
echo -e "${red}Phone number cannot be empty${plain}"
|
||||
done
|
||||
|
||||
read -rp "DNS listen address [0.0.0.0:53]: " listen_addr
|
||||
listen_addr="${listen_addr:-0.0.0.0:53}"
|
||||
|
||||
cat > "$CONFIG_DIR/thefeed.env" <<ENVEOF
|
||||
THEFEED_DOMAIN=${domain}
|
||||
THEFEED_KEY=${passkey}
|
||||
TELEGRAM_API_ID=${api_id}
|
||||
TELEGRAM_API_HASH=${api_hash}
|
||||
TELEGRAM_PHONE=${phone}
|
||||
THEFEED_LISTEN=${listen_addr}
|
||||
ENVEOF
|
||||
chmod 600 "$CONFIG_DIR/thefeed.env"
|
||||
echo -e "${green}Config saved to ${CONFIG_DIR}/thefeed.env${plain}"
|
||||
else
|
||||
echo -e "${yellow}Config already exists: ${CONFIG_DIR}/thefeed.env${plain}"
|
||||
fi
|
||||
|
||||
chmod 700 "$SESSION_DIR"
|
||||
}
|
||||
|
||||
telegram_login() {
|
||||
echo -e "\n${green}═══════════════════════════════════════${plain}"
|
||||
echo -e "${green} Telegram Login (one-time)${plain}"
|
||||
echo -e "${green}═══════════════════════════════════════${plain}"
|
||||
echo -e "${yellow}This will authenticate with Telegram and save the session.${plain}"
|
||||
echo ""
|
||||
|
||||
set -a
|
||||
source "$CONFIG_DIR/thefeed.env"
|
||||
set +a
|
||||
|
||||
"$INSTALL_DIR/thefeed-server" \
|
||||
--login-only \
|
||||
--domain "$THEFEED_DOMAIN" \
|
||||
--key "$THEFEED_KEY" \
|
||||
--api-id "$TELEGRAM_API_ID" \
|
||||
--api-hash "$TELEGRAM_API_HASH" \
|
||||
--phone "$TELEGRAM_PHONE" \
|
||||
--channels "$CONFIG_DIR/channels.txt" \
|
||||
--session "$SESSION_DIR/session.json"
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${red}Telegram login failed${plain}"
|
||||
echo -e "${yellow}You can retry later with:${plain}"
|
||||
echo -e " sudo ${INSTALL_DIR}/thefeed-server --login-only --session ${SESSION_DIR}/session.json ..."
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod 600 "$SESSION_DIR/session.json"
|
||||
echo -e "${green}Telegram login successful, session saved.${plain}"
|
||||
}
|
||||
|
||||
install_service() {
|
||||
echo -e "${green}Installing systemd service...${plain}"
|
||||
|
||||
set -a
|
||||
source "$CONFIG_DIR/thefeed.env"
|
||||
set +a
|
||||
|
||||
cat > "$SERVICE_FILE" <<SVCEOF
|
||||
[Unit]
|
||||
Description=thefeed DNS-based Telegram Feed Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=${CONFIG_DIR}/thefeed.env
|
||||
ExecStart=${INSTALL_DIR}/thefeed-server \\
|
||||
--domain \${THEFEED_DOMAIN} \\
|
||||
--key \${THEFEED_KEY} \\
|
||||
--api-id \${TELEGRAM_API_ID} \\
|
||||
--api-hash \${TELEGRAM_API_HASH} \\
|
||||
--phone \${TELEGRAM_PHONE} \\
|
||||
--channels ${CONFIG_DIR}/channels.txt \\
|
||||
--session ${SESSION_DIR}/session.json \\
|
||||
--listen \${THEFEED_LISTEN:-0.0.0.0:53}
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Security hardening
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVCEOF
|
||||
|
||||
systemctl daemon-reload
|
||||
echo -e "${green}Service installed: thefeed-server${plain}"
|
||||
}
|
||||
|
||||
start_service() {
|
||||
echo -e "${green}Enabling and starting service...${plain}"
|
||||
systemctl enable thefeed-server
|
||||
systemctl start thefeed-server
|
||||
echo ""
|
||||
echo -e "${green}Service status:${plain}"
|
||||
systemctl status thefeed-server --no-pager || true
|
||||
}
|
||||
|
||||
show_usage() {
|
||||
echo ""
|
||||
echo -e "┌─────────────────────────────────────────────────────┐"
|
||||
echo -e "│ ${blue}thefeed service management:${plain} │"
|
||||
echo -e "│ │"
|
||||
echo -e "│ ${blue}systemctl status thefeed-server${plain} - Status │"
|
||||
echo -e "│ ${blue}systemctl restart thefeed-server${plain} - Restart │"
|
||||
echo -e "│ ${blue}systemctl stop thefeed-server${plain} - Stop │"
|
||||
echo -e "│ ${blue}journalctl -u thefeed-server -f${plain} - Live logs │"
|
||||
echo -e "│ │"
|
||||
echo -e "│ ${blue}Config:${plain} ${CONFIG_DIR}/thefeed.env │"
|
||||
echo -e "│ ${blue}Channels:${plain} ${CONFIG_DIR}/channels.txt │"
|
||||
echo -e "│ ${blue}Session:${plain} ${SESSION_DIR}/session.json │"
|
||||
echo -e "│ ${blue}Binary:${plain} ${INSTALL_DIR}/thefeed-server │"
|
||||
echo -e "│ │"
|
||||
echo -e "│ ${yellow}Update:${plain} sudo bash install.sh │"
|
||||
echo -e "│ ${yellow}Re-login:${plain} sudo bash install.sh --login │"
|
||||
echo -e "└─────────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
}
|
||||
|
||||
install_thefeed() {
|
||||
local version="$1"
|
||||
|
||||
# Get version
|
||||
if [[ -z "$version" ]]; then
|
||||
version=$(get_latest_version)
|
||||
if [[ -z "$version" ]]; then
|
||||
echo -e "${red}Failed to fetch latest version from GitHub${plain}"
|
||||
echo -e "${yellow}Please check your network or specify a version: bash install.sh v1.0.0${plain}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo -e "Version: ${green}${version}${plain}"
|
||||
|
||||
# Check current version
|
||||
if [[ -f "${INSTALL_DIR}/thefeed-server" ]]; then
|
||||
local current_version
|
||||
current_version=$("${INSTALL_DIR}/thefeed-server" --version 2>&1 | awk '{print $2}' || echo "unknown")
|
||||
echo -e "Current: ${yellow}${current_version}${plain}"
|
||||
if [[ "$current_version" == "$version" ]]; then
|
||||
echo -e "${yellow}Already running ${version}. Reinstalling anyway...${plain}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Stop existing service
|
||||
if systemctl is-active thefeed-server &>/dev/null; then
|
||||
echo -e "${yellow}Stopping existing service...${plain}"
|
||||
systemctl stop thefeed-server
|
||||
fi
|
||||
|
||||
# Download
|
||||
download_binary "$version"
|
||||
|
||||
# First install: full setup
|
||||
if [[ ! -f "$CONFIG_DIR/thefeed.env" ]]; then
|
||||
setup_config
|
||||
telegram_login
|
||||
install_service
|
||||
start_service
|
||||
else
|
||||
# Update: just restart
|
||||
if [[ ! -f "$SERVICE_FILE" ]]; then
|
||||
install_service
|
||||
fi
|
||||
start_service
|
||||
fi
|
||||
|
||||
echo -e "\n${green}═══════════════════════════════════════${plain}"
|
||||
echo -e "${green} thefeed ${version} installed successfully!${plain}"
|
||||
echo -e "${green}═══════════════════════════════════════${plain}"
|
||||
show_usage
|
||||
}
|
||||
|
||||
login_only() {
|
||||
if [[ ! -f "$CONFIG_DIR/thefeed.env" ]]; then
|
||||
echo -e "${red}Config not found. Run install first: bash install.sh${plain}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "${INSTALL_DIR}/thefeed-server" ]]; then
|
||||
echo -e "${red}Binary not found. Run install first: bash install.sh${plain}"
|
||||
exit 1
|
||||
fi
|
||||
telegram_login
|
||||
echo -e "${green}Restarting service...${plain}"
|
||||
systemctl restart thefeed-server || true
|
||||
}
|
||||
|
||||
uninstall_thefeed() {
|
||||
echo -e "${yellow}Uninstalling thefeed...${plain}"
|
||||
|
||||
systemctl stop thefeed-server 2>/dev/null || true
|
||||
systemctl disable thefeed-server 2>/dev/null || true
|
||||
rm -f "$SERVICE_FILE"
|
||||
systemctl daemon-reload
|
||||
|
||||
rm -rf "$INSTALL_DIR"
|
||||
|
||||
read -rp "Remove config and session data? [y/N]: " remove_data
|
||||
if [[ "$remove_data" == "y" || "$remove_data" == "Y" ]]; then
|
||||
rm -rf "$CONFIG_DIR" "$SESSION_DIR"
|
||||
echo -e "${green}Config and session data removed${plain}"
|
||||
fi
|
||||
|
||||
echo -e "${green}thefeed uninstalled successfully${plain}"
|
||||
}
|
||||
|
||||
show_help() {
|
||||
echo -e "thefeed install script"
|
||||
echo ""
|
||||
echo -e "Usage: bash $0 [OPTION]"
|
||||
echo ""
|
||||
echo -e "Options:"
|
||||
echo -e " ${green}(no args)${plain} Install or update to latest version"
|
||||
echo -e " ${green}v1.0.0${plain} Install specific version"
|
||||
echo -e " ${green}--login${plain} Re-authenticate with Telegram"
|
||||
echo -e " ${green}--uninstall${plain} Remove thefeed"
|
||||
echo -e " ${green}--help${plain} Show this help"
|
||||
}
|
||||
|
||||
# Main
|
||||
echo -e "${green}Running thefeed installer...${plain}"
|
||||
|
||||
case "${1:-}" in
|
||||
--help | -h)
|
||||
show_help
|
||||
;;
|
||||
--login)
|
||||
login_only
|
||||
;;
|
||||
--uninstall)
|
||||
uninstall_thefeed
|
||||
;;
|
||||
*)
|
||||
install_base
|
||||
install_thefeed "$1"
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user