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_HOST:=0.0.0.0}"
 : "${API_PORT:=8000}"
 : "${API_PORT:=8000}"
 : "${LOG_LEVEL:=info}"
 : "${LOG_LEVEL:=info}"
+# App builds its own loopback URL from API_PORT (internal credential upload)
+export API_PORT
 LOG_CONFIG=/etc/uvicorn_log.json
 LOG_CONFIG=/etc/uvicorn_log.json
 [ -f "${ROOM_CAL_DATA_HOME}/uvicorn_log.json" ] && LOG_CONFIG="${ROOM_CAL_DATA_HOME}/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 asyncio
 import json
 import json
 import os
 import os
+import secrets
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from datetime import date, datetime, timedelta
 from datetime import date, datetime, timedelta
 from importlib.resources import files
 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 ─────────────────────────────────────────────────────────────
 # ── Config loading ─────────────────────────────────────────────────────────────
 
 
 def get_config() -> Config:
 def get_config() -> Config:

+ 5 - 0
room_cal/api/_auth.py

@@ -11,6 +11,8 @@ import jwt
 from fastapi import APIRouter, Request
 from fastapi import APIRouter, Request
 from fastapi.responses import JSONResponse, RedirectResponse
 from fastapi.responses import JSONResponse, RedirectResponse
 
 
+from room_cal._utils import internal_token
+
 AUTH_DISABLED = os.getenv('AUTH_DISABLED', '').lower() in ('1', 'true', 'yes')
 AUTH_DISABLED = os.getenv('AUTH_DISABLED', '').lower() in ('1', 'true', 'yes')
 OIDC_ISSUER = os.getenv('OIDC_ISSUER', '')
 OIDC_ISSUER = os.getenv('OIDC_ISSUER', '')
 OIDC_CLIENT_ID = os.getenv('OIDC_CLIENT_ID', '')
 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]:
 def get_user(request: Request) -> Optional[dict]:
   if AUTH_DISABLED:
   if AUTH_DISABLED:
     return {'sub': 'dev', 'email': 'dev@localhost', 'name': 'Dev User'}
     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)
   token = request.cookies.get(_SESSION_COOKIE)
   return _verify(token) if token else None
   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": []
   "calendars": []
 }
 }

+ 1 - 1
room_cal/config.json

@@ -49,7 +49,7 @@
       "url": "webcal://cal.4114.us/fire.ics"
       "url": "webcal://cal.4114.us/fire.ics"
     }
     }
   ],
   ],
-  "api_url": "http://localhost:8000/",
+  "api_url": "",
   "user_cal": {
   "user_cal": {
     "url": "",
     "url": "",
     "username": "",
     "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 ._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 room_cal._utils import data_home, get_config, read_env
 from ._utils import month_bounds, months_in_range
 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
 from .types import DayInfo, ServiceAuthError, Reservation
 
 
 _log = logging.getLogger(__name__)
 _log = logging.getLogger(__name__)
@@ -272,11 +272,15 @@ class _BookingClient:
     _log.warning('Booking.com session may be expired — trying HTTP session refresh')
     _log.warning('Booking.com session may be expired — trying HTTP session refresh')
     if await self._try_http_session_refresh():
     if await self._try_http_session_refresh():
       _log.info('Booking.com session refreshed via HTTP')
       _log.info('Booking.com session refreshed via HTTP')
+      clear_stale('booking')
       return
       return
     _log.warning('HTTP refresh failed — launching headless Playwright')
     _log.warning('HTTP refresh failed — launching headless Playwright')
     try:
     try:
       await asyncio.get_running_loop().run_in_executor(None, _BookingClient.refresh_credentials)
       await asyncio.get_running_loop().run_in_executor(None, _BookingClient.refresh_credentials)
       self._reload_from_env()
       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:
     except ServiceAuthError:
       mark_stale('booking')
       mark_stale('booking')
       raise
       raise

+ 7 - 4
room_cal/service_api/_scripts.py

@@ -6,7 +6,7 @@ import sys
 import threading
 import threading
 from pathlib import Path
 from pathlib import Path
 
 
-from room_cal._utils import get_config
+from room_cal._utils import get_config, internal_token
 
 
 _log = logging.getLogger(__name__)
 _log = logging.getLogger(__name__)
 
 
@@ -25,9 +25,12 @@ def _npm_env() -> dict:
   env = os.environ.copy()
   env = os.environ.copy()
   env.setdefault('ROOM_CAL_DATA_HOME', str(dh))
   env.setdefault('ROOM_CAL_DATA_HOME', str(dh))
   env.setdefault('PLAYWRIGHT_BROWSERS_PATH', str(dh / '.playwright-browsers'))
   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
   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> {
 export async function refresh(mode: string, userDriven = false): Promise<CredentialsPayload> {
-  const profile = profileDir('airbnb', mode);
+  const profile = profileDir('airbnb');
   fs.mkdirSync(profile, { recursive: true });
   fs.mkdirSync(profile, { recursive: true });
 
 
   const envText = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
   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;
 const SKIP_CHAT_2FA = !!process.env.SKIP_CHAT_2FA;
 
 
 export async function refresh(mode: string, userDriven = false): Promise<CredentialsPayload> {
 export async function refresh(mode: string, userDriven = false): Promise<CredentialsPayload> {
-  const profile = profileDir('booking', mode);
+  const profile = profileDir('booking');
   fs.mkdirSync(profile, { recursive: true });
   fs.mkdirSync(profile, { recursive: true });
 
 
   const envText = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
   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');
   if (!baseUrl) throw new Error('ROOM_CAL_API_URL not set');
   const url = `${baseUrl.replace(/\/$/, '')}/credentials/${service}`;
   const url = `${baseUrl.replace(/\/$/, '')}/credentials/${service}`;
   console.error(`[${service}] PUT ${url} ...`);
   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, {
   const res = await fetch(url, {
     method: 'PUT',
     method: 'PUT',
-    headers: { 'Content-Type': 'application/json' },
+    headers,
     body: JSON.stringify(credentials),
     body: JSON.stringify(credentials),
   });
   });
   if (!res.ok) {
   if (!res.ok) {
@@ -46,6 +49,15 @@ export async function putCredentials(service: string, credentials: CredentialsPa
   console.error(`[${service}] PUT ${res.status} OK`);
   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(
 export async function handleOutput(
   service: string,
   service: string,
   credentials: CredentialsPayload,
   credentials: CredentialsPayload,
@@ -55,14 +67,15 @@ export async function handleOutput(
   if (mode === 'stdout') {
   if (mode === 'stdout') {
     process.stdout.write(JSON.stringify(credentials) + '\n');
     process.stdout.write(JSON.stringify(credentials) + '\n');
   } else if (mode === 'api') {
   } 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');
 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> {
 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 {
 export function parseOutputMode(argv: string[]): OutputMode {
   const arg = argv.find(a => a.startsWith('--output='));
   const arg = argv.find(a => a.startsWith('--output='));
   const mode = arg?.split('=')[1];
   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 {
 export function parseUserDriven(argv: string[]): boolean {