PRODSuwerenna europejska platforma BaaSOtwórz Panel →

Inżynieria · 10 min odczytu

Projektowanie dwupłaszczyznowej bramy API w Rust

Affane Daylami · Fondateur · 6 sierpnia 2026

Powrót do bloga

Brama obsługująca zarówno ruch SDK użytkowników, jak i ruch administracyjny zaplecza słabo chroni oba jednocześnie. Brama Aurabase (aura-gateway, Rust/axum) rozwiązuje to napięcie na górze strony: dwa odrębne routery, dwa porty, dwa modele uwierzytelniania, pojedynczy stan współdzielony. W tym przewodniku opisano ten wzorzec płaszczyzny danych/płaszczyzny zarządzania tak, jak faktycznie istnieje w kodzie — trasy, kolejność oprogramowania pośredniczącego, ograniczenie szybkości, wyłącznik automatyczny i serwer proxy — a nie wyidealizowana wersja diagramu architektonicznego.

Ten tekst w języku angielskim został wygenerowany automatycznie na podstawie francuskiego oryginału i nie był jeszcze recenzowany.
Ta strona została przetłumaczona automatycznie. Wersja angielska jest miarodajna.

Najważniejsze

Dwie osie Router na dwóch portach (płaszczyzna danych8080, płaszczyzna zarządzania 8090), zbudowane z tego samego współdzielonego AppState. Płaszczyzna danych wymaga klucza API na dowolnej trasie; Zarządzanie płaszczyzną wymaga dedykowanej konsoli odbiorców JWT — te dwa mechanizmy nigdy się nie pokrywają. Ograniczenie szybkości ma zastosowanie dwukrotnie: według adresu IP przed uwierzytelnieniem, a następnie według uwierzytelnionego aktora po. Wyłącznik automatyczny nie jest globalnym oprogramowaniem pośredniczącym: jest to obiekt na usługę (i na dedykowany cel dla PostgREST), wywoływany bezpośrednio w kodzie proxy. Ostateczny serwer proxy zmienia transport w zależności od trasy — czasami zgodnie z metodą HTTP lub przeszukiwaniem bazy danych: żądanie/odpowiedź NATS dla większości ruchu, bezpośredni przepływ HTTP do przechowywania, trzy warianty czasu rzeczywistego i dedykowany Postgres CRUD.

#
Problem

Jedna brama, dwie bardzo różne grupy odbiorców

Ruch w płaszczyźnie danych pochodzi z pakietu SDK lub aplikacji klienckiej: liczba anonimowych żądań lub żądań uwierzytelnianych za pomocą klucza API, z profilem nadużycia zbliżonym do dowolnego publicznego interfejsu API. Płaszczyzna zarządzania ruchem pochodzi ze Studia – interfejsu administracyjnego projektu – i wykonuje wrażliwe operacje: tworzenie projektu, rotację kluczy, odczyt logów najemcy. Obydwa mają wspólny cel (połączenie pośrednie z tymi samymi usługami wewnętrznymi: aura-auth, aura-db, aura-storage itp.), ale nie mają tej samej powierzchni ryzyka.

Przepuszczenie obu przez ten sam router wymaga wyboru pomiędzy dwiema złymi opcjami: albo Studio CORS dziedziczy symbol wieloznaczny niezbędny dla publicznego zestawu SDK (Access-Control-Allow-Origin: *), albo zestaw SDK dziedziczy ograniczoną listę źródeł zaprojektowaną dla wewnętrznego pulpitu nawigacyjnego. Kod bramy aury rozdziela to napięcie od main.rs: dwa oddzielne Router, każdy z własnym CorsLayer — symbol wieloznaczny autoryzowany po stronie płaszczyzny danych, odrzucony i rejestrowany jako błąd po stronie płaszczyzny zarządzania.

#
Krok 1

Dwa routery axum, jeden współdzielony stan aplikacji

Separacja nie jest oddzielnym wdrożeniem: oba plany działają w tym samym procesie, na tym samym AppState (pule Postgres, klient NATS, pamięci podręczne Moka, wyłączniki automatyczne). Różni się tylko konstrukcja Router, poprzez dwie dedykowane funkcje, z których każda jest wywoływana raz przy uruchomieniu i obsługiwana przez dwie oddzielne TcpListener.

gateway/server.rsrust
// Dwa porty, dwa routery, jeden stan aplikacji

let data_addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let mgmt_addr: SocketAddr = format!("{}:{}", config.host, config.management_port).parse()?;

let data_listener = TcpListener::bind(data_addr).await?;
let mgmt_listener = TcpListener::bind(mgmt_addr).await?;

// Ten sam stan, dwa różne wykresy tras
let data_app = build_data_plane_router(state.clone(), data_cors);
let mgmt_app = build_management_plane_router(state);

tokio::join!(
    axum::serve(data_listener, data_app),
    axum::serve(mgmt_listener, mgmt_app),
);

Dwa wykresy tras zaczynają się od tej samej bazy, service_routes(): te same procedury obsługi proxy (db_proxy, storage_proxy, functions_proxy…) są zamontowane na obu planach, z dodatkowymi trasami specyficznymi dla każdego. Ponowne użycie tych samych procedur obsługi pozwala uniknąć podwójnej implementacji proxy; rozbieżność tylko w zakresie oprogramowania pośredniego pozwala uniknąć powielania logiki biznesowej w celu uzyskania granicy bezpieczeństwa. Jeśli Twój backend sam w sobie jest zorganizowany jako wielousługowy obszar roboczy Cargo, zapoznaj się z naszym Przewodnik po architekturze obszaru roboczego Cargo — brama jest tylko jedną ze skrzyni w tym dziale.

#
Krok 2

Uwierzytelnianie różni się po wejściu

Na płaszczyźnie danych klucz API jest obowiązkowy na każdej trasie, z wyjątkiem kilku naprawdę publicznych ścieżek (/health, JWKS, punkty końcowe rejestracji). Podróżuje jako nagłówek apikey lub X-API-Key — lub, tylko w przypadku tras przesyłania strumieniowego WebSocket i SSE, jako parametr ?apikey=. Kod wyraźnie zabrania tego ostatniego trybu dla klucza service_role: klucz URL wycieka w dziennikach dostępu, śladach Otel i nagłówku Referer. JWT pozostaje opcjonalny po stronie płaszczyzny danych: bez niego obiekt wywołujący pozostaje anon; wraz z nim staje się authenticated.

Na płaszczyźnie zarządzania klucz API nie istnieje: akceptowana jest tylko konsola JWT, której odbiorcami musi być dokładnie aurabase-control. Rola nie jest przenoszona przez sam token – jest ona przeliczana przy każdym żądaniu członkostwa użytkownika w organizacji będącej właścicielem projektu, dziedziczonej poprzez relację projekt → organizacja.

Wymagany tokenKlucz API (apikey / X-API-Key), zawszeKonsola JWT (Autoryzacja: Bearer), zawsze
Podniesienie roliOpcjonalny JWT: anon → uwierzytelnionyKontrola RBAC odziedziczona po organizacji (właściciel/administrator/programista/przeglądający)
Wprowadź ciąg zapytaniaTolerowane tylko w WS/SSE, nigdy w przypadku roli_usługiNie dotyczy
Oczekiwana publicznośćDocelowy projekt (UUID ścieżki)naprawiono „kontrolę aurabase”
CORSSymbol wieloznaczny * dozwolonyOdrzucono symbol wieloznaczny, tylko źródła Studio
#
Krok 3

Prawdziwy porządek oprogramowania pośredniczącego (i dlaczego jest to ważne)

axum układa oprogramowanie pośrednie z kolejnymi wywołaniami .layer() — a zasada rządząca kolejnością wykonywania jest w praktyce zaskakująca: OSTATNI .layer() umieszczony staje się warstwą najbardziej ZEWNĘTRZNĄ, a zatem pierwszą, przez którą przechodzi przychodzące żądanie i ostatnią, która widzi odpowiedź. Liniowy odczyt pliku daje zatem odwrotną kolejność rzeczywistej kolejności wykonania.

gateway/router.rsrust
// Zapisano w następujący sposób (aktualny wyciąg, kolejność plików):

service_routes(...).merge(data_plane_extra)
    .layer(metrics_auth_middleware)      // (1) umieszczone 1. → najbardziej wewnętrzne
    .layer(rate_limit_actor_middleware)  // (2)
    .layer(data_plane_auth_middleware)   // (3)
    .layer(rate_limit_middleware)        // (4)
    .layer(request_id_middleware)        // (5)
    .layer(AccessLogLayer)               // (6)
    .layer(prometheus_layer)             // (7)
    .layer(TraceLayer)                   // (8)
    .layer(RequestBodyLimitLayer)        // (9)
    .layer(security_headers_middleware)  // (10)
    .layer(cors)                         // (11) umieszczony jako ostatni → najbardziej oddalony

// Przychodzące żądanie przechodzi zatem przez (11) → (1), nigdy (1) → (11).
Konkretny efekt tego porządku

Oprogramowanie pośredniczące request_id ustawia nagłówek X-Request-Id tylko w odpowiedzi ODPOWIEDŹ, nigdy w żądaniu przychodzącym. Ponieważ AccessLogLayer jest umieszczane po nim w pliku - a zatem jest bardziej zewnętrzne, dlatego przeszło wcześniej - przechwycenie pola request_id powoduje odczytanie nagłówka w postaci wysłanej przez klienta, a nie identyfikatora wygenerowanego w dalszej części łańcucha. Jeśli wywołujący nie podał żadnego X-Request-Id, wiersz dziennika dostępu pozostawia puste pole, a zwrócona odpowiedź zawiera świeżo wygenerowany identyfikator UUID. Nie jest to ukryta wada — przypomnienie, że kolejność zapisu łańcucha .layer() nie gwarantuje niczego w zakresie porządku logicznego, który mu przypisujemy.

#
Krok 4

Ograniczanie szybkości: najpierw IP, potem aktor

Ograniczanie szybkości jest stosowane w dwóch odrębnych przejściach, w dwóch różnych momentach łańcucha. Pierwszy działa PRZED uwierzytelnieniem i ograniczeniami według adresu IP — ogólny filtr przeciwpowodziowy, aktywny nawet na drogach publicznych: bez niego nieuwierzytelniony przepływ może wpłynąć na kosztowny punkt końcowy, taki jak agregacja dzienników, bez wyzwalania kontroli JWT. Drugi działa PO uwierzytelnieniu i ograniczeniach według aktora — klucza API lub użytkownika — przy użyciu oświadczeń, które właśnie wstrzyknięto podczas uwierzytelniania: jest to rzeczywisty limit produktu, który liczy się w przypadku rozliczeń i planów.

Implementacja opiera się na skrzynce governor (zasobniku tokenów) do obliczeń lokalnych, ze skryptem Lua Redis z przesuwanym oknem do dystrybucji między instancjami bramy oraz lokalną rezerwą (pamięć podręczna Moka), jeśli Redis jest niedostępny. Domyślne ustawienia repozytorium: 100 żądań/sekundę, seria 1000.

#
Krok 5

Wyłącznik nie jest warstwą, jest obiektem przypadającym na cel

W przeciwieństwie do reszty łańcucha, wyłącznik nie pojawia się w ŻADNYM .layer(). AppState przenosi jedną instancję CircuitBreaker na usługę (auth, db, czas rzeczywisty, pamięć masowa, funkcje, powiadomienia, sztuczna inteligencja, dostawca, kontrola) i to sam kod proxy — a nie router — wywołuje try_acquire_probe() przed próbą żądania, a następnie record_success() lub record_failure() w zależności od wyniku.

Przypadek PostgREST jest odrębny: projekty w dedykowanej topologii (specyficzne dla projektu Postgres i PostgREST) ​​nie mają wspólnej domeny błędów — każdy proces PostgREST jest swoim własnym celem. Dlatego brama utrzymuje tabelę wyłączników indeksowanych według rozpoznanego celu, zapełnianą na bieżąco i czyszczoną co 60 sekund przez przeszukiwanie, które usuwa nieaktywne wpisy: bez tego czyszczenia każdy nowy dedykowany projekt dodałby wpis, który nigdy nie znika.

gateway/circuit_breaker.rsrust
let Some(probe) = circuit_breaker.try_acquire_probe() else {
    return Err(ServiceUnavailable);
};

// …próby zapytania NATS z ograniczoną liczbą ponownych prób…

match resultat {
    Ok(Ok(_))     => match probe.take() { Some(p) => p.record_success(), _ => {} },
    Ok(Err(_))    => match probe.take() { Some(p) => p.record_failure(), _ => {} },
    Err(_timeout) => {} // sonda nie została zużyta → zwrócona przez Drop
}

Token sondy jest zwracany jako Drop, jeśli nigdy nie został jawnie zużyty — przydatne, gdy wszystkie próby żądania przekroczą limit czasu bez dotarcia do gałęzi, która by go zwolniła. Automatyczne powtarzanie jest uruchamiane tylko w przypadku ścisłego dowodu niedostarczenia po stronie NATS (NoResponders): zwykły limit czasu bramy nie dowodzi niczego na temat rzeczywistego dostarczenia żądania, a ponowne odtworzenie może spowodować jego dwukrotne wykonanie.

Ostateczny serwer proxy nie obsługuje żadnego protokołu wstecz, a wybór nie jest ustalony w zależności od trasy: może zależeć od metody HTTP lub nawet od wyszukiwania w bazie danych. W przypadku większości ruchu (uwierzytelnianie, funkcje, powiadomienia, kontrola i większość baz danych) brama serializuje żądanie HTTP w kopercie NATS i wysyła je jako żądanie/odpowiedź do podmiotu dedykowanego usłudze — podróż w obie strony bez uzgadniania protokołu TCP, co jest udokumentowane w kodzie jako znacznie szybsze niż klasyczny serwer proxy HTTP dla tego typu ruchu RPC.

Pamięć masowa, trzy warianty czasu rzeczywistego (WebSocket, SSE i REST dla transmisji/kanałów/obecności) i — warunkowo — żądania Postgres CRUD wychodzą z tej ścieżki i przechodzą przez działającego, połączonego klienta HTTP. Pamięć masowa wyraźnie dokonała takiego wyboru: kodowanie treści binarnej w kopercie NATS wymaga jej serializacji, załadowania jej w całości do pamięci po obu stronach i nie przekraczania limitu rozmiaru wiadomości NATS — co jest rzeczywistym kosztem w przypadku dużych obiektów. WebSocket i SSE po prostu nie tolerują semantyki żądanie/odpowiedź: aktualizacja protokołu i przepływ, który pozostaje otwarty, nie mają odpowiednika NATS.

Najciekawszym przypadkiem jest /v1/db/*, którego procedura obsługi decyduje sama o każdym żądaniu: trasy zarządzania (schemat, zasady, surowy SQL) zawsze idą w NATS do aura-db, PUT zawsze trafiają do NATS (PostgREST zwraca 405 w przypadku całkowitej zamiany), projekt MongoDB zawsze trafia do NATS — a tylko CRUD w projekcie Postgres z rozwiązaną dedykowaną instancją PostgREST trafia do bezpośredniego HTTP. Jeśli ta dedykowana instancja nie zostanie rozwiązana, brama odpowie 503, zamiast wracać do współdzielonego PostgREST: zakłada się zamknięcie awaryjne, a nie pogorszoną cichą rezerwę. Nagłówki zabezpieczeń (ścisłe CSP, bez poświadczeń CORS) mają zastosowanie jednakowo do wszystkich tych ścieżek, umieszczonych na samym końcu łańcucha, zanim odpowiedź opuści bramę.

#
Krok 7

Budżet limitu czasu na trasę, a nie globalny limit czasu

Brama stosuje TimeoutLayer NA GRUPĘ tras zamiast globalnego limitu czasu — wybór powiązany z tą samą mechaniką układania stosów, co kolejność oprogramowania pośredniego. Trasa funkcji Edge wymaga znacznie większego budżetu niż pozostałe (funkcja może legalnie działać przez kilka minut): domyślna wartość repozytorium to 30 sekund dla większości tras, w porównaniu do 380 sekund dla /v1/functions/*.

Ułożenie jednego globalnego TimeoutLayer na wszystkim spowodowałoby obcięcie obu grup do tego samego limitu: wygrywa zawsze najkrótszy limit czasu umieszczony na najbardziej zewnętrznej pozycji, niezależnie od dłuższego limitu czasu umieszczonego dalej w środku. Jedynym sposobem na przyznanie funkcjom oddzielnego budżetu jest zatem to, że NIGDY nie wchodzą one we wspólne opakowanie: każda gałąź tras ma swój własny TimeoutLayer, umieszczony przed połączeniem dwóch routerów — i nie jest później stosowany żaden globalny limit czasu.

#
Aby pamiętać

Odtwórz ten wzór w innym miejscu: na liście kontrolnej

  1. Oddziel według PLANU (powierzchni ekspozycji), a nie według usługi: zhakowany publiczny zestaw SDK nigdy nie powinien dotrzeć do listy źródeł CORS w panelu administracyjnym.
  2. Zachowaj pojedynczy stan współdzielony zamiast dwóch oddzielnych wdrożeń — powielanie logiki biznesowej kosztuje więcej niż duplikowanie routera.
  3. Sprawdź RZECZYWISTĄ kolejność oprogramowania pośredniego, śledząc ją od ostatniego .layer(), nigdy na podstawie liniowego odczytu pliku.
  4. Oddzielne ograniczenie szybkości na adres IP (przed uwierzytelnieniem) od limitu na aktora (po) — w przeciwnym razie nieuwierzytelniony przepływ wymusza kosztowną weryfikację bez limitów.
  5. Umieść wyłącznik jak najbliżej rzeczywistego połączenia sieciowego w serwerze proxy — i dostosuj go do celu, gdy domena błędów nie jest współdzielona.
  6. Odtwarzaj żądanie tylko po potwierdzeniu niedostarczenia, nigdy po zwykłym przekroczeniu limitu czasu.
  7. Przed połączeniem routerów przydziel każdej grupie tras własny budżet limitu czasu — nigdy globalny TimeoutLayer, który nadpisałby najdłuższy budżet.

GOTOWY DO WDROŻENIA?

Twój backend w pięć minut.

Karta kredytowa nie jest wymagana · 500 MB za darmo · 50 000 MAU