commit 5f43bfe076a39cd8c95b135bfb8ee9a75782110f Author: Sarto Date: Wed Mar 25 11:30:02 2026 +0330 feat: :tada: first version diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6111529 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..491b5af --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3ea51ee --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..26028ec --- /dev/null +++ b/README.md @@ -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 diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..6b026d1 --- /dev/null +++ b/cmd/client/main.go @@ -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 +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..b3fc3ba --- /dev/null +++ b/cmd/server/main.go @@ -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") +} diff --git a/configs/channels.txt b/configs/channels.txt new file mode 100644 index 0000000..133fdce --- /dev/null +++ b/configs/channels.txt @@ -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 diff --git a/configs/resolvers.txt b/configs/resolvers.txt new file mode 100644 index 0000000..48ec139 --- /dev/null +++ b/configs/resolvers.txt @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8573f11 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..74ebc56 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/client/cache.go b/internal/client/cache.go new file mode 100644 index 0000000..6ebf75c --- /dev/null +++ b/internal/client/cache.go @@ -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)) +} diff --git a/internal/client/cache_test.go b/internal/client/cache_test.go new file mode 100644 index 0000000..383a865 --- /dev/null +++ b/internal/client/cache_test.go @@ -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") + } +} diff --git a/internal/client/fetcher.go b/internal/client/fetcher.go new file mode 100644 index 0000000..4ff6090 --- /dev/null +++ b/internal/client/fetcher.go @@ -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) +} diff --git a/internal/client/resolver.go b/internal/client/resolver.go new file mode 100644 index 0000000..7494af2 --- /dev/null +++ b/internal/client/resolver.go @@ -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 + } + } +} diff --git a/internal/protocol/crypto.go b/internal/protocol/crypto.go new file mode 100644 index 0000000..c82ccd6 --- /dev/null +++ b/internal/protocol/crypto.go @@ -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) +} diff --git a/internal/protocol/crypto_test.go b/internal/protocol/crypto_test.go new file mode 100644 index 0000000..a509da3 --- /dev/null +++ b/internal/protocol/crypto_test.go @@ -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") + } +} diff --git a/internal/protocol/dns.go b/internal/protocol/dns.go new file mode 100644 index 0000000..a61e1ad --- /dev/null +++ b/internal/protocol/dns.go @@ -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 +} diff --git a/internal/protocol/dns_test.go b/internal/protocol/dns_test.go new file mode 100644 index 0000000..49ad3b3 --- /dev/null +++ b/internal/protocol/dns_test.go @@ -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) + } +} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..fd4e63f --- /dev/null +++ b/internal/protocol/protocol.go @@ -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 +} diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go new file mode 100644 index 0000000..98c0fca --- /dev/null +++ b/internal/protocol/protocol_test.go @@ -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") + } +} diff --git a/internal/server/dns.go b/internal/server/dns.go new file mode 100644 index 0000000..17e2d91 --- /dev/null +++ b/internal/server/dns.go @@ -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 +} diff --git a/internal/server/feed.go b/internal/server/feed.go new file mode 100644 index 0000000..b3592f4 --- /dev/null +++ b/internal/server/feed.go @@ -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 +} diff --git a/internal/server/feed_test.go b/internal/server/feed_test.go new file mode 100644 index 0000000..285e12c --- /dev/null +++ b/internal/server/feed_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..8644a41 --- /dev/null +++ b/internal/server/server.go @@ -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() +} diff --git a/internal/server/telegram.go b/internal/server/telegram.go new file mode 100644 index 0000000..775b2ca --- /dev/null +++ b/internal/server/telegram.go @@ -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 +} diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..47e9380 --- /dev/null +++ b/internal/tui/app.go @@ -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 +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..1079c8b --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,8 @@ +package version + +// Set via ldflags at build time. +var ( + Version = "dev" + Commit = "unknown" + Date = "unknown" +) diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..5291a88 --- /dev/null +++ b/scripts/install.sh @@ -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" < "$SERVICE_FILE" <&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