Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ace76183e5 | |||
| f1956a08ff | |||
| e8f868e907 | |||
| 56fdf01d7d | |||
| 23416cb1f9 | |||
| 473d059a6a | |||
| 929b691238 | |||
| 114b7eebdf |
Binary file not shown.
@@ -0,0 +1,296 @@
|
|||||||
|
# Wdrożenie PRE-16 — HTTPS na wejściu i szyfrowanie łączy między warstwami
|
||||||
|
|
||||||
|
Instrukcja krok po kroku. **Kolejność ma znaczenie** — punkt „Dlaczego taka
|
||||||
|
kolejność" niżej tłumaczy, co się stanie, jeśli ją zamienić.
|
||||||
|
|
||||||
|
Dotyczy dwóch pull requestów:
|
||||||
|
|
||||||
|
| Repo | PR | Co wnosi |
|
||||||
|
|---|---|---|
|
||||||
|
| `gitea/astrololo` | [#21](https://gitea.czernobog.pl/gitea/astrololo/pulls/21) | kod: szyfrowanie łączy, limit żądań za proxy |
|
||||||
|
| `gitea/deploy` | [#4](https://gitea.czernobog.pl/gitea/deploy/pulls/4) | manifesty: Ingress, certyfikat, klucze łączy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Co się właściwie zmienia
|
||||||
|
|
||||||
|
**Na wejściu do aplikacji.** Dotąd logowanie szło przez HTTP Basic po zwykłym
|
||||||
|
http — czyli hasło leciało siecią w postaci trywialnej do podsłuchania (base64 to
|
||||||
|
nie szyfrowanie). Po zmianie wejście jest po https, a http odsyła na https.
|
||||||
|
Przy okazji **odblokowują się dwie funkcje zepsute dziś z tego samego powodu**:
|
||||||
|
geolokalizacja („Tu i teraz") i kopiowanie promptu do schowka działają wyłącznie
|
||||||
|
w tzw. secure context i po http po prostu odmawiały.
|
||||||
|
|
||||||
|
**Między warstwami.** Prezentacja, logika i dane rozmawiały ze sobą otwartym
|
||||||
|
tekstem wewnątrz klastra. Token międzywarstwowy mówił *kto* pyta, ale nie ukrywał
|
||||||
|
*czego dotyczy odpowiedź* — a płyną nią surowe wiersze oryginalnych baz. Teraz
|
||||||
|
każde ciało żądania i odpowiedzi jest szyfrowane **AES-256-GCM**, osobnym kluczem
|
||||||
|
na każdą parę rozmówców.
|
||||||
|
|
||||||
|
**Wejście na świat pozostaje jedno: prompt do modelu.** Ta zmiana niczego tu nie
|
||||||
|
rusza — dotyczy wyłącznie ruchu wewnątrz sieci i wejścia z przeglądarki.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zanim zaczniesz — stan wyjściowy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get deploy,svc
|
||||||
|
kubectl -n astrololo get secret # powinny być: astrololo-auth, gitea-registry
|
||||||
|
kubectl -n kube-system get svc traefik -o jsonpath='{.status.loadBalancer.ingress[*].ip}'; echo
|
||||||
|
```
|
||||||
|
|
||||||
|
Zanotuj adres Traefika — będzie potrzebny w kroku 3. Sprawdź też, czy działa
|
||||||
|
aplikacja w obecnej postaci (przez NodePort), żeby mieć punkt odniesienia.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 1 — sekret z kluczami łączy
|
||||||
|
|
||||||
|
**Przed czymkolwiek innym.** Klucze muszą istnieć, zanim pody spróbują wstać
|
||||||
|
z nową konfiguracją, bo bez nich celowo **nie wystartują**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo create secret generic astrololo-link \
|
||||||
|
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
|
||||||
|
--from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Kluczy nikt nigdy nie musi oglądać — służą tylko usługom. Nie ma ich w repo
|
||||||
|
GitOps i **nie ma ich tam wkładać**: cokolwiek trafi do gita, zostaje w historii
|
||||||
|
na zawsze.
|
||||||
|
|
||||||
|
Dwa osobne klucze to nie ozdobnik. Przejęcie klucza prezentacji nie daje dostępu
|
||||||
|
do warstwy danych, gdzie leżą całe bazy. Logika dostaje oba, bo rozmawia w obie
|
||||||
|
strony; prezentacja i dane dostają wyłącznie swój.
|
||||||
|
|
||||||
|
Sprawdź:
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
|
||||||
|
# oczekiwane: dwa klucze, każdy 64 znaki po odkodowaniu (32 bajty)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 2 — cert-manager
|
||||||
|
|
||||||
|
Jednorazowo, na cały klaster:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
|
||||||
|
kubectl -n cert-manager rollout status deploy/cert-manager deploy/cert-manager-webhook --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
Poczekaj, aż **webhook** będzie gotowy — dopóki nie wstanie, tworzenie obiektów
|
||||||
|
`Certificate` kończy się błędem połączenia i wygląda jak zepsuty manifest.
|
||||||
|
|
||||||
|
Sprawdź:
|
||||||
|
```bash
|
||||||
|
kubectl get crd | grep cert-manager | head -3 # muszą się pojawić
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Dlaczego własne CA, a nie Let's Encrypt.** Klaster stoi w LAN (Traefik trzyma
|
||||||
|
> LoadBalancera na adresach 192.168.1.x), więc walidacja HTTP-01 nie ma jak dojść
|
||||||
|
> z internetu, a DNS-01 wymagałby trzymania w klastrze tokena API do domeny.
|
||||||
|
> Własne CA nie potrzebuje niczego z zewnątrz i odnawia certyfikaty samo. Cena:
|
||||||
|
> raz na urządzenie importujesz korzeń (krok 6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 3 — DNS
|
||||||
|
|
||||||
|
Wpis `astrololo.czernobog.pl` → adres Traefika z kroku „stan wyjściowy”.
|
||||||
|
W routerze, lokalnym DNS-ie albo doraźnie w `/etc/hosts`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "192.168.1.73 astrololo.czernobog.pl" | sudo tee -a /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
**To nie jest krok opcjonalny.** Service `presentation` przestaje być NodePortem
|
||||||
|
(był drugą, nieszyfrowaną drogą do aplikacji — czyli obejściem całego PRE-16),
|
||||||
|
więc po wdrożeniu manifestów nazwa jest jedynym wejściem. Awaryjnie zawsze zostaje:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo port-forward svc/presentation 8000:8000 # http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 4 — merge PR-a aplikacji (astrololo #21)
|
||||||
|
|
||||||
|
Teraz, **przed** manifestami.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr merge --login gitea --repo gitea/astrololo 21
|
||||||
|
```
|
||||||
|
|
||||||
|
Po merge'u CI zbuduje obrazy, a image-updater sam podbije tagi w repo `deploy`,
|
||||||
|
skąd ArgoCD wymieni pody. Poczekaj, aż to się przetoczy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
|
||||||
|
kubectl -n astrololo get pods -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Na tym etapie **nic się jeszcze nie szyfruje** — nowy kod to potrafi, ale zmienne
|
||||||
|
z kluczami dokłada dopiero PR do `deploy`. Aplikacja działa dokładnie jak dotąd.
|
||||||
|
To celowe: chcemy, żeby *cała* obsada podów umiała szyfrować, zanim ktokolwiek
|
||||||
|
tego zażąda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 5 — merge PR-a manifestów (deploy #4)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr merge --login gitea --repo gitea/deploy 4
|
||||||
|
```
|
||||||
|
|
||||||
|
ArgoCD zsynchronizuje się sam (`automated`, `selfHeal`). Wjeżdża naraz: Ingress,
|
||||||
|
certyfikat, zmienne z kluczami, `TRUST_PROXY` i zdjęcie NodePortu.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n argocd get application astrololo
|
||||||
|
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
|
||||||
|
kubectl -n astrololo get certificate # astrololo-ca i astrololo-tls: READY=True
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Spodziewaj się kilkudziesięciu sekund błędów w trakcie.** Pody wymieniają się
|
||||||
|
> po kolei, więc przez chwilę stara prezentacja (jeszcze bez klucza) rozmawia
|
||||||
|
> z nową logiką (już z kluczem) i dostaje odmowę. To zamierzone: alternatywą byłby
|
||||||
|
> tryb „przyjmuj i szyfrowane, i jawne”, który zwykle zostaje włączony na zawsze.
|
||||||
|
|
||||||
|
Merge nie cofnie tagów obrazów — PR dotyka w `kustomization.yaml` wyłącznie listy
|
||||||
|
`resources`, nie bloku `images`, więc git złoży to z nowszymi tagami z mastera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 6 — zaufanie do własnego CA (raz na urządzenie)
|
||||||
|
|
||||||
|
Bez tego przeglądarka pokaże ostrzeżenie o certyfikacie. Korzeń jest ważny 10 lat,
|
||||||
|
więc robisz to raz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get secret astrololo-ca -o jsonpath='{.data.ca\.crt}' \
|
||||||
|
| base64 -d > astrololo-ca.crt
|
||||||
|
|
||||||
|
# macOS — do systemowego zaufania (poprosi o hasło administratora)
|
||||||
|
sudo security add-trusted-cert -d -r trustRoot \
|
||||||
|
-k /Library/Keychains/System.keychain astrololo-ca.crt
|
||||||
|
|
||||||
|
# Linux (Debian/Ubuntu)
|
||||||
|
sudo cp astrololo-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
Firefox ma **własny** magazyn certyfikatów — import przez *Ustawienia →
|
||||||
|
Prywatność i bezpieczeństwo → Wyświetl certyfikaty → Organy certyfikacji*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 7 — sprawdzenie, że działa to, co miało zadziałać
|
||||||
|
|
||||||
|
### Wejście po https
|
||||||
|
```bash
|
||||||
|
curl -sI http://astrololo.czernobog.pl/ | head -2 # 301 → https
|
||||||
|
curl -s -o /dev/null -w "bez hasła: %{http_code}\n" https://astrololo.czernobog.pl/
|
||||||
|
curl -s -o /dev/null -w "z hasłem: %{http_code}\n" -u astrololo:'<hasło>' https://astrololo.czernobog.pl/
|
||||||
|
curl -sI -u astrololo:'<hasło>' https://astrololo.czernobog.pl/ | grep -i strict-transport
|
||||||
|
```
|
||||||
|
Oczekiwane: **301**, **401**, **200**, nagłówek HSTS obecny. Brak ostrzeżenia
|
||||||
|
o certyfikacie w przeglądarce oznacza, że krok 6 się udał.
|
||||||
|
|
||||||
|
### W przeglądarce
|
||||||
|
Kliknij **„Tu i teraz"** — powinno pobrać lokalizację (po http odmawiało).
|
||||||
|
Wygeneruj prompt i kliknij **kopiuj** — schowek powinien zadziałać bez obejść.
|
||||||
|
|
||||||
|
### Szyfrowanie łączy — sprawdzenie wprost
|
||||||
|
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **bez klucza**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo exec deploy/presentation -- \
|
||||||
|
python -c "
|
||||||
|
import httpx, os
|
||||||
|
r = httpx.post('http://logic:8001/chart/report',
|
||||||
|
json={'when_utc':'1984-04-30T09:20:00+00:00','lat':50.06,'lon':19.94},
|
||||||
|
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||||
|
print(r.status_code, r.text[:120])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Zwróć uwagę, że
|
||||||
|
żądanie miało **prawidłowy token** — sam token już nie wystarcza, i o to chodziło.
|
||||||
|
|
||||||
|
To samo w dół, do warstwy danych:
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo exec deploy/logic -- \
|
||||||
|
python -c "
|
||||||
|
import httpx, os
|
||||||
|
r = httpx.post('http://data:8002/search',
|
||||||
|
json={'key':'significator','value':'[Sat','exact':False,'limit':5},
|
||||||
|
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||||
|
print(r.status_code, r.text[:120])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logi startowe
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo logs deploy/logic | grep -i "łącze\|UWAGA"
|
||||||
|
```
|
||||||
|
Powinno być `łącze szyfrowane (AES-256-GCM…)`. Jeśli widzisz ostrzeżenie
|
||||||
|
o rozmowie **jawnym tekstem** — klucz nie doszedł do poda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dlaczego taka kolejność
|
||||||
|
|
||||||
|
| Kolejność | Skutek zamiany |
|
||||||
|
|---|---|
|
||||||
|
| Sekret **przed** manifestami | `LINK_ENCRYPTION_REQUIRED=true` bez klucza celowo wywraca start. Pody wpadną w CrashLoop i będą tak siedzieć do czasu utworzenia sekretu. |
|
||||||
|
| cert-manager **przed** manifestami | API odrzuci `Certificate`/`Issuer` jako nieznane rodzaje zasobów, ArgoCD pokaże aplikację jako niezsynchronizowaną i sam tego nie naprawi. |
|
||||||
|
| DNS **przed** manifestami | NodePort znika razem z nimi. Bez wpisu DNS zostaje tylko `port-forward`. |
|
||||||
|
| Aplikacja **przed** manifestami | Odwrotnie: manifesty włączyłyby szyfrowanie na obrazach, które go nie znają — wszystkie żądania kończyłyby się odmową do czasu przebudowy obrazów. |
|
||||||
|
|
||||||
|
Fail-closed w obie strony jest zamierzony. Usługa, która wstała i **po cichu nie
|
||||||
|
szyfruje**, jest gorsza niż pod w CrashLoop — awarii nie widać, a bazy jadą
|
||||||
|
otwartym tekstem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wycofanie
|
||||||
|
|
||||||
|
Manifestów: `git revert` merge'a w `deploy` — ArgoCD samo wróci do NodePortu
|
||||||
|
i ruchu bez szyfrowania. Kod aplikacji **nie wymaga wycofania**: bez zmiennych
|
||||||
|
`LINK_KEY_*` moduł przepuszcza ruch jak dotąd (i głośno o tym mówi w logach).
|
||||||
|
|
||||||
|
Certyfikat i CA zostają w namespace; usunięcie: `kubectl -n astrololo delete
|
||||||
|
certificate astrololo-ca astrololo-tls`. cert-managera można zostawić — nie
|
||||||
|
przeszkadza.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gdy coś nie gra
|
||||||
|
|
||||||
|
| Objaw | Przyczyna | Co zrobić |
|
||||||
|
|---|---|---|
|
||||||
|
| Pody w `CrashLoopBackOff`, w logach `LINK_ENCRYPTION_REQUIRED … nie ustawiony` | brak sekretu `astrololo-link` | krok 1, potem `rollout restart` |
|
||||||
|
| `400 Łącze międzywarstwowe wymaga szyfrowania` przy normalnym korzystaniu | jedna warstwa ma klucz, druga nie (albo trwa rollout) | `rollout status`; sprawdź, czy wszystkie trzy pody mają zmienną |
|
||||||
|
| `400 Nie udało się odczytać zaszyfrowanego żądania` | klucze po obu stronach łącza są **różne** | wymień sekret i zrestartuj **wszystkie trzy** naraz |
|
||||||
|
| `Certificate` stoi w `READY=False` | webhook cert-managera jeszcze nie wstał | `kubectl -n cert-manager get pods`, poczekaj i sprawdź `kubectl -n astrololo describe certificate astrololo-tls` |
|
||||||
|
| Przeglądarka: „połączenie nie jest prywatne” | korzeń CA nieimportowany na tym urządzeniu | krok 6 (pamiętaj, że Firefox ma osobny magazyn) |
|
||||||
|
| `404` z Traefika pod adresem aplikacji | DNS wskazuje gdzie indziej niż LoadBalancer Traefika | porównaj `dig +short astrololo.czernobog.pl` z adresem z kroku „stan wyjściowy” |
|
||||||
|
| Limit żądań odcina wszystkich naraz | brak `TRUST_PROXY=true` — cały ruch liczony jako jeden klient | sprawdź zmienną w `deploy/presentation` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Czego to nie załatwia
|
||||||
|
|
||||||
|
- **Szyfrowane są ciała żądań, nie nagłówki.** Ścieżka (`/search`) i token
|
||||||
|
międzywarstwowy jadą czytelnie. Sam token nikomu nic nie daje — bez klucza łącza
|
||||||
|
każde żądanie kończy się odmową — ale metadanych to nie ukrywa. Pełne ukrycie
|
||||||
|
wymagałoby mTLS.
|
||||||
|
- **Własne CA to nie publiczne zaufanie.** Każde nowe urządzenie wymaga importu
|
||||||
|
korzenia. Gdyby aplikacja miała kiedyś wyjść na świat, właściwą drogą jest
|
||||||
|
Let's Encrypt przez DNS-01.
|
||||||
|
- **NFS z plikami baz** stoi obok aplikacji — kto ma dostęp do share'u, bierze
|
||||||
|
pliki z pominięciem wszystkich powyższych zabezpieczeń. Do zamknięcia po stronie
|
||||||
|
infrastruktury (eksport tylko dla IP węzłów, `root_squash`, najlepiej read-only).
|
||||||
|
- **Sekrety w etcd** są tylko zakodowane base64. Docelowo: szyfrowanie etcd
|
||||||
|
at-rest albo Sealed Secrets / SOPS.
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""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"
|
||||||
|
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 astrololo")
|
||||||
|
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")
|
||||||
@@ -10,7 +10,7 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from app import security
|
from app import link_crypto, security
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||||
from app.providers.factory import build_provider
|
from app.providers.factory import build_provider
|
||||||
@@ -26,6 +26,9 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
||||||
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
||||||
|
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
||||||
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
|
link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/search", response_model=SearchResult)
|
@app.post("/search", response_model=SearchResult)
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ openpyxl>=3.1
|
|||||||
pyarrow>=18.0
|
pyarrow>=18.0
|
||||||
SQLAlchemy>=2.0
|
SQLAlchemy>=2.0
|
||||||
pydantic>=2.10
|
pydantic>=2.10
|
||||||
|
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||||
|
cryptography>=44.0
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +20,13 @@ def _auth_headers() -> dict[str, str]:
|
|||||||
return {"X-Astrololo-Token": token} if token else {}
|
return {"X-Astrololo-Token": token} if token else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _link() -> link_crypto.Link | None:
|
||||||
|
"""Klucz łącza logika↔dane. Czytany przy każdym wywołaniu, bo konfiguracja
|
||||||
|
może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
|
||||||
|
key = link_crypto.key_from_env(link_crypto.ENV_LOGIC_DATA)
|
||||||
|
return link_crypto.Link(key) if key else None
|
||||||
|
|
||||||
|
|
||||||
class DataClient:
|
class DataClient:
|
||||||
def __init__(self, base_url: str | None = None) -> None:
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||||
@@ -33,11 +41,13 @@ class DataClient:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||||
r = client.post(f"{self.base_url}/search", json=payload, headers=_auth_headers())
|
return link_crypto.call_json(client, "POST", f"{self.base_url}/search",
|
||||||
r.raise_for_status()
|
payload=payload, headers=_auth_headers(),
|
||||||
return r.json()
|
link=_link())
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
def health(self) -> dict[str, Any]:
|
||||||
|
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||||
|
# nie mają, a nie przechodzi tędy nic z baz.
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|||||||
@@ -36,13 +36,39 @@ def _shift_pos(pdict: dict, off: float) -> None:
|
|||||||
|
|
||||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||||
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
||||||
|
from app.engine import glyphs as GL
|
||||||
from app.engine.aspects import find_aspects
|
from app.engine.aspects import find_aspects
|
||||||
|
from app.engine.houses import mean_obliquity
|
||||||
|
from app.engine.out_of_zodiac import (
|
||||||
|
declination,
|
||||||
|
find_antiscia,
|
||||||
|
find_declination_aspects,
|
||||||
|
is_out_of_bounds,
|
||||||
|
)
|
||||||
|
|
||||||
positions = engine.positions(moment)
|
positions = engine.positions(moment)
|
||||||
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||||
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
||||||
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
||||||
|
|
||||||
|
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
|
||||||
|
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
|
||||||
|
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
|
||||||
|
# z surowych długości/szerokości silnika.
|
||||||
|
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
||||||
|
result["obliquity"] = round(eps, 6)
|
||||||
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
|
dec = declination(obj.longitude, obj.latitude, eps)
|
||||||
|
pdict["declination"] = round(dec, 4)
|
||||||
|
if is_out_of_bounds(dec, eps):
|
||||||
|
pdict["out_of_bounds"] = True
|
||||||
|
result["parallels"] = find_declination_aspects(result["positions"])
|
||||||
|
result["antiscia"] = find_antiscia(result["positions"])
|
||||||
|
|
||||||
|
# glify aspektów (LOG-22) — symbol aspektu nie zależy od zodiaku
|
||||||
|
for a in result["aspects"]:
|
||||||
|
a["glyph"] = GL.aspect_glyph(a["aspect"])
|
||||||
|
|
||||||
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
||||||
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
||||||
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
||||||
@@ -52,6 +78,13 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
for pdict in result["positions"]:
|
for pdict in result["positions"]:
|
||||||
_shift_pos(pdict, off)
|
_shift_pos(pdict, off)
|
||||||
|
|
||||||
|
# glify obiektów (LOG-22): symbol planety jest niezmienniczy, symbol ZNAKU
|
||||||
|
# zależy od zodiaku, więc po przesunięciu. Pierścień 12 znaków pod kosmogram.
|
||||||
|
for pdict in result["positions"]:
|
||||||
|
pdict["glyph"] = GL.glyph_for(pdict["name"])
|
||||||
|
pdict["sign_glyph"] = GL.sign_glyph(pdict["sign"])
|
||||||
|
result["sign_glyphs"] = [{"sign": s, "glyph": GL.sign_glyph(s)} for s in SIGNS]
|
||||||
|
|
||||||
if not hasattr(engine, "sidereal"):
|
if not hasattr(engine, "sidereal"):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -68,9 +101,12 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
||||||
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
||||||
}
|
}
|
||||||
|
for a in result["angles"].values(): # glif znaku osi (LOG-22)
|
||||||
|
a["sign_glyph"] = GL.sign_glyph(a["sign"])
|
||||||
result["cusps"] = [
|
result["cusps"] = [
|
||||||
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||||
"in_sign": in_sign(norm360(c - off))}
|
"in_sign": in_sign(norm360(c - off)),
|
||||||
|
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
||||||
for i, c in enumerate(cusp_list)
|
for i, c in enumerate(cusp_list)
|
||||||
]
|
]
|
||||||
for pdict, obj in zip(result["positions"], positions):
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
@@ -89,7 +125,9 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
||||||
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
||||||
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
||||||
"house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
|
"house": H.assign_house(lot["longitude"], cusp_list), # dom po długości tropikalnej
|
||||||
|
"glyph": GL.glyph_for(lot["name"]), # ⊗ dla Fortuny; reszta None (LOG-22)
|
||||||
|
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(lot["longitude"] - off))])}
|
||||||
for lot in compute_lots(pts, day, lots_method)
|
for lot in compute_lots(pts, day, lots_method)
|
||||||
]
|
]
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||||
|
|
||||||
|
Litery i słowa (Sa Pis 26°08' conj Fortune) ↔ glify (♄ ♓ 26°08' ☌ ⊗). Potrzebne
|
||||||
|
pod rysowanie kosmogramu (PRE-12) — planety i znaki na kole rysujemy symbolami.
|
||||||
|
|
||||||
|
DWIE zasady z bazy wymagań, obie krytyczne:
|
||||||
|
* DAN-18: TYLKO tekstowy Unicode, NIGDY emoji. Część znaków (zodiak, ♀, ♂) ma
|
||||||
|
domyślnie prezentację emoji — kolorowy kwadrat zamiast czarno-białego glifu,
|
||||||
|
nieczytelny i niesterowalny przez CSS. Wymuszamy prezentację tekstową
|
||||||
|
selektorem wariantu U+FE0E (NIE U+FE0F, który robi odwrotnie).
|
||||||
|
* DAN-17: łańcuch musi znosić dowolny Unicode. Reverse (symbol→tekst)
|
||||||
|
normalizuje wejście, zdejmując selektory wariantu, żeby glif z FE0E i bez
|
||||||
|
dawał ten sam wynik.
|
||||||
|
|
||||||
|
Glify zapisane przez \\u — jednoznaczne code-pointy, odporne na zniekształcenia
|
||||||
|
edytorów i samodokumentujące (widać, który to znak Unicode).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# selektory wariantu prezentacji
|
||||||
|
_TEXT = "︎" # VS15 — wymusza glif tekstowy (czarno-biały)
|
||||||
|
_EMOJI = "️" # VS16 — prezentacja emoji; NIGDY nie emitujemy, zdejmujemy przy reverse
|
||||||
|
|
||||||
|
|
||||||
|
def _t(cp: str) -> str:
|
||||||
|
"""Znak z wymuszoną prezentacją tekstową (dokleja VS15)."""
|
||||||
|
return cp + _TEXT
|
||||||
|
|
||||||
|
|
||||||
|
# ── planety i światła ────────────────────────────────────────────────────
|
||||||
|
# ♀ i ♂ mają wariant emoji → wymuszamy tekst; reszta jest tekstowa domyślnie.
|
||||||
|
PLANET = {
|
||||||
|
"Sun": "☉", # ☉
|
||||||
|
"Moon": "☽", # ☽
|
||||||
|
"Mercury": "☿", # ☿
|
||||||
|
"Venus": _t("♀"), # ♀︎
|
||||||
|
"Mars": _t("♂"), # ♂︎
|
||||||
|
"Jupiter": "♃", # ♃
|
||||||
|
"Saturn": "♄", # ♄
|
||||||
|
"Uranus": "♅", # ♅
|
||||||
|
"Neptune": "♆", # ♆
|
||||||
|
"Pluto": "♇", # ♇
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── punkty wirtualne ─────────────────────────────────────────────────────
|
||||||
|
POINT = {
|
||||||
|
"North Node": "☊", # ☊
|
||||||
|
"South Node": "☋", # ☋
|
||||||
|
"Lilith": "⚸", # ⚸ (Black Moon Lilith)
|
||||||
|
"Chiron": "⚷", # ⚷
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Lots (punkty arabskie) ───────────────────────────────────────────────
|
||||||
|
# Standardowy glif ma tylko Fortuna (⊗). Reszta Lotów nie ma powszechnie
|
||||||
|
# wspieranego symbolu → zwracamy None i UI pokazuje nazwę.
|
||||||
|
LOT = {
|
||||||
|
"Fortune": "⊗", # ⊗
|
||||||
|
"Part of Fortune": "⊗",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── znaki zodiaku (wszystkie mają wariant emoji → wszystkie z VS15) ──────
|
||||||
|
_SIGN_CP = {
|
||||||
|
"Aries": "♈", "Taurus": "♉", "Gemini": "♊", "Cancer": "♋",
|
||||||
|
"Leo": "♌", "Virgo": "♍", "Libra": "♎", "Scorpio": "♏",
|
||||||
|
"Sagittarius": "♐", "Capricorn": "♑", "Aquarius": "♒", "Pisces": "♓",
|
||||||
|
}
|
||||||
|
SIGN = {name: _t(cp) for name, cp in _SIGN_CP.items()}
|
||||||
|
|
||||||
|
# ── aspekty (nazwy jak w aspects.py + minor z abbreviations) ─────────────
|
||||||
|
ASPECT = {
|
||||||
|
"conjunction": "☌", # ☌
|
||||||
|
"opposition": "☍", # ☍
|
||||||
|
"trine": "△", # △
|
||||||
|
"square": "□", # □
|
||||||
|
"sextile": "⚹", # ⚹
|
||||||
|
"semisextile": "⚺", # ⚺
|
||||||
|
"quincunx": "⚻", # ⚻
|
||||||
|
"semisquare": "∠", # ∠
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── ruch ─────────────────────────────────────────────────────────────────
|
||||||
|
RETROGRADE = "℞" # ℞
|
||||||
|
DIRECT = "D" # zwykłe „D" — brak dedykowanego glifu prostego ruchu
|
||||||
|
|
||||||
|
|
||||||
|
# ── forward: nazwa → glif ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def glyph_for(name: str) -> str | None:
|
||||||
|
"""Glif obiektu/punktu/Lota po nazwie (jak w silniku). None, gdy brak."""
|
||||||
|
return PLANET.get(name) or POINT.get(name) or LOT.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_glyph(sign: str) -> str | None:
|
||||||
|
return SIGN.get(sign)
|
||||||
|
|
||||||
|
|
||||||
|
def aspect_glyph(aspect: str) -> str | None:
|
||||||
|
return ASPECT.get(aspect)
|
||||||
|
|
||||||
|
|
||||||
|
# ── reverse: glif → nazwa ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _strip_variants(g: str) -> str:
|
||||||
|
"""Zdejmuje selektory wariantu — glif z FE0E i bez daje ten sam klucz."""
|
||||||
|
return g.replace(_TEXT, "").replace(_EMOJI, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _reverse(mapping: dict[str, str]) -> dict[str, str]:
|
||||||
|
# pierwsze wystąpienie wygrywa (Fortune vs Part of Fortune → 'Fortune')
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for name, g in mapping.items():
|
||||||
|
out.setdefault(_strip_variants(g), name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_PLANET_POINT_REV = _reverse({**PLANET, **POINT, **{"Fortune": LOT["Fortune"]}})
|
||||||
|
_SIGN_REV = _reverse(SIGN)
|
||||||
|
_ASPECT_REV = _reverse(ASPECT)
|
||||||
|
|
||||||
|
|
||||||
|
def name_for_glyph(g: str) -> str | None:
|
||||||
|
"""Obiekt/punkt po glifie (znosi obecność lub brak selektora wariantu)."""
|
||||||
|
return _PLANET_POINT_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
def sign_for_glyph(g: str) -> str | None:
|
||||||
|
return _SIGN_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
def aspect_for_glyph(g: str) -> str | None:
|
||||||
|
return _ASPECT_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
# ── glifikacja tekstu sygnifikatora (składnia [XX z bazy) ────────────────
|
||||||
|
# Nasze sygnifikatory zapisane są tokenami z prefiksem [ (np. „[Sa [conj [PF").
|
||||||
|
# glyphify zamienia znane tokeny na glify, resztę zostawia. To druga strona
|
||||||
|
# abbreviations.expand: tam token → słowo, tu token → symbol.
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
# token po nawiasie (np. „Su", „Tau", „conj", „PF") → glif
|
||||||
|
_TOKEN_GLYPH: dict[str, str] = {}
|
||||||
|
_ABBR_TO_NAME = {
|
||||||
|
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus", "Ma": "Mars",
|
||||||
|
"Ju": "Jupiter", "Sa": "Saturn", "Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
|
||||||
|
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||||
|
"PF": "Fortune", "Fortune": "Fortune",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _ABBR_TO_NAME.items():
|
||||||
|
_g = glyph_for(_name)
|
||||||
|
if _g:
|
||||||
|
_TOKEN_GLYPH[_abbr] = _g
|
||||||
|
_SIGN_ABBR_NAME = {
|
||||||
|
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer", "Leo": "Leo",
|
||||||
|
"Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio", "Sag": "Sagittarius",
|
||||||
|
"Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _SIGN_ABBR_NAME.items():
|
||||||
|
_TOKEN_GLYPH[_abbr] = SIGN[_name]
|
||||||
|
_ASP_ABBR_NAME = {
|
||||||
|
"conj": "conjunction", "opp": "opposition", "tri": "trine", "sq": "square",
|
||||||
|
"sex": "sextile", "semisex": "semisextile", "quincunx": "quincunx", "semisq": "semisquare",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _ASP_ABBR_NAME.items():
|
||||||
|
if _name in ASPECT:
|
||||||
|
_TOKEN_GLYPH[_abbr] = ASPECT[_name]
|
||||||
|
|
||||||
|
_TOKEN_RE = _re.compile(r"\[([A-Za-z]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def glyphify(text: str) -> str:
|
||||||
|
"""Zamienia tokeny [XX na glify; nieznane zostawia bez zmiany. Dodatkowo
|
||||||
|
»Rx«/»R« → ℞. Nie parsuje stopni — te i tak są czytelne (26°08')."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
out = _TOKEN_RE.sub(lambda m: _TOKEN_GLYPH.get(m.group(1), m.group(0)), text)
|
||||||
|
out = _re.sub(r"\bR[x]?\b", RETROGRADE, out)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja.
|
||||||
|
|
||||||
|
Aspekty głowne (LOG-06) mierzą kąt wzdłuż EKLIPTYKI. Ale dwa ciała mogą być
|
||||||
|
powiązane też inaczej — a te powiązania klasyczna astrologia liczy naprawdę,
|
||||||
|
nie na oko:
|
||||||
|
|
||||||
|
* **Paralela / kontrparalela deklinacji.** Deklinacja to „szerokość" na równiku
|
||||||
|
niebieskim — jak daleko na północ/południe od równika stoi ciało. Dwa ciała na
|
||||||
|
tej samej deklinacji (parallel) działają jak koniunkcja, na przeciwnej
|
||||||
|
(kontrparalela) — jak opozycja, mimo że wzdłuż ekliptyki mogą być gdziekolwiek.
|
||||||
|
Baza interpretacyjna zna to zjawisko pod skrótem „P. Dec.".
|
||||||
|
|
||||||
|
* **Antyscja / kontrantyscja.** Odbicie punktu względem osi przesileń
|
||||||
|
(0° Raka – 0° Koziorożca). Dwa punkty w antyscji są równo odległe od tej osi —
|
||||||
|
„dzielą" tę samą długość dnia. Kontrantyscja to odbicie względem osi
|
||||||
|
równonocy (0° Barana – 0° Wagi).
|
||||||
|
|
||||||
|
Wszystko liczymy na współrzędnych TROPIKALNYCH of-date, bo:
|
||||||
|
- deklinacja jest wielkością fizyczną (równikową), niezależną od wyboru zodiaku;
|
||||||
|
- antyscja jest z definicji tropikalna — jej oś to punkty przesileń, czyli
|
||||||
|
kardynalne punkty zodiaku tropikalnego.
|
||||||
|
Dlatego moduł bierze surowe długości/szerokości z silnika, a nie etykiety po
|
||||||
|
przesunięciu na zodiak syderyczny/draconiczny.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine.aspects import LUMINARIES, RIGID_PAIRS, separation
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
from app.engine.zodiac import to_equatorial
|
||||||
|
|
||||||
|
# Orby — konfigurowalne (wymóg LOG-07). Paralele i antyscja są klasycznie CIASNE:
|
||||||
|
# to kontakty „punktowe", więc szeroki orb produkowałby fałszywe trafienia.
|
||||||
|
DECL_ORB = 1.0 # ° deklinacji dla paraleli/kontrparaleli
|
||||||
|
ANTISCIA_ORB = 1.0 # ° długości dla antyscji/kontrantyscji
|
||||||
|
LUMINARY_BONUS = 0.0 # świadomie 0 — te kontakty trzymamy ciasno; do podniesienia w API
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rigid(name_a: str, name_b: str) -> bool:
|
||||||
|
return frozenset({name_a, name_b}) in RIGID_PAIRS
|
||||||
|
|
||||||
|
|
||||||
|
def declination(lon: float, lat: float, eps: float) -> float:
|
||||||
|
"""Deklinacja [−90, 90]° z długości i szerokości ekliptycznej (pełny wzór,
|
||||||
|
z szerokością — istotne dla Księżyca i planet, które schodzą z ekliptyki)."""
|
||||||
|
_, dec = to_equatorial(lon, lat, eps)
|
||||||
|
return dec
|
||||||
|
|
||||||
|
|
||||||
|
def is_out_of_bounds(dec: float, eps: float) -> bool:
|
||||||
|
"""Deklinacja poza zakresem Słońca (|dec| > nachylenie ekliptyki).
|
||||||
|
|
||||||
|
„Out of bounds" — ciało zaszło dalej na północ/południe, niż Słońce kiedykolwiek
|
||||||
|
potrafi. Astrologicznie czytane jako działanie „poza normą", stąd wart odnotowania."""
|
||||||
|
return abs(dec) > eps
|
||||||
|
|
||||||
|
|
||||||
|
def antiscion(lon: float) -> float:
|
||||||
|
"""Odbicie długości względem osi przesileń (0° Raka – 0° Koziorożca)."""
|
||||||
|
return norm360(180.0 - lon)
|
||||||
|
|
||||||
|
|
||||||
|
def contra_antiscion(lon: float) -> float:
|
||||||
|
"""Odbicie długości względem osi równonocy (0° Barana – 0° Wagi)."""
|
||||||
|
return norm360(-lon)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed(orb: float, name_a: str, name_b: str, bonus: float) -> float:
|
||||||
|
if bonus and (name_a in LUMINARIES or name_b in LUMINARIES):
|
||||||
|
return orb + bonus
|
||||||
|
return orb
|
||||||
|
|
||||||
|
|
||||||
|
def find_declination_aspects(
|
||||||
|
bodies: list[dict], orb: float = DECL_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||||
|
) -> list[dict]:
|
||||||
|
"""bodies: dicty z 'name' i 'declination' (°).
|
||||||
|
|
||||||
|
Zwraca paralele (ta sama deklinacja) i kontrparalele (przeciwna). Pary z
|
||||||
|
RIGID_PAIRS pomijane — np. węzły są z definicji zawsze w kontrparaleli
|
||||||
|
(SN = NN+180 na ekliptyce → deklinacja przeciwna), co nie niesie informacji.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
n = len(bodies)
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
a, b = bodies[i], bodies[j]
|
||||||
|
if _is_rigid(a["name"], b["name"]):
|
||||||
|
continue
|
||||||
|
da, db = a.get("declination"), b.get("declination")
|
||||||
|
if da is None or db is None:
|
||||||
|
continue
|
||||||
|
da, db = float(da), float(db)
|
||||||
|
allowed = _allowed(orb, a["name"], b["name"], luminary_bonus)
|
||||||
|
parallel_dev = abs(da - db)
|
||||||
|
contra_dev = abs(da + db)
|
||||||
|
# ciało może wpaść tylko w jeden z dwóch — bierzemy ciaśniejszy
|
||||||
|
if parallel_dev <= allowed and parallel_dev <= contra_dev:
|
||||||
|
out.append(_row("parallel", a, b, parallel_dev, allowed, da, db))
|
||||||
|
elif contra_dev <= allowed:
|
||||||
|
out.append(_row("contraparallel", a, b, contra_dev, allowed, da, db))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_antiscia(
|
||||||
|
bodies: list[dict], orb: float = ANTISCIA_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||||
|
) -> list[dict]:
|
||||||
|
"""bodies: dicty z 'name' i 'decimal' (długość tropikalna of-date, °).
|
||||||
|
|
||||||
|
Zwraca antyscje (odbicie względem osi przesileń) i kontrantyscje (osi równonocy).
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
n = len(bodies)
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
a, b = bodies[i], bodies[j]
|
||||||
|
if _is_rigid(a["name"], b["name"]):
|
||||||
|
continue
|
||||||
|
la, lb = a.get("decimal"), b.get("decimal")
|
||||||
|
if la is None or lb is None:
|
||||||
|
continue
|
||||||
|
la, lb = float(la), float(lb)
|
||||||
|
allowed = _allowed(orb, a["name"], b["name"], luminary_bonus)
|
||||||
|
anti_dev = separation(la, antiscion(lb))
|
||||||
|
contra_dev = separation(la, contra_antiscion(lb))
|
||||||
|
if anti_dev <= allowed and anti_dev <= contra_dev:
|
||||||
|
out.append(_row("antiscion", a, b, anti_dev, allowed))
|
||||||
|
elif contra_dev <= allowed:
|
||||||
|
out.append(_row("contra_antiscion", a, b, contra_dev, allowed))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _row(kind: str, a: dict, b: dict, dev: float, allowed: float,
|
||||||
|
dec_a: float | None = None, dec_b: float | None = None) -> dict:
|
||||||
|
row = {
|
||||||
|
"obj1": a["name"], "obj2": b["name"],
|
||||||
|
"type": kind, "orb": round(dev, 3), "allowed": round(allowed, 3),
|
||||||
|
}
|
||||||
|
if dec_a is not None:
|
||||||
|
row["dec1"], row["dec2"] = round(dec_a, 3), round(dec_b, 3)
|
||||||
|
return row
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""Tabele pomocnicze horoskopu (LOG-23).
|
||||||
|
|
||||||
|
Zbiór wyliczeń, które astrolog czyta „obok" pozycji: bilans żywiołów i jakości,
|
||||||
|
faza Księżyca, stopnie krytyczne, dzień i godziny planetarne, syzygia prenatalna
|
||||||
|
oraz podziały (dwunastniki i nawamsa).
|
||||||
|
|
||||||
|
Dwie rzeczy wymagają prawdziwego liczenia, nie tabelki:
|
||||||
|
* **godziny planetarne** — są NIERÓWNE: dzień od wschodu do zachodu Słońca dzieli
|
||||||
|
się na 12 części, noc osobno. Bez faktycznego wschodu/zachodu wynik byłby
|
||||||
|
zmyślony, więc szukamy ich numerycznie (przejście wysokości Słońca przez −0°50′);
|
||||||
|
* **syzygia prenatalna** — ostatni nów albo pełnia PRZED urodzeniem; szukamy
|
||||||
|
wstecz momentu, w którym elongacja Księżyca przechodzi przez 0° lub 180°.
|
||||||
|
|
||||||
|
Moduł jest silnik-agnostyczny: potrzebuje tylko `positions()` i `sidereal()`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.zodiac import to_equatorial
|
||||||
|
|
||||||
|
# --- żywioły i jakości ------------------------------------------------------
|
||||||
|
ELEMENTS = ["Fire", "Earth", "Air", "Water"]
|
||||||
|
QUALITIES = ["Cardinal", "Fixed", "Mutable"]
|
||||||
|
ELEMENT_PL = {"Fire": "Ogień", "Earth": "Ziemia", "Air": "Powietrze", "Water": "Woda"}
|
||||||
|
QUALITY_PL = {"Cardinal": "Kardynalny", "Fixed": "Stały", "Mutable": "Zmienny"}
|
||||||
|
|
||||||
|
CLASSICAL = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"]
|
||||||
|
MODERN = CLASSICAL + ["Uranus", "Neptune", "Pluto"]
|
||||||
|
|
||||||
|
# --- dzień i godziny planetarne --------------------------------------------
|
||||||
|
# Kolejność chaldejska: od najwolniejszej do najszybszej planety
|
||||||
|
CHALDEAN = ["Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon"]
|
||||||
|
# Władca dnia wg dnia tygodnia (0 = poniedziałek, jak w datetime.weekday())
|
||||||
|
WEEKDAY_RULER = ["Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun"]
|
||||||
|
|
||||||
|
# wysokość środka tarczy Słońca przy wschodzie/zachodzie (refrakcja + promień tarczy)
|
||||||
|
SUNRISE_ALTITUDE = -0.833
|
||||||
|
|
||||||
|
|
||||||
|
def element_of(sign: str) -> str:
|
||||||
|
return ELEMENTS[SIGNS.index(sign) % 4]
|
||||||
|
|
||||||
|
|
||||||
|
def quality_of(sign: str) -> str:
|
||||||
|
return QUALITIES[SIGNS.index(sign) % 3]
|
||||||
|
|
||||||
|
|
||||||
|
def tally(positions: list[dict], asc_sign: str | None = None,
|
||||||
|
modern: bool = True) -> dict:
|
||||||
|
"""Bilans żywiołów i jakości (LOG-23).
|
||||||
|
|
||||||
|
Liczymy w dwóch wariantach naraz, bo szkoły się różnią: 7 planet klasycznych
|
||||||
|
i 10 z nowożytnymi. Ascendent doliczany osobno — bywa traktowany jak punkt
|
||||||
|
równorzędny planetom.
|
||||||
|
"""
|
||||||
|
wanted = MODERN if modern else CLASSICAL
|
||||||
|
by_name = {p.get("name"): p for p in positions}
|
||||||
|
|
||||||
|
def count(names: list[str], with_asc: bool) -> dict:
|
||||||
|
elements = dict.fromkeys(ELEMENTS, 0)
|
||||||
|
qualities = dict.fromkeys(QUALITIES, 0)
|
||||||
|
used = []
|
||||||
|
for name in names:
|
||||||
|
p = by_name.get(name)
|
||||||
|
if not p or not p.get("sign"):
|
||||||
|
continue
|
||||||
|
elements[element_of(p["sign"])] += 1
|
||||||
|
qualities[quality_of(p["sign"])] += 1
|
||||||
|
used.append(name)
|
||||||
|
if with_asc and asc_sign:
|
||||||
|
elements[element_of(asc_sign)] += 1
|
||||||
|
qualities[quality_of(asc_sign)] += 1
|
||||||
|
used.append("Asc")
|
||||||
|
return {"elements": elements, "qualities": qualities,
|
||||||
|
"counted": used, "total": len(used)}
|
||||||
|
|
||||||
|
classical = count(CLASSICAL, False)
|
||||||
|
result = {
|
||||||
|
"classical_7": classical,
|
||||||
|
"with_modern_10": count(wanted, False),
|
||||||
|
"classical_7_plus_asc": count(CLASSICAL, True),
|
||||||
|
"with_modern_10_plus_asc": count(wanted, True),
|
||||||
|
}
|
||||||
|
# brakujące żywioły — klasyczne „no air" itd., podstawa pod scoring (LOG-21)
|
||||||
|
base = result["with_modern_10_plus_asc"]
|
||||||
|
result["missing_elements"] = [e for e, n in base["elements"].items() if n == 0]
|
||||||
|
result["missing_qualities"] = [q for q, n in base["qualities"].items() if n == 0]
|
||||||
|
result["labels"] = {"elements": ELEMENT_PL, "qualities": QUALITY_PL}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --- faza Księżyca ----------------------------------------------------------
|
||||||
|
_PHASES = [
|
||||||
|
(0.0, "New Moon", "Nów"),
|
||||||
|
(45.0, "Waxing Crescent", "Sierp przybywający"),
|
||||||
|
(90.0, "First Quarter", "Pierwsza kwadra"),
|
||||||
|
(135.0, "Waxing Gibbous", "Garb przybywający"),
|
||||||
|
(180.0, "Full Moon", "Pełnia"),
|
||||||
|
(225.0, "Waning Gibbous", "Garb ubywający"),
|
||||||
|
(270.0, "Last Quarter", "Ostatnia kwadra"),
|
||||||
|
(315.0, "Waning Crescent", "Sierp ubywający"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def moon_phase(sun_lon: float, moon_lon: float) -> dict:
|
||||||
|
"""Faza Księżyca z elongacji (Księżyc − Słońce)."""
|
||||||
|
angle = norm360(moon_lon - sun_lon)
|
||||||
|
idx = int(((angle + 22.5) % 360.0) // 45.0)
|
||||||
|
_, name, name_pl = _PHASES[idx]
|
||||||
|
illumination = (1.0 - math.cos(math.radians(angle))) / 2.0
|
||||||
|
return {
|
||||||
|
"angle": round(angle, 4),
|
||||||
|
"phase": name,
|
||||||
|
"phase_pl": name_pl,
|
||||||
|
"illumination": round(illumination, 4),
|
||||||
|
"waxing": angle < 180.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- stopnie krytyczne ------------------------------------------------------
|
||||||
|
# klasyczne stopnie krytyczne zależą od jakości znaku
|
||||||
|
_CRITICAL = {"Cardinal": (0, 13, 26), "Fixed": (8, 21), "Mutable": (4, 17)}
|
||||||
|
CRITICAL_ORB = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def critical_degrees(positions: list[dict]) -> list[dict]:
|
||||||
|
"""Obiekty stojące na stopniach krytycznych, 0° albo 29° (anaretycznym)."""
|
||||||
|
out = []
|
||||||
|
for p in positions:
|
||||||
|
lon = p.get("decimal")
|
||||||
|
sign = p.get("sign")
|
||||||
|
if lon is None or not sign:
|
||||||
|
continue
|
||||||
|
deg = norm360(lon) - sign_index(lon) * 30.0
|
||||||
|
flags = []
|
||||||
|
for critical in _CRITICAL[quality_of(sign)]:
|
||||||
|
if abs(deg - critical) <= CRITICAL_ORB:
|
||||||
|
flags.append(f"stopień krytyczny {critical}° ({QUALITY_PL[quality_of(sign)].lower()})")
|
||||||
|
if deg >= 29.0:
|
||||||
|
flags.append("29° — stopień anaretyczny (koniec znaku)")
|
||||||
|
elif deg < 1.0:
|
||||||
|
flags.append("0° — wejście w znak")
|
||||||
|
if flags:
|
||||||
|
out.append({"name": p.get("name"), "sign": sign,
|
||||||
|
"in_sign": p.get("in_sign"), "flags": flags})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- podziały: dwunastnik i nawamsa ----------------------------------------
|
||||||
|
def dwadasamsa(lon: float) -> float:
|
||||||
|
"""12. część (dwadasamsa): znak dzielony na 12 po 2°30′, licząc od siebie."""
|
||||||
|
lon = norm360(lon)
|
||||||
|
start = sign_index(lon) * 30.0
|
||||||
|
return norm360(start + (lon - start) * 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
def navamsa(lon: float) -> float:
|
||||||
|
"""9. część (nawamsa): 108 podziałów po 3°20′ liczonych od 0° Barana."""
|
||||||
|
lon = norm360(lon)
|
||||||
|
part = int(lon // (30.0 / 9.0))
|
||||||
|
return norm360((part % 12) * 30.0 + (lon % (30.0 / 9.0)) * 9.0)
|
||||||
|
|
||||||
|
|
||||||
|
def divisional(positions: list[dict]) -> list[dict]:
|
||||||
|
"""Pozycje w podziałach 12. i 9. — obie tabele naraz."""
|
||||||
|
out = []
|
||||||
|
for p in positions:
|
||||||
|
lon = p.get("decimal")
|
||||||
|
if lon is None:
|
||||||
|
continue
|
||||||
|
d12, d9 = dwadasamsa(lon), navamsa(lon)
|
||||||
|
out.append({
|
||||||
|
"name": p.get("name"),
|
||||||
|
"d12_sign": SIGNS[sign_index(d12)], "d12_in_sign": in_sign(d12),
|
||||||
|
"d9_sign": SIGNS[sign_index(d9)], "d9_in_sign": in_sign(d9),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- wschód/zachód Słońca i godziny planetarne ------------------------------
|
||||||
|
def sun_altitude(engine, moment: ChartMoment) -> float:
|
||||||
|
"""Wysokość Słońca nad horyzontem [°] dla momentu i miejsca."""
|
||||||
|
ramc, eps = engine.sidereal(moment)
|
||||||
|
sun = engine.positions(moment, ["Sun"])[0]
|
||||||
|
ra, dec = to_equatorial(sun.longitude, sun.latitude, eps)
|
||||||
|
hour_angle = math.radians(norm360(ramc - ra))
|
||||||
|
phi, d = math.radians(moment.lat), math.radians(dec)
|
||||||
|
sin_alt = math.sin(d) * math.sin(phi) + math.cos(d) * math.cos(phi) * math.cos(hour_angle)
|
||||||
|
return math.degrees(math.asin(max(-1.0, min(1.0, sin_alt))))
|
||||||
|
|
||||||
|
|
||||||
|
def _at(moment: ChartMoment, when: datetime) -> ChartMoment:
|
||||||
|
return ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
|
||||||
|
|
||||||
|
|
||||||
|
def _crossings(engine, moment: ChartMoment, start: datetime, end: datetime,
|
||||||
|
step_minutes: int = 20) -> list[tuple[datetime, str]]:
|
||||||
|
"""Momenty przejścia Słońca przez horyzont w oknie [start, end].
|
||||||
|
|
||||||
|
Skan zgrubny + bisekcja — ten sam wzorzec co przy stacjach planet (LOG-03).
|
||||||
|
"""
|
||||||
|
out: list[tuple[datetime, str]] = []
|
||||||
|
step = timedelta(minutes=step_minutes)
|
||||||
|
t0 = start
|
||||||
|
f0 = sun_altitude(engine, _at(moment, t0)) - SUNRISE_ALTITUDE
|
||||||
|
while t0 < end:
|
||||||
|
t1 = min(t0 + step, end)
|
||||||
|
f1 = sun_altitude(engine, _at(moment, t1)) - SUNRISE_ALTITUDE
|
||||||
|
if f0 == 0.0 or (f0 < 0.0) != (f1 < 0.0):
|
||||||
|
lo, hi, flo = t0, t1, f0
|
||||||
|
for _ in range(40): # ~sekundowa dokładność
|
||||||
|
mid = lo + (hi - lo) / 2
|
||||||
|
fmid = sun_altitude(engine, _at(moment, mid)) - SUNRISE_ALTITUDE
|
||||||
|
if (flo < 0.0) != (fmid < 0.0):
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo, flo = mid, fmid
|
||||||
|
out.append((lo + (hi - lo) / 2, "sunrise" if f1 > f0 else "sunset"))
|
||||||
|
t0, f0 = t1, f1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def planetary_hours(engine, moment: ChartMoment) -> dict | None:
|
||||||
|
"""Dzień i godziny planetarne w porządku chaldejskim (LOG-23).
|
||||||
|
|
||||||
|
Godziny są NIERÓWNE: dzień (wschód→zachód) i noc (zachód→wschód) dzielą się
|
||||||
|
na 12 części każde. Doba planetarna zaczyna się o WSCHODZIE, nie o północy —
|
||||||
|
dlatego władcę dnia bierzemy z dnia tygodnia tego wschodu, który otworzył
|
||||||
|
bieżący okres.
|
||||||
|
|
||||||
|
Zwraca None dla dnia polarnego/nocy polarnej, gdzie wschód nie występuje.
|
||||||
|
"""
|
||||||
|
now = moment.when_utc
|
||||||
|
events = _crossings(engine, moment, now - timedelta(hours=30), now + timedelta(hours=30))
|
||||||
|
if not events:
|
||||||
|
return None # brak wschodu/zachodu w oknie
|
||||||
|
|
||||||
|
before = [e for e in events if e[0] <= now]
|
||||||
|
after = [e for e in events if e[0] > now]
|
||||||
|
if not before or not after:
|
||||||
|
return None
|
||||||
|
|
||||||
|
last_time, last_kind = before[-1]
|
||||||
|
next_time, _ = after[0]
|
||||||
|
|
||||||
|
daytime = last_kind == "sunrise"
|
||||||
|
period_start, period_end = last_time, next_time
|
||||||
|
# doba planetarna startuje o wschodzie: w nocy to wschód POPRZEDZAJĄCY zachód
|
||||||
|
day_start = last_time if daytime else next((t for t, k in reversed(before)
|
||||||
|
if k == "sunrise"), last_time)
|
||||||
|
|
||||||
|
length = (period_end - period_start) / 12
|
||||||
|
index = int((now - period_start) / length)
|
||||||
|
index = max(0, min(11, index))
|
||||||
|
|
||||||
|
day_ruler = WEEKDAY_RULER[day_start.weekday()]
|
||||||
|
hour_number = index if daytime else index + 12 # 0..23 od wschodu
|
||||||
|
ruler = CHALDEAN[(CHALDEAN.index(day_ruler) + hour_number) % 7]
|
||||||
|
|
||||||
|
hours = []
|
||||||
|
for i in range(12):
|
||||||
|
start = period_start + length * i
|
||||||
|
hours.append({
|
||||||
|
"index": i + 1,
|
||||||
|
"ruler": CHALDEAN[(CHALDEAN.index(day_ruler) + (i if daytime else i + 12)) % 7],
|
||||||
|
"start": start.isoformat(timespec="seconds"),
|
||||||
|
"end": (start + length).isoformat(timespec="seconds"),
|
||||||
|
"current": i == index,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"day_ruler": day_ruler,
|
||||||
|
"hour_ruler": ruler,
|
||||||
|
"hour_number": hour_number + 1,
|
||||||
|
"daytime": daytime,
|
||||||
|
"period": "dzień" if daytime else "noc",
|
||||||
|
"hour_length_minutes": round(length.total_seconds() / 60.0, 2),
|
||||||
|
"period_start": period_start.isoformat(timespec="seconds"),
|
||||||
|
"period_end": period_end.isoformat(timespec="seconds"),
|
||||||
|
"hours": hours,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- syzygia prenatalna -----------------------------------------------------
|
||||||
|
def prenatal_syzygy(engine, moment: ChartMoment, max_days: float = 32.0) -> dict | None:
|
||||||
|
"""Ostatni nów albo pełnia PRZED podanym momentem (LOG-23).
|
||||||
|
|
||||||
|
Szukamy wstecz przejścia elongacji przez 0° (nów) lub 180° (pełnia); bierzemy
|
||||||
|
to, które wypadło później. Cykl trwa ~29,5 dnia, więc okno 32 dni wystarcza.
|
||||||
|
"""
|
||||||
|
def elongation(when: datetime) -> float:
|
||||||
|
pts = {p.name: p.longitude for p in
|
||||||
|
engine.positions(_at(moment, when), ["Sun", "Moon"])}
|
||||||
|
return norm360(pts["Moon"] - pts["Sun"])
|
||||||
|
|
||||||
|
def signed(when: datetime, target: float) -> float:
|
||||||
|
"""Odległość od celu w [−180, 180] — zeruje się dokładnie w syzygii."""
|
||||||
|
return ((elongation(when) - target + 180.0) % 360.0) - 180.0
|
||||||
|
|
||||||
|
best: tuple[datetime, str] | None = None
|
||||||
|
for target, kind in ((0.0, "new_moon"), (180.0, "full_moon")):
|
||||||
|
step = timedelta(hours=6)
|
||||||
|
t1 = moment.when_utc
|
||||||
|
f1 = signed(t1, target)
|
||||||
|
scanned = timedelta()
|
||||||
|
while scanned < timedelta(days=max_days):
|
||||||
|
t0 = t1 - step
|
||||||
|
f0 = signed(t0, target)
|
||||||
|
if (f0 < 0.0) != (f1 < 0.0) and abs(f0 - f1) < 180.0:
|
||||||
|
lo, hi, flo = t0, t1, f0
|
||||||
|
for _ in range(40):
|
||||||
|
mid = lo + (hi - lo) / 2
|
||||||
|
fmid = signed(mid, target)
|
||||||
|
if (flo < 0.0) != (fmid < 0.0):
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo, flo = mid, fmid
|
||||||
|
found = lo + (hi - lo) / 2
|
||||||
|
if best is None or found > best[0]:
|
||||||
|
best = (found, kind)
|
||||||
|
break
|
||||||
|
t1, f1 = t0, f0
|
||||||
|
scanned += step
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
when, kind = best
|
||||||
|
pts = {p.name: p.longitude for p in engine.positions(_at(moment, when), ["Sun", "Moon"])}
|
||||||
|
lon = pts["Sun"] if kind == "new_moon" else pts["Moon"]
|
||||||
|
return {
|
||||||
|
"type": kind,
|
||||||
|
"type_pl": "nów" if kind == "new_moon" else "pełnia",
|
||||||
|
"when_utc": when.isoformat(timespec="seconds"),
|
||||||
|
"days_before_birth": round((moment.when_utc - when).total_seconds() / 86400.0, 3),
|
||||||
|
"sign": SIGNS[sign_index(lon)],
|
||||||
|
"in_sign": in_sign(lon),
|
||||||
|
"decimal": round(norm360(lon), 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- złożenie wszystkiego ---------------------------------------------------
|
||||||
|
def build_tables(engine, moment: ChartMoment, chart: dict,
|
||||||
|
heavy: bool = True) -> dict:
|
||||||
|
"""Komplet tabel dla policzonego horoskopu.
|
||||||
|
|
||||||
|
`heavy=False` pomija wyliczenia wymagające szukania numerycznego (godziny
|
||||||
|
planetarne, syzygia) — przydatne, gdy liczy się czas odpowiedzi.
|
||||||
|
"""
|
||||||
|
positions = chart.get("positions") or []
|
||||||
|
by_name = {p.get("name"): p for p in positions}
|
||||||
|
asc_sign = (chart.get("angles") or {}).get("Asc", {}).get("sign")
|
||||||
|
|
||||||
|
out: dict = {
|
||||||
|
"tally": tally(positions, asc_sign),
|
||||||
|
"critical_degrees": critical_degrees(positions),
|
||||||
|
"divisional": divisional(positions),
|
||||||
|
}
|
||||||
|
if "Sun" in by_name and "Moon" in by_name:
|
||||||
|
out["moon_phase"] = moon_phase(by_name["Sun"]["decimal"], by_name["Moon"]["decimal"])
|
||||||
|
if heavy:
|
||||||
|
out["planetary_hours"] = planetary_hours(engine, moment)
|
||||||
|
out["prenatal_syzygy"] = prenatal_syzygy(engine, moment)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""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"
|
||||||
|
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 astrololo")
|
||||||
|
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")
|
||||||
@@ -124,7 +124,14 @@ class _Driver:
|
|||||||
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
def generate(self, prompt: str, max_tokens: int, on_event=None) -> Completion:
|
||||||
|
"""`on_event(dict)` dostaje zdarzenia postępu — UI pokazuje z nich log.
|
||||||
|
Raportujemy KAŻDĄ turę, bo to ona trwa; bez tego pasek postępu byłby
|
||||||
|
ozdobnikiem, a nie informacją."""
|
||||||
|
def emit(kind: str, message: str, **extra):
|
||||||
|
if on_event:
|
||||||
|
on_event({"type": kind, "message": message, **extra})
|
||||||
|
|
||||||
messages: list[dict] = [{"role": "user", "content": prompt}]
|
messages: list[dict] = [{"role": "user", "content": prompt}]
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
usage: dict = {}
|
usage: dict = {}
|
||||||
@@ -137,9 +144,29 @@ class _Driver:
|
|||||||
while turns <= MAX_CONTINUATIONS:
|
while turns <= MAX_CONTINUATIONS:
|
||||||
turns += 1
|
turns += 1
|
||||||
budget = max(256, min(remaining, TURN_TOKENS_CAP))
|
budget = max(256, min(remaining, TURN_TOKENS_CAP))
|
||||||
|
emit("turn_start",
|
||||||
|
f"Tura {turns}: wysyłam do modelu {self.model} (limit {budget} tokenów)…",
|
||||||
|
turn=turns)
|
||||||
|
started = time.monotonic()
|
||||||
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
|
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
|
||||||
|
took = time.monotonic() - started
|
||||||
_merge_usage(usage, turn_usage)
|
_merge_usage(usage, turn_usage)
|
||||||
remaining -= budget
|
|
||||||
|
# Odejmujemy tokeny FAKTYCZNIE wyprodukowane, nie zamówiony limit tury.
|
||||||
|
# Inaczej pierwsza tura zjadałaby cały budżet i urwana odpowiedź nigdy
|
||||||
|
# nie doczekałaby się kontynuacji — wracałby do użytkownika fragment
|
||||||
|
# udający całość.
|
||||||
|
produced = (turn_usage or {}).get("completion_tokens")
|
||||||
|
if produced is None:
|
||||||
|
produced = (turn_usage or {}).get("output_tokens")
|
||||||
|
if produced is None:
|
||||||
|
produced = max(1, int(len(text) / 3.6))
|
||||||
|
remaining -= max(1, int(produced))
|
||||||
|
|
||||||
|
emit("turn_end",
|
||||||
|
f"Tura {turns}: odebrano {len(text.strip())} znaków w {took:.1f}s"
|
||||||
|
+ (" — odpowiedź urwana, poproszę o dokończenie" if truncated else ""),
|
||||||
|
turn=turns, chars=len(text.strip()), truncated=truncated)
|
||||||
|
|
||||||
chunk = text.strip()
|
chunk = text.strip()
|
||||||
if chunk:
|
if chunk:
|
||||||
@@ -172,6 +199,8 @@ class _Driver:
|
|||||||
final = _join(parts)
|
final = _join(parts)
|
||||||
if not final:
|
if not final:
|
||||||
raise LLMError(_explain_empty(turns, usage, stop))
|
raise LLMError(_explain_empty(turns, usage, stop))
|
||||||
|
emit("generated", f"Gotowe: {len(final)} znaków w {turns} turach.",
|
||||||
|
chars=len(final), turns=turns)
|
||||||
|
|
||||||
usage["turns"] = turns
|
usage["turns"] = turns
|
||||||
return Completion(text=final, model=model_name, provider=self.name,
|
return Completion(text=final, model=model_name, provider=self.name,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import httpx
|
|||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app import security
|
from app import link_crypto, security
|
||||||
from app.clients.data_client import DataClient
|
from app.clients.data_client import DataClient
|
||||||
from app.models import QueryRequest, QueryResponse
|
from app.models import QueryRequest, QueryResponse
|
||||||
from app.service import QueryService
|
from app.service import QueryService
|
||||||
@@ -20,6 +20,9 @@ from app.service import QueryService
|
|||||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||||
service = QueryService()
|
service = QueryService()
|
||||||
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
||||||
|
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
||||||
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
|
link_crypto.install(app, link_crypto.ENV_PRESENTATION_LOGIC, "logiczna")
|
||||||
|
|
||||||
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
||||||
_engine = None
|
_engine = None
|
||||||
@@ -41,6 +44,7 @@ class PositionsRequest(BaseModel):
|
|||||||
objects: list[str] | None = None
|
objects: list[str] | None = None
|
||||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||||
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
||||||
|
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
|
||||||
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +76,10 @@ def chart_positions(req: PositionsRequest) -> dict:
|
|||||||
st = find_stations(engine, moment, p["name"])
|
st = find_stations(engine, moment, p["name"])
|
||||||
if st:
|
if st:
|
||||||
p["stations"] = st
|
p["stations"] = st
|
||||||
|
if req.tables:
|
||||||
|
from app.engine.tables import build_tables
|
||||||
|
|
||||||
|
chart["tables"] = build_tables(engine, moment, chart)
|
||||||
return chart
|
return chart
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +124,8 @@ def chart_report(req: ReportRequest) -> dict:
|
|||||||
try:
|
try:
|
||||||
report = build_report(
|
report = build_report(
|
||||||
chart["positions"], DataClient(),
|
chart["positions"], DataClient(),
|
||||||
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
|
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
||||||
|
per_object_limit=req.limit, group=req.group,
|
||||||
)
|
)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
||||||
@@ -180,7 +189,8 @@ def chart_prompt(req: PromptRequest) -> dict:
|
|||||||
try:
|
try:
|
||||||
report = build_report(
|
report = build_report(
|
||||||
chart["positions"], DataClient(),
|
chart["positions"], DataClient(),
|
||||||
aspects=chart.get("aspects"), per_object_limit=req.limit,
|
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
||||||
|
per_object_limit=req.limit,
|
||||||
)
|
)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
data_error = f"Warstwa danych niedostępna: {e}"
|
data_error = f"Warstwa danych niedostępna: {e}"
|
||||||
@@ -265,6 +275,72 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/horoscope/stream")
|
||||||
|
def chart_horoscope_stream(req: HoroscopeRequest):
|
||||||
|
"""To samo co /chart/horoscope, ale strumieniuje POSTĘP w trakcie pracy.
|
||||||
|
|
||||||
|
Pisanie horoskopu trwa minutami — bez sygnału aplikacja wygląda na zawieszoną.
|
||||||
|
Strumień (NDJSON, jedna linia = jedno zdarzenie) niesie RZECZYWISTE etapy:
|
||||||
|
budowę promptu, limity modelu i każdą turę generowania. Ostatnie zdarzenie
|
||||||
|
(`result`) ma identyczny kształt co odpowiedź zwykłego endpointu.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.progress import stream
|
||||||
|
|
||||||
|
def work(emit) -> dict:
|
||||||
|
from app.llm.base import LLMError
|
||||||
|
from app.llm.factory import build_provider
|
||||||
|
from app.llm.limits import plan
|
||||||
|
|
||||||
|
emit({"type": "stage", "message": "Liczę horoskop i szukam wskazań w bazach…"})
|
||||||
|
out = chart_prompt(req)
|
||||||
|
st = out.get("stats", {})
|
||||||
|
emit({"type": "stage", "message":
|
||||||
|
f"Prompt gotowy: {st.get('chars', 0)} znaków, "
|
||||||
|
f"wskazań {st.get('included', 0)}"
|
||||||
|
+ (f", pominięto {st['omitted']}" if st.get("omitted") else "")})
|
||||||
|
if out.get("data_error"):
|
||||||
|
emit({"type": "warn", "message": out["data_error"]})
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = build_provider(req.provider, req.model)
|
||||||
|
emit({"type": "stage", "message":
|
||||||
|
f"Dostawca: {provider.name}, model: {provider.model}"
|
||||||
|
+ ("" if not provider.leaves_lan else " — dane opuszczają sieć")})
|
||||||
|
|
||||||
|
emit({"type": "stage", "message": "Liczę tokeny promptu…"})
|
||||||
|
prompt_tokens = provider.count_tokens(out["prompt"])
|
||||||
|
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
||||||
|
out["token_plan"] = budget
|
||||||
|
emit({"type": "stage", "message":
|
||||||
|
f"Prompt {prompt_tokens} tok. · okno modelu {budget['context_window']} · "
|
||||||
|
f"na odpowiedź {budget['max_output']}"})
|
||||||
|
for warning in budget["warnings"]:
|
||||||
|
emit({"type": "warn", "message": warning})
|
||||||
|
if budget["warnings"]:
|
||||||
|
out["warnings"] = budget["warnings"]
|
||||||
|
if not budget["fits"]:
|
||||||
|
out["llm_error"] = " ".join(budget["warnings"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
result = provider.generate(out["prompt"], budget["max_output"], on_event=emit)
|
||||||
|
except LLMError as e:
|
||||||
|
emit({"type": "warn", "message": f"Model zawiódł: {e}"})
|
||||||
|
out["llm_error"] = str(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
out.update(horoscope=result.text, provider=result.provider, model=result.model,
|
||||||
|
leaves_lan=result.leaves_lan, usage=result.usage)
|
||||||
|
return out
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream(work),
|
||||||
|
media_type="application/x-ndjson",
|
||||||
|
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/llm/models")
|
@app.get("/llm/models")
|
||||||
def llm_models() -> dict:
|
def llm_models() -> dict:
|
||||||
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Strumień postępu długiej operacji (NDJSON).
|
||||||
|
|
||||||
|
Po co: pisanie horoskopu trwa — czasem minuty. Bez sygnału aplikacja wygląda na
|
||||||
|
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
|
||||||
|
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
|
||||||
|
|
||||||
|
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
|
||||||
|
a to jest POST z ciałem. Strumień „jedna linia = jeden obiekt JSON" czyta się
|
||||||
|
zwykłym `fetch()` i jest trywialny do sparsowania.
|
||||||
|
|
||||||
|
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
|
||||||
|
ją w wątku roboczym, a generator odpompowuje kolejkę zdarzeń — dzięki temu
|
||||||
|
zdarzenia docierają w trakcie pracy, a nie dopiero na końcu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
_HEARTBEAT_SECONDS = 10.0
|
||||||
|
_DONE = object()
|
||||||
|
|
||||||
|
|
||||||
|
def line(kind: str, message: str, **extra: Any) -> str:
|
||||||
|
return json.dumps({"type": kind, "message": message, **extra}, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def stream(work: Callable[[Callable[[dict], None]], dict]) -> Iterator[str]:
|
||||||
|
"""Uruchamia `work(emit)` w wątku i strumieniuje zdarzenia w czasie rzeczywistym.
|
||||||
|
|
||||||
|
`work` dostaje funkcję `emit(zdarzenie)` i zwraca końcowy wynik, który leci
|
||||||
|
jako ostatnie zdarzenie typu `result`. Wyjątek zamienia się w zdarzenie `error`
|
||||||
|
— połączenie nigdy nie urywa się bez wyjaśnienia.
|
||||||
|
"""
|
||||||
|
events: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
|
def emit(event: dict) -> None:
|
||||||
|
events.put(event)
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
try:
|
||||||
|
result = work(emit)
|
||||||
|
events.put({"type": "result", "message": "Gotowe.", "result": result})
|
||||||
|
except Exception as e: # noqa: BLE001 — zgłaszamy KAŻDY błąd
|
||||||
|
events.put({
|
||||||
|
"type": "error",
|
||||||
|
"message": f"{type(e).__name__}: {e}",
|
||||||
|
"detail": traceback.format_exc(limit=3),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
events.put(_DONE)
|
||||||
|
|
||||||
|
worker = threading.Thread(target=run, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = events.get(timeout=_HEARTBEAT_SECONDS)
|
||||||
|
except queue.Empty:
|
||||||
|
# cisza dłuższa niż heartbeat: dajemy znak życia, żeby pośredniki
|
||||||
|
# (proxy, load balancer) nie uznały połączenia za martwe
|
||||||
|
yield line("ping", "…")
|
||||||
|
continue
|
||||||
|
if event is _DONE:
|
||||||
|
break
|
||||||
|
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||||
@@ -114,17 +114,24 @@ def build_report(
|
|||||||
positions: list[dict],
|
positions: list[dict],
|
||||||
data: DataSource,
|
data: DataSource,
|
||||||
aspects: list[dict] | None = None,
|
aspects: list[dict] | None = None,
|
||||||
|
parallels: list[dict] | None = None,
|
||||||
per_object_limit: int = 5000,
|
per_object_limit: int = 5000,
|
||||||
group: bool = False,
|
group: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""positions: pozycje z build_chart (name, sign, direction, house).
|
"""positions: pozycje z build_chart (name, sign, direction, house).
|
||||||
|
|
||||||
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
|
Dla każdego obiektu fasety: „w znaku", „w domu", „w aspekcie" (dla każdego
|
||||||
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
|
aspektu głównego z listy `aspects`) oraz „paralela deklinacji" (dla paraleli
|
||||||
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
|
z listy `parallels` — LOG-07). Duplikaty (ten sam sygnifikator i opis) są
|
||||||
|
odsiewane wewnątrz każdej fasety.
|
||||||
"""
|
"""
|
||||||
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
|
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
|
||||||
|
|
||||||
|
# W bazie paralela deklinacji zapisana jest jako fraza „P. Dec." (nie token [).
|
||||||
|
# UWAGA: samo „Dec." w bazie to CZĘSTO dekanat („3rd Dec. of [Gem"), więc
|
||||||
|
# szukamy dokładnie „P. Dec.", inaczej sypnęłoby fałszywymi trafieniami.
|
||||||
|
PARALLEL_MARK = "P. Dec."
|
||||||
|
|
||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
provider = None
|
provider = None
|
||||||
for p in positions:
|
for p in positions:
|
||||||
@@ -180,6 +187,25 @@ def build_report(
|
|||||||
"count": len(asp_samples), "samples": asp_samples,
|
"count": len(asp_samples), "samples": asp_samples,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# paralele deklinacji (LOG-07) — baza zna je jako „P. Dec.". Tylko `parallel`:
|
||||||
|
# kontrparaleli i antyscji baza nie opisuje osobnym znacznikiem.
|
||||||
|
for par in (parallels or []):
|
||||||
|
if par.get("type") != "parallel" or name not in (par.get("obj1"), par.get("obj2")):
|
||||||
|
continue
|
||||||
|
other = par["obj2"] if par["obj1"] == name else par["obj1"]
|
||||||
|
if other not in PLANET_ABBR:
|
||||||
|
continue
|
||||||
|
other_tok = "[" + PLANET_ABBR[other]
|
||||||
|
par_samples = _facet_samples(rows, [PARALLEL_MARK, other_tok])
|
||||||
|
if not par_samples: # pokazujemy tylko z trafieniami
|
||||||
|
continue
|
||||||
|
facets.append({
|
||||||
|
"type": "parallel", "label": f"paralela deklinacji z {other}",
|
||||||
|
"token": f"{PARALLEL_MARK} + {other_tok}",
|
||||||
|
"orb": par.get("orb"), "allowed": par.get("allowed"),
|
||||||
|
"count": len(par_samples), "samples": par_samples,
|
||||||
|
})
|
||||||
|
|
||||||
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
||||||
for f in facets:
|
for f in facets:
|
||||||
f["score"] = score_facet(f)
|
f["score"] = score_facet(f)
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ httpx>=0.28
|
|||||||
pydantic>=2.10
|
pydantic>=2.10
|
||||||
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
|
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
|
||||||
skyfield>=1.49
|
skyfield>=1.49
|
||||||
|
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||||
|
cryptography>=44.0
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||||
|
|
||||||
|
Sedno: glify muszą być KOMPLETNE (każdy obiekt/znak/aspekt, który liczymy, ma
|
||||||
|
symbol), DWUKIERUNKOWE (symbol → tekst z powrotem) i TEKSTOWE, nigdy emoji
|
||||||
|
(DAN-18) — inaczej na kosmogramie wyjdą kolorowe kwadraty zamiast czarno-białych
|
||||||
|
symboli.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine import glyphs as G
|
||||||
|
from app.engine.aspects import MAJOR
|
||||||
|
from app.engine.formats import SIGNS
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS
|
||||||
|
|
||||||
|
VS15, VS16 = "︎", "️"
|
||||||
|
|
||||||
|
|
||||||
|
def _all_glyphs():
|
||||||
|
return (list(G.PLANET.values()) + list(G.POINT.values()) + list(G.LOT.values())
|
||||||
|
+ list(G.SIGN.values()) + list(G.ASPECT.values()) + [G.RETROGRADE])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- kompletność
|
||||||
|
|
||||||
|
def test_every_default_object_has_a_glyph():
|
||||||
|
"""Każdy obiekt z kanonicznego zestawu musi mieć symbol — inaczej na kole
|
||||||
|
zostanie dziura."""
|
||||||
|
missing = [name for name in DEFAULT_OBJECTS if G.glyph_for(name) is None]
|
||||||
|
assert not missing, f"obiekty bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_sign_has_a_glyph():
|
||||||
|
missing = [s for s in SIGNS if G.sign_glyph(s) is None]
|
||||||
|
assert not missing, f"znaki bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_major_aspect_has_a_glyph():
|
||||||
|
missing = [a for a in MAJOR if G.aspect_glyph(a) is None]
|
||||||
|
assert not missing, f"aspekty główne bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_and_retrograde_from_requirement():
|
||||||
|
"""Wymóg wprost wymienia ⊗ (Fortuna) i ℞ (retrogradacja)."""
|
||||||
|
assert G.glyph_for("Fortune") == "⊗"
|
||||||
|
assert G.RETROGRADE == "℞"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ DAN-18: tekst, nie emoji
|
||||||
|
|
||||||
|
def test_no_glyph_uses_emoji_variation_selector():
|
||||||
|
"""VS16 (U+FE0F) wymusiłby prezentację emoji — nie może go być NIGDZIE."""
|
||||||
|
offenders = [g for g in _all_glyphs() if VS16 in g]
|
||||||
|
assert not offenders, f"glify z wariantem emoji (FE0F): {offenders!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signs_force_text_presentation():
|
||||||
|
"""Znaki zodiaku mają domyślnie wariant emoji → MUSZĄ nieść VS15."""
|
||||||
|
for s in SIGNS:
|
||||||
|
assert VS15 in G.sign_glyph(s), f"{s} bez wymuszenia tekstu (VS15)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_venus_mars_force_text_but_sun_does_not():
|
||||||
|
"""♀/♂ mają wariant emoji (VS15 konieczny); ☉ nie ma (VS15 zbędny)."""
|
||||||
|
assert VS15 in G.glyph_for("Venus")
|
||||||
|
assert VS15 in G.glyph_for("Mars")
|
||||||
|
assert VS15 not in G.glyph_for("Sun") # bez zaśmiecania niepotrzebnym selektorem
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- dwukierunkowość
|
||||||
|
|
||||||
|
def test_object_round_trip():
|
||||||
|
for name in DEFAULT_OBJECTS:
|
||||||
|
assert G.name_for_glyph(G.glyph_for(name)) == name
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_round_trip():
|
||||||
|
for s in SIGNS:
|
||||||
|
assert G.sign_for_glyph(G.sign_glyph(s)) == s
|
||||||
|
|
||||||
|
|
||||||
|
def test_aspect_round_trip():
|
||||||
|
for a in MAJOR:
|
||||||
|
assert G.aspect_for_glyph(G.aspect_glyph(a)) == a
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_tolerates_missing_variant_selector():
|
||||||
|
"""Symbol wklejony bez VS15 też musi się rozpoznać — bo z zewnątrz przyjdzie
|
||||||
|
dowolna forma (DAN-17: łańcuch znosi każdy Unicode)."""
|
||||||
|
assert G.sign_for_glyph("♓") == "Pisces" # ♓ bez VS15
|
||||||
|
assert G.sign_for_glyph("♓" + VS15) == "Pisces" # ♓ z VS15
|
||||||
|
assert G.name_for_glyph("♀") == "Venus" # ♀ bez VS15
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- glifikacja tekstu
|
||||||
|
|
||||||
|
def test_glyphify_matches_requirement_example():
|
||||||
|
"""Przykład z wymagań: [Sa [Pis 26°08' [conj [PF -> ♄ ♓ 26°08' ☌ ⊗."""
|
||||||
|
out = G.glyphify("[Sa [Pis 26°08' [conj [PF")
|
||||||
|
assert G.glyph_for("Saturn") in out
|
||||||
|
assert G.sign_glyph("Pisces") in out
|
||||||
|
assert G.aspect_glyph("conjunction") in out
|
||||||
|
assert G.glyph_for("Fortune") in out
|
||||||
|
assert "26°08'" in out # stopnie nietknięte
|
||||||
|
|
||||||
|
|
||||||
|
def test_glyphify_handles_retrograde_and_leaves_unknown():
|
||||||
|
out = G.glyphify("[Ma Rx w [Xyz")
|
||||||
|
assert G.glyph_for("Mars") in out
|
||||||
|
assert G.RETROGRADE in out
|
||||||
|
assert "[Xyz" in out # nieznany token bez zmian
|
||||||
|
|
||||||
|
|
||||||
|
def test_glyphify_empty_is_safe():
|
||||||
|
assert G.glyphify("") == ""
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""Szyfrowanie łącza między warstwami (PRE-16).
|
||||||
|
|
||||||
|
Sedno: przez to łącze płyną surowe wiersze oryginalnych baz interpretacyjnych.
|
||||||
|
Testy nie sprawdzają więc tylko, czy „coś się zaszyfrowało i odszyfrowało" —
|
||||||
|
sprawdzają, czy tajnego tekstu FAKTYCZNIE NIE MA w bajtach lecących po sieci
|
||||||
|
oraz czy każda znana droga na skróty (brak szyfrowania, obcy klucz, podmieniony
|
||||||
|
bajt, przeklejenie na inny endpoint, odtworzenie po czasie) kończy się odmową.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
|
from app.link_crypto import ENV_LOGIC_DATA, ENV_PRESENTATION_LOGIC, Link, LinkError
|
||||||
|
|
||||||
|
SECRET = "Saturn w VII domu — opis z oryginalnej bazy interpretacyjnej"
|
||||||
|
KEY_A = "11" * 32 # hex, 32 bajty
|
||||||
|
KEY_B = "22" * 32
|
||||||
|
|
||||||
|
|
||||||
|
class Payload(BaseModel):
|
||||||
|
question: str
|
||||||
|
|
||||||
|
|
||||||
|
def _app(link: Link | None) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
if link is not None:
|
||||||
|
app.add_middleware(link_crypto.LinkCryptoMiddleware, link=link, layer="testowa")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.post("/search")
|
||||||
|
def search(payload: Payload):
|
||||||
|
return {"echo": payload.question, "interpretation": SECRET}
|
||||||
|
|
||||||
|
@app.get("/catalog")
|
||||||
|
def catalog():
|
||||||
|
return {"models": ["a", "b"]}
|
||||||
|
|
||||||
|
@app.post("/stream")
|
||||||
|
def stream():
|
||||||
|
def lines():
|
||||||
|
for i in range(4):
|
||||||
|
yield json.dumps({"step": i, "note": SECRET}).encode() + b"\n"
|
||||||
|
|
||||||
|
return StreamingResponse(lines(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def link():
|
||||||
|
return Link(link_crypto.parse_key(KEY_A))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def test_key_accepts_hex_and_base64():
|
||||||
|
import base64
|
||||||
|
|
||||||
|
raw = bytes(range(32))
|
||||||
|
assert link_crypto.parse_key(raw.hex()) == raw
|
||||||
|
assert link_crypto.parse_key(base64.b64encode(raw).decode()) == raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_of_wrong_length_is_rejected_loudly():
|
||||||
|
"""Krótki klucz to nie „słabsze szyfrowanie", tylko błąd konfiguracji."""
|
||||||
|
with pytest.raises(LinkError, match="32"):
|
||||||
|
link_crypto.parse_key("aabb")
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_that_is_neither_hex_nor_base64_is_rejected():
|
||||||
|
with pytest.raises(LinkError, match="hex"):
|
||||||
|
link_crypto.parse_key("!!! to nie jest klucz !!!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_from_env_is_lazy(monkeypatch):
|
||||||
|
monkeypatch.delenv(ENV_PRESENTATION_LOGIC, raising=False)
|
||||||
|
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) is None
|
||||||
|
monkeypatch.setenv(ENV_PRESENTATION_LOGIC, KEY_A)
|
||||||
|
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) == bytes.fromhex(KEY_A)
|
||||||
|
|
||||||
|
|
||||||
|
def test_directions_use_different_subkeys(link):
|
||||||
|
"""Żądanie i odpowiedź nie dzielą klucza — powtórzenie jednorazówki w jedną
|
||||||
|
stronę nie osłabia drugiej."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
sealed = link.seal(link_crypto.REQUEST, "/search", stamp, 0, b"tajne")
|
||||||
|
with pytest.raises(LinkError):
|
||||||
|
link.open(link_crypto.RESPONSE, "/search", stamp, 0, sealed)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- podstawowy obieg
|
||||||
|
|
||||||
|
def test_round_trip_delivers_plaintext_to_the_app(link):
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||||
|
payload={"question": "Saturn"}, link=link)
|
||||||
|
assert got["echo"] == "Saturn"
|
||||||
|
assert got["interpretation"] == SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_without_body_also_works(link):
|
||||||
|
"""GET nie ma ciała, ale i tak pieczętujemy pustą kopertę — to ona dowodzi,
|
||||||
|
że pytający ma klucz, i ona wymusza zaszyfrowanie odpowiedzi."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
got = link_crypto.call_json(client, "GET", "http://testserver/catalog", link=link)
|
||||||
|
assert got == {"models": ["a", "b"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_key_traffic_stays_plaintext(link):
|
||||||
|
"""Dev bez sekretów ma działać jak dotąd — inaczej nikt nie odpali projektu lokalnie."""
|
||||||
|
with TestClient(_app(None)) as client:
|
||||||
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||||
|
payload={"question": "Saturn"}, link=None)
|
||||||
|
assert got["interpretation"] == SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_stays_open_for_kubernetes_probes(link):
|
||||||
|
"""Sondy k8s klucza nie mają. Gdyby /health wymagał szyfrowania, literówka
|
||||||
|
w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
assert client.get("http://testserver/health").json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- czy na kablu naprawdę nic nie widać
|
||||||
|
|
||||||
|
def _raw_exchange(client, link, payload):
|
||||||
|
"""Wysyła zapieczętowane żądanie i zwraca SUROWE bajty obu stron."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
body = link_crypto.frame_out(
|
||||||
|
link.seal(link_crypto.REQUEST, "/search", stamp, 0, json.dumps(payload).encode()))
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
return body, response
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_bytes_do_not_contain_the_question(link):
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
body, _ = _raw_exchange(client, link, {"question": "Saturn w VII"})
|
||||||
|
assert b"Saturn" not in body
|
||||||
|
assert b"question" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_bytes_do_not_contain_the_interpretation(link):
|
||||||
|
"""To jest właściwy powód istnienia całego modułu."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
_, response = _raw_exchange(client, link, {"question": "Saturn"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
assert b"interpretation" not in response.content
|
||||||
|
assert response.headers[link_crypto.HEADER_ENC] == link_crypto.VERSION
|
||||||
|
assert response.headers["content-type"] == link_crypto.CONTENT_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- drogi na skróty i ataki
|
||||||
|
|
||||||
|
def test_plaintext_request_is_refused_when_key_is_set(link):
|
||||||
|
"""Fail-closed: regresja po stronie klienta nie może oznaczać cichego
|
||||||
|
powrotu do jawnego ruchu."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
response = client.post("http://testserver/search", json={"question": "Saturn"})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_foreign_key_cannot_read_the_link(link):
|
||||||
|
"""Klucze są osobne dla każdej pary warstw — przejęcie jednego nie otwiera drugiej."""
|
||||||
|
intruder = Link(link_crypto.parse_key(KEY_B))
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
body = link_crypto.frame_out(
|
||||||
|
intruder.seal(link_crypto.REQUEST, "/search", stamp, 0, b'{"question":"x"}'))
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_flipped_bit_is_rejected(link):
|
||||||
|
"""GCM uwierzytelnia, więc nie ma wariantu „odszyfrowało się, ale zmienione"."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
sealed = bytearray(link.seal(link_crypto.REQUEST, "/search", stamp, 0,
|
||||||
|
b'{"question":"x"}'))
|
||||||
|
sealed[-1] ^= 0x01
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search",
|
||||||
|
content=link_crypto.frame_out(bytes(sealed)),
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_cannot_be_replayed_against_another_endpoint(link):
|
||||||
|
"""Ścieżka wchodzi do materiału uwierzytelnianego, więc podsłuchanej koperty
|
||||||
|
nie da się przekleić tam, gdzie odpowiedź byłaby ciekawsza."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
sealed = link.seal(link_crypto.REQUEST, "/catalog", stamp, 0, b"")
|
||||||
|
with pytest.raises(LinkError):
|
||||||
|
link.open(link_crypto.REQUEST, "/search", stamp, 0, sealed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_frames_cannot_be_reordered(link):
|
||||||
|
"""Numer ramki jest uwierzytelniony — przestawienie kolejności w strumieniu
|
||||||
|
to błąd, a nie po cichu pomieszany horoskop."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
second = link.seal(link_crypto.RESPONSE, "/stream", stamp, 1, b"druga")
|
||||||
|
with pytest.raises(LinkError):
|
||||||
|
link.open(link_crypto.RESPONSE, "/stream", stamp, 0, second)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_frame_is_refused(link, monkeypatch):
|
||||||
|
"""Bez okna czasowego podsłuchane żądanie dałoby się odtworzyć kiedykolwiek."""
|
||||||
|
old = f"{time.time() - link_crypto.MAX_SKEW_SECONDS - 60:.3f}"
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
body = link_crypto.frame_out(
|
||||||
|
link.seal(link_crypto.REQUEST, "/search", old, 0, b'{"question":"x"}'))
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: old,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncated_stream_is_an_error_not_silent_loss(link):
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
full = link_crypto.frame_out(link.seal(link_crypto.RESPONSE, "/x", stamp, 0, b"abc"))
|
||||||
|
with pytest.raises(LinkError, match="urwana"):
|
||||||
|
link.open_all(link_crypto.RESPONSE, "/x", stamp, full[:-2])
|
||||||
|
|
||||||
|
|
||||||
|
def test_absurd_frame_length_does_not_allocate(link):
|
||||||
|
"""Zadeklarowana długość pochodzi z sieci — nie wolno jej wierzyć na słowo."""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
with pytest.raises(LinkError, match="rozmiar"):
|
||||||
|
list(link_crypto.frames_in(struct.pack(">I", 2 ** 31) + b"nic"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- odpowiedź strumieniowa
|
||||||
|
|
||||||
|
def test_streaming_response_survives_encryption(link):
|
||||||
|
"""Okno postępu dostaje kolejne linie na żywo — muszą dojść po kolei
|
||||||
|
i w komplecie, mimo że każda jedzie w osobnej kopercie."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
body = link_crypto.frame_out(link.seal(link_crypto.REQUEST, "/stream", stamp, 0, b""))
|
||||||
|
with client.stream("POST", "http://testserver/stream", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE}) as response:
|
||||||
|
chunks = list(link_crypto.open_response_stream(response, link))
|
||||||
|
|
||||||
|
steps = [json.loads(line) for line in b"".join(chunks).splitlines()]
|
||||||
|
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
||||||
|
assert all(s["note"] == SECRET for s in steps)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_lines_helper_round_trips_ndjson(link):
|
||||||
|
"""Wyższa warstwa (`stream_lines`) — dokładnie tej używa klient okna postępu.
|
||||||
|
|
||||||
|
Regresja, którą pilnuje ten test: strumień horoskopu powstał na gałęzi, która
|
||||||
|
nie widziała szyfrowania, więc szedł surowym httpx. Po scaleniu z PRE-16 przy
|
||||||
|
włączonym łączu żądanie było odrzucane (400) albo odpowiedź wracała jako
|
||||||
|
nieczytelne ramki. Tu sprawdzamy, że helper zwraca CZYSTE linie NDJSON i że
|
||||||
|
tajny opis nie przecieka po drodze."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
lines = list(link_crypto.stream_lines(
|
||||||
|
client, "http://testserver/stream", payload=None, link=link))
|
||||||
|
steps = [json.loads(line) for line in lines]
|
||||||
|
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
||||||
|
assert all(s["note"] == SECRET for s in steps)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_lines_plaintext_path_still_works(link):
|
||||||
|
"""Bez klucza (dev) strumień ma działać jak dotąd — surowy, bez szyfrowania."""
|
||||||
|
with TestClient(_app(None)) as client:
|
||||||
|
lines = list(link_crypto.stream_lines(
|
||||||
|
client, "http://testserver/stream", payload=None, link=None))
|
||||||
|
assert [json.loads(line)["step"] for line in lines] == [0, 1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_request_without_key_is_refused_when_required(link):
|
||||||
|
"""Fail-closed obejmuje też strumień: nieszyfrowane żądanie na łączu z kluczem
|
||||||
|
dostaje odmowę, a nie cichy jawny przelot tajnych danych."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
response = client.post("http://testserver/stream", json={})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_lines_client_is_fail_closed_when_required(monkeypatch, link):
|
||||||
|
"""Klient strumienia też nie wypuszcza jawnego żądania — jak `call`. Bez tego
|
||||||
|
ciało (dane urodzenia) poszłoby w eter, zanim serwer zdążyłby odmówić."""
|
||||||
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
class Tripwire:
|
||||||
|
def stream(self, *args, **kwargs):
|
||||||
|
sent.append(args)
|
||||||
|
raise AssertionError("strumień NIE powinien opuścić procesu")
|
||||||
|
|
||||||
|
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
||||||
|
list(link_crypto.stream_lines(Tripwire(), "http://logic/chart/horoscope/stream",
|
||||||
|
payload={"lat": 50.0}, link=None))
|
||||||
|
assert not sent, "żądanie strumienia wyszłoby jawnym tekstem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_unframing_handles_split_frames(link):
|
||||||
|
"""Ramka potrafi rozjechać się między dwa odczyty z gniazda — składamy ją
|
||||||
|
w buforze, zamiast zakładać, że każdy kawałek to komplet."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
stream = b"".join(link.seal_stream(link_crypto.RESPONSE, "/x", stamp,
|
||||||
|
[b"raz", b"dwa", b"trzy"]))
|
||||||
|
buffer = bytearray()
|
||||||
|
opened, seq = [], 0
|
||||||
|
for i in range(0, len(stream), 5): # ciachamy w poprzek ramek
|
||||||
|
buffer += stream[i:i + 5]
|
||||||
|
for frame in link_crypto.unframe_incremental(buffer):
|
||||||
|
opened.append(link.open(link_crypto.RESPONSE, "/x", stamp, seq, frame))
|
||||||
|
seq += 1
|
||||||
|
assert opened == [b"raz", b"dwa", b"trzy"]
|
||||||
|
assert not buffer, "bufor musi zostać pusty — inaczej gdzieś zgubiliśmy ramkę"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------- trzy kopie muszą być identyczne
|
||||||
|
|
||||||
|
def test_all_three_services_share_the_same_module():
|
||||||
|
"""Moduł jest skopiowany do trzech niezależnych usług (nie mają wspólnej
|
||||||
|
biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako
|
||||||
|
„nie da się odszyfrować" — więc pilnujemy tego testem."""
|
||||||
|
root = pathlib.Path(__file__).resolve().parents[3]
|
||||||
|
copies = {svc: (root / "services" / svc / "app" / "link_crypto.py")
|
||||||
|
for svc in ("presentation", "logic", "data")}
|
||||||
|
missing = [svc for svc, path in copies.items() if not path.is_file()]
|
||||||
|
assert not missing, f"brak modułu w warstwach: {missing}"
|
||||||
|
contents = {svc: path.read_bytes() for svc, path in copies.items()}
|
||||||
|
assert len(set(contents.values())) == 1, (
|
||||||
|
"kopie link_crypto.py rozjechały się między warstwami: "
|
||||||
|
+ ", ".join(f"{svc}={len(body)}B" for svc, body in contents.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_names_are_two_distinct_keys():
|
||||||
|
"""Wymóg wprost: osobny klucz dla pary prezentacja-logika i logika-dane."""
|
||||||
|
assert ENV_PRESENTATION_LOGIC != ENV_LOGIC_DATA
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- klient też musi być fail-closed
|
||||||
|
#
|
||||||
|
# To wyszło dopiero z podsłuchu prawdziwego gniazda, nie z testów: przy kliencie
|
||||||
|
# BEZ klucza serwer owszem odmawiał, ale pytanie leciało po drodze otwartym
|
||||||
|
# tekstem. Odpowiedź była chroniona — zapytanie już nie.
|
||||||
|
|
||||||
|
def test_client_without_key_sends_nothing_when_encryption_required(monkeypatch):
|
||||||
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
class Tripwire:
|
||||||
|
def request(self, *args, **kwargs):
|
||||||
|
sent.append(args)
|
||||||
|
raise AssertionError("żądanie NIE powinno opuścić procesu")
|
||||||
|
|
||||||
|
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
||||||
|
link_crypto.call(Tripwire(), "POST", "http://logic/search",
|
||||||
|
payload={"value": "[Sat"}, link=None)
|
||||||
|
assert not sent, "treść zapytania wyszłaby jawnym tekstem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plaintext_still_allowed_in_dev(monkeypatch, link):
|
||||||
|
"""Bez tej flagi lokalne uruchomienie bez sekretów ma dalej działać."""
|
||||||
|
monkeypatch.delenv(link_crypto.ENV_REQUIRED, raising=False)
|
||||||
|
with TestClient(_app(None)) as client:
|
||||||
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||||
|
payload={"question": "Saturn"}, link=None)
|
||||||
|
assert got["interpretation"] == SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_refuses_to_start_without_key_when_required(monkeypatch):
|
||||||
|
"""Pod w CrashLoop widać od razu; usługę, która wstała i nie szyfruje — nie."""
|
||||||
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||||
|
monkeypatch.delenv(ENV_LOGIC_DATA, raising=False)
|
||||||
|
with pytest.raises(LinkError, match=ENV_LOGIC_DATA):
|
||||||
|
link_crypto.install(FastAPI(), ENV_LOGIC_DATA, "danych")
|
||||||
@@ -405,3 +405,33 @@ def test_max_budget_differs_between_models(monkeypatch):
|
|||||||
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
||||||
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
||||||
assert opus > haiku > local > 0
|
assert opus > haiku > local > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_turn_budget_counts_produced_not_requested(monkeypatch):
|
||||||
|
"""Regresja: odejmowanie ZAMOWIONEGO limitu tury zamiast wyprodukowanych
|
||||||
|
tokenow konczylo petle po jednej turze — urwany fragment wracal jako calosc."""
|
||||||
|
seq = [_chat("Fragment 1. ", "length"), _chat("Fragment 2. ", "length"),
|
||||||
|
_chat("Zakonczenie. KONIEC", "stop")]
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
# budzet 8000 < TURN_TOKENS_CAP: przy starej logice byla dokladnie jedna tura
|
||||||
|
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 8000)
|
||||||
|
assert out.usage["turns"] == 3, "urwana odpowiedz musi byc kontynuowana"
|
||||||
|
assert "Fragment 1." in out.text and "Zakonczenie." in out.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_reports_progress_events(monkeypatch):
|
||||||
|
"""Log w UI ma pokazywac RZECZYWISTE tury, nie udawany pasek postepu."""
|
||||||
|
seq = [_chat("Czesc. ", "length"), _chat("Reszta. KONIEC", "stop")]
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(
|
||||||
|
lambda r: httpx.Response(200, json=seq.pop(0) if seq else seq[-1])))
|
||||||
|
events = []
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate(
|
||||||
|
"p", 8000, on_event=events.append)
|
||||||
|
kinds = [e["type"] for e in events]
|
||||||
|
assert kinds.count("turn_start") == 2 and kinds.count("turn_end") == 2
|
||||||
|
assert kinds[-1] == "generated"
|
||||||
|
assert any("urwana" in e["message"] for e in events if e["type"] == "turn_end")
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Aspekty pozazodiakalne — paralele deklinacji i antyscja (LOG-07).
|
||||||
|
|
||||||
|
Walidacja wobec faktów NIEZALEŻNYCH od naszego kodu:
|
||||||
|
* deklinacja Słońca 30.04.1984 ≈ +14.9° (almanach; Słońce w ~10° Byka, koniec
|
||||||
|
kwietnia, deklinacja rośnie ku przesileniu letniemu),
|
||||||
|
* antyscja to czysta arytmetyka odbicia względem osi przesileń — pary znaków
|
||||||
|
(Rak↔Bliźnięta, Baran↔Panna) i inwolucja dają się sprawdzić na piechotę,
|
||||||
|
* węzły są z definicji w kontrparaleli (SN = NN+180° na ekliptyce → deklinacja
|
||||||
|
przeciwna), więc muszą być odsiane jako kontakt bez informacji.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.out_of_zodiac import (
|
||||||
|
antiscion,
|
||||||
|
contra_antiscion,
|
||||||
|
declination,
|
||||||
|
find_antiscia,
|
||||||
|
find_declination_aspects,
|
||||||
|
is_out_of_bounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def krakow():
|
||||||
|
return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||||
|
lat=50.0647, lon=19.9450)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def chart(own_engine, krakow):
|
||||||
|
return build_chart(own_engine, krakow)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- deklinacja
|
||||||
|
|
||||||
|
def test_sun_declination_matches_almanac(chart):
|
||||||
|
"""Deklinacja Słońca 30.04.1984 ≈ +14.9° (wartość z almanachu)."""
|
||||||
|
sun = next(p for p in chart["positions"] if p["name"] == "Sun")
|
||||||
|
assert 14.6 < sun["declination"] < 15.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_uses_latitude_not_only_longitude():
|
||||||
|
"""Pełny wzór: przy tej samej długości różna szerokość → różna deklinacja.
|
||||||
|
Gdyby liczyć z samej długości (β=0), obie wyszłyby identyczne."""
|
||||||
|
eps = 23.44
|
||||||
|
on_ecliptic = declination(60.0, 0.0, eps) # β = 0
|
||||||
|
above = declination(60.0, 5.0, eps) # 5° na północ od ekliptyki
|
||||||
|
assert abs(above - on_ecliptic) > 3.0 # szerokość realnie zmienia deklinację
|
||||||
|
|
||||||
|
|
||||||
|
def test_obliquity_is_reasonable_for_1984(chart):
|
||||||
|
assert 23.43 < chart["obliquity"] < 23.45
|
||||||
|
|
||||||
|
|
||||||
|
def test_out_of_bounds_flag():
|
||||||
|
eps = 23.44
|
||||||
|
assert is_out_of_bounds(24.0, eps) is True # poza zakresem Słońca
|
||||||
|
assert is_out_of_bounds(20.0, eps) is False
|
||||||
|
assert is_out_of_bounds(-24.5, eps) is True # także na południe
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- antyscja (wzór)
|
||||||
|
|
||||||
|
def test_antiscion_is_reflection_across_solstitial_axis():
|
||||||
|
assert antiscion(0.0) == pytest.approx(180.0) # 0° Barana → 0° Wagi
|
||||||
|
assert antiscion(105.0) == pytest.approx(75.0) # 15° Raka → 15° Bliźniąt
|
||||||
|
assert antiscion(90.0) == pytest.approx(90.0) # 0° Raka leży NA osi
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscion_is_an_involution():
|
||||||
|
for lon in (12.3, 88.0, 200.5, 359.9):
|
||||||
|
assert antiscion(antiscion(lon)) == pytest.approx(lon)
|
||||||
|
|
||||||
|
|
||||||
|
def test_contra_antiscion_across_equinoctial_axis():
|
||||||
|
assert contra_antiscion(0.0) == pytest.approx(0.0) # 0° Barana leży NA osi
|
||||||
|
assert contra_antiscion(30.0) == pytest.approx(330.0) # 0° Byka → 0° Ryb (odbicie)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_pairs_are_classic():
|
||||||
|
"""Antyscja parami znaków: Baran↔Panna, Byk↔Lew, Bliźnięta↔Rak."""
|
||||||
|
# 5° Barana (5°) → 175° = 25° Panny
|
||||||
|
assert antiscion(5.0) == pytest.approx(175.0)
|
||||||
|
# 10° Byka (40°) → 140° = 20° Lwa
|
||||||
|
assert antiscion(40.0) == pytest.approx(140.0)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------- wykrywanie paraleli/antyscji
|
||||||
|
|
||||||
|
def test_parallel_and_contraparallel_detection():
|
||||||
|
bodies = [
|
||||||
|
{"name": "A", "declination": 12.0},
|
||||||
|
{"name": "B", "declination": 12.4}, # paralela z A (0.4°)
|
||||||
|
{"name": "C", "declination": -11.6}, # kontrparalela z A (|12-11.6|=0.4)
|
||||||
|
{"name": "D", "declination": 5.0}, # nic (daleko)
|
||||||
|
]
|
||||||
|
res = {(r["obj1"], r["obj2"]): r for r in find_declination_aspects(bodies, orb=1.0)}
|
||||||
|
assert res[("A", "B")]["type"] == "parallel"
|
||||||
|
assert res[("A", "C")]["type"] == "contraparallel"
|
||||||
|
assert ("A", "D") not in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_orb_is_configurable():
|
||||||
|
bodies = [{"name": "A", "declination": 10.0}, {"name": "B", "declination": 11.5}]
|
||||||
|
assert find_declination_aspects(bodies, orb=1.0) == [] # 1.5° > 1.0°
|
||||||
|
assert len(find_declination_aspects(bodies, orb=2.0)) == 1 # mieści się
|
||||||
|
|
||||||
|
|
||||||
|
def test_nodes_definitional_contraparallel_is_filtered(chart):
|
||||||
|
"""SN = NN+180° na ekliptyce → deklinacja przeciwna ZAWSZE. To kontakt bez
|
||||||
|
informacji, więc pary NN/SN nie może być w wynikach."""
|
||||||
|
decl = {p["name"]: p["declination"] for p in chart["positions"]}
|
||||||
|
# najpierw potwierdzamy, że deklinacje faktycznie są przeciwne
|
||||||
|
assert decl["North Node"] == pytest.approx(-decl["South Node"], abs=0.05)
|
||||||
|
# a mimo to para NN/SN nie pojawia się na liście paraleli
|
||||||
|
pairs = {frozenset((r["obj1"], r["obj2"])) for r in chart["parallels"]}
|
||||||
|
assert frozenset(("North Node", "South Node")) not in pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscia_detection_with_constructed_longitudes():
|
||||||
|
# A na 40°, B na 138° → antyscja A leży na 140°, więc B jest 2° od niej
|
||||||
|
bodies = [{"name": "A", "decimal": 40.0}, {"name": "B", "decimal": 138.0}]
|
||||||
|
assert find_antiscia(bodies, orb=1.0) == [] # 2° > 1°
|
||||||
|
hit = find_antiscia(bodies, orb=3.0)
|
||||||
|
assert len(hit) == 1 and hit[0]["type"] == "antiscion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contra_antiscion_detection():
|
||||||
|
# A na 40°, kontrantyscja A = 320°; B na 320.5° → kontrantyscja w orbie
|
||||||
|
bodies = [{"name": "A", "decimal": 40.0}, {"name": "B", "decimal": 320.5}]
|
||||||
|
hit = find_antiscia(bodies, orb=1.0)
|
||||||
|
assert len(hit) == 1 and hit[0]["type"] == "contra_antiscion"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ spójność z build_chart
|
||||||
|
|
||||||
|
def test_chart_exposes_parallels_and_antiscia(chart):
|
||||||
|
assert "parallels" in chart and "antiscia" in chart
|
||||||
|
assert "obliquity" in chart
|
||||||
|
# znany, ciasny kontakt z tego horoskopu: Uran ~paralela~ Neptun (mid-80s)
|
||||||
|
un = next((r for r in chart["parallels"]
|
||||||
|
if {r["obj1"], r["obj2"]} == {"Uranus", "Neptune"}), None)
|
||||||
|
assert un is not None and un["type"] == "parallel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_is_zodiac_independent(own_engine, krakow):
|
||||||
|
"""Deklinacja jest fizyczna — nie zmienia się z wyborem zodiaku."""
|
||||||
|
tropical = build_chart(own_engine, krakow, zodiac="tropical")
|
||||||
|
sidereal = build_chart(own_engine, krakow, zodiac="sidereal_lahiri")
|
||||||
|
dt = {p["name"]: p["declination"] for p in tropical["positions"]}
|
||||||
|
ds = {p["name"]: p["declination"] for p in sidereal["positions"]}
|
||||||
|
for name in dt:
|
||||||
|
assert dt[name] == pytest.approx(ds[name], abs=1e-6)
|
||||||
@@ -61,6 +61,42 @@ def test_aspect_facet():
|
|||||||
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
|
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_facet_hits_db_and_avoids_decanate_trap():
|
||||||
|
"""Paralela deklinacji (LOG-07): baza zna ją jako „P. Dec.". Pułapka: samo
|
||||||
|
„Dec." to często DEKANAT („3rd Dec. of [Gem"), który NIE może wpaść do paraleli."""
|
||||||
|
positions = [
|
||||||
|
{"name": "Mercury", "sign": "Taurus", "direction": "D", "house": 3},
|
||||||
|
{"name": "Saturn", "sign": "Scorpio", "direction": "Rx", "house": 9},
|
||||||
|
]
|
||||||
|
parallels = [{"obj1": "Mercury", "obj2": "Saturn", "type": "parallel",
|
||||||
|
"orb": 0.3, "allowed": 1.0}]
|
||||||
|
data = FakeData({"[Me": [
|
||||||
|
{"significator": "[Me at B. in P. Dec. [Sa and [Ma", "actioneffect": "dyscyplina umysłu"},
|
||||||
|
{"significator": "3rd Dec. of [Gem on Asc.", "actioneffect": "TO DEKANAT, nie paralela"},
|
||||||
|
{"significator": "[Me [conj [Sa", "actioneffect": "inny aspekt, nie paralela"},
|
||||||
|
]})
|
||||||
|
par = [f for f in build_report(positions, data, parallels=parallels)["objects"][0]["facets"]
|
||||||
|
if f["type"] == "parallel"]
|
||||||
|
assert par and par[0]["count"] == 1
|
||||||
|
assert "Saturn" in par[0]["label"]
|
||||||
|
assert all("Dec. of" not in s["significator"] for s in par[0]["samples"]), "dekanat przeciekł"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contraparallel_gets_no_db_facet():
|
||||||
|
"""Baza nie ma osobnego znacznika dla kontrparaleli — nie zmyślamy fasety."""
|
||||||
|
positions = [
|
||||||
|
{"name": "Mercury", "sign": "Taurus", "direction": "D", "house": 3},
|
||||||
|
{"name": "Saturn", "sign": "Scorpio", "direction": "Rx", "house": 9},
|
||||||
|
]
|
||||||
|
parallels = [{"obj1": "Mercury", "obj2": "Saturn", "type": "contraparallel",
|
||||||
|
"orb": 0.3, "allowed": 1.0}]
|
||||||
|
data = FakeData({"[Me": [
|
||||||
|
{"significator": "[Me at B. in P. Dec. [Sa", "actioneffect": "paralela, nie kontr-"},
|
||||||
|
]})
|
||||||
|
types = [f["type"] for f in build_report(positions, data, parallels=parallels)["objects"][0]["facets"]]
|
||||||
|
assert "parallel" not in types
|
||||||
|
|
||||||
|
|
||||||
def test_grouping_by_effect():
|
def test_grouping_by_effect():
|
||||||
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
|
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
|
||||||
data = FakeData({"[Ma": [
|
data = FakeData({"[Ma": [
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Tabele pomocnicze horoskopu (LOG-23).
|
||||||
|
|
||||||
|
Wartości referencyjne dla horoskopu 30.04.1984 09:20 UTC, Kraków (50.0647N, 19.9450E)
|
||||||
|
sprawdzone wobec faktów NIEZALEŻNYCH od naszego kodu:
|
||||||
|
* 30.04.1984 to poniedziałek → władca dnia Księżyc; 5. godzina poniedziałku
|
||||||
|
w porządku chaldejskim to Słońce (Mo, Sa, Ju, Ma, Su),
|
||||||
|
* wschód/zachód dla Krakowa końcem kwietnia ≈ 5:18 / 19:57 czasu lokalnego
|
||||||
|
(CEST = UTC+2), czyli 03:18 / 17:57 UTC,
|
||||||
|
* pełnia poprzedzająca urodzenie: 15.04.1984 ok. 19:11 UTC.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.tables import (
|
||||||
|
build_tables,
|
||||||
|
critical_degrees,
|
||||||
|
dwadasamsa,
|
||||||
|
element_of,
|
||||||
|
moon_phase,
|
||||||
|
navamsa,
|
||||||
|
planetary_hours,
|
||||||
|
prenatal_syzygy,
|
||||||
|
quality_of,
|
||||||
|
tally,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def krakow():
|
||||||
|
return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||||
|
lat=50.0647, lon=19.9450)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def tables(own_engine, krakow):
|
||||||
|
return build_tables(own_engine, krakow, build_chart(own_engine, krakow))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ żywioły i jakości
|
||||||
|
|
||||||
|
def test_element_and_quality_mapping():
|
||||||
|
assert element_of("Aries") == "Fire" and element_of("Cancer") == "Water"
|
||||||
|
assert quality_of("Aries") == "Cardinal" and quality_of("Taurus") == "Fixed"
|
||||||
|
assert quality_of("Gemini") == "Mutable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tally_matches_hand_count(tables):
|
||||||
|
"""Ręcznie przeliczone dla horoskopu referencyjnego (10 planet + Asc)."""
|
||||||
|
base = tables["tally"]["with_modern_10_plus_asc"]
|
||||||
|
assert base["elements"] == {"Fire": 4, "Earth": 4, "Air": 0, "Water": 3}
|
||||||
|
assert base["qualities"] == {"Cardinal": 4, "Fixed": 6, "Mutable": 1}
|
||||||
|
assert base["total"] == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_element_detected(tables):
|
||||||
|
"""Klasyczne „no air" — podstawa pod scoring siły (LOG-21)."""
|
||||||
|
assert tables["tally"]["missing_elements"] == ["Air"]
|
||||||
|
assert tables["tally"]["missing_qualities"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tally_variants_differ_by_object_count(tables):
|
||||||
|
t = tables["tally"]
|
||||||
|
assert t["classical_7"]["total"] == 7
|
||||||
|
assert t["with_modern_10"]["total"] == 10
|
||||||
|
assert t["classical_7_plus_asc"]["total"] == 8
|
||||||
|
assert t["with_modern_10_plus_asc"]["total"] == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_tally_sums_equal_counted_objects(tables):
|
||||||
|
for variant in ("classical_7", "with_modern_10", "with_modern_10_plus_asc"):
|
||||||
|
v = tables["tally"][variant]
|
||||||
|
assert sum(v["elements"].values()) == v["total"]
|
||||||
|
assert sum(v["qualities"].values()) == v["total"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- faza Księżyca
|
||||||
|
|
||||||
|
def test_moon_phase_reference_is_balsamic_new(tables):
|
||||||
|
"""Nów wypadł 1.05.1984, więc 30.04 Księżyc jest tuż przed nowiem."""
|
||||||
|
mp = tables["moon_phase"]
|
||||||
|
assert mp["phase"] == "New Moon"
|
||||||
|
assert 340.0 < mp["angle"] < 360.0
|
||||||
|
assert mp["illumination"] < 0.05
|
||||||
|
assert mp["waxing"] is False # elongacja > 180 = ubywa
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("angle,expected", [
|
||||||
|
(0.0, "New Moon"), (90.0, "First Quarter"), (180.0, "Full Moon"),
|
||||||
|
(270.0, "Last Quarter"), (46.0, "Waxing Crescent"), (300.0, "Waning Crescent"),
|
||||||
|
])
|
||||||
|
def test_moon_phase_buckets(angle, expected):
|
||||||
|
assert moon_phase(0.0, angle)["phase"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_moon_phase_illumination_extremes():
|
||||||
|
assert moon_phase(0.0, 0.0)["illumination"] == 0.0
|
||||||
|
assert moon_phase(0.0, 180.0)["illumination"] == 1.0
|
||||||
|
assert moon_phase(0.0, 90.0)["illumination"] == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- stopnie krytyczne
|
||||||
|
|
||||||
|
def test_critical_degrees_reference(tables):
|
||||||
|
found = {c["name"]: c["flags"] for c in tables["critical_degrees"]}
|
||||||
|
assert "Jupiter" in found # Cap 12°57' -> 13° kardynalny
|
||||||
|
assert any("13" in f for f in found["Jupiter"])
|
||||||
|
assert "Pluto" in found # Sco 0°28' -> wejście w znak
|
||||||
|
|
||||||
|
|
||||||
|
def test_anaretic_degree_flagged():
|
||||||
|
flags = critical_degrees([{"name": "X", "sign": "Leo", "decimal": 149.5,
|
||||||
|
"in_sign": "Leo 29°30'"}])
|
||||||
|
assert flags and any("anaretyczny" in f for f in flags[0]["flags"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_flags_for_ordinary_degree():
|
||||||
|
assert critical_degrees([{"name": "X", "sign": "Leo", "decimal": 135.0,
|
||||||
|
"in_sign": "Leo 15°"}]) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- podziały
|
||||||
|
|
||||||
|
def test_dwadasamsa_starts_from_own_sign():
|
||||||
|
"""12. część liczy się OD znaku, w którym stoi punkt."""
|
||||||
|
assert dwadasamsa(0.0) == pytest.approx(0.0) # Ari 0 -> Ari
|
||||||
|
assert dwadasamsa(2.5) == pytest.approx(30.0) # Ari 2°30' -> Tau 0
|
||||||
|
assert dwadasamsa(30.0) == pytest.approx(30.0) # Tau 0 -> Tau
|
||||||
|
|
||||||
|
|
||||||
|
def test_navamsa_classic_starts():
|
||||||
|
"""Znaki kardynalne zaczynają od siebie, stałe od 9. znaku."""
|
||||||
|
assert navamsa(0.0) == pytest.approx(0.0) # Ari -> Ari
|
||||||
|
assert navamsa(30.0) == pytest.approx(270.0) # Tau -> Cap (9. od Byka)
|
||||||
|
assert navamsa(60.0) == pytest.approx(180.0) # Gem -> Lib
|
||||||
|
|
||||||
|
|
||||||
|
def test_divisional_covers_all_positions(tables, own_engine, krakow):
|
||||||
|
chart = build_chart(own_engine, krakow)
|
||||||
|
assert len(tables["divisional"]) == len(chart["positions"])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- dzień i godziny planetarne
|
||||||
|
|
||||||
|
def test_planetary_day_ruler_is_moon_on_monday(tables):
|
||||||
|
"""30.04.1984 to poniedziałek → władcą dnia jest Księżyc."""
|
||||||
|
assert tables["planetary_hours"]["day_ruler"] == "Moon"
|
||||||
|
|
||||||
|
|
||||||
|
def test_planetary_hour_matches_chaldean_sequence(tables):
|
||||||
|
"""Poniedziałek: 1=Mo, 2=Sa, 3=Ju, 4=Ma, 5=Su — urodzenie w 5. godzinie dnia."""
|
||||||
|
ph = tables["planetary_hours"]
|
||||||
|
assert ph["hour_number"] == 5
|
||||||
|
assert ph["hour_ruler"] == "Sun"
|
||||||
|
assert ph["daytime"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sunrise_sunset_match_krakow_late_april(tables):
|
||||||
|
"""Wschód ≈ 03:18 UTC, zachód ≈ 17:57 UTC (5:18 i 19:57 czasu lokalnego)."""
|
||||||
|
ph = tables["planetary_hours"]
|
||||||
|
assert ph["period_start"].startswith("1984-04-30T03:1")
|
||||||
|
assert ph["period_end"].startswith("1984-04-30T17:5")
|
||||||
|
|
||||||
|
|
||||||
|
def test_planetary_hours_are_unequal_and_complete(tables):
|
||||||
|
"""Godziny są nierówne: wiosną dzienna trwa dłużej niż 60 minut."""
|
||||||
|
ph = tables["planetary_hours"]
|
||||||
|
assert ph["hour_length_minutes"] > 60.0
|
||||||
|
assert len(ph["hours"]) == 12
|
||||||
|
assert sum(1 for h in ph["hours"] if h["current"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_polar_night_returns_none(own_engine):
|
||||||
|
"""Za kołem podbiegunowym w grudniu Słońce nie wschodzi — brak godzin."""
|
||||||
|
polar = ChartMoment(when_utc=datetime(2024, 12, 21, 12, 0, tzinfo=timezone.utc),
|
||||||
|
lat=78.0, lon=15.0)
|
||||||
|
assert planetary_hours(own_engine, polar) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- syzygia prenatalna
|
||||||
|
|
||||||
|
def test_prenatal_syzygy_is_april_1984_full_moon(tables):
|
||||||
|
"""Rzeczywista pełnia: 15.04.1984 ok. 19:11 UTC."""
|
||||||
|
s = tables["prenatal_syzygy"]
|
||||||
|
assert s["type"] == "full_moon"
|
||||||
|
assert s["when_utc"].startswith("1984-04-15T19:1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prenatal_syzygy_precedes_birth_within_a_cycle(tables):
|
||||||
|
days = tables["prenatal_syzygy"]["days_before_birth"]
|
||||||
|
assert 0 < days < 29.6, "syzygia musi być w ostatnim cyklu przed urodzeniem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prenatal_syzygy_elongation_is_at_target(own_engine, krakow):
|
||||||
|
"""W znalezionym momencie elongacja MUSI wynosić 0° albo 180°."""
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
s = prenatal_syzygy(own_engine, krakow)
|
||||||
|
when = datetime.fromisoformat(s["when_utc"])
|
||||||
|
pts = {p.name: p.longitude for p in
|
||||||
|
own_engine.positions(ChartMoment(when_utc=when, lat=krakow.lat, lon=krakow.lon),
|
||||||
|
["Sun", "Moon"])}
|
||||||
|
elong = norm360(pts["Moon"] - pts["Sun"])
|
||||||
|
target = 0.0 if s["type"] == "new_moon" else 180.0
|
||||||
|
assert abs(((elong - target + 180.0) % 360.0) - 180.0) < 0.02
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ całość
|
||||||
|
|
||||||
|
def test_build_tables_light_skips_numeric_search(own_engine, krakow):
|
||||||
|
"""heavy=False pomija to, co wymaga szukania numerycznego."""
|
||||||
|
light = build_tables(own_engine, krakow, build_chart(own_engine, krakow), heavy=False)
|
||||||
|
assert "tally" in light and "moon_phase" in light
|
||||||
|
assert "planetary_hours" not in light and "prenatal_syzygy" not in light
|
||||||
@@ -10,6 +10,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -19,16 +20,29 @@ def _auth_headers() -> dict[str, str]:
|
|||||||
return {"X-Astrololo-Token": token} if token else {}
|
return {"X-Astrololo-Token": token} if token else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _link() -> link_crypto.Link | None:
|
||||||
|
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
|
||||||
|
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
|
||||||
|
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
|
||||||
|
return link_crypto.Link(key) if key else None
|
||||||
|
|
||||||
|
|
||||||
class LogicClient:
|
class LogicClient:
|
||||||
def __init__(self, base_url: str | None = None) -> None:
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
||||||
|
|
||||||
|
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||||
|
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
|
||||||
|
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
|
||||||
|
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
||||||
|
payload=payload, headers=_auth_headers(),
|
||||||
|
link=_link())
|
||||||
|
|
||||||
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
return self._post("/api/query", payload, settings.http_timeout)
|
||||||
r = client.post(f"{self.base_url}/api/query", json=payload, headers=_auth_headers())
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def positions(
|
def positions(
|
||||||
self,
|
self,
|
||||||
@@ -39,6 +53,7 @@ class LogicClient:
|
|||||||
house_system: str = "whole_sign",
|
house_system: str = "whole_sign",
|
||||||
stations: bool = False,
|
stations: bool = False,
|
||||||
zodiac: str = "tropical",
|
zodiac: str = "tropical",
|
||||||
|
tables: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||||
payload = {
|
payload = {
|
||||||
@@ -49,22 +64,18 @@ class LogicClient:
|
|||||||
"house_system": house_system,
|
"house_system": house_system,
|
||||||
"stations": stations,
|
"stations": stations,
|
||||||
"zodiac": zodiac,
|
"zodiac": zodiac,
|
||||||
|
"tables": tables,
|
||||||
}
|
}
|
||||||
# stacje wymagają root-findów — dłuższy timeout
|
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
|
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
|
||||||
r = client.post(f"{self.base_url}/chart/positions", json=payload, headers=_auth_headers())
|
return self._post("/chart/positions", payload, timeout)
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def report(
|
def report(
|
||||||
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
||||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
|
||||||
r = client.post(f"{self.base_url}/chart/report", json=payload, headers=_auth_headers())
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def prompt(
|
def prompt(
|
||||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||||
@@ -82,10 +93,7 @@ class LogicClient:
|
|||||||
}
|
}
|
||||||
if from_date and to_date:
|
if from_date and to_date:
|
||||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
return self._post("/chart/prompt", payload, max(settings.http_timeout, 60.0))
|
||||||
r = client.post(f"{self.base_url}/chart/prompt", json=payload, headers=_auth_headers())
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def horoscope(
|
def horoscope(
|
||||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||||
@@ -106,17 +114,29 @@ class LogicClient:
|
|||||||
payload["model"] = model
|
payload["model"] = model
|
||||||
if from_date and to_date:
|
if from_date and to_date:
|
||||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client:
|
return self._post("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
|
||||||
r = client.post(f"{self.base_url}/chart/horoscope", json=payload, headers=_auth_headers())
|
|
||||||
r.raise_for_status()
|
def horoscope_stream(self, payload: dict[str, Any]):
|
||||||
return r.json()
|
"""Strumień postępu pisania horoskopu (NDJSON) — przekazywany do przeglądarki.
|
||||||
|
|
||||||
|
Timeout jest długi, bo generowanie trwa; strumień i tak niesie heartbeat,
|
||||||
|
więc cisza na łączu nie zostanie wzięta za zerwanie.
|
||||||
|
|
||||||
|
Idzie przez szyfrowane łącze jak reszta ruchu w dół (PRE-16). Bez tego przy
|
||||||
|
włączonym `LINK_ENCRYPTION_REQUIRED` serwer odrzuciłby nieszyfrowane żądanie
|
||||||
|
i okno postępu przestałoby działać. `stream_lines` pieczętuje żądanie i
|
||||||
|
odszyfrowuje odpowiedź ramka po ramce, zachowując dostarczanie na żywo.
|
||||||
|
"""
|
||||||
|
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
||||||
|
yield from link_crypto.stream_lines(
|
||||||
|
client, f"{self.base_url}/chart/horoscope/stream",
|
||||||
|
payload=payload, headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
def llm_models(self) -> dict[str, Any]:
|
def llm_models(self) -> dict[str, Any]:
|
||||||
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
r = client.get(f"{self.base_url}/llm/models", headers=_auth_headers())
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
|
||||||
r.raise_for_status()
|
headers=_auth_headers(), link=_link())
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def timeline(
|
def timeline(
|
||||||
self, when_utc_iso: str, lat: float, lon: float,
|
self, when_utc_iso: str, lat: float, lon: float,
|
||||||
@@ -127,7 +147,4 @@ class LogicClient:
|
|||||||
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
||||||
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
||||||
}
|
}
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
|
||||||
r = client.post(f"{self.base_url}/chart/timeline", json=payload, headers=_auth_headers())
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""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"
|
||||||
|
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 astrololo")
|
||||||
|
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")
|
||||||
@@ -9,11 +9,12 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
@@ -78,17 +79,18 @@ def chart_compute(
|
|||||||
house_system: str = Form("whole_sign"),
|
house_system: str = Form("whole_sign"),
|
||||||
stations: bool = Form(False),
|
stations: bool = Form(False),
|
||||||
zodiac: str = Form("tropical"),
|
zodiac: str = Form("tropical"),
|
||||||
|
tables: bool = Form(False),
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
|
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
|
||||||
"zodiac": zodiac}
|
"zodiac": zodiac, "tables": tables}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
ctx["result"] = logic.positions(
|
ctx["result"] = logic.positions(
|
||||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
house_system=house_system, stations=stations, zodiac=zodiac,
|
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
|
||||||
)
|
)
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
@@ -228,6 +230,61 @@ def timeline_run(
|
|||||||
return templates.TemplateResponse(request, "timeline.html", ctx)
|
return templates.TemplateResponse(request, "timeline.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
|
||||||
|
@app.post("/horoscope/stream")
|
||||||
|
def horoscope_stream(
|
||||||
|
profile: str = Form("natal"),
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
prompt_budget: str = Form("medium"),
|
||||||
|
llm_provider: str = Form("local"),
|
||||||
|
llm_model: str = Form(""),
|
||||||
|
from_date: str = Form(""),
|
||||||
|
to_date: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
|
||||||
|
|
||||||
|
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
|
||||||
|
przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
try:
|
||||||
|
iso_utc, _ = _build_utc(date, time, tz_offset)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
|
||||||
|
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
|
||||||
|
}
|
||||||
|
if profile == "period" and from_date and to_date:
|
||||||
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
|
|
||||||
|
def relay():
|
||||||
|
try:
|
||||||
|
for raw in logic.horoscope_stream(payload):
|
||||||
|
try:
|
||||||
|
event = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if event.get("type") == "result":
|
||||||
|
html = templates.get_template("_prompt_result.html").render(
|
||||||
|
prompt_result=event.get("result") or {}
|
||||||
|
)
|
||||||
|
event["html"] = html
|
||||||
|
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
yield json.dumps({"type": "error", "message": _logic_error(e)},
|
||||||
|
ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
return StreamingResponse(relay(), media_type="application/x-ndjson",
|
||||||
|
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||||||
@app.get("/geocode")
|
@app.get("/geocode")
|
||||||
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ jakiegokolwiek modelu językowego. Ten moduł zamyka tę drogę.
|
|||||||
|
|
||||||
Dwa mechanizmy:
|
Dwa mechanizmy:
|
||||||
* **HTTP Basic** — wejście do aplikacji; włącza się, gdy ustawiono APP_PASSWORD.
|
* **HTTP Basic** — wejście do aplikacji; włącza się, gdy ustawiono APP_PASSWORD.
|
||||||
* **limit żądań** — hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań).
|
* **limit żądań** — hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań);
|
||||||
|
rozliczany per adres klienta, a za odwrotnym proxy — po TRUST_PROXY=true —
|
||||||
|
per adres z nagłówka, nie per adres proxy (patrz `client_ip`).
|
||||||
|
|
||||||
Świadomie NIE logujemy treści żądań ani promptów — logi to kolejny nośnik wycieku.
|
Świadomie NIE logujemy treści żądań ani promptów — logi to kolejny nośnik wycieku.
|
||||||
|
|
||||||
@@ -46,6 +48,11 @@ def app_password() -> str:
|
|||||||
def rate_limit_per_min() -> int:
|
def rate_limit_per_min() -> int:
|
||||||
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
|
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
|
||||||
|
|
||||||
|
|
||||||
|
def trust_proxy() -> bool:
|
||||||
|
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
PUBLIC_PATHS = frozenset({"/health"})
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
PUBLIC_PREFIXES = ("/static/",)
|
PUBLIC_PREFIXES = ("/static/",)
|
||||||
|
|
||||||
@@ -74,6 +81,31 @@ def _authorized(header: str | None) -> bool:
|
|||||||
return ok_user and ok_pass
|
return ok_user and ok_pass
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
"""Adres, po którym rozliczamy limit żądań.
|
||||||
|
|
||||||
|
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
|
||||||
|
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
|
||||||
|
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
|
||||||
|
wyklika limit, odcięłaby pozostałe.
|
||||||
|
|
||||||
|
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
|
||||||
|
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
|
||||||
|
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
|
||||||
|
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
|
||||||
|
klient, więc nie znaczą nic.
|
||||||
|
"""
|
||||||
|
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:
|
def _rate_limited(client: str) -> bool:
|
||||||
cap = rate_limit_per_min()
|
cap = rate_limit_per_min()
|
||||||
if cap <= 0:
|
if cap <= 0:
|
||||||
@@ -105,8 +137,7 @@ def install(app) -> None:
|
|||||||
if _is_public(request.url.path):
|
if _is_public(request.url.path):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
client = request.client.host if request.client else "?"
|
if _rate_limited(client_ip(request)):
|
||||||
if _rate_limited(client):
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
||||||
status_code=429, headers={"Retry-After": "60"},
|
status_code=429, headers={"Retry-After": "60"},
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Okno postępu przy pisaniu horoskopu.
|
||||||
|
//
|
||||||
|
// Problem: generowanie trwa minutami, a zwykły POST formularza nie daje żadnego
|
||||||
|
// sygnału — aplikacja wygląda na zawieszoną. Zamiast udawanego paska postępu
|
||||||
|
// czytamy strumień RZECZYWISTYCH zdarzeń z serwera (NDJSON) i wypisujemy je
|
||||||
|
// jako log: budowa promptu, limity modelu, każda tura generowania.
|
||||||
|
//
|
||||||
|
// Degradacja: jeśli przeglądarka nie umie strumieniować `fetch`, nie przechwytujemy
|
||||||
|
// wysyłki — formularz idzie klasycznie i wszystko działa jak wcześniej, tylko bez okna.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const canStream = typeof fetch === 'function' && typeof ReadableStream === 'function' &&
|
||||||
|
typeof TextDecoder === 'function';
|
||||||
|
const form = document.querySelector('form[action="/interpret"], form[action="/timeline"]');
|
||||||
|
if (!canStream || !form) return;
|
||||||
|
|
||||||
|
const profile = form.getAttribute('action') === '/timeline' ? 'period' : 'natal';
|
||||||
|
const btn = form.querySelector('button[value="horoscope"]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
// --- okno ---------------------------------------------------------------
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'progress-overlay';
|
||||||
|
overlay.hidden = true;
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="progress-box" role="dialog" aria-modal="true" aria-label="Postęp generowania">' +
|
||||||
|
'<div class="progress-head">' +
|
||||||
|
'<span class="progress-spinner" aria-hidden="true"></span>' +
|
||||||
|
'<strong id="progressTitle">Piszę horoskop…</strong>' +
|
||||||
|
'<span class="progress-clock" id="progressClock">0:00</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<ol class="progress-log" id="progressLog"></ol>' +
|
||||||
|
'<p class="muted small">Nie zamykaj tej karty — generowanie trwa na serwerze.</p>' +
|
||||||
|
'<div class="actions"><button type="button" class="ghost" id="progressClose" hidden>Zamknij</button></div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const logEl = overlay.querySelector('#progressLog');
|
||||||
|
const clockEl = overlay.querySelector('#progressClock');
|
||||||
|
const titleEl = overlay.querySelector('#progressTitle');
|
||||||
|
const closeEl = overlay.querySelector('#progressClose');
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function addLine(text, kind) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
if (kind) li.className = 'log-' + kind;
|
||||||
|
const now = new Date();
|
||||||
|
li.textContent = String(now.getHours()).padStart(2, '0') + ':' +
|
||||||
|
String(now.getMinutes()).padStart(2, '0') + ':' +
|
||||||
|
String(now.getSeconds()).padStart(2, '0') + ' ' + text;
|
||||||
|
logEl.appendChild(li);
|
||||||
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startClock() {
|
||||||
|
const t0 = Date.now();
|
||||||
|
timer = setInterval(function () {
|
||||||
|
const s = Math.floor((Date.now() - t0) / 1000);
|
||||||
|
clockEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(title, allowClose) {
|
||||||
|
if (timer) { clearInterval(timer); timer = null; }
|
||||||
|
titleEl.textContent = title;
|
||||||
|
overlay.querySelector('.progress-spinner').style.visibility = 'hidden';
|
||||||
|
if (allowClose) closeEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEl.addEventListener('click', function () { overlay.hidden = true; });
|
||||||
|
|
||||||
|
// --- przechwycenie wysyłki ----------------------------------------------
|
||||||
|
btn.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!form.reportValidity()) return; // te same reguły co przy zwykłej wysyłce
|
||||||
|
|
||||||
|
const data = new FormData(form);
|
||||||
|
data.set('profile', profile);
|
||||||
|
|
||||||
|
logEl.innerHTML = '';
|
||||||
|
closeEl.hidden = true;
|
||||||
|
overlay.querySelector('.progress-spinner').style.visibility = '';
|
||||||
|
titleEl.textContent = 'Piszę horoskop…';
|
||||||
|
overlay.hidden = false;
|
||||||
|
startClock();
|
||||||
|
addLine('Wysyłam żądanie…');
|
||||||
|
|
||||||
|
fetch('/horoscope/stream', { method: 'POST', body: data })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok || !response.body) throw new Error('HTTP ' + response.status);
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
return reader.read().then(function (chunk) {
|
||||||
|
if (chunk.done) return;
|
||||||
|
buffer += decoder.decode(chunk.value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop(); // ostatni może być niepełny
|
||||||
|
lines.forEach(function (raw) {
|
||||||
|
if (!raw.trim()) return;
|
||||||
|
let ev;
|
||||||
|
try { ev = JSON.parse(raw); } catch (e) { return; }
|
||||||
|
if (ev.type === 'ping') return; // sam heartbeat, nie logujemy
|
||||||
|
if (ev.type === 'result') {
|
||||||
|
addLine(ev.message || 'Gotowe.', 'ok');
|
||||||
|
if (ev.html) {
|
||||||
|
const host = document.getElementById('promptResult');
|
||||||
|
if (host) host.innerHTML = ev.html;
|
||||||
|
}
|
||||||
|
finish('Gotowe', true);
|
||||||
|
overlay.hidden = true;
|
||||||
|
const host = document.getElementById('promptResult');
|
||||||
|
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
|
||||||
|
: ev.type === 'warn' ? 'warn' : null);
|
||||||
|
if (ev.type === 'error') finish('Nie udało się', true);
|
||||||
|
});
|
||||||
|
return pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pump();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
addLine('Połączenie przerwane: ' + e.message, 'err');
|
||||||
|
addLine('Możesz spróbować ponownie — nic nie zostało utracone.');
|
||||||
|
finish('Nie udało się', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,18 @@ h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
|||||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||||
|
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||||
|
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||||
|
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||||
|
2) font-variant-emoji:text — nowsza własność CSS,
|
||||||
|
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||||
|
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||||
|
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||||
|
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||||
|
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||||
|
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||||
|
"DejaVu Sans", sans-serif; }
|
||||||
|
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||||
.row { display: flex; gap: .5rem; }
|
.row { display: flex; gap: .5rem; }
|
||||||
.row input[type=text] { flex: 1; }
|
.row input[type=text] { flex: 1; }
|
||||||
@@ -88,3 +100,31 @@ textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizi
|
|||||||
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
||||||
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* Okno postępu przy generowaniu horoskopu */
|
||||||
|
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
|
||||||
|
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
|
||||||
|
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
|
||||||
|
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
|
||||||
|
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1200; padding: 1rem; }
|
||||||
|
.progress-overlay[hidden] { display: none; }
|
||||||
|
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||||
|
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
||||||
|
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
|
||||||
|
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
|
||||||
|
color: var(--muted); font-size: .9rem; }
|
||||||
|
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
|
||||||
|
border: 2px solid var(--line); border-top-color: var(--accent);
|
||||||
|
animation: spin .8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
||||||
|
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
|
||||||
|
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
max-height: 15rem; overflow-y: auto;
|
||||||
|
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
|
||||||
|
.progress-log li { color: var(--ink); white-space: pre-wrap; }
|
||||||
|
.progress-log li.log-ok { color: #7fd18b; }
|
||||||
|
.progress-log li.log-warn { color: #e0c060; }
|
||||||
|
.progress-log li.log-err { color: #ef6b6b; }
|
||||||
|
|||||||
@@ -36,80 +36,4 @@
|
|||||||
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
|
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if prompt_result %}
|
<div id="promptResult">{% include "_prompt_result.html" %}</div>
|
||||||
{% set st = prompt_result.stats %}
|
|
||||||
|
|
||||||
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
|
|
||||||
{% if prompt_result.horoscope %}
|
|
||||||
<div class="meta">
|
|
||||||
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
|
|
||||||
model: {{ prompt_result.model }}
|
|
||||||
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
|
|
||||||
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
|
|
||||||
{% endif %}
|
|
||||||
{% if prompt_result.leaves_lan %}
|
|
||||||
· <strong class="retro">dane opuściły sieć</strong>
|
|
||||||
{% else %}
|
|
||||||
· dane nie opuściły sieci
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
|
|
||||||
</div>
|
|
||||||
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
|
|
||||||
<p class="muted small">
|
|
||||||
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
|
|
||||||
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if prompt_result.warnings %}
|
|
||||||
{% for w in prompt_result.warnings %}
|
|
||||||
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if prompt_result.token_plan %}
|
|
||||||
{% set tp = prompt_result.token_plan %}
|
|
||||||
<p class="muted small">
|
|
||||||
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
|
|
||||||
zarezerwowane na odpowiedź {{ tp.max_output }}
|
|
||||||
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
|
|
||||||
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if prompt_result.llm_error %}
|
|
||||||
<div class="error">
|
|
||||||
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
|
||||||
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
|
|
||||||
<div class="meta">
|
|
||||||
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
|
|
||||||
budżet: {{ st.budget }} ·
|
|
||||||
wskazań: <strong>{{ st.included }}</strong>
|
|
||||||
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
|
|
||||||
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if st.omitted %}
|
|
||||||
<p class="muted small">
|
|
||||||
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
|
|
||||||
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if prompt_result.data_error %}
|
|
||||||
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
|
|
||||||
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
|
|
||||||
</div>
|
|
||||||
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
|
|
||||||
{% endif %}
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{# Blok WYNIKU generowania (prompt/horoskop). Wydzielony, bo wstawia go także
|
||||||
|
okno postępu po zakończeniu strumienia — dzięki temu jest jedno źródło
|
||||||
|
prawdy dla wyglądu wyniku, niezależnie od drogi, którą przyszedł. #}
|
||||||
|
{% if prompt_result %}
|
||||||
|
{% set st = prompt_result.stats %}
|
||||||
|
|
||||||
|
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
|
||||||
|
{% if prompt_result.horoscope %}
|
||||||
|
<div class="meta">
|
||||||
|
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
|
||||||
|
model: {{ prompt_result.model }}
|
||||||
|
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
|
||||||
|
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
|
||||||
|
{% endif %}
|
||||||
|
{% if prompt_result.leaves_lan %}
|
||||||
|
· <strong class="retro">dane opuściły sieć</strong>
|
||||||
|
{% else %}
|
||||||
|
· dane nie opuściły sieci
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
|
||||||
|
<p class="muted small">
|
||||||
|
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
|
||||||
|
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.warnings %}
|
||||||
|
{% for w in prompt_result.warnings %}
|
||||||
|
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.token_plan %}
|
||||||
|
{% set tp = prompt_result.token_plan %}
|
||||||
|
<p class="muted small">
|
||||||
|
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
|
||||||
|
zarezerwowane na odpowiedź {{ tp.max_output }}
|
||||||
|
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
|
||||||
|
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.llm_error %}
|
||||||
|
<div class="error">
|
||||||
|
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
||||||
|
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
|
||||||
|
<div class="meta">
|
||||||
|
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
|
||||||
|
budżet: {{ st.budget }} ·
|
||||||
|
wskazań: <strong>{{ st.included }}</strong>
|
||||||
|
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
|
||||||
|
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if st.omitted %}
|
||||||
|
<p class="muted small">
|
||||||
|
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
|
||||||
|
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.data_error %}
|
||||||
|
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
|
||||||
|
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
|
||||||
|
{% endif %}
|
||||||
@@ -47,6 +47,8 @@
|
|||||||
<div class="opts">
|
<div class="opts">
|
||||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||||
licz stacje planet (wolniejsze)</label>
|
licz stacje planet (wolniejsze)</label>
|
||||||
|
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
|
||||||
|
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
|
||||||
</div>
|
</div>
|
||||||
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -84,18 +86,22 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
|
||||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||||
|
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for p in result.positions %}
|
{% for p in result.positions %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
|
||||||
<td>{{ p.name }}</td>
|
<td>{{ p.name }}</td>
|
||||||
<td>{{ p.sign }}</td>
|
<td>{{ p.sign }}</td>
|
||||||
<td class="mono">{{ p.in_sign }}</td>
|
<td class="mono">{{ p.in_sign }}</td>
|
||||||
<td>{{ p.house if p.house is defined else '—' }}</td>
|
<td>{{ p.house if p.house is defined else '—' }}</td>
|
||||||
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||||
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
||||||
|
<td class="mono">{% if p.declination is defined %}{{ '%+.2f°' | format(p.declination) }}{% if p.out_of_bounds %} <span class="badge" title="Out of bounds — deklinacja poza zakresem Słońca">OOB</span>{% endif %}{% else %}—{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -136,12 +142,129 @@
|
|||||||
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for a in result.aspects %}
|
{% for a in result.aspects %}
|
||||||
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja #}
|
||||||
|
{% if result.parallels %}
|
||||||
|
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th><th>Dekl.</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in result.parallels %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.obj1 }}</td>
|
||||||
|
<td>{{ 'paralela' if r.type == 'parallel' else 'kontrparalela' }}</td>
|
||||||
|
<td>{{ r.obj2 }}</td>
|
||||||
|
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
|
||||||
|
<td class="mono">{{ '%+.2f'|format(r.dec1) }} / {{ '%+.2f'|format(r.dec2) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.antiscia %}
|
||||||
|
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in result.antiscia %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.obj1 }}</td>
|
||||||
|
<td>{{ 'antyscja' if r.type == 'antiscion' else 'kontrantyscja' }}</td>
|
||||||
|
<td>{{ r.obj2 }}</td>
|
||||||
|
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.tables %}
|
||||||
|
{% set tb = result.tables %}
|
||||||
|
<div class="meta">Tabele dodatkowe (LOG-23)</div>
|
||||||
|
|
||||||
|
{% set bal = tb.tally.with_modern_10_plus_asc %}
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Bilans (10 planet + Asc)</th><th colspan="4">Rozkład</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Żywioły</td>
|
||||||
|
{% for e, n in bal.elements.items() %}
|
||||||
|
<td>{{ tb.tally.labels.elements[e] }}: <strong>{{ n }}</strong></td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr><td>Jakości</td>
|
||||||
|
{% for q, n in bal.qualities.items() %}
|
||||||
|
<td>{{ tb.tally.labels.qualities[q] }}: <strong>{{ n }}</strong></td>
|
||||||
|
{% endfor %}
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if tb.tally.missing_elements or tb.tally.missing_qualities %}
|
||||||
|
<p class="muted small">Brak:
|
||||||
|
{% for e in tb.tally.missing_elements %}<strong>{{ tb.tally.labels.elements[e] }}</strong>{{ ", " if not loop.last }}{% endfor %}
|
||||||
|
{% for q in tb.tally.missing_qualities %}<strong>{{ tb.tally.labels.qualities[q] }}</strong>{{ ", " if not loop.last }}{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.moon_phase %}
|
||||||
|
<p class="muted small">
|
||||||
|
<strong>Faza Księżyca:</strong> {{ tb.moon_phase.phase_pl }} ·
|
||||||
|
elongacja {{ '%.2f'|format(tb.moon_phase.angle) }}° ·
|
||||||
|
oświetlenie {{ '%.1f'|format(tb.moon_phase.illumination * 100) }}% ·
|
||||||
|
{{ 'przybywa' if tb.moon_phase.waxing else 'ubywa' }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.planetary_hours %}
|
||||||
|
{% set ph = tb.planetary_hours %}
|
||||||
|
<p class="muted small">
|
||||||
|
<strong>Godziny planetarne:</strong> władca dnia {{ ph.day_ruler }} ·
|
||||||
|
{{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
|
||||||
|
godzina trwa {{ ph.hour_length_minutes }} min
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.prenatal_syzygy %}
|
||||||
|
{% set s = tb.prenatal_syzygy %}
|
||||||
|
<p class="muted small">
|
||||||
|
<strong>Syzygia prenatalna:</strong> {{ s.type_pl }} {{ s.when_utc }}
|
||||||
|
({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.critical_degrees %}
|
||||||
|
<div class="meta">Stopnie krytyczne</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt</th><th>Pozycja</th><th>Uwaga</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in tb.critical_degrees %}
|
||||||
|
<tr><td>{{ c.name }}</td><td class="mono">{{ c.in_sign }}</td>
|
||||||
|
<td class="muted small">{{ c.flags | join('; ') }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<details class="loc">
|
||||||
|
<summary>Podziały: dwunastniki (D12) i nawamsa (D9)</summary>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Obiekt</th><th>D12</th><th>D9</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in tb.divisional %}
|
||||||
|
<tr><td>{{ d.name }}</td><td class="mono">{{ d.d12_in_sign }}</td>
|
||||||
|
<td class="mono">{{ d.d9_in_sign }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if result.cusps %}
|
{% if result.cusps %}
|
||||||
<details class="loc">
|
<details class="loc">
|
||||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||||
|
|||||||
@@ -91,4 +91,5 @@
|
|||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
<script src="/static/copy.js"></script>
|
<script src="/static/copy.js"></script>
|
||||||
<script src="/static/models.js"></script>
|
<script src="/static/models.js"></script>
|
||||||
|
<script src="/static/progress.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -81,4 +81,5 @@
|
|||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
<script src="/static/copy.js"></script>
|
<script src="/static/copy.js"></script>
|
||||||
<script src="/static/models.js"></script>
|
<script src="/static/models.js"></script>
|
||||||
|
<script src="/static/progress.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ uvicorn[standard]>=0.34
|
|||||||
httpx>=0.28
|
httpx>=0.28
|
||||||
jinja2>=3.1
|
jinja2>=3.1
|
||||||
python-multipart>=0.0.20
|
python-multipart>=0.0.20
|
||||||
|
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||||
|
cryptography>=44.0
|
||||||
|
|||||||
@@ -1,30 +1,47 @@
|
|||||||
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32).
|
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32)
|
||||||
|
oraz klucz szyfrujący łącze (PRE-16).
|
||||||
|
|
||||||
Powód istnienia tego testu: token dodano do klienta na gałęzi, która odbiła się od
|
Powód istnienia tego testu: token dodano do klienta na gałęzi, która odbiła się od
|
||||||
mastera zanim powstały metody `prompt()` i `horoscope()`. Git zmergował obie zmiany
|
mastera zanim powstały metody `prompt()` i `horoscope()`. Git zmergował obie zmiany
|
||||||
czysto (różne linie), ale nowe metody wyszły BEZ tokenu — i dostawały 401 dopiero na
|
czysto (różne linie), ale nowe metody wyszły BEZ tokenu — i dostawały 401 dopiero na
|
||||||
produkcji. Zwykły test jednej metody by tego nie złapał, więc sprawdzamy regułę
|
produkcji. Zwykły test jednej metody by tego nie złapał, więc sprawdzamy regułę
|
||||||
strukturalnie: nie ma wywołania bez `headers=`.
|
strukturalnie.
|
||||||
|
|
||||||
|
Po dołożeniu szyfrowania ta sama klasa błędu ma gorszy objaw: wywołanie bez `link=`
|
||||||
|
nie wywala się widocznie, tylko po cichu wysyła treść JAWNYM tekstem. Dlatego dla
|
||||||
|
wywołań przez `link_crypto` wymagamy obu argumentów naraz.
|
||||||
"""
|
"""
|
||||||
import ast
|
import ast
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "app" / "clients" / "logic_client.py"
|
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "app" / "clients" / "logic_client.py"
|
||||||
|
|
||||||
|
HTTP_VERBS = ("post", "get", "put", "patch", "delete")
|
||||||
|
# Wszystkie drogi w dół przez link_crypto — także strumień postępu (stream_lines),
|
||||||
|
# który dołożono po scaleniu okna postępu z szyfrowaniem. Każda musi nieść i token,
|
||||||
|
# i klucz łącza.
|
||||||
|
LINK_CALLS = ("call", "call_json", "stream_lines")
|
||||||
|
|
||||||
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool]]:
|
|
||||||
"""(nazwa_metody_http, linia, czy_ma_headers) dla każdego client.post/get."""
|
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool, bool]]:
|
||||||
|
"""(opis, linia, czy_ma_headers, czy_wymaga_i_ma_link) dla każdego wyjścia w dół."""
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
out = []
|
out = []
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
||||||
continue
|
continue
|
||||||
if node.func.attr not in ("post", "get", "put", "patch", "delete"):
|
target = node.func.value
|
||||||
continue
|
|
||||||
if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "client"):
|
|
||||||
continue
|
|
||||||
has_headers = any(kw.arg == "headers" for kw in node.keywords)
|
has_headers = any(kw.arg == "headers" for kw in node.keywords)
|
||||||
out.append((node.func.attr, node.lineno, has_headers))
|
has_link = any(kw.arg == "link" for kw in node.keywords)
|
||||||
|
|
||||||
|
if (node.func.attr in HTTP_VERBS
|
||||||
|
and isinstance(target, ast.Name) and target.id == "client"):
|
||||||
|
# Surowe wywołanie httpx — omija szyfrowanie, więc dopuszczalne tylko
|
||||||
|
# dla ścieżek wyjętych spod niego (patrz link_crypto.PUBLIC_PATHS).
|
||||||
|
out.append((f"client.{node.func.attr}()", node.lineno, has_headers, True))
|
||||||
|
elif (node.func.attr in LINK_CALLS
|
||||||
|
and isinstance(target, ast.Name) and target.id == "link_crypto"):
|
||||||
|
out.append((f"link_crypto.{node.func.attr}()", node.lineno, has_headers, has_link))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -35,13 +52,43 @@ def test_client_module_exists():
|
|||||||
def test_every_outbound_call_sends_auth_header():
|
def test_every_outbound_call_sends_auth_header():
|
||||||
calls = _http_calls(CLIENT)
|
calls = _http_calls(CLIENT)
|
||||||
assert calls, "nie znaleziono żadnego wywołania HTTP — test przestał cokolwiek pilnować"
|
assert calls, "nie znaleziono żadnego wywołania HTTP — test przestał cokolwiek pilnować"
|
||||||
missing = [f"{CLIENT.name}:{line} client.{verb}()" for verb, line, ok in calls if not ok]
|
missing = [f"{CLIENT.name}:{line} {what}" for what, line, ok, _ in calls if not ok]
|
||||||
assert not missing, (
|
assert not missing, (
|
||||||
"Wywołania w dół bez tokenu międzywarstwowego (dostaną 401 przy włączonej "
|
"Wywołania w dół bez tokenu międzywarstwowego (dostaną 401 przy włączonej "
|
||||||
"ochronie): " + ", ".join(missing)
|
"ochronie): " + ", ".join(missing)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_outbound_call_passes_link_key():
|
||||||
|
"""Brak `link=` nie boli od razu — po prostu treść leci jawnym tekstem."""
|
||||||
|
calls = _http_calls(CLIENT)
|
||||||
|
missing = [f"{CLIENT.name}:{line} {what}" for what, line, _, ok in calls if not ok]
|
||||||
|
assert not missing, (
|
||||||
|
"Wywołania w dół bez klucza łącza — poszłyby NIEZASZYFROWANE: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_http_calls_only_on_paths_exempt_from_encryption():
|
||||||
|
"""Surowe `client.get/post` wolno wołać wyłącznie tam, gdzie szyfrowania nie ma
|
||||||
|
z założenia (`/health` dla sond k8s). Każde inne to obejście PRE-16."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
|
|
||||||
|
source = CLIENT.read_text(encoding="utf-8").splitlines()
|
||||||
|
offenders = []
|
||||||
|
for what, line, _, _ in _http_calls(CLIENT):
|
||||||
|
if not what.startswith("client."):
|
||||||
|
continue
|
||||||
|
url = re.search(r'f"\{self\.base_url\}([^"]*)"', source[line - 1])
|
||||||
|
if url is None or url.group(1) not in link_crypto.PUBLIC_PATHS:
|
||||||
|
offenders.append(f"{CLIENT.name}:{line} {what}")
|
||||||
|
assert not offenders, (
|
||||||
|
"Surowe wywołania HTTP poza ścieżkami wyjętymi spod szyfrowania: "
|
||||||
|
+ ", ".join(offenders)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_auth_headers_helper_is_lazy():
|
def test_auth_headers_helper_is_lazy():
|
||||||
"""Token czytany przy wywołaniu, nie przy imporcie — inaczej pod wystartowałby
|
"""Token czytany przy wywołaniu, nie przy imporcie — inaczej pod wystartowałby
|
||||||
z pustym tokenem, gdyby zmienna pojawiła się później."""
|
z pustym tokenem, gdyby zmienna pojawiła się później."""
|
||||||
|
|||||||
@@ -131,3 +131,71 @@ def test_rate_limit_disabled_when_zero(monkeypatch):
|
|||||||
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||||
client = TestClient(_app())
|
client = TestClient(_app())
|
||||||
assert all(client.get("/significators").status_code == 200 for _ in range(30))
|
assert all(client.get("/significators").status_code == 200 for _ in range(30))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------ adres klienta za odwrotnym proxy
|
||||||
|
#
|
||||||
|
# Po włączeniu TLS (PRE-16) aplikacja stoi za Ingressem, więc bezpośredni peer
|
||||||
|
# to zawsze POD proxy. Te testy pilnują obu stron kompromisu: żeby limit dalej
|
||||||
|
# rozróżniał ludzi, a jednocześnie żeby nagłówek nie stał się furtką do jego
|
||||||
|
# ominięcia.
|
||||||
|
|
||||||
|
class _Req:
|
||||||
|
"""Minimalny zamiennik Request — `client_ip` czyta tylko te dwa pola."""
|
||||||
|
|
||||||
|
def __init__(self, peer: str | None, **headers: str):
|
||||||
|
self.client = type("C", (), {"host": peer})() if peer else None
|
||||||
|
self.headers = {k.replace("_", "-"): v for k, v in headers.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_ignores_headers_without_trust_proxy(monkeypatch):
|
||||||
|
"""Bez TRUST_PROXY nagłówek jest bezwartościowy — każdy może go dopisać."""
|
||||||
|
monkeypatch.delenv("TRUST_PROXY", raising=False)
|
||||||
|
req = _Req("10.42.0.7", x_forwarded_for="1.2.3.4", x_real_ip="5.6.7.8")
|
||||||
|
assert security.client_ip(req) == "10.42.0.7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_takes_last_forwarded_entry(monkeypatch):
|
||||||
|
"""Ostatni wpis dopisał NASZ proxy; wcześniejsze mógł podstawić klient."""
|
||||||
|
monkeypatch.setenv("TRUST_PROXY", "true")
|
||||||
|
req = _Req("10.42.0.7", x_forwarded_for="1.1.1.1, 2.2.2.2, 192.168.1.50")
|
||||||
|
assert security.client_ip(req) == "192.168.1.50"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_falls_back_to_real_ip(monkeypatch):
|
||||||
|
monkeypatch.setenv("TRUST_PROXY", "true")
|
||||||
|
req = _Req("10.42.0.7", x_real_ip="192.168.1.50")
|
||||||
|
assert security.client_ip(req) == "192.168.1.50"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_falls_back_to_peer_when_headers_missing(monkeypatch):
|
||||||
|
monkeypatch.setenv("TRUST_PROXY", "true")
|
||||||
|
assert security.client_ip(_Req("10.42.0.7")) == "10.42.0.7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spoofed_forwarded_header_cannot_dodge_the_limit(monkeypatch):
|
||||||
|
"""Sedno sprawy: bez zaufania do proxy podszywanie się NIE resetuje wiadra.
|
||||||
|
|
||||||
|
Gdyby limiter brał pierwszy lepszy `X-Forwarded-For`, wystarczyłoby zmieniać
|
||||||
|
go co żądanie, żeby pompować bazę bez ograniczeń.
|
||||||
|
"""
|
||||||
|
monkeypatch.delenv("TRUST_PROXY", raising=False)
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
|
||||||
|
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||||
|
client = TestClient(_app())
|
||||||
|
codes = [client.get("/significators", headers={"X-Forwarded-For": f"9.9.9.{i}"}).status_code
|
||||||
|
for i in range(6)]
|
||||||
|
assert 429 in codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxied_clients_get_separate_buckets(monkeypatch):
|
||||||
|
"""Za proxy dwie różne osoby nie mogą się nawzajem odcinać."""
|
||||||
|
monkeypatch.setenv("TRUST_PROXY", "true")
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
|
||||||
|
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||||
|
client = TestClient(_app())
|
||||||
|
first = [client.get("/significators", headers={"X-Forwarded-For": "192.168.1.50"}).status_code
|
||||||
|
for _ in range(5)]
|
||||||
|
second = client.get("/significators", headers={"X-Forwarded-For": "192.168.1.51"})
|
||||||
|
assert 429 in first, "limit musi zadziałać dla pierwszego adresu"
|
||||||
|
assert second.status_code == 200, "drugi adres ma własne wiadro"
|
||||||
|
|||||||
Reference in New Issue
Block a user