diff --git a/README.md b/README.md
index dcd139b..9365727 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,9 @@ DNS-based feed reader for Telegram channels. Designed for environments where onl
- Browser-based web UI with RTL/Farsi support (VazirMatn font)
- Configure via the web UI — no CLI flags needed
- Sends encrypted DNS TXT queries via available resolvers
+- **Resolver scoring**: tracks per-resolver success rate and latency; healthier resolvers are preferred automatically
+- **Scatter mode**: fans out the same DNS request to multiple resolvers simultaneously and uses the fastest response (default: 2 concurrent resolvers per request)
+- **1-hour localStorage cache**: channel list and messages are cached in the browser — reopening the app shows cached data instantly while a fresh fetch runs in the background
- Send messages to channels and private chats (requires server `--allow-manage`)
- Channel management (add/remove channels remotely via admin commands)
- Message compression (deflate) for efficient transfer
@@ -39,8 +42,9 @@ DNS-based feed reader for Telegram channels. Designed for environments where onl
- Variable response and query sizes to prevent fingerprinting
- Multiple query encoding modes for stealth
-- Resolver shuffling and rate limiting
-- Background noise traffic
+- **Resolver scoring**: per-resolver success-rate + latency scoreboard; high-scoring resolvers are picked more often via weighted-random selection
+- **Scatter mode**: same block fetched from N resolvers simultaneously, first response wins — faster fetches and implicit failover
+- Rate limiting and background noise traffic to blend in
- Message compression to minimize query count
## Protocol
@@ -114,7 +118,7 @@ make build-server
All data files (session, channels) are stored in the `--data-dir` directory (default: `./data`).
-Environment variables: `THEFEED_DOMAIN`, `THEFEED_KEY`, `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_PHONE`, `TELEGRAM_PASSWORD`
+Environment variables: `THEFEED_DOMAIN`, `THEFEED_KEY`, `THEFEED_MSG_LIMIT`, `THEFEED_ALLOW_MANAGE` (set to `0` to force-disable even if the flag is baked into the service), `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_PHONE`, `TELEGRAM_PASSWORD`
#### Server Flags
@@ -165,6 +169,8 @@ All configuration, cache, and data files are stored in the data directory.
| `--password` | | Password for web UI (empty = no auth) |
| `--version` | | Show version and exit |
+The **concurrent requests (scatter)** setting and all other profile options (resolvers, rate limit, query mode, timeout) are configured through the web UI profile editor, not CLI flags.
+
#### Android (Termux)
```bash
@@ -227,7 +233,8 @@ The browser-based UI has:
- **Next-fetch timer**: countdown to next automatic refresh
- **Media detection**: `[IMAGE]`, `[VIDEO]`, `[DOCUMENT]` tag highlighting
- **Log panel** (bottom): live DNS query log
-- **Settings modal**: configure domain, passphrase, resolvers, query mode, rate limit, timeout, debug mode
+- **Settings modal**: configure domain, passphrase, resolvers, query mode, rate limit, concurrent requests (scatter), timeout, debug mode
+- **Per-profile cache**: 1-hour browser cache so data is visible instantly on reopen
## Development
diff --git a/android/app/src/main/java/com/thefeed/android/MainActivity.kt b/android/app/src/main/java/com/thefeed/android/MainActivity.kt
index 6ed82ff..76254ba 100644
--- a/android/app/src/main/java/com/thefeed/android/MainActivity.kt
+++ b/android/app/src/main/java/com/thefeed/android/MainActivity.kt
@@ -19,6 +19,7 @@ import android.webkit.JsResult
import android.webkit.WebChromeClient
import android.app.AlertDialog
import androidx.activity.ComponentActivity
+import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
@@ -57,10 +58,25 @@ class MainActivity : ComponentActivity() {
requestNotificationPermission()
configureWebView()
+ registerBackHandler()
startThefeedService()
waitForServerThenLoad()
}
+ private fun registerBackHandler() {
+ onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (webView.canGoBack()) {
+ webView.goBack()
+ } else {
+ // No WebView history to go back to — move app to background
+ // instead of finishing the activity (keeps the service alive).
+ moveTaskToBack(true)
+ }
+ }
+ })
+ }
+
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
diff --git a/cmd/client/main.go b/cmd/client/main.go
index 906e0f6..ff39956 100644
--- a/cmd/client/main.go
+++ b/cmd/client/main.go
@@ -17,6 +17,10 @@ func main() {
port := flag.Int("port", 8080, "Web UI port")
password := flag.String("password", "", "Admin password for web UI (empty = no auth)")
showVersion := flag.Bool("version", false, "Show version and exit")
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "thefeed-client %s\n\nWeb UI for reading thefeed content over DNS.\n\nUsage:\n thefeed-client [flags]\n\nFlags:\n", version.Version)
+ flag.PrintDefaults()
+ }
flag.Parse()
if *showVersion {
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 61fea5f..252d224 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -35,6 +35,10 @@ func main() {
msgLimit := flag.Int("msg-limit", 15, "Maximum messages to fetch per Telegram channel")
allowManage := flag.Bool("allow-manage", false, "Allow remote channel management and sending via DNS")
showVersion := flag.Bool("version", false, "Show version and exit")
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "thefeed-server %s\n\nServes Telegram channel content over encrypted DNS for censorship-resistant access.\n\nUsage:\n thefeed-server [flags]\n\nFlags:\n", version.Version)
+ flag.PrintDefaults()
+ }
flag.Parse()
if *showVersion {
diff --git a/internal/client/fetcher.go b/internal/client/fetcher.go
index 7e8b94a..ef0591c 100644
--- a/internal/client/fetcher.go
+++ b/internal/client/fetcher.go
@@ -6,8 +6,10 @@ import (
"encoding/binary"
"fmt"
"math/rand"
+ "sort"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/miekg/dns"
@@ -26,6 +28,32 @@ var noiseDomains = []string{
"www.wikipedia.org", "www.reddit.com", "www.twitter.com",
}
+// resolverStat tracks per-resolver health metrics; fields are accessed with sync/atomic.
+type resolverStat struct {
+ success int64 // number of successful queries
+ failure int64 // number of failed queries
+ totalMs int64 // sum of latency in milliseconds over successful queries
+}
+
+func (s *resolverStat) score() float64 {
+ success := atomic.LoadInt64(&s.success)
+ failure := atomic.LoadInt64(&s.failure)
+ totalMs := atomic.LoadInt64(&s.totalMs)
+ total := success + failure
+ if total == 0 {
+ return 1.0 // no data yet → neutral weight
+ }
+ successRate := float64(success) / float64(total)
+ var avgMs float64
+ if success > 0 {
+ avgMs = float64(totalMs) / float64(success)
+ } else {
+ avgMs = 30000 // 30 s effective penalty for 0% success resolvers
+ }
+ // Higher success rate + lower latency → higher score.
+ return successRate / (avgMs/1000.0 + 0.1)
+}
+
// Fetcher fetches feed blocks over DNS.
type Fetcher struct {
domain string
@@ -46,6 +74,17 @@ type Fetcher struct {
debug bool
logFunc LogFunc
+
+ // Resolver scoring: per-resolver success/failure counters and latency.
+ stats sync.Map // string (resolver:port) -> *resolverStat
+
+ // scatter is how many resolvers to query simultaneously per DNS block request.
+ // 1 = sequential (no scatter), 2+ = fan-out (use fastest response).
+ scatter int
+
+ // exchangeFn is the function used to send a DNS message to a resolver.
+ // It defaults to a real dns.Client exchange and can be replaced in tests.
+ exchangeFn func(ctx context.Context, m *dns.Msg, addr string) (*dns.Msg, time.Duration, error)
}
// NewFetcher creates a new DNS block fetcher.
@@ -58,15 +97,22 @@ func NewFetcher(domain, passphrase string, resolvers []string) (*Fetcher, error)
r := make([]string, len(resolvers))
copy(r, resolvers)
- return &Fetcher{
- domain: strings.TrimSuffix(domain, "."),
- queryKey: qk,
- responseKey: rk,
- queryMode: protocol.QuerySingleLabel,
- allResolvers: r,
- activeResolvers: r,
- timeout: 15 * time.Second,
- }, nil
+ f := &Fetcher{
+ domain: strings.TrimSuffix(domain, "."),
+ queryKey: qk,
+ responseKey: rk,
+ queryMode: protocol.QuerySingleLabel,
+ allResolvers: r,
+ // activeResolvers starts empty — the ResolverChecker fills it in after
+ // the first health-check scan so no fetch is attempted with unvalidated resolvers.
+ timeout: 25 * time.Second,
+ scatter: 2, // query 2 resolvers in parallel by default
+ }
+ f.exchangeFn = func(ctx context.Context, m *dns.Msg, addr string) (*dns.Msg, time.Duration, error) {
+ c := &dns.Client{Timeout: f.timeout, Net: "udp"}
+ return c.ExchangeContext(ctx, m, addr)
+ }
+ return f, nil
}
// SetRateLimit sets the maximum queries per second (0 = unlimited). Must be called before Start.
@@ -130,6 +176,169 @@ func (f *Fetcher) Resolvers() []string {
return result
}
+// SetScatter sets the number of resolvers queried simultaneously per DNS block request.
+// 1 = sequential (no scatter). Values > 1 fan out to N resolvers and use the fastest response.
+// Must be called before Start().
+func (f *Fetcher) SetScatter(n int) {
+ if n < 1 {
+ n = 1
+ }
+ f.scatter = n
+}
+
+// RecordSuccess records a successful DNS query for the given resolver.
+func (f *Fetcher) RecordSuccess(resolver string, latency time.Duration) {
+ if !strings.Contains(resolver, ":") {
+ resolver += ":53"
+ }
+ v, _ := f.stats.LoadOrStore(resolver, &resolverStat{})
+ s := v.(*resolverStat)
+ atomic.AddInt64(&s.success, 1)
+ atomic.AddInt64(&s.totalMs, latency.Milliseconds())
+}
+
+// RecordFailure records a failed DNS query for the given resolver.
+func (f *Fetcher) RecordFailure(resolver string) {
+ if !strings.Contains(resolver, ":") {
+ resolver += ":53"
+ }
+ v, _ := f.stats.LoadOrStore(resolver, &resolverStat{})
+ s := v.(*resolverStat)
+ atomic.AddInt64(&s.failure, 1)
+}
+
+// resolverScore returns the health score for a resolver (higher = better).
+func (f *Fetcher) resolverScore(resolver string) float64 {
+ key := resolver
+ if !strings.Contains(key, ":") {
+ key += ":53"
+ }
+ if v, ok := f.stats.Load(key); ok {
+ return v.(*resolverStat).score()
+ }
+ return 1.0 // no data yet → neutral weight
+}
+
+// pickWeightedResolvers picks up to n resolvers from the active pool using
+// weighted-random selection (higher score → more likely to be chosen).
+func (f *Fetcher) pickWeightedResolvers(n int) []string {
+ resolvers := f.Resolvers()
+ if len(resolvers) == 0 {
+ return nil
+ }
+ if n <= 0 {
+ n = 1
+ }
+ if n >= len(resolvers) {
+ // Return all resolvers sorted by score descending.
+ type scored struct {
+ r string
+ s float64
+ }
+ ss := make([]scored, len(resolvers))
+ for i, r := range resolvers {
+ ss[i] = scored{r, f.resolverScore(r)}
+ }
+ sort.Slice(ss, func(i, j int) bool { return ss[i].s > ss[j].s })
+ out := make([]string, len(ss))
+ for i, s := range ss {
+ out[i] = s.r
+ }
+ return out
+ }
+ // Weighted random sampling without replacement.
+ weights := make([]float64, len(resolvers))
+ total := 0.0
+ for i, r := range resolvers {
+ w := f.resolverScore(r)
+ if w < 0.001 {
+ w = 0.001 // every resolver keeps a minimal chance
+ }
+ weights[i] = w
+ total += w
+ }
+ picked := make([]string, 0, n)
+ for len(picked) < n && total > 0 {
+ r := rand.Float64() * total
+ cumul := 0.0
+ chosen := -1
+ for i, w := range weights {
+ if w == 0 {
+ continue
+ }
+ cumul += w
+ if r < cumul {
+ chosen = i
+ break
+ }
+ }
+ if chosen < 0 {
+ // Floating-point edge case: pick last non-zero entry.
+ for i := len(weights) - 1; i >= 0; i-- {
+ if weights[i] > 0 {
+ chosen = i
+ break
+ }
+ }
+ }
+ if chosen < 0 {
+ break
+ }
+ picked = append(picked, resolvers[chosen])
+ total -= weights[chosen]
+ weights[chosen] = 0
+ }
+ return picked
+}
+
+// scatterQuery sends qname to all given resolvers concurrently and returns
+// the first successful response. The winning response cancels the others.
+func (f *Fetcher) scatterQuery(ctx context.Context, resolvers []string, qname string) ([]byte, error) {
+ if len(resolvers) == 1 {
+ return f.queryResolver(ctx, resolvers[0], qname)
+ }
+ type result struct {
+ data []byte
+ err error
+ }
+ resultCh := make(chan result, len(resolvers))
+ subCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ for i, r := range resolvers {
+ go func(resolver string, idx int) {
+ // Stagger launches: first resolver fires immediately, others wait
+ // a random 50–300 ms to avoid a simultaneous burst.
+ if idx > 0 {
+ jitter := time.Duration(50+rand.Intn(250)) * time.Millisecond
+ select {
+ case <-time.After(jitter):
+ case <-subCtx.Done():
+ return
+ }
+ }
+ data, err := f.queryResolver(subCtx, resolver, qname)
+ select {
+ case resultCh <- result{data, err}:
+ case <-subCtx.Done():
+ }
+ }(r, i)
+ }
+ var lastErr error
+ for i := 0; i < len(resolvers); i++ {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case r := <-resultCh:
+ if r.err == nil {
+ cancel() // cancel remaining in-flight queries
+ return r.data, nil
+ }
+ lastErr = r.err
+ }
+ }
+ return nil, lastErr
+}
+
// Start launches background goroutines (rate limiter and noise generator).
// ctx controls their lifetime — cancel it to cleanly stop them.
// Call once per fetcher configuration; creating a new fetcher replaces the old one.
@@ -244,7 +453,7 @@ func (f *Fetcher) rateWait(ctx context.Context) error {
// It enqueues through the rate limiter and respects ctx cancellation.
// On transient failure it retries up to 2 additional times with a short back-off.
func (f *Fetcher) FetchBlock(ctx context.Context, channel, block uint16) ([]byte, error) {
- const maxAttempts = 10
+ const maxAttempts = 20
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
@@ -268,31 +477,23 @@ func (f *Fetcher) FetchBlock(ctx context.Context, channel, block uint16) ([]byte
f.log("[debug] query ch=%d blk=%d attempt=%d qname=%s", channel, block, attempt+1, qname)
}
- resolvers := f.Resolvers()
- if len(resolvers) == 0 {
+ scatter := f.scatter
+ if scatter < 1 {
+ scatter = 1
+ }
+ picked := f.pickWeightedResolvers(scatter)
+ if len(picked) == 0 {
return nil, fmt.Errorf("no active resolvers")
}
- // Shuffle to spread load across resolvers.
- shuffled := make([]string, len(resolvers))
- copy(shuffled, resolvers)
- rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
-
- for _, resolver := range shuffled {
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
- data, err := f.queryResolver(ctx, resolver, qname)
- if err != nil {
- lastErr = err
- continue
- }
+ data, err := f.scatterQuery(ctx, picked, qname)
+ if err == nil {
if f.debug {
f.log("[debug] response ch=%d blk=%d len=%d", channel, block, len(data))
}
return data, nil
}
- lastErr = fmt.Errorf("all resolvers failed: %w", lastErr)
+ lastErr = fmt.Errorf("scatter query failed: %w", err)
if attempt+1 < maxAttempts {
f.log("block ch=%d blk=%d attempt %d/%d failed, retrying: %v", channel, block, attempt+1, maxAttempts, lastErr)
}
@@ -418,22 +619,33 @@ func (f *Fetcher) queryResolver(ctx context.Context, resolver, qname string) ([]
resolver += ":53"
}
+ start := time.Now()
resp, err := f.exchangeResolver(ctx, resolver, qname)
+ latency := time.Since(start)
if err != nil {
+ f.RecordFailure(resolver)
return nil, err
}
if resp.Rcode != dns.RcodeSuccess {
+ f.RecordFailure(resolver)
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)
+ data, err := protocol.DecodeResponse(f.responseKey, encoded)
+ if err != nil {
+ f.RecordFailure(resolver)
+ return nil, err
+ }
+ f.RecordSuccess(resolver, latency)
+ return data, nil
}
}
+ f.RecordFailure(resolver)
return nil, fmt.Errorf("no TXT record in response from %s", resolver)
}
@@ -441,14 +653,12 @@ func (f *Fetcher) exchangeResolver(ctx context.Context, resolver, qname string)
resolverCtx, cancel := context.WithTimeout(ctx, f.timeout)
defer cancel()
- c := &dns.Client{Timeout: f.timeout, Net: "udp"}
-
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(qname), dns.TypeTXT)
m.RecursionDesired = true
m.SetEdns0(4096, false)
- resp, _, err := c.ExchangeContext(resolverCtx, m, resolver)
+ resp, _, err := f.exchangeFn(resolverCtx, m, resolver)
if err != nil {
return nil, fmt.Errorf("dns exchange with %s: %w", resolver, err)
}
diff --git a/internal/client/fetcher_test.go b/internal/client/fetcher_test.go
index d8864ad..32aabe9 100644
--- a/internal/client/fetcher_test.go
+++ b/internal/client/fetcher_test.go
@@ -1,26 +1,217 @@
package client
-import "testing"
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
-func TestSetActiveResolversAllowsEmpty(t *testing.T) {
- fetcher, err := NewFetcher("t.example.com", "test-passphrase", []string{"1.1.1.1:53", "8.8.8.8:53"})
+ "github.com/miekg/dns"
+ "github.com/sartoopjj/thefeed/internal/protocol"
+)
+
+// mockExchange returns a factory for exchangeFn that records calls and
+// returns either a successful TXT response (encoded payload) or an error.
+//
+// When payload is non-nil the mock builds a valid encrypted TXT record using
+// the fetcher's responseKey so that queryResolver can decode it correctly.
+// When payload is nil the mock returns errFn(addr).
+func mockExchange(f *Fetcher, payload []byte, errFn func(addr string) error) func(context.Context, *dns.Msg, string) (*dns.Msg, time.Duration, error) {
+ return func(ctx context.Context, m *dns.Msg, addr string) (*dns.Msg, time.Duration, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, 0, err
+ }
+ if errFn != nil {
+ if err := errFn(addr); err != nil {
+ return nil, 0, err
+ }
+ }
+ resp := new(dns.Msg)
+ resp.SetReply(m)
+ resp.Rcode = dns.RcodeSuccess
+ if payload != nil {
+ encoded, encErr := protocol.EncodeResponse(f.responseKey, payload, 0)
+ if encErr != nil {
+ return nil, 0, encErr
+ }
+ resp.Answer = []dns.RR{&dns.TXT{
+ Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 0},
+ Txt: []string{encoded},
+ }}
+ }
+ return resp, time.Millisecond, nil
+ }
+}
+
+func newTestFetcher(t *testing.T, resolvers []string) *Fetcher {
+ t.Helper()
+ f, err := NewFetcher("t.example.com", "test-passphrase", resolvers)
if err != nil {
t.Fatalf("NewFetcher: %v", err)
}
- fetcher.SetActiveResolvers(nil)
- if got := fetcher.Resolvers(); len(got) != 0 {
+ // Simulate the resolver scanner having validated all provided resolvers.
+ f.SetActiveResolvers(resolvers)
+ // Block all real DNS traffic by default.
+ f.exchangeFn = func(_ context.Context, _ *dns.Msg, addr string) (*dns.Msg, time.Duration, error) {
+ return nil, 0, fmt.Errorf("real DNS blocked in tests (resolver: %s)", addr)
+ }
+ return f
+}
+
+func TestSetActiveResolversAllowsEmpty(t *testing.T) {
+ f := newTestFetcher(t, []string{"1.1.1.1:53", "8.8.8.8:53"})
+ f.SetActiveResolvers(nil)
+ if got := f.Resolvers(); len(got) != 0 {
t.Fatalf("len(Resolvers()) = %d, want 0", len(got))
}
}
func TestSetActiveResolversReplacesPool(t *testing.T) {
- fetcher, err := NewFetcher("t.example.com", "test-passphrase", []string{"1.1.1.1:53", "8.8.8.8:53"})
- if err != nil {
- t.Fatalf("NewFetcher: %v", err)
- }
- fetcher.SetActiveResolvers([]string{"9.9.9.9:53"})
- got := fetcher.Resolvers()
+ f := newTestFetcher(t, []string{"1.1.1.1:53", "8.8.8.8:53"})
+ f.SetActiveResolvers([]string{"9.9.9.9:53"})
+ got := f.Resolvers()
if len(got) != 1 || got[0] != "9.9.9.9:53" {
t.Fatalf("Resolvers() = %v, want [9.9.9.9:53]", got)
}
}
+
+// TestResolverScoreNoData checks that a resolver with no recorded stats gets neutral weight 1.0.
+func TestResolverScoreNoData(t *testing.T) {
+ f := newTestFetcher(t, []string{"1.1.1.1:53"})
+ if got := f.resolverScore("1.1.1.1:53"); got != 1.0 {
+ t.Fatalf("resolverScore with no data = %v, want 1.0", got)
+ }
+}
+
+// TestResolverScoreSuccessBeatsFailure checks that a 100% success resolver
+// scores higher than a 100% failure resolver.
+func TestResolverScoreSuccessBeatsFailure(t *testing.T) {
+ f := newTestFetcher(t, []string{"1.1.1.1:53", "8.8.8.8:53"})
+ f.RecordSuccess("1.1.1.1:53", 50*time.Millisecond)
+ f.RecordFailure("8.8.8.8:53")
+ good := f.resolverScore("1.1.1.1:53")
+ bad := f.resolverScore("8.8.8.8:53")
+ if good <= bad {
+ t.Fatalf("expected good resolver (%v) to score higher than bad (%v)", good, bad)
+ }
+}
+
+// TestResolverScoreFasterBeatsSlower checks that an equal-success resolver
+// with lower latency scores higher.
+func TestResolverScoreFasterBeatsSlower(t *testing.T) {
+ f := newTestFetcher(t, []string{"1.1.1.1:53", "8.8.8.8:53"})
+ f.RecordSuccess("1.1.1.1:53", 10*time.Millisecond)
+ f.RecordSuccess("8.8.8.8:53", 500*time.Millisecond)
+ fast := f.resolverScore("1.1.1.1:53")
+ slow := f.resolverScore("8.8.8.8:53")
+ if fast <= slow {
+ t.Fatalf("expected fast resolver (%v) to score higher than slow (%v)", fast, slow)
+ }
+}
+
+// TestPickWeightedResolversReturnsN checks that pickWeightedResolvers returns
+// at most n distinct resolvers.
+func TestPickWeightedResolversReturnsN(t *testing.T) {
+ resolvers := []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53", "208.67.222.222:53"}
+ f := newTestFetcher(t, resolvers)
+ f.SetActiveResolvers(resolvers)
+ picked := f.pickWeightedResolvers(2)
+ if len(picked) != 2 {
+ t.Fatalf("pickWeightedResolvers(2) returned %d items, want 2", len(picked))
+ }
+ seen := map[string]bool{}
+ for _, r := range picked {
+ if seen[r] {
+ t.Fatalf("pickWeightedResolvers returned duplicate resolver %s", r)
+ }
+ seen[r] = true
+ }
+}
+
+// TestPickWeightedResolversMoreThanAvailable returns all when n > pool size.
+func TestPickWeightedResolversMoreThanAvailable(t *testing.T) {
+ f := newTestFetcher(t, []string{"1.1.1.1:53"})
+ picked := f.pickWeightedResolvers(5)
+ if len(picked) != 1 {
+ t.Fatalf("expected 1 resolver when pool has 1, got %d", len(picked))
+ }
+}
+
+// TestScatterQuerySuccess checks that scatterQuery returns data when
+// the mock exchange responds successfully.
+func TestScatterQuerySuccess(t *testing.T) {
+ f := newTestFetcher(t, []string{"1.1.1.1:53", "8.8.8.8:53"})
+ want := []byte("hello")
+ f.exchangeFn = mockExchange(f, want, nil)
+
+ ctx := context.Background()
+ got, err := f.scatterQuery(ctx, []string{"1.1.1.1:53"}, "test.t.example.com.")
+ if err != nil {
+ t.Fatalf("scatterQuery: unexpected error: %v", err)
+ }
+ if string(got) != string(want) {
+ t.Fatalf("scatterQuery returned %q, want %q", got, want)
+ }
+}
+
+// TestScatterQueryUsesFirstResponse checks that when multiple resolvers respond,
+// the first successful answer wins and the call returns without error.
+func TestScatterQueryUsesFirstResponse(t *testing.T) {
+ f := newTestFetcher(t, []string{"1.1.1.1:53", "8.8.8.8:53"})
+ want := []byte("winner")
+ f.exchangeFn = mockExchange(f, want, nil)
+
+ ctx := context.Background()
+ got, err := f.scatterQuery(ctx, []string{"1.1.1.1:53", "8.8.8.8:53"}, "test.t.example.com.")
+ if err != nil {
+ t.Fatalf("scatterQuery: unexpected error: %v", err)
+ }
+ if string(got) != string(want) {
+ t.Fatalf("scatterQuery returned %q, want %q", got, want)
+ }
+}
+
+// TestScatterQueryAllFail checks that scatterQuery returns an error when
+// all resolvers fail.
+func TestScatterQueryAllFail(t *testing.T) {
+ f := newTestFetcher(t, []string{"1.1.1.1:53", "8.8.8.8:53"})
+ f.exchangeFn = mockExchange(f, nil, func(addr string) error {
+ return fmt.Errorf("connection refused from %s", addr)
+ })
+
+ ctx := context.Background()
+ _, err := f.scatterQuery(ctx, []string{"1.1.1.1:53", "8.8.8.8:53"}, "test.t.example.com.")
+ if err == nil {
+ t.Fatal("expected error when all resolvers fail, got nil")
+ }
+}
+
+// TestScatterQueryContextCancel checks that scatterQuery respects context cancellation.
+func TestScatterQueryContextCancel(t *testing.T) {
+ f := newTestFetcher(t, []string{"1.1.1.1:53"})
+ // Block forever until context is cancelled.
+ f.exchangeFn = func(ctx context.Context, _ *dns.Msg, _ string) (*dns.Msg, time.Duration, error) {
+ <-ctx.Done()
+ return nil, 0, ctx.Err()
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ _, err := f.scatterQuery(ctx, []string{"1.1.1.1:53"}, "test.t.example.com.")
+ if err == nil {
+ t.Fatal("expected error after context cancel, got nil")
+ }
+}
+
+// TestSetScatter validates that SetScatter clamps values < 1 to 1.
+func TestSetScatter(t *testing.T) {
+ f := newTestFetcher(t, []string{"1.1.1.1:53"})
+ f.SetScatter(0) // should clamp to 1
+ if f.scatter != 1 {
+ t.Fatalf("scatter = %d after SetScatter(0), want 1", f.scatter)
+ }
+ f.SetScatter(3)
+ if f.scatter != 3 {
+ t.Fatalf("scatter = %d after SetScatter(3), want 3", f.scatter)
+ }
+}
diff --git a/internal/client/resolver.go b/internal/client/resolver.go
index 6698d84..880b124 100644
--- a/internal/client/resolver.go
+++ b/internal/client/resolver.go
@@ -24,10 +24,10 @@ type ResolverChecker struct {
}
// NewResolverChecker creates a health checker for the resolvers in fetcher.
-// timeout is the per-probe deadline; 0 uses a 5-second default.
+// timeout is the per-probe deadline; 0 uses a 15-second default.
func NewResolverChecker(fetcher *Fetcher, timeout time.Duration) *ResolverChecker {
if timeout <= 0 {
- timeout = 10 * time.Second
+ timeout = 15 * time.Second
}
return &ResolverChecker{
fetcher: fetcher,
@@ -48,18 +48,38 @@ func (rc *ResolverChecker) Start(ctx context.Context) {
}
// StartAndNotify is like Start but calls onFirstDone (if non-nil) after the
-// initial health-check pass finishes, before the periodic ticker begins.
-// This lets callers sequence "DNS scan → metadata fetch" without races.
+// first successful health-check pass (i.e. at least one resolver is healthy),
+// before the periodic ticker begins.
+// If the initial scan finds zero healthy resolvers it retries every minute
+// until at least one resolver becomes reachable (or ctx is cancelled).
// Safe to call only once per checker instance; subsequent calls are no-ops.
func (rc *ResolverChecker) StartAndNotify(ctx context.Context, onFirstDone func()) {
if !rc.started.CompareAndSwap(false, true) {
return // already started — prevent duplicate scan goroutines
}
go func() {
- rc.CheckNow(ctx)
+ // Keep scanning every minute until we find at least one healthy resolver.
+ for {
+ rc.CheckNow(ctx)
+ if ctx.Err() != nil {
+ return
+ }
+ if len(rc.fetcher.Resolvers()) > 0 {
+ break // at least one resolver is up — proceed normally
+ }
+ rc.log("No healthy resolvers found — retrying in 1 minute...")
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(1 * time.Minute):
+ }
+ }
+
if onFirstDone != nil && ctx.Err() == nil {
onFirstDone()
}
+
+ // Periodic re-check every 30 minutes.
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for {
@@ -68,6 +88,23 @@ func (rc *ResolverChecker) StartAndNotify(ctx context.Context, onFirstDone func(
return
case <-ticker.C:
rc.CheckNow(ctx)
+ // If the periodic check leaves us with no resolvers,
+ // fall back into the retry-every-minute loop.
+ if ctx.Err() == nil && len(rc.fetcher.Resolvers()) == 0 {
+ rc.log("All resolvers lost — scanning every minute until one recovers...")
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(1 * time.Minute):
+ }
+ rc.CheckNow(ctx)
+ if ctx.Err() != nil || len(rc.fetcher.Resolvers()) > 0 {
+ break
+ }
+ rc.log("Still no healthy resolvers — retrying in 1 minute...")
+ }
+ }
}
}
}()
@@ -110,7 +147,7 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) {
rc.log("Resolver failed: %s", r)
}
done++
- rc.log("RESOLVER_SCAN progress %d/%d", done, total)
+ rc.log("RESOLVER_SCAN progress %d/%d healthy=%d", done, total, len(healthy))
mu.Unlock()
}(r)
}
@@ -131,8 +168,11 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) {
}
// checkOne probes a single resolver by sending a metadata channel query
-// (channel 0, block 0). A successful DNS response (any rcode that isn't a
-// network/timeout error) means the resolver is reachable and understands the domain.
+// (channel 0, block 0). A resolver is considered healthy only if it returns
+// a DNS response containing at least one TXT record that can be decoded with
+// the fetcher's response key — the same bar as a real data fetch.
+// This filters out resolvers that are reachable but strip TXT records, or
+// that resolve the domain through a path that doesn't reach the thefeed server.
func (rc *ResolverChecker) checkOne(ctx context.Context, resolver string) bool {
if !strings.Contains(resolver, ":") {
resolver += ":53"
@@ -157,10 +197,27 @@ func (rc *ResolverChecker) checkOne(ctx context.Context, resolver string) bool {
m.RecursionDesired = true
m.SetEdns0(4096, false)
+ start := time.Now()
resp, _, err := c.ExchangeContext(probeCtx, m, resolver)
- // We consider the resolver healthy if we get any DNS response back
- // (even NXDOMAIN means the resolver forwarded the query to our server).
- return err == nil && resp != nil
+ latency := time.Since(start)
+ if err != nil || resp == nil {
+ rc.fetcher.RecordFailure(resolver)
+ return false
+ }
+
+ // Require a decodable TXT record — same check as a real fetch.
+ for _, ans := range resp.Answer {
+ if txt, ok := ans.(*dns.TXT); ok {
+ encoded := strings.Join(txt.Txt, "")
+ if _, decErr := protocol.DecodeResponse(rc.fetcher.responseKey, encoded); decErr == nil {
+ rc.fetcher.RecordSuccess(resolver, latency)
+ return true
+ }
+ }
+ }
+
+ rc.fetcher.RecordFailure(resolver)
+ return false
}
func (rc *ResolverChecker) log(format string, args ...any) {
diff --git a/internal/web/static/index.html b/internal/web/static/index.html
index f5dfb26..7be20d5 100644
--- a/internal/web/static/index.html
+++ b/internal/web/static/index.html
@@ -75,7 +75,7 @@ input,textarea,select{font-family:inherit}
.messages{flex:1;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:4px;direction:ltr}
.msg-date-sep{text-align:center;padding:8px 0;font-size:12px;color:var(--text-dim)}
.msg-date-sep span{background:rgba(0,0,0,.3);padding:3px 10px;border-radius:10px}
-.msg{max-width:min(82%,580px);padding:7px 10px 4px;border-radius:12px;line-height:1.7;word-break:break-word;white-space:pre-wrap;font-size:inherit;background:var(--msg-in);border:1px solid var(--border);align-self:flex-start;border-bottom-left-radius:4px}
+.msg{max-width:min(82%,580px);padding:7px 10px 4px;border-radius:12px;line-height:1.7;word-break:break-word;white-space:pre-wrap;font-size:inherit;background:var(--msg-in);border:1px solid rgba(255,255,255,.07);align-self:flex-start;border-bottom-left-radius:4px}
.msg-meta{display:flex;justify-content:flex-end;gap:6px;font-size:10px;color:var(--text-dim);margin-top:2px;direction:ltr}
.media-tag{display:block;padding:2px 6px;border-radius:4px;background:rgba(51,144,236,.15);color:var(--accent);font-size:11px;margin-bottom:6px}
@@ -334,6 +334,7 @@ html[dir=ltr] .active-badge{margin-left:0;margin-right:6px}
+
@@ -362,7 +363,8 @@ var I18N = {
no_channels:'هنوز کانالی دریافت نشده',loading:'در حال بارگذاری...',no_messages:'هنوز پیامی در این کانال وجود ندارد',
no_channels_hint:'در پسزمینه داریم کار میکنیم — برای دیدن جزئیات روی',
no_channels_hint2:'کلیک کنید',
- scanning_resolvers:'در حال بررسی resolverها',
+ scanning_resolvers:'در حال بررسی ریزالورها',
+ server_fetch_wait:'سرور در حال دریافت اطلاعات جدید از تلگرام',
no_messages_hint:'برنامه در حال دریافت پیامها است. لطفاً چند لحظه صبر کنید...',
write_message:'پیام بنویسید...',configure_server:'برای شروع یک سرور راهاندازی کنید',
set_up:'راهاندازی',switching:'در حال تغییر پروفایل...',
@@ -376,6 +378,7 @@ var I18N = {
nickname:'نام مستعار',domain:'دامنه',passphrase:'رمز رمزنگاری',
resolvers:'Resolvers (یک در هر خط)',query_mode:'حالت کوئری',rate_limit:'محدودیت نرخ (ک/ث، ۰=بدون محدودیت)',
channels:'کانال\u200cها',add:'افزودن',remove:'حذف',
+ scatter:'درخواستهای همزمان',
channel_mgmt_note:'مدیریت کانال نیاز به پشتیبانی سمت سرور دارد. اگر توسط ادمین غیرفعال شده باشد، افزودن/حذف کار نمی\u200cکند.',
channel_mgmt_inactive:'برای مدیریت کانال\u200cها، ابتدا این پروفایل را فعال کنید.',
channel_placeholder:'نام کاربری کانال',
@@ -390,6 +393,7 @@ var I18N = {
no_channels_hint:'Working in the background — tap',
no_channels_hint2:'to see what\'s happening',
scanning_resolvers:'Scanning resolvers',
+ server_fetch_wait:'Server fetching fresh data from Telegram',
no_messages_hint:'The app is trying to fetch messages. Please wait a moment...',
write_message:'Write a message...',configure_server:'Configure a server to start reading',
set_up:'Set Up',switching:'Switching profile...',
@@ -403,6 +407,7 @@ var I18N = {
nickname:'Nickname',domain:'Domain',passphrase:'Passphrase',
resolvers:'Resolvers (one per line)',query_mode:'Query Mode',rate_limit:'Rate Limit (q/s, 0=unlimited)',
channels:'Channels',add:'Add',remove:'Remove',
+ scatter:'Concurrent requests',
channel_mgmt_note:'Channel management requires server-side support. If disabled by the server admin, adding/removing channels will not work.',
channel_mgmt_inactive:'Switch to this profile first to manage its channels.',
channel_placeholder:'channel_username',
@@ -432,7 +437,7 @@ function setLang(l){lang=l;localStorage.setItem('thefeed_lang',l);applyLang()}
// ===== STATE =====
var selectedChannel=0,channels=[],eventSource=null,autoRefreshTimer=null,telegramLoggedIn=false,logVisible=false;
var serverNextFetch=0,nextFetchInterval=null,previousMsgIDs={},currentMsgTexts=[];
-var profiles=null,activeProfileId='',editingProfileId=null,resolverScanHint='';
+var profiles=null,activeProfileId='',editingProfileId=null,resolverScanHint='',resolverScanHealthy=0,resolverScanDone=0,resolverScanTotal=0;
// ===== MOBILE NAV =====
function openChat(){if(window.innerWidth<=768)document.getElementById('app').classList.add('chat-open')}
@@ -494,14 +499,15 @@ function connectSSE(){
eventSource.addEventListener('update',async function(e){
var data;try{data=JSON.parse(e.data)}catch(x){data=e.data}
var wasEmpty=channels.length===0;
+ var snapChannel=selectedChannel;
await loadChannels();
if(wasEmpty&&channels.length>0&&selectedChannel===0){
closeProfiles();
await selectChannel(1);return
}
if(data&&typeof data==='object'&&data.channel){
- if(data.channel===selectedChannel)await loadMessages(data.channel)
- }else if(selectedChannel>0){await loadMessages(selectedChannel)}
+ if(data.channel===snapChannel)await loadMessages(data.channel)
+ }else if(snapChannel>0){await loadMessages(snapChannel)}
updateSendPanel();
});
eventSource.onerror=function(){
@@ -654,6 +660,7 @@ function openProfileEditor(id){
document.getElementById('peResolvers').value=(p.config.resolvers||[]).join('\n');
document.getElementById('peQueryMode').value=p.config.queryMode||'single';
document.getElementById('peRateLimit').value=p.config.rateLimit||5;
+ document.getElementById('peScatter').value=p.config.scatter||2;
}
document.getElementById('peChannelSection').style.display='';
var isActive=id===activeProfileId;
@@ -675,6 +682,7 @@ function openProfileEditor(id){
document.getElementById('peResolvers').value='';
document.getElementById('peQueryMode').value='single';
document.getElementById('peRateLimit').value='5';
+ document.getElementById('peScatter').value='2';
document.getElementById('peChannelSection').style.display='none';
}
}
@@ -723,7 +731,7 @@ async function saveProfile(){
var key=document.getElementById('peKey').value;
var resolvers=document.getElementById('peResolvers').value.trim().split(/[\n,]+/).map(function(s){return s.trim()}).filter(Boolean);
if(!domain||!key||!resolvers.length){errEl.textContent=t('resolvers')+' / '+t('domain')+' / '+t('passphrase');errEl.style.display='block';return}
- var profile={id:editingProfileId||'',nickname:nick||domain,config:{domain:domain,key:key,resolvers:resolvers,queryMode:document.getElementById('peQueryMode').value,rateLimit:parseFloat(document.getElementById('peRateLimit').value)||5}};
+ var profile={id:editingProfileId||'',nickname:nick||domain,config:{domain:domain,key:key,resolvers:resolvers,queryMode:document.getElementById('peQueryMode').value,rateLimit:parseFloat(document.getElementById('peRateLimit').value)||5,scatter:parseInt(document.getElementById('peScatter').value)||2}};
var action=editingProfileId?'update':'create';
var wasFirst=!profiles||!profiles.profiles||profiles.profiles.length===0;
try{
@@ -757,8 +765,20 @@ async function deleteEditingProfile(){
}catch(e){}
}
+// ===== CACHE (1 h localStorage per profile) =====
+function cacheKey(){return 'thefeed_cache_'+activeProfileId}
+function saveCache(data){try{localStorage.setItem(cacheKey(),JSON.stringify(data))}catch(e){}}
+function loadCache(){
+ try{
+ var raw=localStorage.getItem(cacheKey());if(!raw)return null;
+ var c=JSON.parse(raw);if(Date.now()-c.ts>3600000)return null; // 1 h TTL
+ return c;
+ }catch(e){return null}
+}
+
// ===== CHANNELS =====
async function loadChannels(){
+ var _c=loadCache();if(_c&&_c.channels&&_c.channels.length){channels=_c.channels;renderChannels();}
try{
var r=await fetch('/api/channels');channels=await r.json();if(!channels)channels=[];
renderChannels();updateSendPanel();
@@ -766,6 +786,7 @@ async function loadChannels(){
var sr=await fetch('/api/status');var st=await sr.json();
telegramLoggedIn=!!st.telegramLoggedIn;
if(st.nextFetch){serverNextFetch=st.nextFetch;updateNextFetchDisplay()}
+ var _cache=loadCache()||{messages:{}};_cache.channels=channels;_cache.ts=Date.now();saveCache(_cache);
}catch(e){}
}
@@ -818,10 +839,12 @@ function updateSendPanel(){
// ===== MESSAGES =====
async function loadMessages(chNum){
+ if(chNum===selectedChannel){var _c=loadCache();if(_c&&_c.messages&&_c.messages[''+chNum])renderMessages(_c.messages[''+chNum]);}
try{
var r=await fetch('/api/messages/'+chNum);if(chNum!==selectedChannel)return;
var msgs=await r.json();if(chNum!==selectedChannel)return;
renderMessages(msgs);
+ var _cache=loadCache()||{channels:channels,messages:{}};if(!_cache.messages)_cache.messages={};_cache.messages[''+chNum]=msgs;_cache.ts=Date.now();saveCache(_cache);
if(channels[chNum-1]){previousMsgIDs[chNum]=channels[chNum-1].LastMsgID||channels[chNum-1].lastMsgID||0;renderChannels()}
}catch(e){}
}
@@ -861,6 +884,7 @@ function addLogLine(line){
if(typeof line==='string'){
// Handle structured resolver scan events — show progress bar, suppress from log
if(line.includes('RESOLVER_SCAN ')){updateResolverScanDisplay(line);return}
+ if(line.includes('SERVER_FETCH_WAIT ')){updateServerFetchDisplay(line);return}
if(line.includes('Error:')||line.includes('error')||line.includes('Invalid passphrase'))cls='err';
else if(line.includes('Warning:'))cls='warn';
else if(line.includes('OK')||line.includes('success')||line.includes('done'))cls='ok';
@@ -871,6 +895,41 @@ function addLogLine(line){
while(el.children.length>200)el.removeChild(el.firstChild);
}
+function updateServerFetchDisplay(line){
+ var panel=document.getElementById('progressPanel');
+ var item=document.getElementById('prog-server-fetch');
+ // SERVER_FETCH_WAIT start
+ var startMatch=line.match(/SERVER_FETCH_WAIT start (\d+)/);
+ if(startMatch){
+ var total=parseInt(startMatch[1]);
+ if(!item){
+ item=document.createElement('div');item.id='prog-server-fetch';item.className='progress-item';
+ item.innerHTML='
';
+ panel.insertBefore(item,panel.firstChild);
+ }
+ item.dataset.total=total;
+ item.querySelector('.progress-label').textContent=t('server_fetch_wait')+' — '+total+'s';
+ item.querySelector('.progress-fill').style.width='0%';
+ return;
+ }
+ if(!item)return;
+ // SERVER_FETCH_WAIT tick /
+ var tickMatch=line.match(/SERVER_FETCH_WAIT tick (\d+)\/(\d+)/);
+ if(tickMatch){
+ var remaining=parseInt(tickMatch[1]),total2=parseInt(tickMatch[2]);
+ var pct=Math.round(((total2-remaining)/total2)*100);
+ item.querySelector('.progress-label').textContent=t('server_fetch_wait')+' — '+remaining+'s';
+ item.querySelector('.progress-fill').style.width=pct+'%';
+ return;
+ }
+ // SERVER_FETCH_WAIT done
+ if(line.includes('SERVER_FETCH_WAIT done')){
+ item.querySelector('.progress-label').textContent=t('loading');
+ item.querySelector('.progress-fill').style.width='100%';
+ setTimeout(function(){if(item.parentNode)item.parentNode.removeChild(item)},1200);
+ }
+}
+
function updateResolverScanDisplay(line){
var panel=document.getElementById('progressPanel');
var item=document.getElementById('prog-resolvers');
@@ -878,6 +937,7 @@ function updateResolverScanDisplay(line){
var startMatch=line.match(/RESOLVER_SCAN start (\d+)/);
if(startMatch){
var total=parseInt(startMatch[1]);
+ resolverScanDone=0;resolverScanHealthy=0;resolverScanTotal=total;
if(!item){
item=document.createElement('div');item.id='prog-resolvers';item.className='progress-item';
item.innerHTML='
';
@@ -891,14 +951,18 @@ function updateResolverScanDisplay(line){
return;
}
if(!item)return;
- // RESOLVER_SCAN progress D/T
- var progMatch=line.match(/RESOLVER_SCAN progress (\d+)\/(\d+)/);
+ // RESOLVER_SCAN progress D/T healthy=H
+ var progMatch=line.match(/RESOLVER_SCAN progress (\d+)\/(\d+)(?: healthy=(\d+))?/);
if(progMatch){
- var done=parseInt(progMatch[1]),tot=parseInt(progMatch[2]);
+ var done=parseInt(progMatch[1]),tot=parseInt(progMatch[2]),hlthy=progMatch[3]!==undefined?parseInt(progMatch[3]):null;
+ // Use authoritative values from the structured message.
+ resolverScanDone=done;resolverScanTotal=tot;
+ if(hlthy!==null)resolverScanHealthy=hlthy;
var pct=Math.round((done/tot)*100);
- item.querySelector('.progress-label').textContent=t('scanning_resolvers')+' '+done+'/'+tot;
+ var label=t('scanning_resolvers')+' '+done+'/'+tot+' \u2713'+resolverScanHealthy;
+ item.querySelector('.progress-label').textContent=label;
item.querySelector('.progress-fill').style.width=pct+'%';
- resolverScanHint=t('scanning_resolvers')+' ('+done+'/'+tot+') ';
+ resolverScanHint=t('scanning_resolvers')+' ('+done+'/'+tot+', \u2713'+resolverScanHealthy+')'+ ' ';
var hintEl=document.getElementById('no-ch-hint');if(hintEl)hintEl.innerHTML=resolverScanHint;
return;
}
@@ -906,11 +970,14 @@ function updateResolverScanDisplay(line){
var doneMatch=line.match(/RESOLVER_SCAN done (\d+)\/(\d+)/);
if(doneMatch){
var healthy=parseInt(doneMatch[1]),total2=parseInt(doneMatch[2]);
+ resolverScanDone=total2;resolverScanHealthy=healthy;resolverScanTotal=total2;
item.querySelector('.progress-label').textContent=t('scanning_resolvers')+': '+healthy+'/'+total2+' active';
item.querySelector('.progress-fill').style.width='100%';
resolverScanHint='';
var hintEl=document.getElementById('no-ch-hint');if(hintEl)hintEl.innerHTML=t('no_channels_hint')+' '+t('no_channels_hint2');
setTimeout(function(){if(item.parentNode)item.parentNode.removeChild(item)},2000);
+ // Scan is done — load channels in case the SSE 'update' event was dropped.
+ setTimeout(function(){loadChannels().then(function(){if(channels.length>0&&selectedChannel===0)selectChannel(1)})},3000);
}
}
diff --git a/internal/web/web.go b/internal/web/web.go
index 1c65b11..a93da11 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -33,9 +33,12 @@ type Config struct {
Resolvers []string `json:"resolvers"`
QueryMode string `json:"queryMode"`
RateLimit float64 `json:"rateLimit"`
- // Timeout is the per-query DNS timeout in seconds (0 = default 5 s).
+ // Timeout is the per-query DNS timeout in seconds (0 = default 15 s).
// Also used as the resolver health-check probe timeout.
Timeout float64 `json:"timeout,omitempty"`
+ // Scatter is the number of resolvers queried simultaneously per DNS block request
+ // (0 or 1 = sequential, 2 = default parallel pair).
+ Scatter int `json:"scatter,omitempty"`
}
// Profile wraps a Config with a user-chosen nickname and a unique ID.
@@ -74,6 +77,11 @@ type Server struct {
// checker is the active resolver health-checker; set by initFetcher.
checker *client.ResolverChecker
+ // metaFetchedAt is when channels/nextFetch were last fetched from DNS.
+ // refreshChannel reuses the in-memory metadata when it is younger than metaCacheTTL.
+ metaFetchedAt time.Time
+ metaCacheTTL time.Duration
+
// fetcherCtx/fetcherCancel control the lifetime of the active fetcher's
// background goroutines (rate limiter, noise, resolver checker).
// They are cancelled and recreated each time the config changes.
@@ -280,9 +288,11 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", 405)
return
}
- // Background (quiet) refreshes skip silently if one is already running,
+ // Background (quiet) metadata-only refreshes skip silently if one is already running,
// so the auto-refresh timer never cancels a slow in-progress fetch.
- if r.URL.Query().Get("quiet") == "1" {
+ // Channel refreshes are NOT skipped here — refreshChannel has its own duplicate guard.
+ chParam := r.URL.Query().Get("channel")
+ if r.URL.Query().Get("quiet") == "1" && chParam == "" {
s.refreshMu.Lock()
running := s.refreshCancel != nil
s.refreshMu.Unlock()
@@ -291,7 +301,6 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
return
}
}
- chParam := r.URL.Query().Get("channel")
if chParam != "" {
chNum, err := strconv.Atoi(chParam)
if err != nil || chNum < 1 {
@@ -422,11 +431,15 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
return
}
+ // Disable the server-wide WriteTimeout for this long-lived SSE connection.
+ rc := http.NewResponseController(w)
+ _ = rc.SetWriteDeadline(time.Time{})
+
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
- ch := make(chan string, 100)
+ ch := make(chan string, 500)
s.sseMu.Lock()
s.clients[ch] = struct{}{}
s.sseMu.Unlock()
@@ -446,12 +459,23 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
flusher.Flush()
ctx := r.Context()
+ ping := time.NewTicker(30 * time.Second)
+ defer ping.Stop()
for {
select {
case <-ctx.Done():
return
+ case <-ping.C:
+ // SSE comment line as heartbeat — keeps the connection alive and
+ // lets us detect a dead client (write error).
+ if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
+ return
+ }
+ flusher.Flush()
case msg := <-ch:
- fmt.Fprint(w, msg)
+ if _, err := fmt.Fprint(w, msg); err != nil {
+ return
+ }
flusher.Flush()
}
}
@@ -520,8 +544,11 @@ func (s *Server) initFetcher() error {
if cfg.RateLimit > 0 {
fetcher.SetRateLimit(cfg.RateLimit)
}
+ if cfg.Scatter > 1 {
+ fetcher.SetScatter(cfg.Scatter)
+ }
- timeout := 10 * time.Second
+ timeout := 15 * time.Second
if cfg.Timeout > 0 {
timeout = time.Duration(cfg.Timeout * float64(time.Second))
}
@@ -563,13 +590,77 @@ func (s *Server) startCheckerThenRefresh() {
if checker == nil {
return
}
+
checker.StartAndNotify(ctx, func() {
- time.Sleep(1 * time.Second)
s.refreshMetadataOnly()
})
}
+// nextFetchDeadline returns the Time when the server will next fetch from Telegram.
+// Returns zero value if nextFetch is not set or has already passed.
+func (s *Server) nextFetchDeadline() time.Time {
+ s.mu.RLock()
+ nf := s.nextFetch
+ s.mu.RUnlock()
+ if nf == 0 {
+ return time.Time{}
+ }
+ t := time.Unix(int64(nf), 0)
+ if time.Until(t) <= 0 {
+ return time.Time{} // already passed
+ }
+ return t
+}
+
+// waitForServerFetch blocks until the server's Telegram fetch is likely complete
+// (nextFetch + 45 s), emitting a countdown progress event each second so the UI
+// can render a live progress bar. Returns true on completion, false if ctx cancelled.
+func (s *Server) waitForServerFetch(ctx context.Context, nf uint32) bool {
+ const serverFetchDuration = 45 * time.Second
+ deadline := time.Unix(int64(nf), 0).Add(serverFetchDuration)
+ totalWait := time.Until(deadline)
+ if totalWait <= 0 {
+ totalWait = serverFetchDuration
+ }
+ totalSec := int(totalWait.Seconds()) + 1
+
+ s.addLog(fmt.Sprintf("SERVER_FETCH_WAIT start %d", totalSec))
+
+ timer := time.NewTimer(totalWait)
+ defer timer.Stop()
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+
+ start := time.Now()
+ for {
+ select {
+ case <-ctx.Done():
+ s.addLog("SERVER_FETCH_WAIT done")
+ return false
+ case <-timer.C:
+ s.addLog("SERVER_FETCH_WAIT done")
+ return true
+ case <-ticker.C:
+ remaining := int((totalWait - time.Since(start)).Seconds())
+ if remaining < 0 {
+ remaining = 0
+ }
+ s.addLog(fmt.Sprintf("SERVER_FETCH_WAIT tick %d/%d", remaining, totalSec))
+ }
+ }
+}
+
func (s *Server) refreshMetadataOnly() {
+ // Don't fetch before resolver scanning has found at least one healthy resolver.
+ // The onFirstDone callback in startCheckerThenRefresh is the canonical first trigger.
+ s.mu.RLock()
+ fetcherEarly := s.fetcher
+ s.mu.RUnlock()
+ if fetcherEarly != nil && len(fetcherEarly.Resolvers()) == 0 {
+ s.addLog("Waiting for resolver scan to complete...")
+ return
+ }
+
// Cancel any in-progress refresh and start a new cancellable one.
s.refreshMu.Lock()
if s.refreshCancel != nil {
@@ -599,6 +690,17 @@ func (s *Server) refreshMetadataOnly() {
}()
s.addLog("Fetching metadata...")
+
+ // If the server's next Telegram fetch is imminent (within 5 s), wait for it first.
+ if dl := s.nextFetchDeadline(); !dl.IsZero() && time.Until(dl) < 5*time.Second {
+ s.mu.RLock()
+ nf := s.nextFetch
+ s.mu.RUnlock()
+ if !s.waitForServerFetch(ctx, nf) {
+ return
+ }
+ }
+
meta, err := fetcher.FetchMetadata(ctx)
if err != nil {
if ctx.Err() != nil {
@@ -619,6 +721,7 @@ func (s *Server) refreshMetadataOnly() {
s.channels = meta.Channels
s.telegramLoggedIn = meta.TelegramLoggedIn
s.nextFetch = meta.NextFetch
+ s.metaFetchedAt = time.Now()
s.mu.Unlock()
if cache != nil {
@@ -663,32 +766,64 @@ func (s *Server) refreshChannel(channelNum int) {
s.refreshMu.Unlock()
}()
- meta, err := fetcher.FetchMetadata(ctx)
- if err != nil {
- if ctx.Err() != nil {
- s.addLog("Refresh cancelled")
+ // Use the cached in-memory metadata if it is fresh enough (< metaCacheTTL, default 3 min).
+ // This avoids a redundant metadata DNS fetch for every channel refresh.
+ // If the metadata is stale (or was never fetched), fetch it from DNS now.
+ s.mu.RLock()
+ ttl := s.metaCacheTTL
+ if ttl <= 0 {
+ ttl = 2 * time.Minute
+ }
+ // Cap TTL at the time remaining until the server's next Telegram fetch.
+ // If nextFetch is sooner than our TTL the cached metadata may already be stale.
+ if nf := s.nextFetch; nf > 0 {
+ if rem := time.Until(time.Unix(int64(nf), 0)); rem > 0 && rem < ttl {
+ ttl = rem
+ }
+ }
+ cachedChannels := s.channels
+ cachedAge := time.Since(s.metaFetchedAt)
+ s.mu.RUnlock()
+
+ var meta *protocol.Metadata
+ if len(cachedChannels) > 0 && cachedAge < ttl {
+ // Build a lightweight Metadata from the cached fields to keep the rest of the
+ // function unchanged.
+ s.mu.RLock()
+ meta = &protocol.Metadata{
+ Channels: s.channels,
+ TelegramLoggedIn: s.telegramLoggedIn,
+ NextFetch: s.nextFetch,
+ }
+ s.mu.RUnlock()
+ } else {
+ var err error
+ meta, err = fetcher.FetchMetadata(ctx)
+ if err != nil {
+ if ctx.Err() != nil {
+ s.addLog("Refresh cancelled")
+ return
+ }
+ errStr := err.Error()
+ if strings.Contains(errStr, "integrity check failed") || strings.Contains(errStr, "message authentication failed") || strings.Contains(errStr, "cipher") {
+ s.addLog("Error: Invalid passphrase — check your encryption key in Settings")
+ } else {
+ s.addLog(fmt.Sprintf("Error: %v", err))
+ }
return
}
- errStr := err.Error()
- if strings.Contains(errStr, "integrity check failed") || strings.Contains(errStr, "message authentication failed") || strings.Contains(errStr, "cipher") {
- s.addLog("Error: Invalid passphrase — check your encryption key in Settings")
- } else {
- s.addLog(fmt.Sprintf("Error: %v", err))
+ s.mu.Lock()
+ s.channels = meta.Channels
+ s.telegramLoggedIn = meta.TelegramLoggedIn
+ s.nextFetch = meta.NextFetch
+ s.metaFetchedAt = time.Now()
+ s.mu.Unlock()
+ if cache != nil {
+ _ = cache.PutMetadata(meta)
}
- return
+ s.broadcast("event: update\ndata: \"channels\"\n\n")
}
- s.mu.Lock()
- s.channels = meta.Channels
- s.telegramLoggedIn = meta.TelegramLoggedIn
- s.nextFetch = meta.NextFetch
- s.mu.Unlock()
-
- if cache != nil {
- _ = cache.PutMetadata(meta)
- }
- s.broadcast("event: update\ndata: \"channels\"\n\n")
-
channels := meta.Channels
if channelNum < 1 || channelNum > len(channels) {
s.addLog(fmt.Sprintf("Warning: channel %d is not available", channelNum))
@@ -698,11 +833,13 @@ func (s *Server) refreshChannel(channelNum int) {
ch := channels[channelNum-1]
// Skip refresh if the last message ID and content hash haven't changed
+ // AND we already have messages stored for this channel.
s.mu.RLock()
prevID := s.lastMsgIDs[channelNum]
prevHash := s.lastHashes[channelNum]
+ prevMsgs := s.messages[channelNum]
s.mu.RUnlock()
- if prevID > 0 && ch.LastMsgID == prevID && ch.ContentHash == prevHash {
+ if prevID > 0 && ch.LastMsgID == prevID && ch.ContentHash == prevHash && len(prevMsgs) > 0 {
s.addLog(fmt.Sprintf("Channel %s: no changes (last ID: %d)", ch.Name, prevID))
s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"messages\",\"channel\":%d}\n\n", channelNum))
return
@@ -720,9 +857,32 @@ func (s *Server) refreshChannel(channelNum int) {
return
}
+ // Wrap the context with a deadline at the server's next Telegram fetch.
+ // If the server starts fetching during our block download we cancel early,
+ // wait for the fresh data to land, then restart this channel fetch.
+ fetchCtx := ctx
+ var fetchCancel context.CancelFunc
+ var fetchNF uint32
+ if dl := s.nextFetchDeadline(); !dl.IsZero() {
+ s.mu.RLock()
+ fetchNF = s.nextFetch
+ s.mu.RUnlock()
+ fetchCtx, fetchCancel = context.WithDeadline(ctx, dl)
+ defer fetchCancel()
+ }
+
var msgs []protocol.Message
- msgs, err = fetcher.FetchChannel(ctx, channelNum, blockCount)
+ var err error
+ msgs, err = fetcher.FetchChannel(fetchCtx, channelNum, blockCount)
if err != nil {
+ if fetchCancel != nil && fetchCtx.Err() == context.DeadlineExceeded {
+ // nextFetch fired mid-download — wait for the server, then re-fetch.
+ fetchCancel()
+ if s.waitForServerFetch(ctx, fetchNF) {
+ go s.refreshChannel(channelNum)
+ }
+ return
+ }
if ctx.Err() != nil {
s.addLog("Refresh cancelled")
return
@@ -733,8 +893,13 @@ func (s *Server) refreshChannel(channelNum int) {
s.mu.Lock()
s.messages[channelNum] = msgs
- s.lastMsgIDs[channelNum] = ch.LastMsgID
- s.lastHashes[channelNum] = ch.ContentHash
+ // Only store the metadata IDs when we actually received messages.
+ // If the fetch returned 0 messages but the channel has content (LastMsgID > 0),
+ // keep the old IDs so the next refresh will try a full fetch instead of skipping.
+ if len(msgs) > 0 || ch.LastMsgID == 0 {
+ s.lastMsgIDs[channelNum] = ch.LastMsgID
+ s.lastHashes[channelNum] = ch.ContentHash
+ }
s.mu.Unlock()
if cache != nil {
diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go
index 55994a3..3606c48 100644
--- a/test/e2e/e2e_test.go
+++ b/test/e2e/e2e_test.go
@@ -112,6 +112,7 @@ func TestE2E_FetchMetadataThroughDNS(t *testing.T) {
if err != nil {
t.Fatalf("create fetcher: %v", err)
}
+ fetcher.SetActiveResolvers([]string{resolver})
meta, err := fetcher.FetchMetadata(context.Background())
if err != nil {
@@ -152,6 +153,7 @@ func TestE2E_FetchChannelMessages(t *testing.T) {
if err != nil {
t.Fatalf("create fetcher: %v", err)
}
+ fetcher.SetActiveResolvers([]string{resolver})
meta, err := fetcher.FetchMetadata(context.Background())
if err != nil {
@@ -197,6 +199,7 @@ func TestE2E_FetchWithDoubleLabel(t *testing.T) {
if err != nil {
t.Fatalf("create fetcher: %v", err)
}
+ fetcher.SetActiveResolvers([]string{resolver})
fetcher.SetQueryMode(protocol.QueryMultiLabel)
meta, err := fetcher.FetchMetadata(context.Background())
@@ -232,6 +235,7 @@ func TestE2E_WrongPassphrase(t *testing.T) {
if err != nil {
t.Fatalf("create fetcher: %v", err)
}
+ fetcher.SetActiveResolvers([]string{resolver})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
@@ -261,6 +265,7 @@ func TestE2E_LargeMessages(t *testing.T) {
if err != nil {
t.Fatalf("create fetcher: %v", err)
}
+ fetcher.SetActiveResolvers([]string{resolver})
meta, err := fetcher.FetchMetadata(context.Background())
if err != nil {
@@ -605,6 +610,10 @@ func TestE2E_FullRoundTrip(t *testing.T) {
t.Fatalf("config POST status=%d", resp.StatusCode)
}
+ // Wait for the resolver scan + initial metadata fetch triggered by config POST.
+ // The scan probes the single resolver, then onFirstDone calls refreshMetadataOnly.
+ time.Sleep(2 * time.Second)
+
// Refresh channels via selected-channel API semantics.
respRefresh1, err := http.Post(base+"/api/refresh?channel=1", "application/json", nil)
if err != nil {
@@ -613,7 +622,7 @@ func TestE2E_FullRoundTrip(t *testing.T) {
respRefresh1.Body.Close()
// Give channel 1 refresh goroutine time to complete before refreshing channel 2,
// because starting a new refresh cancels the previous in-flight refresh.
- time.Sleep(700 * time.Millisecond)
+ time.Sleep(1500 * time.Millisecond)
// Channels should be populated
resp2, err := http.Get(base + "/api/channels")
@@ -652,7 +661,7 @@ func TestE2E_FullRoundTrip(t *testing.T) {
t.Fatalf("POST /api/refresh?channel=2: %v", err)
}
respRefresh2.Body.Close()
- time.Sleep(700 * time.Millisecond)
+ time.Sleep(1500 * time.Millisecond)
// Messages for channel 2
resp4, err := http.Get(base + "/api/messages/2")
@@ -753,6 +762,7 @@ func TestE2E_AdminAllowManage(t *testing.T) {
if err != nil {
t.Fatalf("create fetcher: %v", err)
}
+ fetcher.SetActiveResolvers([]string{resolver})
ctx, ctxCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer ctxCancel()
@@ -784,6 +794,7 @@ func TestE2E_AdminNoManage(t *testing.T) {
if err != nil {
t.Fatalf("create fetcher: %v", err)
}
+ fetcher.SetActiveResolvers([]string{resolver})
ctx, ctxCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer ctxCancel()
@@ -1091,3 +1102,220 @@ func TestE2E_Settings_MethodNotAllowed(t *testing.T) {
t.Errorf("expected 405, got %d", resp.StatusCode)
}
}
+
+// --- SSE E2E Tests ---
+
+func TestE2E_SSE_StreamReceivesEvents(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ // Connect to SSE stream
+ resp, err := http.Get(base + "/api/events")
+ if err != nil {
+ t.Fatalf("GET /api/events: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+ ct := resp.Header.Get("Content-Type")
+ if !strings.Contains(ct, "text/event-stream") {
+ t.Errorf("Content-Type = %q, want text/event-stream", ct)
+ }
+}
+
+func TestE2E_SSE_ReceivesRefreshEvents(t *testing.T) {
+ domain := "sse.example.com"
+ passphrase := "sse-test-key"
+ channels := []string{"ssechan"}
+ msgs := map[int][]protocol.Message{
+ 1: {{ID: 1, Timestamp: 1700000000, Text: "SSE test msg"}},
+ }
+
+ resolver, cancelDNS := startDNSServer(t, domain, passphrase, channels, msgs)
+ defer cancelDNS()
+
+ dataDir := t.TempDir()
+ port := findFreePort(t, "tcp")
+ srv, err := web.New(dataDir, port, "")
+ if err != nil {
+ t.Fatalf("create web server: %v", err)
+ }
+ go srv.Run()
+ time.Sleep(200 * time.Millisecond)
+
+ base := fmt.Sprintf("http://127.0.0.1:%d", port)
+
+ // Configure the server
+ cfgJSON := fmt.Sprintf(`{"domain":"%s","key":"%s","resolvers":["%s"],"queryMode":"single","rateLimit":0}`,
+ domain, passphrase, resolver)
+ resp, err := http.Post(base+"/api/config", "application/json", strings.NewReader(cfgJSON))
+ if err != nil {
+ t.Fatalf("POST /api/config: %v", err)
+ }
+ resp.Body.Close()
+
+ // Connect SSE stream
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ req, _ := http.NewRequestWithContext(ctx, "GET", base+"/api/events", nil)
+ sseResp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET /api/events: %v", err)
+ }
+ defer sseResp.Body.Close()
+
+ // Read SSE events — we should see log events from the resolver scan
+ buf := make([]byte, 4096)
+ gotLog := false
+ for i := 0; i < 20; i++ {
+ n, err := sseResp.Body.Read(buf)
+ if err != nil {
+ break
+ }
+ chunk := string(buf[:n])
+ if strings.Contains(chunk, "event: log") {
+ gotLog = true
+ break
+ }
+ }
+ if !gotLog {
+ t.Error("expected to receive at least one log event via SSE")
+ }
+}
+
+// --- Refresh API Tests ---
+
+func TestE2E_WebAPI_RefreshQuietSkip(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ // Quiet metadata refresh on unconfigured server should return ok
+ resp := postJSON(t, base+"/api/refresh?quiet=1", "")
+ m := decodeJSON(t, resp)
+ if m["ok"] != true {
+ t.Errorf("expected ok=true, got %v", m["ok"])
+ }
+}
+
+func TestE2E_WebAPI_RefreshInvalidChannel(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ resp, err := http.Post(base+"/api/refresh?channel=abc", "application/json", nil)
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 400 {
+ t.Errorf("expected 400 for invalid channel, got %d", resp.StatusCode)
+ }
+}
+
+func TestE2E_WebAPI_RefreshNegativeChannel(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ resp, err := http.Post(base+"/api/refresh?channel=-1", "application/json", nil)
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 400 {
+ t.Errorf("expected 400 for negative channel, got %d", resp.StatusCode)
+ }
+}
+
+func TestE2E_WebAPI_SendNotConfigured(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ resp := postJSON(t, base+"/api/send", `{"channel":1,"text":"hello"}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != 400 {
+ t.Errorf("send without config: expected 400, got %d", resp.StatusCode)
+ }
+}
+
+func TestE2E_WebAPI_SendInvalidPayload(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ // Not JSON
+ resp, err := http.Post(base+"/api/send", "application/json", strings.NewReader("not json"))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 400 {
+ t.Errorf("expected 400, got %d", resp.StatusCode)
+ }
+
+ // Missing fields
+ resp2 := postJSON(t, base+"/api/send", `{"channel":0,"text":""}`)
+ defer resp2.Body.Close()
+ if resp2.StatusCode != 400 {
+ t.Errorf("missing fields: expected 400, got %d", resp2.StatusCode)
+ }
+}
+
+func TestE2E_WebAPI_AdminNotConfigured(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ resp := postJSON(t, base+"/api/admin", `{"command":"list_channels"}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != 400 {
+ t.Errorf("admin without config: expected 400, got %d", resp.StatusCode)
+ }
+}
+
+func TestE2E_WebAPI_AdminUnknownCommand(t *testing.T) {
+ dataDir := t.TempDir()
+ port := findFreePort(t, "tcp")
+ srv, err := web.New(dataDir, port, "")
+ if err != nil {
+ t.Fatalf("create web server: %v", err)
+ }
+ go srv.Run()
+ time.Sleep(200 * time.Millisecond)
+ base := fmt.Sprintf("http://127.0.0.1:%d", port)
+
+ // Configure first
+ cfg := `{"domain":"test.example.com","key":"testpass","resolvers":["127.0.0.1:9999"],"queryMode":"single","rateLimit":10}`
+ http.Post(base+"/api/config", "application/json", strings.NewReader(cfg))
+ time.Sleep(100 * time.Millisecond)
+
+ resp := postJSON(t, base+"/api/admin", `{"command":"drop_tables"}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != 400 {
+ t.Errorf("unknown admin command: expected 400, got %d", resp.StatusCode)
+ }
+}
+
+func TestE2E_WebAPI_AdminEmptyCommand(t *testing.T) {
+ base, _ := startWebServer(t)
+
+ resp := postJSON(t, base+"/api/admin", `{"command":""}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != 400 {
+ t.Errorf("empty admin command: expected 400, got %d", resp.StatusCode)
+ }
+}
+
+func TestE2E_WebAPI_SendTooLong(t *testing.T) {
+ dataDir := t.TempDir()
+ port := findFreePort(t, "tcp")
+ srv, err := web.New(dataDir, port, "")
+ if err != nil {
+ t.Fatalf("create web server: %v", err)
+ }
+ go srv.Run()
+ time.Sleep(200 * time.Millisecond)
+ base := fmt.Sprintf("http://127.0.0.1:%d", port)
+
+ cfg := `{"domain":"test.example.com","key":"testpass","resolvers":["127.0.0.1:9999"],"queryMode":"single","rateLimit":10}`
+ http.Post(base+"/api/config", "application/json", strings.NewReader(cfg))
+ time.Sleep(100 * time.Millisecond)
+
+ longText := strings.Repeat("x", 4001)
+ body := fmt.Sprintf(`{"channel":1,"text":"%s"}`, longText)
+ resp := postJSON(t, base+"/api/send", body)
+ defer resp.Body.Close()
+ if resp.StatusCode != 400 {
+ t.Errorf("send too long: expected 400, got %d", resp.StatusCode)
+ }
+}