add get public and storage feature
This commit is contained in:
@@ -7,6 +7,7 @@ from app.auth_db import AuthBase
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models.config import AppConfigEntry # noqa: F401
|
from app.models.config import AppConfigEntry # noqa: F401
|
||||||
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
||||||
|
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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 import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import models # noqa: F401
|
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.homeassistant import router as homeassistant_router
|
||||||
from app.api.routes.location import router as location_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.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.api.routes.ticktick import router as ticktick_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
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.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.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||||
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
||||||
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_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:
|
def ensure_auth_db_ready() -> None:
|
||||||
session_local = auth_db.get_auth_session_local()
|
session_local = auth_db.get_auth_session_local()
|
||||||
session: Session = session_local()
|
session: Session = session_local()
|
||||||
@@ -72,7 +85,18 @@ async def lifespan(_: FastAPI):
|
|||||||
ensure_auth_db_ready()
|
ensure_auth_db_ready()
|
||||||
ensure_location_db_ready()
|
ensure_location_db_ready()
|
||||||
ensure_poo_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
|
yield
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -97,6 +121,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(homeassistant_router)
|
app.include_router(homeassistant_router)
|
||||||
app.include_router(location_router)
|
app.include_router(location_router)
|
||||||
app.include_router(poo_router)
|
app.include_router(poo_router)
|
||||||
|
app.include_router(public_ip_router)
|
||||||
app.include_router(ticktick_router)
|
app.include_router(ticktick_router)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,13 @@
|
|||||||
from app.models.auth import AuthSession, AuthUser
|
from app.models.auth import AuthSession, AuthUser
|
||||||
from app.models.config import AppConfigEntry
|
from app.models.config import AppConfigEntry
|
||||||
from app.models.location import Location
|
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)
|
||||||
+13
-7
@@ -8,15 +8,17 @@ alembic==1.18.4
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
annotated-types==0.7.0
|
annotated-types==0.7.0
|
||||||
# via pydantic
|
# via pydantic
|
||||||
argon2-cffi==25.1.0
|
|
||||||
# via -r requirements.in
|
|
||||||
argon2-cffi-bindings==25.1.0
|
|
||||||
# via argon2-cffi
|
|
||||||
anyio==4.13.0
|
anyio==4.13.0
|
||||||
# via
|
# via
|
||||||
# httpx
|
# httpx
|
||||||
# starlette
|
# starlette
|
||||||
# watchfiles
|
# 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
|
build==1.4.3
|
||||||
# via pip-tools
|
# via pip-tools
|
||||||
certifi==2026.2.25
|
certifi==2026.2.25
|
||||||
@@ -42,7 +44,9 @@ httpcore==1.0.9
|
|||||||
httptools==0.7.1
|
httptools==0.7.1
|
||||||
# via uvicorn
|
# via uvicorn
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
# via -r dev-requirements.in
|
# via
|
||||||
|
# -r dev-requirements.in
|
||||||
|
# -r requirements.in
|
||||||
idna==3.11
|
idna==3.11
|
||||||
# via
|
# via
|
||||||
# anyio
|
# anyio
|
||||||
@@ -66,6 +70,8 @@ pip-tools==7.5.3
|
|||||||
# via -r dev-requirements.in
|
# via -r dev-requirements.in
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
# via pytest
|
# via pytest
|
||||||
|
pycparser==2.23
|
||||||
|
# via cffi
|
||||||
pydantic==2.13.2
|
pydantic==2.13.2
|
||||||
# via
|
# via
|
||||||
# fastapi
|
# fastapi
|
||||||
@@ -88,8 +94,6 @@ python-dotenv==1.2.2
|
|||||||
# uvicorn
|
# uvicorn
|
||||||
python-multipart==0.0.26
|
python-multipart==0.0.26
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
pycparser==2.23
|
|
||||||
# via cffi
|
|
||||||
pyyaml==6.0.3
|
pyyaml==6.0.3
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
@@ -112,6 +116,8 @@ typing-inspection==0.4.2
|
|||||||
# via
|
# via
|
||||||
# pydantic
|
# pydantic
|
||||||
# pydantic-settings
|
# pydantic-settings
|
||||||
|
tzlocal==5.3.1
|
||||||
|
# via apscheduler
|
||||||
uvicorn[standard]==0.44.0
|
uvicorn[standard]==0.44.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
uvloop==0.22.1
|
uvloop==0.22.1
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
alembic>=1.14,<2.0
|
alembic>=1.14,<2.0
|
||||||
|
apscheduler>=3.10,<4.0
|
||||||
argon2-cffi>=25.1,<26.0
|
argon2-cffi>=25.1,<26.0
|
||||||
fastapi>=0.115,<0.116
|
fastapi>=0.115,<0.116
|
||||||
|
httpx>=0.28,<1.0
|
||||||
jinja2>=3.1,<4.0
|
jinja2>=3.1,<4.0
|
||||||
pydantic-settings>=2.6,<3.0
|
pydantic-settings>=2.6,<3.0
|
||||||
python-multipart>=0.0.12,<1.0
|
python-multipart>=0.0.12,<1.0
|
||||||
|
|||||||
+24
-7
@@ -8,14 +8,21 @@ alembic==1.18.4
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
annotated-types==0.7.0
|
annotated-types==0.7.0
|
||||||
# via pydantic
|
# via pydantic
|
||||||
|
anyio==4.13.0
|
||||||
|
# via
|
||||||
|
# httpx
|
||||||
|
# starlette
|
||||||
|
# watchfiles
|
||||||
|
apscheduler==3.11.2
|
||||||
|
# via -r requirements.in
|
||||||
argon2-cffi==25.1.0
|
argon2-cffi==25.1.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
argon2-cffi-bindings==25.1.0
|
argon2-cffi-bindings==25.1.0
|
||||||
# via argon2-cffi
|
# via argon2-cffi
|
||||||
anyio==4.13.0
|
certifi==2026.4.22
|
||||||
# via
|
# via
|
||||||
# starlette
|
# httpcore
|
||||||
# watchfiles
|
# httpx
|
||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
# via argon2-cffi-bindings
|
# via argon2-cffi-bindings
|
||||||
click==8.3.2
|
click==8.3.2
|
||||||
@@ -25,11 +32,19 @@ fastapi==0.115.14
|
|||||||
greenlet==3.4.0
|
greenlet==3.4.0
|
||||||
# via sqlalchemy
|
# via sqlalchemy
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
# via uvicorn
|
# via
|
||||||
|
# httpcore
|
||||||
|
# uvicorn
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via httpx
|
||||||
httptools==0.7.1
|
httptools==0.7.1
|
||||||
# via uvicorn
|
# via uvicorn
|
||||||
|
httpx==0.28.1
|
||||||
|
# via -r requirements.in
|
||||||
idna==3.11
|
idna==3.11
|
||||||
# via anyio
|
# via
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
jinja2==3.1.6
|
jinja2==3.1.6
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
mako==1.3.11
|
mako==1.3.11
|
||||||
@@ -38,6 +53,8 @@ markupsafe==3.0.3
|
|||||||
# via
|
# via
|
||||||
# jinja2
|
# jinja2
|
||||||
# mako
|
# mako
|
||||||
|
pycparser==2.23
|
||||||
|
# via cffi
|
||||||
pydantic==2.13.2
|
pydantic==2.13.2
|
||||||
# via
|
# via
|
||||||
# fastapi
|
# fastapi
|
||||||
@@ -52,8 +69,6 @@ python-dotenv==1.2.2
|
|||||||
# uvicorn
|
# uvicorn
|
||||||
python-multipart==0.0.26
|
python-multipart==0.0.26
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
pycparser==2.23
|
|
||||||
# via cffi
|
|
||||||
pyyaml==6.0.3
|
pyyaml==6.0.3
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
@@ -76,6 +91,8 @@ typing-inspection==0.4.2
|
|||||||
# via
|
# via
|
||||||
# pydantic
|
# pydantic
|
||||||
# pydantic-settings
|
# pydantic-settings
|
||||||
|
tzlocal==5.3.1
|
||||||
|
# via apscheduler
|
||||||
uvicorn[standard]==0.44.0
|
uvicorn[standard]==0.44.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
uvloop==0.22.1
|
uvloop==0.22.1
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from app.config import get_settings
|
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):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user