chore(history): squash 67 commit(s) from 2026-09-25
- feat(accounts): persist device links with opaque hashed tokens, list and revoke endpoints - feat(frontend): app shell, routing and landing page with the chat-command hero - feat(frontend): Cyrillic-first fonts (Unbounded, Onest, JetBrains Mono); add i18next and motion - docs: free mod, bilingual site, one-click download, theme editor, public profiles, rich landing in plans - feat(accounts): internal gRPC AuthenticateDevice guarded by internal key - feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch - feat(accounts): GET /me profile endpoint - feat(gateway): scaffold crate with config validation and health check - feat(gateway): reverse proxy to accounts and configs services - feat(gateway): resolve identity once from access JWT or device token via gRPC - feat(gateway): per-route and global rate limits with Retry-After - feat(gateway): CORS for the site origin; docs for gateway and internal contract - feat(configs): scaffold service with schema, config validation and health check - feat(configs): four config slots per account with list, get and save - feat(configs): permanent share codes with regenerate and public load-by-code - feat(accounts): GetPublicProfiles gRPC for showcase author info - style(accounts,common): apply rustfmt to existing sources - feat(configs): public showcase with publish, browse, detail and copy-to-slot - feat(backend): public profile endpoint and showcase author filter - fix(gateway): silence clippy collapsible-if and needless-ref warnings - docs(backend): configs-service implemented; Подсистема 1 backend complete - feat(mod): add Optimize module skeleton with OptimizeState holder - feat(mod): gate glass blur behind Optimize no_glass knob - feat(mod): cut MotionBlur and DoF sample counts behind lite_post knob - feat(mod): trim procedural sky noise behind lite_sky knob - feat(mod): drop fade gradients and digit rolls behind lean_hud knob - docs(todo): mark Optimize module phase 9.2 complete - refactor(mod): drop dead Renderer2D compatibility shims - refactor(mod): prune unreachable Renderer2D overload towers - refactor(mod): remove unused Renderer2D overloads and imports - docs(todo): mark Renderer2D giant-splitting done (2179 to 1597) - refactor(mod): extract shader id constants from LoVisualRenderPipelines - docs(todo): record registry wave 2026-09-25 (Renderer2D, pipelines) - refactor(mod): move Renderer2D instance state into base class - refactor(mod): extract Renderer2DRounded drawing family - refactor(mod): extract Renderer2DPath connector and chamfer family - refactor(mod): extract Renderer2DShapes circle line and texture primitives - refactor(mod): extract Renderer2DGlass and Renderer2DItem families - refactor(mod): prune Renderer2D imports after facade split - docs(todo): record Renderer2D facade inheritance split (1597 to 475) - docs: easter eggs — .env honeypot, konami troll mode, devtools banner, IDDQD config, breakable 404 block, 418 teapot - feat(mod): introduce surface style system core (SurfaceStyle, StyleSpec, StyleConfig, SurfaceRenderer) - refactor(mod): delegate HudRenderUtil liquid glass draws to SurfaceRenderer (dedupe glass constants) - refactor(mod): route bespoke glass call sites through SurfaceRenderer.plateSpec - feat(mod): add Auto option to HUD bg effects via shared HudBgStyles resolution - feat(mod): flat fallback for no-glass optimize mode and persist global HUD config - feat(mod): default HUD bg effects to Auto so the global surface style drives widgets - feat(mod): add global cycle-style hotkey with surface style notification - feat(mod): add surface style swatch strip under the global style picker - feat(gateway): reject ambiguous paths and answer .env probes with a honeypot - fix(gateway): charge failed credentials against the rate limit, allow stale ones on /auth - feat(frontend): ClickGui theme pipeline generated from the mod, live site theming - feat(frontend): landing v2 hero — voxel/particle backdrop, live ClickGui, theme strip - docs(todo): drop the FPS A/B measurement from phase 9.3, close phase 9 - feat(gateway): answer /coffee with a 418 teapot - feat(frontend): land the rest of landing v2 — HUD, module wall, showcase, FAQ, footer - feat(frontend): one-click download from GitHub releases, changelog page, release CI - feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links - fix(frontend): landing HUD playground now shows real mod widgets (fps, coordinates, module list, keybinds, ping) - style(frontend): apply ClickGui glass effect to landing HUD playground widgets - fix(frontend): prevent color field row overflow in theme editor grid - fix(frontend): never attach stale bearer token to /auth/* requests - fix(configs): unpublish/publish can no longer bypass moderation - refactor(accounts): shrink auth/handlers.rs under the 250-line cap - fix(accounts): tolerate concurrent refresh without killing every session - fix(gateway): minor hardening from the backend review - feat(configs): IDDQD easter egg config
This commit is contained in:
parent
72bc4c7148
commit
7f4b532f99
257 changed files with 13085 additions and 6582 deletions
29
backend/gateway/Cargo.toml
Normal file
29
backend/gateway/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "gateway"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "gateway"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
axum = "0.8"
|
||||
http-body-util = "0.1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
|
||||
tower-http = { version = "0.7", features = ["cors", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream"] }
|
||||
tonic = "0.14"
|
||||
governor = "0.10"
|
||||
anyhow = "1"
|
||||
dotenvy = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
axum-test = "21"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] }
|
||||
|
|
@ -1531,7 +1531,7 @@ git commit -m "feat(gateway): scaffold crate with config validation and health c
|
|||
|
||||
**Interfaces:**
|
||||
- Produces: `proxy::routes::{Upstream, upstream_for(&str) -> Option<Upstream>}`; `proxy::forward::{Upstreams, proxy}` where `Upstreams { client: reqwest::Client, accounts: String, configs: String, internal_key: HeaderValue }` and `Upstreams::new(&Config) -> anyhow::Result<Self>`; `async fn proxy(State<Arc<Upstreams>>, Request) -> Response` mounted as the router `fallback`.
|
||||
- Routing table: `auth, device, avatars, me` → accounts; `configs, showcase` → configs; anything else → 404 JSON.
|
||||
- Routing table: `auth, device, avatars, me, users` → accounts; `configs, showcase` → configs; anything else → 404 JSON.
|
||||
|
||||
- [ ] **Step 1: Echo upstream helper (tests/common/mod.rs)**
|
||||
|
||||
|
|
@ -1627,7 +1627,7 @@ pub enum Upstream {
|
|||
|
||||
pub fn upstream_for(path: &str) -> Option<Upstream> {
|
||||
match path.trim_start_matches('/').split('/').next()? {
|
||||
"auth" | "device" | "avatars" | "me" => Some(Upstream::Accounts),
|
||||
"auth" | "device" | "avatars" | "me" | "users" => Some(Upstream::Accounts),
|
||||
"configs" | "showcase" => Some(Upstream::Configs),
|
||||
_ => None,
|
||||
}
|
||||
|
|
@ -2337,6 +2337,13 @@ git commit -m "feat(gateway): CORS for the site origin; docs for gateway and int
|
|||
|
||||
---
|
||||
|
||||
### Task 12: Easter eggs in the gateway
|
||||
|
||||
- `.env` honeypot — done together with the dot-segment fix (see `.superpowers` fix list; commit "feat(gateway): .env honeypot easter egg for traversal scanners").
|
||||
- `GET /coffee` (any method) → `418 I'm a teapot`, `text/plain; charset=utf-8`: «Я чайник. Кофе не варю, зато LoVisual бесплатный: https://github.com/loki5512344/LoVisual-/releases». Handled in the gateway before identity/rate limits, not forwarded. Test + commit `feat(gateway): 418 teapot at /coffee`.
|
||||
|
||||
---
|
||||
|
||||
## Deferred (not in this plan)
|
||||
|
||||
- Admin role enforcement at the gateway (`/admin/*`) — Подсистема 3 plan; `Identity` will then carry the role (add `role` to the access-token claims).
|
||||
|
|
|
|||
89
backend/gateway/src/config.rs
Normal file
89
backend/gateway/src/config.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub port: u16,
|
||||
pub jwt_secret: String,
|
||||
pub internal_key: String,
|
||||
pub accounts_http_url: String,
|
||||
pub accounts_grpc_url: String,
|
||||
pub configs_http_url: String,
|
||||
pub site_origin: String,
|
||||
/// Behind a reverse proxy (nginx/caddy) that appends the client IP to
|
||||
/// X-Forwarded-For. Never enable when the gateway is exposed directly.
|
||||
pub trust_proxy: bool,
|
||||
}
|
||||
|
||||
fn var(name: &str) -> Result<String> {
|
||||
std::env::var(name).with_context(|| format!("{name} not set"))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Config> {
|
||||
Ok(Config {
|
||||
port: std::env::var("GATEWAY_PORT")
|
||||
.unwrap_or_else(|_| "8080".into())
|
||||
.parse()
|
||||
.context("GATEWAY_PORT")?,
|
||||
jwt_secret: var("JWT_SECRET")?,
|
||||
internal_key: var("INTERNAL_KEY")?,
|
||||
accounts_http_url: var("ACCOUNTS_HTTP_URL")?,
|
||||
accounts_grpc_url: var("ACCOUNTS_GRPC_URL")?,
|
||||
configs_http_url: var("CONFIGS_HTTP_URL")?,
|
||||
site_origin: var("SITE_ORIGIN")?,
|
||||
trust_proxy: std::env::var("TRUST_PROXY").is_ok_and(|v| v == "true"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (name, value) in [
|
||||
("JWT_SECRET", &self.jwt_secret),
|
||||
("INTERNAL_KEY", &self.internal_key),
|
||||
] {
|
||||
if value.len() < 32 {
|
||||
anyhow::bail!("{name} must be at least 32 bytes");
|
||||
}
|
||||
}
|
||||
// Both are sent as raw header values (INTERNAL_KEY on every upstream
|
||||
// call, SITE_ORIGIN in the CORS layer); checked here so a bad value
|
||||
// is a startup error, not a panic deep in request handling.
|
||||
axum::http::HeaderValue::from_str(&self.internal_key)
|
||||
.context("INTERNAL_KEY is not a valid header value")?;
|
||||
axum::http::HeaderValue::from_str(&self.site_origin)
|
||||
.context("SITE_ORIGIN is not a valid header value")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
pub fn sample() -> Config {
|
||||
Config {
|
||||
port: 0,
|
||||
jwt_secret: "j".repeat(32),
|
||||
internal_key: "k".repeat(32),
|
||||
accounts_http_url: String::new(),
|
||||
accounts_grpc_url: String::new(),
|
||||
configs_http_url: String::new(),
|
||||
site_origin: "http://localhost:5173".into(),
|
||||
trust_proxy: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_config_passes() {
|
||||
assert!(sample().validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_secrets_are_rejected() {
|
||||
let mut c = sample();
|
||||
c.internal_key = "short".into();
|
||||
assert!(c.validate().is_err());
|
||||
let mut c = sample();
|
||||
c.jwt_secret = "short".into();
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
}
|
||||
60
backend/gateway/src/guard/honeypot.rs
Normal file
60
backend/gateway/src/guard/honeypot.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! Easter egg for `.env` scanners: instead of a 400 they get a fake file.
|
||||
//! Never forwarded upstream; answered before identity and rate limiting.
|
||||
|
||||
use axum::{
|
||||
http::{StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
const FAKE_ENV: &str = "\
|
||||
# LoVisual production secrets — не благодари
|
||||
DATABASE_URL=postgres://idi_naxui:daun_ebani@localhost:5432/tvoya_mamka
|
||||
JWT_SECRET=nice_try_skiddie_tvoy_ip_uzhe_v_bane
|
||||
INTERNAL_KEY=0000-0000-0000-0000-otvali
|
||||
ADMIN_PASSWORD=hunter2
|
||||
S3_SECRET_KEY=lovisual_luchshe_chem_tvoy_chit
|
||||
# P.S. лучше скачай мод: https://github.com/loki5512344/LoVisual-/releases
|
||||
";
|
||||
|
||||
/// Any segment named `.env` or `.env.<suffix>` (dots may be `%2e`-encoded).
|
||||
pub fn is_env_probe(path: &str) -> bool {
|
||||
super::segments(path).any(|raw| {
|
||||
let name = super::decode_dots(raw);
|
||||
name == ".env" || name.starts_with(".env.")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fake_env() -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(CONTENT_TYPE, "text/plain; charset=utf-8")],
|
||||
FAKE_ENV,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_env_segments_only() {
|
||||
for hit in [
|
||||
"/.env",
|
||||
"/../../.env",
|
||||
"/api/.env",
|
||||
"/.env.local",
|
||||
"/%2e%2e/%2Eenv",
|
||||
] {
|
||||
assert!(is_env_probe(hit), "{hit}");
|
||||
}
|
||||
for miss in [
|
||||
"/auth/login",
|
||||
"/configs/.environment",
|
||||
"/x.env",
|
||||
"/showcase/env",
|
||||
] {
|
||||
assert!(!is_env_probe(miss), "{miss}");
|
||||
}
|
||||
}
|
||||
}
|
||||
111
backend/gateway/src/guard/mod.rs
Normal file
111
backend/gateway/src/guard/mod.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! Outermost request filter: answers the gateway's own jokes and refuses paths
|
||||
//! whose meaning changes once the upstream URL is built. reqwest/`url` (WHATWG)
|
||||
//! resolves `..`, `%2e%2e` and backslashes, while rate-limit rules match the raw
|
||||
//! path — so without this, `POST /auth/x/../login` reaches `/auth/login` without
|
||||
//! its 5/min limit.
|
||||
|
||||
pub mod honeypot;
|
||||
|
||||
use crate::rate_limit::client_ip;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
/// A path segment with `%2e`/`%2E` decoded — the only escape WHATWG URL
|
||||
/// parsing treats as a dot when resolving dot-segments.
|
||||
fn decode_dots(segment: &str) -> String {
|
||||
segment.to_ascii_lowercase().replace("%2e", ".")
|
||||
}
|
||||
|
||||
fn segments(path: &str) -> impl Iterator<Item = &str> {
|
||||
path.strip_prefix('/').unwrap_or(path).split('/')
|
||||
}
|
||||
|
||||
/// True when the upstream could see a different path than the one the
|
||||
/// gateway routed and rate-limited.
|
||||
pub fn is_ambiguous(path: &str) -> bool {
|
||||
if path == "/" {
|
||||
return false;
|
||||
}
|
||||
segments(path).any(|raw| {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
let dots = decode_dots(raw);
|
||||
raw.is_empty()
|
||||
|| dots == "."
|
||||
|| dots == ".."
|
||||
|| raw.contains('\\')
|
||||
|| lower.contains("%2f")
|
||||
|| lower.contains("%5c")
|
||||
})
|
||||
}
|
||||
|
||||
const TEAPOT: &str = "Я чайник. Кофе не варю, зато LoVisual бесплатный: https://github.com/loki5512344/LoVisual-/releases\n";
|
||||
|
||||
fn teapot() -> Response {
|
||||
(
|
||||
StatusCode::IM_A_TEAPOT,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8",
|
||||
)],
|
||||
TEAPOT,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn reject_ambiguous_paths(
|
||||
State(trust_proxy): State<bool>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = req.uri().path();
|
||||
if path == "/coffee" {
|
||||
return teapot();
|
||||
}
|
||||
if honeypot::is_env_probe(path) {
|
||||
tracing::info!(ip = %client_ip(&req, trust_proxy), path, "honeypot hit");
|
||||
return honeypot::fake_env();
|
||||
}
|
||||
if is_ambiguous(path) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "bad path" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_paths() {
|
||||
for bad in [
|
||||
"/a/../b",
|
||||
"/a/%2E%2e/b",
|
||||
"/a/.%2E",
|
||||
"/a//b",
|
||||
"/a%2Fb",
|
||||
"/a\\b",
|
||||
"/a/",
|
||||
] {
|
||||
assert!(is_ambiguous(bad), "{bad}");
|
||||
}
|
||||
for ok in [
|
||||
"/",
|
||||
"/auth/login",
|
||||
"/configs/shared/AB2C",
|
||||
"/a/..b",
|
||||
"/a/.x",
|
||||
] {
|
||||
assert!(!is_ambiguous(ok), "{ok}");
|
||||
}
|
||||
}
|
||||
}
|
||||
66
backend/gateway/src/identity/device.rs
Normal file
66
backend/gateway/src/identity/device.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use common::internal::GrpcKeyAttach;
|
||||
use common::pb::accounts::{
|
||||
AuthenticateDeviceRequest, accounts_internal_client::AccountsInternalClient,
|
||||
};
|
||||
use std::{future::Future, pin::Pin};
|
||||
use tonic::{
|
||||
Code,
|
||||
service::interceptor::InterceptedService,
|
||||
transport::{Channel, Endpoint},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub enum DeviceAuth {
|
||||
Valid(Uuid),
|
||||
Invalid,
|
||||
/// accounts-service unreachable — the gateway answers 503, not 401,
|
||||
/// so the mod doesn't wrongly forget its token.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub type DeviceAuthFuture<'a> = Pin<Box<dyn Future<Output = DeviceAuth> + Send + 'a>>;
|
||||
|
||||
pub trait DeviceAuthenticator: Send + Sync + 'static {
|
||||
fn authenticate<'a>(&'a self, token: &'a str) -> DeviceAuthFuture<'a>;
|
||||
}
|
||||
|
||||
pub struct GrpcDevices {
|
||||
client: AccountsInternalClient<InterceptedService<Channel, GrpcKeyAttach>>,
|
||||
}
|
||||
|
||||
impl GrpcDevices {
|
||||
/// Lazy: the gateway boots even if accounts-service is still starting.
|
||||
pub fn connect_lazy(url: &str, internal_key: &str) -> anyhow::Result<Self> {
|
||||
let channel = Endpoint::from_shared(url.to_owned())?
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.connect_lazy();
|
||||
Ok(GrpcDevices {
|
||||
client: AccountsInternalClient::with_interceptor(
|
||||
channel,
|
||||
GrpcKeyAttach::new(internal_key)?,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceAuthenticator for GrpcDevices {
|
||||
fn authenticate<'a>(&'a self, token: &'a str) -> DeviceAuthFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let mut client = self.client.clone();
|
||||
match client
|
||||
.authenticate_device(AuthenticateDeviceRequest {
|
||||
device_token: token.to_owned(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(reply) => Uuid::parse_str(&reply.into_inner().account_id)
|
||||
.map_or(DeviceAuth::Invalid, DeviceAuth::Valid),
|
||||
Err(status) if status.code() == Code::Unauthenticated => DeviceAuth::Invalid,
|
||||
Err(status) => {
|
||||
tracing::warn!("AuthenticateDevice failed: {status}");
|
||||
DeviceAuth::Unavailable
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
114
backend/gateway/src/identity/mod.rs
Normal file
114
backend/gateway/src/identity/mod.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
pub mod device;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Request, State},
|
||||
http::{HeaderValue, StatusCode, header::AUTHORIZATION},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use common::internal::{ACCOUNT_ID_HEADER, DEVICE_TOKEN_PREFIX, INTERNAL_KEY_HEADER};
|
||||
use common::jwt::{TokenType, bearer_token, verify_token};
|
||||
use device::{DeviceAuth, DeviceAuthenticator};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Who made the request, as far as the gateway could verify.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Identity(pub Option<Uuid>);
|
||||
|
||||
/// How the caller's credentials resolved. Recorded by `identify`, acted on by
|
||||
/// `authorize` — resolution must not reject on its own, because it sits before
|
||||
/// rate limiting and an unknown device token already cost a gRPC round trip.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Credentials {
|
||||
/// No `Authorization` header at all.
|
||||
Anonymous,
|
||||
/// Verified; `Identity` carries the account.
|
||||
Valid,
|
||||
/// Present but not verifiable (expired/garbage/unknown device token).
|
||||
Invalid,
|
||||
/// Device lookup failed; the caller is neither allowed nor blamed.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IdentityState {
|
||||
pub jwt_secret: Arc<str>,
|
||||
pub devices: Arc<dyn DeviceAuthenticator>,
|
||||
}
|
||||
|
||||
fn reject(status: StatusCode, message: &str) -> Response {
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
|
||||
/// Public auth endpoints: stale credentials there must not lock the caller
|
||||
/// out of logout/refresh — they are treated as anonymous instead of 401.
|
||||
fn is_auth_path(path: &str) -> bool {
|
||||
path.starts_with("/auth/")
|
||||
}
|
||||
|
||||
/// Resolves the caller into `Identity` + `Credentials` extensions, never
|
||||
/// rejecting; the gate is `authorize`.
|
||||
pub async fn identify(
|
||||
State(state): State<IdentityState>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Client-supplied copies of trusted headers are never forwarded.
|
||||
req.headers_mut().remove(ACCOUNT_ID_HEADER);
|
||||
req.headers_mut().remove(INTERNAL_KEY_HEADER);
|
||||
|
||||
let (account, credentials) = match bearer_token(req.headers()) {
|
||||
None => (None, Credentials::Anonymous),
|
||||
Some(token) if token.starts_with(DEVICE_TOKEN_PREFIX) => {
|
||||
match state.devices.authenticate(token).await {
|
||||
DeviceAuth::Valid(id) => (Some(id), Credentials::Valid),
|
||||
DeviceAuth::Invalid => (None, Credentials::Invalid),
|
||||
DeviceAuth::Unavailable => (None, Credentials::Unavailable),
|
||||
}
|
||||
}
|
||||
Some(token) => {
|
||||
let id = verify_token(token, &state.jwt_secret, TokenType::Access)
|
||||
.and_then(|claims| Uuid::parse_str(&claims.sub).ok());
|
||||
match id {
|
||||
Some(id) => (Some(id), Credentials::Valid),
|
||||
None => (None, Credentials::Invalid),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The client's bearer token stops here either way.
|
||||
req.headers_mut().remove(AUTHORIZATION);
|
||||
if let Some(id) = account
|
||||
&& let Ok(value) = HeaderValue::from_str(&id.to_string())
|
||||
{
|
||||
req.headers_mut().insert(ACCOUNT_ID_HEADER, value);
|
||||
}
|
||||
req.extensions_mut().insert(Identity(account));
|
||||
req.extensions_mut().insert(credentials);
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Turns a failed resolution into an HTTP rejection — positioned after rate
|
||||
/// limiting so that a rejected credential still costs the caller a token.
|
||||
pub async fn authorize(req: Request, next: Next) -> Response {
|
||||
let credentials = req
|
||||
.extensions()
|
||||
.get::<Credentials>()
|
||||
.copied()
|
||||
.unwrap_or(Credentials::Anonymous);
|
||||
let allowed = credentials == Credentials::Anonymous
|
||||
|| credentials == Credentials::Valid
|
||||
|| is_auth_path(req.uri().path());
|
||||
if !allowed {
|
||||
return match credentials {
|
||||
Credentials::Unavailable => {
|
||||
reject(StatusCode::SERVICE_UNAVAILABLE, "auth backend unavailable")
|
||||
}
|
||||
_ => reject(StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
};
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
76
backend/gateway/src/lib.rs
Normal file
76
backend/gateway/src/lib.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
pub mod config;
|
||||
pub mod guard;
|
||||
pub mod identity;
|
||||
pub mod proxy;
|
||||
pub mod rate_limit;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
http::{
|
||||
HeaderValue, Method,
|
||||
header::{AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER},
|
||||
},
|
||||
routing::get,
|
||||
};
|
||||
use config::Config;
|
||||
use identity::device::DeviceAuthenticator;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
pub fn build_app(cfg: &Config, devices: Arc<dyn DeviceAuthenticator>) -> Router {
|
||||
let upstreams = Arc::new(proxy::forward::Upstreams::new(cfg).expect("valid upstream config"));
|
||||
let identity = identity::IdentityState {
|
||||
jwt_secret: cfg.jwt_secret.as_str().into(),
|
||||
devices,
|
||||
};
|
||||
let limits = rate_limit::RateLimits::new(cfg.trust_proxy);
|
||||
spawn_purger(Arc::clone(&limits));
|
||||
let cors = CorsLayer::new()
|
||||
// Array form = AllowOrigin::list: the header is only sent back when the
|
||||
// request's Origin actually matches (the HeaderValue form would echo
|
||||
// the configured origin unconditionally).
|
||||
.allow_origin([
|
||||
HeaderValue::from_str(&cfg.site_origin).expect("SITE_ORIGIN is a valid origin")
|
||||
])
|
||||
.allow_credentials(true)
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
|
||||
.allow_headers([AUTHORIZATION, CONTENT_TYPE])
|
||||
// Without this, browser JS can't read Retry-After on a 429 despite
|
||||
// the response carrying it — cross-origin responses only expose a
|
||||
// fixed default header set unless the server opts more in.
|
||||
.expose_headers([RETRY_AFTER]);
|
||||
// Layers run bottom-up: the last `.layer` is outermost. Request order is
|
||||
// therefore cors → guard → identify (resolve only) → rate limit →
|
||||
// authorize → upstream: a bad credential is counted against the caller's
|
||||
// limit before it gets rejected, and CORS preflights skip all of it.
|
||||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.fallback(proxy::forward::proxy)
|
||||
.with_state(upstreams)
|
||||
.layer(axum::middleware::from_fn(identity::authorize))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
limits,
|
||||
rate_limit::enforce,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
identity,
|
||||
identity::identify,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
cfg.trust_proxy,
|
||||
guard::reject_ambiguous_paths,
|
||||
))
|
||||
.layer(cors)
|
||||
}
|
||||
|
||||
fn spawn_purger(limits: Arc<rate_limit::RateLimits>) {
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
limits.purge();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
23
backend/gateway/src/main.rs
Normal file
23
backend/gateway/src/main.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use gateway::identity::device::GrpcDevices;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt::init();
|
||||
let cfg = gateway::config::Config::from_env()?;
|
||||
cfg.validate()?;
|
||||
let devices = Arc::new(GrpcDevices::connect_lazy(
|
||||
&cfg.accounts_grpc_url,
|
||||
&cfg.internal_key,
|
||||
)?);
|
||||
let app = gateway::build_app(&cfg, devices);
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
|
||||
tracing::info!("gateway listening on {}", cfg.port);
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
124
backend/gateway/src/proxy/forward.rs
Normal file
124
backend/gateway/src/proxy/forward.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
use super::routes::{Upstream, upstream_for};
|
||||
use crate::config::Config;
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use common::internal::INTERNAL_KEY_HEADER;
|
||||
use serde_json::json;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
/// 5 MB avatar + multipart overhead; configs are far smaller.
|
||||
pub const MAX_BODY_BYTES: usize = 6 * 1024 * 1024;
|
||||
|
||||
/// A slow/stalled client body must not hold a connection open forever.
|
||||
const BODY_READ_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
const HOP_BY_HOP: [HeaderName; 7] = [
|
||||
header::CONNECTION,
|
||||
header::PROXY_AUTHENTICATE,
|
||||
header::PROXY_AUTHORIZATION,
|
||||
header::TE,
|
||||
header::TRAILER,
|
||||
header::TRANSFER_ENCODING,
|
||||
header::UPGRADE,
|
||||
];
|
||||
|
||||
pub struct Upstreams {
|
||||
pub client: reqwest::Client,
|
||||
pub accounts: String,
|
||||
pub configs: String,
|
||||
pub internal_key: HeaderValue,
|
||||
}
|
||||
|
||||
impl Upstreams {
|
||||
pub fn new(cfg: &Config) -> anyhow::Result<Self> {
|
||||
Ok(Upstreams {
|
||||
client: reqwest::Client::builder()
|
||||
// Never follow redirects on behalf of the client — pass them through.
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?,
|
||||
accounts: cfg.accounts_http_url.trim_end_matches('/').to_owned(),
|
||||
configs: cfg.configs_http_url.trim_end_matches('/').to_owned(),
|
||||
internal_key: HeaderValue::from_str(&cfg.internal_key)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn error(status: StatusCode, message: &str) -> Response {
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
|
||||
fn strip_hop_by_hop(headers: &mut HeaderMap) {
|
||||
for name in &HOP_BY_HOP {
|
||||
headers.remove(name);
|
||||
}
|
||||
headers.remove("keep-alive");
|
||||
}
|
||||
|
||||
pub async fn proxy(State(up): State<Arc<Upstreams>>, req: Request) -> Response {
|
||||
let Some(target) = upstream_for(req.uri().path()) else {
|
||||
return error(StatusCode::NOT_FOUND, "not found");
|
||||
};
|
||||
let base = match target {
|
||||
Upstream::Accounts => &up.accounts,
|
||||
Upstream::Configs => &up.configs,
|
||||
};
|
||||
let path_and_query = req.uri().path_and_query().map_or("/", |p| p.as_str());
|
||||
let url = format!("{base}{path_and_query}");
|
||||
|
||||
let (parts, body) = req.into_parts();
|
||||
let bytes = match tokio::time::timeout(
|
||||
BODY_READ_TIMEOUT,
|
||||
axum::body::to_bytes(body, MAX_BODY_BYTES),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(bytes)) => bytes,
|
||||
Ok(Err(err)) => {
|
||||
// `to_bytes` reports both "over the limit" and "the connection
|
||||
// broke while reading" the same way; only the former is 413.
|
||||
let over_limit = std::error::Error::source(&err)
|
||||
.is_some_and(|e| e.is::<http_body_util::LengthLimitError>());
|
||||
return if over_limit {
|
||||
error(StatusCode::PAYLOAD_TOO_LARGE, "payload too large")
|
||||
} else {
|
||||
error(StatusCode::BAD_REQUEST, "invalid request body")
|
||||
};
|
||||
}
|
||||
Err(_) => return error(StatusCode::REQUEST_TIMEOUT, "request body read timed out"),
|
||||
};
|
||||
let mut headers = parts.headers;
|
||||
strip_hop_by_hop(&mut headers);
|
||||
headers.remove(header::HOST);
|
||||
headers.insert(INTERNAL_KEY_HEADER, up.internal_key.clone());
|
||||
|
||||
let upstream = match up
|
||||
.client
|
||||
.request(parts.method, url)
|
||||
.headers(headers)
|
||||
.body(bytes)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
tracing::warn!("upstream {target:?} failed: {err}");
|
||||
return error(StatusCode::BAD_GATEWAY, "upstream unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
let mut response = Response::builder().status(upstream.status());
|
||||
for (name, value) in upstream.headers() {
|
||||
if !HOP_BY_HOP.contains(name) && name != "keep-alive" {
|
||||
response = response.header(name, value);
|
||||
}
|
||||
}
|
||||
response
|
||||
.body(Body::from_stream(upstream.bytes_stream()))
|
||||
.unwrap_or_else(|_| error(StatusCode::BAD_GATEWAY, "bad upstream response"))
|
||||
}
|
||||
2
backend/gateway/src/proxy/mod.rs
Normal file
2
backend/gateway/src/proxy/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod forward;
|
||||
pub mod routes;
|
||||
29
backend/gateway/src/proxy/routes.rs
Normal file
29
backend/gateway/src/proxy/routes.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Upstream {
|
||||
Accounts,
|
||||
Configs,
|
||||
}
|
||||
|
||||
pub fn upstream_for(path: &str) -> Option<Upstream> {
|
||||
match path.trim_start_matches('/').split('/').next()? {
|
||||
"auth" | "device" | "avatars" | "me" | "users" => Some(Upstream::Accounts),
|
||||
"configs" | "showcase" => Some(Upstream::Configs),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn routes_by_first_segment_only() {
|
||||
assert_eq!(upstream_for("/auth/login"), Some(Upstream::Accounts));
|
||||
assert_eq!(upstream_for("/me"), Some(Upstream::Accounts));
|
||||
assert_eq!(upstream_for("/configs/shared/ABC"), Some(Upstream::Configs));
|
||||
assert_eq!(upstream_for("/showcase"), Some(Upstream::Configs));
|
||||
assert_eq!(upstream_for("/authx"), None);
|
||||
assert_eq!(upstream_for("/"), None);
|
||||
assert_eq!(upstream_for("/health"), None);
|
||||
}
|
||||
}
|
||||
111
backend/gateway/src/rate_limit/mod.rs
Normal file
111
backend/gateway/src/rate_limit/mod.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
pub mod rules;
|
||||
|
||||
use crate::identity::Identity;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{ConnectInfo, Request, State},
|
||||
http::{HeaderValue, StatusCode, header::RETRY_AFTER},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use governor::{
|
||||
DefaultKeyedRateLimiter, RateLimiter,
|
||||
clock::{Clock, DefaultClock},
|
||||
};
|
||||
use rules::{KeyBy, Rule};
|
||||
use serde_json::json;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
pub struct RateLimits {
|
||||
rules: Vec<(Rule, DefaultKeyedRateLimiter<String>)>,
|
||||
global: DefaultKeyedRateLimiter<String>,
|
||||
trust_proxy: bool,
|
||||
clock: DefaultClock,
|
||||
}
|
||||
|
||||
impl RateLimits {
|
||||
pub fn new(trust_proxy: bool) -> Arc<Self> {
|
||||
Arc::new(RateLimits {
|
||||
rules: rules::rules()
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let l = RateLimiter::keyed(r.quota);
|
||||
(r, l)
|
||||
})
|
||||
.collect(),
|
||||
global: RateLimiter::keyed(rules::global_quota()),
|
||||
trust_proxy,
|
||||
clock: DefaultClock::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Drops idle keys so the maps don't grow forever. Call periodically.
|
||||
pub fn purge(&self) {
|
||||
for (_, limiter) in &self.rules {
|
||||
limiter.retain_recent();
|
||||
limiter.shrink_to_fit();
|
||||
}
|
||||
self.global.retain_recent();
|
||||
self.global.shrink_to_fit();
|
||||
}
|
||||
|
||||
fn client_ip(&self, req: &Request) -> String {
|
||||
client_ip(req, self.trust_proxy)
|
||||
}
|
||||
}
|
||||
|
||||
/// The caller's IP: the rightmost X-Forwarded-For entry (the one our own
|
||||
/// proxy appended) when `trust_proxy`, else the socket peer.
|
||||
pub fn client_ip(req: &Request, trust_proxy: bool) -> String {
|
||||
if trust_proxy
|
||||
&& let Some(ip) = req
|
||||
.headers()
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.rsplit(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return ip.to_owned();
|
||||
}
|
||||
req.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map_or_else(|| "unknown".to_owned(), |c| c.0.ip().to_string())
|
||||
}
|
||||
|
||||
fn too_many(wait: std::time::Duration) -> Response {
|
||||
let secs = wait.as_secs_f64().ceil().max(1.0) as u64;
|
||||
let mut res = (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({ "error": "too many requests" })),
|
||||
)
|
||||
.into_response();
|
||||
res.headers_mut()
|
||||
.insert(RETRY_AFTER, HeaderValue::from(secs));
|
||||
res
|
||||
}
|
||||
|
||||
pub async fn enforce(State(limits): State<Arc<RateLimits>>, req: Request, next: Next) -> Response {
|
||||
let ip_key = format!("ip:{}", limits.client_ip(&req));
|
||||
let account_key = req
|
||||
.extensions()
|
||||
.get::<Identity>()
|
||||
.and_then(|i| i.0)
|
||||
.map(|id| format!("acc:{id}"));
|
||||
let caller_key = account_key.unwrap_or_else(|| ip_key.clone());
|
||||
|
||||
let (method, path) = (req.method().clone(), req.uri().path().to_owned());
|
||||
if let Some((rule, limiter)) = limits.rules.iter().find(|(r, _)| r.matches(&method, &path)) {
|
||||
let key = match rule.key_by {
|
||||
KeyBy::Ip => &ip_key,
|
||||
KeyBy::Account => &caller_key,
|
||||
};
|
||||
if let Err(not_until) = limiter.check_key(key) {
|
||||
return too_many(not_until.wait_time_from(limits.clock.now()));
|
||||
}
|
||||
}
|
||||
if let Err(not_until) = limits.global.check_key(&caller_key) {
|
||||
return too_many(not_until.wait_time_from(limits.clock.now()));
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
85
backend/gateway/src/rate_limit/rules.rs
Normal file
85
backend/gateway/src/rate_limit/rules.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use axum::http::Method;
|
||||
use governor::Quota;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyBy {
|
||||
Ip,
|
||||
Account,
|
||||
}
|
||||
|
||||
pub struct Rule {
|
||||
pub method: Method,
|
||||
/// Exact path, or a prefix when it ends with '*'.
|
||||
pub pattern: &'static str,
|
||||
pub quota: Quota,
|
||||
pub key_by: KeyBy,
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
pub fn matches(&self, method: &Method, path: &str) -> bool {
|
||||
if self.method != *method {
|
||||
return false;
|
||||
}
|
||||
match self.pattern.strip_suffix('*') {
|
||||
Some(prefix) => path.starts_with(prefix),
|
||||
None => path == self.pattern,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn n(v: u32) -> NonZeroU32 {
|
||||
NonZeroU32::new(v).expect("rate-limit constants are non-zero")
|
||||
}
|
||||
|
||||
fn rule(method: Method, pattern: &'static str, quota: Quota, key_by: KeyBy) -> Rule {
|
||||
Rule {
|
||||
method,
|
||||
pattern,
|
||||
quota,
|
||||
key_by,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rules() -> Vec<Rule> {
|
||||
use KeyBy::*;
|
||||
vec![
|
||||
rule(Method::POST, "/auth/login", Quota::per_minute(n(5)), Ip),
|
||||
rule(Method::POST, "/auth/register", Quota::per_hour(n(3)), Ip),
|
||||
rule(Method::POST, "/auth/refresh", Quota::per_minute(n(30)), Ip),
|
||||
rule(Method::POST, "/device/code", Quota::per_minute(n(10)), Ip),
|
||||
// The mod polls every 2–3 s for up to 10 min: 10/min would break linking.
|
||||
rule(Method::POST, "/device/token", Quota::per_minute(n(30)), Ip),
|
||||
rule(Method::PUT, "/configs/*", Quota::per_minute(n(20)), Account),
|
||||
rule(
|
||||
Method::GET,
|
||||
"/configs/shared/*",
|
||||
Quota::per_minute(n(30)),
|
||||
Ip,
|
||||
),
|
||||
rule(Method::POST, "/avatars", Quota::per_hour(n(5)), Account),
|
||||
rule(Method::GET, "/showcase*", Quota::per_minute(n(60)), Ip),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn global_quota() -> Quota {
|
||||
Quota::per_minute(n(300))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_and_prefix_matching() {
|
||||
let all = rules();
|
||||
let find = |m: Method, p: &str| all.iter().position(|r| r.matches(&m, p));
|
||||
assert_eq!(find(Method::POST, "/auth/login"), Some(0));
|
||||
assert_eq!(find(Method::GET, "/auth/login"), None);
|
||||
assert_eq!(find(Method::POST, "/auth/login/x"), None);
|
||||
assert!(find(Method::PUT, "/configs/3").is_some());
|
||||
assert!(find(Method::GET, "/configs/shared/ABCD").is_some());
|
||||
assert!(find(Method::GET, "/showcase").is_some());
|
||||
assert!(find(Method::GET, "/showcase/11111111-1111-1111-1111-111111111111").is_some());
|
||||
}
|
||||
}
|
||||
141
backend/gateway/tests/common/mod.rs
Normal file
141
backend/gateway/tests/common/mod.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
#![allow(dead_code)]
|
||||
|
||||
use axum::{Json, Router, body::Bytes, extract::Request, routing::any};
|
||||
use gateway::config::Config;
|
||||
use gateway::identity::device::{DeviceAuth, DeviceAuthFuture, DeviceAuthenticator};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const JWT_SECRET: &str = "gateway-test-secret-gateway-test!!";
|
||||
pub const INTERNAL_KEY: &str = "internal-key-internal-key-internal!!";
|
||||
|
||||
#[allow(unused_imports)] // used by identity.rs; other test crates don't need it
|
||||
pub use ::common::jwt;
|
||||
|
||||
pub fn config(accounts: &str, configs: &str) -> Config {
|
||||
Config {
|
||||
port: 0,
|
||||
jwt_secret: JWT_SECRET.into(),
|
||||
internal_key: INTERNAL_KEY.into(),
|
||||
accounts_http_url: accounts.into(),
|
||||
accounts_grpc_url: String::new(),
|
||||
configs_http_url: configs.into(),
|
||||
site_origin: "http://localhost:5173".into(),
|
||||
trust_proxy: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts exactly one device token, mapped to one account.
|
||||
pub struct FakeDevices {
|
||||
pub token: String,
|
||||
pub account: Uuid,
|
||||
}
|
||||
|
||||
impl DeviceAuthenticator for FakeDevices {
|
||||
fn authenticate<'a>(&'a self, token: &'a str) -> DeviceAuthFuture<'a> {
|
||||
Box::pin(async move {
|
||||
if token == self.token {
|
||||
DeviceAuth::Valid(self.account)
|
||||
} else {
|
||||
DeviceAuth::Invalid
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// accounts-service unreachable: every device token lookup fails.
|
||||
pub struct DownDevices;
|
||||
|
||||
impl DeviceAuthenticator for DownDevices {
|
||||
fn authenticate<'a>(&'a self, _token: &'a str) -> DeviceAuthFuture<'a> {
|
||||
Box::pin(async { DeviceAuth::Unavailable })
|
||||
}
|
||||
}
|
||||
|
||||
/// An access JWT signed with the right key but already expired.
|
||||
pub fn expired_access_token(account: Uuid) -> String {
|
||||
let exp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock after epoch")
|
||||
.as_secs() as usize
|
||||
- 60;
|
||||
let claims = ::common::jwt::Claims {
|
||||
sub: account.to_string(),
|
||||
exp,
|
||||
token_type: ::common::jwt::TokenType::Access,
|
||||
};
|
||||
jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_secret(JWT_SECRET.as_bytes()),
|
||||
)
|
||||
.expect("encode test jwt")
|
||||
}
|
||||
|
||||
pub fn no_devices() -> Arc<dyn DeviceAuthenticator> {
|
||||
Arc::new(FakeDevices {
|
||||
token: "lvd_none".into(),
|
||||
account: Uuid::nil(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts a fake service that echoes what it received as JSON; returns its base URL.
|
||||
pub async fn spawn_echo() -> String {
|
||||
async fn echo(req: Request) -> Json<serde_json::Value> {
|
||||
let (parts, body) = req.into_parts();
|
||||
let body: Bytes = axum::body::to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let header = |name: &str| {
|
||||
parts
|
||||
.headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
};
|
||||
Json(serde_json::json!({
|
||||
"method": parts.method.as_str(),
|
||||
"path": parts.uri.path(),
|
||||
"query": parts.uri.query(),
|
||||
"body": String::from_utf8_lossy(&body),
|
||||
"account_id": header("x-lovisual-account-id"),
|
||||
"internal_key": header("x-lovisual-internal-key"),
|
||||
"authorization": header("authorization"),
|
||||
}))
|
||||
}
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, Router::new().fallback(any(echo)))
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
/// Sends a request straight into the router, bypassing the test client's URL
|
||||
/// handling, so the raw path (`..`, `%2e`, `//`) reaches the gateway as-is.
|
||||
pub async fn raw(
|
||||
app: &axum::Router,
|
||||
method: &str,
|
||||
uri: &str,
|
||||
forwarded_for: &str,
|
||||
) -> (axum::http::StatusCode, axum::http::HeaderMap, String) {
|
||||
use tower::ServiceExt;
|
||||
let req = axum::http::Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("x-forwarded-for", forwarded_for)
|
||||
.body(axum::body::Body::empty())
|
||||
.expect("valid raw request");
|
||||
let res = app.clone().oneshot(req).await.expect("infallible router");
|
||||
let (parts, body) = res.into_parts();
|
||||
let bytes = axum::body::to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
(
|
||||
parts.status,
|
||||
parts.headers,
|
||||
String::from_utf8_lossy(&bytes).into_owned(),
|
||||
)
|
||||
}
|
||||
111
backend/gateway/tests/identity.rs
Normal file
111
backend/gateway/tests/identity.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn server(devices: common::FakeDevices) -> axum_test::TestServer {
|
||||
let echo = common::spawn_echo().await;
|
||||
let app = gateway::build_app(&common::config(&echo, &echo), Arc::new(devices));
|
||||
axum_test::TestServer::new(app)
|
||||
}
|
||||
|
||||
fn devices() -> common::FakeDevices {
|
||||
common::FakeDevices {
|
||||
token: "lvd_good".into(),
|
||||
account: Uuid::new_v4(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_jwt_becomes_account_header_and_authorization_is_dropped() {
|
||||
let account = Uuid::new_v4();
|
||||
let token = common::jwt::issue_access_token(account, common::JWT_SECRET);
|
||||
let echo: serde_json::Value = server(devices())
|
||||
.await
|
||||
.get("/me")
|
||||
.authorization_bearer(token)
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(echo["account_id"], account.to_string());
|
||||
assert!(echo["authorization"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_token_is_resolved_via_authenticator() {
|
||||
let d = devices();
|
||||
let account = d.account;
|
||||
let echo: serde_json::Value = server(d)
|
||||
.await
|
||||
.get("/configs")
|
||||
.authorization_bearer("lvd_good")
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(echo["account_id"], account.to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_credentials_are_rejected_at_the_gateway() {
|
||||
let s = server(devices()).await;
|
||||
s.get("/me")
|
||||
.authorization_bearer("lvd_bad")
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
s.get("/me")
|
||||
.authorization_bearer("not.a.jwt")
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
let wrong_key =
|
||||
common::jwt::issue_access_token(Uuid::new_v4(), "another-secret-another-secret-12345");
|
||||
s.get("/me")
|
||||
.authorization_bearer(wrong_key)
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anonymous_requests_pass_without_identity() {
|
||||
let echo: serde_json::Value = server(devices()).await.post("/auth/login").await.json();
|
||||
assert!(echo["account_id"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spoofed_identity_header_never_reaches_the_service() {
|
||||
let echo: serde_json::Value = server(devices())
|
||||
.await
|
||||
.get("/me")
|
||||
.add_header("x-lovisual-account-id", Uuid::new_v4().to_string())
|
||||
.await
|
||||
.json();
|
||||
assert!(echo["account_id"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_credentials_on_auth_paths_are_treated_as_anonymous() {
|
||||
let s = server(devices()).await;
|
||||
let expired = common::expired_access_token(Uuid::new_v4());
|
||||
for token in [expired.as_str(), "lvd_bad", "not.a.jwt"] {
|
||||
let res = s.post("/auth/logout").authorization_bearer(token).await;
|
||||
res.assert_status_ok();
|
||||
let echo: serde_json::Value = res.json();
|
||||
assert_eq!(echo["path"], "/auth/logout", "reached upstream");
|
||||
assert!(echo["account_id"].is_null());
|
||||
assert!(echo["authorization"].is_null());
|
||||
}
|
||||
// Outside /auth/ the same token is still rejected.
|
||||
s.get("/me")
|
||||
.authorization_bearer(expired)
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreachable_device_backend_is_503() {
|
||||
let echo = common::spawn_echo().await;
|
||||
let app = gateway::build_app(&common::config(&echo, &echo), Arc::new(common::DownDevices));
|
||||
axum_test::TestServer::new(app)
|
||||
.get("/configs")
|
||||
.authorization_bearer("lvd_whatever")
|
||||
.await
|
||||
.assert_status(StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
89
backend/gateway/tests/path_guard.rs
Normal file
89
backend/gateway/tests/path_guard.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
async fn app() -> axum::Router {
|
||||
let echo = common::spawn_echo().await;
|
||||
gateway::build_app(&common::config(&echo, &echo), common::no_devices())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dot_segments_encoded_slashes_and_empty_segments_are_400() {
|
||||
let app = app().await;
|
||||
for uri in [
|
||||
"/auth/x/../login",
|
||||
"/auth/./login",
|
||||
"/auth/%2e%2e/device/token",
|
||||
"/auth/%2E%2E/login",
|
||||
"/auth/x/.%2e/login",
|
||||
"/auth/x/%2e./login",
|
||||
"/auth/%2e/login",
|
||||
"/configs%2f1",
|
||||
"/configs/1%5Cx",
|
||||
"/configs/1\\..\\2",
|
||||
"//evil.com/x",
|
||||
"/auth//login",
|
||||
] {
|
||||
let (status, _, body) = common::raw(&app, "POST", uri, "203.0.113.50").await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{uri}");
|
||||
assert!(body.contains("bad path"), "{uri}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normal_paths_are_unaffected() {
|
||||
let app = app().await;
|
||||
for uri in ["/auth/login", "/configs/2?x=../y", "/showcase", "/health"] {
|
||||
let (status, _, _) = common::raw(&app, "POST", uri, "203.0.113.51").await;
|
||||
assert_ne!(status, StatusCode::BAD_REQUEST, "{uri}");
|
||||
}
|
||||
let (status, _, _) = common::raw(&app, "GET", "/", "203.0.113.51").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn traversal_cannot_dodge_the_login_limit() {
|
||||
let app = app().await;
|
||||
for _ in 0..8 {
|
||||
let (status, _, _) = common::raw(&app, "POST", "/auth/x/../login", "203.0.113.52").await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
for _ in 0..5 {
|
||||
let (status, _, _) = common::raw(&app, "POST", "/auth/login", "203.0.113.52").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
let (status, _, _) = common::raw(&app, "POST", "/auth/login", "203.0.113.52").await;
|
||||
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn env_probes_get_the_honeypot_instead_of_400() {
|
||||
let app = app().await;
|
||||
for uri in [
|
||||
"/../../.env",
|
||||
"/%2e%2e/.env",
|
||||
"/.env",
|
||||
"/api/.env",
|
||||
"/.env.local",
|
||||
"/.env.production",
|
||||
] {
|
||||
let (status, headers, body) = common::raw(&app, "GET", uri, "203.0.113.53").await;
|
||||
assert_eq!(status, StatusCode::OK, "{uri}");
|
||||
assert_eq!(headers["content-type"], "text/plain; charset=utf-8");
|
||||
assert!(body.contains("nice_try_skiddie"), "{uri}: {body}");
|
||||
assert!(!body.contains("\"path\""), "never forwarded upstream");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coffee_is_a_teapot_for_every_method() {
|
||||
let app = app().await;
|
||||
for method in ["GET", "POST", "PUT"] {
|
||||
let (status, headers, body) = common::raw(&app, method, "/coffee", "203.0.113.54").await;
|
||||
assert_eq!(status, StatusCode::IM_A_TEAPOT, "{method}");
|
||||
assert_eq!(headers["content-type"], "text/plain; charset=utf-8");
|
||||
assert!(body.contains("Я чайник"), "{method}: {body}");
|
||||
assert!(body.contains("LoVisual-/releases"), "{method}: {body}");
|
||||
assert!(!body.contains("\"path\""), "never forwarded upstream");
|
||||
}
|
||||
}
|
||||
110
backend/gateway/tests/proxy.rs
Normal file
110
backend/gateway/tests/proxy.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
async fn server() -> axum_test::TestServer {
|
||||
let accounts = common::spawn_echo().await;
|
||||
let configs = common::spawn_echo().await;
|
||||
let app = gateway::build_app(&common::config(&accounts, &configs), common::no_devices());
|
||||
axum_test::TestServer::new(app)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_is_ok() {
|
||||
server().await.get("/health").await.assert_status_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forwards_method_path_query_and_body() {
|
||||
let res = server().await.put("/configs/2?x=1").text("payload").await;
|
||||
res.assert_status_ok();
|
||||
let echo: serde_json::Value = res.json();
|
||||
assert_eq!(echo["method"], "PUT");
|
||||
assert_eq!(echo["path"], "/configs/2");
|
||||
assert_eq!(echo["query"], "x=1");
|
||||
assert_eq!(echo["body"], "payload");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adds_internal_key_and_strips_spoofed_identity() {
|
||||
let res = server()
|
||||
.await
|
||||
.post("/auth/login")
|
||||
.add_header("x-lovisual-account-id", uuid::Uuid::new_v4().to_string())
|
||||
.add_header("x-lovisual-internal-key", "spoofed")
|
||||
.await;
|
||||
let echo: serde_json::Value = res.json();
|
||||
assert_eq!(echo["internal_key"], common::INTERNAL_KEY);
|
||||
assert!(echo["account_id"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_prefix_is_404() {
|
||||
server()
|
||||
.await
|
||||
.get("/nope")
|
||||
.await
|
||||
.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dead_upstream_is_502() {
|
||||
let app = gateway::build_app(
|
||||
&common::config("http://127.0.0.1:1", "http://127.0.0.1:1"),
|
||||
common::no_devices(),
|
||||
);
|
||||
axum_test::TestServer::new(app)
|
||||
.get("/me")
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_body_is_413() {
|
||||
let big = "x".repeat(6 * 1024 * 1024 + 1);
|
||||
server()
|
||||
.await
|
||||
.put("/configs/1")
|
||||
.text(big)
|
||||
.await
|
||||
.assert_status(StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cors_preflight_allows_only_the_site_origin() {
|
||||
let app = gateway::build_app(
|
||||
&common::config("http://127.0.0.1:1", "http://127.0.0.1:1"),
|
||||
common::no_devices(),
|
||||
);
|
||||
let s = axum_test::TestServer::new(app);
|
||||
let ok = s
|
||||
.method(axum::http::Method::OPTIONS, "/auth/login")
|
||||
.add_header("origin", "http://localhost:5173")
|
||||
.add_header("access-control-request-method", "POST")
|
||||
.await;
|
||||
assert_eq!(
|
||||
ok.header("access-control-allow-origin"),
|
||||
"http://localhost:5173"
|
||||
);
|
||||
assert_eq!(ok.header("access-control-allow-credentials"), "true");
|
||||
|
||||
let evil = s
|
||||
.method(axum::http::Method::OPTIONS, "/auth/login")
|
||||
.add_header("origin", "https://evil.example")
|
||||
.add_header("access-control-request-method", "POST")
|
||||
.await;
|
||||
assert!(evil.maybe_header("access-control-allow-origin").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cors_exposes_retry_after_so_js_can_read_it() {
|
||||
let s = server().await;
|
||||
let res = s
|
||||
.post("/auth/login")
|
||||
.add_header("origin", "http://localhost:5173")
|
||||
.await;
|
||||
assert_eq!(
|
||||
res.header("access-control-expose-headers"),
|
||||
"retry-after"
|
||||
);
|
||||
}
|
||||
78
backend/gateway/tests/rate_limit.rs
Normal file
78
backend/gateway/tests/rate_limit.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
async fn server() -> axum_test::TestServer {
|
||||
let echo = common::spawn_echo().await;
|
||||
axum_test::TestServer::new(gateway::build_app(
|
||||
&common::config(&echo, &echo),
|
||||
common::no_devices(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sixth_login_in_a_minute_from_one_ip_is_429_with_retry_after() {
|
||||
let s = server().await;
|
||||
for _ in 0..5 {
|
||||
s.post("/auth/login")
|
||||
.add_header("x-forwarded-for", "203.0.113.7")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
}
|
||||
let res = s
|
||||
.post("/auth/login")
|
||||
.add_header("x-forwarded-for", "203.0.113.7")
|
||||
.await;
|
||||
res.assert_status(StatusCode::TOO_MANY_REQUESTS);
|
||||
let retry: u64 = res.header("retry-after").to_str().unwrap().parse().unwrap();
|
||||
assert!((1..=60).contains(&retry));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn limits_are_per_ip() {
|
||||
let s = server().await;
|
||||
for _ in 0..5 {
|
||||
s.post("/auth/login")
|
||||
.add_header("x-forwarded-for", "203.0.113.8")
|
||||
.await;
|
||||
}
|
||||
s.post("/auth/login")
|
||||
.add_header("x-forwarded-for", "203.0.113.9")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rightmost_forwarded_for_entry_is_used() {
|
||||
// A client can prepend fake entries; only the one our proxy appended counts.
|
||||
let s = server().await;
|
||||
for i in 0..5 {
|
||||
s.post("/auth/login")
|
||||
.add_header("x-forwarded-for", format!("10.0.0.{i}, 203.0.113.10"))
|
||||
.await
|
||||
.assert_status_ok();
|
||||
}
|
||||
s.post("/auth/login")
|
||||
.add_header("x-forwarded-for", "1.1.1.1, 203.0.113.10")
|
||||
.await
|
||||
.assert_status(StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_credentials_count_against_the_ip_limit() {
|
||||
// Every bad device token costs accounts-service a gRPC call + DB query,
|
||||
// so the per-IP global limit must apply before identity rejects it.
|
||||
let s = server().await;
|
||||
for _ in 0..300 {
|
||||
s.get("/configs")
|
||||
.authorization_bearer("lvd_bad")
|
||||
.add_header("x-forwarded-for", "203.0.113.20")
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
s.get("/configs")
|
||||
.authorization_bearer("lvd_bad")
|
||||
.add_header("x-forwarded-for", "203.0.113.20")
|
||||
.await
|
||||
.assert_status(StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue