diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 64479d7..8d9d9b0 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -9,10 +9,12 @@ jobs: - uses: actions/checkout@v4 - name: Login run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin - - name: Build & push (data, logic, presentation) + # astrodemo dołącza do tej samej pętli: dzieli warstwę logiczną i łącze, + # więc jego obraz ma powstawać z tego samego commita co reszta produktu. + - name: Build & push (data, logic, presentation, astrodemo) run: | TAG=${GITHUB_SHA::8} - for SVC in data logic presentation; do + for SVC in data logic presentation astrodemo; do docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG done diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index e8bf142..c102699 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -91,6 +91,27 @@ jobs: PYTHONPATH: . run: pytest tests -q -rs + astrodemo-tests: + name: Testy astrodemo + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: services/astrodemo/requirements-dev.txt + - name: Instalacja zależności + run: pip install -r services/astrodemo/requirements-dev.txt + # Demo rozmawia z warstwą danych PRODUKCJI, więc jego powierzchnia musi być + # pilnowana tak samo jak reszty: testy sprawdzają m.in., że nie przybyła + # żadna trasa poza dwiema funkcjami. + - name: Testy (pytest) + working-directory: services/astrodemo + env: + PYTHONPATH: . + run: pytest tests -q -rs + swisseph-image: name: Build obrazu silnika B (swisseph) runs-on: ubuntu-latest diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 656a3cf..63e679c 100644 Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ diff --git a/services/astrodemo/Dockerfile b/services/astrodemo/Dockerfile new file mode 100644 index 0000000..860165e --- /dev/null +++ b/services/astrodemo/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też katalog +# tests/ — a leży w nim lista słów, które w tej usłudze nie mają prawa paść, +# wypisanych wprost. Obraz tej usługi się KOMUŚ ODDAJE, więc niósłby dokładnie +# to, co ten test ma pilnować. Przy okazji obraz jest mniejszy. +COPY app ./app + +EXPOSE 8005 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"] diff --git a/services/astrodemo/README.md b/services/astrodemo/README.md new file mode 100644 index 0000000..4a0999e --- /dev/null +++ b/services/astrodemo/README.md @@ -0,0 +1,91 @@ +# astrodemo — wersja demonstracyjna (PRE-28) + +Osobna warstwa prezentacji o **dwóch funkcjach**: + +1. **dodanie pliku bazy** — wgranie i włączenie do użytku jedną czynnością, +2. **zapytanie o interpretację urodzeniową** dla podanych danych wejściowych. + +I na tym koniec. Nie ma tu kosmogramu, synastrii, kalendarza, promptów do modelu, +eksportu ani kont — nie są ukryte, tylko **nie istnieją w tym obrazie**. + +## Dlaczego osobna usługa, a nie konto z ograniczeniami + +Mechanizm uprawnień z PRE-27 umiałby to ukryć w pełnej aplikacji. Ale **ukrycie +a nieobecność to dwie różne rzeczy**: tutaj nie ma tras, nie ma szablonów, nie ma +nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy +okazji kodu reszty programu. + +## Izolacja: własne dane, pula na konto + +``` +astrodemo → logic-demo → data-demo → udział astrololo-demo (pusty na starcie) + └── klientA/ ← pula konta + └── klientB/ ← pula konta +``` + +**Oryginalne bazy interpretacyjne są dla demo nieosiągalne.** Nie chodzi +o uprawnienia: demo ma własną warstwę danych, pracującą na osobnym udziale. +Osobna jest też warstwa logiczna, bo zna ona JEDEN adres warstwy danych — demo +korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy. + +**Każde konto ma własną pulę** plików: swój podkatalog na tym udziale. Konta nie +widzą swoich baz nawzajem ani w liście, ani w wynikach wyszukiwania. Dlatego demo +można rozdawać szeroko. + +Pula bierze się **z loginu zalogowanej osoby** i z niczego innego. Gdyby +przychodziła z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze +bazy — osobny test wysyła `tenant`, `user` i `login` w formularzu i sprawdza, że +nie mają na nią wpływu. + +Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne +w nazwie katalogu: `../..` czy ukośnik wyprowadziłyby zapytanie poza udział. + +**Pula klienta to jeden katalog**, więc przejście na pełną wersję nie oznacza +utraty wgrań — procedura importu jest w runbooku wdrożenia (repo `deploy`, +`astrololo/README-astrodemo.md`). + +## Konfiguracja + +| zmienna | znaczenie | +|---|---| +| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) | +| `ASTRODEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików | +| `ASTRODEMO_USER` / `ASTRODEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `ASTRODEMO_USERS` jest puste. **Brak obu = aplikacja stoi otwarta** i mówi o tym głośno przy starcie | +| `INTERNAL_TOKEN` | token międzywarstwowy (LOG-32) — ten sam co reszta usług | +| `LINK_KEY_PRESENTATION_LOGIC` | klucz szyfrowanego łącza (PRE-16) — ten sam co prezentacja | +| `RATE_LIMIT_PER_MIN` | limit żądań na adres, domyślnie 60; `0` wyłącza | +| `REPORT_LIMIT` | górny limit rekordów w jednej odpowiedzi, domyślnie 2000 | +| `TRUST_PROXY` | `true` za Ingressem — inaczej limit liczy wszystkich do jednego wiadra | + +Hash hasła zrobisz tym samym skryptem, co dla głównej aplikacji: + +```bash +cd services/presentation && python scripts/make_user.py demo +``` + +## Uruchomienie lokalne + +```bash +cd services/astrodemo +pip install -r requirements-dev.txt +ASTRODEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \ + uvicorn app.main:app --port 8005 +``` + +Testy: + +```bash +cd services/astrodemo && PYTHONPATH=. pytest tests -q +``` + +## Co pilnują testy + +Poza zwykłą poprawnością — dwie własności, które łatwo zepsuć bez zauważenia: + +* **powierzchnia**: zbiór tras aplikacji musi być dokładnie taki, jak dwie funkcje + wymagają; klient warstwy logicznej ma tylko te metody, których używa. Trasa, + o której nikt nie pamiętał, jest tu realnym ryzykiem, bo ta usługa rozmawia + z produkcyjną warstwą danych. +* **milczenie o regułach walidacji**: plik odrzucony daje komunikat bez powodu. + Reguły zna wyłącznie administrator w pełnej aplikacji (DAN-27), a demo nie ma + prawa ich zdradzić nawet mimochodem. diff --git a/services/astrodemo/app/__init__.py b/services/astrodemo/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/astrodemo/app/clients/__init__.py b/services/astrodemo/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/astrodemo/app/clients/logic_client.py b/services/astrodemo/app/clients/logic_client.py new file mode 100644 index 0000000..13cdadf --- /dev/null +++ b/services/astrodemo/app/clients/logic_client.py @@ -0,0 +1,79 @@ +"""Klient warstwy logicznej — CELOWO okrojony do dwóch funkcji (PRE-28). + +Ma tu być wyłącznie to, czego ta usługa naprawdę używa. Metoda, której nie ma, +nie zostanie przypadkiem zawołana ani nie podpowie, że po drugiej stronie jest +coś więcej. + +Rozmowa idzie tym samym szyfrowanym łączem co reszta (PRE-16) i pod tym samym +tokenem międzywarstwowym (LOG-32) — demo nie jest furtką omijającą ochronę. +""" +from __future__ import annotations + +import os +from typing import Any + +import httpx + +from app import link_crypto +from app.config import settings + + +def _auth_headers() -> dict[str, str]: + token = os.getenv("INTERNAL_TOKEN", "") + return {"X-Astrololo-Token": token} if token else {} + + +def _link() -> link_crypto.Link | None: + """Klucz łącza czytany przy KAŻDYM wywołaniu — pod przeżywa rotację sekretu + bez restartu, a brak klucza przy wymaganym szyfrowaniu ma zatrzymać żądanie + ZANIM cokolwiek wyjdzie jawnym tekstem.""" + key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC) + return link_crypto.Link(key) if key else None + + +class LogicClient: + """Rozmówca warstwy logicznej ZWIĄZANY Z PULĄ KONTA (PRE-29). + + Pulę podaje się przy tworzeniu klienta, a bierze się ją WYŁĄCZNIE z loginu + zalogowanej osoby — nigdy z pola formularza. Gdyby przychodziła z żądania, + wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy.""" + + def __init__(self, tenant: str = "", base_url: str | None = None) -> None: + self.base_url = (base_url or settings.logic_url).rstrip("/") + self.tenant = tenant or "" + + def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]: + """Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach. + + Jedyne pytanie, jakie astrodemo zadaje o treść — bez grupowania, + bez grupowania i bez żadnej obróbki treści.""" + payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, + "limit": settings.report_limit, "group": False, + "tenant": self.tenant} + with httpx.Client(timeout=settings.http_timeout) as client: + return link_crypto.call_json(client, "POST", f"{self.base_url}/chart/report", + payload=payload, headers=_auth_headers(), link=_link()) + + def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]: + with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client: + return link_crypto.call_json( + client, "POST", f"{self.base_url}/files/upload", + payload={"filename": filename, "content_b64": content_b64, + "by": by, "tenant": self.tenant}, + headers=_auth_headers(), link=_link()) + + def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]: + """Włączenie wgranego pliku do użytku. W demo wgranie i włączenie to JEDNA + czynność: „dodać do bazy" ma znaczyć, że plik od razu bierze udział + w wyszukiwaniu — inaczej demo wyglądałoby na zepsute.""" + with httpx.Client(timeout=settings.http_timeout) as client: + return link_crypto.call_json( + client, "POST", f"{self.base_url}/files/status", + payload={"path": path, "status": status, "by": by, "tenant": self.tenant}, + headers=_auth_headers(), link=_link()) + + def health(self) -> dict[str, Any]: + with httpx.Client(timeout=settings.http_timeout) as client: + r = client.get(f"{self.base_url}/health", headers=_auth_headers()) + r.raise_for_status() + return r.json() diff --git a/services/astrodemo/app/config.py b/services/astrodemo/app/config.py new file mode 100644 index 0000000..c88142a --- /dev/null +++ b/services/astrodemo/app/config.py @@ -0,0 +1,22 @@ +"""Konfiguracja astrodemo — wersji demonstracyjnej (PRE-28). + +Zna WYŁĄCZNIE adres warstwy logicznej. Nie wie nic o bazach, Excelu ani SQL-u. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +@dataclass +class Settings: + logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001")) + # Szukanie interpretacji potrafi zajrzeć do wszystkich baz naraz, więc limit + # czasu jest wyższy niż domyślny dla zwykłego żądania. + http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "30"))) + # Górny limit rekordów w jednej odpowiedzi. Nie jest to optymalizacja, tylko + # ograniczenie, ile treści baz może wyjść jednym zapytaniem. + report_limit: int = field(default_factory=lambda: int(os.getenv("REPORT_LIMIT", "2000"))) + + +settings = Settings() diff --git a/services/astrodemo/app/link_crypto.py b/services/astrodemo/app/link_crypto.py new file mode 100644 index 0000000..da99bc2 --- /dev/null +++ b/services/astrodemo/app/link_crypto.py @@ -0,0 +1,525 @@ +"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33). + +Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token +międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź +— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń +produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu, +zrzut z węzła), miał je w całości. + +Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi. +GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt +nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale +podatne na modyfikację". + +**Dwa niezależne klucze**, po jednym na parę rozmówców: + * ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika, + * ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane. +Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych, +gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony. + +Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF). +Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie +losowej jednorazówki w jedną stronę nie osłabia drugiej. + +Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu): + + [4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik] + +Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu +i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć +po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu. + +Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność +wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa +działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa +regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu. +""" +from __future__ import annotations + +import base64 +import binascii +import logging +import os +import struct +import time +from typing import Iterable, Iterator + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +log = logging.getLogger("astrololo.link") + +MAGIC = b"AL1" +VERSION = "v1" +NONCE_BYTES = 12 +KEY_BYTES = 32 # AES-256 +LENGTH_PREFIX = 4 +MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości +MAX_SKEW_SECONDS = 300.0 + +HEADER_ENC = "X-Astrololo-Enc" +HEADER_TS = "X-Astrololo-Enc-Ts" +CONTENT_TYPE = "application/vnd.astrololo.enc" + +ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" +ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" +# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych — +# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie +# jej klucza nie może otwierać łącza do logiki ani do danych. +ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER" +ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" + +REQUEST, RESPONSE = b"req", b"res" + +# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka +# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji. +PUBLIC_PATHS = frozenset({"/health"}) + + +class LinkError(Exception): + """Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz.""" + + +# --------------------------------------------------------------------- klucze + +def parse_key(raw: str) -> bytes: + """Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty.""" + text = raw.strip() + if not text: + raise LinkError("pusty klucz łącza") + try: + key = bytes.fromhex(text) + except ValueError: + try: + key = base64.b64decode(text, validate=True) + except (binascii.Error, ValueError) as exc: + raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc + if len(key) != KEY_BYTES: + raise LinkError( + f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez " + f"`openssl rand -hex 32`" + ) + return key + + +def key_from_env(env_name: str) -> bytes | None: + """Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu.""" + raw = os.getenv(env_name, "") + return parse_key(raw) if raw.strip() else None + + +def encryption_required() -> bool: + """Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu. + + Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza + wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść + zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim + cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu. + """ + return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _subkey(link_key: bytes, direction: bytes) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None, + info=b"astrololo/link/" + direction, + ).derive(link_key) + + +class Link: + """Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze.""" + + def __init__(self, link_key: bytes) -> None: + self._by_direction = { + REQUEST: AESGCM(_subkey(link_key, REQUEST)), + RESPONSE: AESGCM(_subkey(link_key, RESPONSE)), + } + + # ---------------------------------------------------------- pojedyncza ramka + + def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes: + return b"|".join([MAGIC, direction, path.encode("utf-8"), + stamp.encode("ascii"), str(seq).encode("ascii")]) + + def seal(self, direction: bytes, path: str, stamp: str, seq: int, + plaintext: bytes) -> bytes: + nonce = os.urandom(NONCE_BYTES) + sealed = self._by_direction[direction].encrypt( + nonce, plaintext, self._aad(direction, path, stamp, seq)) + return MAGIC + nonce + sealed + + def open(self, direction: bytes, path: str, stamp: str, seq: int, + frame: bytes) -> bytes: + if not frame.startswith(MAGIC): + raise LinkError("ramka bez znacznika protokołu") + body = frame[len(MAGIC):] + if len(body) <= NONCE_BYTES: + raise LinkError("ramka za krótka") + nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:] + try: + return self._by_direction[direction].decrypt( + nonce, sealed, self._aad(direction, path, stamp, seq)) + except InvalidTag as exc: + # Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt, + # przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich + # na zewnątrz podpowiadałoby atakującemu, w co trafił. + raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc + + # ------------------------------------------------------------ strumień ramek + + def seal_stream(self, direction: bytes, path: str, stamp: str, + chunks: Iterable[bytes]) -> Iterator[bytes]: + for seq, chunk in enumerate(chunks): + yield frame_out(self.seal(direction, path, stamp, seq, chunk)) + + def open_stream(self, direction: bytes, path: str, stamp: str, + raw: bytes) -> Iterator[bytes]: + for seq, frame in enumerate(frames_in(raw)): + yield self.open(direction, path, stamp, seq, frame) + + def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes: + return b"".join(self.open_stream(direction, path, stamp, raw)) + + +# ---------------------------------------------------------------- ramkowanie + +def frame_out(payload: bytes) -> bytes: + return struct.pack(">I", len(payload)) + payload + + +def frames_in(raw: bytes) -> Iterator[bytes]: + """Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych.""" + offset = 0 + while offset < len(raw): + if offset + LENGTH_PREFIX > len(raw): + raise LinkError("urwana ramka (brak nagłówka długości)") + (size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX]) + if size > MAX_FRAME: + raise LinkError("ramka ponad dopuszczalny rozmiar") + offset += LENGTH_PREFIX + if offset + size > len(raw): + raise LinkError("urwana ramka (za mało danych)") + yield raw[offset:offset + size] + offset += size + + +def unframe_incremental(buffer: bytearray) -> Iterator[bytes]: + """Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później. + + Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi + rozjechać się między dwa odczyty. + """ + while True: + if len(buffer) < LENGTH_PREFIX: + return + (size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX]) + if size > MAX_FRAME: + raise LinkError("ramka ponad dopuszczalny rozmiar") + if len(buffer) < LENGTH_PREFIX + size: + return + frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size]) + del buffer[:LENGTH_PREFIX + size] + yield frame + + +# ------------------------------------------------------------- świeżość ruchu + +def stamp_now() -> str: + return f"{time.time():.3f}" + + +def check_stamp(stamp: str) -> None: + """Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się + odtworzyć w dowolnym momencie w przyszłości.""" + try: + sent = float(stamp) + except (TypeError, ValueError) as exc: + raise LinkError("brak albo błędny znacznik czasu") from exc + if abs(time.time() - sent) > MAX_SKEW_SECONDS: + raise LinkError("znacznik czasu poza dopuszczalnym oknem") + + +# =========================================================== strona serwerowa + +class LinkCryptoMiddleware: + """Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi. + + Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba + podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić + odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci. + """ + + def __init__(self, app, link: Link | None, layer: str) -> None: + self.app = app + self.link = link + self.layer = layer + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS: + return await self.app(scope, receive, send) + + path = scope["path"] + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} + + if headers.get(HEADER_ENC.lower()) != VERSION: + # Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo + # pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie + # chcemy po cichu wrócić do jawnego ruchu. + log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)", + self.layer, path) + return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.") + + stamp = headers.get(HEADER_TS.lower(), "") + try: + check_stamp(stamp) + plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive)) + except LinkError as exc: + log.warning("warstwa %s: %s (%s)", self.layer, exc, path) + return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.") + + scope = dict(scope) + scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext)) + await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path)) + + def _sealing_send(self, send, path: str): + state: dict = {"stamp": "", "seq": 0} + + async def sealing(message): + if message["type"] == "http.response.start": + state["stamp"] = stamp_now() + keep = [(k, v) for k, v in message.get("headers", []) + if k.lower() not in (b"content-length", b"content-type")] + message = dict(message) + message["headers"] = keep + [ + (b"content-type", CONTENT_TYPE.encode()), + (HEADER_ENC.lower().encode(), VERSION.encode()), + (HEADER_TS.lower().encode(), state["stamp"].encode()), + ] + return await send(message) + + if message["type"] == "http.response.body": + chunk = message.get("body", b"") + sealed = b"" + if chunk: + sealed = frame_out(self.link.seal( + RESPONSE, path, state["stamp"], state["seq"], chunk)) + state["seq"] += 1 + return await send({"type": "http.response.body", "body": sealed, + "more_body": message.get("more_body", False)}) + + return await send(message) + + return sealing + + +def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int): + """Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI + próbowałby sparsować JSON o cudzej deklarowanej wielkości.""" + kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")] + kept.append((b"content-length", str(length).encode())) + if length: + kept.append((b"content-type", b"application/json")) + return kept + + +async def _read_body(receive) -> bytes: + body = bytearray() + while True: + message = await receive() + if message["type"] == "http.disconnect": + raise LinkError("rozłączenie w trakcie odbioru żądania") + body += message.get("body", b"") + if not message.get("more_body", False): + return bytes(body) + + +def _replay(body: bytes, original): + """Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji. + + Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu + zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia + równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji + okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały. + """ + delivered = False + + async def receive(): + nonlocal delivered + if delivered: + return await original() + delivered = True + return {"type": "http.request", "body": body, "more_body": False} + + return receive + + +async def _refuse(send, detail: str) -> None: + """Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować, + więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny.""" + payload = f'{{"detail":"{detail}"}}'.encode("utf-8") + await send({"type": "http.response.start", "status": 400, "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(payload)).encode()), + ]}) + await send({"type": "http.response.body", "body": payload}) + + +def install(app, env_name: str, layer: str): + """Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa + tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał.""" + link_key = key_from_env(env_name) + if link_key is None and encryption_required(): + # Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania: + # wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni + # niczego. Pod w CrashLoop widać od razu, jawny ruch — nie. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — " + f"warstwa {layer} nie wystartuje bez klucza łącza" + ) + if link_key is None: + log.warning( + "UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, " + "więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha " + "ruch wewnątrz sieci.", env_name, layer, + ) + return None + link = Link(link_key) + app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer) + log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name) + return link + + +# ============================================================ strona kliencka + +def call(client, method: str, url: str, *, payload=None, + headers: dict[str, str] | None = None, link: Link | None) -> bytes: + """Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi. + + Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego + argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby + się niejasnym błędem odszyfrowania. + """ + import json as _json + + import httpx + + request_headers = dict(headers or {}) + if link is None: + if encryption_required(): + # Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero + # zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie + # sygnifikatory, o które pytamy bazę. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie " + f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem" + ) + response = client.request(method, url, json=payload, headers=request_headers) + response.raise_for_status() + return response.content + + path = httpx.URL(url).path + stamp = stamp_now() + plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8") + body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext)) + request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, + "Content-Type": CONTENT_TYPE}) + + response = client.request(method, url, content=body, headers=request_headers) + if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION: + log.error("łącze %s odmówiło: %s", path, response.text[:200]) + response.raise_for_status() + if response.headers.get(HEADER_ENC) != VERSION: + raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony") + reply_stamp = response.headers.get(HEADER_TS, "") + check_stamp(reply_stamp) + return link.open_all(RESPONSE, path, reply_stamp, response.content) + + +def call_json(client, method: str, url: str, *, payload=None, + headers: dict[str, str] | None = None, link: Link | None): + import json as _json + + return _json.loads(call(client, method, url, payload=payload, + headers=headers, link=link)) + + +def open_response_stream(response, link: Link | None) -> Iterator[bytes]: + """Odbiór odpowiedzi płynącej kawałkami (okno postępu). + + Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją + w buforze zamiast zakładać, że każdy kawałek to komplet. + """ + if link is None: + yield from response.iter_bytes() + return + if response.headers.get(HEADER_ENC) != VERSION: + raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony") + stamp = response.headers.get(HEADER_TS, "") + check_stamp(stamp) + path = response.request.url.path + buffer = bytearray() + seq = 0 + for chunk in response.iter_bytes(): + buffer += chunk + for frame in unframe_incremental(buffer): + yield link.open(RESPONSE, path, stamp, seq, frame) + seq += 1 + if buffer: + raise LinkError("strumień urwał się w połowie ramki") + + +def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None, + link: Link | None) -> Iterator[str]: + """Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo. + + Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc + czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest + pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE + pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je + dopiero na znakach nowej linii. + + Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów + działał bez zmian. + """ + import json as _json + + import httpx as _httpx + + request_headers = dict(headers or {}) + if link is None: + if encryption_required(): + # Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy + # szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale + # ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień " + f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem" + ) + with client.stream("POST", url, json=payload, headers=request_headers) as response: + response.raise_for_status() + for text_line in response.iter_lines(): + if text_line: + yield text_line + return + + path = _httpx.URL(url).path + stamp = stamp_now() + body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8"))) + request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE}) + with client.stream("POST", url, content=body, headers=request_headers) as response: + response.raise_for_status() + buffer = bytearray() + for plain in open_response_stream(response, link): + buffer += plain + while True: + nl = buffer.find(b"\n") + if nl < 0: + break + text_line = bytes(buffer[:nl]) + del buffer[:nl + 1] + if text_line: + yield text_line.decode("utf-8") + if buffer: + yield bytes(buffer).decode("utf-8") diff --git a/services/astrodemo/app/main.py b/services/astrodemo/app/main.py new file mode 100644 index 0000000..e90a9e7 --- /dev/null +++ b/services/astrodemo/app/main.py @@ -0,0 +1,255 @@ +"""astrodemo — wersja DEMONSTRACYJNA (PRE-28). + +DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ: + 1. dodanie pliku bazy (wgranie + włączenie do użytku), + 2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych. + +DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Ukrycie a nieobecność to +dwie różne rzeczy. Tutaj niczego poza tymi dwiema funkcjami NIE MA W OBRAZIE: nie +ma tras, nie ma szablonów, nie ma nawet metod w kliencie warstwy logicznej. Obraz +tej usługi można komuś oddać i nie oddaje się przy tym niczego ponad to, co widać +na ekranie — łącznie z komentarzami w tym pliku, bo one też jadą razem z obrazem. + +PULE SĄ ROZDZIELONE (PRE-29). Każde konto ma własny, izolowany zbiór plików, +niewidoczny dla pozostałych kont; pula bierze się WYŁĄCZNIE z loginu zalogowanej +osoby, nigdy z pola formularza. Konta są osobne (ASTRODEMO_USERS) — żeby dało się +odciąć tę usługę jedną zmienną, bez +ruszania kont głównej aplikacji. +""" +from __future__ import annotations + +import base64 +import logging +import re +from functools import lru_cache +from pathlib import Path +from datetime import datetime, timedelta, timezone + +import httpx +from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.templating import Jinja2Templates + +from app import link_crypto, security, session +from app.clients.logic_client import LogicClient + +# Automatyczna dokumentacja WYŁĄCZONA. /docs wypisałoby komplet tras, a ta usługa +# ma nie zdradzać nawet własnej powierzchni. +app = FastAPI(title="astrodemo", docs_url=None, redoc_url=None, openapi_url=None) +security.install(app) + +# ── zasoby (PRE-27) ──────────────────────────────────────────────────────── +# Katalog NIE jest montowany. Montowanie serwuje wszystko, co w nim leży, każdemu +# i bez śladu w dzienniku — a komentarz w arkuszu stylów mówił wprost, że istnieje +# więcej, niż widać na ekranie. Tu jest jawna lista: arkusz logowania jest +# publiczny, bo +# potrzebuje go strona logowania, a cokolwiek innego jest nieznane i nie istnieje. +ZASOBY_PUBLICZNE = frozenset({"styles.css"}) +_KATALOG_ZASOBOW = Path("app/static") + + +def _bez_komentarzy(tresc: str) -> str: + """Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt. + W repozytorium zostaje — do odpowiedzi nie ma po co trafiać.""" + tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S) + return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n" + + +@lru_cache(maxsize=None) +def _zasob(nazwa: str) -> bytes | None: + """Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma.""" + if nazwa not in ZASOBY_PUBLICZNE: + return None + sciezka = (_KATALOG_ZASOBOW / nazwa).resolve() + # Wyjście poza katalog: `..` w ścieżce albo dowiązanie na zewnątrz. + if _KATALOG_ZASOBOW.resolve() not in sciezka.parents or not sciezka.is_file(): + return None + if sciezka.suffix in (".css", ".js"): + return _bez_komentarzy(sciezka.read_text(encoding="utf-8")).encode("utf-8") + return sciezka.read_bytes() + + +@app.get("/static/{nazwa:path}") +def zasob(nazwa: str): + tresc = _zasob(nazwa) + if tresc is None: + raise HTTPException(status_code=404) + typ = "text/css; charset=utf-8" if nazwa.endswith(".css") else "application/octet-stream" + return Response(tresc, media_type=typ, + headers={"Cache-Control": "public, max-age=3600"}) + +templates = Jinja2Templates(directory="app/templates") +# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _blad). +log = logging.getLogger("astrodemo") +templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or "" +templates.env.globals["auth_on"] = security.auth_enabled + +def _logic(request: Request) -> LogicClient: + """Klient związany z pulą ZALOGOWANEGO konta. + + Budowany PER ŻĄDANIE, nie raz na moduł: pula musi wynikać z tego, kto się + zalogował, a nie z globalnego stanu procesu. Login bierzemy z request.state, + które ustawia warstwa logowania — nigdy z pola formularza, bo wtedy + wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy.""" + return LogicClient(tenant=getattr(request.state, "user", "") or "") + +# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana +# tutaj wprost, bo astrodemo nie importuje tamtej warstwy; literówka objawiłaby +# się dopiero na żywo, więc pilnuje jej test. +ACTIVE = "active" + + +def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]: + """Data + godzina lokalna + offset → chwila w UTC. + + Offset podaje się wprost, bez wyszukiwania strefy z współrzędnych — demo ma + dwie funkcje, a strefy to trzecia.""" + try: + local = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M") + except ValueError as e: + raise ValueError("Podaj datę jako RRRR-MM-DD i godzinę jako GG:MM.") from e + moment = local.replace(tzinfo=timezone(timedelta(hours=tz_offset))) + return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M") + + +# Jedno zdanie dla wszystkich awarii i CELOWO żadnego szczegółu. Treść wyjątku +# niosła nazwę usługi, jej port i nazwy jej tras — czyli mapę tego, co stoi za tym +# ekranem, podaną komuś, kto ma widzieć wyłącznie dwa formularze. +_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę." + + +def _blad(e: Exception) -> str: + """Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran.""" + log.warning("operacja nieudana: %s: %s", type(e).__name__, e) + return _AWARIA + + +def _ile_rekordow(report: dict) -> int: + return sum(len(s.get("samples") or []) for p in (report.get("positions") or []) + for s in (p.get("significators") or [])) + + +@app.get("/", response_class=HTMLResponse) +def formularz(request: Request, error: str = "", done: str = ""): + return templates.TemplateResponse(request, "index.html", + {"result": None, "form": {}, "error": error, "done": done}) + + +@app.post("/", response_class=HTMLResponse) +def interpretacja(request: Request, date: str = Form(...), time: str = Form(...), + tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0)): + """Interpretacja urodzeniowa dla podanych danych. Jedyne pytanie o treść.""" + form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon} + ctx: dict = {"form": form, "result": None, "error": None, "done": "", "moment": None} + try: + iso_utc, label = _build_utc(date, time, tz_offset) + ctx["moment"] = label + ctx["result"] = _logic(request).report(when_utc_iso=iso_utc, lat=lat, lon=lon) + # Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17). + security.audit_records(request, _ile_rekordow(ctx["result"])) + except ValueError as e: + ctx["error"] = str(e) + except (httpx.HTTPError, link_crypto.LinkError) as e: + ctx["error"] = _blad(e) + return templates.TemplateResponse(request, "index.html", ctx) + + +@app.post("/plik") +async def dodaj_plik(request: Request, upload: UploadFile = File(...)): + """Dodanie pliku bazy: wgranie ORAZ włączenie do użytku. + + Jedna czynność, nie dwie: „dodać plik" ma tu znaczyć, że od razu bierze udział + w wyszukiwaniu — inaczej po wgraniu nic by się nie zmieniło i wyglądałoby to + na zepsute. + + Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje + zapisany i czeka.""" + raw = await upload.read() + if not raw: + return RedirectResponse("/?error=Pusty+plik.", status_code=303) + + who = getattr(request.state, "user", "") or "demo" + try: + out = _logic(request).files_upload(upload.filename or "baza.xlsx", + base64.b64encode(raw).decode("ascii"), by=who) + except (httpx.HTTPError, link_crypto.LinkError) as e: + return _wroc(error=_blad(e)) + + nazwa = out.get("name") or "plik" + if not out.get("accepted"): + # Bez słowa o regułach — te zna wyłącznie administrator (DAN-27). + return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się " + f"do użycia w tej postaci.") + try: + _logic(request).files_status(out["path"], ACTIVE, by=who) + except (httpx.HTTPError, link_crypto.LinkError) as e: + return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}") + return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.") + + +def _wroc(error: str = "", done: str = "") -> RedirectResponse: + """Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może + wgrać pliku drugi raz.""" + from urllib.parse import urlencode + + q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v}) + return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303) + + +# ---------------- Logowanie i wylogowanie (LOG-34) ---------------- +# Bez własnego wolumenu nie ma gdzie trzymać licznika pokolenia sesji, więc +# „wyrzuć ze wszystkich urządzeń" robi się zmianą hasła w ASTRODEMO_USERS. +# Wylogowanie działa natychmiast, bo polega na skasowaniu ciasteczka. + +def _bezpieczne_dokad(dokad: str) -> str: + """Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl` + zamieniłby ekran logowania w narzędzie do wyłudzania haseł.""" + dokad = (dokad or "").strip() + if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad: + return "/" + return dokad.split("?", 1)[0].split("#", 1)[0] or "/" + + +@app.get("/logowanie", response_class=HTMLResponse) +def logowanie_form(request: Request, dokad: str = "/", blad: str = ""): + if security.auth_enabled() and security.principal(request): + return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303) + return templates.TemplateResponse(request, "logowanie.html", + {"dokad": _bezpieczne_dokad(dokad), "blad": blad}) + + +@app.post("/logowanie") +def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""), + dokad: str = Form("/")): + cel = _bezpieczne_dokad(dokad) + kto = security.verify(login.strip(), haslo) + if kto is None: + security.audit_login(request, login.strip(), udane=False) + return templates.TemplateResponse( + request, "logowanie.html", + {"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login}, + status_code=401) + security.audit_login(request, kto, udane=True) + odpowiedz = RedirectResponse(cel, status_code=303) + odpowiedz.set_cookie(session.COOKIE, security.issue_session(kto), + max_age=session.max_age(), + **session.cookie_params(secure=security.cookies_secure())) + return odpowiedz + + +@app.post("/wyloguj") +def wyloguj(request: Request): + """POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej stronie, + żeby wylogować kogoś bez jego wiedzy.""" + security.audit_logout(request) + odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303) + odpowiedz.delete_cookie(session.COOKIE, + **session.cookie_params(secure=security.cookies_secure())) + return odpowiedz + + +@app.get("/health") +def health() -> dict: + # Sama „ok". Nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa; + # kubernetes i compose czytają wyłącznie kod odpowiedzi. + return {"status": "ok"} diff --git a/services/astrodemo/app/security.py b/services/astrodemo/app/security.py new file mode 100644 index 0000000..a5639b1 --- /dev/null +++ b/services/astrodemo/app/security.py @@ -0,0 +1,266 @@ +"""Logowanie do astrodemo (PRE-28). + +OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej +warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy +interpretacyjne. Własny login i hasło pozwalają odciąć demo jedną zmienną +środowiskową, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek. + +Mechanizm jest CELOWO prosty: jedno konto z konfiguracji, bez ekranu kont +i bez uprawnień. Ta usługa ma dwie funkcje i obie są dla tego samego człowieka — +model uprawnień z PRE-27 nie miałby tu czego dzielić. + +Hasło może być jawne albo jako hash scrypt (`scrypt$sól$hash`), tym samym formatem +co APP_USERS w głównej aplikacji — dzięki temu skrypt make_user.py działa na oba. +""" +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import logging +import os +import time +from collections import deque + +from fastapi import Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +log = logging.getLogger("astrodemo.security") +audit_log = logging.getLogger("astrodemo.audit") + +_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32 +_HASH_PREFIX = "scrypt$" + +# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być +# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji. +PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/styles.css"}) +# Pusto CELOWO. Prefiks "/static/" przepuszczał CAŁY katalog obok bramki: każdy +# plik pobierał ktokolwiek, także niezalogowany, i nie zostawiało to śladu ani +# w dzienniku, ani w limicie żądań. Publiczny jest jeden arkusz, wymieniony +# z nazwy, bo potrzebuje go ekran logowania. +PUBLIC_PREFIXES: tuple[str, ...] = () + +_hits: dict[str, deque[float]] = {} +MAX_TRACKED_CLIENTS = 4096 + + +def accounts() -> dict[str, str]: + """Konta demo: `login:sekret`, po przecinku, ze zmiennej ASTRODEMO_USERS. + + WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29). + Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem + swoje wgrania. + + Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) — ten sam format i ten + sam skrypt make_user.py, co w głównej aplikacji. + + Zgodność wstecz: pojedyncze ASTRODEMO_USER/ASTRODEMO_PASSWORD działa, gdy ASTRODEMO_USERS + jest puste.""" + out: dict[str, str] = {} + for entry in os.getenv("ASTRODEMO_USERS", "").split(","): + entry = entry.strip() + if not entry: + continue + user, sep, secret = entry.partition(":") + if sep and user.strip() and secret.strip(): + out[user.strip()] = secret.strip() + if out: + return out + single = os.getenv("ASTRODEMO_PASSWORD", "") + return {os.getenv("ASTRODEMO_USER", "demo"): single} if single else {} + + +def rate_limit_per_min() -> int: + return int(os.getenv("RATE_LIMIT_PER_MIN", "60")) + + +def trust_proxy() -> bool: + return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"} + + +def auth_enabled() -> bool: + return bool(accounts()) + + +def _secret_matches(spec: str, password: str) -> bool: + if spec.startswith(_HASH_PREFIX): + try: + _, salt_hex, hash_hex = spec.split("$", 2) + salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex) + except ValueError: + return False + actual = hashlib.scrypt(password.encode("utf-8"), salt=salt, + n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN) + return hmac.compare_digest(actual, expected) + # BAJTY, nie tekst: compare_digest na stringach rzuca TypeError przy znakach + # spoza ASCII, więc hasło z polskimi literami dawałoby 500 zamiast odmowy. + return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8")) + + +def verify(login: str, password: str) -> str | None: + """Sprawdza login i hasło; zwraca login albo None. + + Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które + konto istnieje, a przy demo rozdawanym szeroko to realna informacja.""" + spec = accounts().get(login) + if spec is None: + return None + return login if _secret_matches(spec, password) else None + + +def issue_session(login: str) -> str: + """Zawartość ciasteczka dla właśnie zalogowanego konta. + + W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie + konta z ASTRODEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny + sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu, + bo nie ma własnego wolumenu — a stan sesji na udziale z bazami klientów + byłby pomieszaniem dwóch zupełnie różnych rzeczy.""" + from app import session + + return session.issue(login, session.fingerprint(accounts().get(login) or ""), 0) + + +def principal(request: Request) -> str | None: + """Ciasteczko → login zalogowanej osoby albo None.""" + from app import session + + dane = session.read(request.cookies.get(session.COOKIE)) + if dane is None: + return None + spec = accounts().get(dane["login"]) + if spec is None or session.fingerprint(spec) != dane["odcisk"]: + return None + return dane["login"] + + +def client_ip(request: Request) -> str: + """Adres do rozliczania limitu. Nagłówkom wierzymy WYŁĄCZNIE za proxy — + inaczej wystarczyłoby podstawić własny X-Forwarded-For, żeby ominąć limit.""" + peer = request.client.host if request.client else "?" + if not trust_proxy(): + return peer + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + last = forwarded.rsplit(",", 1)[-1].strip() + if last: + return last + return request.headers.get("x-real-ip", "").strip() or peer + + +def _rate_limited(client: str) -> bool: + cap = rate_limit_per_min() + if cap <= 0: + return False + now = time.monotonic() + window = _hits.get(client) + if window is None: + if len(_hits) >= MAX_TRACKED_CLIENTS: + _hits.clear() + window = _hits[client] = deque() + while window and now - window[0] > 60.0: + window.popleft() + if len(window) >= cap: + return True + window.append(now) + return False + + +def _setup_audit_logging() -> None: + """Własny handler na stdout. Domyślna konfiguracja uvicorna nie obsługuje + naszych loggerów, więc wpisy INFO ginęły — dziennik istniał w kodzie, ale był + pusty. Niewidoczny dziennik jest gorszy niż jego brak (błąd zastany w PRE-17).""" + audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper()) + if not audit_log.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + audit_log.addHandler(handler) + audit_log.propagate = False + + +def audit_records(request: Request, count: int) -> None: + try: + request.state.audit_records = int(count) + except (TypeError, ValueError): + pass + + +def cookies_secure() -> bool: + """Ciasteczko tylko dla https. Wyłączane wyłącznie w pracy lokalnej po http — + inaczej przeglądarka po prostu go nie zapamięta i logowanie „nie działa" + bez żadnego komunikatu.""" + return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"} + + +def audit_login(request: Request, login: str, udane: bool) -> None: + """Zdarzenie logowania. Nieudane próby są ważniejsze od udanych: pojedyncza + nic nie znaczy, seria pod jednym adresem to ślad zgadywania haseł. Login tak, + hasło NIGDY.""" + audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s", + "logowanie-ok" if udane else "logowanie-odmowa", + login or "-", client_ip(request)) + + +def audit_logout(request: Request) -> None: + audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s", + getattr(request.state, "user", "") or "-", client_ip(request)) + + +def install(app) -> None: + from app import session + + _setup_audit_logging() + + # Fail-closed: konta bez klucza podpisu to sesje nie do odróżnienia od + # podrobionych. Lepiej, żeby usługa nie wstała. + if auth_enabled() and not session.available(): + raise session.SessionError( + "Konta demo są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza " + "podpisu logowanie nie miałoby czym się bronić. openssl rand -hex 32") + + if not auth_enabled(): + log.warning( + "UWAGA: brak kont (ASTRODEMO_USERS / ASTRODEMO_PASSWORD) — astrodemo stoi OTWARTY. " + "Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym " + "zbiorze baz, widząc nawzajem swoje wgrania." + ) + + @app.middleware("http") + async def _guard(request: Request, call_next): + if request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES): + return await call_next(request) + + started = time.monotonic() + if _rate_limited(client_ip(request)): + return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."}, + status_code=429, headers={"Retry-After": "60"}) + + user, odswiez = "", None + if auth_enabled(): + user = principal(request) or "" + if not user: + from urllib.parse import quote + + cel = request.url.path + dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else "" + return RedirectResponse(f"/logowanie{dokad}", status_code=303) + from app import session as _s + + dane = _s.read(request.cookies.get(_s.COOKIE)) + odswiez = _s.refresh(dane) if dane else None + request.state.user = user + + response = await call_next(request) + if odswiez: + from app import session as _s + + response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(), + **_s.cookie_params(secure=cookies_secure())) + records = getattr(request.state, "audit_records", None) + audit_log.info( + "AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f", + user or "-", client_ip(request), request.method, request.url.path, + response.status_code, "-" if records is None else records, + (time.monotonic() - started) * 1000) + return response diff --git a/services/astrodemo/app/session.py b/services/astrodemo/app/session.py new file mode 100644 index 0000000..0ea7083 --- /dev/null +++ b/services/astrodemo/app/session.py @@ -0,0 +1,190 @@ +"""Sesje logowania — podpisane ciasteczko (LOG-34). + +Kopia modułu z warstwy prezentacji, tak samo jak link_crypto: usługi są osobnymi +obrazami i nie importują się nawzajem. Różni je WYŁĄCZNIE nazwa ciasteczka. + + +CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje +hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć. +Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest +w środku, i może tę decyzję cofnąć. + +TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego: + + 1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu, + działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj". + 2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować. + Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania + o tym — i też nie wymaga magazynu. + 3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji, + który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja + nie poda licznika, poziomy 1 i 2 nadal działają. + +Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę +hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie +dostępu nie odbierałoby dostępu. + +KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie +byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie +poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania. + +Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt +w PRE-27 i AES w PRE-16. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time + +# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały +# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby +# z jednej, logując się do drugiej. +COOKIE = "astrodemo_sesja" + +# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji. +DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować +DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec + +# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde +# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów. +REFRESH_AFTER = 300 + + +class SessionError(RuntimeError): + """Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia.""" + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, "") or default) + except ValueError: + return default + + +def max_age() -> int: + return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE) + + +def idle_timeout() -> int: + return _env_int("SESSION_IDLE", DEFAULT_IDLE) + + +def secret() -> bytes: + """Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz. + + Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji + od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała.""" + raw = os.getenv("SESSION_SECRET", "").strip() + if not raw: + raise SessionError( + "Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. " + "Wygeneruj: openssl rand -hex 32") + if len(raw) < 32: + raise SessionError( + f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; " + f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.") + return raw.encode("utf-8") + + +def available() -> bool: + """Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie).""" + try: + secret() + except SessionError: + return False + return True + + +def fingerprint(secret_spec: str) -> str: + """Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza). + + NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót + zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt. + Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta + unieważniły jego sesje same z siebie.""" + return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16] + + +def _sign(payload: bytes) -> bytes: + return hmac.new(secret(), payload, hashlib.sha256).digest() + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _unb64(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str: + """Nowe ciasteczko sesji dla konta.""" + teraz = int(now if now is not None else time.time()) + payload = json.dumps( + {"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz}, + separators=(",", ":"), sort_keys=True).encode("utf-8") + return f"{_b64(payload)}.{_b64(_sign(payload))}" + + +def read(token: str | None, *, now: float | None = None) -> dict | None: + """Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza. + + None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości + dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga + zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania.""" + if not token or "." not in token: + return None + surowy, _, podpis = token.rpartition(".") + try: + payload = _unb64(surowy) + oczekiwany = _sign(payload) + podany = _unb64(podpis) + except (ValueError, SessionError): + return None + # Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile + # pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia. + if not hmac.compare_digest(oczekiwany, podany): + return None + try: + dane = json.loads(payload.decode("utf-8")) + login, odcisk = str(dane["u"]), str(dane["f"]) + wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"]) + except (ValueError, KeyError, TypeError): + return None + + teraz = int(now if now is not None else time.time()) + # Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie — + # w obu wypadkach nie ma powodu mu ufać. + if wyd > teraz + 60: + return None + if teraz - wyd > max_age() or teraz - akt > idle_timeout(): + return None + return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt} + + +def refresh(dane: dict, *, now: float | None = None) -> str | None: + """Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć. + + None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby + Set-Cookie — także po arkusze stylów i obrazki.""" + teraz = int(now if now is not None else time.time()) + if teraz - dane["aktywnosc"] < REFRESH_AFTER: + return None + payload = json.dumps( + {"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"], + "wyd": dane["wydano"], "akt": teraz}, + separators=(",", ":"), sort_keys=True).encode("utf-8") + return f"{_b64(payload)}.{_b64(_sign(payload))}" + + +def cookie_params(secure: bool = True) -> dict: + """Ustawienia ciasteczka wspólne dla ustawiania i kasowania. + + httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty + skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających + na tym, że obca strona wysyła żądanie w imieniu zalogowanego.""" + return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"} diff --git a/services/astrodemo/app/static/styles.css b/services/astrodemo/app/static/styles.css new file mode 100644 index 0000000..2e8699d --- /dev/null +++ b/services/astrodemo/app/static/styles.css @@ -0,0 +1,63 @@ +/* Arkusz CELOWO minimalny: dwa formularze i tabela wyników. Każda klasa ponad to + niosłaby nazwę funkcji, której tu nie ma. */ +:root { + --bg: #11121b; --panel: #191b26; --line: #2b2e3d; + --fg: #e8e9f0; --muted: #9aa0b4; --accent: #8ab4ff; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--bg); color: var(--fg); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +main { max-width: 880px; margin: 0 auto; padding: 1.5rem 1rem 3rem; } +.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line); + display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; } +.kto { display: flex; align-items: center; gap: .5rem; } +.whoami { color: var(--muted); font-size: .85rem; } +.wyloguj-form { display: inline; margin: 0; } +button.wyloguj { + background: none; border: 1px solid var(--line); color: var(--muted); + font: inherit; font-weight: 400; padding: .35rem .8rem; border-radius: 8px; cursor: pointer; +} +button.wyloguj:hover { color: var(--fg); } + +/* Ekran logowania — wąski, bez niczego poza formularzem. */ +.login-card label { display: block; margin-top: .75rem; } +.login-card input { width: 100%; } +h1 { margin: 0; font-size: 1.4rem; letter-spacing: .5px; } +h2 { margin: 0 0 .3rem; font-size: 1.05rem; } +h3 { margin: 0 0 .5rem; font-size: 1rem; } + +.card { + margin-top: 1.25rem; padding: 1rem 1.25rem; + background: var(--panel); border: 1px solid var(--line); border-radius: 12px; +} +.row { display: flex; gap: .75rem; flex-wrap: wrap; align-items: flex-end; margin-top: .6rem; } +label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 10rem; } +input { + width: 100%; padding: .45rem .6rem; background: #0e0f18; color: var(--fg); + border: 1px solid var(--line); border-radius: 8px; font: inherit; +} +button { + padding: .5rem 1.25rem; background: var(--accent); color: #10121a; + border: none; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer; +} + +table { width: 100%; border-collapse: collapse; margin-top: .3rem; } +td { padding: .35rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; font-size: .92rem; } +td.key { color: var(--accent); white-space: nowrap; padding-right: 1rem; } +tr:last-child td { border-bottom: none; } + +.sig { margin-top: .75rem; } +.sig-head { color: var(--muted); font-size: .85rem; } +.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; } +.muted { color: var(--muted); } +.small { font-size: .85rem; } +.foot { margin-top: 2.5rem; color: var(--muted); font-size: .8rem; } + +.alert, .ok { + margin: 1rem 0; padding: .6rem .9rem; border-radius: 8px; font-size: .92rem; + border: 1px solid var(--line); border-left-width: 4px; +} +.alert { border-left-color: #b8860b; background: rgba(184, 134, 11, .1); } +.ok { border-left-color: var(--accent); background: rgba(138, 180, 255, .08); } diff --git a/services/astrodemo/app/templates/base.html b/services/astrodemo/app/templates/base.html new file mode 100644 index 0000000..3a54f58 --- /dev/null +++ b/services/astrodemo/app/templates/base.html @@ -0,0 +1,30 @@ + + +
+ + +{{ error }}
{% endif %} +{% if done %}{{ done }}
{% endif %} + ++ Wgrany plik zaczyna być używany od razu przy wyszukiwaniu interpretacji. +
+ +| {{ s.expanded }} | +{{ s.effect }} | +
+ Nie znaleziono interpretacji dla tych danych. Sprawdź, czy dodano bazę + zawierającą pasujące opisy. +
+ {% endif %} +{% endif %} +{% endblock %} diff --git a/services/astrodemo/app/templates/logowanie.html b/services/astrodemo/app/templates/logowanie.html new file mode 100644 index 0000000..6af2388 --- /dev/null +++ b/services/astrodemo/app/templates/logowanie.html @@ -0,0 +1,36 @@ + + + + + +Wylogowano.
+ {% endif %} + {% if blad %}{{ blad }}
{% endif %} + + + ++ Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała + Ci dostęp — kont nie da się odzyskać samodzielnie. +
+