# NetScan — Home Network Security Scanner

A new Django sub-app (`netscan`) added to the Links project that runs configurable, scheduled security checks against your home network, stores results in SQLite, sends Telegram alerts for critical findings, and surfaces everything through a Tailwind UI inside the existing mini-apps section.

---

## Context & Constraints

- **Codebase**: Django + APScheduler + Tailwind + SQLite (PostgreSQL in prod via env var)
- **Deployment**: Same Docker image as the Links app, deployed in K3s at 192.168.1.2 (server-3)
- **Why in-cluster**: The scanner must run **inside the LAN** to probe 192.168.1.x addresses (router, cameras). Running on the cluster node satisfies this automatically.
- **No new dependencies except `requests`** (already present). All checks use stdlib `socket`, `ssl`, `subprocess`, `struct`.
- **No new JS framework** — pure Django templates + Tailwind, matching the rest of the app.

---

## Database Models (`netscan/models.py`)

### `ScanProfile`
| Field | Type | Notes |
|---|---|---|
| `name` | CharField | Human label e.g. "Home Network" |
| `enabled` | BooleanField | Controls scheduler |
| `schedule_interval` | IntegerField | 1 / 3 / 7 / 30 days (choices) |
| `gateway_ip` | GenericIPAddressField | e.g. 192.168.1.1 |
| `public_ip` | GenericIPAddressField | e.g. 14.137.198.99 |
| `network_cidr` | CharField | e.g. 192.168.1.0/24 |
| `auth_provider_host` | CharField | e.g. pass.junv.cc (for ingress check) |
| `domains` | JSONField | List of public hostnames to check |
| `cameras` | JSONField | List of camera IPs to probe |
| `telegram_bot_token` | CharField | blank/null; stored encrypted in env preferred |
| `telegram_chat_id` | CharField | blank/null |
| `notify_on_severity` | CharField | "warning" or "critical" (default "critical") |
| `last_run_at` | DateTimeField | null |
| `created_at` | DateTimeField | auto |

### `ScanRun`
| Field | Type | Notes |
|---|---|---|
| `profile` | FK → ScanProfile | cascade delete |
| `started_at` | DateTimeField | |
| `finished_at` | DateTimeField | null |
| `status` | CharField | pending / running / success / failed |
| `summary` | JSONField | `{ok:N, info:N, warning:N, critical:N}` |
| `triggered_by` | CharField | "scheduler" or "manual" |

### `ScanFinding`
| Field | Type | Notes |
|---|---|---|
| `run` | FK → ScanRun | cascade delete |
| `check_name` | CharField | e.g. "router_ports", "tls_expiry" |
| `severity` | CharField | ok / info / warning / critical |
| `title` | CharField | Short human summary |
| `detail` | TextField | Full explanation |
| `raw` | JSONField | Raw probe output for debugging |

---

## Check Modules (`netscan/checks/`)

Each module exposes a single function `run(profile) -> list[Finding]`. All use only stdlib + `requests`.

### `checks/base.py`
```python
@dataclass
class Finding:
    check_name: str
    severity: str        # ok | info | warning | critical
    title: str
    detail: str
    raw: dict
```

### `checks/router.py` — Gateway security
- TCP connect-probe gateway IP on ports: 22, 23, 53, 80, 139, 443, 445, 8080, 8443
- Fetch HTTP headers from port 80 to identify plain-HTTP admin (`Server: httpd`)
- Flag: SMB open (139/445) → **warning**
- Flag: SSH closed → **ok**; SSH open from WAN → **warning**
- Flag: plain-HTTP admin (no HTTPS on 443) → **warning**
- Flag: unknown port 8080 open → **info**

### `checks/dns.py` — Open resolver (WAN DNS exposure)
- Send raw DNS query for `google.com A` to `profile.public_ip:53` over UDP using `socket` + `struct`
- Parse the response: if `NOERROR` + answer section returned → recursion available
- Flag: open recursive resolver → **critical**
- Note in detail: "Verify from off-LAN; NAT hairpin may cause false positive"

### `checks/ingress.py` — Public domain auth verification
- For each domain in `profile.domains`:
  - `requests.get(f"https://{domain}/", allow_redirects=True, timeout=8)`
  - Check: does the redirect chain pass through `profile.auth_provider_host`?
  - Check: does the final URL (after all redirects) contain the auth provider host?
  - If final URL is the app itself (not the auth host) → **critical** (auth bypassed)
  - If redirect goes through auth provider → **ok**
  - If connection refused / DNS fails → **warning**

### `checks/cameras.py` — RTSP unauthenticated access
- For each IP in `profile.cameras`:
  1. TCP connect to port 554 — if closed → **info** (skip)
  2. Send `OPTIONS rtsp://{ip}/ RTSP/1.0\r\nCSeq: 1\r\n\r\n` over raw socket
  3. Parse response status line
  4. If OPTIONS returns 200, send `DESCRIBE rtsp://{ip}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n`
  5. If DESCRIBE returns 200 (stream accessible with no creds) → **critical**
  6. If DESCRIBE returns 401/403 → **ok** (auth required)
  7. If OPTIONS returns 404 / no common path accessible → **ok**

### `checks/tls.py` — TLS certificate validity
- For each domain in `profile.domains`:
  - `ssl.get_server_certificate((domain, 443))` + parse `notAfter`
  - Days to expiry < 0 → **critical** (expired)
  - Days to expiry < 14 → **critical**
  - Days to expiry < 30 → **warning**
  - Otherwise → **ok**
  - Also flag if cert `CN`/`SAN` doesn't match the domain → **warning**

### `checks/ports.py` — Public IP port exposure
- TCP connect-probe `profile.public_ip` on: 22, 23, 25, 53, 80, 443, 3306, 5432, 6379, 8080, 8443
- Flag presence of each open port with canned risk descriptions:
  - 80/443 → **info** (expected for web services)
  - 22 → **warning** (SSH exposed to internet)
  - 23 → **critical** (Telnet exposed)
  - 53 → **warning** (DNS — run dns check to confirm resolver)
  - Database ports (3306/5432/6379) → **critical**
  - 8443/8080 → **warning** (alt web ports)

---

## Scanner Orchestrator (`netscan/scanner.py`)

```python
def run_scan(profile_id: int) -> int:
    """
    Runs all checks for the given profile.
    Returns the ScanRun PK.
    Called by APScheduler jobs and TriggerScanView.
    """
    profile = ScanProfile.objects.get(pk=profile_id)
    run = ScanRun.objects.create(profile=profile, status='running', triggered_by=...)

    all_findings = []
    check_modules = [router, dns, ingress, cameras, tls, ports]
    for mod in check_modules:
        try:
            findings = mod.run(profile)
            all_findings.extend(findings)
        except Exception as e:
            # Wrap uncaught errors as a warning finding so run still completes
            all_findings.append(Finding(check_name=mod.__name__, severity='warning',
                                        title='Check errored', detail=str(e), raw={}))

    # Persist findings
    ScanFinding.objects.bulk_create([...])

    # Update run summary
    summary = Counter(f.severity for f in all_findings)
    run.summary = dict(summary)
    run.status = 'success'
    run.finished_at = now()
    run.save()

    # Telegram notification
    if profile.telegram_bot_token and profile.telegram_chat_id:
        notify_telegram(profile, run, all_findings)

    # Update profile.last_run_at
    profile.last_run_at = now()
    profile.save(update_fields=['last_run_at'])

    return run.pk
```

---

## Telegram Notifications (`netscan/notifications.py`)

```python
def notify_telegram(profile, run, findings):
    """
    Sends a Telegram message if any finding meets or exceeds notify_on_severity.
    Uses the Bot API sendMessage endpoint directly via requests (no library needed).
    """
    threshold_order = ['ok', 'info', 'warning', 'critical']
    threshold_idx = threshold_order.index(profile.notify_on_severity)

    flagged = [f for f in findings
               if threshold_order.index(f.severity) >= threshold_idx]
    if not flagged:
        return

    lines = [f"🔒 *NetScan Alert* — {profile.name}",
             f"Run #{run.pk} finished at {run.finished_at:%Y-%m-%d %H:%M}",
             f"Summary: {run.summary}",
             ""]
    for f in flagged[:10]:   # cap at 10 to stay under TG message limit
        icon = {'critical': '🔴', 'warning': '🟡', 'ok': '🟢', 'info': 'ℹ️'}[f.severity]
        lines.append(f"{icon} *{f.title}*\n  {f.detail[:120]}")

    if len(flagged) > 10:
        lines.append(f"_...and {len(flagged)-10} more findings_")

    text = "\n".join(lines)
    url = f"https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage"
    requests.post(url, json={
        "chat_id": profile.telegram_chat_id,
        "text": text,
        "parse_mode": "Markdown"
    }, timeout=10)
```

---

## APScheduler Integration (`netscan/tasks.py` + `netscan/apps.py`)

### `tasks.py`
```python
from core.scheduler import scheduler
from apscheduler.triggers.interval import IntervalTrigger

def schedule_profile(profile):
    job_id = f'netscan_profile_{profile.pk}'
    scheduler.add_job(
        run_scan,
        trigger=IntervalTrigger(days=profile.schedule_interval),
        id=job_id,
        args=[profile.pk],
        replace_existing=True
    )

def unschedule_profile(profile):
    job_id = f'netscan_profile_{profile.pk}'
    if scheduler.get_job(job_id):
        scheduler.remove_job(job_id)
```

### `apps.py`
```python
class NetscanConfig(AppConfig):
    name = 'netscan'

    def ready(self):
        from netscan.tasks import schedule_profile
        from netscan.models import ScanProfile
        for profile in ScanProfile.objects.filter(enabled=True):
            schedule_profile(profile)
```

### `signals.py`
```python
@receiver(post_save, sender=ScanProfile)
def reschedule_on_save(sender, instance, **kwargs):
    if instance.enabled:
        schedule_profile(instance)
    else:
        unschedule_profile(instance)
```

---

## Views (`netscan/views.py`)

All views use `LoginRequiredMixin`.

| View | URL | Notes |
|---|---|---|
| `DashboardView` | `/ui/netscan/` | List profiles, worst-severity badge per profile, last run time, Run Now + Edit buttons |
| `ProfileCreateView` | `/ui/netscan/profile/new/` | ModelForm |
| `ProfileUpdateView` | `/ui/netscan/profile/<pk>/edit/` | ModelForm |
| `ProfileDeleteView` | `/ui/netscan/profile/<pk>/delete/` | Confirm page |
| `ScanRunListView` | `/ui/netscan/profile/<pk>/runs/` | Paginated history, status + severity count cols |
| `ScanRunDetailView` | `/ui/netscan/run/<pk>/` | Findings grouped by severity, collapsible raw JSON |
| `TriggerScanView` | `/ui/netscan/profile/<pk>/trigger/` | POST-only; spawns `Thread(target=run_scan, args=[pk])`, redirects to run list |
| `TestTelegramView` | `/ui/netscan/profile/<pk>/test-telegram/` | POST-only; sends a test message, returns JSON |

---

## Templates (`netscan/templates/netscan/`)

All extend `base.html`, use Tailwind classes matching the existing app.

### `dashboard.html`
- Grid of profile cards (matches mini_apps card style)
- Each card: name, schedule chip (e.g. "Every 7 days"), last run timestamp, worst-severity badge (🔴/🟡/🟢), finding count breakdown
- "Run Now" button (POST to trigger URL), "Edit" link, "History" link
- Empty state with "Create your first scan profile" CTA

### `profile_form.html`
- Fields: Name, Schedule (dropdown: 1/3/7/30 days), Gateway IP, Public IP, Network CIDR, Auth Provider Host, Domains (textarea, one per line), Camera IPs (textarea, one per line), Telegram Bot Token, Telegram Chat ID, Notify on Severity (dropdown: warning/critical), Enabled checkbox
- "Test Telegram" button (JS fetch to TestTelegramView, shows inline success/error)

### `run_list.html`
- Table: Started, Duration, Triggered by, Status badge, 🔴 Critical, 🟡 Warning, 🟢 OK counts, View link
- Pagination

### `run_detail.html`
- Header: profile name, run timestamp, status, summary badges, "Re-run" button
- Three collapsible sections: Critical findings, Warnings, OK/Info
- Each finding: title, detail text; expandable "Raw" disclosure showing JSON
- Back to history link

---

## URL Wiring

### `core/urls.py` — add:
```python
path('ui/netscan/', include('netscan.urls')),
```

### `netscan/urls.py`:
```python
urlpatterns = [
    path('', DashboardView.as_view(), name='netscan-dashboard'),
    path('profile/new/', ProfileCreateView.as_view(), name='netscan-profile-create'),
    path('profile/<int:pk>/edit/', ProfileUpdateView.as_view(), name='netscan-profile-edit'),
    path('profile/<int:pk>/delete/', ProfileDeleteView.as_view(), name='netscan-profile-delete'),
    path('profile/<int:pk>/runs/', ScanRunListView.as_view(), name='netscan-run-list'),
    path('profile/<int:pk>/trigger/', TriggerScanView.as_view(), name='netscan-trigger'),
    path('profile/<int:pk>/test-telegram/', TestTelegramView.as_view(), name='netscan-test-telegram'),
    path('run/<int:pk>/', ScanRunDetailView.as_view(), name='netscan-run-detail'),
]
```

### `core/settings.py` — add to INSTALLED_APPS:
```python
'netscan',
```

### `links/mini_apps_views.py` — add to `mini_apps` list:
```python
{
    'name': 'NetScan',
    'description': 'Scheduled home network security scanner. Checks router exposure, DNS, TLS certs, public ingress auth, and camera access.',
    'url': 'netscan-dashboard',
    'thumbnail': 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=400&h=300&fit=crop',
    'icon': 'fas fa-shield-alt',
    'color': '#e74c3c'
},
```

---

## K3s Deployment Changes (`k8s/manifest.yaml`)

The scanner runs **inside the same Pod** as the Links app — no new container or service needed. The existing Deployment only needs two new env vars (or they can be set per-profile in the DB):

```yaml
# Optional: global fallback Telegram credentials
- name: NETSCAN_TELEGRAM_BOT_TOKEN
  valueFrom:
    secretKeyRef:
      name: netscan-credentials
      key: telegram_bot_token
- name: NETSCAN_TELEGRAM_CHAT_ID
  valueFrom:
    secretKeyRef:
      name: netscan-credentials
      key: telegram_chat_id
```

Create the secret:
```bash
kubectl create secret generic netscan-credentials \
  --from-literal=telegram_bot_token=YOUR_TOKEN \
  --from-literal=telegram_chat_id=YOUR_CHAT_ID \
  -n home-apps
```

The Pod already runs inside the LAN (on server-3 at 192.168.1.2), so it can reach 192.168.1.1 (router), 192.168.1.70–250 (cameras), and the public IP.

---

## File Checklist

| File | Action |
|---|---|
| `netscan/__init__.py` | create (empty) |
| `netscan/apps.py` | create |
| `netscan/models.py` | create |
| `netscan/admin.py` | create (register all 3 models) |
| `netscan/checks/__init__.py` | create (empty) |
| `netscan/checks/base.py` | create |
| `netscan/checks/router.py` | create |
| `netscan/checks/dns.py` | create |
| `netscan/checks/ingress.py` | create |
| `netscan/checks/cameras.py` | create |
| `netscan/checks/tls.py` | create |
| `netscan/checks/ports.py` | create |
| `netscan/scanner.py` | create |
| `netscan/notifications.py` | create |
| `netscan/tasks.py` | create |
| `netscan/signals.py` | create |
| `netscan/forms.py` | create |
| `netscan/views.py` | create |
| `netscan/urls.py` | create |
| `netscan/migrations/0001_initial.py` | create (via makemigrations) |
| `netscan/templates/netscan/dashboard.html` | create |
| `netscan/templates/netscan/profile_form.html` | create |
| `netscan/templates/netscan/run_list.html` | create |
| `netscan/templates/netscan/run_detail.html` | create |
| `core/settings.py` | add `'netscan'` to INSTALLED_APPS |
| `core/urls.py` | add `path('ui/netscan/', include('netscan.urls'))` |
| `links/mini_apps_views.py` | add NetScan entry to mini_apps list |
| `k8s/manifest.yaml` | add env vars for Telegram credentials |

---

## Verification Steps

1. `python manage.py makemigrations netscan && python manage.py migrate` — no errors
2. `python manage.py runserver` — navigate to `/ui/netscan/` — dashboard loads
3. Create a ScanProfile via the form, ensure all fields save
4. Click "Run Now" — `ScanRun` + `ScanFinding` rows appear in DB; run_detail page shows them
5. Set schedule to 1 day, save — `scheduler.get_jobs()` shows a `netscan_profile_N` job
6. Toggle profile disabled — job is removed from scheduler
7. If Telegram configured, click "Test Telegram" — message appears in chat
8. After a scheduled run: Telegram alert arrives for any critical/warning findings
9. All views return 302 to login when accessed without auth
10. `kubectl apply -f k8s/manifest.yaml` — Pod starts cleanly with new env vars

---

## Implementation Order (for LLM session in links workspace)

1. **Phase 1** (parallel): models + migration + app registration + all check modules (no inter-dependencies)
2. **Phase 2**: scanner.py + notifications.py (depends on models + checks)
3. **Phase 3**: tasks.py + apps.py + signals.py (depends on scanner)
4. **Phase 4**: views.py + urls.py + forms.py (depends on models)
5. **Phase 5**: all 4 templates (depends on views)
6. **Phase 6**: wire into core/settings, core/urls, mini_apps_views, k8s/manifest
