extensive changes

This commit is contained in:
2026-03-27 17:12:20 -04:00
parent c5a563f047
commit 1eef99e3b4
29 changed files with 3535 additions and 2499 deletions

0
src/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,44 @@
from typing import List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from .db_models import Base, Location, Route, Waypoint
DATABASE_URL = "sqlite+aiosqlite:///.locations.db"
engine = create_async_engine(DATABASE_URL, echo=True)
async_session_local = async_sessionmaker(engine, expire_on_commit=False)
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def create_location(name: str, address: str, latitude: float, longitude: float, is_favorite: bool = False):
async with async_session_local() as session:
new_location = Location(name=name, address=address, latitude=latitude, longitude=longitude, is_favorite=is_favorite)
session.add(new_location)
await session.commit()
return new_location
async def get_locations():
async with async_session_local() as session:
result = await session.execute(select(Location))
return result.scalars().all()
async def create_route(name: str, origin_id: int, destination_id: int, waypoints_data: List[dict]):
async with async_session_local() as session:
new_route = Route(name=name, origin_id=origin_id, destination_id=destination_id)
for wp in waypoints_data:
new_route.waypoints.append(Waypoint(order=wp['order'], description=wp['description']))
session.add(new_route)
await session.commit()
return new_route
async def get_routes():
async with async_session() as session:
# Use joinedload for efficient loading of relationships
from sqlalchemy.orm import joinedload
result = await session.execute(
select(Route).options(joinedload(Route.waypoints))
)
return result.scalars().unique().all()

View File

@@ -0,0 +1,42 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy.ext.asyncio import AsyncAttrs
from typing import List
class Base(AsyncAttrs, DeclarativeBase):
pass
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
address: Mapped[str] = mapped_column(String(255))
latitude: Mapped[float]
longitude: Mapped[float]
is_favorite: Mapped[bool] = mapped_column(default=False)
routes: Mapped[List["Route"]] = relationship(back_populates="destination", cascade="all, delete-orphan")
class Route(Base):
__tablename__ = "routes"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
origin_id: Mapped[int] = mapped_column(ForeignKey("locations.id"))
destination_id: Mapped[int] = mapped_column(ForeignKey("locations.id"))
destination: Mapped["Location"] = relationship(back_populates="routes")
waypoints: Mapped[List["Waypoint"]] = relationship(back_populates="route", cascade="all, delete-orphan")
class Waypoint(Base):
__tablename__ = "waypoint"
id: Mapped[int] = mapped_column(primary_key=True)
order: Mapped[int]
description: Mapped[str]
route_id: Mapped[int] = mapped_column(ForeignKey("route.id"))
route: Mapped["Route"] = relationship(back_populates="waypoints")

View File

@@ -0,0 +1,334 @@
import asyncio
import logging
import os
import sys
from collections.abc import Callable
import click
from dotenv import load_dotenv
from pyicloud import PyiCloudService
from pyicloud.exceptions import (
PyiCloud2FARequiredException,
PyiCloud2SARequiredException,
PyiCloudAuthRequiredException,
PyiCloudFailedLoginException,
PyiCloudNoStoredPasswordAvailableException,
PyiCloudPasswordException,
)
from .models import iCloudReturnData
load_dotenv()
logger = logging.getLogger("ios-api")
AUTH_EXCEPTIONS = (
PyiCloudAuthRequiredException,
PyiCloudFailedLoginException,
PyiCloudPasswordException,
PyiCloud2FARequiredException,
PyiCloud2SARequiredException,
PyiCloudNoStoredPasswordAvailableException,
)
class FindMyMonitor:
def __init__(
self,
queue: asyncio.Queue,
token_file: str = "icloud_token.txt",
sio=None,
get_client_sids: Callable[[], list[str]] | None = None,
code_timeout_seconds: int = 180,
):
self.username = os.getenv("APPLE_ID")
self.password = os.getenv("APPLE_PW")
self.token_file = token_file
self.selected_device_id = os.getenv("SELECTED_DEVICE_ID")
self.selected_device_name = os.getenv("SELECTED_DEVICE_NAME")
self.selected_device = None
self.queue = queue
self.api = None
self.device = None
self.running = True
self.sio = sio
self.get_client_sids = get_client_sids
self.code_timeout_seconds = code_timeout_seconds
self.auth_init_timeout_seconds = int(
os.getenv("ICLOUD_AUTH_INIT_TIMEOUT_SECONDS", "0")
)
self._logged_candidates = False
self._no_location_streak = 0
self._auth_error_streak = 0
async def _request_code_from_vue(self, prompt: str) -> str | None:
if self.sio is None or self.get_client_sids is None:
logger.warning("2FA request skipped: Socket.IO context not configured")
return None
sids = self.get_client_sids()
if not sids:
logger.warning("2FA request skipped: no connected Socket.IO clients")
return None
payload = {"prompt": prompt, "digits": 6}
logger.info("Emitting icloud_2fa_request to %s connected client(s)", len(sids))
for sid in sids:
try:
# Ask one connected UI client for the code and wait for ACK response.
response = await self.sio.call(
"icloud_2fa_request",
payload,
to=sid,
namespace="/",
timeout=self.code_timeout_seconds,
)
except Exception:
logger.exception("Failed to retrieve 2FA code from sid=%s", sid)
continue
code = response.get("code") if isinstance(response, dict) else response
code_str = str(code).strip() if code is not None else ""
if len(code_str) == 6 and code_str.isdigit():
return code_str
logger.warning("Invalid 2FA code payload from sid=%s", sid)
return None
async def authenticate(self):
"""Authenticates with iCloud, handling 2FA and token storage."""
if not self.username:
logger.warning("APPLE_ID is not configured; skipping iCloud monitor authentication")
return False
has_token = os.path.exists(self.token_file)
if not has_token and not self.password:
logger.warning(
"No stored iCloud session and APPLE_PW is not configured; skipping iCloud monitor authentication"
)
return False
if os.path.exists(self.token_file):
print("Loading stored session...")
try:
init_task = asyncio.to_thread(
PyiCloudService, self.username, cookie_directory="./cookies"
)
if self.auth_init_timeout_seconds > 0:
self.api = await asyncio.wait_for(
init_task, timeout=self.auth_init_timeout_seconds
)
else:
self.api = await init_task
except Exception as e:
logger.exception("Failed to initialize iCloud session from cookies: %s", e)
return False
else:
print("No stored session. Authenticating...")
try:
init_task = asyncio.to_thread(
PyiCloudService,
self.username,
self.password,
cookie_directory="./cookies",
)
if self.auth_init_timeout_seconds > 0:
self.api = await asyncio.wait_for(
init_task, timeout=self.auth_init_timeout_seconds
)
else:
self.api = await init_task
except Exception as e:
logger.exception("Failed to initialize iCloud session with credentials: %s", e)
return False
if self.api.requires_2fa:
print("Two-factor authentication required.")
code = await self._request_code_from_vue("Enter the 6-digit Apple verification code")
if code is None:
if sys.stdin and sys.stdin.isatty():
code = str(
await asyncio.to_thread(
click.prompt,
"Please enter the 6-digit code sent to your trusted device",
type=int,
)
)
else:
logger.warning(
"2FA required but no interactive terminal or Vue responder is available; deferring authentication"
)
return False
# Verify the code
result = await asyncio.to_thread(self.api.validate_2fa_code, code)
print(f"2FA validation result: {result}")
if not result:
print("Failed to verify 2FA code")
return False
# Trust the session
await asyncio.to_thread(self.api.trust_session)
if self.api.requires_2sa:
import textwrap
print(textwrap.dedent("""
Two-step authentication required.
Please select a device to receive a SMS verification code:
"""))
# List available devices for 2SA
for i, device in enumerate(self.api.trusted_devices):
print(
f" {i + 1}: {device.get('deviceName', 'Unknown device')} ({device.get('phoneNumber', 'Unknown number')})")
# Prompt the user for their choice
if not (sys.stdin and sys.stdin.isatty()):
logger.warning(
"2SA required but no interactive terminal is available; deferring authentication"
)
return False
device_index = await asyncio.to_thread(click.prompt, "Please select a device number", type=int) - 1
device = self.api.trusted_devices[device_index]
if not await asyncio.to_thread(self.api.send_verification_code, device):
print("Failed to send verification code")
return False
# Prompt the user to enter the verification code they received
code = await asyncio.to_thread(click.prompt, "Please enter verification code", type=int)
if not await asyncio.to_thread(self.api.validate_verification_code, device, code):
print("Failed to verify verification code")
return False
print("Successfully authenticated.")
return True
async def get_location(self):
"""Fetches the latest latitude and longitude."""
if not self.api:
await self.authenticate()
# pyicloud 2.4.1 refresh lives on the FindMy manager, not PyiCloudService.
devices_manager = await asyncio.to_thread(lambda: self.api.devices)
await asyncio.to_thread(devices_manager.refresh, True)
# One-time diagnostics to help verify exact device selection among duplicates.
if not self._logged_candidates:
for d in devices_manager:
status = d.status()
logger.debug(
"iCloud candidate id=%s name=%s model=%s deviceStatus=%s has_location=%s features=%s",
d.data.get("id"),
d.name,
d.model_name,
status.get("deviceStatus"),
d.data.get("location") is not None,
d.data.get("features"),
)
self._logged_candidates = True
# Select by ID first (exact), then by name, then first device if no selectors set.
self.selected_device = None
for device in devices_manager:
if self.selected_device_id and device.data.get("id") == self.selected_device_id:
self.selected_device = device
break
if self.selected_device is None:
for device in devices_manager:
if self.selected_device_name and device.name == self.selected_device_name:
self.selected_device = device
break
if self.selected_device is None and not self.selected_device_id and not self.selected_device_name:
for device in devices_manager:
self.selected_device = device
break
if self.selected_device:
location = self.selected_device.location
status = self.selected_device.status()
logger.info(
"iCloud device=%s location_available=%s status=%s",
self.selected_device.name,
self.selected_device.location_available,
status,
)
if location:
data = {
"latitude": location['latitude'],
"longitude": location['longitude'],
"timeStamp": location['timeStamp'],
"altitude": location['altitude'],
"horizontalAccuracy": location['horizontalAccuracy'],
"verticalAccuracy": location['verticalAccuracy'],
"batteryLevel": status['batteryLevel'],
"deviceDisplayName": status['deviceDisplayName'],
"deviceStatus": status['deviceStatus'],
"name": status['name']
}
response = iCloudReturnData(**data)
return response
logger.info("Location payload is None for device=%s", self.selected_device.name)
return None
logger.warning(
"No iCloud device matched SELECTED_DEVICE_ID='%s' or SELECTED_DEVICE_NAME='%s'.",
self.selected_device_id,
self.selected_device_name,
)
return None
def start(self):
self.running = True
def stop(self):
self.running = False
def _no_location_backoff_seconds(self, base_interval: int) -> int:
schedule = [15, 30, 60, 120, 300]
idx = min(self._no_location_streak - 1, len(schedule) - 1)
return max(base_interval, schedule[idx])
def _auth_backoff_seconds(self, base_interval: int) -> int:
schedule = [15, 30, 60, 120, 300]
idx = min(self._auth_error_streak - 1, len(schedule) - 1)
return max(base_interval, schedule[idx])
async def run_monitor(self, interval=60):
"""Runs the monitor loop."""
if not await self.authenticate():
return
if not self.running:
self.start()
while self.running:
sleep_seconds = interval
try:
device_data = await self.get_location()
if device_data is not None:
self._no_location_streak = 0
self._auth_error_streak = 0
print(f"{device_data.timeStamp} - Location: {device_data.latitude}, {device_data.longitude}")
await self.queue.put(device_data)
else:
self._no_location_streak += 1
sleep_seconds = self._no_location_backoff_seconds(interval)
logger.info(
"No iCloud location found for device id='%s' name='%s' streak=%s next_retry=%ss",
self.selected_device_id,
self.selected_device_name,
self._no_location_streak,
sleep_seconds,
)
except AUTH_EXCEPTIONS as e:
self._auth_error_streak += 1
sleep_seconds = self._auth_backoff_seconds(interval)
logger.warning(
"iCloud auth error (%s). Re-authenticating; streak=%s next_retry=%ss",
type(e).__name__,
self._auth_error_streak,
sleep_seconds,
)
try:
await self.authenticate()
except Exception:
logger.exception("iCloud re-authentication failed")
except Exception as e:
logger.exception("iCloud monitor loop error: %s", e)
sleep_seconds = max(interval, 30)
await asyncio.sleep(sleep_seconds)

View File

@@ -0,0 +1,18 @@
import logging
import json
handler = logging.StreamHandler()
root_logger = logging.getLogger()
logger = logging.getLogger("ios-api")
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
payload["exc_info"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=True)

View File

@@ -0,0 +1,213 @@
from pymobiledevice3.services.dvt.instruments.location_simulation_base import (
LocationSimulationBase,
)
from pymobiledevice3.services.dvt.instruments.location_simulation import (
LocationSimulation,
)
class LocationSimulationQueue(LocationSimulation):
def __init__(self, dvt, context: LocationSimulationState):
super().__init__(dvt)
self.context = context
async def play_queue(
self, disable_sleep: bool = False, timing_randomness_range: int = 0
) -> None:
while True:
if self.context.queue_state == "PAUSED":
await asyncio.sleep(0.1)
continue
if self.context.queue_state == "SHUTDOWN":
break
loc_id = await self.context.queue.get()
if loc_id is None:
self.context.queue.task_done()
break
location_item = self.context.queue_data.get(loc_id)
if location_item is None:
logger.warning(
"Simulation queue item missing for loc_id=%s; skipping stale entry",
loc_id,
)
self.context.queue.task_done()
continue
new_latitude = location_item.get("latitude")
new_longitude = location_item.get("longitude")
new_delay = location_item.get("delay")
new_delay = 0 if new_delay is None else new_delay
new_start = location_item.get("start")
current_location_item = self.context.queue_data.get(self.context.loc_id)
current_latitude = (
current_location_item.get("latitude")
if isinstance(current_location_item, dict)
else self.context.latitude
)
current_longitude = (
current_location_item.get("longitude")
if isinstance(current_location_item, dict)
else self.context.longitude
)
current_start = (
current_location_item.get("start")
if isinstance(current_location_item, dict)
else None
)
if self.context.set_location_enabled:
if new_delay > 0 and not disable_sleep:
countdown_delay = int(round(float(new_delay)))
if timing_randomness_range > 0:
new_delay = new_delay + random.uniform(
-timing_randomness_range, timing_randomness_range
)
countdown_delay = int(round(float(new_delay)))
for i in range(max(0, countdown_delay), 0, -1):
self.context.next_move = i
await self.context.sio.emit(
"simulation_status",
{
"status": self.context.simulation_active,
"loc_id": self.context.loc_id,
"latitude": current_latitude,
"longitude": current_longitude,
"start": current_start,
"next_move": i,
},
namespace="/",
)
await asyncio.sleep(1)
self.context.queue_data[loc_id]["start"] = self.context.queue_data[self.context.loc_id]["end"] = datetime.now(timezone.utc).isoformat()
await self.set(new_latitude, new_longitude)
self.context.loc_id = loc_id
self.context.latitude = new_latitude
self.context.longitude = new_longitude
await self.context.sio.emit(
"simulation_status",
{
"status": self.context.simulation_active,
"loc_id": self.context.loc_id,
"latitude": self.context.latitude,
"longitude": self.context.longitude,
"start": new_start,
"next_move": None,
},
namespace="/",
)
logger.info(
"Set simulated location to %s, %s after %ss delay",
new_latitude,
new_longitude,
new_delay,
)
self.context.queue.task_done()
class LocationSimulationTestQueue(LocationSimulationBase):
def __init__(self, context: LocationSimulationState):
super().__init__()
self.context = context
def __enter__(self):
return self
def __exit__(self):
return self
async def set(self, latitude: float, longitude: float) -> None:
await asyncio.sleep(0.1)
logger.info("Simulated location set to %s, %s", latitude, longitude)
async def clear(self) -> None:
q = self.context.queue
self.context.set_location_enabled = False
self.context.queue_state = "SHUTDOWN"
while not q.empty():
try:
item = q.get_nowait()
q.task_done()
logger.info("Discarding item from queue: %s", item)
except asyncio.QueueEmpty:
break
await q.put(None)
if self.context.simulation_task is not None and not self.context.simulation_task.done():
try:
await asyncio.wait_for(self.context.simulation_task, timeout=5)
except TimeoutError:
self.context.simulation_task.cancel()
with suppress(asyncio.CancelledError):
await self.context.simulation_task
self.context.simulation_active = False
self.context.queue_state = "SHUTDOWN"
async def play_queue(
self, disable_sleep: bool = False, timing_randomness_range: int = 0
) -> None:
while True:
if self.context.queue_state == "PAUSED":
await asyncio.sleep(0.1)
continue
if self.context.queue_state == "SHUTDOWN":
break
loc_id = await self.context.queue.get()
if loc_id is None:
self.context.queue.task_done()
break
location_item = self.context.queue_data.get(loc_id)
if location_item is None:
logger.warning(
"Test simulation queue item missing for loc_id=%s; skipping stale entry",
loc_id,
)
self.context.queue.task_done()
continue
latitude = location_item.get("latitude")
longitude = location_item.get("longitude")
delay = location_item.get("delay")
delay = 0 if delay is None else delay
start_time = location_item.get("start")
if self.context.set_location_enabled:
if delay > 0 and not disable_sleep:
countdown_delay = int(round(float(delay)))
if timing_randomness_range > 0:
delay = delay + random.uniform(
-timing_randomness_range, timing_randomness_range
)
countdown_delay = int(round(float(delay)))
for i in range(max(0, countdown_delay), 0, -1):
self.context.next_move = i
await self.context.sio.emit(
"simulation_status",
{
"status": self.context.simulation_active,
"loc_id": self.context.loc_id,
"latitude": self.context.latitude,
"longitude": self.context.longitude,
"next_move": i,
},
namespace="/",
)
await asyncio.sleep(1)
await self.set(latitude, longitude)
self.context.latitude = latitude
self.context.longitude = longitude
await self.context.sio.emit(
"simulation_status",
{
"status": self.context.simulation_active,
"loc_id": self.context.loc_id,
"latitude": self.context.latitude,
"longitude": self.context.longitude,
"next_move": None,
},
namespace="/",
)
logger.info(
"Set simulated location to %s, %s after %ss delay",
latitude,
longitude,
delay,
)
self.context.queue.task_done()

View File

@@ -0,0 +1,64 @@
from typing import Optional, Dict
from pydantic import BaseModel
class SimulationStatusData(BaseModel):
latitude: float
longitude: float
start: float
end: Optional[float]
next_move: Optional[float]
class SimulationStatus(BaseModel):
status: bool
data: Optional[SimulationStatusData]
class SimulationRequestData(BaseModel):
latitude: float
longitude: float
delay: int = 0
start: Optional[str] = None
end: Optional[str] = None
class SimulationRequest(BaseModel):
status: bool
data: Optional[SimulationRequestData]
class SimulationRequestResponseData(BaseModel):
loc_id: str
latitude: float
longitude: float
delay: int = 0
start: Optional[str] = None
end: Optional[str] = None
class SimulationQueueList(BaseModel):
data: Optional[SimulationRequestResponseData]
class SimulationRequestResponse(BaseModel):
status: bool
data: Optional[SimulationRequestResponseData]
class SimulationQueueDict(BaseModel):
location_id: Dict[str, SimulationRequestResponseData]
class iCloudLocationData(BaseModel):
latitude: float
longitude: float
timestamp: str
class iCloudReturnData(BaseModel):
latitude: float
longitude: float
timeStamp: int
altitude: float
horizontalAccuracy: float
verticalAccuracy: float
batteryLevel: float
deviceDisplayName: str
deviceStatus: int
name: str

View File

@@ -0,0 +1,379 @@
""" Tunnel Functions"""
@self._app.get("/start-tunnel")
async def start_tunnel(
udid: Optional[str] = self.context.udid,
ip: Optional[str] = None,
connection_type: Optional[str] = None,
) -> fastapi.Response:
udid_tunnels = [
t.tunnel
for t in self._tunneld_core.tunnel_tasks.values()
if t.udid == udid and t.tunnel is not None
]
if len(udid_tunnels) > 0:
self.context.udid = udid
data = {
"interface": udid_tunnels[0].interface,
"port": udid_tunnels[0].port,
"address": udid_tunnels[0].address,
}
return generate_http_response(data)
queue = asyncio.Queue()
created_task = False
try:
if not created_task and connection_type in ("usbmux", None):
task_identifier = f"usbmux-{udid}"
try:
async with await create_using_usbmux(udid) as lockdown:
service = await CoreDeviceTunnelProxy.create(lockdown)
task = asyncio.create_task(
self._tunneld_core.start_tunnel_task(
task_identifier,
service,
protocol=TunnelProtocol.TCP,
queue=queue,
),
name=f"start-tunnel-task-{task_identifier}",
)
self._tunneld_core.tunnel_tasks[task_identifier] = TunnelTask(
task=task, udid=udid
)
created_task = True
except ConnectionFailedError, InvalidServiceError, MuxException:
pass
if connection_type in ("usb", None):
for rsd in await get_rsds(udid=udid):
rsd_ip = rsd.service.address[0]
if ip is not None and rsd_ip != ip:
await rsd.close()
continue
task = asyncio.create_task(
self._tunneld_core.start_tunnel_task(
rsd_ip,
await create_core_device_tunnel_service_using_rsd(rsd),
queue=queue,
),
name=f"start-tunnel-usb-{rsd_ip}",
)
self._tunneld_core.tunnel_tasks[rsd_ip] = TunnelTask(
task=task, udid=rsd.udid
)
created_task = True
if not created_task and connection_type in ("wifi", None):
for remotepairing in await get_remote_pairing_tunnel_services(
udid=udid
):
remotepairing_ip = remotepairing.hostname
if ip is not None and remotepairing_ip != ip:
await remotepairing.close()
continue
task = asyncio.create_task(
self._tunneld_core.start_tunnel_task(
remotepairing_ip, remotepairing, queue=queue
),
name=f"start-tunnel-wifi-{remotepairing_ip}",
)
self._tunneld_core.tunnel_tasks[remotepairing_ip] = TunnelTask(
task=task, udid=remotepairing.remote_identifier
)
created_task = True
except Exception as e:
return fastapi.Response(
status_code=501,
content=json.dumps(
{
"error": {
"exception": e.__class__.__name__,
"traceback": traceback.format_exc(),
}
}
),
)
if not created_task:
return fastapi.Response(
status_code=501, content=json.dumps({"error": "task not created"})
)
tunnel: Optional[TunnelResult] = await queue.get()
if tunnel is not None:
self.context.udid = udid
data = {
"interface": tunnel.interface,
"port": tunnel.port,
"address": tunnel.address,
}
return generate_http_response(data)
else:
return fastapi.Response(
status_code=404,
content=json.dumps(
{"error": "something went wrong during tunnel creation"}
),
)
@self._app.get("/restart-tunneld")
async def restart() -> fastapi.Response:
"""Restart Tunneld"""
self._tunneld_core.clear()
await asyncio.sleep(2)
self._tunneld_core.start()
data = {
"operation": "restart-tunneld",
"data": True,
"message": "Restarting tunneld...",
}
return generate_http_response(data)
@self._app.get("/shutdown")
async def shutdown() -> fastapi.Response:
"""Shutdown Tunneld"""
os.kill(os.getpid(), signal.SIGINT)
data = {
"operation": "shutdown",
"data": True,
"message": "Server shutting down...",
}
return generate_http_response(data)
@self._app.get("/clear-tunnels")
async def clear_tunnels() -> fastapi.Response:
"""Clear all tunnels"""
self._tunneld_core.clear()
data = {
"operation": "clear_tunnels",
"data": True,
"message": "Cleared tunnels...",
}
return generate_http_response(data)
@self._app.get("/cancel")
async def cancel_tunnel(udid: str) -> fastapi.Response:
"""Cancel a tunnel"""
self._tunneld_core.cancel(udid=udid)
data = {
"operation": "cancel",
"udid": udid,
"data": True,
"message": f"tunnel {udid} Canceled ...",
}
return generate_http_response(data)
"""Simulation Functions"""
@self._app.get("/start-simulation")
async def app_start_simulation() -> fastapi.Response:
logger.info("Simulation Start Requested ")
if (
self.context.simulation_task is None
or self.context.simulation_task.done()
):
await start_icloud_monitor()
self.context.simulation_active = True
self.context.simulation_task = asyncio.create_task(
start_simulation_queue(),
name="location-simulation-worker",
)
data = {"status": "started", "message": "Simulation started"}
else:
data = {"status": "error", "message": "Simulation already running"}
return generate_http_response(data)
@self._app.get("/start-icloud-monitor")
async def app_start_icloud_monitor() -> fastapi.Response:
await start_icloud_monitor()
data = {
"status": "started",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
return generate_http_response(data)
@self._app.get("/stop-icloud-monitor")
async def app_stop_icloud_monitor() -> fastapi.Response:
await end_icloud_monitor()
data = {
"status": "stopped",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
return generate_http_response(data)
@self._app.get("/icloud-monitor-status")
async def app_icloud_monitor_status() -> fastapi.Response:
data = {
"status": "ok",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
return generate_http_response(data)
@self._app.post("/add-location")
async def app_add_location(data: SimulationRequestData) -> fastapi.Response:
"""Add a location to the simulation queue"""
logger.info("Request to add new location to queue")
loc_id = str(uuid.uuid4())
latitude = (
data.get("latitude")
if isinstance(data, dict)
else getattr(data, "latitude", None)
)
longitude = (
data.get("longitude")
if isinstance(data, dict)
else getattr(data, "longitude", None)
)
delay = (
data.get("delay", 0)
if isinstance(data, dict)
else getattr(data, "delay", 0)
)
try:
delay = parse_delay_seconds(delay)
except ValueError as e:
return generate_http_response(
{"status": "error", "message": str(e)},
status_code=400,
)
if latitude is not None and longitude is not None:
logger.info(
"Adding location %s (%s, %s) with %s delay to the queue",
loc_id,
latitude,
longitude,
delay,
)
accrued_delay = 0
if self.context.queue_data:
accrued_delay = sum(
parse_delay_seconds(item.get("delay", 0))
for item in self.context.queue_data.values()
)
now_time = datetime.now(timezone.utc)
new_time = (
now_time
+ timedelta(seconds=accrued_delay)
+ timedelta(seconds=delay)
)
start_time = new_time.isoformat()
location_item = {
"loc_id": loc_id,
"latitude": latitude,
"longitude": longitude,
"delay": delay,
"start": start_time,
"status": "queued",
}
resp = {
"status": "added",
"message": f"Location {loc_id} added to the queue",
"item": location_item,
}
await self.context.queue.put(loc_id)
add_item(loc_id, location_item)
logger.info("Location %s added to the queue", loc_id)
else:
resp = {"status": "error", "message": "Invalid location data"}
return generate_http_response(resp)
@self._app.get("/clear-queue")
async def app_clear_queue() -> fastapi.Response:
"""Clear the simulation queue"""
logger.info("Simulation Start Requested ")
await empty_simulation_queue()
data = {"status": "cleared", "message": "Simulation cleared"}
return generate_http_response(data)
@self._app.get("/pause-queue")
async def app_pause_queue() -> fastapi.Response:
"""Pause the simulation queue"""
await pause_simulation_queue()
data = {"status": "paused", "message": "Simulation paused"}
return generate_http_response(data)
@self._app.get("/resume-queue")
async def app_resume_queue() -> fastapi.Response:
"""Resume the simulation queue"""
await resume_simulation_queue()
data = {"status": "resumed", "message": "Simulation resumed"}
return generate_http_response(data)
@self._app.get("/end-simulation")
async def app_end_simulation() -> fastapi.Response:
"""End the simulation queue"""
logger.info("End location simulation request")
end_task = asyncio.create_task(
end_simulation_queue(), name="end-simulation-worker"
)
result = await end_task
data = {"status": result, "message": "Simulation ended"}
return generate_http_response(data)
"""Status Functions"""
@self._app.get("/")
async def list_tunnels() -> dict[str, list[dict]]:
"""Retrieve the available tunnels and format them as {UUID: TUNNEL_ADDRESS}"""
tunnels = {}
for ip, active_tunnel in self._tunneld_core.tunnel_tasks.items():
if (active_tunnel.udid is None) or (active_tunnel.tunnel is None):
continue
if active_tunnel.udid not in tunnels:
tunnels[active_tunnel.udid] = []
tunnels[active_tunnel.udid].append(
{
"tunnel-address": active_tunnel.tunnel.address,
"tunnel-port": active_tunnel.tunnel.port,
"interface": ip,
}
)
return tunnels
@self._app.get("/device-info")
async def device_info():
"""Get device information"""
tunnels = {}
for ip, active_tunnel in self._tunneld_core.tunnel_tasks.items():
if (active_tunnel.udid is None) or (active_tunnel.tunnel is None):
continue
if active_tunnel.udid not in tunnels:
tunnels[active_tunnel.udid] = {}
try:
lockdown = await create_using_usbmux(
serial=active_tunnel.udid, autopair=False
)
tunnels[active_tunnel.udid] = iterate_multidim(lockdown.all_values)
except Exception as e:
logger.error(
f"Failed to create lockdown session for device {active_tunnel.udid}: {e}"
)
continue
return tunnels
@self._app.get("/device-name")
async def rsd_info():
"""Get rsd information"""
device_name = await get_device_name()
return generate_http_response(device_name)
@self._app.get("/rsd-info")
async def rsd_info():
"""Get rsd information"""
rsd_info = {}
if self.context.tunnel is None:
await get_tun()
if self.context.tunnel is not None:
rsd_info = self.context.tunnel.peer_info
return generate_http_response(rsd_info)
@self._app.get("/hello")
async def hello() -> fastapi.Response:
data = {"message": "Hello, I'm alive"}
return generate_http_response(data)
@self._app.get("/context-status")
async def app_context_status() -> fastapi.Response:
data = get_status()
return generate_http_response(data)

View File

@@ -0,0 +1,425 @@
""" Socket.IO Functions"""
async def sio_send_status(sid):
"""Send Current Status"""
await self.context.sio.emit(
"status", jsonable_encoder(get_status()), namespace="/", to=sid
)
"""Socket.IO Connection Events"""
@self.context.sio.event
async def connect(sid, environ):
"""Client connection event handler."""
self.context.connected_clients.add(sid)
logger.info("Client connected: %s", sid)
await sio_send_status(sid)
return "%s connected" % sid
@self.context.sio.event
async def disconnect(sid):
"""Client disconnection event handler."""
self.context.connected_clients.discard(sid)
logger.info("Client disconnected: %s", sid)
""" Socket.IO Request Events """
@self.context.sio.event
async def request_update(sid):
status_update = jsonable_encoder(get_status())
logger.info("Update request from %s sending %s", sid, status_update)
return status_update
@self.context.sio.event
async def message(sid, data):
logger.info("Received message from %s: %s", sid, data)
return True, "Message received"
# await self.context.sio.emit("message", f"Received message from {sid}: {data}", namespace="/")
""" Device Control"""
@self.context.sio.event
async def device_control(sid, data):
"""Device Control"""
command = (
data.get("command")
if isinstance(data, dict)
else getattr(data, "command", None)
)
delay = (
data.get("delay")
if isinstance(data, dict)
else getattr(data, "delay", None)
)
if delay is None:
delay = 5
match command:
case "shutdown":
""" Shutdown the device"""
logger.info(
"Shutdown command received from %s with delay %s", sid, delay
)
await device_shutdown(delay)
return {
"command": "shutdown",
"status": "success",
"message": f"Device shutdown initiated with {delay} seconds delay",
}
case "reboot":
""" Reboot the device"""
logger.info(
"Reboot command received from %s with delay %s", sid, delay
)
await device_reboot(delay)
return {
"command": "reboot",
"status": "success",
"message": f"Device reboot initiated with {delay} seconds delay",
}
case _:
return {
"command": command,
"status": "error",
"message": f"Invalid command: {command}",
}
@self.context.sio.event
async def simulation_control(sid, data):
"""Simulation Control"""
command = (
data.get("command")
if isinstance(data, dict)
else getattr(data, "command", None)
)
logger.info(
"Simulation Control command: %s requested from %s", command, sid
)
try:
match command:
case "add":
""" Add a location to the simulation queue"""
loc_id = str(uuid.uuid4())
latitude = (
data.get("latitude")
if isinstance(data, dict)
else getattr(data, "latitude", None)
)
longitude = (
data.get("longitude")
if isinstance(data, dict)
else getattr(data, "longitude", None)
)
delay = (
data.get("delay", 0)
if isinstance(data, dict)
else getattr(data, "delay", 0)
)
try:
delay = parse_delay_seconds(delay)
except ValueError as e:
return {
"command": command,
"status": "error",
"message": str(e),
}
if latitude is not None and longitude is not None:
logger.info(
"Adding location %s (%s, %s) with %s delay to the queue",
loc_id,
latitude,
longitude,
delay,
)
accrued_delay = 0
if self.context.queue_data:
accrued_delay = sum(
parse_delay_seconds(item.get("delay", 0))
for item in self.context.queue_data.values()
)
now_time = datetime.now(timezone.utc)
new_time = (
now_time
+ timedelta(seconds=accrued_delay)
+ timedelta(seconds=delay)
)
start_time = new_time.isoformat()
coords = f"{latitude}, {longitude}"
rev_geocode = self.context.reverse_geocode(coords)
if rev_geocode:
address = rev_geocode.address
else:
address = f"{latitude}, {longitude}"
location_item = {
"loc_id": loc_id,
"latitude": latitude,
"longitude": longitude,
"delay": delay,
"start": start_time,
"address": address,
}
ack = {
"command": command,
"status": "added",
"message": f"Location {loc_id} added to the queue",
"item": location_item,
}
await self.context.queue.put(loc_id)
add_item(loc_id, location_item)
logger.info("Location %s added to the queue", loc_id)
return ack
else:
logger.warning(
"Invalid location data received from %s: %s", sid, data
)
return {
"command": command,
"status": "error",
"message": "Invalid location data",
"data": location_item,
}
case "clear":
""" Clear the simulation queue"""
await empty_simulation_queue()
return {
"command": command,
"status": "cleared",
"message": "Simulation cleared",
}
case "pause":
""" Pause the simulation queue"""
await pause_simulation_queue()
return {
"command": command,
"status": "paused",
"message": "Simulation paused",
}
case "resume":
""" Resume the simulation queue"""
await resume_simulation_queue()
return {
"command": command,
"status": "resumed",
"message": "Simulation resumed",
}
case "end":
""" End the simulation queue"""
logger.info("End location simulation request from %s", sid)
end_task = asyncio.create_task(
end_simulation_queue(), name="end-simulation-worker"
)
result = await end_task
simstatus = not result
return {
"command": command,
"status": simstatus,
"message": "Simulation ended",
}
case "start":
""" Start the simulation queue"""
logger.info("Start location simulation request from %s", sid)
if (
self.context.simulation_task is None
or self.context.simulation_task.done()
):
await start_icloud_monitor()
self.context.simulation_active = True
self.context.queue_state = "RUNNING"
self.context.simulation_task = asyncio.create_task(
start_simulation_queue(),
name="location-simulation-worker",
)
return {
"command": command,
"status": self.context.queue_state,
"message": "Simulation started",
}
else:
return {
"command": command,
"status": "error",
"message": "Simulation already running",
}
case _:
logger.warning(
"Invalid command received from %s: %s", sid, command
)
return {"status": "error", "message": "Invalid command"}
finally:
await sio_send_status(sid)
@self.context.sio.event
async def icloud_monitor_control(sid, data):
command = (
data.get("command")
if isinstance(data, dict)
else getattr(data, "command", None)
)
logger.info(
"iCloud Monitor control command: %s requested from %s", command, sid
)
try:
match command:
case "start":
await start_icloud_monitor()
return {
"command": command,
"status": "running",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
case "stop":
await end_icloud_monitor()
return {
"command": command,
"status": "stopped",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
case "status":
return {
"command": command,
"status": "ok",
"icloud_monitor_enabled": self.context.icloud_monitor_enabled,
"icloud_monitor_running": is_icloud_monitor_running(),
}
case _:
return {
"command": command,
"status": "error",
"message": "Invalid command",
}
finally:
await sio_send_status(sid)
""" Tunnel Control """
@self.context.sio.event
async def tunneld_control(sid, data):
command = (
data.get("command")
if isinstance(data, dict)
else getattr(data, "command", None)
)
logger.info("Tunneld Control command: %s requested from %s", command, sid)
match command:
case "start":
"""Start Tunneld"""
logger.info("Start tunneld request from %s: %s", sid, data)
try:
self._tunneld_core.start()
logger.info("Tunneld started successfully")
return {
"status": "running",
"message": "Tunneld started successfully",
}
except Exception as e:
logger.error("Error starting tunneld: %s", e)
return {
"command": command,
"status": "error",
"message": f"Error starting tunneld: {e}",
}
case "start-watcher":
""" Start Tunneld Watcher """
logger.info("Start tunneld watcher request from %s: %s", sid, data)
await start_tunnel_watcher()
return {
"status": "running",
"message": "Tunneld watcher started successfully",
}
case "end-watcher":
""" End Tunneld Watcher """
logger.info("End tunneld watcher request from %s: %s", sid, data)
await end_tunnel_watcher()
return {
"status": "stopped",
"message": "Tunneld watcher stopped successfully",
}
case "shutdown":
"""Shutdown Tunneld"""
logger.info("Shutdown tunneld request from %s: %s", sid, data)
try:
os.kill(os.getpid(), signal.SIGINT)
return {
"command": command,
"status": "Success",
"message": "Server shutting down...",
}
except Exception as e:
logger.error("Error shutting down tunneld: %s", e)
return {
"command": command,
"status": "error",
"message": f"Error shutting down tunneld: {e}",
}
case "clear":
"""Clear all tunnels"""
logger.info("Clearing tunnels...")
try:
self._tunneld_core.clear()
return {
"command": command,
"status": "Success",
"message": "Cleared tunnels...",
}
except Exception as e:
logger.error("Error clearing tunnels: %s", e)
return {
"command": command,
"status": "error",
"message": f"Error clearing tunnels: {e}",
}
case "cancel":
"""Cancel a tunnel"""
logger.info("Canceling tunnel request from %s: %s", sid, data)
try:
udid = (
data.get("udid")
if isinstance(data, dict)
else getattr(data, "udid", self.context.udid)
)
if udid is None:
udid = self.context.udid
self._tunneld_core.cancel(udid=udid)
return {
"command": command,
"status": "Success",
"udid": udid,
"message": f"tunnel {udid} Canceled ...",
}
except Exception as e:
logger.error("Error canceling tunnel: %s", e)
return {
"command": command,
"status": "error",
"message": f"Error canceling tunnel: {e}",
}
case _:
return {
"command": command,
"status": "error",
"message": f"Unknown operation: {command}",
}

File diff suppressed because it is too large Load Diff