- add multi-device portal device management, key rotation, and sync request APIs - add Pi settings UI for remote server URL, device name, and API key - add local test-connection and sync-now actions - make upload and config sync reuse dynamic shared credentials - add Phase 25.5c migration and docs updates Co-authored-by: Copilot <[email protected]>
155 lines
4.7 KiB
Python
155 lines
4.7 KiB
Python
"""Manage remote server credentials stored in db/upload_creds.env."""
|
|
|
|
import json
|
|
import os
|
|
from urllib.parse import urlparse
|
|
|
|
import requests
|
|
from dotenv import dotenv_values
|
|
|
|
|
|
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
_CREDS_PATH = os.path.join(_BASE_DIR, "db", "upload_creds.env")
|
|
_ORDERED_KEYS = [
|
|
"YARIS_SERVER_URL",
|
|
"YARIS_DEVICE_NAME",
|
|
"YARIS_API_KEY",
|
|
"YARIS_CELLULAR_ENABLED",
|
|
"YARIS_CELLULAR_APN",
|
|
"YARIS_CELLULAR_APN_USER",
|
|
"YARIS_CELLULAR_APN_PASS",
|
|
]
|
|
|
|
|
|
def server_credentials():
|
|
values = _read_values()
|
|
return {
|
|
"server_url": values.get("YARIS_SERVER_URL", "").rstrip("/"),
|
|
"device_name": values.get("YARIS_DEVICE_NAME", ""),
|
|
"api_key": values.get("YARIS_API_KEY", ""),
|
|
}
|
|
|
|
|
|
def get_remote_config():
|
|
credentials = server_credentials()
|
|
return {
|
|
"server_url": credentials["server_url"],
|
|
"device_name": credentials["device_name"],
|
|
"api_key_configured": bool(credentials["api_key"]),
|
|
}
|
|
|
|
|
|
def update_remote_config(body, errors):
|
|
if not isinstance(body, dict):
|
|
errors["error"] = "Invalid request body"
|
|
return errors
|
|
|
|
values = _read_values()
|
|
|
|
if "server_url" in body:
|
|
server_url = str(body["server_url"]).strip()
|
|
if server_url:
|
|
parsed = urlparse(server_url)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
errors["server_url"] = "Server URL must be a valid http(s) URL."
|
|
else:
|
|
values["YARIS_SERVER_URL"] = server_url.rstrip("/")
|
|
else:
|
|
values["YARIS_SERVER_URL"] = ""
|
|
|
|
if "device_name" in body:
|
|
device_name = str(body["device_name"]).strip()
|
|
if device_name and len(device_name) > 100:
|
|
errors["remote_device_name"] = "Device name must be 1-100 characters."
|
|
else:
|
|
values["YARIS_DEVICE_NAME"] = device_name
|
|
|
|
if body.get("clear_api_key"):
|
|
values["YARIS_API_KEY"] = ""
|
|
elif "api_key" in body:
|
|
api_key = str(body["api_key"]).strip()
|
|
if api_key:
|
|
if len(api_key) > 512:
|
|
errors["api_key"] = "API key must be 1-512 characters."
|
|
else:
|
|
values["YARIS_API_KEY"] = api_key
|
|
|
|
if errors:
|
|
return errors
|
|
|
|
_write_values(values)
|
|
return errors
|
|
|
|
|
|
def test_connection():
|
|
credentials = server_credentials()
|
|
if not credentials["server_url"]:
|
|
return {"ok": False, "error": "Server URL is not configured."}
|
|
try:
|
|
health_response = requests.get(
|
|
f"{credentials['server_url']}/health",
|
|
timeout=5,
|
|
)
|
|
health_response.raise_for_status()
|
|
except requests.RequestException as error:
|
|
return {"ok": False, "error": f"Could not reach server: {error}"}
|
|
|
|
if not credentials["device_name"] or not credentials["api_key"]:
|
|
return {
|
|
"ok": True,
|
|
"message": "Server is reachable, but device credentials are not fully configured yet.",
|
|
"authenticated": False,
|
|
}
|
|
|
|
try:
|
|
auth_response = requests.get(
|
|
f"{credentials['server_url']}/api/device-config",
|
|
headers=_headers(credentials),
|
|
timeout=5,
|
|
)
|
|
except requests.RequestException as error:
|
|
return {"ok": False, "error": f"Server reachable but auth test failed: {error}"}
|
|
|
|
if auth_response.status_code == 200:
|
|
return {
|
|
"ok": True,
|
|
"message": "Server reachable and device credentials are valid.",
|
|
"authenticated": True,
|
|
}
|
|
if auth_response.status_code == 401:
|
|
return {
|
|
"ok": False,
|
|
"error": "Server reachable, but device name or API key is invalid.",
|
|
}
|
|
return {
|
|
"ok": False,
|
|
"error": f"Server reachable, but config auth returned HTTP {auth_response.status_code}.",
|
|
}
|
|
|
|
|
|
def _headers(credentials):
|
|
return {
|
|
"X-Device-Name": credentials["device_name"],
|
|
"X-Api-Key": credentials["api_key"],
|
|
}
|
|
|
|
|
|
def _read_values():
|
|
values = {key: "" for key in _ORDERED_KEYS}
|
|
parsed = dotenv_values(_CREDS_PATH)
|
|
for key, value in parsed.items():
|
|
values[key] = value or ""
|
|
return values
|
|
|
|
|
|
def _write_values(values):
|
|
os.makedirs(os.path.dirname(_CREDS_PATH), exist_ok=True)
|
|
with open(_CREDS_PATH, "w", encoding="utf-8") as handle:
|
|
handle.write("##\n")
|
|
handle.write("## yaris-obd remote server upload client credentials\n")
|
|
handle.write("##\n")
|
|
handle.write("## Managed via the local Settings page. Real values stay local to this Pi.\n")
|
|
handle.write("##\n\n")
|
|
for key in _ORDERED_KEYS:
|
|
handle.write(f"{key} = {json.dumps(values.get(key, ''))}\n")
|