feat: 🎉 first version

This commit is contained in:
Sarto
2026-03-25 11:30:02 +03:30
commit 5f43bfe076
28 changed files with 4151 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
name: Build
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
permissions:
contents: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- name: Test
run: go test -race -count=1 ./...
- name: Vet
run: go vet ./...
build:
needs: test
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- name: Build Server
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
run: |
VERSION=${GITHUB_REF_NAME:-dev}
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS="-s -w -X github.com/sartoopjj/thefeed/internal/version.Version=${VERSION} -X github.com/sartoopjj/thefeed/internal/version.Commit=${COMMIT} -X github.com/sartoopjj/thefeed/internal/version.Date=${DATE}"
ext=""
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
go build -trimpath -ldflags="${LDFLAGS}" -o build/thefeed-server-${{ matrix.goos }}-${{ matrix.goarch }}${ext} ./cmd/server
- name: Build Client
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
run: |
VERSION=${GITHUB_REF_NAME:-dev}
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS="-s -w -X github.com/sartoopjj/thefeed/internal/version.Version=${VERSION} -X github.com/sartoopjj/thefeed/internal/version.Commit=${COMMIT} -X github.com/sartoopjj/thefeed/internal/version.Date=${DATE}"
ext=""
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
go build -trimpath -ldflags="${LDFLAGS}" -o build/thefeed-client-${{ matrix.goos }}-${{ matrix.goarch }}${ext} ./cmd/client
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
path: build/
release:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
+21
View File
@@ -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
+72
View File
@@ -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
+249
View File
@@ -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
+154
View File
@@ -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
}
+127
View File
@@ -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")
}
+12
View File
@@ -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
+39
View File
@@ -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
+67
View File
@@ -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
)
+147
View File
@@ -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=
+122
View File
@@ -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))
}
+76
View File
@@ -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")
}
}
+252
View File
@@ -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)
}
+181
View File
@@ -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
}
}
}
+69
View File
@@ -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)
}
+80
View File
@@ -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")
}
}
+141
View File
@@ -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
}
+164
View File
@@ -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)
}
}
+229
View File
@@ -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
}
+120
View File
@@ -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")
}
}
+124
View File
@@ -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
}
+111
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+95
View File
@@ -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()
}
+308
View File
@@ -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
}
+590
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
package version
// Set via ldflags at build time.
var (
Version = "dev"
Commit = "unknown"
Date = "unknown"
)
+408
View File
@@ -0,0 +1,408 @@
#!/bin/bash
red='\033[0;31m'
green='\033[0;32m'
blue='\033[0;34m'
yellow='\033[0;33m'
plain='\033[0m'
GITHUB_REPO="sartoopjj/thefeed"
INSTALL_DIR="/opt/thefeed"
CONFIG_DIR="/etc/thefeed"
SESSION_DIR="/var/lib/thefeed"
SERVICE_FILE="/etc/systemd/system/thefeed-server.service"
# check root
[[ $EUID -ne 0 ]] && echo -e "${red}Fatal error:${plain} Please run this script with root privilege" && exit 1
# Check OS and set release variable
if [[ -f /etc/os-release ]]; then
source /etc/os-release
release=$ID
elif [[ -f /usr/lib/os-release ]]; then
source /usr/lib/os-release
release=$ID
else
echo -e "${red}Failed to check the system OS, please contact the author!${plain}" >&2
exit 1
fi
echo -e "OS: ${green}$release${plain}"
arch() {
case "$(uname -m)" in
x86_64 | x64 | amd64) echo 'amd64' ;;
armv8* | armv8 | arm64 | aarch64) echo 'arm64' ;;
*) echo -e "${red}Unsupported CPU architecture: $(uname -m)${plain}" && exit 1 ;;
esac
}
echo -e "Arch: ${green}$(arch)${plain}"
install_base() {
echo -e "${green}Installing base dependencies...${plain}"
case "${release}" in
ubuntu | debian | armbian)
apt-get update && apt-get install -y -q curl tar ca-certificates
;;
fedora | amzn | rhel | almalinux | rocky | ol)
dnf -y update && dnf install -y -q curl tar ca-certificates
;;
centos)
if [[ "${VERSION_ID}" =~ ^7 ]]; then
yum -y update && yum install -y curl tar ca-certificates
else
dnf -y update && dnf install -y -q curl tar ca-certificates
fi
;;
arch | manjaro | parch)
pacman -Syu --noconfirm curl tar ca-certificates
;;
alpine)
apk update && apk add curl tar ca-certificates bash
;;
*)
apt-get update && apt-get install -y -q curl tar ca-certificates
;;
esac
}
get_latest_version() {
local version
version=$(curl -Ls "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ -z "$version" ]]; then
echo -e "${yellow}Trying with IPv4...${plain}" >&2
version=$(curl -4 -Ls "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
fi
echo "$version"
}
download_binary() {
local version="$1"
local arch_name
arch_name=$(arch)
local binary_name="thefeed-server-linux-${arch_name}"
local url="https://github.com/${GITHUB_REPO}/releases/download/${version}/${binary_name}"
echo -e "${green}Downloading thefeed-server ${version} for linux/${arch_name}...${plain}"
mkdir -p "$INSTALL_DIR"
curl -4fLo "${INSTALL_DIR}/thefeed-server" "$url"
if [[ $? -ne 0 ]]; then
echo -e "${red}Failed to download binary from:${plain}"
echo -e "${red} ${url}${plain}"
echo -e "${yellow}Please check that the version exists and your server can reach GitHub${plain}"
exit 1
fi
chmod 755 "${INSTALL_DIR}/thefeed-server"
echo -e "${green}Binary installed to ${INSTALL_DIR}/thefeed-server${plain}"
}
setup_config() {
mkdir -p "$CONFIG_DIR" "$SESSION_DIR"
# Channels file
if [[ ! -f "$CONFIG_DIR/channels.txt" ]]; then
echo -e "\n${green}Setting up channels...${plain}"
echo "# Telegram channel usernames (one per line)" > "$CONFIG_DIR/channels.txt"
echo "# Lines starting with # are comments" >> "$CONFIG_DIR/channels.txt"
echo ""
echo -e "${yellow}Enter Telegram channel usernames (one per line, empty line to finish):${plain}"
while true; do
read -rp " Channel: " channel
if [[ -z "$channel" ]]; then
break
fi
channel="${channel#@}"
echo "@$channel" >> "$CONFIG_DIR/channels.txt"
echo -e " ${green}Added @${channel}${plain}"
done
else
echo -e "${yellow}Channels file already exists: ${CONFIG_DIR}/channels.txt${plain}"
fi
# Environment file
if [[ ! -f "$CONFIG_DIR/thefeed.env" ]]; then
echo -e "\n${green}═══════════════════════════════════════${plain}"
echo -e "${green} Server Configuration${plain}"
echo -e "${green}═══════════════════════════════════════${plain}"
echo ""
local domain=""
while true; do
read -rp "DNS domain (e.g., t.example.com): " domain
if [[ -n "$domain" ]]; then break; fi
echo -e "${red}Domain cannot be empty${plain}"
done
local passkey=""
while true; do
read -rp "Encryption passphrase: " passkey
if [[ -n "$passkey" ]]; then break; fi
echo -e "${red}Passphrase cannot be empty${plain}"
done
local api_id=""
while true; do
read -rp "Telegram API ID: " api_id
if [[ "$api_id" =~ ^[0-9]+$ ]]; then break; fi
echo -e "${red}API ID must be a number${plain}"
done
local api_hash=""
while true; do
read -rp "Telegram API Hash: " api_hash
if [[ -n "$api_hash" ]]; then break; fi
echo -e "${red}API Hash cannot be empty${plain}"
done
local phone=""
while true; do
read -rp "Telegram phone number (e.g., +1234567890): " phone
if [[ -n "$phone" ]]; then break; fi
echo -e "${red}Phone number cannot be empty${plain}"
done
read -rp "DNS listen address [0.0.0.0:53]: " listen_addr
listen_addr="${listen_addr:-0.0.0.0:53}"
cat > "$CONFIG_DIR/thefeed.env" <<ENVEOF
THEFEED_DOMAIN=${domain}
THEFEED_KEY=${passkey}
TELEGRAM_API_ID=${api_id}
TELEGRAM_API_HASH=${api_hash}
TELEGRAM_PHONE=${phone}
THEFEED_LISTEN=${listen_addr}
ENVEOF
chmod 600 "$CONFIG_DIR/thefeed.env"
echo -e "${green}Config saved to ${CONFIG_DIR}/thefeed.env${plain}"
else
echo -e "${yellow}Config already exists: ${CONFIG_DIR}/thefeed.env${plain}"
fi
chmod 700 "$SESSION_DIR"
}
telegram_login() {
echo -e "\n${green}═══════════════════════════════════════${plain}"
echo -e "${green} Telegram Login (one-time)${plain}"
echo -e "${green}═══════════════════════════════════════${plain}"
echo -e "${yellow}This will authenticate with Telegram and save the session.${plain}"
echo ""
set -a
source "$CONFIG_DIR/thefeed.env"
set +a
"$INSTALL_DIR/thefeed-server" \
--login-only \
--domain "$THEFEED_DOMAIN" \
--key "$THEFEED_KEY" \
--api-id "$TELEGRAM_API_ID" \
--api-hash "$TELEGRAM_API_HASH" \
--phone "$TELEGRAM_PHONE" \
--channels "$CONFIG_DIR/channels.txt" \
--session "$SESSION_DIR/session.json"
if [[ $? -ne 0 ]]; then
echo -e "${red}Telegram login failed${plain}"
echo -e "${yellow}You can retry later with:${plain}"
echo -e " sudo ${INSTALL_DIR}/thefeed-server --login-only --session ${SESSION_DIR}/session.json ..."
return 1
fi
chmod 600 "$SESSION_DIR/session.json"
echo -e "${green}Telegram login successful, session saved.${plain}"
}
install_service() {
echo -e "${green}Installing systemd service...${plain}"
set -a
source "$CONFIG_DIR/thefeed.env"
set +a
cat > "$SERVICE_FILE" <<SVCEOF
[Unit]
Description=thefeed DNS-based Telegram Feed Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=${CONFIG_DIR}/thefeed.env
ExecStart=${INSTALL_DIR}/thefeed-server \\
--domain \${THEFEED_DOMAIN} \\
--key \${THEFEED_KEY} \\
--api-id \${TELEGRAM_API_ID} \\
--api-hash \${TELEGRAM_API_HASH} \\
--phone \${TELEGRAM_PHONE} \\
--channels ${CONFIG_DIR}/channels.txt \\
--session ${SESSION_DIR}/session.json \\
--listen \${THEFEED_LISTEN:-0.0.0.0:53}
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
# Security hardening
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
SVCEOF
systemctl daemon-reload
echo -e "${green}Service installed: thefeed-server${plain}"
}
start_service() {
echo -e "${green}Enabling and starting service...${plain}"
systemctl enable thefeed-server
systemctl start thefeed-server
echo ""
echo -e "${green}Service status:${plain}"
systemctl status thefeed-server --no-pager || true
}
show_usage() {
echo ""
echo -e "┌─────────────────────────────────────────────────────┐"
echo -e "${blue}thefeed service management:${plain}"
echo -e "│ │"
echo -e "${blue}systemctl status thefeed-server${plain} - Status │"
echo -e "${blue}systemctl restart thefeed-server${plain} - Restart │"
echo -e "${blue}systemctl stop thefeed-server${plain} - Stop │"
echo -e "${blue}journalctl -u thefeed-server -f${plain} - Live logs │"
echo -e "│ │"
echo -e "${blue}Config:${plain} ${CONFIG_DIR}/thefeed.env │"
echo -e "${blue}Channels:${plain} ${CONFIG_DIR}/channels.txt │"
echo -e "${blue}Session:${plain} ${SESSION_DIR}/session.json │"
echo -e "${blue}Binary:${plain} ${INSTALL_DIR}/thefeed-server │"
echo -e "│ │"
echo -e "${yellow}Update:${plain} sudo bash install.sh │"
echo -e "${yellow}Re-login:${plain} sudo bash install.sh --login │"
echo -e "└─────────────────────────────────────────────────────┘"
echo ""
}
install_thefeed() {
local version="$1"
# Get version
if [[ -z "$version" ]]; then
version=$(get_latest_version)
if [[ -z "$version" ]]; then
echo -e "${red}Failed to fetch latest version from GitHub${plain}"
echo -e "${yellow}Please check your network or specify a version: bash install.sh v1.0.0${plain}"
exit 1
fi
fi
echo -e "Version: ${green}${version}${plain}"
# Check current version
if [[ -f "${INSTALL_DIR}/thefeed-server" ]]; then
local current_version
current_version=$("${INSTALL_DIR}/thefeed-server" --version 2>&1 | awk '{print $2}' || echo "unknown")
echo -e "Current: ${yellow}${current_version}${plain}"
if [[ "$current_version" == "$version" ]]; then
echo -e "${yellow}Already running ${version}. Reinstalling anyway...${plain}"
fi
fi
# Stop existing service
if systemctl is-active thefeed-server &>/dev/null; then
echo -e "${yellow}Stopping existing service...${plain}"
systemctl stop thefeed-server
fi
# Download
download_binary "$version"
# First install: full setup
if [[ ! -f "$CONFIG_DIR/thefeed.env" ]]; then
setup_config
telegram_login
install_service
start_service
else
# Update: just restart
if [[ ! -f "$SERVICE_FILE" ]]; then
install_service
fi
start_service
fi
echo -e "\n${green}═══════════════════════════════════════${plain}"
echo -e "${green} thefeed ${version} installed successfully!${plain}"
echo -e "${green}═══════════════════════════════════════${plain}"
show_usage
}
login_only() {
if [[ ! -f "$CONFIG_DIR/thefeed.env" ]]; then
echo -e "${red}Config not found. Run install first: bash install.sh${plain}"
exit 1
fi
if [[ ! -f "${INSTALL_DIR}/thefeed-server" ]]; then
echo -e "${red}Binary not found. Run install first: bash install.sh${plain}"
exit 1
fi
telegram_login
echo -e "${green}Restarting service...${plain}"
systemctl restart thefeed-server || true
}
uninstall_thefeed() {
echo -e "${yellow}Uninstalling thefeed...${plain}"
systemctl stop thefeed-server 2>/dev/null || true
systemctl disable thefeed-server 2>/dev/null || true
rm -f "$SERVICE_FILE"
systemctl daemon-reload
rm -rf "$INSTALL_DIR"
read -rp "Remove config and session data? [y/N]: " remove_data
if [[ "$remove_data" == "y" || "$remove_data" == "Y" ]]; then
rm -rf "$CONFIG_DIR" "$SESSION_DIR"
echo -e "${green}Config and session data removed${plain}"
fi
echo -e "${green}thefeed uninstalled successfully${plain}"
}
show_help() {
echo -e "thefeed install script"
echo ""
echo -e "Usage: bash $0 [OPTION]"
echo ""
echo -e "Options:"
echo -e " ${green}(no args)${plain} Install or update to latest version"
echo -e " ${green}v1.0.0${plain} Install specific version"
echo -e " ${green}--login${plain} Re-authenticate with Telegram"
echo -e " ${green}--uninstall${plain} Remove thefeed"
echo -e " ${green}--help${plain} Show this help"
}
# Main
echo -e "${green}Running thefeed installer...${plain}"
case "${1:-}" in
--help | -h)
show_help
;;
--login)
login_only
;;
--uninstall)
uninstall_thefeed
;;
*)
install_base
install_thefeed "$1"
;;
esac