Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ class RiskOut(BaseModel):
device_id: int
ip_address: str
hostname: str | None
label: str | None
severity: str
display_severity: str # may differ from severity based on active network profile
check_id: str
Expand Down Expand Up @@ -406,6 +407,7 @@ def _risk_to_out(r, profile: str = "standard_home") -> RiskOut: # noqa: ANN001
device_id=r.device_id,
ip_address=r.device.ip_address,
hostname=r.device.hostname,
label=r.device.label,
severity=r.severity,
display_severity=display_severity_for_check(r.check_id, r.severity, profile),
check_id=r.check_id,
Expand Down
66 changes: 48 additions & 18 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ def _migrate_schema(engine) -> None:
if column not in existing_cols:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {col_def}")) # noqa: S608 — table/column/col_def are internal constants, not user input
conn.commit()
# Ensure the MAC address index exists (CREATE INDEX IF NOT EXISTS is idempotent).
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_devices_mac_address ON devices (mac_address)" # noqa: S608 — DDL constant
)
)
conn.commit()


def init_db() -> None:
Expand Down Expand Up @@ -93,21 +100,42 @@ def upsert_device(
hostname: str | None = None,
os_guess: str | None = None,
) -> Device:
"""Insert or update a Device row keyed on ip_address.

If a Device with the given ip_address already exists, only non-None
fields are written so that richer data from a previous scan is never
overwritten with None. The caller is responsible for committing.

Returns the Device instance (either existing or newly created).
"""Insert or update a Device row, using MAC address as the primary identity key.

Lookup order:
1. If mac_address is provided, find an existing device with that MAC.
- Found at the same IP → normal update (non-None fields only).
- Found at a different IP → update ip_address to the new one, preserving
user-set fields (label, trusted, device_type).
2. Fall back to ip_address lookup (covers devices that don't broadcast MAC,
e.g. traffic routed through a switch without ARP visibility).
3. If no existing device is found, create a new one.

The caller is responsible for committing. Returns the Device instance.
"""
from sqlalchemy import select

from app.models.device import Device

stmt = select(Device).where(Device.ip_address == ip_address)
device: Device | None = session.execute(stmt).scalar_one_or_none()
device: Device | None = None

# --- MAC-first lookup ---
if mac_address is not None:
device = session.execute(
select(Device).where(Device.mac_address == mac_address)
).scalar_one_or_none()
if device is not None and device.ip_address != ip_address:
# Device moved to a new IP — update the address in place so
# user-assigned label/trusted/device_type are preserved.
device.ip_address = ip_address

# --- IP fallback ---
if device is None:
device = session.execute(
select(Device).where(Device.ip_address == ip_address)
).scalar_one_or_none()

# --- Create ---
if device is None:
device = Device(
ip_address=ip_address,
Expand All @@ -117,15 +145,17 @@ def upsert_device(
os_guess=os_guess,
)
session.add(device)
else:
if mac_address is not None:
device.mac_address = mac_address
if vendor is not None:
device.vendor = vendor
if hostname is not None:
device.hostname = hostname
if os_guess is not None:
device.os_guess = os_guess
return device

# --- Update non-None scan fields (never overwrite user-set fields) ---
if mac_address is not None:
device.mac_address = mac_address
if vendor is not None:
device.vendor = vendor
if hostname is not None:
device.hostname = hostname
if os_guess is not None:
device.os_guess = os_guess

return device

Expand Down
3 changes: 3 additions & 0 deletions backend/app/models/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
Expand All @@ -22,6 +23,8 @@ class Device(Base):
__table_args__ = (
# One row per IP address — upserts update in place rather than inserting duplicates.
UniqueConstraint("ip_address", name="uq_devices_ip_address"),
# Index for MAC-first lookup in upsert_device().
Index("ix_devices_mac_address", "mac_address"),
)

id = Column(Integer, primary_key=True, index=True)
Expand Down
1 change: 1 addition & 0 deletions backend/app/scan_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ def _record_scan_events(
{
"ip": d.ip_address,
"hostname": d.hostname,
"label": d.label,
"mac": d.mac_address,
"vendor": d.vendor,
}
Expand Down
88 changes: 88 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -965,3 +965,91 @@ def test_wan_returns_stored_ip(client, db_engine):
db.query(AppSetting).filter(AppSetting.key.in_(["wan_ip", "wan_ip_detected_at"])).delete()
db.commit()
db.close()


# ── upsert_device MAC-first identity ─────────────────────────────────────────


def test_upsert_device_creates_new(db_engine):
"""upsert_device creates a new Device when MAC and IP are both unseen."""
from app.db import upsert_device
from app.models.device import Device
from sqlalchemy.orm import sessionmaker

Session = sessionmaker(bind=db_engine) # noqa: N806 — sessionmaker convention
db = Session()
try:
device = upsert_device(
db,
ip_address="192.168.99.10",
mac_address="aa:bb:cc:dd:ee:01",
hostname="newhost",
)
db.commit()
assert device.ip_address == "192.168.99.10"
assert device.hostname == "newhost"
finally:
db.query(Device).filter(Device.mac_address == "aa:bb:cc:dd:ee:01").delete()
db.commit()
db.close()


def test_upsert_device_mac_first_updates_ip(db_engine):
"""upsert_device uses MAC as primary key: if MAC seen at a new IP, updates IP."""
from app.db import upsert_device
from app.models.device import Device
from sqlalchemy.orm import sessionmaker

Session = sessionmaker(bind=db_engine) # noqa: N806 — sessionmaker convention
db = Session()
try:
# Create device at original IP
d = upsert_device(
db,
ip_address="192.168.99.20",
mac_address="aa:bb:cc:dd:ee:02",
)
d.label = "my-server"
d.trusted = True
db.commit()
original_id = d.id

# Simulate DHCP lease change: same MAC, new IP
d2 = upsert_device(
db,
ip_address="192.168.99.21",
mac_address="aa:bb:cc:dd:ee:02",
)
db.commit()

assert d2.id == original_id, "Should be the same DB row"
assert d2.ip_address == "192.168.99.21", "IP should be updated"
assert d2.label == "my-server", "User label must be preserved"
assert d2.trusted is True, "Trusted flag must be preserved"
finally:
db.query(Device).filter(Device.mac_address == "aa:bb:cc:dd:ee:02").delete()
db.commit()
db.close()


def test_upsert_device_no_mac_falls_back_to_ip(db_engine):
"""upsert_device falls back to IP lookup when MAC is not provided."""
from app.db import upsert_device
from app.models.device import Device
from sqlalchemy.orm import sessionmaker

Session = sessionmaker(bind=db_engine) # noqa: N806 — sessionmaker convention
db = Session()
try:
d1 = upsert_device(db, ip_address="192.168.99.30", hostname="alpha")
db.commit()

d2 = upsert_device(db, ip_address="192.168.99.30", hostname="alpha-updated")
db.commit()

assert d1.id == d2.id
assert d2.hostname == "alpha-updated"
finally:
db.query(Device).filter(Device.ip_address == "192.168.99.30").delete()
db.commit()
db.close()
10 changes: 8 additions & 2 deletions frontend/src/pages/DeviceDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,14 @@ export function DeviceDetailPage() {
</span>
</div>
<PageHeader
title={device.ip_address}
subtitle={displayLabel ?? device.hostname ?? undefined}
title={displayLabel ?? device.hostname ?? device.ip_address}
subtitle={
displayLabel != null
? (device.hostname ?? device.ip_address)
: device.hostname != null
? device.ip_address
: undefined
}
action={
<div className="flex items-center gap-3">
{device.trusted && (
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/pages/RisksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ function RiskModal({
className="text-[var(--color-accent-positive)] hover:underline"
onClick={onClose}
>
{risk.hostname || risk.ip_address}
{risk.label ?? risk.hostname ?? risk.ip_address}
</Link>
</dd>
</div>
Expand Down Expand Up @@ -381,8 +381,8 @@ export function RisksPage() {
<option value="">All devices</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.ip_address}
{d.hostname ? ` (${d.hostname})` : ""}
{d.label ?? d.ip_address}
{!d.label && d.hostname ? ` (${d.hostname})` : ""}
</option>
))}
</select>
Expand Down Expand Up @@ -428,7 +428,8 @@ export function RisksPage() {
<Card padding="none">
<div className="divide-y divide-[var(--color-border)]">
{risks.map((risk) => {
const deviceLabel = risk.hostname || risk.ip_address;
const deviceLabel =
risk.label ?? risk.hostname ?? risk.ip_address;
return (
<button
key={risk.id}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface Risk {
device_id: number;
ip_address: string;
hostname: string | null;
label: string | null;
severity: Severity;
display_severity: Severity;
check_id: string;
Expand Down
Loading