cameras: map only working HTTP/MJPEG feeds
All checks were successful
build-and-deploy / build (push) Successful in 2m53s
All checks were successful
build-and-deploy / build (push) Successful in 2m53s
Masscan open-554 hosts almost never have a public picture. The map now defaults to cameras with an http(s) snapshot_url (~132 directory cams). Ingest probes unauthenticated still/MJPEG paths first and skips the rest, so dead RTSP-only hosts never land on the map. Pass working=false to see unverified port-554 hits.
This commit is contained in:
parent
12262d1562
commit
7ceba736dd
3 changed files with 107 additions and 15 deletions
|
|
@ -42,6 +42,16 @@ _HTTP_PATHS = (
|
||||||
"/tmpfs/auto.jpg",
|
"/tmpfs/auto.jpg",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Browser-playable MJPEG paths the /stream proxy can pass through.
|
||||||
|
_MJPEG_PATHS = (
|
||||||
|
"/mjpg/video.mjpg",
|
||||||
|
"/video.mjpg",
|
||||||
|
"/cgi-bin/mjpg/video.cgi",
|
||||||
|
"/axis-cgi/mjpg/video.cgi",
|
||||||
|
"/nphMotionJpeg",
|
||||||
|
"/mjpeg.cgi",
|
||||||
|
)
|
||||||
|
|
||||||
_FFMPEG = shutil.which("ffmpeg")
|
_FFMPEG = shutil.which("ffmpeg")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -77,6 +87,55 @@ async def _http_get_image(url: str, timeout: float = 2.5) -> bytes | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _http_feed_url(url: str, timeout: float = 2.5) -> str | None:
|
||||||
|
"""Return url if it looks like an unauthenticated image/MJPEG feed."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=timeout, follow_redirects=True,
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
) as c:
|
||||||
|
async with c.stream("GET", url) as r:
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
ctype = (r.headers.get("content-type") or "").lower()
|
||||||
|
if "html" in ctype or ctype.startswith("text/"):
|
||||||
|
return None
|
||||||
|
if any(x in ctype for x in ("image/", "multipart", "mjpeg", "octet-stream")):
|
||||||
|
# Read a little to reject empty/error bodies.
|
||||||
|
chunk = b""
|
||||||
|
async for b in r.aiter_bytes():
|
||||||
|
chunk += b
|
||||||
|
if len(chunk) >= 64:
|
||||||
|
break
|
||||||
|
if len(chunk) < 64:
|
||||||
|
return None
|
||||||
|
if b"html" in chunk[:64].lower():
|
||||||
|
return None
|
||||||
|
return url
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_public_feed(host: str) -> str | None:
|
||||||
|
"""Unauthenticated HTTP still or MJPEG URL for this host, or None.
|
||||||
|
|
||||||
|
Used at masscan ingest time so dead RTSP-only hosts never hit the map.
|
||||||
|
No credentials, no RTSP path-walking (too slow / rarely public).
|
||||||
|
"""
|
||||||
|
urls = [f"http://{host}{p}" for p in _HTTP_PATHS]
|
||||||
|
urls.append(f"http://{host}:8080/shot.jpg")
|
||||||
|
urls.extend(f"http://{host}{p}" for p in _MJPEG_PATHS)
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(_http_feed_url(u) for u in urls),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
for url, hit in zip(urls, results):
|
||||||
|
if isinstance(hit, str) and hit:
|
||||||
|
return hit
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def ffmpeg_snapshot(url: str, timeout: float = 8.0) -> bytes | None:
|
async def ffmpeg_snapshot(url: str, timeout: float = 8.0) -> bytes | None:
|
||||||
"""Grab a single JPEG frame from an RTSP URL. None if ffmpeg missing/fails."""
|
"""Grab a single JPEG frame from an RTSP URL. None if ffmpeg missing/fails."""
|
||||||
if not _FFMPEG or not url.lower().startswith("rtsp://"):
|
if not _FFMPEG or not url.lower().startswith("rtsp://"):
|
||||||
|
|
|
||||||
16
app/main.py
16
app/main.py
|
|
@ -23,7 +23,7 @@ import structlog
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import and_, func, select, text
|
from sqlalchemy import and_, func, or_, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import async_session, init_extensions
|
from database import async_session, init_extensions
|
||||||
|
|
@ -708,6 +708,12 @@ async def list_cameras(
|
||||||
description="Bounding box 'min_lon,min_lat,max_lon,max_lat'",
|
description="Bounding box 'min_lon,min_lat,max_lon,max_lat'",
|
||||||
),
|
),
|
||||||
source: str | None = Query(None, description="Filter by discovery_source"),
|
source: str | None = Query(None, description="Filter by discovery_source"),
|
||||||
|
working: bool = Query(
|
||||||
|
True,
|
||||||
|
description="Only cameras with a verified HTTP/MJPEG snapshot_url "
|
||||||
|
"(the ones that actually preview). Set false to include "
|
||||||
|
"unverified masscan port-554 hits.",
|
||||||
|
),
|
||||||
limit: int = Query(500, ge=1, le=5000),
|
limit: int = Query(500, ge=1, le=5000),
|
||||||
):
|
):
|
||||||
"""Cameras for map display, optionally filtered by geographic bbox."""
|
"""Cameras for map display, optionally filtered by geographic bbox."""
|
||||||
|
|
@ -715,6 +721,14 @@ async def list_cameras(
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
stmt = select(cam_table).order_by(cam_table.c.last_seen.desc())
|
stmt = select(cam_table).order_by(cam_table.c.last_seen.desc())
|
||||||
|
if working:
|
||||||
|
stmt = stmt.where(
|
||||||
|
cam_table.c.snapshot_url.isnot(None),
|
||||||
|
or_(
|
||||||
|
cam_table.c.snapshot_url.startswith("http://"),
|
||||||
|
cam_table.c.snapshot_url.startswith("https://"),
|
||||||
|
),
|
||||||
|
)
|
||||||
if bbox:
|
if bbox:
|
||||||
try:
|
try:
|
||||||
min_lon, min_lat, max_lon, max_lat = (
|
min_lon, min_lat, max_lon, max_lat = (
|
||||||
|
|
|
||||||
|
|
@ -94,20 +94,36 @@ def extract_open_ips(records: list[dict], port: int) -> list[str]:
|
||||||
|
|
||||||
# ── Persistence ───────────────────────────────────────────────────────────
|
# ── Persistence ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def ingest_open_hosts(ips: list[str]) -> tuple[int, int]:
|
async def ingest_open_hosts(ips: list[str]) -> tuple[int, list[str]]:
|
||||||
"""Insert-or-refresh camera rows for open RTSP hosts.
|
"""Insert-or-refresh camera rows for open RTSP hosts that have a public feed.
|
||||||
|
|
||||||
Returns (newly_inserted, total_hosts_seen_this_batch). Geolocates each
|
A host only lands in the table (and therefore on the map) if an
|
||||||
host via the shared ip-api batch resolver. Already-known hosts (matching
|
unauthenticated HTTP still or MJPEG URL responds. Port-554-only hosts
|
||||||
url_hash) have last_seen/coords refreshed and are NOT counted as new.
|
are skipped. Returns (newly_inserted, hosts_with_working_feed).
|
||||||
"""
|
"""
|
||||||
if not ips:
|
if not ips:
|
||||||
return 0, 0
|
return 0, []
|
||||||
|
from camera_preview import probe_public_feed
|
||||||
|
|
||||||
|
unique = list(dict.fromkeys(ips))
|
||||||
|
sem = asyncio.Semaphore(20)
|
||||||
|
|
||||||
|
async def _probe(ip: str) -> tuple[str, str | None]:
|
||||||
|
async with sem:
|
||||||
|
return ip, await probe_public_feed(ip)
|
||||||
|
|
||||||
|
probed = await asyncio.gather(*(_probe(ip) for ip in unique))
|
||||||
|
live = [(ip, feed) for ip, feed in probed if feed]
|
||||||
|
if not live:
|
||||||
|
logger.info("masscan ingest: 0 working feeds of %d open-554 hosts",
|
||||||
|
len(unique))
|
||||||
|
return 0, []
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
coords = await geolocate_ips(list(dict.fromkeys(ips)))
|
coords = await geolocate_ips([ip for ip, _ in live])
|
||||||
new = 0
|
new = 0
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
for ip in dict.fromkeys(ips):
|
for ip, feed in live:
|
||||||
url = build_rtsp_url(ip)
|
url = build_rtsp_url(ip)
|
||||||
h = url_hash(url)
|
h = url_hash(url)
|
||||||
lat, lon = coords.get(ip, (None, None))
|
lat, lon = coords.get(ip, (None, None))
|
||||||
|
|
@ -118,7 +134,7 @@ async def ingest_open_hosts(ips: list[str]) -> tuple[int, int]:
|
||||||
await session.execute(cameras.insert().values(
|
await session.execute(cameras.insert().values(
|
||||||
url_hash=h,
|
url_hash=h,
|
||||||
source_url=url,
|
source_url=url,
|
||||||
snapshot_url=None, # RTSP-only; no HTTP snapshot
|
snapshot_url=feed,
|
||||||
discovery_source=MASSCAN_DISCOVERY_SOURCE,
|
discovery_source=MASSCAN_DISCOVERY_SOURCE,
|
||||||
location_lat=lat,
|
location_lat=lat,
|
||||||
location_lon=lon,
|
location_lon=lon,
|
||||||
|
|
@ -127,7 +143,8 @@ async def ingest_open_hosts(ips: list[str]) -> tuple[int, int]:
|
||||||
device_type="rtsp",
|
device_type="rtsp",
|
||||||
first_seen=now,
|
first_seen=now,
|
||||||
last_seen=now,
|
last_seen=now,
|
||||||
raw={"discovered_via": "masscan", "port": 554},
|
raw={"discovered_via": "masscan", "port": 554,
|
||||||
|
"public_feed": feed},
|
||||||
))
|
))
|
||||||
new += 1
|
new += 1
|
||||||
else:
|
else:
|
||||||
|
|
@ -135,13 +152,15 @@ async def ingest_open_hosts(ips: list[str]) -> tuple[int, int]:
|
||||||
cameras.c.url_hash == h
|
cameras.c.url_hash == h
|
||||||
).values(
|
).values(
|
||||||
last_seen=now,
|
last_seen=now,
|
||||||
|
snapshot_url=feed,
|
||||||
location_lat=lat,
|
location_lat=lat,
|
||||||
location_lon=lon,
|
location_lon=lon,
|
||||||
location_name=f"{ip} (IP-geo)" if lat is not None else None,
|
location_name=f"{ip} (IP-geo)" if lat is not None else None,
|
||||||
))
|
))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("masscan ingest: %d new, %d refreshed", new, len(set(ips)))
|
logger.info("masscan ingest: %d new working feeds (%d probed, %d open-554)",
|
||||||
return new, len(set(ips))
|
new, len(live), len(unique))
|
||||||
|
return new, [ip for ip, _ in live]
|
||||||
|
|
||||||
|
|
||||||
# ── NATS publish ──────────────────────────────────────────────────────────
|
# ── NATS publish ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -201,7 +220,7 @@ async def flush(seen: set[str], new_accum: int) -> tuple[int, int]:
|
||||||
if not seen:
|
if not seen:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
ips = list(seen)
|
ips = list(seen)
|
||||||
new, _ = await ingest_open_hosts(ips)
|
new, live = await ingest_open_hosts(ips)
|
||||||
published = await publish_new_hosts(ips)
|
published = await publish_new_hosts(live)
|
||||||
seen.clear()
|
seen.clear()
|
||||||
return new, published
|
return new, published
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue