From 5a420bd37baa315ad22c5f60d674f86a03e9b9cb Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Wed, 29 Apr 2026 11:45:49 +0200 Subject: [PATCH] add get public and storage feature --- alembic_app/env.py | 1 + .../versions/20260429_05_public_ip_monitor.py | 55 ++++++ app/api/routes/public_ip.py | 25 +++ app/main.py | 25 +++ app/models/__init__.py | 10 +- app/models/public_ip.py | 30 +++ app/schemas/public_ip.py | 13 ++ app/services/public_ip.py | 139 ++++++++++++++ dev-requirements.txt | 20 +- requirements.in | 2 + requirements.txt | 31 +++- scripts/app_db_adopt.py | 2 +- tests/test_public_ip.py | 174 ++++++++++++++++++ 13 files changed, 511 insertions(+), 16 deletions(-) create mode 100644 alembic_app/versions/20260429_05_public_ip_monitor.py create mode 100644 app/api/routes/public_ip.py create mode 100644 app/models/public_ip.py create mode 100644 app/schemas/public_ip.py create mode 100644 app/services/public_ip.py create mode 100644 tests/test_public_ip.py diff --git a/alembic_app/env.py b/alembic_app/env.py index c20c54e..00d6bea 100644 --- a/alembic_app/env.py +++ b/alembic_app/env.py @@ -7,6 +7,7 @@ from app.auth_db import AuthBase from app.config import get_settings from app.models.config import AppConfigEntry # noqa: F401 from app.models.auth import AuthSession, AuthUser # noqa: F401 +from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401 config = context.config diff --git a/alembic_app/versions/20260429_05_public_ip_monitor.py b/alembic_app/versions/20260429_05_public_ip_monitor.py new file mode 100644 index 0000000..48978e4 --- /dev/null +++ b/alembic_app/versions/20260429_05_public_ip_monitor.py @@ -0,0 +1,55 @@ +"""public ip monitor tables + +Revision ID: 20260429_05_public_ip_monitor +Revises: 20260420_04_app_config_table +Create Date: 2026-04-29 00:00:01.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260429_05_public_ip_monitor" +down_revision: Union[str, None] = "20260420_04_app_config_table" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "public_ip_history", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("ipv4", sa.String(length=45), nullable=False), + sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("change_type", sa.String(length=32), nullable=False), + sa.Column("provider", sa.String(length=64), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_public_ip_history_observed_at", + "public_ip_history", + ["observed_at"], + unique=False, + ) + + op.create_table( + "public_ip_state", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("current_ipv4", sa.String(length=45), nullable=False), + sa.Column("previous_ipv4", sa.String(length=45), nullable=True), + sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_changed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_check_status", sa.String(length=32), nullable=False), + sa.Column("last_check_error", sa.String(length=255), nullable=True), + sa.Column("last_provider", sa.String(length=64), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("public_ip_state") + op.drop_index("ix_public_ip_history_observed_at", table_name="public_ip_history") + op.drop_table("public_ip_history") \ No newline at end of file diff --git a/app/api/routes/public_ip.py b/app/api/routes/public_ip.py new file mode 100644 index 0000000..ee9d722 --- /dev/null +++ b/app/api/routes/public_ip.py @@ -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, + ) \ No newline at end of file diff --git a/app/main.py b/app/main.py index 7dddb19..4a8ee2a 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index d8889cc..24d4862 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", +] diff --git a/app/models/public_ip.py b/app/models/public_ip.py new file mode 100644 index 0000000..a88fd4e --- /dev/null +++ b/app/models/public_ip.py @@ -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) \ No newline at end of file diff --git a/app/schemas/public_ip.py b/app/schemas/public_ip.py new file mode 100644 index 0000000..497b32c --- /dev/null +++ b/app/schemas/public_ip.py @@ -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 diff --git a/app/services/public_ip.py b/app/services/public_ip.py new file mode 100644 index 0000000..a984f25 --- /dev/null +++ b/app/services/public_ip.py @@ -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) diff --git a/dev-requirements.txt b/dev-requirements.txt index 26de089..1d79e6a 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -8,15 +8,17 @@ alembic==1.18.4 # via -r requirements.in annotated-types==0.7.0 # via pydantic -argon2-cffi==25.1.0 - # via -r requirements.in -argon2-cffi-bindings==25.1.0 - # via argon2-cffi anyio==4.13.0 # via # httpx # starlette # watchfiles +apscheduler==3.11.2 + # via -r requirements.in +argon2-cffi==25.1.0 + # via -r requirements.in +argon2-cffi-bindings==25.1.0 + # via argon2-cffi build==1.4.3 # via pip-tools certifi==2026.2.25 @@ -42,7 +44,9 @@ httpcore==1.0.9 httptools==0.7.1 # via uvicorn httpx==0.28.1 - # via -r dev-requirements.in + # via + # -r dev-requirements.in + # -r requirements.in idna==3.11 # via # anyio @@ -66,6 +70,8 @@ pip-tools==7.5.3 # via -r dev-requirements.in pluggy==1.6.0 # via pytest +pycparser==2.23 + # via cffi pydantic==2.13.2 # via # fastapi @@ -88,8 +94,6 @@ python-dotenv==1.2.2 # uvicorn python-multipart==0.0.26 # via -r requirements.in -pycparser==2.23 - # via cffi pyyaml==6.0.3 # via # -r requirements.in @@ -112,6 +116,8 @@ typing-inspection==0.4.2 # via # pydantic # pydantic-settings +tzlocal==5.3.1 + # via apscheduler uvicorn[standard]==0.44.0 # via -r requirements.in uvloop==0.22.1 diff --git a/requirements.in b/requirements.in index 3211579..61b29b2 100644 --- a/requirements.in +++ b/requirements.in @@ -1,6 +1,8 @@ alembic>=1.14,<2.0 +apscheduler>=3.10,<4.0 argon2-cffi>=25.1,<26.0 fastapi>=0.115,<0.116 +httpx>=0.28,<1.0 jinja2>=3.1,<4.0 pydantic-settings>=2.6,<3.0 python-multipart>=0.0.12,<1.0 diff --git a/requirements.txt b/requirements.txt index be07d71..c76c026 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,14 +8,21 @@ alembic==1.18.4 # via -r requirements.in annotated-types==0.7.0 # via pydantic +anyio==4.13.0 + # via + # httpx + # starlette + # watchfiles +apscheduler==3.11.2 + # via -r requirements.in argon2-cffi==25.1.0 # via -r requirements.in argon2-cffi-bindings==25.1.0 # via argon2-cffi -anyio==4.13.0 +certifi==2026.4.22 # via - # starlette - # watchfiles + # httpcore + # httpx cffi==2.0.0 # via argon2-cffi-bindings click==8.3.2 @@ -25,11 +32,19 @@ fastapi==0.115.14 greenlet==3.4.0 # via sqlalchemy h11==0.16.0 - # via uvicorn + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx httptools==0.7.1 # via uvicorn +httpx==0.28.1 + # via -r requirements.in idna==3.11 - # via anyio + # via + # anyio + # httpx jinja2==3.1.6 # via -r requirements.in mako==1.3.11 @@ -38,6 +53,8 @@ markupsafe==3.0.3 # via # jinja2 # mako +pycparser==2.23 + # via cffi pydantic==2.13.2 # via # fastapi @@ -52,8 +69,6 @@ python-dotenv==1.2.2 # uvicorn python-multipart==0.0.26 # via -r requirements.in -pycparser==2.23 - # via cffi pyyaml==6.0.3 # via # -r requirements.in @@ -76,6 +91,8 @@ typing-inspection==0.4.2 # via # pydantic # pydantic-settings +tzlocal==5.3.1 + # via apscheduler uvicorn[standard]==0.44.0 # via -r requirements.in uvloop==0.22.1 diff --git a/scripts/app_db_adopt.py b/scripts/app_db_adopt.py index 0e88286..4a1b30d 100644 --- a/scripts/app_db_adopt.py +++ b/scripts/app_db_adopt.py @@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path: from app.config import get_settings -APP_BASELINE_REVISION = "20260420_04_app_config_table" +APP_BASELINE_REVISION = "20260429_05_public_ip_monitor" class AppDatabaseAdoptionError(RuntimeError): diff --git a/tests/test_public_ip.py b/tests/test_public_ip.py new file mode 100644 index 0000000..23b39e1 --- /dev/null +++ b/tests/test_public_ip.py @@ -0,0 +1,174 @@ +from datetime import UTC, datetime +import re +import sqlite3 + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.services.public_ip import PublicIPCheckResult, check_public_ipv4 + + +def _make_session(database_url: str) -> Session: + engine = create_engine(database_url, connect_args={"check_same_thread": False}) + session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session) + return session_local() + + +def _extract_csrf_token(html: str) -> str: + match = re.search(r'name="csrf_token" value="([^"]+)"', html) + assert match is not None + return match.group(1) + + +def _login(client: TestClient) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + response = client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + assert response.status_code == 303 + + +def test_public_ip_first_seen_persists_state_and_history(auth_database) -> None: + session = _make_session(auth_database["app_url"]) + try: + result = check_public_ipv4(session, fetch_public_ipv4=lambda: "203.0.113.10") + finally: + session.close() + + assert result.status == "first_seen" + assert result.changed is False + + conn = sqlite3.connect(auth_database["app_path"]) + try: + state = conn.execute( + "SELECT current_ipv4, previous_ipv4, last_check_status, last_check_error, last_provider FROM public_ip_state" + ).fetchone() + history = conn.execute( + "SELECT ipv4, change_type, provider FROM public_ip_history ORDER BY id" + ).fetchall() + finally: + conn.close() + + assert state == ("203.0.113.10", None, "first_seen", None, "ipify") + assert history == [("203.0.113.10", "first_seen", "ipify")] + + +def test_public_ip_unchanged_updates_state_without_adding_history(auth_database) -> None: + session = _make_session(auth_database["app_url"]) + try: + first_result = check_public_ipv4(session, fetch_public_ipv4=lambda: "203.0.113.10") + unchanged_result = check_public_ipv4(session, fetch_public_ipv4=lambda: "203.0.113.10") + finally: + session.close() + + assert first_result.status == "first_seen" + assert unchanged_result.status == "unchanged" + assert unchanged_result.changed is False + + conn = sqlite3.connect(auth_database["app_path"]) + try: + state = conn.execute( + "SELECT current_ipv4, previous_ipv4, last_check_status FROM public_ip_state" + ).fetchone() + history_count = conn.execute("SELECT COUNT(*) FROM public_ip_history").fetchone()[0] + finally: + conn.close() + + assert state == ("203.0.113.10", None, "unchanged") + assert history_count == 1 + + +def test_public_ip_changed_updates_state_and_adds_history(auth_database) -> None: + session = _make_session(auth_database["app_url"]) + try: + check_public_ipv4(session, fetch_public_ipv4=lambda: "203.0.113.10") + result = check_public_ipv4(session, fetch_public_ipv4=lambda: "198.51.100.25") + finally: + session.close() + + assert result.status == "changed" + assert result.changed is True + + conn = sqlite3.connect(auth_database["app_path"]) + try: + state = conn.execute( + "SELECT current_ipv4, previous_ipv4, last_check_status, last_changed_at FROM public_ip_state" + ).fetchone() + history = conn.execute( + "SELECT ipv4, change_type FROM public_ip_history ORDER BY id" + ).fetchall() + finally: + conn.close() + + assert state[0:3] == ("198.51.100.25", "203.0.113.10", "changed") + assert state[3] is not None + assert history == [("203.0.113.10", "first_seen"), ("198.51.100.25", "changed")] + + +def test_public_ip_error_keeps_existing_ip_and_does_not_add_history(auth_database) -> None: + session = _make_session(auth_database["app_url"]) + try: + check_public_ipv4(session, fetch_public_ipv4=lambda: "203.0.113.10") + result = check_public_ipv4(session, fetch_public_ipv4=lambda: "not-an-ip") + finally: + session.close() + + assert result.status == "error" + assert result.changed is False + + conn = sqlite3.connect(auth_database["app_path"]) + try: + state = conn.execute( + "SELECT current_ipv4, previous_ipv4, last_check_status, last_check_error FROM public_ip_state" + ).fetchone() + history_count = conn.execute("SELECT COUNT(*) FROM public_ip_history").fetchone()[0] + finally: + conn.close() + + assert state[0:3] == ("203.0.113.10", None, "error") + assert state[3] is not None + assert history_count == 1 + + +def test_public_ip_check_endpoint_requires_authentication(client: TestClient) -> None: + response = client.get("/public-ip/check") + + assert response.status_code == 401 + assert response.json() == {"detail": "authentication required"} + + +def test_public_ip_check_endpoint_hides_ip_values(client: TestClient, monkeypatch) -> None: + from app.api.routes import public_ip as public_ip_route + + fixed_checked_at = datetime(2026, 4, 29, 12, 0, tzinfo=UTC) + + monkeypatch.setattr( + public_ip_route, + "check_public_ipv4", + lambda session: PublicIPCheckResult( + status="changed", + checked_at=fixed_checked_at, + changed=True, + ), + ) + + _login(client) + response = client.get("/public-ip/check") + + assert response.status_code == 200 + assert response.json() == { + "status": "changed", + "checked_at": "2026-04-29T12:00:00Z", + "changed": True, + } + assert "current_ipv4" not in response.text + assert "previous_ipv4" not in response.text + assert "203.0.113.10" not in response.text