mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
430 lines
16 KiB
Markdown
430 lines
16 KiB
Markdown
# 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
|