add get public and storage feature
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_auth_db, get_current_auth_session
|
||||
from app.schemas.public_ip import PublicIPCheckResponse
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.public_ip import check_public_ipv4
|
||||
|
||||
router = APIRouter(tags=["public-ip"])
|
||||
|
||||
|
||||
@router.get("/public-ip/check", response_model=PublicIPCheckResponse)
|
||||
def run_public_ip_check(
|
||||
session: Session = Depends(get_auth_db),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> PublicIPCheckResponse:
|
||||
if current_auth is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentication required")
|
||||
|
||||
result = check_public_ipv4(session)
|
||||
return PublicIPCheckResponse(
|
||||
status=result.status,
|
||||
checked_at=result.checked_at,
|
||||
changed=result.changed,
|
||||
)
|
||||
+25
@@ -3,6 +3,8 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import models # noqa: F401
|
||||
@@ -12,15 +14,26 @@ import app.auth_db as auth_db
|
||||
from app.api.routes.homeassistant import router as homeassistant_router
|
||||
from app.api.routes.location import router as location_router
|
||||
from app.api.routes.poo import router as poo_router
|
||||
from app.api.routes.public_ip import router as public_ip_router
|
||||
from app.api.routes.ticktick import router as ticktick_router
|
||||
from app.config import get_settings
|
||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
||||
from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
|
||||
from app.services.public_ip import check_public_ipv4
|
||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
||||
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
|
||||
|
||||
|
||||
def _run_scheduled_public_ip_check() -> None:
|
||||
session_local = auth_db.get_auth_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
check_public_ipv4(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def ensure_auth_db_ready() -> None:
|
||||
session_local = auth_db.get_auth_session_local()
|
||||
session: Session = session_local()
|
||||
@@ -72,7 +85,18 @@ async def lifespan(_: FastAPI):
|
||||
ensure_auth_db_ready()
|
||||
ensure_location_db_ready()
|
||||
ensure_poo_db_ready()
|
||||
scheduler = BackgroundScheduler(timezone="UTC")
|
||||
scheduler.add_job(
|
||||
_run_scheduled_public_ip_check,
|
||||
trigger=IntervalTrigger(hours=4),
|
||||
id="public-ip-check",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.start()
|
||||
yield
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -97,6 +121,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(homeassistant_router)
|
||||
app.include_router(location_router)
|
||||
app.include_router(poo_router)
|
||||
app.include_router(public_ip_router)
|
||||
app.include_router(ticktick_router)
|
||||
return app
|
||||
|
||||
|
||||
@@ -3,5 +3,13 @@
|
||||
from app.models.auth import AuthSession, AuthUser
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.models.location import Location
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState
|
||||
|
||||
__all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"]
|
||||
__all__ = [
|
||||
"AppConfigEntry",
|
||||
"AuthSession",
|
||||
"AuthUser",
|
||||
"Location",
|
||||
"PublicIPHistory",
|
||||
"PublicIPState",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
|
||||
|
||||
class PublicIPState(AuthBase):
|
||||
__tablename__ = "public_ip_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
current_ipv4: Mapped[str] = mapped_column(String(45), nullable=False)
|
||||
previous_ipv4: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_check_status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
last_check_error: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
last_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class PublicIPHistory(AuthBase):
|
||||
__tablename__ = "public_ip_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ipv4: Mapped[str] = mapped_column(String(45), nullable=False)
|
||||
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
change_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
@@ -0,0 +1,13 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
PublicIPCheckStatus = Literal["first_seen", "unchanged", "changed", "error"]
|
||||
|
||||
|
||||
class PublicIPCheckResponse(BaseModel):
|
||||
status: PublicIPCheckStatus
|
||||
checked_at: datetime
|
||||
changed: bool
|
||||
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user