mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:27:14 +01:00
Two things the pool needed to be real rather than merely correct.
**freedays no longer depends on gatelock.** It imported
gatelock.log_integrity for ~15 lines of HMAC, but importing gatelock runs
its __init__, which imports tkinter for the lock-window machinery. So a
headless policy library had acquired a hard dependency on a GUI toolkit,
and `import freedays` failed anywhere without Tk:
ImportError: libtk8.6.so: cannot open shared object file
A vmbox run caught it. The host masked it completely -- Tk is installed
here for the lockers -- and so did CI, whose own comment claimed "no
display needed" while the job would have needed libtk. freedays/_hmac.py
now does the signing with hashlib and hmac.
The format is deliberately unchanged (HMAC-SHA256 over compact
sorted-key JSON), so nothing has to be re-signed: all 8 entries already
in the real audit trail, written by gatelock's implementation, verify
against the new one. That was checked, not assumed.
This also dissolves the version diamond that shaped the app rollout:
freedays now pins only crdt-sync, so it can never conflict with whatever
gatelock tag an app carries.
**The pool now syncs on a schedule.** A `sync-quiet` subcommand plus a
systemd user timer every 15 minutes, because "it syncs" was otherwise
only true if you typed `freedays sync` yourself -- a day marked on the
phone would sit there. sync-quiet exits 0 when there is no network or no
credential, on purpose: a unit that goes red every time the laptop is
offline is a unit you learn to ignore, and a real bug still reaches the
journal through the library's logging.
install.sh gives freedays its own venv rather than borrowing an app's --
the pool is shared by five apps, so binding its CLI to one of them would
make "is freedays installed?" depend on something unrelated, and
uninstalling that app would take the command with it.
Verified in vmbox first (systemd), then on this host: the timer is
enabled and scheduled, and the service *finishes* rather than failing
when there is no remote.
seed_session.py gains freedays, without which the one command that can
give the pool a Firebase session would reject it outright (--app is
choices-validated) and the pool would stay local-only with nothing
saying why. That file was already exactly at the 250-line cap, so the
app list and its rationale move to tool/_seeded_apps.py.
freedays: 109 tests, 100% branch coverage. crdt-sync: 433 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
8.9 KiB
Python
222 lines
8.9 KiB
Python
r"""Reseed each app's desktop Firebase session by signing in with Google.
|
|
|
|
The sync account moved to Google-only sign-in, so the password flow that used
|
|
to fill ``~/.config/<app>/firebase_auth.json`` no longer works and every
|
|
desktop refresh token eventually expires. Headless jobs (systemd timers, the
|
|
sync CLIs) cannot run a browser flow themselves, so this runs it once,
|
|
interactively, and stores the refresh token each app then lives on.
|
|
|
|
Deliberately *not* a service-account key. That authenticates as an admin and
|
|
bypasses the security rules, so the pinned-uid rule protecting this data would
|
|
stop applying to every PC write.
|
|
|
|
Run from the repo root, as a module -- ``python3 tool/seed_session.py`` fails
|
|
with ``No module named 'tool'``, because that puts ``tool/`` on the path
|
|
instead of the root this imports ``tool.google_id_token`` from::
|
|
|
|
python3 -m tool.seed_session --client-id <web-client-id> \\
|
|
--client-secret <web-client-secret>
|
|
|
|
# Reseed a subset instead of every app:
|
|
python3 -m tool.seed_session --client-id ... --client-secret ... \\
|
|
--app diet_guard --app wake_alarm
|
|
|
|
One consent tap covers every app: the Google ID token is fetched once and
|
|
exchanged once per app, because ``credential_store_for`` keeps a separate file
|
|
per app on purpose (independent refresh schedules, no concurrent writers).
|
|
|
|
The client id and secret come from the **Web application** OAuth client in the
|
|
project's Google Cloud console -- the same pair ``google_id_token.py`` takes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import sys
|
|
|
|
from crdt_sync._config import ConfigError, FirebaseConfig, credential_store_for
|
|
from crdt_sync._firebase import FirebaseSyncClient
|
|
from crdt_sync._firebase_auth import FirebaseAuthError, FirebaseTokenProvider
|
|
from crdt_sync._remote import RemoteSyncError
|
|
|
|
# Importable as a package module (tool/__init__.py exists) so the loopback
|
|
# OAuth flow is shared, not reimplemented.
|
|
from tool._seeded_apps import DEFAULT_APPS
|
|
from tool.google_id_token import TokenError, fetch_id_token
|
|
|
|
|
|
# A fixed default so the redirect URI is stable enough to register once on the
|
|
# Web client. Arbitrary but out of the ephemeral range, and free on this box.
|
|
_DEFAULT_REDIRECT_PORT = 8765
|
|
|
|
_logger = logging.getLogger("seed_session")
|
|
|
|
|
|
class SeedError(Exception):
|
|
"""A session was stored but could not actually reach the database.
|
|
|
|
Carries the apps already finished when it was raised, so a partially
|
|
completed run can report what is left rather than leaving the state to be
|
|
reconstructed by hand.
|
|
"""
|
|
|
|
def __init__(self, message: str, done: tuple[str, ...] = ()) -> None:
|
|
"""Record ``message`` and the apps that verified before the failure."""
|
|
super().__init__(message)
|
|
self.done = done
|
|
|
|
|
|
def seed_apps(
|
|
config: FirebaseConfig,
|
|
google_id_token: str,
|
|
app_names: tuple[str, ...],
|
|
) -> list[str]:
|
|
"""Store a Google-backed session for each app. Returns the apps seeded.
|
|
|
|
Each app gets its own exchange because each has its own credential file;
|
|
a single session copied across them would have them all refreshing the
|
|
same rotated token and invalidating each other.
|
|
|
|
Each session is then proved with a real authenticated read, because a
|
|
written credential file is not evidence of working sync: the whole reason
|
|
this script exists is that the files were present and the tokens dead.
|
|
Firebase may also invalidate an earlier refresh token when a new session
|
|
is minted for the same uid, which would leave every app but the last one
|
|
broken -- a failure only a per-app read can see.
|
|
|
|
Args:
|
|
config: The shared Firebase config, already loaded.
|
|
google_id_token: An ID token from Google's OAuth flow.
|
|
app_names: The apps whose credential caches to fill.
|
|
|
|
Returns:
|
|
The app names seeded, in the order given.
|
|
|
|
Raises:
|
|
FirebaseAuthError: If Firebase rejects the token, or it resolves to a
|
|
uid other than the pinned one. Raised on the first failure, so a
|
|
wrong account cannot be written to any app.
|
|
SeedError: If a seeded session cannot actually read the database.
|
|
"""
|
|
seeded: list[str] = []
|
|
for app_name in app_names:
|
|
provider = FirebaseTokenProvider(
|
|
config.api_key,
|
|
credential_store_for(app_name),
|
|
)
|
|
email = provider.sign_in_with_google(
|
|
google_id_token,
|
|
expected_uid=config.uid,
|
|
)
|
|
seeded.append(app_name)
|
|
_logger.info(" .. %s seeded as %s", app_name, email or config.email)
|
|
|
|
# Verified in a second pass, after every session exists: checking each one
|
|
# as it is written would pass an app that a later exchange then
|
|
# invalidates, which is precisely the failure being guarded against.
|
|
verified: list[str] = []
|
|
for app_name in seeded:
|
|
client = FirebaseSyncClient(
|
|
config.database_url,
|
|
FirebaseTokenProvider(config.api_key, credential_store_for(app_name)),
|
|
)
|
|
# list_directory rather than can_access_remote(): the latter collapses
|
|
# rules-denied, dead-token and network-down into one False, and a gate
|
|
# whose whole job is saying what broke should not throw that away.
|
|
# Both read the database root, which is where the rules grant .read.
|
|
try:
|
|
client.list_directory("")
|
|
except (RemoteSyncError, FirebaseAuthError) as exc:
|
|
msg = (
|
|
f"{app_name} was seeded but cannot read the database ({exc}). "
|
|
"Its session is not usable, so sync would keep failing "
|
|
"silently."
|
|
)
|
|
raise SeedError(msg, tuple(verified)) from exc
|
|
verified.append(app_name)
|
|
_logger.info(" .. %s verified: authenticated read succeeded", app_name)
|
|
return verified
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Run the consent flow and seed every app. Returns an exit code."""
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--client-id", required=True, help="Web OAuth client id.")
|
|
parser.add_argument(
|
|
"--client-secret",
|
|
required=True,
|
|
help="Web OAuth client secret.",
|
|
)
|
|
parser.add_argument(
|
|
"--app",
|
|
action="append",
|
|
dest="apps",
|
|
# Constrained rather than free-form: a typo like "diet-guard" would
|
|
# otherwise create ~/.config/diet-guard/, verify against it happily,
|
|
# and report PASS while the real diet_guard stayed dead. The stray
|
|
# directories already in ~/.config are what that looks like.
|
|
choices=DEFAULT_APPS,
|
|
help=(
|
|
"Seed only this app; repeatable. Defaults to all of: "
|
|
+ ", ".join(DEFAULT_APPS)
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--no-browser",
|
|
action="store_true",
|
|
help="Print the consent URL without launching a browser.",
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=_DEFAULT_REDIRECT_PORT,
|
|
help=(
|
|
"Loopback port for the OAuth redirect. Must be registered on the "
|
|
"Web client as http://localhost:<port>, exactly -- a Web client "
|
|
"does not accept arbitrary loopback ports the way a Desktop "
|
|
"client does."
|
|
),
|
|
)
|
|
args = parser.parse_args(argv)
|
|
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
|
|
|
|
app_names = tuple(args.apps) if args.apps else DEFAULT_APPS
|
|
|
|
# Caught by name rather than with a bare `except Exception` so an
|
|
# unanticipated bug still surfaces as a traceback instead of a tidy
|
|
# "FAIL" line that hides it.
|
|
try:
|
|
config = FirebaseConfig.load()
|
|
_logger.info(" .. seeding %s", ", ".join(app_names))
|
|
_logger.info(" .. expecting uid %s (%s)", config.uid, config.email)
|
|
token = fetch_id_token(
|
|
args.client_id,
|
|
args.client_secret,
|
|
open_browser=not args.no_browser,
|
|
port=args.port,
|
|
)
|
|
seeded = seed_apps(config, token, app_names)
|
|
except (ConfigError, FirebaseAuthError, TokenError, SeedError) as exc:
|
|
# logging.exception already renders the exception and its traceback.
|
|
_logger.exception(" FAIL")
|
|
# A run that dies partway leaves some apps working and some not.
|
|
# Saying which is the difference between rerunning the ones that need
|
|
# it and a hunt through ~/.config for what actually happened.
|
|
done = exc.done if isinstance(exc, SeedError) else ()
|
|
remaining = [name for name in app_names if name not in done]
|
|
if done:
|
|
_logger.exception(" .. already working: %s", ", ".join(done))
|
|
_logger.exception(
|
|
" .. rerun with: %s",
|
|
" ".join(f"--app {name}" for name in remaining),
|
|
)
|
|
return 1
|
|
|
|
_logger.info(" PASS %d desktop session(s) seeded and read-verified", len(seeded))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|