# 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//edit/` | ModelForm | | `ProfileDeleteView` | `/ui/netscan/profile//delete/` | Confirm page | | `ScanRunListView` | `/ui/netscan/profile//runs/` | Paginated history, status + severity count cols | | `ScanRunDetailView` | `/ui/netscan/run//` | Findings grouped by severity, collapsible raw JSON | | `TriggerScanView` | `/ui/netscan/profile//trigger/` | POST-only; spawns `Thread(target=run_scan, args=[pk])`, redirects to run list | | `TestTelegramView` | `/ui/netscan/profile//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//edit/', ProfileUpdateView.as_view(), name='netscan-profile-edit'), path('profile//delete/', ProfileDeleteView.as_view(), name='netscan-profile-delete'), path('profile//runs/', ScanRunListView.as_view(), name='netscan-run-list'), path('profile//trigger/', TriggerScanView.as_view(), name='netscan-trigger'), path('profile//test-telegram/', TestTelegramView.as_view(), name='netscan-test-telegram'), path('run//', 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