Răsfoiți Sursa

service_api: route internal credential refresh through the API upload path

The refresh scripts default to --output=api: credentials are PUT to
/credentials/{service}, which runs set_credentials (env file + UPDATED_AT +
clear_stale), reload_client, and a health probe — one sanctioned write path
instead of file-write + _reload_from_env coordination. File mode (previously
the default, and the only path that skipped the timestamp and stale-flag
bookkeeping) remains available via --output=env for standalone runs.

Auth: the server generates an in-memory internal_token() at first use and
accepts it as a bearer token in get_user; _npm_env injects it plus a loopback
ROOM_CAL_API_URL default (http://127.0.0.1:$API_PORT, exported by the docker
entrypoint). config.api_url is now empty by default — it is only for
dev/admin --user runs against a remote server and would otherwise shadow the
runtime loopback default.

If the PUT fails, handleOutput falls back to writing the env file so a
session that cost a login + 2FA round-trip is never dropped.

Browser profiles are no longer split by output mode (the -api suffix is
gone); the profile is browser state, independent of credential delivery, and
existing server profiles carry over warm sessions. Machines that previously
ran --output=api will need one re-login.

Also: _auto_refresh clears the booking stale flag after a successful refresh
on both the HTTP-bump and Playwright paths.
ZiB 3 luni în urmă
părinte
comite
bdebf0373a

+ 2 - 0
docker/entrypoint.sh

@@ -37,6 +37,8 @@ fi
 : "${API_HOST:=0.0.0.0}"
 : "${API_PORT:=8000}"
 : "${LOG_LEVEL:=info}"
+# App builds its own loopback URL from API_PORT (internal credential upload)
+export API_PORT
 LOG_CONFIG=/etc/uvicorn_log.json
 [ -f "${ROOM_CAL_DATA_HOME}/uvicorn_log.json" ] && LOG_CONFIG="${ROOM_CAL_DATA_HOME}/uvicorn_log.json"
 

+ 19 - 0
room_cal/_utils.py

@@ -1,6 +1,7 @@
 import asyncio
 import json
 import os
+import secrets
 from dataclasses import dataclass, field
 from datetime import date, datetime, timedelta
 from importlib.resources import files
@@ -444,6 +445,24 @@ class Config:
     )
 
 
+# ── Internal API token ─────────────────────────────────────────────────────────
+
+_internal_token: str | None = None
+
+
+def internal_token() -> str:
+  """Process-wide bearer token for API requests the server makes to itself.
+
+  Generated on first use, never persisted; subprocesses spawned by the server
+  (e.g. credential refresh scripts) receive it via environment and the auth
+  middleware accepts it for the lifetime of this process.
+  """
+  global _internal_token
+  if _internal_token is None:
+    _internal_token = secrets.token_urlsafe(32)
+  return _internal_token
+
+
 # ── Config loading ─────────────────────────────────────────────────────────────
 
 def get_config() -> Config:

+ 5 - 0
room_cal/api/_auth.py

@@ -11,6 +11,8 @@ import jwt
 from fastapi import APIRouter, Request
 from fastapi.responses import JSONResponse, RedirectResponse
 
+from room_cal._utils import internal_token
+
 AUTH_DISABLED = os.getenv('AUTH_DISABLED', '').lower() in ('1', 'true', 'yes')
 OIDC_ISSUER = os.getenv('OIDC_ISSUER', '')
 OIDC_CLIENT_ID = os.getenv('OIDC_CLIENT_ID', '')
@@ -57,6 +59,9 @@ def _verify(token: str) -> Optional[dict]:
 def get_user(request: Request) -> Optional[dict]:
   if AUTH_DISABLED:
     return {'sub': 'dev', 'email': 'dev@localhost', 'name': 'Dev User'}
+  auth = request.headers.get('authorization', '')
+  if auth.startswith('Bearer ') and secrets.compare_digest(auth[7:], internal_token()):
+    return {'sub': 'internal', 'name': 'Internal'}
   token = request.cookies.get(_SESSION_COOKIE)
   return _verify(token) if token else None
 

+ 1 - 1
room_cal/config-all.json

@@ -123,6 +123,6 @@
       }
     }
   },
-  "api_url": "http://localhost:8000/",
+  "api_url": "",
   "calendars": []
 }

+ 1 - 1
room_cal/config.json

@@ -49,7 +49,7 @@
       "url": "webcal://cal.4114.us/fire.ics"
     }
   ],
-  "api_url": "http://localhost:8000/",
+  "api_url": "",
   "user_cal": {
     "url": "",
     "username": "",

+ 5 - 1
room_cal/service_api/_booking.py

@@ -13,7 +13,7 @@ import httpx
 from ._scripts import _assert_not_async, _booking_lock, _npm_env, _ts_dir, _ensure_installed
 from room_cal._utils import data_home, get_config, read_env
 from ._utils import month_bounds, months_in_range
-from .credentials import is_stale, mark_stale, set_credentials
+from .credentials import clear_stale, is_stale, mark_stale, set_credentials
 from .types import DayInfo, ServiceAuthError, Reservation
 
 _log = logging.getLogger(__name__)
@@ -272,11 +272,15 @@ class _BookingClient:
     _log.warning('Booking.com session may be expired — trying HTTP session refresh')
     if await self._try_http_session_refresh():
       _log.info('Booking.com session refreshed via HTTP')
+      clear_stale('booking')
       return
     _log.warning('HTTP refresh failed — launching headless Playwright')
     try:
       await asyncio.get_running_loop().run_in_executor(None, _BookingClient.refresh_credentials)
       self._reload_from_env()
+      # the file-mode refresh script writes booking.env directly (no set_credentials),
+      # so the stale flag must be cleared here or the service stays poisoned
+      clear_stale('booking')
     except ServiceAuthError:
       mark_stale('booking')
       raise

+ 7 - 4
room_cal/service_api/_scripts.py

@@ -6,7 +6,7 @@ import sys
 import threading
 from pathlib import Path
 
-from room_cal._utils import get_config
+from room_cal._utils import get_config, internal_token
 
 _log = logging.getLogger(__name__)
 
@@ -25,9 +25,12 @@ def _npm_env() -> dict:
   env = os.environ.copy()
   env.setdefault('ROOM_CAL_DATA_HOME', str(dh))
   env.setdefault('PLAYWRIGHT_BROWSERS_PATH', str(dh / '.playwright-browsers'))
-  api_url = get_config().api_url
-  if api_url:
-    env.setdefault('ROOM_CAL_API_URL', api_url)
+  # config api_url is for --user runs on non-server machines; on the server the
+  # API is our own process, reachable on loopback at the port we were started with.
+  # The entrypoint may export ROOM_CAL_API_URL as an empty string — treat as unset.
+  if not env.get('ROOM_CAL_API_URL'):
+    env['ROOM_CAL_API_URL'] = get_config().api_url or f'http://127.0.0.1:{os.environ.get("API_PORT", "8000")}'
+  env.setdefault('ROOM_CAL_API_TOKEN', internal_token())
   return env
 
 

+ 1 - 1
room_cal/service_api/typescript/airbnb/refresh-cookies.ts

@@ -119,7 +119,7 @@ async function performLogin(page: Page, email: string, password: string): Promis
 }
 
 export async function refresh(mode: string, userDriven = false): Promise<CredentialsPayload> {
-  const profile = profileDir('airbnb', mode);
+  const profile = profileDir('airbnb');
   fs.mkdirSync(profile, { recursive: true });
 
   const envText = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';

+ 1 - 1
room_cal/service_api/typescript/booking/refresh-cookies.ts

@@ -241,7 +241,7 @@ const FORCE_HEADLESS = !!process.env.HEADLESS;
 const SKIP_CHAT_2FA = !!process.env.SKIP_CHAT_2FA;
 
 export async function refresh(mode: string, userDriven = false): Promise<CredentialsPayload> {
-  const profile = profileDir('booking', mode);
+  const profile = profileDir('booking');
   fs.mkdirSync(profile, { recursive: true });
 
   const envText = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';

+ 22 - 9
room_cal/service_api/typescript/shared/api-client.ts

@@ -34,9 +34,12 @@ export async function putCredentials(service: string, credentials: CredentialsPa
   if (!baseUrl) throw new Error('ROOM_CAL_API_URL not set');
   const url = `${baseUrl.replace(/\/$/, '')}/credentials/${service}`;
   console.error(`[${service}] PUT ${url} ...`);
+  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
+  const token = process.env.ROOM_CAL_API_TOKEN;
+  if (token) headers['Authorization'] = `Bearer ${token}`;
   const res = await fetch(url, {
     method: 'PUT',
-    headers: { 'Content-Type': 'application/json' },
+    headers,
     body: JSON.stringify(credentials),
   });
   if (!res.ok) {
@@ -46,6 +49,15 @@ export async function putCredentials(service: string, credentials: CredentialsPa
   console.error(`[${service}] PUT ${res.status} OK`);
 }
 
+function writeEnvFile(service: string, credentials: CredentialsPayload, envPath: string): void {
+  let env = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
+  for (const [key, value] of Object.entries(credentials)) {
+    env = upsertEnvLine(env, key, value);
+  }
+  fs.writeFileSync(envPath, env);
+  console.error(`[${service}] Credentials written to ${envPath}`);
+}
+
 export async function handleOutput(
   service: string,
   credentials: CredentialsPayload,
@@ -55,14 +67,15 @@ export async function handleOutput(
   if (mode === 'stdout') {
     process.stdout.write(JSON.stringify(credentials) + '\n');
   } else if (mode === 'api') {
-    await putCredentials(service, credentials);
-    console.error(`[${service}] Credentials sent to server.`);
-  } else {
-    let env = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
-    for (const [key, value] of Object.entries(credentials)) {
-      env = upsertEnvLine(env, key, value);
+    try {
+      await putCredentials(service, credentials);
+      console.error(`[${service}] Credentials sent to server.`);
+    } catch (err) {
+      // A captured session may have cost a login/2FA round-trip — never drop it.
+      console.error(`[${service}] API upload failed (${(err as Error).message}) — falling back to env file.`);
+      writeEnvFile(service, credentials, envPath);
     }
-    fs.writeFileSync(envPath, env);
-    console.error(`[${service}] Credentials written to ${envPath}`);
+  } else {
+    writeEnvFile(service, credentials, envPath);
   }
 }

+ 2 - 3
room_cal/service_api/typescript/shared/browser.ts

@@ -6,9 +6,8 @@ import * as fs from 'fs';
 
 const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
 
-export function profileDir(service: string, mode: string): string {
-  const suffix = mode === 'api' ? '-api' : '';
-  return path.join(DATA_HOME, service, `.playwright-profile${suffix}`);
+export function profileDir(service: string): string {
+  return path.join(DATA_HOME, service, '.playwright-profile');
 }
 
 export async function openPersistentContext(profilePath: string, headless: boolean): Promise<BrowserContext> {

+ 3 - 2
room_cal/service_api/typescript/shared/cookie-utils.ts

@@ -19,8 +19,9 @@ export function upsertEnvLine(text: string, key: string, value: string): string
 export function parseOutputMode(argv: string[]): OutputMode {
   const arg = argv.find(a => a.startsWith('--output='));
   const mode = arg?.split('=')[1];
-  if (mode === 'stdout' || mode === 'file' || mode === 'api') return mode;
-  return 'file';
+  if (mode === 'env' || mode === 'file') return 'file';
+  if (mode === 'stdout' || mode === 'api') return mode;
+  return 'api';
 }
 
 export function parseUserDriven(argv: string[]): boolean {