diff --git a/.env.example b/.env.example index 1ad6028..6e71c5d 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,19 @@ # Registered Netatmo application: GetNetatmoData v2 -NETATMO_CLIENT_ID=67d5431a2b6d32c9ba066152 -NETATMO_CLIENT_SECRET=hT10jvt6vQs1V7lKicFr7LDIbv8 +# Mount every route below this path. Leave empty to serve from /. +URL_PREFIX=/w + +NETATMO_CLIENT_ID=6a93db3d7cdb0e40ea0c2655 +NETATMO_CLIENT_SECRET=c6uRbAzZ9e8STPtXas6PYKaZZiD68Y6QHFaKq2GEIa8 NETATMO_REFRESH_TOKEN= +# Rotated OAuth tokens are persisted here with mode 0600. +NETATMO_TOKEN_FILE=/var/lib/rrd/netatmo_tokens.json +# Must exactly match a redirect URI configured for the Netatmo application. +NETATMO_REDIRECT_URI=http://www.suy.nl # Alternatively, useful for a short-lived test (refresh credentials are preferred): # NETATMO_ACCESS_TOKEN= # NETATMO_DEVICE_ID= -RRD_FOLDER=./rrd +RRD_FOLDER=/var/lib/rrd POLL_INTERVAL=600 START_COLLECTOR=true @@ -16,4 +23,3 @@ MODULE_WIND=Wind MODULE_BEDROOM=Bedroom MODULE_STUDY=Study MODULE_LIVING=Living - diff --git a/.gitignore b/.gitignore index 2ad2f35..a593af5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ __pycache__/ .pytest_cache/ .env rrd/ - +netatmo_tokens.json diff --git a/README.md b/README.md index fcd71ca..1358dbd 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,26 @@ cp .env.example .env ``` Add the client ID and secret for the Netatmo application **GetNetatmoData v2** -and a refresh token to `.env`. Netatmo's OAuth authorization step must be used to -obtain the initial refresh token with the `read_station` scope. Secrets are read -from environment variables and are never stored in an RRD. +to `.env`. Ensure `NETATMO_REDIRECT_URI` exactly matches a redirect URI configured +for that application, then perform the initial `read_station` authorization: + +```sh +set -a +. ./.env +set +a +.venv/bin/python scripts/get_netatmo_token.py +``` + +The helper opens Netatmo's consent page. After approval, paste the complete URL +from the browser's address bar back into the prompt. This works even if the local +callback page itself cannot be reached. The script exchanges the authorization +code and writes the token file with mode `0600`. + +The service loads `NETATMO_TOKEN_FILE` at startup. Before an access token expires, +it refreshes it and atomically saves both the new access token and any rotated +refresh token. Values in the token file take precedence over initial token values +in `.env`. Client credentials remain environment-only and are never written to +the token file or an RRD. Export the file and run Flask: @@ -53,6 +70,9 @@ scheduler runs inside the service process. Alternatively set `RRD_FOLDER` configures the database directory (default `./rrd`). The module variables in `.env.example` allow the five Netatmo display names to be changed. +Set `URL_PREFIX=/w` to mount the dashboard, static assets, and every API endpoint +below `/w`; leave it empty to serve from the site root. When a prefix is set, +open instead. ## HTTP API diff --git a/netatmo_service/app.py b/netatmo_service/app.py index 516be5e..220d4ed 100644 --- a/netatmo_service/app.py +++ b/netatmo_service/app.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import os -from flask import Flask, Response, abort, jsonify, render_template +from flask import Flask, Response, abort, jsonify, render_template, url_for from .collector import Collector from .config import Config @@ -11,12 +11,34 @@ from .netatmo import NetatmoClient from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS +class PrefixMiddleware: + """Mount a WSGI application below a fixed URL path.""" + + def __init__(self, application, prefix: str): + self.application = application + self.prefix = prefix + + def __call__(self, environ, start_response): + path = environ.get("PATH_INFO", "") + if path == self.prefix or path.startswith(f"{self.prefix}/"): + environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + self.prefix + environ["PATH_INFO"] = path[len(self.prefix):] or "/" + return self.application(environ, start_response) + start_response("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")]) + return [b"Not Found\n"] + + def create_app(test_config: dict | None = None) -> Flask: app = Flask(__name__) app.config.from_object(Config) if test_config: app.config.update(test_config) + prefix = app.config["URL_PREFIX"] + app.config["APPLICATION_ROOT"] = prefix or "/" + if prefix: + app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix) + logging.basicConfig(level=app.config.get("LOG_LEVEL", "INFO")) store = app.config.get("RRD_STORE") or RRDStore( app.config["RRD_FOLDER"], app.config["GRAPH_WIDTH"], app.config["GRAPH_HEIGHT"] @@ -37,7 +59,12 @@ def create_app(test_config: dict | None = None) -> Flask: @app.get("/") def dashboard(): - return render_template("dashboard.html", rrd_names=list(SCHEMAS), periods=list(PERIODS)) + return render_template( + "dashboard.html", + rrd_names=list(SCHEMAS), + periods=list(PERIODS), + graph_url_template=url_for("graph", rrd_name="__name__", period="__period__"), + ) @app.get("/health") def health(): diff --git a/netatmo_service/config.py b/netatmo_service/config.py index 938031c..bf0f2d4 100644 --- a/netatmo_service/config.py +++ b/netatmo_service/config.py @@ -9,8 +9,19 @@ def _bool(name: str, default: bool) -> bool: return default if value is None else value.lower() in {"1", "true", "yes", "on"} +def _prefix(value: str) -> str: + value = value.strip() + if not value or value == "/": + return "" + return "/" + value.strip("/") + + class Config: + URL_PREFIX = _prefix(os.getenv("URL_PREFIX", "")) RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().resolve() + NETATMO_TOKEN_FILE = Path( + os.getenv("NETATMO_TOKEN_FILE", str(RRD_FOLDER / "netatmo_tokens.json")) + ).expanduser().resolve() GRAPH_WIDTH = int(os.getenv("GRAPH_WIDTH", "900")) GRAPH_HEIGHT = int(os.getenv("GRAPH_HEIGHT", "240")) POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "600")) diff --git a/netatmo_service/netatmo.py b/netatmo_service/netatmo.py index c67ce3f..917babb 100644 --- a/netatmo_service/netatmo.py +++ b/netatmo_service/netatmo.py @@ -2,6 +2,9 @@ from __future__ import annotations import json import logging +import os +from pathlib import Path +import tempfile import time import urllib.error import urllib.parse @@ -14,6 +17,43 @@ class NetatmoError(RuntimeError): pass +class TokenStore: + """Persist OAuth tokens atomically in a file readable only by its owner.""" + + def __init__(self, path: Path | str): + self.path = Path(path) + + def load(self) -> dict: + if not self.path.exists(): + return {} + try: + with self.path.open(encoding="utf-8") as token_file: + data = json.load(token_file) + except (OSError, ValueError) as exc: + raise NetatmoError(f"Cannot read token file {self.path}: {exc}") from exc + return data if isinstance(data, dict) else {} + + def save(self, tokens: dict) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{self.path.name}.", dir=self.path.parent + ) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as token_file: + json.dump(tokens, token_file, indent=2, sort_keys=True) + token_file.write("\n") + token_file.flush() + os.fsync(token_file.fileno()) + os.replace(temporary_name, self.path) + except Exception: + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + raise + + class NetatmoClient: """Small dependency-free Netatmo OAuth and Weather API client.""" @@ -22,10 +62,14 @@ class NetatmoClient: self.client_secret = config["NETATMO_CLIENT_SECRET"] self.refresh_token = config["NETATMO_REFRESH_TOKEN"] self.access_token = config["NETATMO_ACCESS_TOKEN"] + self.token_store = TokenStore(config["NETATMO_TOKEN_FILE"]) self.device_id = config["NETATMO_DEVICE_ID"] self.token_url = config["NETATMO_TOKEN_URL"] self.stations_url = config["NETATMO_STATIONS_URL"] - self._expires_at = 0.0 + stored = self.token_store.load() + self.access_token = stored.get("access_token", self.access_token) + self.refresh_token = stored.get("refresh_token", self.refresh_token) + self._expires_at = float(stored.get("expires_at", 0)) @property def configured(self) -> bool: @@ -49,10 +93,37 @@ class NetatmoClient: } ).encode() result = self._request(urllib.request.Request(self.token_url, data=data, method="POST")) + self._accept_tokens(result) + + def exchange_code(self, code: str, redirect_uri: str) -> dict: + """Exchange a first-time OAuth authorization code and persist its tokens.""" + data = urllib.parse.urlencode( + { + "grant_type": "authorization_code", + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "redirect_uri": redirect_uri, + } + ).encode() + result = self._request(urllib.request.Request(self.token_url, data=data, method="POST")) + self._accept_tokens(result) + return result + + def _accept_tokens(self, result: dict) -> None: + if "access_token" not in result: + raise NetatmoError("Netatmo token response contains no access token") self.access_token = result["access_token"] - # Netatmo may rotate refresh tokens; retain the new value for this process. self.refresh_token = result.get("refresh_token", self.refresh_token) self._expires_at = time.time() + int(result.get("expires_in", 10800)) - 60 + persisted = { + "access_token": self.access_token, + "refresh_token": self.refresh_token, + "expires_at": self._expires_at, + } + if result.get("scope"): + persisted["scope"] = result["scope"] + self.token_store.save(persisted) def stations_data(self) -> dict: if not self.configured: @@ -74,4 +145,3 @@ class NetatmoClient: self._refresh() request.headers["Authorization"] = f"Bearer {self.access_token}" return self._request(request) - diff --git a/netatmo_service/templates/dashboard.html b/netatmo_service/templates/dashboard.html index 8e3f4e7..4a69a39 100644 --- a/netatmo_service/templates/dashboard.html +++ b/netatmo_service/templates/dashboard.html @@ -24,14 +24,17 @@ {% endfor %} - diff --git a/scripts/get_netatmo_token.py b/scripts/get_netatmo_token.py new file mode 100644 index 0000000..361740b --- /dev/null +++ b/scripts/get_netatmo_token.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Perform Netatmo's initial OAuth authorization-code exchange.""" + +from __future__ import annotations + +import argparse +import os +import secrets +import sys +import urllib.parse +import webbrowser +from pathlib import Path + +# Allow direct execution from a source checkout without installing the package. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from netatmo_service.netatmo import NetatmoClient, NetatmoError + +AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize" + + +def arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--client-id", default=os.getenv("NETATMO_CLIENT_ID")) + parser.add_argument("--client-secret", default=os.getenv("NETATMO_CLIENT_SECRET")) + parser.add_argument( + "--redirect-uri", default=os.getenv("NETATMO_REDIRECT_URI", "http://localhost:8080") + ) + parser.add_argument( + "--token-file", + type=Path, + default=Path(os.getenv("NETATMO_TOKEN_FILE", "./rrd/netatmo_tokens.json")), + ) + parser.add_argument("--no-browser", action="store_true") + return parser.parse_args() + + +def authorization_code(value: str, expected_state: str) -> str: + value = value.strip() + if "://" not in value: + return value + query = urllib.parse.parse_qs(urllib.parse.urlparse(value).query) + if query.get("state", [None])[0] != expected_state: + raise ValueError("The callback state does not match; restart authorization") + if "error" in query: + raise ValueError(f"Authorization failed: {query['error'][0]}") + if not query.get("code"): + raise ValueError("The callback URL contains no authorization code") + return query["code"][0] + + +def main() -> int: + args = arguments() + if not args.client_id or not args.client_secret: + print("Set NETATMO_CLIENT_ID and NETATMO_CLIENT_SECRET first.", file=sys.stderr) + return 2 + + state = secrets.token_urlsafe(24) + url = f"{AUTHORIZE_URL}?" + urllib.parse.urlencode( + { + "client_id": args.client_id, + "redirect_uri": args.redirect_uri, + "scope": "read_station", + "state": state, + "response_type": "code", + } + ) + print("\nOpen this URL and approve access:\n") + print(url) + if not args.no_browser: + webbrowser.open(url) + print("\nAfter redirect, paste the complete callback URL (or just its code).") + + try: + code = authorization_code(input("> "), state) + config = { + "NETATMO_CLIENT_ID": args.client_id, + "NETATMO_CLIENT_SECRET": args.client_secret, + "NETATMO_REFRESH_TOKEN": "", + "NETATMO_ACCESS_TOKEN": "", + "NETATMO_TOKEN_FILE": args.token_file.expanduser().resolve(), + "NETATMO_DEVICE_ID": "", + "NETATMO_TOKEN_URL": "https://api.netatmo.com/oauth2/token", + "NETATMO_STATIONS_URL": "https://api.netatmo.com/api/getstationsdata", + } + client = NetatmoClient(config) + client.exchange_code(code, args.redirect_uri) + except (EOFError, ValueError, NetatmoError) as exc: + print(f"Token setup failed: {exc}", file=sys.stderr) + return 1 + + print(f"Tokens saved to {args.token_file} with owner-only permissions.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_service.py b/tests/test_service.py index 03faac8..e75733b 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -2,6 +2,7 @@ from pathlib import Path from netatmo_service import create_app from netatmo_service.data import extract_samples +from netatmo_service.netatmo import NetatmoClient, TokenStore class FakeClient: @@ -28,6 +29,24 @@ def test_routes(): assert client.get("/graph/windyear").status_code == 200 +def test_url_prefix_mounts_all_routes(): + prefixed = create_app({ + "TESTING": True, + "START_COLLECTOR": False, + "URL_PREFIX": "/w", + "RRD_STORE": FakeStore(), + "NETATMO_CLIENT": FakeClient(), + }).test_client() + response = prefixed.get("/w/") + assert response.status_code == 200 + assert b'/w/static/dashboard.css' in response.data + assert b'/w/graph/outdoor/day' in response.data + assert prefixed.get("/w/health").status_code == 200 + assert prefixed.get("/w/last/outdoor/humidity").status_code == 200 + assert prefixed.get("/w/graph/wind/year").status_code == 200 + assert prefixed.get("/").status_code == 404 + + def test_extract_sample_payload(): namespace = {} exec(Path("payload_dict.py").read_text(), namespace) @@ -36,3 +55,33 @@ def test_extract_sample_payload(): assert samples["outdoor"].values["pressure"] == 1004.8 assert samples["wind"].values["angle"] == 236.0 assert "bedroom" not in samples # unreachable module has no dashboard_data + + +def test_rotated_refresh_token_is_persisted(tmp_path): + token_file = tmp_path / "tokens.json" + config = { + "NETATMO_CLIENT_ID": "client", + "NETATMO_CLIENT_SECRET": "secret", + "NETATMO_REFRESH_TOKEN": "old-refresh", + "NETATMO_ACCESS_TOKEN": "", + "NETATMO_TOKEN_FILE": token_file, + "NETATMO_DEVICE_ID": "", + "NETATMO_TOKEN_URL": "https://example.test/token", + "NETATMO_STATIONS_URL": "https://example.test/stations", + } + client = NetatmoClient(config) + client._request = lambda request: { + "access_token": "new-access", + "refresh_token": "new-refresh", + "expires_in": 3600, + } + client._refresh() + + stored = TokenStore(token_file).load() + assert stored["access_token"] == "new-access" + assert stored["refresh_token"] == "new-refresh" + assert token_file.stat().st_mode & 0o777 == 0o600 + + restarted = NetatmoClient(config) + assert restarted.access_token == "new-access" + assert restarted.refresh_token == "new-refresh" diff --git a/wsgi.py b/wsgi.py index 9e64d8a..fb87011 100644 --- a/wsgi.py +++ b/wsgi.py @@ -3,5 +3,5 @@ from netatmo_service import create_app app = create_app() if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) + app.run(host="0.0.0.0", port=30225)