# Nexstream Scraper: Bot Attack & Hybrid Mitigation Case Study

## Incident Summary
- **Issue:** `nexstream-scraper` container reported as `unhealthy` for 2000+ consecutive health checks.
- **Symptom:** High volume of `404` errors in logs targeting `/.git/config`, `/wp-content/`, `/wp-json/` paths.
- **Root Cause:** Bot vulnerability scanners hitting the scraper on two attack vectors: (1) directly via `0.0.0.0:3091/3443` (Docker port mapping), and (2) through the Tailscale Funnel URL.

## Technical Deep Dive

### Container State
Container was unhealthy with `FailingStreak: 2069`. Every 30s the health check ran:
```
wget http://localhost:3091/health → Connection refused
```

### Health Check Bug (IPv4/IPv6 Mismatch)
The scraper app binds to `0.0.0.0` (IPv4 all interfaces). Inside the Docker container, `localhost` resolves to `::1` (IPv6 loopback) first. Since the app doesn't listen on IPv6, `wget http://localhost:3091/health` fails.

**Fix:** Use `127.0.0.1` instead of `localhost` in the health check URL:
```yaml
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3091/health"]
```

### Approach Decision
| Option | Rejected Because |
|--------|-----------------|
| Caddy with self-signed cert | Requires every Android TV user to install a custom CA — impossible for consumer app |
| Cloudflare Tunnel | ToS restricts video streaming, requires cloudflared daemon, adds 15-45ms latency |
| nginx | **Chosen** — already running on the server for Open WebUI, zero additional infra |

## Final Architecture

```
Users → https://debian-ai.tailf83ed5.ts.net (Tailscale Funnel, valid HTTPS, IP hidden)
          ↓
       http://127.0.0.1:3089 (nginx, rate limit 50r/s per IP, burst 100)
          ↓
       http://172.19.0.2:3091 (Docker nexstream-net, no public ports)
          ↓
       scraper (Node.js)
```

### Changes Made
| Before | After |
|--------|-------|
| Docker `ports: ["3091:3091", "3443:3443"]` | Ports removed — container reachable only inside Docker network |
| Health check: `http://localhost:3091/health` | Changed to `http://127.0.0.1:3091/health` |
| Funnel → scraper directly | Funnel → nginx (127.0.0.1:3089) → scraper |
| No rate limiting | nginx `limit_req_zone` at 50r/s per IP, burst 100 |

### Key nginx Configuration
```nginx
limit_req_zone $binary_remote_addr zone=scraper:10m rate=50r/s;
upstream scraper_backend { server 172.19.0.2:3091; keepalive 16; }
server {
    listen 127.0.0.1:3089;
    limit_req zone=scraper burst=100 nodelay;
    proxy_pass http://scraper_backend;
}
```

### Verification
- Direct port 127.0.0.1:3091 → CLOSED (connection refused) ✅
- nginx proxy 127.0.0.1:3089/health → 200 OK ✅
- Funnel URL /health → 200 OK ✅
- Container health status → healthy ✅

## Key Takeaways
1. Health check using `localhost` inside Docker fails on IPv6 — use `127.0.0.1`.
2. Removing `0.0.0.0` port mappings eliminates direct bot attacks.
3. Tailscale Funnel + nginx rate limiting is the simplest production-ready pattern for services without a domain.
4. Rate limiting at the proxy layer prevents bots from overwhelming the application event loop.
