diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c35b4a..ed4eced 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,9 @@ -name: CI, release and publish +name: Test -# Tests run on every push (any branch) and every PR. Only a push to `main` -# can ever produce a release: python-semantic-release inspects the -# Conventional Commits since the last vX.Y.Z tag, and if they warrant a -# release it bumps waypoint/__init__.py, updates CHANGELOG.md, and pushes a -# new tag + GitHub Release. The multi-arch Docker image is only built and -# published to GHCR when that actually happened -- pushes to `develop` (or -# any other branch) and PRs only ever run the test job. +# Runs the test suite on every push (any branch) and every PR. Versioning +# and publishing are handled by the separate release-please.yml and +# publish.yml workflows -- this one only ever answers "does the test suite +# pass right now?". on: push: @@ -17,10 +14,6 @@ on: permissions: contents: read -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - jobs: test: name: Run test suite @@ -37,76 +30,3 @@ jobs: - name: Run pytest run: python -m pytest -q - - release: - name: Determine next version - needs: test - # Only main can cut a release -- develop/PRs never reach this job. - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - runs-on: ubuntu-latest - permissions: - contents: write - outputs: - released: ${{ steps.release.outputs.released }} - tag: ${{ steps.release.outputs.tag }} - version: ${{ steps.release.outputs.version }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Python Semantic Release - id: release - uses: python-semantic-release/python-semantic-release@v9 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - - build: - name: Build and push image - needs: [test, release] - # Only runs off the back of an actual release just cut above -- - # i.e. only when main was pushed to *and* semantic-release decided the - # accumulated commits warrant a new version. - if: needs.release.outputs.released == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.release.outputs.tag }} - - - name: Set up QEMU (for linux/arm64) - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract image metadata (tags + OCI labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=semver,pattern={{version}},value=${{ needs.release.outputs.tag }} - type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.tag }} - type=raw,value=latest - - - name: Build and push image - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a8850af --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,69 @@ +name: Build and publish Docker image + +# Fires only on a vX.Y.Z tag -- which only ever exists once release-please +# (see release-please.yml) has cut an actual release by merging its release +# PR. A plain push to main/develop never reaches this workflow at all, so +# the multi-arch build only ever runs for a real release. + +on: + push: + tags: ["v*.*.*"] + workflow_dispatch: + +permissions: + contents: read + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + name: Build and push image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU (for linux/arm64) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # docker/metadata-action reads the semver straight off the triggering + # tag ref (github.ref), so no manual version wiring is needed here -- + # unlike the previous single-workflow setup where the build job had to + # check out a tag computed by an earlier job in the same run. + - name: Extract image metadata (tags + OCI labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + # Build always runs as a "does the Dockerfile still build (and pass + # its embedded test stage)?" check; push is unconditional here since + # this workflow itself only ever runs for a real vX.Y.Z tag. + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..cb6399b --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,25 @@ +name: Release Please + +# Watches pushes to main and maintains a standing "release PR" that +# accumulates Conventional Commits into the next semantic version + +# CHANGELOG.md entry. Nothing is tagged or released automatically -- only +# merging that PR (a normal, reviewable PR merge, not a bot pushing straight +# to main) makes release-please cut the vX.Y.Z tag + GitHub Release. That +# tag push is what publish.yml reacts to. + +on: + push: + branches: ["main"] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d1a18..5cd2541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # Changelog All notable changes to this project are documented in this file, generated automatically by -[python-semantic-release](https://python-semantic-release.readthedocs.io/) from +[Release Please](https://github.com/googleapis/release-please) from [Conventional Commits](https://www.conventionalcommits.org/) merged into `main`. diff --git a/CLAUDE.md b/CLAUDE.md index 3de979b..6f3faed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,14 +107,21 @@ the test stage, never test files themselves). `HEALTHCHECK` hits `/healthz` with ### Branching, versioning & release pipeline `develop` is the integration branch (feature branches → PR → `develop`); `main` only moves via a -PR from `develop`. `.github/workflows/ci.yml` runs the `test` job on every push/PR regardless of -branch, but the `release` (python-semantic-release, `pyproject.toml`'s `[tool.semantic_release]`) -and `build` (multi-arch Docker build + push to GHCR) jobs are gated on `github.ref == -'refs/heads/main'` and on `release` actually having cut a new version, respectively — so pushing to -`develop` never builds or publishes anything. The version lives in `waypoint/__init__.py` -(`__version__`) and is bumped automatically from Conventional Commit prefixes (`fix:`, `feat:`, -`BREAKING CHANGE:`) since the last `vX.Y.Z` tag; don't hand-edit it or hand-create release tags — -that's what causes the version and the Docker tags to drift apart. +PR from `develop`. Three separate workflows split the concerns: + +- `.github/workflows/ci.yml` — the `test` job, on every push (`main`/`develop`) and every PR. +- `.github/workflows/release-please.yml` — runs + [Release Please](https://github.com/googleapis/release-please) (config: + `release-please-config.json` + `.release-please-manifest.json`) on every push to `main`. It never + pushes to `main` directly: it maintains a standing release PR (version bump + `CHANGELOG.md`) + from Conventional Commits (`fix:`, `feat:`, `BREAKING CHANGE:`) and only cuts the `vX.Y.Z` tag + + GitHub Release once that PR is actually merged. +- `.github/workflows/publish.yml` — triggers only on a `vX.Y.Z` tag push (i.e. only once + release-please has cut a release) and does the multi-arch Docker build + push to GHCR. + +The version lives in `waypoint/__init__.py` (`__version__`, marked with a trailing +`# x-release-please-version` comment that release-please's generic file updater matches on) — don't +hand-edit it or hand-create release tags, or the version, the tag, and `CHANGELOG.md` drift apart. ## Invariants to preserve when editing @@ -134,3 +141,19 @@ that's what causes the version and the Docker tags to drift apart. - `probe_iframe_embeddable` must never raise and must default to `False` (redirect) on any ambiguity (network error, timeout, non-2xx, unparseable header) — the redirect page is the one path that always works, so failure must never accidentally select the iframe path instead. +- `render_page` must keep escaping `link` for HTML-attribute contexts (`html.escape(..., quote=True)` + → `link_attr`) and JSON-encoding it for the inline-JS context (`json.dumps` → `link_js`) — never + interpolate the raw string again. The admin override isn't guaranteed to match + `LIVETRACK_LINK_RE`, so this is the only thing standing between an admin-set value containing + quotes/`<>` and breaking out of an attribute or the JS string literal. +- `/admin/link` must keep rejecting (`400`) any submitted link that doesn't fullmatch + `LIVETRACK_LINK_RE` before it ever reaches `state.set_link`/`probe()` — this is what keeps the + admin override from being usable to probe/redirect to arbitrary internal URLs. +- `_check_auth` must keep using `secrets.compare_digest`, not `==`, for both the username and + password comparison. +- `__main__.py` starts the server via `waitress.create_server(...)` + `server.run()`, not the + `serve()` shortcut — the signal handler needs a `server` handle to call `.close()` on. Without + it, SIGTERM is silently swallowed by `handle_signal` and the container only stops on Docker's + SIGKILL, skipping `EmailMonitor`'s clean IMAP logout. +- The `Dockerfile`'s `HEALTHCHECK` reads `WEB_PORT` from the environment at check time (defaulting + to `8080`) rather than hard-coding a port — keep it that way if `WEB_PORT` stays configurable. diff --git a/Dockerfile b/Dockerfile index 0c1864b..0bb6b9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,10 @@ USER waypoint EXPOSE 8080 VOLUME ["/data"] +# Reads WEB_PORT at check time (falling back to 8080) rather than +# hard-coding it, since the CMD's JSON form doesn't get shell/env +# substitution -- only the python process it execs does. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD ["python", "-c", "import sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).status == 200 else 1)"] + CMD ["python", "-c", "import os,sys,urllib.request; port=os.environ.get('WEB_PORT','8080'); sys.exit(0 if urllib.request.urlopen(f'http://127.0.0.1:{port}/healthz', timeout=3).status == 200 else 1)"] CMD ["python", "-m", "waypoint"] diff --git a/README.md b/README.md index c6bbba6..1005bec 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,9 @@ At `/admin` (Basic Auth): - **Status dashboard** — current link, when it was last updated, IMAP connection status, last error. - **Manual override** — set the current link directly, or clear it (e.g. to show the "offline" - page when no activity is live). + page when no activity is live). The link must match the same + `https://livetrack.garmin.com/session/.../token/...` shape as an email-extracted one (`400` on + anything else). - **Recent history** — the last 20 links that were published, with timestamp and source (`email` or `admin`), including whether each was served as an iframe or a redirect. @@ -175,17 +177,20 @@ and the web routes (via Flask's test client) — all without needing a real IMAP ### Branching and releases - `develop` is the integration branch — feature branches and day-to-day work target `develop` via - PR. Pushes here (and PRs) only run the test suite; nothing gets built or published. -- `main` only ever moves forward via a PR from `develop`. A push to `main` additionally runs - [python-semantic-release](https://python-semantic-release.readthedocs.io/), which inspects the - [Conventional Commits](https://www.conventionalcommits.org/) merged in since the last `vX.Y.Z` - tag and, if they warrant a release, bumps `waypoint/__init__.py`, updates `CHANGELOG.md`, and - pushes the new tag + GitHub Release. -- Only that release step (never a plain push) triggers the multi-arch Docker build and publish to - GHCR — see `.github/workflows/ci.yml`. -- Commit messages therefore need a Conventional Commits prefix for the release step to pick them - up correctly: `fix:` → patch, `feat:` → minor, `BREAKING CHANGE:` (in the footer) → major. - Anything else (`chore:`, `docs:`, `test:`, ...) is ignored for versioning purposes. + PR. Pushes here (and PRs) only run the test suite (`.github/workflows/ci.yml`); nothing gets + built or released. +- `main` only ever moves forward via a PR from `develop`. Every push to `main` runs + [Release Please](https://github.com/googleapis/release-please) + (`.github/workflows/release-please.yml`), which inspects the + [Conventional Commits](https://www.conventionalcommits.org/) merged in since the last release and + keeps a standing **release PR** up to date with the accumulated version bump and `CHANGELOG.md` + entry — it never pushes to `main` directly. +- Merging that release PR (like any other PR, via review) is what actually cuts the `vX.Y.Z` tag + + GitHub Release. Only that tag push triggers `.github/workflows/publish.yml`, which builds the + multi-arch image and publishes it to GHCR — a plain commit landing on `main` never does. +- Commit messages need a Conventional Commits prefix for Release Please to pick them up correctly: + `fix:` → patch, `feat:` → minor, `BREAKING CHANGE:` (in the footer) → major. Anything else + (`chore:`, `docs:`, `test:`, ...) is included in the changelog but doesn't bump the version. ## Troubleshooting diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 7c9435c..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,16 +0,0 @@ -[tool.semantic_release] -version_variables = ["waypoint/__init__.py:__version__"] -tag_format = "v{version}" -commit_message = "chore(release): v{version} [skip ci]" -build_command = "" -major_on_zero = false - -[tool.semantic_release.branches.main] -match = "main" -prerelease = false - -[tool.semantic_release.changelog] -changelog_file = "CHANGELOG.md" - -[tool.semantic_release.remote] -type = "github" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..297d5a4 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "packages": { + ".": { + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "generic", + "path": "waypoint/__init__.py" + } + ] + } + } +} diff --git a/tests/test_render_page.py b/tests/test_render_page.py index 6354c6d..c5624b1 100644 --- a/tests/test_render_page.py +++ b/tests/test_render_page.py @@ -1,12 +1,19 @@ """Tests for the pure HTML renderer in waypoint.web.""" import datetime as dt +import json -from waypoint.web import render_page +from waypoint.web import OFFLINE_REFRESH_SECONDS, render_page LINK = "https://livetrack.garmin.com/session/xxxx/token/YYYY" NOW = dt.datetime(2026, 7, 9, 12, 0, 0) +# Not something the email extractor or the (validating) admin route would +# ever hand render_page, but render_page itself must still neutralize it -- +# defense in depth, since it's a pure function with no knowledge of the +# caller. +MALICIOUS_LINK = 'https://evil.example/">' + def test_redirect_page_contains_link_meta_refresh_and_countdown(): html = render_page(LINK, use_iframe=False, countdown=15, now=NOW) @@ -27,6 +34,34 @@ def test_offline_page_when_no_link_is_available(): html = render_page(None, use_iframe=False, countdown=10, now=NOW) assert LINK not in html - assert "http-equiv=\"refresh\"" not in html # Some human-readable indication that there is currently no active session. assert "no active" in html.lower() or "offline" in html.lower() + + +def test_offline_page_polls_for_a_session_starting(): + # The copy promises this page updates itself once a session goes live -- + # it needs an actual refresh mechanism to back that up, otherwise a + # viewer who opens the link early is stuck until they manually reload. + html = render_page(None, use_iframe=False, countdown=10, now=NOW) + + assert f'' in html + + +def test_iframe_page_escapes_link_in_html_attributes(): + html = render_page(MALICIOUS_LINK, use_iframe=True, countdown=10, now=NOW) + + assert "" not in html + assert "<script>" in html + + +def test_redirect_page_escapes_link_in_meta_and_encodes_in_js(): + html = render_page(MALICIOUS_LINK, use_iframe=False, countdown=10, now=NOW) + + assert "" not in html + assert "<script>" in html + # Plain json.dumps alone would still leak the literal "" into + # the page, which closes the surrounding " in json.dumps(MALICIOUS_LINK) # sanity: the raw case would be unsafe + assert "\\u003cscript\\u003e" in html diff --git a/tests/test_web.py b/tests/test_web.py index 97dda96..ce60b34 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -145,6 +145,28 @@ def test_admin_set_link_probes_embeddability(config, state): assert state.iframe_ok is False +def test_admin_set_link_rejects_non_livetrack_url(client, state): + resp = client.post( + "/admin/link", + data={"link": "javascript:alert(1)"}, + headers=_basic_auth_header("admin", "s3cret"), + ) + + assert resp.status_code == 400 + assert state.current_link is None + + +def test_admin_set_link_rejects_non_garmin_domain(client, state): + resp = client.post( + "/admin/link", + data={"link": "https://evil.example/session/a/token/1"}, + headers=_basic_auth_header("admin", "s3cret"), + ) + + assert resp.status_code == 400 + assert state.current_link is None + + def test_admin_can_clear_link(client, state): state.set_link("https://livetrack.garmin.com/session/c/token/3", source="email", iframe_ok=True) diff --git a/waypoint/__init__.py b/waypoint/__init__.py index dcd58ec..9150542 100644 --- a/waypoint/__init__.py +++ b/waypoint/__init__.py @@ -1,6 +1,7 @@ """Garmin LiveTrack monitor: watches an inbox for LiveTrack emails and serves the current session link as a small self-contained web service.""" -# Bumped automatically by python-semantic-release based on Conventional -# Commits (fix:/feat:/BREAKING CHANGE) merged into main -- don't hand-edit. -__version__ = "0.1.0" +# Bumped automatically by Release Please (release-please-config.json) when a +# release PR is merged into main -- don't hand-edit. The trailing comment is +# a marker release-please's "generic" file updater looks for. +__version__ = "0.1.0" # x-release-please-version diff --git a/waypoint/__main__.py b/waypoint/__main__.py index bb7ebd6..615a5a9 100644 --- a/waypoint/__main__.py +++ b/waypoint/__main__.py @@ -11,7 +11,7 @@ import threading from dotenv import load_dotenv -from waitress import serve +from waitress import create_server from .config import Config from .email_monitor import EmailMonitor @@ -47,9 +47,20 @@ def main() -> None: monitor = EmailMonitor(config, state) + app = create_app(config, state) + # create_server (rather than the serve() shortcut) gives us a handle to + # close from the signal handler -- serve() blocks forever and installs + # no signal handling of its own, so without this, SIGTERM would just get + # silently overridden by handle_signal below and the container would + # sit until Docker's SIGKILL, skipping the monitor's clean IMAP logout. + server = create_server(app, host="0.0.0.0", port=config.web_port) + shutting_down = threading.Event() + def handle_signal(signum, frame): log.info("Shutting down Garmin LiveTrack...") monitor.stop() + shutting_down.set() + server.close() signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGTERM, handle_signal) @@ -57,9 +68,22 @@ def handle_signal(signum, frame): monitor_thread = threading.Thread(target=monitor.run, name="email-monitor", daemon=True) monitor_thread.start() - app = create_app(config, state) log.info(f"Starting web server on port {config.web_port}...") - serve(app, host="0.0.0.0", port=config.web_port) + try: + server.run() + except OSError: + # server.close() (above) closes the listening socket while + # server.run()'s select() loop may be blocked on it -- waitress + # surfaces that as a "Bad file descriptor" OSError rather than a + # clean return. That's expected once we've asked for a shutdown; + # anything else is a real failure and should still propagate. + if not shutting_down.is_set(): + raise + log.info("Web server stopped.") + finally: + # Give the monitor thread a chance to finish process_new_emails() + # and log out of IMAP cleanly before the process exits. + monitor_thread.join(timeout=15) if __name__ == "__main__": diff --git a/waypoint/web.py b/waypoint/web.py index 7a5f5c7..3002ff6 100644 --- a/waypoint/web.py +++ b/waypoint/web.py @@ -7,6 +7,8 @@ """ import functools +import json +import secrets from datetime import datetime, timezone from html import escape from typing import Callable, Optional @@ -14,9 +16,29 @@ from flask import Flask, Response, abort, jsonify, redirect, request, url_for from .config import Config +from .email_monitor import LIVETRACK_LINK_RE from .link_probe import probe_iframe_embeddable from .state import AppState +# How often the "no active session" placeholder polls for a session having +# started, so a viewer who opens the shared link early doesn't have to +# manually reload once one goes live. +OFFLINE_REFRESH_SECONDS = 30 + + +def _js_string_literal(value: str) -> str: + """JSON-encode `value` for safe embedding inside an inline " would close the surrounding + - +

Garmin LiveTrack

The LiveTrack session cannot be embedded directly.

-

Open LiveTrack in a new tab

+

Open LiveTrack in a new tab

Last updated: {timestamp}

@@ -97,7 +129,7 @@ def render_page(link: Optional[str], *, use_iframe: bool, countdown: int, now: d Garmin LiveTrack - Redirecting - +