astrodemo: zmiana nazwy, rebase na mastera i domknięcie wycieków
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m50s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 8s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4m43s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astrodemo (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 6s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s

Pierwszy z pięciu kroków budowy trzech produktów: astrodemo (dwie funkcje) →
astroklient (pełne astro bez AI) → astrololo (wszystko). Nazwa „astroklient"
zostaje zwolniona dla warstwy pośredniej, więc dotychczasowe astroklient-demo
nazywa się teraz astrodemo.

REBASE. Cztery commity demo przeniesione na aktualnego mastera. Konflikt był
jeden — rejestr wymagań (xlsx, binarny, git go nie scali). Master dodał LOG-34,
gałąź demo PRE-28 i PRE-29; żaden wspólny wiersz się nie różnił, więc scalone
ręcznie: 148 pozycji, wszystkie trzy obecne.

ZMIANA NAZWY. Katalog, ciasteczko sesji (astrodemo_sesja), zmienne
ASTRODEMO_USERS/USER/PASSWORD, CI, README, docstringi. Ponieważ PR z demo nigdy
nie został zmergowany, usługa nie jest nigdzie wdrożona — zmiana nazw niczego
nie migruje i nikogo nie wylogowuje.

WYCIEKI. astrodemo powstało przed audytem z PRE-27, więc miało komplet tych
samych dziur:

- /static omijało bramkę (PUBLIC_PREFIXES), a pierwszy komentarz w styles.css
  brzmiał „Nie kopiujemy stylów pełnej aplikacji" — czyli anonimowy curl
  dowiadywał się, że istnieje pełna aplikacja. Zasoby idą teraz trasą z jawną
  listą, komentarze są zdejmowane przy serwowaniu.
- Komunikat awarii wypisywał na ekran treść wyjątku httpx, z nazwą usługi
  i portem. Teraz jedno neutralne zdanie, szczegóły do dziennika.
- /health oddawał nazwę warstwy. Teraz samo „ok".
- Dziesięć komentarzy i docstringów tłumaczyło decyzje przez porównanie
  z „pełną aplikacją". Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie,
  przeczyta też komentarze. Przepisane tak, żeby opisywały tę usługę samą
  w sobie.
- Nagłówek main.py twierdził, że demo dzieli pulę plików z produkcją. To
  nieprawda od PRE-29 (pule per konto) — opis poprawiony.

ZAPORA SŁOWNIKOWA, dwupoziomowa. Poziom „wszędzie" (także w kodzie serwera, bo
obraz się oddaje) obejmuje wzmianki o większym rodzeństwie, o modelu językowym
i o funkcjach, których tu nie ma. Poziom „do przeglądarki" dokłada słownictwo
mechanizmów. Test sprawdza odpowiedzi ORAZ drzewo plików.

Jeden wyjątek jest jawny i opisany: stałe protokołu łącza (X-Astrololo-Token,
X-Astrololo-Enc, typ treści, etykieta HKDF) niosą nazwę rodziny produktów. Są
wspólne z warstwą logiczną, więc zmiana wymaga jednoczesnej podmiany we
wszystkich usługach i rotacji — osobna decyzja. Osobny test pilnuje warunku, pod
jakim to zostaje: że nie docierają do przeglądarki. Wcześniej przechodziły tylko
dlatego, że regex nie dopasowywał po myślniku — przypadek, nie decyzja.

Przy okazji: komunikat „ramka bez znacznika astrololo" zmieniony na neutralny we
WSZYSTKICH PIĘCIU kopiach link_crypto.py (presentation, astrodemo, logic, data,
render), żeby nie rozjechały się przed scaleniem w rdzeń. Te kopie to 2625 linii
tego samego kodu.

Testy: astrodemo 27, presentation 358, logic 342, data 42, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 12:05:40 +02:00
parent c73ba6a964
commit 2e9d3706ec
24 changed files with 327 additions and 99 deletions
View File
@@ -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()
+22
View File
@@ -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()
+525
View File
@@ -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")
+255
View File
@@ -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"}
+266
View File
@@ -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
+190
View File
@@ -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": "/"}
+63
View File
@@ -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); }
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrodemo · {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main>
<header class="topbar">
<h1>astrodemo</h1>
{# Kto jest zalogowany i wyjście. Demo bywa używane na cudzych komputerach,
więc bez tego pierwszy klient zostawia otwartą sesję drugiemu. #}
{% if auth_on() %}
<div class="kto">
<span class="whoami">{{ current_user(request) }}</span>
<form method="post" action="/wyloguj" class="wyloguj-form">
<button type="submit" class="wyloguj">Wyloguj</button>
</form>
</div>
{% endif %}
{# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie,
a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #}
</header>
{% block content %}{% endblock %}
<footer class="foot">wersja demonstracyjna</footer>
</main>
</body>
</html>
@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}Interpretacja{% endblock %}
{% block content %}
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
{% if done %}<p class="ok">{{ done }}</p>{% endif %}
<section class="card">
<h2>Dodaj bazę</h2>
<p class="muted small">
Wgrany plik zaczyna być używany od razu przy wyszukiwaniu interpretacji.
</p>
<form method="post" action="/plik" enctype="multipart/form-data">
<div class="row">
<label>Plik <input type="file" name="upload" required></label>
<button type="submit">Dodaj</button>
</div>
</form>
</section>
<section class="card">
<h2>Zapytaj o interpretację urodzeniową</h2>
<form method="post" action="/">
<div class="row">
<label>Data urodzenia
<input type="date" name="date" value="{{ form.date or '' }}" required></label>
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required></label>
<label>Offset względem GMT (h)
<input type="number" name="tz_offset" step="0.5" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}"></label>
</div>
<div class="row">
<label>Szerokość (lat, + N)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}" required></label>
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}" required></label>
<button type="submit">Szukaj interpretacji</button>
</div>
</form>
</section>
{% if result %}
<p class="meta">
Moment: <strong>{{ moment }}</strong>
{% if result.engine %}· silnik: {{ result.engine }}{% endif %}
{% if result.objects %}· obiektów: {{ result.objects | length }}{% endif %}
</p>
{# Kształt odpowiedzi: objects[] → facets[] → samples[]. Pytamy bez grupowania,
więc `groups` tu nie wystąpi i nie ma czego obsługiwać. #}
{% set znaleziono = namespace(ile=0) %}
{% for o in result.objects or [] %}
{% set trafienia = o.facets | selectattr('samples') | list %}
{% if trafienia %}
{% set znaleziono.ile = znaleziono.ile + trafienia | length %}
<section class="card">
<h3>{{ o.object }}
<span class="muted">w {{ o.sign }}{% if o.house %}, {{ o.house }}. dom{% endif %}
{%- if o.direction == 'Rx' %} · Rx{% endif %}</span>
</h3>
{% for f in trafienia %}
<div class="sig">
<div class="sig-head">{{ f.label }} <span class="muted small">— {{ f.count }} dopasowań</span></div>
<table>
<tbody>
{% for s in f.samples %}
<tr>
<td class="key" title="{{ s.significator }}">{{ s.expanded }}</td>
<td>{{ s.effect }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</section>
{% endif %}
{% endfor %}
{% if znaleziono.ile == 0 %}
<p class="muted">
Nie znaleziono interpretacji dla tych danych. Sprawdź, czy dodano bazę
zawierającą pasujące opisy.
</p>
{% endif %}
{% endif %}
{% endblock %}
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrodemo · logowanie</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main class="login-main">
<header class="topbar"><h1>astrodemo</h1></header>
{% if request.query_params.get('wylogowano') %}
<p class="ok">Wylogowano.</p>
{% endif %}
{% if blad %}<p class="alert">{{ blad }}</p>{% endif %}
<form method="post" action="/logowanie" class="card login-card">
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
<input type="hidden" name="dokad" value="{{ dokad }}">
<label>Login
<input type="text" name="login" value="{{ login or '' }}" required
autocomplete="username" autofocus></label>
<label>Hasło
<input type="password" name="haslo" required autocomplete="current-password"></label>
<div class="actions"><button type="submit">Zaloguj</button></div>
</form>
<p class="muted small login-note">
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.
</p>
</main>
</body>
</html>