874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
621 lines
24 KiB
Rust
621 lines
24 KiB
Rust
//! The ingest server for opt-in data collection (docs/telemetry.md).
|
|
//!
|
|
//! - `POST /v1/batch`: a zstd batch from the launcher, checked by `horizon_telemetry::batch`.
|
|
//! - `DELETE /v1/installs/<id>`: everything an install sent goes.
|
|
//! - `GET /v1/aggregate?days=30`: the anonymous totals the website reads, cached.
|
|
//! - `GET /v1/benchmarks/summary`: players' benchmark results grouped by GPU and CPU, groups of at
|
|
//! least 3 installs only (`summary`), cached; the website's Benchmarks page reads it.
|
|
//! - `POST /v1/live`: a game's anonymous "I'm playing" ping (`live`, in memory only).
|
|
//! - `GET /v1/live`: how many play now, rounded, cached briefly.
|
|
//! - `/hub/…`: the hub's votes (`hub`), bug reports and suggestions (`intake`).
|
|
//! - `GET /healthz`.
|
|
//!
|
|
//! Abuse limits: a token bucket per IP address (kept in memory for an hour, never stored), a
|
|
//! daily item cap per install, and the batch size limits. The live count has its own, larger
|
|
//! bucket per address (several players can share one) and a cap on the sessions it holds. Raw
|
|
//! rows expire after `retention_days`; the daily totals stay. One small process and one SQLite
|
|
//! file: see `README.md` for running it.
|
|
use std::collections::HashMap;
|
|
use std::hash::{Hash, RandomState};
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use axum::Router;
|
|
use axum::extract::{ConnectInfo, Path, Query, Request, State};
|
|
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::{delete, get, post};
|
|
use horizon_telemetry::batch;
|
|
|
|
pub mod classes;
|
|
pub mod github;
|
|
pub mod hub;
|
|
pub mod intake;
|
|
pub mod live;
|
|
pub mod store;
|
|
pub mod summary;
|
|
|
|
use github::Filer;
|
|
use horizon_telemetry::live::{MAX_PING_BYTES, Ping};
|
|
use live::Live;
|
|
use store::Store;
|
|
|
|
pub struct Config {
|
|
/// Days raw rows are kept.
|
|
pub retention_days: u32,
|
|
/// Groups in the public totals need at least this many install-days.
|
|
pub min_install_days: u64,
|
|
/// Requests an IP address may make an hour (with a burst of a tenth of it).
|
|
pub requests_per_hour: f64,
|
|
/// Items one install may send a day.
|
|
pub items_per_day: u32,
|
|
/// Behind a proxy (Railway, Fly…): the client's address is the proxy's `X-Real-IP`, else the
|
|
/// last `X-Forwarded-For` entry.
|
|
pub trust_proxy: bool,
|
|
/// The website allowed to read the totals from a browser.
|
|
pub allow_origin: String,
|
|
/// Live count requests (pings and reads) an IP address may make an hour, with a burst of a
|
|
/// tenth of it. A game pings once a minute, so the default lets ten play behind one address.
|
|
pub live_per_hour: f64,
|
|
/// How long a session counts after its last ping.
|
|
pub live_window: Duration,
|
|
/// Sessions the live count holds at most; a new one past that is refused.
|
|
pub live_max_sessions: usize,
|
|
/// The hub's votes and reads of their counts an IP address may make an hour.
|
|
pub hub_per_hour: f64,
|
|
/// Bug reports and suggestions together an IP address may send an hour.
|
|
pub hub_posts_per_hour: f64,
|
|
/// Bug reports taken a day, from everyone: each one opens a GitHub issue.
|
|
pub hub_reports_per_day: u32,
|
|
/// Suggestions taken a day, from everyone.
|
|
pub hub_suggestions_per_day: u32,
|
|
/// Days reports and suggestions are kept (Kira has relayed them long before).
|
|
pub hub_keep_days: u32,
|
|
/// The bearer token `GET /hub/suggestions` wants (Kira's); unset, it answers 404.
|
|
pub hub_token: Option<String>,
|
|
/// Opens the website's bug reports as issues; unset, `POST /hub/report` answers 503.
|
|
pub github_token: Option<String>,
|
|
/// Where those issues go, `owner/name`.
|
|
pub github_repo: String,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
Self {
|
|
retention_days: 180,
|
|
min_install_days: 5,
|
|
requests_per_hour: 60.0,
|
|
items_per_day: 100,
|
|
trust_proxy: false,
|
|
allow_origin: "https://playhorizon.dev".into(),
|
|
live_per_hour: 600.0,
|
|
live_window: horizon_telemetry::live::WINDOW,
|
|
live_max_sessions: 200_000,
|
|
hub_per_hour: 600.0,
|
|
hub_posts_per_hour: 20.0,
|
|
hub_reports_per_day: 30,
|
|
hub_suggestions_per_day: 200,
|
|
hub_keep_days: 90,
|
|
hub_token: None,
|
|
github_token: None,
|
|
github_repo: "horizon-rust/horizon".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
/// The defaults, with `HORIZON_TELEMETRY_RETENTION_DAYS`, `…_MIN_INSTALL_DAYS`,
|
|
/// `…_REQUESTS_PER_HOUR`, `…_ITEMS_PER_DAY`, `…_TRUST_PROXY` (1), `…_ALLOW_ORIGIN`,
|
|
/// `…_LIVE_PER_HOUR`, `…_LIVE_WINDOW_SECONDS`, `…_LIVE_MAX_SESSIONS`, `…_HUB_PER_HOUR`,
|
|
/// `…_HUB_POSTS_PER_HOUR`, `…_HUB_REPORTS_PER_DAY`, `…_HUB_SUGGESTIONS_PER_DAY`, `…_HUB_KEEP_DAYS`,
|
|
/// `…_HUB_TOKEN`, `…_GITHUB_TOKEN` and `…_GITHUB_REPO`. Empty tokens count as unset.
|
|
pub fn from_env() -> Self {
|
|
let var = |k: &str| std::env::var(format!("HORIZON_TELEMETRY_{k}")).ok();
|
|
let secret = |k: &str| {
|
|
var(k)
|
|
.map(|v| v.trim().to_owned())
|
|
.filter(|v| !v.is_empty())
|
|
};
|
|
let d = Self::default();
|
|
Self {
|
|
retention_days: var("RETENTION_DAYS")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.retention_days),
|
|
min_install_days: var("MIN_INSTALL_DAYS")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.min_install_days),
|
|
requests_per_hour: var("REQUESTS_PER_HOUR")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.requests_per_hour),
|
|
items_per_day: var("ITEMS_PER_DAY")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.items_per_day),
|
|
trust_proxy: var("TRUST_PROXY").is_some_and(|v| v == "1"),
|
|
allow_origin: var("ALLOW_ORIGIN").unwrap_or(d.allow_origin),
|
|
live_per_hour: var("LIVE_PER_HOUR")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.live_per_hour),
|
|
live_window: var("LIVE_WINDOW_SECONDS")
|
|
.and_then(|v| v.parse().ok())
|
|
.map_or(d.live_window, Duration::from_secs),
|
|
live_max_sessions: var("LIVE_MAX_SESSIONS")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.live_max_sessions),
|
|
hub_per_hour: var("HUB_PER_HOUR")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.hub_per_hour),
|
|
hub_posts_per_hour: var("HUB_POSTS_PER_HOUR")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.hub_posts_per_hour),
|
|
hub_reports_per_day: var("HUB_REPORTS_PER_DAY")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.hub_reports_per_day),
|
|
hub_suggestions_per_day: var("HUB_SUGGESTIONS_PER_DAY")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.hub_suggestions_per_day),
|
|
hub_keep_days: var("HUB_KEEP_DAYS")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(d.hub_keep_days),
|
|
hub_token: secret("HUB_TOKEN"),
|
|
github_token: secret("GITHUB_TOKEN"),
|
|
github_repo: var("GITHUB_REPO").unwrap_or(d.github_repo),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct App {
|
|
store: Mutex<Store>,
|
|
config: Config,
|
|
/// Tokens left and when they were counted, per client address.
|
|
buckets: Mutex<Buckets>,
|
|
/// The same for the live count's requests.
|
|
live_buckets: Mutex<Buckets>,
|
|
/// Who is playing now (`live`).
|
|
live: Mutex<Live>,
|
|
/// `GET /v1/live`'s answer and when it was made.
|
|
live_cache: Mutex<Option<(Instant, String)>>,
|
|
/// `/v1/aggregate` answers by `days`, with when they were made.
|
|
cache: Mutex<HashMap<u32, (Instant, String)>>,
|
|
/// `/v1/benchmarks/summary`'s answer and when it was made.
|
|
summary_cache: Mutex<Option<(Instant, String)>>,
|
|
/// Today, in days since 1970 (a test moves it).
|
|
today: Box<dyn Fn() -> u32 + Send + Sync>,
|
|
/// The key the hub's buckets hash addresses with, drawn at start and never stored.
|
|
hub_key: RandomState,
|
|
/// The hub's votes and vote counts, per hashed address.
|
|
hub_buckets: Mutex<hub::HashedBuckets>,
|
|
/// The hub's reports and suggestions, per hashed address.
|
|
hub_post_buckets: Mutex<hub::HashedBuckets>,
|
|
/// `GET /hub/votes`'s answer and when it was made.
|
|
votes_cache: Mutex<Option<(Instant, String)>>,
|
|
/// Opens bug reports as GitHub issues; None without a token.
|
|
filer: Option<Arc<Filer>>,
|
|
}
|
|
|
|
type Buckets = HashMap<IpAddr, (f64, Instant)>;
|
|
|
|
const CACHE_FOR: Duration = Duration::from_secs(600);
|
|
|
|
fn status(code: StatusCode, message: &str) -> Response {
|
|
(code, message.to_owned()).into_response()
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(store: Store, config: Config) -> Arc<Self> {
|
|
Self::with_clock(store, config, Box::new(horizon_telemetry::today))
|
|
}
|
|
|
|
pub fn with_clock(
|
|
store: Store,
|
|
config: Config,
|
|
today: Box<dyn Fn() -> u32 + Send + Sync>,
|
|
) -> Arc<Self> {
|
|
let filer = config
|
|
.github_token
|
|
.clone()
|
|
.map(|token| Arc::from(github::github(config.github_repo.clone(), token)));
|
|
Self::with_filer(store, config, today, filer)
|
|
}
|
|
|
|
/// [`Self::with_clock`] with the hub's bug reports opened by `filer` (tests give their own).
|
|
pub fn with_filer(
|
|
store: Store,
|
|
config: Config,
|
|
today: Box<dyn Fn() -> u32 + Send + Sync>,
|
|
filer: Option<Arc<Filer>>,
|
|
) -> Arc<Self> {
|
|
let live = Live::new(config.live_window, config.live_max_sessions);
|
|
Arc::new(Self {
|
|
store: Mutex::new(store),
|
|
config,
|
|
buckets: Mutex::default(),
|
|
live_buckets: Mutex::default(),
|
|
live: Mutex::new(live),
|
|
live_cache: Mutex::default(),
|
|
cache: Mutex::default(),
|
|
summary_cache: Mutex::default(),
|
|
today,
|
|
hub_key: RandomState::new(),
|
|
hub_buckets: Mutex::default(),
|
|
hub_post_buckets: Mutex::default(),
|
|
votes_cache: Mutex::default(),
|
|
filer,
|
|
})
|
|
}
|
|
|
|
fn store(&self) -> std::sync::MutexGuard<'_, Store> {
|
|
self.store.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// Takes a token for `ip`; false when it has none left.
|
|
fn allow(&self, ip: IpAddr) -> bool {
|
|
take(&self.buckets, self.config.requests_per_hour, ip)
|
|
}
|
|
|
|
/// [`Self::allow`] for the live count's requests.
|
|
fn allow_live(&self, ip: IpAddr) -> bool {
|
|
take(&self.live_buckets, self.config.live_per_hour, ip)
|
|
}
|
|
|
|
fn live(&self) -> std::sync::MutexGuard<'_, Live> {
|
|
self.live.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// The address requests are counted under, for the rate limits only: kept in memory, never
|
|
/// stored or logged. Behind a proxy (Railway) the TCP peer is the proxy, so it is the proxy's
|
|
/// `X-Real-IP`, else the last `X-Forwarded-For` entry: the one the proxy added, which a client
|
|
/// can't choose (the first one it can). An IPv6 client counts by its /64.
|
|
fn client(&self, headers: &HeaderMap, peer: Option<SocketAddr>) -> IpAddr {
|
|
let header = |name: &str| headers.get(name)?.to_str().ok();
|
|
let forwarded = self
|
|
.config
|
|
.trust_proxy
|
|
.then(|| {
|
|
header("x-real-ip")
|
|
.and_then(|ip| ip.trim().parse().ok())
|
|
.or_else(|| {
|
|
header("x-forwarded-for")?
|
|
.rsplit(',')
|
|
.next()?
|
|
.trim()
|
|
.parse()
|
|
.ok()
|
|
})
|
|
})
|
|
.flatten();
|
|
rate_key(
|
|
forwarded
|
|
.or(peer.map(|p| p.ip()))
|
|
.unwrap_or(IpAddr::from([0, 0, 0, 0])),
|
|
)
|
|
}
|
|
|
|
/// The day an install's newest row came in, if it has any left.
|
|
pub fn newest_day(&self, install: &[u8]) -> Option<u32> {
|
|
self.store().newest_day(install).ok().flatten()
|
|
}
|
|
|
|
/// Drops the cached public answers (after a deletion or expiry).
|
|
fn forget_cached(&self) {
|
|
self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
|
*self.summary_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
}
|
|
|
|
/// Daily upkeep: totals rebuilt, expired rows removed, idle rate-limit entries and live
|
|
/// sessions forgotten, the hub's old reports and suggestions removed. Reports still waiting for
|
|
/// their issue are [`Self::file_pending`]'s.
|
|
pub fn maintain(&self) -> rusqlite::Result<usize> {
|
|
let oldest = (self.today)().saturating_sub(self.config.retention_days);
|
|
let removed = self.store().expire(oldest)?;
|
|
self.expire_intake()?;
|
|
self.forget_hub_buckets();
|
|
for buckets in [&self.buckets, &self.live_buckets] {
|
|
buckets
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.retain(|_, (_, at)| at.elapsed() < Duration::from_secs(3600));
|
|
}
|
|
self.live().forget_expired(Instant::now());
|
|
if removed > 0 {
|
|
self.forget_cached();
|
|
}
|
|
Ok(removed)
|
|
}
|
|
}
|
|
|
|
/// Takes a token for `key` from its bucket in `buckets` (`per_hour`, bursts of a tenth of it);
|
|
/// false when it has none left.
|
|
fn take<K: Hash + Eq>(buckets: &Mutex<HashMap<K, (f64, Instant)>>, per_hour: f64, key: K) -> bool {
|
|
let rate = per_hour / 3600.0;
|
|
let burst = (per_hour / 10.0).max(1.0);
|
|
let mut buckets = buckets.lock().unwrap_or_else(|e| e.into_inner());
|
|
let now = Instant::now();
|
|
let (tokens, at) = buckets.entry(key).or_insert((burst, now));
|
|
*tokens = (*tokens + now.duration_since(*at).as_secs_f64() * rate).min(burst);
|
|
*at = now;
|
|
if *tokens < 1.0 {
|
|
return false;
|
|
}
|
|
*tokens -= 1.0;
|
|
true
|
|
}
|
|
|
|
/// The address a client's requests count under: IPv4 (an IPv4-mapped IPv6 address as IPv4 too),
|
|
/// or an IPv6 address's /64, which one home or host holds whole, so addresses rotated inside it
|
|
/// don't get fresh buckets.
|
|
fn rate_key(ip: IpAddr) -> IpAddr {
|
|
match ip {
|
|
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
|
|
Some(v4) => IpAddr::V4(v4),
|
|
None => {
|
|
let s = v6.segments();
|
|
IpAddr::V6(std::net::Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0))
|
|
}
|
|
},
|
|
v4 => v4,
|
|
}
|
|
}
|
|
|
|
fn peer(req: &Request) -> Option<SocketAddr> {
|
|
req.extensions()
|
|
.get::<ConnectInfo<SocketAddr>>()
|
|
.map(|c| c.0)
|
|
}
|
|
|
|
async fn receive(State(app): State<Arc<App>>, req: Request) -> Response {
|
|
let ip = app.client(req.headers(), peer(&req));
|
|
if !app.allow(ip) {
|
|
return status(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
|
}
|
|
let zstd = req
|
|
.headers()
|
|
.get(header::CONTENT_ENCODING)
|
|
.is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"zstd"));
|
|
if !zstd {
|
|
return status(
|
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
"send zstd (Content-Encoding: zstd)",
|
|
);
|
|
}
|
|
let Ok(body) = axum::body::to_bytes(req.into_body(), batch::MAX_COMPRESSED).await else {
|
|
return status(StatusCode::PAYLOAD_TOO_LARGE, "a batch is at most 256 KiB");
|
|
};
|
|
let batch = match batch::decode(&body) {
|
|
Ok(batch) => batch,
|
|
Err(e) => return status(StatusCode::BAD_REQUEST, &e),
|
|
};
|
|
let today = (app.today)();
|
|
let install = store::install_key(&batch.install_id).unwrap_or_default();
|
|
let mut store = app.store();
|
|
let sent = store.count_on(&install, today).unwrap_or(u32::MAX);
|
|
if sent.saturating_add(batch.items.len() as u32) > app.config.items_per_day {
|
|
return status(
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"this install's daily limit is reached",
|
|
);
|
|
}
|
|
match store.insert(&batch, today) {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(e) => {
|
|
eprintln!("horizon-telemetry-server: could not store a batch: {e}");
|
|
status(StatusCode::SERVICE_UNAVAILABLE, "try again later")
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn forget(State(app): State<Arc<App>>, Path(id): Path<String>, req: Request) -> Response {
|
|
if !app.allow(app.client(req.headers(), peer(&req))) {
|
|
return status(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
|
}
|
|
let Some(install) = store::install_key(&id) else {
|
|
return status(StatusCode::BAD_REQUEST, "not an install ID");
|
|
};
|
|
match app.store().delete_install(&install) {
|
|
Ok(_) => {
|
|
app.forget_cached();
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
Err(e) => {
|
|
eprintln!("horizon-telemetry-server: could not delete an install: {e}");
|
|
status(StatusCode::SERVICE_UNAVAILABLE, "try again later")
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct Window {
|
|
days: Option<u32>,
|
|
}
|
|
|
|
async fn aggregate(State(app): State<Arc<App>>, Query(window): Query<Window>) -> Response {
|
|
let days = window.days.unwrap_or(30).clamp(1, 366);
|
|
let cached = app
|
|
.cache
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.get(&days)
|
|
.filter(|(at, _)| at.elapsed() < CACHE_FOR)
|
|
.map(|(_, json)| json.clone());
|
|
let json = match cached {
|
|
Some(json) => json,
|
|
None => {
|
|
let to = (app.today)();
|
|
let made =
|
|
app.store()
|
|
.aggregate(to + 1 - days.min(to + 1), to, app.config.min_install_days);
|
|
match made {
|
|
Ok(value) => {
|
|
let json = value.to_string();
|
|
app.cache
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.insert(days, (Instant::now(), json.clone()));
|
|
json
|
|
}
|
|
Err(e) => {
|
|
eprintln!("horizon-telemetry-server: could not build the totals: {e}");
|
|
return status(StatusCode::SERVICE_UNAVAILABLE, "try again later");
|
|
}
|
|
}
|
|
}
|
|
};
|
|
json_response(&app, json, CACHE_FOR.as_secs())
|
|
}
|
|
|
|
/// `GET /v1/benchmarks/summary`: players' benchmark groups (`summary`), made again at most every
|
|
/// ten minutes.
|
|
async fn benchmarks(State(app): State<Arc<App>>) -> Response {
|
|
let cached = app
|
|
.summary_cache
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.as_ref()
|
|
.filter(|(at, _)| at.elapsed() < CACHE_FOR)
|
|
.map(|(_, json)| json.clone());
|
|
let json = match cached {
|
|
Some(json) => json,
|
|
None => {
|
|
let oldest = (app.today)().saturating_sub(app.config.retention_days);
|
|
let uploads = match app.store().benchmark_uploads(oldest) {
|
|
Ok(uploads) => uploads,
|
|
Err(e) => {
|
|
eprintln!("horizon-telemetry-server: could not read the benchmarks: {e}");
|
|
return status(StatusCode::SERVICE_UNAVAILABLE, "try again later");
|
|
}
|
|
};
|
|
let json = summary::summary(&uploads, summary::MIN_INSTALLS, &utc_now()).to_string();
|
|
*app.summary_cache.lock().unwrap_or_else(|e| e.into_inner()) =
|
|
Some((Instant::now(), json.clone()));
|
|
json
|
|
}
|
|
};
|
|
json_response(&app, json, CACHE_FOR.as_secs())
|
|
}
|
|
|
|
/// Now as `YYYY-MM-DDTHH:MM:SSZ`.
|
|
fn utc_now() -> String {
|
|
let secs = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |d| d.as_secs());
|
|
let (days, rest) = (secs / 86_400, secs % 86_400);
|
|
// Days since 1970 to a civil date (Howard Hinnant's algorithm).
|
|
let z = days as i64 + 719_468;
|
|
let era = z.div_euclid(146_097);
|
|
let doe = z - era * 146_097;
|
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let day = doy - (153 * mp + 2) / 5 + 1;
|
|
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
let year = yoe + era * 400 + i64::from(month <= 2);
|
|
format!(
|
|
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
|
|
rest / 3600,
|
|
rest / 60 % 60,
|
|
rest % 60
|
|
)
|
|
}
|
|
|
|
/// `POST /v1/live`: one "I'm playing". 204, or 400 for a bad ping, 413 for one too large, 429
|
|
/// over the address's limit, 503 when the table is full.
|
|
async fn ping(State(app): State<Arc<App>>, req: Request) -> Response {
|
|
if !app.allow_live(app.client(req.headers(), peer(&req))) {
|
|
return status(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
|
}
|
|
let Ok(body) = axum::body::to_bytes(req.into_body(), MAX_PING_BYTES).await else {
|
|
return status(StatusCode::PAYLOAD_TOO_LARGE, "a ping is at most 512 bytes");
|
|
};
|
|
let ping: Ping = match serde_json::from_slice(&body) {
|
|
Ok(ping) => ping,
|
|
Err(e) => return status(StatusCode::BAD_REQUEST, &e.to_string()),
|
|
};
|
|
if let Err(e) = ping.check() {
|
|
return status(StatusCode::BAD_REQUEST, e);
|
|
}
|
|
match app.live().ping(&ping, Instant::now()) {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(live::Full) => status(StatusCode::SERVICE_UNAVAILABLE, "try again later"),
|
|
}
|
|
}
|
|
|
|
/// `GET /v1/live`: the rounded live count (`horizon_telemetry::live::Count`), made again at
|
|
/// most every eighth of the window.
|
|
async fn playing(State(app): State<Arc<App>>, req: Request) -> Response {
|
|
if !app.allow_live(app.client(req.headers(), peer(&req))) {
|
|
return status(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
|
}
|
|
let fresh_for = app.config.live_window / 8;
|
|
let json = {
|
|
let mut cache = app.live_cache.lock().unwrap_or_else(|e| e.into_inner());
|
|
match &*cache {
|
|
Some((at, json)) if at.elapsed() < fresh_for => json.clone(),
|
|
_ => {
|
|
let json =
|
|
serde_json::to_string(&app.live().count(Instant::now())).unwrap_or_default();
|
|
*cache = Some((Instant::now(), json.clone()));
|
|
json
|
|
}
|
|
}
|
|
};
|
|
json_response(&app, json, fresh_for.as_secs())
|
|
}
|
|
|
|
fn json_response(app: &App, json: String, max_age: u64) -> Response {
|
|
let origin =
|
|
HeaderValue::from_str(&app.config.allow_origin).unwrap_or(HeaderValue::from_static("*"));
|
|
let cache = HeaderValue::from_str(&format!("public, max-age={max_age}"))
|
|
.unwrap_or(HeaderValue::from_static("no-cache"));
|
|
(
|
|
[
|
|
(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("application/json"),
|
|
),
|
|
(header::CACHE_CONTROL, cache),
|
|
(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin),
|
|
],
|
|
json,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
pub fn router(app: Arc<App>) -> Router {
|
|
Router::new()
|
|
.route("/v1/batch", post(receive))
|
|
.route("/v1/live", post(ping).get(playing))
|
|
.route("/v1/installs/{id}", delete(forget))
|
|
.route("/v1/aggregate", get(aggregate))
|
|
.route("/v1/benchmarks/summary", get(benchmarks))
|
|
.route("/hub/vote", post(hub::vote).options(hub::preflight))
|
|
.route("/hub/votes", get(hub::votes))
|
|
.route("/hub/report", post(intake::report).options(hub::preflight))
|
|
.route(
|
|
"/hub/suggest",
|
|
post(intake::suggest).options(hub::preflight),
|
|
)
|
|
.route("/hub/suggestions", get(intake::suggestions))
|
|
.route("/healthz", get(|| async { "ok" }))
|
|
.with_state(app)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn ipv6_rates_count_by_64() {
|
|
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
|
|
// Rotated inside a /64: one key. Another /64: another key.
|
|
assert_eq!(rate_key(ip("2001:db8::1")), rate_key(ip("2001:db8::99")));
|
|
assert_ne!(rate_key(ip("2001:db8::1")), rate_key(ip("2001:db8:1::1")));
|
|
// IPv4-mapped counts as the IPv4 it is.
|
|
assert_eq!(
|
|
rate_key(ip("::ffff:203.0.113.7")),
|
|
IpAddr::from([203, 0, 113, 7])
|
|
);
|
|
// Plain IPv4 unchanged.
|
|
assert_eq!(rate_key(ip("203.0.113.8")), IpAddr::from([203, 0, 113, 8]));
|
|
}
|
|
}
|