140 lines
4.2 KiB
Python
140 lines
4.2 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import ipaddress
|
||
|
|
import logging
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from typing import Callable, Literal
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
|
||
|
|
from app.models.public_ip import PublicIPHistory, PublicIPState
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
PUBLIC_IP_PROVIDER_NAME = "ipify"
|
||
|
|
PUBLIC_IP_PROVIDER_URL = "https://api.ipify.org"
|
||
|
|
PUBLIC_IP_PROVIDER_TIMEOUT_SECONDS = 5.0
|
||
|
|
|
||
|
|
PublicIPResultStatus = Literal["first_seen", "unchanged", "changed", "error"]
|
||
|
|
PublicIPv4Fetcher = Callable[[], str]
|
||
|
|
|
||
|
|
|
||
|
|
class PublicIPCheckError(RuntimeError):
|
||
|
|
"""Raised when the public IPv4 provider cannot return a valid IPv4."""
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(slots=True)
|
||
|
|
class PublicIPCheckResult:
|
||
|
|
status: PublicIPResultStatus
|
||
|
|
checked_at: datetime
|
||
|
|
changed: bool
|
||
|
|
|
||
|
|
|
||
|
|
def check_public_ipv4(
|
||
|
|
session: Session,
|
||
|
|
*,
|
||
|
|
fetch_public_ipv4: PublicIPv4Fetcher | None = None,
|
||
|
|
provider_name: str = PUBLIC_IP_PROVIDER_NAME,
|
||
|
|
) -> PublicIPCheckResult:
|
||
|
|
checked_at = _utc_now()
|
||
|
|
state = session.scalar(select(PublicIPState).where(PublicIPState.id == 1).limit(1))
|
||
|
|
|
||
|
|
try:
|
||
|
|
raw_ipv4 = (fetch_public_ipv4 or fetch_public_ipv4_from_provider)()
|
||
|
|
current_ipv4 = _validate_ipv4(raw_ipv4)
|
||
|
|
except PublicIPCheckError as exc:
|
||
|
|
logger.warning("Public IPv4 check failed: %s", exc)
|
||
|
|
if state is not None:
|
||
|
|
state.last_checked_at = checked_at
|
||
|
|
state.last_check_status = "error"
|
||
|
|
state.last_check_error = str(exc)
|
||
|
|
state.last_provider = provider_name
|
||
|
|
session.commit()
|
||
|
|
return PublicIPCheckResult(status="error", checked_at=checked_at, changed=False)
|
||
|
|
|
||
|
|
if state is None:
|
||
|
|
state = PublicIPState(
|
||
|
|
id=1,
|
||
|
|
current_ipv4=current_ipv4,
|
||
|
|
previous_ipv4=None,
|
||
|
|
first_seen_at=checked_at,
|
||
|
|
last_checked_at=checked_at,
|
||
|
|
last_changed_at=None,
|
||
|
|
last_check_status="first_seen",
|
||
|
|
last_check_error=None,
|
||
|
|
last_provider=provider_name,
|
||
|
|
)
|
||
|
|
session.add(state)
|
||
|
|
session.add(
|
||
|
|
PublicIPHistory(
|
||
|
|
ipv4=current_ipv4,
|
||
|
|
observed_at=checked_at,
|
||
|
|
change_type="first_seen",
|
||
|
|
provider=provider_name,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
session.commit()
|
||
|
|
return PublicIPCheckResult(status="first_seen", checked_at=checked_at, changed=False)
|
||
|
|
|
||
|
|
if state.current_ipv4 == current_ipv4:
|
||
|
|
state.last_checked_at = checked_at
|
||
|
|
state.last_check_status = "unchanged"
|
||
|
|
state.last_check_error = None
|
||
|
|
state.last_provider = provider_name
|
||
|
|
session.commit()
|
||
|
|
return PublicIPCheckResult(status="unchanged", checked_at=checked_at, changed=False)
|
||
|
|
|
||
|
|
state.previous_ipv4 = state.current_ipv4
|
||
|
|
state.current_ipv4 = current_ipv4
|
||
|
|
state.last_checked_at = checked_at
|
||
|
|
state.last_changed_at = checked_at
|
||
|
|
state.last_check_status = "changed"
|
||
|
|
state.last_check_error = None
|
||
|
|
state.last_provider = provider_name
|
||
|
|
session.add(
|
||
|
|
PublicIPHistory(
|
||
|
|
ipv4=current_ipv4,
|
||
|
|
observed_at=checked_at,
|
||
|
|
change_type="changed",
|
||
|
|
provider=provider_name,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
session.commit()
|
||
|
|
return PublicIPCheckResult(status="changed", checked_at=checked_at, changed=True)
|
||
|
|
|
||
|
|
|
||
|
|
def fetch_public_ipv4_from_provider() -> str:
|
||
|
|
try:
|
||
|
|
response = httpx.get(
|
||
|
|
PUBLIC_IP_PROVIDER_URL,
|
||
|
|
params={"format": "text"},
|
||
|
|
timeout=PUBLIC_IP_PROVIDER_TIMEOUT_SECONDS,
|
||
|
|
)
|
||
|
|
response.raise_for_status()
|
||
|
|
except httpx.HTTPError as exc:
|
||
|
|
raise PublicIPCheckError(f"provider request failed: {exc}") from exc
|
||
|
|
|
||
|
|
return response.text.strip()
|
||
|
|
|
||
|
|
|
||
|
|
def _validate_ipv4(raw_value: str) -> str:
|
||
|
|
if not raw_value:
|
||
|
|
raise PublicIPCheckError("provider returned an empty response")
|
||
|
|
|
||
|
|
try:
|
||
|
|
parsed = ipaddress.ip_address(raw_value)
|
||
|
|
except ValueError as exc:
|
||
|
|
raise PublicIPCheckError("provider returned an invalid IPv4 value") from exc
|
||
|
|
|
||
|
|
if parsed.version != 4:
|
||
|
|
raise PublicIPCheckError("provider returned a non-IPv4 value")
|
||
|
|
|
||
|
|
return str(parsed)
|
||
|
|
|
||
|
|
|
||
|
|
def _utc_now() -> datetime:
|
||
|
|
return datetime.now(UTC)
|