fix: authenticate device config sync like upload client

- send X-Device-Name and X-Api-Key to /api/device-config
- require YARIS_DEVICE_NAME alongside shared upload creds
- fix server create_device output to match actual Pi env names
- document shared upload/config sync credential requirements

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 17:42:40 -04:00
co-authored by Copilot
parent 5a5058adca
commit a114939914
6 changed files with 47 additions and 42 deletions
+4 -2
View File
@@ -1505,7 +1505,8 @@ phase roadmap.
- Detects changes via config_version - Detects changes via config_version
- Stores synced config in SQLite - Stores synced config in SQLite
- Reuses the existing upload credentials from `db/upload_creds.env` - Reuses the existing upload credentials from `db/upload_creds.env`
(`YARIS_SERVER_URL` + `YARIS_API_KEY`; `YARIS_DEVICE_KEY` also accepted as an override) (`YARIS_SERVER_URL` + `YARIS_DEVICE_NAME` + `YARIS_API_KEY`;
`YARIS_DEVICE_KEY` also accepted as an override for the key)
- Ready for hot-reload on config change (future: signal handler) - Ready for hot-reload on config change (future: signal handler)
- **Pi startup** (`app.py`): - **Pi startup** (`app.py`):
@@ -1526,7 +1527,8 @@ phase roadmap.
#### Testing #### Testing
- Server endpoints accessible via `/api/device-config` - Server endpoints accessible via `/api/device-config`
- Pi thread starts on boot (check logs for connectivity attempts) - Pi thread starts on boot (check logs for connectivity attempts)
- Sync disabled if `YARIS_SERVER_URL` or an API key (`YARIS_API_KEY` / `YARIS_DEVICE_KEY`) is not set - Sync disabled if `YARIS_SERVER_URL`, `YARIS_DEVICE_NAME`, or an API key
(`YARIS_API_KEY` / `YARIS_DEVICE_KEY`) is not set
- **2026-08-10 access verification**: SSH access confirmed to Pi at `[email protected]` - **2026-08-10 access verification**: SSH access confirmed to Pi at `[email protected]`
(new WiFi card/subnet) and to unraid via `ssh unraid` (`[email protected]`). (new WiFi card/subnet) and to unraid via `ssh unraid` (`[email protected]`).
Follow-up deployment succeeded the same day: Pi fast-forwarded from `d7b9bb6` to `cd004fb`, Follow-up deployment succeeded the same day: Pi fast-forwarded from `d7b9bb6` to `cd004fb`,
+5 -4
View File
@@ -204,10 +204,11 @@ nano /home/yaris/app/yaris-obd/config/device.json
- **`avatar_path`**: Avatar image shown in topbar; must exist in `static/img/` (replace `static/img/yaris.png` with your own image, or point to a different file) - **`avatar_path`**: Avatar image shown in topbar; must exist in `static/img/` (replace `static/img/yaris.png` with your own image, or point to a different file)
If you want the remote server to become the source of truth instead, keep the Pi If you want the remote server to become the source of truth instead, keep the Pi
upload credentials in `db/upload_creds.env` (`YARIS_SERVER_URL` + `YARIS_API_KEY`) upload credentials in `db/upload_creds.env` (`YARIS_SERVER_URL`,
and enable **Sync with server** in the local Settings page. The same creds file is `YARIS_DEVICE_NAME`, and `YARIS_API_KEY`) and enable **Sync with server** in the
reused for config sync; when the server pushes new identity values, the running Pi local Settings page. The same creds file is reused for config sync; when the
UI hot-reloads them on the next successful sync. server pushes new identity values, the running Pi UI hot-reloads them on the next
successful sync.
After editing, restart the app: After editing, restart the app:
+14 -21
View File
@@ -40,8 +40,9 @@ def _has_internet():
def _server_credentials(): def _server_credentials():
server_url = os.getenv("YARIS_SERVER_URL", "").rstrip("/") server_url = os.getenv("YARIS_SERVER_URL", "").rstrip("/")
device_name = os.getenv("YARIS_DEVICE_NAME") or os.getenv("YARIS_UPLOAD_DEVICE_NAME", "")
device_key = os.getenv("YARIS_DEVICE_KEY") or os.getenv("YARIS_API_KEY", "") device_key = os.getenv("YARIS_DEVICE_KEY") or os.getenv("YARIS_API_KEY", "")
return server_url, device_key return server_url, device_name, device_key
def start_sync(): def start_sync():
@@ -71,47 +72,39 @@ def _sync_loop():
"""Periodically sync device config from server when internet available.""" """Periodically sync device config from server when internet available."""
global _SYNC_CONFIG_VERSION global _SYNC_CONFIG_VERSION
server_url, device_key = _server_credentials() server_url, device_name, device_key = _server_credentials()
if not server_url or not device_key: if not server_url or not device_name or not device_key:
return # Sync disabled if server URL or device key not configured return
# Sync every 5 minutes (300 sec) when connected, backoff to 60 sec when offline
sync_interval = 300 sync_interval = 300
offline_interval = 60 offline_interval = 60
while not _SYNC_STOP: while not _SYNC_STOP:
try: try:
is_connected = _has_internet() if _has_internet():
if is_connected:
# Fetch device config from server
headers = {"Authorization": f"Bearer {device_key}"}
response = requests.get( response = requests.get(
f"{server_url.rstrip('/')}/api/device-config", f"{server_url}/api/device-config",
headers=headers, headers={
timeout=5 "X-Device-Name": device_name,
"X-Api-Key": device_key,
},
timeout=5,
) )
if response.status_code == 200: if response.status_code == 200:
config = response.json() config = response.json()
# Check if config changed
new_version = config.get("config_version", 0) new_version = config.get("config_version", 0)
if new_version != _SYNC_CONFIG_VERSION: if new_version != _SYNC_CONFIG_VERSION:
_SYNC_CONFIG_VERSION = new_version _SYNC_CONFIG_VERSION = new_version
# Store in database
_store_synced_config(config) _store_synced_config(config)
# Signal that config changed (for hot-reload)
_on_config_changed() _on_config_changed()
time.sleep(sync_interval) time.sleep(sync_interval)
else: else:
# Offline: backoff to less frequent checks
time.sleep(offline_interval) time.sleep(offline_interval)
except Exception as e: except Exception as e:
# Log error but don't crash sync thread
print(f"Device config sync error: {e}") print(f"Device config sync error: {e}")
time.sleep(60) time.sleep(60)
@@ -134,8 +127,8 @@ def _on_config_changed():
def is_sync_enabled(): def is_sync_enabled():
"""Check if device config sync from server is enabled.""" """Check if device config sync from server is enabled."""
server_url, device_key = _server_credentials() server_url, device_name, device_key = _server_credentials()
return bool(server_url) and bool(device_key) return bool(server_url) and bool(device_name) and bool(device_key)
def get_synced_config_version(): def get_synced_config_version():
+2 -2
View File
@@ -97,8 +97,8 @@ Run this once per device (e.g. once for the Pi) after the stack is up:
docker compose exec api python3 scripts/create_device.py yaris-2013 docker compose exec api python3 scripts/create_device.py yaris-2013
``` ```
This prints a one-time API key — copy the `YARIS_UPLOAD_DEVICE_NAME` / This prints a one-time API key — copy the `YARIS_DEVICE_NAME` /
`YARIS_UPLOAD_API_KEY` values into the Pi's upload-client config. Only the `YARIS_API_KEY` values into the Pi's upload-client config. Only the
key's sha256 hash is stored server-side; the raw key is never shown again key's sha256 hash is stored server-side; the raw key is never shown again
(re-run the script with the same device name to rotate/replace the key). (re-run the script with the same device name to rotate/replace the key).
+3 -3
View File
@@ -40,9 +40,9 @@ def main():
print(f"Device '{device_name}' registered (id={device_id}).") print(f"Device '{device_name}' registered (id={device_id}).")
print() print()
print("Save these on the Pi (e.g. in server/.env-style local config, gitignored):") print("Save these on the Pi (e.g. in db/upload_creds.env, gitignored):")
print(f" YARIS_UPLOAD_DEVICE_NAME={device_name}") print(f" YARIS_DEVICE_NAME={device_name}")
print(f" YARIS_UPLOAD_API_KEY={raw_key}") print(f" YARIS_API_KEY={raw_key}")
print() print()
print("This key will not be shown again - only its hash is stored server-side.") print("This key will not be shown again - only its hash is stored server-side.")
+11 -2
View File
@@ -56,11 +56,20 @@ class DeviceSyncReloadCallbackTest(unittest.TestCase):
finally: finally:
app_device_sync._RELOAD_CALLBACK = original app_device_sync._RELOAD_CALLBACK = original
@patch.dict(os.environ, {"YARIS_SERVER_URL": "https://example.test", "YARIS_API_KEY": "upload-key"}, clear=True) @patch.dict(
os.environ,
{
"YARIS_SERVER_URL": "https://example.test",
"YARIS_DEVICE_NAME": "yaris-2013",
"YARIS_API_KEY": "upload-key",
},
clear=True,
)
def test_sync_credentials_fall_back_to_upload_api_key(self): def test_sync_credentials_fall_back_to_upload_api_key(self):
server_url, device_key = app_device_sync._server_credentials() server_url, device_name, device_key = app_device_sync._server_credentials()
self.assertEqual(server_url, "https://example.test") self.assertEqual(server_url, "https://example.test")
self.assertEqual(device_name, "yaris-2013")
self.assertEqual(device_key, "upload-key") self.assertEqual(device_key, "upload-key")